이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.
공부 › Spring › IoC 컨테이너와 DI
빈 생명주기 콜백
생성자에서 주입받은 필드를 썼더니 왜 null인가
컨테이너는 빈을 정해진 순서로 다룹니다. 생성자로 객체를 만들고, 의존성을 넣고, 초기화 콜백을 부르고, 쓰다가, 컨테이너가 닫힐 때 소멸 콜백을 부릅니다.
실행으로 대조 · 스프링 부트 4.1.1에서 스무 가지 조합을 실행해 불린 순서를 대조
이 실습에 나오는 개념
- 빈 생명주기
- 빈이 만들어져 사라지기까지의 과정이다. 객체 생성, 의존성 주입, 초기화 콜백 호출, 사용, 컨테이너 종료 시 소멸 콜백 호출의 순서로 진행된다. 의존성이 모두 주입된 뒤에 해야 하는 초기화 작업은 @PostConstruct에, 종료 전 정리 작업은 @PreDestroy에 둔다.
- @PostConstruct와 @PreDestroy
- 빈의 초기화 시점과 소멸 시점에 불릴 메서드를 표시하는 애너테이션이다. 컨테이너는 객체를 만들고 의존성 주입을 끝낸 뒤에 @PostConstruct가 붙은 메서드를 부르고, 컨테이너가 닫힐 때 @PreDestroy가 붙은 메서드를 부른다. 그래서 필드나 세터로 주입받는 객체를 써야 하는 준비 작업은 생성자가 아니라 @PostConstruct 메서드에 둔다(생성자로 주입받은 것은 생성자 안에서도 쓸 수 있다). 스프링의 것이 아니라 jakarta.annotation 패키지의 애너테이션이다. 프로토타입 스코프의 빈에서는 @PreDestroy가 불리지 않는다.
- InitializingBean과 DisposableBean
- 빈의 초기화와 소멸 시점에 불릴 메서드를 정하는 스프링의 두 인터페이스다. InitializingBean을 구현하면 의존성 주입이 끝난 뒤에 afterPropertiesSet()이 불리고, DisposableBean을 구현하면 컨테이너가 닫힐 때 destroy()가 불린다. 클래스가 스프링의 인터페이스를 구현해야 하므로 코드가 스프링에 묶인다. 같은 일을 @PostConstruct와 @PreDestroy로, 또는 @Bean의 initMethod와 destroyMethod로도 할 수 있다. 세 가지를 함께 쓰면 애너테이션, 인터페이스, @Bean에 지정한 메서드의 순서로 불린다.
- 생성자 주입, 세터 주입, 필드 주입
- 의존성을 넣는 세 가지 방법이다. 생성자 주입은 객체 생성 시 의존성을 받으므로 필드를 final로 둘 수 있고 의존성이 빠진 채 객체가 만들어지는 일이 없어 스프링이 권장한다. 세터 주입은 선택적인 의존성에 쓴다. 필드 주입은 필드에 @Autowired를 붙이는 방식으로 코드는 짧지만 스프링 없이 객체를 만들어 테스트하기 어렵다.
- @Configuration과 @Bean
- @Configuration은 빈 정의를 담은 설정 클래스임을 나타내고, 그 안의 @Bean 메서드가 반환하는 객체가 빈으로 등록된다. 직접 수정할 수 없는 외부 라이브러리의 클래스를 빈으로 만들거나 생성 과정을 직접 제어해야 할 때 쓴다. 내가 작성한 클래스는 보통 @Component 계열을 붙여 등록한다.
이렇게 바꿔 보세요
- 처음 설정으로 — 필드 주입인데 생성자 안에서 repository를 씁니다. 기동이 실패합니다.
- @PostConstruct로 옮기면 — 주입이 끝난 뒤에 불리므로 repository를 쓸 수 있습니다.
- 생성자 주입이면 — 생성자 안에서 이미 repository가 들어 있습니다.
- 세 가지 콜백을 모두 쓰면 — 어느 것이 먼저 불리는지 순서를 봅니다.
- @Bean의 initMethod — 클래스에 애너테이션을 붙이지 않고 설정 쪽에서 메서드 이름을 지정합니다.
- 프로토타입 빈이면 — 초기화 콜백은 불리지만 소멸 콜백은 불리지 않습니다.
- InitializingBean과 DisposableBean — 스프링의 인터페이스를 구현하는 방법입니다.
처음 설정으로 실행하면
- 컨테이너 시작: priceRepository와 priceCache 두 빈을 등록합니다. priceCache의 스코프는 싱글톤입니다. 싱글톤 빈은 기동하는 동안 만들어집니다.
- 생성자: 컨테이너가 PriceCache의 생성자를 호출해 객체를 만듭니다. 필드 주입은 객체가 만들어진 뒤에 이루어지므로, 생성자 안에서 본 repository는 아직 null입니다.
- 생성자 안의 this.repository.findAll(): repository가 null이므로 NullPointerException이 발생합니다. 생성자가 끝나지 못해 객체가 만들어지지 않았고, 컨테이너는 기동을 중단합니다.
실행 결과
- 컨테이너는 생성자로 객체를 만든 다음에 @Autowired 필드를 채웁니다. 생성자 안에서 그 필드를 쓰면 null입니다.
- 고치는 방법은 두 가지입니다. 준비 작업을 @PostConstruct 같은 초기화 콜백으로 옮기거나, 생성자 주입으로 바꿔 생성자의 인자로 받습니다.
코드
@Component
class PriceCache {
@Autowired
private PriceRepository repository;
private Map<String, Integer> prices;
PriceCache() {
System.out.println("생성자: repository = " + this.repository);
prices = this.repository.findAll(); // 가격표를 미리 읽어 둔다
}
int price(String name) {
return prices.get(name);
}
}
@Component
class PriceRepository {
Map<String, Integer> findAll() {
return new HashMap<>(Map.of("사과", 1000));
}
}
// 쓰는 곳
PriceCache priceCache = context.getBean(PriceCache.class);
int price = priceCache.price("사과");
context.close(); // 컨테이너를 닫는다
이 모형의 전제
스무 가지 조합을 이 PC의 스프링 부트 4.1.1(스프링 프레임워크 7.0.9, jakarta.annotation-api 3.0.0)에서 실행해 대조했습니다. 조합마다 화면과 같은 구조의 소스를 컴파일해 컨테이너를 띄우고, 빈을 꺼내 price("사과")를 한 번 부른 뒤 컨테이너를 닫으면서, 생성자와 각 콜백이 불린 순서와 그때 repository 필드가 null이었는지를 기록해 모형의 순서와 하나하나 비교했습니다. 기동 실패의 예외 사슬은 BeanCreationException → BeanInstantiationException → NullPointerException이었고, 화면에는 맨 안쪽 원인을 적었습니다. 화면에서 단순화한 것: "의존성 주입" 단계는 따로 기록할 수 없어, 생성자에서는 null이고 초기화 콜백에서는 들어 있었다는 것으로 그 사이에 주입이 일어났음을 확인했습니다. 세터 주입과, 컨테이너가 닫힐 때 여러 빈 사이의 소멸 순서는 다루지 않았습니다. @Bean에 destroyMethod를 적지 않아도 public close() 메서드가 있으면 컨테이너가 닫힐 때 불리는 것(프로토타입이면 불리지 않는 것)도 실행으로 확인했습니다. 실행으로 확인하지 않고 스프링 공식 문서의 설명을 따른 것은 두 가지입니다: @PostConstruct와 @PreDestroy가 권장되는 방법이라는 것, 프로세스가 강제로 종료되면 컨테이너가 닫히지 못해 소멸 콜백이 불리지 않는다는 것.
앞 실습: 빈 스코프 · 다음 실습: 프록시와 자기 호출