학습 자료

생성자 주입 — 자바 편 15강의 "밖"이 여기였다


Article

5강 끝에서 스스로 문제를 제기했다.

Notifier n = ctx.getBean(Notifier.class);

new 는 지웠는데 getBean 이 생겼다. 자바 편 15강은 OrderServiceEmailNotifier 라는 이름에 묶이는 것을 문제 삼았는데, 이제는 ctx 라는 것에 묶였다. 묶인 상대만 바뀌었을 뿐이다.

이번 강의에서 그것도 지운다.

자바 편 15강의 코드를 그대로 가져온다

기억을 되살리자. 15강의 OrderService 는 이렇게 생겼었다.

class OrderService {
    private final Notifier notifier;

    OrderService(Notifier notifier) {
        this.notifier = notifier;
    }
    ...
}

그리고 이렇게 썼다.

new OrderService(new EmailNotifier()).order();

밖에서 만들어서 넣어준다고 했다. 그 "밖"이 결국 우리 손이었다.

이제 표식을 하나 붙인다. 그게 전부다.

@Component
public class OrderService {

    private final Notifier notifier;

    public OrderService(Notifier notifier) {
        this.notifier = notifier;
    }

    public void order() {
        System.out.println("주문을 처리했다");
        notifier.send("주문이 완료됐습니다");
    }
}

생성자 본문은 자바 편 15강과 똑같다. this.notifier = notifier; 한 줄. 우리가 추가한 것은 클래스 위의 @Component 뿐이다.

Notifier 를 어디서 구해오라는 코드가 없다. getBean 도 없다. new 도 없다. 그냥 생성자 매개변수에 타입이 적혀 있을 뿐이다.

돌려본다.

터미널
  • ### OrderService 안에 들어온 것: EmailNotifier
  • 주문을 처리했다
  • 메일: 주문이 완료됐습니다
  • ### 컨테이너의 Notifier 와 같은 객체인가: true

들어왔다. 아무도 넣으라고 시키지 않았는데 들어왔다.

컨테이너가 한 일

기동할 때 벌어진 일
  1. 01

    OrderService 를 만들어야 한다

    @Component 가 붙어 있으니 빈으로 만들어야 한다. 그런데 생성자를 보니 인자가 하나 필요하다.

  2. 02

    생성자가 무슨 타입을 요구하는지 읽는다

    Notifier 타입이다. 타입만 보면 된다. 이름은 안 봐도 된다.

    public OrderService(Notifier notifier)
  3. 03

    상자에서 그 타입의 빈을 찾는다

    EmailNotifierNotifier 를 구현하고 있고 @Component 가 붙어 있다. 찾았다.

  4. 04

    찾은 것을 넣어서 new 한다

    new OrderService(emailNotifier). 자바 편 15강에서 우리 손으로 치던 그 줄을 컨테이너가 대신 친다.

이 과정을 의존성 주입(DI)이라고 부른다. 필요한 것을 밖에서 넣어준다는 뜻이다.

세 번째 줄의 출력도 짚고 넘어가자. OrderService 안에 들어온 객체가 컨테이너의 그 빈과 같은 객체다. == 가 참이다.

5강에서 빈은 하나뿐이라고 했다. 컨테이너는 EmailNotifier 를 새로 만들어 넣어준 게 아니라, 이미 상자에 있던 그 하나를 가리켜줬다.

이름이 사라졌다

OrderService 본문을 다시 훑어보자. EmailNotifier 라는 글자가 한 번도 안 나온다. Notifier 라는 타입만 있다.

그러면 구현체를 바꿔도 OrderService 는 모를 것이다. 정말 그런지 해보자.

EmailNotifier 에서 @Component 를 떼고, SmsNotifier 를 새로 만들어 거기에 붙인다.

public class EmailNotifier implements Notifier {   // 표식을 뗐다
    public void send(String msg) {
        System.out.println("메일: " + msg);
    }
}
@Component
public class SmsNotifier implements Notifier {     // 표식을 옮겼다
    public void send(String msg) {
        System.out.println("문자: " + msg);
    }
}

OrderService 는 한 글자도 안 건드렸다.

터미널
  • ### OrderService 안에 들어온 것: SmsNotifier
  • 주문을 처리했다
  • 문자: 주문이 완료됐습니다
  • ### 컨테이너의 Notifier 와 같은 객체인가: true

자바 편 15강에 이런 문장이 있었다.

OrderService 코드는 한 글자도 안 바뀌었다. 카카오톡을 추가해도 안 바뀐다.

그때는 그 문장이 참이 되려면 누군가 밖에서 new OrderService(new SmsNotifier()) 라고 고쳐 써야 했다. 고칠 코드가 OrderService 바깥으로 옮겨갔을 뿐, 누군가는 고쳐야 했다.

이제는 아무도 안 고친다. 표식을 옮겼을 뿐이다.

둘 다 붙이면 어떻게 되나

당연한 질문이다. EmailNotifierSmsNotifier 둘 다 @Component 를 붙이면?

컨테이너 입장에서 생각해보자. OrderService 의 생성자가 Notifier 하나를 달라고 하는데, 상자에는 Notifier 가 둘이다. 누구를 넣어야 하나.

./gradlew bootRun
  • ***************************
  • APPLICATION FAILED TO START
  • ***************************
  • Description:
  • Parameter 0 of constructor in kr.dcodelab.bookstore.OrderService required a single bean, but 2 were found:
  • - emailNotifier: defined in file [.../EmailNotifier.class]
  • - smsNotifier: defined in file [.../SmsNotifier.class]
  • Action:
  • Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed

Spring 이 해법을 세 개나 알려준다. 4강에서 포트 충돌 때도 그랬다. 무엇이 잘못됐는지(Description)와 무엇을 하라는지(Action)를 나눠서 찍는다.

여기서 놓치면 안 되는 것이 하나 있다.

언제 터지는가

5강 — 빈이 없을 때

getBean 으로 꺼내다 실패

  • 기동은 성공한다
  • 서버가 뜬 뒤에
  • getBean 을 부르는 순간 예외
  • NoSuchBeanDefinitionException
  • 돌려봐야 안다

6강 — 넣을 것이 모호할 때

생성자 주입이 실패

  • 기동 자체가 실패한다
  • APPLICATION FAILED TO START
  • 요청 한 번 못 받아본다
  • UnsatisfiedDependencyException
  • 뜨기도 전에 안다
생성자 주입의 가장 큰 장점이다. 객체를 만들 때 필요하니, 만들다가 실패하면 아예 시작을 못 한다.

새벽 세 시에 특정 요청이 들어왔을 때 처음 터지는 것과, 배포하자마자 안 뜨는 것. 어느 쪽이 나은지는 분명하다.

둘 중 하나를 고른다

Spring 이 알려준 대로 @Primary 를 붙여본다. "굳이 안 정해주면 이걸 써라"는 뜻이다.

@Component
@Primary
public class EmailNotifier implements Notifier { ... }
터미널
  • ### OrderService 안에 들어온 것: EmailNotifier
  • 메일: 주문이 완료됐습니다

SmsNotifier 도 여전히 @Component 인데, @Primary 가 붙은 쪽이 뽑혔다.

그런데 어떤 곳에서는 문자를 보내고 싶다면? 그때는 받는 쪽에서 콕 집어 말한다.

public OrderService(@Qualifier("smsNotifier") Notifier notifier) {
    this.notifier = notifier;
}

"smsNotifier" 는 5강에서 본 그 이름이다. 클래스 이름의 첫 글자를 내린 것.

터미널
  • ### OrderService 안에 들어온 것: SmsNotifier
  • 문자: 주문이 완료됐습니다
  • ### 컨테이너의 Notifier 와 같은 객체인가: false

@Qualifier@Primary 를 이긴다.

마지막 줄이 false 인 것에 주목하자. ctx.getBean(Notifier.class) 는 여전히 @PrimaryEmailNotifier 를 준다. 그런데 OrderServiceSmsNotifier 를 들고 있다. 같은 타입이지만 다른 객체다. 한 줄이 두 사실을 동시에 증명한다.

누구를 넣을지 정하는 순서
우선순위무엇어디에 붙이나
1@Qualifier("이름")받는 쪽 생성자 매개변수
2@Primary주는 쪽 클래스
3후보가 하나면 그것
후보가 둘인데 아무 표시도 없으면기동 실패

왜 하필 생성자인가

필드에 바로 넣는 방법도 있다. 옛 예제에서 흔히 본다.

필드 주입 vs 생성자 주입

필드 주입

@Autowired 를 필드에

  • final 을 못 붙인다 — 나중에 바뀔 수 있다
  • new OrderService() 가 가능해진다. 그런데 `notifier` 는 `null` 이다
  • 테스트에서 가짜를 넣으려면 리플렉션이 필요하다
  • 생성자만 봐서는 무엇이 필요한지 모른다

생성자 주입

생성자 매개변수로

  • final 을 붙일 수 있다 — 한 번 정하면 못 바꾼다
  • 인자 없이는 아예 못 만든다. null 인 채로 존재할 수 없다
  • 테스트에서 그냥 넣어주면 된다
  • 생성자만 보면 무엇이 필요한지 다 보인다
그래서 Spring 공식 문서도 생성자 주입을 권한다. 이 시리즈는 생성자 주입만 쓴다.

final 은 자바 편 13강에서 배웠다. 한 번 정하면 재대입이 안 된다. 생성자에서 딱 한 번 넣고 그 뒤로 아무도 못 바꾼다.

두 번째 줄이 특히 중요하다. null 인 채로 존재할 수 있는 객체를 만들지 않는다. 자바 편 20강은 없을 수 있다는 사실을 Optional 이라는 상자에 담아 타입에 적는 법이었다. 생성자 주입은 방향이 반대다. 애초에 null 인 상태가 만들어지지 못하게 막는다.

그리고 자바 편 15강에 이런 문장이 있었다.

테스트할 때는 진짜 메일을 보내지 않는 가짜 Notifier 를 넣으면 된다. OrderService 는 눈치채지 못한다.

생성자로 받기 때문에 가능한 일이었다. 23강에서 실제로 가짜를 넣어본다.

getBean 은 어디로 갔나

OrderService 안에는 getBean 이 없다. ctx 도 없다. 자기가 무엇을 필요로 하는지만 적어뒀고, 그것을 어떻게 구하는지는 모른다.

이게 자바 편 15강이 말한 "밖에서 만들어서 넣어준다"의 완성이다.

묶인 상대가 사라졌다
  1. 자바 편 초반new EmailNotifier() — 구현체 이름에 묶인다
  2. 자바 편 15강생성자로 받는다 — 밖에서 누군가 new 해야 한다
  3. 5강ctx.getBean() — 컨테이너에 묶인다
  4. 6강타입만 적는다 — 아무것에도 안 묶인다
마지막 칸의 OrderService 는 Spring 을 import 하지 않아도 되는 순수한 자바 클래스에 가깝다. @Component 표식 하나만 얹혀 있을 뿐이다.

다만 아직 maingetBean 이 하나 남아 있다. 누군가는 첫 단추를 꿰어야 하니까.

OrderService svc = ctx.getBean(OrderService.class);
svc.order();

9강부터 이것도 사라진다. 컨트롤러는 우리가 부르는 게 아니라 요청이 들어올 때 Spring 이 부르기 때문이다.

정리하면

필요한 것을 생성자에 타입으로 적어두면, 컨테이너가 찾아서 넣어준다.

다시 짚어보기
  1. 01

    생성자에 타입만 적는다

    @Autowired 도, getBean 도 필요 없다. 생성자가 하나뿐이면 표식조차 생략된다.

    public OrderService(Notifier notifier) {
        this.notifier = notifier;
    }
  2. 02

    구현체를 바꿔도 받는 쪽은 안 바뀐다

    @Component 를 옮기기만 하면 된다. OrderServiceEmailNotifier 라는 이름을 모른다.

  3. 03

    후보가 둘이면 기동이 실패한다

    돌려봐야 아는 게 아니라 뜨기도 전에 안다. 생성자 주입의 가장 큰 장점이다.

  4. 04

    @Qualifier@Primary 를 이긴다

    주는 쪽이 기본값을 정하고, 받는 쪽이 필요하면 뒤집는다.

  5. 05

    생성자 주입이면 final 을 붙일 수 있다

    인자 없이는 객체가 만들어지지 않는다. null 인 채로 존재할 수 없다.

    private final Notifier notifier;

여기까지 오면서 계속 미룬 질문이 하나 있다.

@Component 를 붙이면 빈이 된다고 했다. 그런데 Spring 은 그 표식이 붙은 클래스를 어떻게 찾아내는가. 우리 프로젝트에 클래스가 다섯 개뿐이라 다행이지, 수백 개면 어쩔 것인가. 폴더를 전부 뒤지나.

그리고 5강에서 흘린 말이 있다. "자바 편 21강에서 우리가 만들어본 그 리플렉션 러너와 같은 일이다."

7강에서 그 뒤지는 과정을 직접 본다. 우리가 자바 편에서 손으로 짜본 것과 정말 같은 일인지 확인한다.

생성자 주입 — 자바 편 15강의 "밖"이 여기였다 — 디코드랩(DCODELAB)