한 클래스가 쓰는 다른 객체를 직접 만들지 않고 밖에서 받아오는 것. 제어의 역전이 “내가 내 부품을 정하지 않는다”는 원칙이라면, 의존성 주입은 그 원칙을 실제로 구현하는 방법이다. 원칙과 구현체의 관계다.

// 직접 만든다. A가 B를 결정한다
class A {
    private B b;
    A() { this.b = new B(); }
}
 
// 밖에서 받는다. A는 B가 뭔지 모른다
class A {
    private B b;
    A(B b) { this.b = b; }
}

두 코드의 차이는 한 줄이지만 결과는 다르다. 위쪽은 B를 바꾸려면 A를 고쳐야 하고, 아래쪽은 A를 건드리지 않고 넣어주는 쪽만 바꾸면 된다.

생성자 주입

필요한 의존성을 전부 받는 생성자를 만들고 그 생성자로 넣는다. 객체가 만들어지는 순간 이미 완전한 상태가 된다.

Setter 주입

의존성마다 setter를 두고 밖에서 호출한다. 호출을 빠뜨려도 컴파일이 통과하므로 비어 있는 객체가 남을 수 있다. 런타임에 의존성을 다시 바꾸거나 선택적으로 주입할 때 쓴다.

인터페이스 주입

주입 메서드를 담은 인터페이스를 만들고 그것을 구현하게 한다. 호출은 여전히 밖에서 해야 하지만, 오버라이딩으로 구현을 강제할 수 있다는 점이 setter 주입과 다르다.

생성자 주입이 권장되는 까닭

스프링이 권장하는 것도, 실제로 문제를 덜 만드는 것도 생성자 주입이다.

의존 관계를 불변으로 만들 수 있다. 주입이 생성 시점 한 번만 일어나므로 필드를 final로 선언할 수 있고, 만들어진 뒤에 누가 바꿔치기할 수 없다.

NullPointerException도 막는다. 필드 주입이나 setter 주입은 기본 생성자로 객체를 만들 수 있어서 의존성이 비어 있는 객체가 존재할 수 있다. 스프링을 거치지 않고 new로 만드는 테스트 코드에서 특히 잘 터진다. 생성자 주입은 만들 때 다 채워야 하니 그런 상태 자체가 생기지 않는다.

순환 참조도 시작할 때 잡힌다. A가 B를, B가 A를 필요로 하면 필드 주입에서는 잘 뜨다가 런타임에 서로를 호출하며 스택 오버플로가 난다. 생성자 주입이면 A를 만들려면 B가 필요하고 B를 만들려면 A가 필요해 애초에 만들 수가 없으므로, 애플리케이션이 뜨는 시점에 에러로 알려준다. 스프링 부트 2.6부터는 필드 주입과 setter 주입도 기본적으로 순환 참조를 막는다.

구체 타입으로 받는 주입

의존성을 밖에서 받더라도 받는 타입이 구체 클래스면 여전히 그 클래스에 묶여 있다. A(KakaoLogin login)은 카카오 로그인 말고는 받을 수 없다. 추상 타입으로 받아야 비로소 갈아 끼울 수 있게 되고, 그 이야기는 DIP에 있다.

관련

출처