자원을 직접 명시하지 말고 의존 객체 주입을 사용하라

자원을 직접 명시하지 말고 의존 객체 주입을 사용하라

이번 아이템의 핵심은 하나다. 클래스가 사용하는 자원을 클래스 스스로 만들거나 고정하지 말고, 외부에서 주입받아라.

앞선 아이템 3과 4에서 싱글턴과 유틸리티 클래스를 다뤘다. 이 두 가지 구조는 편리하지만, 남용되면 클래스가 의존하는 자원을 내부에 고정시켜버린다는 공통된 문제를 만든다. 아이템 5는 바로 그 문제를 정면으로 다룬다.

클래스 내부에서 자원을 직접 명시하면 무슨 일이 생기는가. 테스트가 어려워지고, 동작이 환경에 따라 달라지며, 구현을 바꾸기 위해 코드를 수정해야 한다. 이 세 가지는 모두 유지보수성이 나빠지는 신호다.

이 글은 의존 객체 주입이 왜 필요한지, 어떤 방식으로 구현하는지, 팩터리 메서드 패턴과 Supplier<T>가 어떻게 연결되는지, 그리고 Spring IoC 컨테이너가 이 원칙을 어떻게 체계적으로 실현하는지를 단계적으로 짚는다.


문제의 시작: 클래스 내부에서 자원을 고정하는 코드

아이템 5에서 Effective Java가 제시하는 예시는 맞춤법 검사기(SpellChecker)다. 이 클래스는 사전(Dictionary)을 사용한다. 아래 두 가지 잘못된 구현을 보자.

잘못된 구현 1: 정적 유틸리티 클래스로 사전을 고정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 안티패턴: 유연하지 않고 테스트하기 어렵다
public class SpellChecker {
    private static final Lexicon dictionary = new KoreanDictionary();

    private SpellChecker() {} // 인스턴스화 방지

    public static boolean isValid(String word) {
        return dictionary.contains(word);
    }

    public static List<String> suggestions(String typo) {
        return dictionary.suggest(typo);
    }
}

KoreanDictionary가 코드 안에 박혀 있다. 영어 사전이 필요하면? 테스트용 사전이 필요하면? 코드를 직접 고쳐야 한다.

잘못된 구현 2: 싱글턴으로 사전을 고정

1
2
3
4
5
6
7
8
9
10
11
12
// 안티패턴: 마찬가지로 유연하지 않다
public class SpellChecker {
    private final Lexicon dictionary = new KoreanDictionary();

    public static final SpellChecker INSTANCE = new SpellChecker();

    private SpellChecker() {}

    public boolean isValid(String word) {
        return dictionary.contains(word);
    }
}

싱글턴이라도 결국 KoreanDictionary를 직접 명시하고 있으므로 문제는 같다.

두 구현의 공통된 결함

이 두 방식이 갖는 문제는 클래스가 자원을 스스로 결정한다는 점이다. 그 결과 다음 세 가지가 불가능해진다.

1. 테스트 격리: 실제 사전 대신 테스트용 더미 사전을 넣을 수 없다. 테스트가 실제 사전 파일에 의존하게 된다.

2. 다중 자원 지원: 한국어 사전, 영어 사전, 의학 용어 사전처럼 사용 목적에 따라 다른 자원이 필요할 때 대응할 수 없다.

3. 개방-폐쇄 원칙(OCP) 준수: 새로운 사전을 추가하려면 SpellChecker 코드 자체를 수정해야 한다. 확장을 위해 수정이 필요한 구조다.


1. 핵심 정리: 의존 객체 주입이란 무엇인가

의존 객체 주입(Dependency Injection, DI)의 핵심은 단순하다.

클래스가 필요한 자원을 스스로 생성하지 말고, 인스턴스를 생성할 때 외부에서 전달받아라.

Effective Java의 해법은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 의존 객체 주입 — 유연하고 테스트하기 쉽다
public class SpellChecker {
    private final Lexicon dictionary;

    // 사전을 외부에서 주입받는다
    public SpellChecker(Lexicon dictionary) {
        this.dictionary = Objects.requireNonNull(dictionary);
    }

    public boolean isValid(String word) {
        return dictionary.contains(word);
    }

    public List<String> suggestions(String typo) {
        return dictionary.suggest(typo);
    }
}

이제 SpellCheckerLexicon이라는 인터페이스에만 의존하고, 구체적인 구현체는 외부에서 결정된다.

1
2
3
4
5
6
7
8
// 한국어 맞춤법 검사기
SpellChecker koreanChecker = new SpellChecker(new KoreanDictionary());

// 영어 맞춤법 검사기
SpellChecker englishChecker = new SpellChecker(new EnglishDictionary());

// 테스트용 맞춤법 검사기
SpellChecker testChecker = new SpellChecker(new MockDictionary());

코드를 한 줄도 수정하지 않고, 주입하는 객체만 바꿔서 완전히 다른 동작을 만들 수 있다.

Objects.requireNonNull의 역할

1
this.dictionary = Objects.requireNonNull(dictionary);

이 한 줄이 하는 일은 두 가지다.

첫 번째, null이 들어오면 즉시 NullPointerException을 던진다. 문제를 발생 시점(생성자 호출)에 발견하도록 한다. null을 필드에 저장했다가 나중에 메서드를 호출할 때 터지면, 스택 트레이스가 진짜 원인에서 멀어진다.

두 번째, final 필드와 결합하면 한 번 주입된 자원이 이후에 교체되지 않음을 보장한다. 불변(immutable) 설계의 기초가 된다.


2. 의존 객체 주입의 세 가지 형태

의존 객체 주입은 “생성자에서 받는다”만 있는 것이 아니다. 세 가지 형태가 있고, 각각 적합한 상황이 다르다.

형태 1: 생성자 주입 (Constructor Injection) — 권장

1
2
3
4
5
6
7
public class SpellChecker {
    private final Lexicon dictionary;

    public SpellChecker(Lexicon dictionary) {
        this.dictionary = Objects.requireNonNull(dictionary);
    }
}

Effective Java와 Spring 모두 권장하는 방식이다.

장점은 다음과 같다. 의존성이 final로 선언될 수 있어 불변을 보장한다. 객체 생성 시점에 의존성이 완전히 설정되므로, 부분적으로 초기화된 객체가 존재하지 않는다. 모든 의존성이 생성자 시그니처에 드러나 있어 의존성이 명시적이다. 순환 의존을 컴파일 타임 또는 컨테이너 초기화 시점에 감지할 수 있다.

형태 2: 정적 팩터리 주입 (Static Factory Method Injection)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SpellChecker {
    private final Lexicon dictionary;

    private SpellChecker(Lexicon dictionary) {
        this.dictionary = Objects.requireNonNull(dictionary);
    }

    public static SpellChecker of(Lexicon dictionary) {
        return new SpellChecker(dictionary);
    }
}

// 사용
SpellChecker checker = SpellChecker.of(new KoreanDictionary());

생성자 주입과 본질적으로 같지만, 팩터리 메서드를 통해 생성 과정에 추가 로직(캐싱, 검증 등)을 끼워 넣을 수 있다.

형태 3: 세터 주입 (Setter Injection) — 제한적으로 사용

1
2
3
4
5
6
7
8
9
10
11
12
public class SpellChecker {
    private Lexicon dictionary;

    public void setDictionary(Lexicon dictionary) {
        this.dictionary = Objects.requireNonNull(dictionary);
    }

    public boolean isValid(String word) {
        if (dictionary == null) throw new IllegalStateException("사전이 설정되지 않았습니다.");
        return dictionary.contains(word);
    }
}

선택적 의존성이거나, 런타임에 자원을 교체해야 하는 경우에 쓴다. 그러나 객체가 완전히 초기화되지 않은 상태로 존재할 수 있으므로 주의가 필요하다. final을 쓸 수 없고, 스레드 안전성도 따로 챙겨야 한다. Spring에서는 가급적 생성자 주입을 권장하고, 세터 주입은 선택적 의존성에만 사용하도록 가이드한다.


3. 이 패턴이 적합한 클래스의 특징

Effective Java는 이 패턴이 언제 적합한지를 명확히 이야기한다. 핵심은 다음이다.

클래스가 내부적으로 하나 이상의 자원에 의존하고, 그 자원이 클래스 동작에 영향을 준다면 의존 객체 주입을 사용하라.

반대로, 다음 조건이 모두 충족되면 싱글턴이나 정적 유틸리티도 괜찮다.

  • 사용하는 자원이 단 하나이고, 변경될 일이 없다
  • 자원이 코드 외부 상태(파일, DB, 네트워크)에 의존하지 않는다
  • 테스트에서 그 자원을 교체할 필요가 없다

이 세 조건이 모두 성립하는 경우는 생각보다 드물다. 현실의 코드에서 자원이 변하지 않는다는 보장은 쉽게 무너진다.


4. 완벽 공략: 불변성과 의존 객체 주입

의존 객체 주입과 불변(immutability)은 자연스럽게 결합된다. Effective Java는 이 패턴이 “불변(아이템 17)”을 보장하고, “여러 클라이언트가 의존 객체를 안심하고 공유할 수 있다”고 강조한다.

1
2
3
4
5
6
7
public class SpellChecker {
    private final Lexicon dictionary;  // final — 불변

    public SpellChecker(Lexicon dictionary) {
        this.dictionary = Objects.requireNonNull(dictionary);
    }
}

final 필드에 주입받은 자원을 저장하면 두 가지가 보장된다.

첫 번째, SpellChecker 인스턴스는 생성 이후 사전을 교체할 수 없다. 인스턴스의 동작이 예측 가능해진다.

두 번째, 같은 Lexicon 인스턴스를 여러 SpellChecker가 공유해도 안전하다. 누구도 내부 자원을 바꾸지 못하므로 스레드 간 공유도 안전하다.

1
2
3
4
5
Lexicon shared = new KoreanDictionary();

SpellChecker checker1 = new SpellChecker(shared);
SpellChecker checker2 = new SpellChecker(shared);
// 두 체커가 같은 사전을 공유 — 안전하다 (불변 자원이라면)

반대로, 세터 주입은 이 보장을 잃는다. 자원이 언제 바뀔지 알 수 없어서 스레드 안전성 보장이 어렵다.


5. 팩터리 메서드 패턴과 Supplier<T>

Effective Java가 특별히 주목하는 변형이 있다. 자원 인스턴스를 직접 주입하는 것이 아니라, 자원을 만들어주는 팩터리를 주입하는 방법이다.

왜 팩터리를 주입하는가

때로는 필요할 때마다 새로운 자원 인스턴스를 만들어야 한다. 예를 들어 요청마다 다른 사전 인스턴스가 필요하거나, 자원 생성 자체가 지연되어야 하는 경우다. 이럴 때 완성된 인스턴스를 주입하는 것이 아니라, 인스턴스를 만드는 방법(팩터리)을 주입한다.

Java 8의 Supplier<T>가 이 역할에 딱 맞는다.

Supplier<T>를 팩터리로 주입하기

1
2
3
4
5
6
7
8
9
10
11
12
public class SpellChecker {
    private final Lexicon dictionary;

    // 완성된 인스턴스가 아닌 팩터리를 받는다
    public SpellChecker(Supplier<? extends Lexicon> dictionaryFactory) {
        this.dictionary = Objects.requireNonNull(dictionaryFactory.get());
    }

    public boolean isValid(String word) {
        return dictionary.contains(word);
    }
}

Supplier<? extends Lexicon>은 “호출하면 Lexicon의 하위 타입을 반환하는 무언가”를 의미한다. 호출부에서는 메서드 참조나 람다로 팩터리를 전달할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 메서드 참조로 팩터리 전달
SpellChecker checker1 = new SpellChecker(KoreanDictionary::new);

// 람다로 팩터리 전달
SpellChecker checker2 = new SpellChecker(() -> new EnglishDictionary("basic"));

// 조건에 따라 다른 구현체 생성
SpellChecker checker3 = new SpellChecker(() -> {
    if (isKoreanMode()) return new KoreanDictionary();
    else return new EnglishDictionary("advanced");
});

// 테스트에서는 목 팩터리 전달
SpellChecker testChecker = new SpellChecker(MockDictionary::new);

와일드카드 bounded type의 중요성

Supplier<Lexicon>이 아니라 Supplier<? extends Lexicon>으로 선언하는 이유가 있다.

Supplier<Lexicon>은 정확히 Lexicon 타입을 반환하는 Supplier만 받는다. 그러나 KoreanDictionary::newSupplier<KoreanDictionary>이고, 제네릭의 불공변성(invariance) 때문에 Supplier<Lexicon>으로 취급되지 않는다.

1
2
3
4
5
6
7
Supplier<KoreanDictionary> factory = KoreanDictionary::new;

// 오류 발생: Supplier<KoreanDictionary>는 Supplier<Lexicon>의 하위 타입이 아니다
public SpellChecker(Supplier<Lexicon> f) {}  // KoreanDictionary::new 전달 불가

// 올바른 선언: Lexicon의 하위 타입이면 무엇이든 받는다
public SpellChecker(Supplier<? extends Lexicon> f) {}  // KoreanDictionary::new 전달 가능

? extends Lexicon이라는 상한 경계 와일드카드(upper bounded wildcard)를 쓰면, Lexicon의 모든 하위 타입 팩터리를 받을 수 있다.

완성된 패턴: 팩터리를 주입받아 필요할 때 자원을 생성

팩터리 주입의 진가는 자원 생성 시점을 제어할 수 있다는 점이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class SpellCheckService {
    private final Supplier<? extends Lexicon> dictionaryFactory;

    public SpellCheckService(Supplier<? extends Lexicon> dictionaryFactory) {
        this.dictionaryFactory = Objects.requireNonNull(dictionaryFactory);
    }

    public boolean check(String word) {
        // 자원을 미리 만들어두지 않고, 필요한 시점에 생성한다
        Lexicon dictionary = dictionaryFactory.get();
        return dictionary.contains(word);
    }
}

이 구조는 다음 세 가지를 동시에 얻는다.

지연 초기화(lazy initialization): check()가 호출될 때까지 사전을 생성하지 않는다.

유연성: 팩터리만 바꾸면 완전히 다른 구현체가 쓰인다. SpellCheckService 코드는 손대지 않아도 된다.

테스트 용이성: 테스트에서 MockDictionary::new를 전달하면 실제 사전 파일 없이 테스트할 수 있다.

Supplier<T>와 GoF 팩터리 메서드 패턴의 관계

Gang of Four(GoF)의 팩터리 메서드 패턴은 “객체를 생성하는 인터페이스를 정의하되, 어떤 클래스를 생성할지는 하위 클래스가 결정하도록 한다”는 패턴이다. Java 8 이전에는 이를 위해 별도의 인터페이스나 추상 클래스를 만들어야 했다.

1
2
3
4
5
6
7
8
9
10
11
// Java 8 이전 — 팩터리를 표현하기 위한 별도 인터페이스 필요
public interface LexiconFactory {
    Lexicon create();
}

public class KoreanLexiconFactory implements LexiconFactory {
    @Override
    public Lexicon create() {
        return new KoreanDictionary();
    }
}

Java 8 이후에는 Supplier<T>가 이 역할을 표준 라이브러리 수준에서 제공한다.

1
2
3
// Java 8 이후 — Supplier<T>가 팩터리 역할
Supplier<Lexicon> factory = KoreanDictionary::new;
Lexicon dictionary = factory.get();  // LexiconFactory.create()와 동일

Supplier<T>는 GoF 팩터리 메서드 패턴을 함수형 인터페이스 하나로 추상화한 셈이다. 별도의 인터페이스를 정의하지 않아도 되고, 람다나 메서드 참조로 즉시 구현할 수 있다.

팩터리 주입이 적합한 상황

팩터리(Supplier<T>) 주입은 다음 상황에서 직접 인스턴스 주입보다 유리하다.

  • 자원 생성 비용이 크고 항상 필요하지는 않을 때: 실제 필요한 순간에만 만든다
  • 요청마다 새로운 인스턴스가 필요할 때: 팩터리를 매번 호출하면 된다
  • 생성 조건이 런타임에 결정될 때: 람다 안에서 조건 분기를 할 수 있다
  • 자원 생성 로직 자체를 테스트에서 제어해야 할 때: 목 팩터리를 주입한다

6. Spring IoC와 의존 객체 주입

아이템 5의 원칙은 Spring Framework의 핵심 철학과 정확히 일치한다. Spring의 IoC(Inversion of Control) 컨테이너는 의존 객체 주입을 프레임워크 수준에서 체계화한 것이다.

IoC란 무엇인가

IoC(제어의 역전)는 이름이 추상적으로 들리지만 개념은 단순하다.

전통적인 방식: 객체가 자신이 필요한 의존 객체를 스스로 만든다. 객체가 의존성 생성을 “제어”한다.

1
2
3
4
5
6
7
8
// 전통적인 방식 — 객체가 의존성을 직접 생성
public class OrderService {
    private final OrderRepository repository;

    public OrderService() {
        this.repository = new JdbcOrderRepository();  // 직접 만든다
    }
}

IoC 방식: 객체가 필요한 의존 객체를 외부(컨테이너)로부터 받는다. 의존성 생성의 “제어권”이 컨테이너로 넘어간다.

1
2
3
4
5
6
7
8
// IoC 방식 — 컨테이너가 의존성을 주입한다
public class OrderService {
    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;  // 외부에서 받는다
    }
}

Spring IoC 컨테이너(ApplicationContext)는 다음 역할을 한다.

  • 애플리케이션에 필요한 객체(Bean)를 생성하고 관리한다
  • 각 객체가 필요로 하는 의존성을 파악하고 주입한다
  • 객체의 생명주기(생성, 초기화, 소멸)를 관리한다

Spring에서의 의존 객체 주입: @Autowired와 생성자 주입

Spring이 의존성을 주입하는 방법은 세 가지다.

생성자 주입 (Spring 권장)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class SpellCheckService {
    private final Lexicon dictionary;

    // Spring 4.3 이후, 생성자가 하나면 @Autowired 생략 가능
    @Autowired
    public SpellCheckService(Lexicon dictionary) {
        this.dictionary = dictionary;
    }

    public boolean isValid(String word) {
        return dictionary.contains(word);
    }
}

@Component
public class KoreanDictionary implements Lexicon {
    @Override
    public boolean contains(String word) { ... }

    @Override
    public List<String> suggest(String typo) { ... }
}

Spring 컨테이너는 SpellCheckService를 생성할 때 Lexicon 타입의 Bean을 찾아 생성자에 자동으로 주입한다. 개발자는 new SpellCheckService(new KoreanDictionary())를 직접 쓸 필요가 없다.

필드 주입 (편리하지만 권장하지 않음)

1
2
3
4
5
@Service
public class SpellCheckService {
    @Autowired
    private Lexicon dictionary;  // 필드에 직접 주입
}

코드가 짧아서 자주 쓰이지만, 문제가 있다. final을 쓸 수 없어 불변을 보장할 수 없다. 생성자 없이 객체가 불완전한 상태로 존재할 수 있다. Spring 컨테이너 없이는 의존성을 주입할 방법이 없어 순수 단위 테스트가 어렵다.

세터 주입 (선택적 의존성에 한정)

1
2
3
4
5
6
7
8
9
@Service
public class SpellCheckService {
    private Lexicon dictionary;

    @Autowired(required = false)  // 선택적 의존성
    public void setDictionary(Lexicon dictionary) {
        this.dictionary = dictionary;
    }
}

required = false와 함께 선택적 의존성을 표현할 때만 사용하는 것이 일반적이다.

@Configuration과 @Bean: 명시적 팩터리

Spring의 @Configuration 클래스는 아이템 5의 “팩터리 주입” 개념과 정확히 대응된다. 개발자가 직접 Bean 생성 방식을 팩터리 메서드로 정의한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class AppConfig {

    @Bean
    public Lexicon koreanDictionary() {
        return new KoreanDictionary();  // Supplier<Lexicon>의 역할
    }

    @Bean
    public SpellCheckService spellCheckService(Lexicon dictionary) {
        return new SpellCheckService(dictionary);  // 생성자 주입
    }
}

@Bean 메서드는 사실상 Supplier<T>와 같다. “호출하면 해당 타입의 인스턴스를 반환한다”는 역할이 동일하다. Spring 컨테이너가 필요할 때 이 메서드를 호출해서 Bean을 생성하고 관리한다.

Bean의 스코프: 싱글턴과 프로토타입

아이템 5와 Spring이 만나는 또 다른 지점이 스코프(Scope)다.

1
2
3
4
5
6
7
8
9
10
11
@Bean
@Scope("singleton")  // 기본값 — 컨테이너당 하나의 인스턴스
public Lexicon dictionary() {
    return new KoreanDictionary();
}

@Bean
@Scope("prototype")  // 요청마다 새 인스턴스 생성
public SpellChecker spellChecker(Lexicon dictionary) {
    return new SpellChecker(dictionary);
}

singleton 스코프는 아이템 3의 싱글턴 개념을 컨테이너가 대신 관리하는 것이다. 개발자가 private 생성자나 INSTANCE 필드를 직접 만들지 않아도 된다. Spring이 알아서 하나만 만들고 재사용한다.

prototype 스코프는 아이템 5의 팩터리 주입과 대응된다. Supplier<T>처럼 Bean을 요청할 때마다 새로운 인스턴스를 만들어 준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Supplier를 통한 prototype Bean 사용
@Service
public class OrderProcessor {
    private final ApplicationContext context;

    public OrderProcessor(ApplicationContext context) {
        this.context = context;
    }

    public void process(Order order) {
        // 요청마다 새 SpellChecker 인스턴스 생성 (prototype scope)
        SpellChecker checker = context.getBean(SpellChecker.class);
        // ...
    }
}

혹은 Spring이 제공하는 ObjectProvider<T> 또는 Supplier<T>를 직접 주입받는 방식으로 더 우아하게 처리할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class OrderProcessor {
    private final ObjectProvider<SpellChecker> checkerProvider;

    public OrderProcessor(ObjectProvider<SpellChecker> checkerProvider) {
        this.checkerProvider = checkerProvider;
    }

    public void process(Order order) {
        SpellChecker checker = checkerProvider.getObject();  // 매번 새 인스턴스
        // ...
    }
}

ObjectProvider<T>Supplier<T>와 매우 유사하지만 Spring의 Bean 컨테이너와 통합되어 있고, 예외 처리 및 선택적 의존성 처리 등의 추가 기능을 제공한다.

Spring IoC가 해결하는 실무 문제들

Spring의 DI 컨테이너를 쓰면 아이템 5가 제시하는 원칙을 코드 전반에 일관되게 적용하기가 훨씬 쉬워진다. 구체적으로 어떤 문제를 해결하는지 살펴보자.

문제 1: 의존성 그래프 수동 조립의 복잡함

실제 애플리케이션은 의존성이 수십에서 수백 개가 넘는다. OrderServiceOrderRepository, PaymentGateway, NotificationService, EventPublisher를 필요로 하고, 각각이 또 다른 의존성을 가진다.

1
2
3
4
5
6
// 수동 조립 — 의존성이 늘어날수록 지옥이 된다
OrderRepository repo = new JdbcOrderRepository(dataSource);
PaymentGateway payment = new StripePaymentGateway(apiKey);
NotificationService notif = new EmailNotificationService(smtpConfig);
EventPublisher events = new KafkaEventPublisher(kafkaConfig);
OrderService service = new OrderService(repo, payment, notif, events);

Spring 컨테이너는 이 조립을 자동으로 처리한다. 각 클래스가 필요한 것을 생성자에 선언하면, 컨테이너가 의존성 그래프를 분석하고 올바른 순서로 생성 및 주입을 처리한다.

문제 2: 환경에 따른 구현체 교체

개발 환경에서는 인메모리 구현체, 운영 환경에서는 실제 DB 구현체, 테스트에서는 목(mock) 구현체가 필요할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 프로파일로 환경별 Bean 분리
@Configuration
@Profile("test")
public class TestConfig {
    @Bean
    public Lexicon dictionary() {
        return new InMemoryDictionary();  // 테스트용
    }
}

@Configuration
@Profile("prod")
public class ProdConfig {
    @Bean
    public Lexicon dictionary() {
        return new DatabaseDictionary(dataSource);  // 운영용
    }
}

SpellCheckService 코드는 전혀 바뀌지 않는다. 어떤 프로파일이 활성화되느냐에 따라 다른 Lexicon이 주입된다.

문제 3: 순환 의존 감지

AB에 의존하고, B가 다시 A에 의존하는 순환 의존은 설계 문제의 신호다. 생성자 주입을 쓰면 Spring이 시작 시점에 이를 감지하고 예외를 던진다.

1
2
The dependencies of some of the beans in the application context
form a cycle: A → B → A

세터 주입이나 필드 주입에서는 순환 의존이 시작 시점에 감지되지 않고, 런타임에 문제가 나타나는 경우도 있다. 생성자 주입이 순환 의존을 가장 빠르게 드러낸다는 점도 Spring이 생성자 주입을 권장하는 이유 중 하나다.


7. 의존 객체 주입의 단점과 현실적인 균형

Effective Java는 의존 객체 주입의 단점도 언급한다.

의존성 주입이 유연성과 테스트 용이성을 크게 향상시키지만, 의존성이 수천 개에 이르는 큰 프로젝트에서는 코드를 어지럽힐 수 있다.

의존 객체 주입을 직접 수동으로 관리하면 다음 문제가 생긴다.

1
2
3
4
5
6
7
8
// 의존성이 많아지면 수동 조립이 복잡해진다
UserService userService = new UserService(
    new JdbcUserRepository(dataSource),
    new BcryptPasswordEncoder(),
    new EmailNotificationService(new SmtpConfig("smtp.example.com", 587)),
    new JwtTokenProvider(secretKey, expirationTime),
    new AuditLogger(new FileAppender("/var/log/audit.log"))
);

이 복잡성을 해결하는 것이 의존 객체 주입 프레임워크다. Spring의 IoC 컨테이너, Google의 Guice, 그리고 Jakarta EE의 CDI(Contexts and Dependency Injection)가 대표적이다. 이들은 의존성 주입의 원칙은 그대로 따르되, 조립 과정의 복잡성을 자동화해준다.

Effective Java는 이렇게 결론 짓는다.

Dagger, Guice, Spring 같은 의존 객체 주입 프레임워크를 사용하면 이 단점을 해소할 수 있다.


8. 싱글턴/정적 유틸리티와 의존 객체 주입의 관계 재정리

아이템 3, 4, 5를 함께 보면 설계 원칙의 흐름이 보인다.

상황 권장 방식
인스턴스가 하나만 존재해야 하고, 외부 자원에 의존하지 않는다 싱글턴 (아이템 3)
인스턴스가 필요 없고, 모든 기능이 정적이다 정적 유틸리티 + private 생성자 (아이템 4)
클래스가 외부 자원에 의존하고, 그 자원이 동작에 영향을 준다 의존 객체 주입 (아이템 5)

세 아이템의 공통 철학은 하나다. 설계 의도를 코드 구조로 명확히 표현하라. 싱글턴은 “하나만 있어야 한다”는 의도를, 유틸리티 클래스는 “인스턴스가 없어야 한다”는 의도를, 의존 객체 주입은 “자원을 고정하지 않는다”는 의도를 각각 코드로 표현한다.


9. 실무 선택 기준 정리

의존 객체 주입을 써야 하는 신호

클래스 코드를 보고 다음 중 하나라도 해당하면 의존 객체 주입을 고려해야 한다.

  • 클래스 내부에 new ConcreteClass()가 있다 (자원을 직접 생성)
  • 코드 안에 특정 구현 클래스 이름이 박혀 있다
  • 테스트를 작성하려면 실제 DB, 파일, 네트워크가 필요하다
  • 환경(개발/테스트/운영)에 따라 다른 구현이 필요하다
  • 구현을 바꾸려면 클래스 내부 코드를 수정해야 한다

생성자 주입 vs 팩터리 주입 선택 기준

1
2
3
4
5
6
7
자원이 몇 개 필요한가?
│
├── 인스턴스 하나만 필요하다
│    └── 생성자에 직접 인스턴스를 주입한다 (기본)
│
└── 매번 새 인스턴스가 필요하거나 생성 시점을 제어해야 한다
     └── Supplier<? extends T> 또는 팩터리를 주입한다

Spring을 사용하는 경우 원칙

  • 생성자 주입을 기본으로 사용한다 (final 필드 + 불변 보장)
  • 필드 주입(@Autowired on field)은 피한다 (테스트 어려움, 불변 불가)
  • 세터 주입은 선택적 의존성(required = false)에만 사용한다
  • @Configuration + @Bean으로 복잡한 생성 로직을 명시적으로 표현한다
  • 스코프를 명확히 결정한다 (singleton / prototype / request / session)

체크리스트

의존 객체 주입을 적용했다면 다음을 확인한다.

1
2
3
4
5
6
7
✅ 클래스 내부에 new ConcreteClass()가 없는가?
✅ 의존성이 생성자 시그니처에 명시적으로 드러나는가?
✅ 의존 필드가 final로 선언되어 불변을 보장하는가?
✅ Objects.requireNonNull로 null 주입을 방지하는가?
✅ 인터페이스 타입으로 선언되어 구현체 교체가 가능한가?
✅ 테스트에서 목(mock) 객체를 주입할 수 있는가?
✅ 팩터리가 필요한 경우 Supplier<? extends T>를 사용하는가?

결론

아이템 5의 메시지는 명확하다.

“클래스가 하나 이상의 자원에 의존하고, 그 자원이 동작에 영향을 준다면, 직접 명시하지 말고 주입받아라.”

자원을 클래스 내부에서 직접 생성하거나 고정하는 순간, 테스트가 어려워지고, 구현 교체가 어려워지고, 코드가 환경에 종속된다. 이 세 가지는 유지보수 비용이 커지는 대표적인 신호다.

의존 객체 주입은 이 문제를 “자원의 생성과 사용을 분리”함으로써 해결한다. 클래스는 자원을 어떻게 쓰는지만 알고, 어떤 자원을 쓰는지는 외부에서 결정한다. 이것이 개방-폐쇄 원칙(OCP)이 코드 설계에서 구현되는 방식이기도 하다.

Supplier<T>를 활용한 팩터리 주입은 이 원칙의 자연스러운 확장이다. 자원 인스턴스 대신 “만드는 방법”을 주입함으로써 생성 시점과 전략까지 외부에서 제어할 수 있게 된다.

Spring IoC 컨테이너는 이 모든 원칙을 프레임워크 수준에서 체계화한 것이다. 생성자 주입, Bean 스코프 관리, 프로파일 기반 구현체 교체가 모두 아이템 5가 말하는 “자원을 외부에서 결정하라”는 원칙 위에 서 있다.

아이템 3(싱글턴), 아이템 4(유틸리티 클래스), 아이템 5(의존 객체 주입)는 각각 다른 상황에서의 해법이지만, 공통된 철학에서 출발한다. 설계 의도를 코드 구조로 강제하라. 이 세 아이템을 이해했다면, 클래스를 만들 때마다 “이 클래스의 의존성과 생명주기를 어떻게 표현할 것인가”를 자연스럽게 질문하게 된다.


다음 글에서는 불필요한 객체 생성을 피하라 (아이템 6) 를 정리한다.