빈 라이프사이클: 태어나서 사라지기까지
들어가며
1편에서 스프링 컨테이너가 빈을 만들고 연결하는 큰 그림을, 2편에서 그
빈의 설계도인 BeanDefinition을 봤다. 3편은 그 설계도가
실제 객체로 찍혀 나오는 순간부터 사라지는 순간까지의
시간 축을 다룬다. 같은 질문을 시간으로 바꿔보자 — "그 빈이 언제 살아
움직이는가."
이 주제가 어렵게 느껴지는 건 14단계 어쩌고 하는 암기표 때문이다. 실제로 필요한 건 개발자가 끼어들 수 있는 훅이 어디에 있는지, 그리고 어떤 훅이 어떤 시점에 동작하는지뿐이다. 이걸 놓치면 아래 같은 버그를 만난다.
@PostConstruct안에서@Transactional메서드를 불렀는데 트랜잭션이 안 걸린다 — 버그가 아니라 시간 축의 필연이다BeanPostProcessor를@Bean으로 등록했더니 다른 빈에서@Value치환이 조용히 사라진다 —static키워드 한 줄의 차이다prototype빈에@PreDestroy를 달았는데 로그가 안 찍힌다 — 스프링은 애초에 부르지 않는다- 비웹
main에서 컨테이너를 띄웠더니 종료 시 소멸 콜백이 침묵한다 —registerShutdownHook()을 안 걸었기 때문이다 - Spring 6으로 올렸더니
javax.annotation.PostConstruct가 갑자기 안 먹힌다 —jakarta로 바꿔야 한다
이 글은 개요 → 인스턴스화 → 주입 → Aware → BPP → 초기화 → 사용 → 소멸 → 실무 순으로, Spring Framework 6.x(Java 17+) 기준으로 정리한다.
목차
- 1) 라이프사이클 한 장: 외울 것과 이해할 것
- 2) 인스턴스화 단계: 리플렉션이 껍데기를 찍어내는 순간
- 3) 의존성 주입 단계: 반제품에서 멈춘 빈
- 4) Aware 인터페이스: 컨테이너 환경을 건네주는 창구
- 5) BeanPostProcessor: AOP 프록시가 태어나는 곳
- 6) BPP 함정: BPP가 참조하는 빈은 AOP가 안 먹힌다
- 7) 초기화 콜백 3종: @PostConstruct / InitializingBean / init-method
- 8) 사용 단계: "완성된 빈"의 의미
- 9) 소멸 콜백: @PreDestroy / DisposableBean / destroy-method
- 10) 실무에서 이렇게 읽고 쓴다
- 11) 한 줄 정리
1) 라이프사이클 한 장: 외울 것과 이해할 것
1-1) 전체 흐름 한 줄
빈이 태어나서 사라지기까지를 한 줄로 적으면 이렇다.
인스턴스화 → 의존성 주입 → Aware 콜백 →
BPP.before→@PostConstruct→afterPropertiesSet→init-method→BPP.after→ 사용 →@PreDestroy→DisposableBean→destroy-method
단계가 많아 보이지만 실제로 개발자가 개입할 수 있는 지점은 세 개뿐이다.
@PostConstruct— 주입 끝난 직후, 초기화 콜백. 가장 자주 쓴다BeanPostProcessor— 모든 빈의 초기화 전/후를 가로챌 수 있는 컨테이너 레벨 훅. AOP가 여기서 태어난다@PreDestroy— 컨테이너 종료 시 자원 정리
나머지는 Spring 내부가 알아서 돌리는 구간이다. 이 세 훅이 전체 흐름의 어느 지점에 끼어 있는지만 이해하면, 라이프사이클 때문에 생기는 대부분의 버그는 자연스럽게 풀린다.
1-2) 시퀀스로 보는 전체 흐름
위 다이어그램에서 4번과 8번이 같은
BeanPostProcessor의 두 콜백이라는 점, 그리고
AOP 프록시 교체는 8번 after에서 일어난다는
점 — 이 두 사실이 이 글 전체의 뼈대다. §5 BPP와 §8 사용 단계에서 같은 지점을
다시 꺼낸다.
1-3) 싱글톤 생성 락
한 가지 더 기억해두자. Spring Reference는 빈 초기화가 컨테이너의 싱글톤 생성 락(singleton creation lock) 안에서 실행된다고 명시한다.
"Initialization callbacks are executed within the container's singleton creation lock."
이게 왜 중요한가 — @PostConstruct 안에서 다른
싱글톤 빈의 비동기 작업을 기다리거나, 자기 자신을 다시
참조하려고 하면 데드락이 생긴다. 초기화 단계는
생각보다 좁은 방이다. 이 복선은 §8 사용 단계에서
self-invocation 특수 케이스로 다시 꺼낸다.
2) 인스턴스화 단계: 리플렉션이 껍데기를 찍어내는 순간
2-1) 생성자가 호출되는 지점
컨테이너가 BeanDefinition을 보고 리플렉션으로
생성자를 호출하는 순간, 그 클래스의 객체가 처음으로 메모리에
등장한다. 2편에서 본 BeanDefinition — 클래스 이름, 스코프,
의존성 목록이 담긴 설계도 — 가 바로 이 시점에 실제 객체로 찍혀
나온다. JVM 입장에서는 그냥
Constructor.newInstance() 호출이다.
// 컨테이너 내부 의사 코드
Class<?> clazz = Class.forName(beanDefinition.getBeanClassName());
Constructor<?> ctor = clazz.getDeclaredConstructor(/* 생성자 파라미터 타입 */);
Object bean = ctor.newInstance(/* 인자 */);
// 이 시점: bean은 껍데기이 시점의 빈은 껍데기다. 필드에는 아직 아무것도 주입되지 않았고, 컨테이너 환경도 모르고, 초기화 콜백도 돌지 않았다. 그냥 JVM 힙에 객체 하나가 올라왔다는 사실만 존재한다.
2-2) 생성자 주입은 이 단계에서 끝난다
여기서 한 가지 구분이 필요하다. **생성자 주입(constructor injection)**의 경우, 인스턴스화와 의존성 주입이 사실상 같은 순간에 일어난다. 생성자 파라미터 자체가 의존성이기 때문이다.
@Service
public class OrderService {
private final OrderRepository repository;
// 이 생성자가 호출되는 순간 repository는 이미 주입되어 있다
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}컨테이너는 OrderService를 인스턴스화하기 전에
OrderRepository부터 먼저 완성시켜야 한다.
그래서 의존성 그래프의 리프(leaf) 노드부터 차례로
만들어진다. 1편에서 순환 의존성이 생성자 주입에서는 시작 시 즉시
실패하는 이유도 이것이다 — 누구를 먼저 만들지 정할 수 없는 사이클이기
때문이다.
세터/필드 주입이면 먼저 빈 생성자로 껍데기를 만든 뒤, 다음 단계에서 주입한다. §3 주입 단계가 의미를 가지는 건 이 경우다.
3) 의존성 주입 단계: 반제품에서 멈춘 빈
3-1) 주입이 일어나는 방식
세터 또는 필드 주입의 경우, 인스턴스화 직후에 컨테이너가 리플렉션으로
의존성을 채운다. 필드 주입이면 Field.set(), 세터 주입이면
세터 메서드 호출이다.
@Service
public class OrderService {
private OrderRepository repository;
// 컨테이너가 이 세터를 호출해서 의존성을 채운다
@Autowired
public void setRepository(OrderRepository repository) {
this.repository = repository;
}
}주입이 끝나면 이 빈은 협력자를 알고 있는 상태가 된다. 그런데 여기서 틀리기 쉬운 지점이 있다 — 주입이 끝났다고 해서 완제품이 아니다. 아직 Aware 콜백, BPP, 초기화 콜백이 남아 있다. 지금은 반제품이다.
3-2) 이 구분이 왜 중요한가
반제품 상태에서 협력자의 메서드를 호출하면 — 즉, 다른 빈의
초기화가 끝나지 않은 상태에서 호출하면 — 예측 불가능한 결과가
나온다. 그래서 스프링은 빈과 빈 사이에 초기화가 모두 끝난 후에만
통신하도록 설계되어 있고, 이 보장은 BPP.after까지
전부 돌아야 성립한다.
그래서 생성자 바디 안에서 협력자의 복잡한 로직을 호출하지
말아야 한다. 생성자에서는 필드 할당만 하고, 실제 "시작" 작업은
@PostConstruct로 미루는 게 관례인 이유가 이것이다. 생성자
시점에 주입된 협력자는 그 협력자의 @PostConstruct가
아직 안 돌았을 수 있다.
4) Aware 인터페이스: 컨테이너 환경을 건네주는 창구
4-1) Aware란 무엇인가
Aware는 "이 빈은 컨테이너의 어떤 정보를
필요로 한다"고 신호를 보내는 마커 인터페이스 계열이다. 빈이 특정
Aware 인터페이스를 구현하면, 컨테이너가 주입 단계 직후에
해당 정보를 콜백으로 건네준다. 빈이 컨테이너 환경에
접근할 수 있는 좁은 창구다.
Spring이 호출하는 순서는 고정되어 있다.
| 순서 | 인터페이스 | 건네받는 것 |
|---|---|---|
| 1 | BeanNameAware |
자기 빈 이름 (setBeanName(String)) |
| 2 | BeanClassLoaderAware |
빈을 로드한 ClassLoader |
| 3 | BeanFactoryAware |
자신이 속한 BeanFactory |
| 4 | EnvironmentAware |
Environment (프로퍼티, 프로파일) |
| 5 | ApplicationContextAware |
전체 ApplicationContext |
이 순서는 우연이 아니다. 좁은 것부터 넓은 것으로 의도적으로 설계되어 있다. 자기 이름 → 자기 클래스로더 → 자기 팩토리 → 환경 → 전체 컨텍스트. 나중 단계로 갈수록 건네받는 정보의 범위가 넓어지고, 빈이 컨테이너에 더 많이 결합된다.
4-2) 직접 구현할 일은 거의 없다
실무에서 Aware 인터페이스를 직접 구현할 일은
거의 없다. 대부분은 생성자 주입으로 대체 가능하다.
// 피하기: Aware 직접 구현
public class OrderService implements ApplicationContextAware {
private ApplicationContext context;
@Override
public void setApplicationContext(ApplicationContext context) {
this.context = context;
}
}
// 선호: 생성자 주입
@Service
public class OrderService {
private final ApplicationContext context;
public OrderService(ApplicationContext context) {
this.context = context;
}
}스프링이 ApplicationContext, Environment,
BeanFactory 같은 컨테이너 객체를 보통 빈처럼
주입해주기 때문에, Aware 인터페이스에 의존해서 스프링 API에
직접 결합할 이유가 없다.
4-3) 경고: ApplicationContextAware로 getBean()은 안티패턴
여기서 틀리기 쉬운 지점 하나. ApplicationContextAware를
구현해서 context.getBean()을 호출하는 패턴은
Service Locator 안티패턴이다.
// 피하기
public class OrderService implements ApplicationContextAware {
private ApplicationContext context;
public void placeOrder(Order order) {
// 런타임에 getBean으로 의존성을 찾는다 — DI의 의미를 훼손
OrderRepository repo = context.getBean(OrderRepository.class);
repo.save(order);
}
// ...
}이 코드는 컴파일 시점에 의존성이 드러나지 않고, 테스트 시 전체 컨텍스트를 띄워야 하며, 1편에서 본 DI의 불변성·null 안전·명시성 보장을 모두 잃는다. 의존성이 필요하면 생성자 파라미터로 받아라. 이게 유일한 정답이다.
ApplicationContextAware를 쓰고 싶어지는 순간은 설계를
의심해야 하는 순간이다.
5) BeanPostProcessor: AOP 프록시가 태어나는 곳
5-1) 두 개의 콜백
BeanPostProcessor(이하 BPP)는 모든 빈의
초기화 전/후에 끼어들 수 있는 컨테이너 레벨 확장 지점이다. Spring
Reference의 문장이 간결하다.
"The BeanPostProcessor interface consists of exactly two callback methods."
정확히 두 개의 콜백 메서드로 구성된다.
public interface BeanPostProcessor {
default Object postProcessBeforeInitialization(Object bean, String beanName)
throws BeansException { return bean; }
default Object postProcessAfterInitialization(Object bean, String beanName)
throws BeansException { return bean; }
}두 메서드 모두 Object를 받아서
Object를 반환한다는 점이 결정적이다. BPP는 들어온
빈을 다른 객체로 교체할 수 있다. 이 교체 가능성이 AOP
프록시의 메커니즘이다.
5-2) 타임라인 안에서 BPP가 끼는 자리
BPP의 두 콜백이 전체 흐름의 어디에 끼는지 보자.
... 주입 완료 → Aware 콜백
↓
BPP.postProcessBeforeInitialization ← 여기(원본 그대로 통과 보통)
↓
@PostConstruct
↓
afterPropertiesSet()
↓
init-method
↓
BPP.postProcessAfterInitialization ← 여기서 프록시 교체
↓
사용 준비 완료
초기화 콜백(@PostConstruct,
afterPropertiesSet, init-method)은
BPP.before와 BPP.after 사이에 끼어
있다. 그리고 AOP 프록시는 BPP.after에서
만들어진다. 이 순서 하나가 §8의
@PostConstruct 트랜잭션 함정을 설명한다.
5-3) 프록시는 AbstractAutoProxyCreator가 만든다
AOP 자동 프록시 생성은 스프링 내부 클래스인
AbstractAutoProxyCreator가 담당하고, 이 클래스 자체가
BeanPostProcessor의 구현체다. Javadoc은
이렇게 말한다.
"BeanPostProcessor implementation that wraps each eligible bean with an AOP proxy..."
적격한 빈(eligible bean)을 AOP 프록시로 감싸는 BPP
구현체라는 것이다. 즉, @Transactional,
@Async, @Cacheable이 동작하는 메커니즘은 전부
BPP의 postProcessAfterInitialization 훅에서 원본
빈을 프록시로 교체하는 과정이다.
위 다이어그램에서 핵심은 @PostConstruct까지는
원본 객체에서 실행되고, **프록시가 태어나는 건
BPP.after**라는 점이다. 이 시간 차이가 §8에서 다룰 자기
호출(self-invocation) 함정의 진짜 원인이다.
5-4) 개발자가 BPP를 직접 구현할 일은 거의 없다
강조해둔다 — BPP를 직접 구현할 일은 실무에서 거의
없다. @Transactional, @Async,
@Cacheable, @Validated — 이 모든 것이 이미
스프링이 제공하는 BPP 구현체 위에서 돈다. 개발자는 BPP가 거기
있다는 사실과, 그 타이밍을 이해하기만 하면 된다. 직접 구현은
프레임워크/라이브러리 작성자의 일이다.
6) BPP 함정: BPP가 참조하는 빈은 AOP가 안 먹힌다
6-1) 증상부터 본다
어느 날 팀에서 @Transactional이 먹히지 않는
서비스가 하나 발견됐다고 해보자. 코드를 봐도 평범한
@Service이고, 셀프 인보케이션도 없고,
this.method()도 없다. 그런데 트랜잭션 로그가 안 찍힌다. 이
증상의 범인 중 하나가 BPP 의존 관계다.
Spring Reference가 못을 박아둔다.
"Because AOP auto-proxying is implemented as a BeanPostProcessor itself, neither BeanPostProcessor instances nor the beans they directly reference are eligible for auto-proxying and, thus, do not have aspects woven into them."
번역하면 — AOP 자동 프록시 생성 자체가 BPP로
구현되어 있기 때문에, BPP 인스턴스 자체도, 그 BPP가
직접 참조하는 빈도 자동 프록시 대상이 되지 않는다. 그래서
@Transactional이 조용히 무시된다.
왜 그런가. BPP는 모든 빈의 초기화에 끼어들기 위해 다른 빈보다 먼저 초기화되어야 한다. 그런데 BPP가 어떤 빈 X를 주입받으면, X도 BPP를 초기화하기 위해 먼저 만들어져야 한다. X는 BPP보다 먼저 태어나야 하므로, AOP 자동 프록시를 만드는 또 다른 BPP가 아직 준비되지 않은 시점에 X가 완성된다. 결국 X는 프록시로 감싸이지 못한 채 그대로 컨테이너에 등록된다.
6-2)
@Bean으로 BPP를 등록할 때는 static
이 문제의 직접적인 증상은 @Configuration 클래스
안에 @Bean으로 BPP를 등록할 때다.
// 피하기
@Configuration
public class AppConfig {
// non-static @Bean
@Bean
public MyBeanPostProcessor myBpp() {
return new MyBeanPostProcessor();
}
}이렇게 쓰면 스프링이 BPP를 만들기 위해 AppConfig
자체를 먼저 인스턴스화해야 하고, 그 과정에서
AppConfig가 참조하는 다른 @Bean들의 초기화
순서가 꼬인다. 그 결과 일부 빈에 @Value 치환이 되지 않거나,
@Autowired가 누락되거나, BPP가 너무 이른 시점에
만들어졌다는 경고 로그가 찍힌다.
해결은 간단하다. @Bean 메서드를
static으로 만든다.
// 선호
@Configuration
public class AppConfig {
// static @Bean — AppConfig 인스턴스 없이 호출 가능
@Bean
public static MyBeanPostProcessor myBpp() {
return new MyBeanPostProcessor();
}
}static이면 스프링이 AppConfig를
인스턴스화하지 않고도 BPP를 먼저 만들 수 있다. BPP가 컨테이너의
가장 앞쪽에 자리 잡고, 나머지 빈들이 그 뒤에 생성되면서
정상적으로 자동 프록시를 받는다.
6-3) 증상 체크 박스
다음 증상이 보이면 BPP 함정을 의심한다.
- 특정 서비스에서만
@Transactional이 안 먹힌다 — self-invocation도 아니다 - 부팅 시 "Bean X is not eligible for getting processed by all BeanPostProcessors" 경고
- BPP 구현체나
@EnableAsync,@EnableCaching관련@Configuration에 non-static@Bean이 있다
이 세 가지가 겹치면 @Bean 메서드 앞에
static이 있는지부터 확인한다.
7) 초기화 콜백 3종: @PostConstruct / InitializingBean / init-method
7-1) 세 방식이 있는 이유
같은 "초기화 콜백" 자리에 세 가지 방식이 동시에 존재한다 —
@PostConstruct,
InitializingBean.afterPropertiesSet(),
@Bean(initMethod=...). 같은 일을 할 수 있는 방식이 셋이나
있는 건 역사적 이유다. 오래된 방식부터 현대적
방식까지가 차곡차곡 쌓였고, 스프링은 호환성을 위해 셋을 모두
지원한다.
세 방식이 동시에 선언되어 있으면 실행 순서는 고정되어 있다.
@PostConstruct→afterPropertiesSet()→init-method
Spring Reference에 그대로 나와 있다.
"If you are using both mechanisms configured together for the same bean, with an initialization method that has a different name, each method is executed in the order listed in this topic."
7-2) 비교표
| 방식 | Spring 결합도 | 표준 | 권장 여부 |
|---|---|---|---|
@PostConstruct |
낮음 (JSR-250 어노테이션) | Jakarta EE 표준 | 권장 (최우선) |
InitializingBean |
높음 (Spring 인터페이스 구현 강제) | Spring 전용 | 비권장 |
@Bean(initMethod=...) |
없음 (POJO) | 설정 시점에 선언 | 외부 라이브러리 빈에 쓸 때만 |
실무에서 99%의 경우는 @PostConstruct
하나로 충분하다. InitializingBean은 빈 클래스를
스프링 API에 결박하므로 레거시가 아니면 쓸 이유가 없다.
@Bean(initMethod=...)은 내가 고칠 수 없는 외부
라이브러리 클래스를 빈으로 등록할 때 — 소스 코드에 어노테이션을
못 붙이는 상황 — 유용하다.
7-3) Spring 6의 함정: javax → jakarta
Spring 6.x로 업그레이드할 때 반드시 확인해야 하는
지점이다. Spring 6은 Jakarta EE 9+ 기반이고, 그 결과
javax.* 네임스페이스가 전부
jakarta.*로 이동했다.
// 피하기: Spring 6에서는 조용히 무시된다
import javax.annotation.PostConstruct;
@Service
public class OrderService {
@PostConstruct
public void init() {
// 이 메서드, 절대 호출되지 않는다
}
}// 선호
import jakarta.annotation.PostConstruct;
@Service
public class OrderService {
@PostConstruct
public void init() {
// 정상 호출
}
}문제는 이게 컴파일 에러를 내지 않는다는 점이다.
javax.annotation.PostConstruct가 클래스패스에 남아
있으면(의존성 전이 등) 임포트는 통과하지만, 스프링 6은 그 어노테이션을
더 이상 인식하지 않는다. 메서드는 그냥 안 불린 채로
조용히 넘어가고, 며칠 뒤 초기화가 안 된 상태에서 NPE가
터지고 나서야 원인을 찾는다.
Spring 5 → 6 마이그레이션에서 테스트를 충분히 돌려야 하는 이유가
이것이다. IDE의 "Migrate to jakarta" 리팩토링을 쓰거나,
전체 소스에서 javax\.annotation으로 검색해서 모두 바꿔야
한다.
8) 사용 단계: "완성된 빈"의 의미
8-1) BPP.after까지 끝나야 완제품
지금까지의 흐름을 다시 모으면, 빈이 "완성"되는 시점은
BPP.after가 끝난 직후다. 그 이전은 원본 객체이고,
그 이후부터가 프록시로 감싸진 최종 형태다. 그리고 컨테이너가
getBean()이나 @Autowired로 돌려주는 건
이 완제품이다.
이 한 문장이 지금부터 다룰 함정 전체의 원인이다.
8-2) 최대 함정: @PostConstruct에서 @Transactional 메서드 호출
실무에서 자주 만나는 버그다.
// 피하기
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
@PostConstruct
public void init() {
// 시작할 때 DB에 초기 데이터 넣기
this.insertDefaults();
// 예상: 트랜잭션이 걸린다
// 실제: 트랜잭션이 절대 안 걸린다
}
@Transactional
public void insertDefaults() {
repository.save(new Order("DEFAULT"));
}
}이 코드를 돌리면 insertDefaults()는 실행은 되지만,
트랜잭션 없이 실행된다. @Transactional이
그냥 무시된다. 1편에서 봤던 self-invocation과 증상은 같지만,
원인이 다르다.
1편의 self-invocation은 "빈이 이미 완성된 뒤에도
this.method()는 프록시를 우회한다"는 이야기였다. 3편의 이
함정은 더 근본적이다 — @PostConstruct가 실행되는
시점은 BPP.after 이전이다. 즉, 프록시가
아직 태어나지도 않았다. this는 반드시 원본
객체일 수밖에 없고, 프록시 우회를 걱정할 이유도 없이
프록시 자체가 없다.
타임라인으로 다시 적으면 이렇다.
... 주입 → Aware → BPP.before
↓
@PostConstruct 실행 ← 이 시점, 프록시는 아직 없다
→ this.insertDefaults() ← 원본 객체 호출
→ @Transactional 무시
↓
BPP.after ← 여기서 프록시 생성
↓
사용 준비 완료
8-3) 올바른 해결: 시점을 뒤로 미룬다
해결책은 트랜잭션이 필요한 초기화 로직을
@PostConstruct에서 빼는 것이다. 컨테이너가
완전히 기동된 후 실행되는 훅을 쓴다.
// 선호: ApplicationReadyEvent 사용
@Service
public class OrderInitializer {
private final OrderService orderService; // 이 시점에는 프록시가 주입된다
public OrderInitializer(OrderService orderService) {
this.orderService = orderService;
}
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
// 컨테이너 기동이 완전히 끝난 시점
// orderService는 프록시이므로 @Transactional 정상 동작
orderService.insertDefaults();
}
}ApplicationReadyEvent는 모든 빈이 완성되고
애플리케이션이 요청을 받을 준비가 끝난 시점에 발행된다. 이
시점이면 orderService는 프록시이고,
insertDefaults() 호출은 정상적으로 트랜잭션 인터셉터를
거친다.
다른 선택지도 있다.
ApplicationRunner/CommandLineRunner— Spring Boot의 기동 후 훅. 같은 시점에 실행된다SmartInitializingSingleton.afterSingletonsInstantiated()— 모든 싱글톤이 초기화된 직후. Boot 아닌 순수 스프링에서 쓴다- 초기화 로직을 트랜잭션 필요한 것과 아닌 것으로 분리
—
@PostConstruct에는 순수 로컬 계산만 두고, DB가 필요한 건 별도 빈으로
@PostConstruct는 메모리 안에서 끝나는
세팅(캐시 프리로드, 환경 변수 파싱, 검증 등)에만 쓴다.
외부 자원을 건드리는 초기화는 기동 후 이벤트로 — 이게
원칙이다.
8-4) 싱글톤 생성 락 복선 회수
§1-3에서
걸어둔 복선을 회수한다. 초기화 콜백은 컨테이너의 싱글톤 생성
락 안에서 실행된다고 했다. @PostConstruct 안에서
다른 싱글톤 빈의 완성을 기다리거나, 비동기 작업이 그 빈을 다시 참조하면
데드락이 생긴다. 좁은 방이기 때문이다.
이 제약까지 포함해서 — **@PostConstruct는 "내 자원을 내
안에서만 세팅하는 짧은 구간"**으로 이해하면 거의 틀리지 않는다.
9) 소멸 콜백: @PreDestroy / DisposableBean / destroy-method
9-1) 소멸 콜백의 실행 순서
초기화 3종과 대칭으로, 소멸도 세 방식이 있다. 실행 순서도 마찬가지로 고정되어 있다.
@PreDestroy→DisposableBean.destroy()→destroy-method
쓰임도 대칭이다. @PreDestroy(Jakarta 표준 어노테이션)가
가장 권장되고, DisposableBean은 Spring API에 결박되므로
기피하며, destroy-method는 외부 라이브러리 빈에 쓴다.
9-2) 함정 1: prototype 빈은 소멸 콜백이 안 불린다
Spring Reference가 단호하게 말한다.
"In contrast to the other scopes, Spring does not manage the complete lifecycle of a prototype bean: the container instantiates, configures, and otherwise assembles a prototype object and hands it to the client, with no further record of that prototype instance. Thus, although initialization lifecycle callbacks are called on all objects regardless of scope, in the case of prototypes, configured destruction lifecycle callbacks are not called."
요약하면 — prototype 빈에서는 초기화 콜백은 불리지만, 소멸 콜백은 불리지 않는다. 컨테이너는 prototype을 만들어서 건네준 뒤 더 이상 추적하지 않기 때문이다. 이 비대칭은 처음에 당황스럽다.
// 피하기 (prototype + @PreDestroy)
@Component
@Scope("prototype")
public class TempSession {
@PreDestroy
public void cleanup() {
// 절대 호출되지 않는다
log.info("cleanup!"); // 이 로그는 평생 안 찍힌다
}
}이게 자원 누수로 이어지는 조합이다. 파일 핸들, DB
커넥션, 외부 연결을 들고 있는 prototype 빈은 스프링이 닫아주지
않는다. 호출 측이 직접 try-with-resources로
닫거나, ObjectProvider/@Lookup 같은 패턴으로
스코프 문제를 회피해야 한다. 이 근본 해결은 4편
스코프에서 다룬다. 지금은 "prototype은 소멸 콜백이 없다"는
사실만 기억해두자.
9-3) 함정 2: 비웹 main에서 셧다운 훅 누락
Spring Boot는 애플리케이션 종료 시 자동으로 컨테이너를 닫고
@PreDestroy를 호출해준다. 그런데 순수
스프링으로 비웹 main 메서드에서 띄우면 다르다.
// 피하기
public class Main {
public static void main(String[] args) {
var ctx = new AnnotationConfigApplicationContext(AppConfig.class);
OrderService service = ctx.getBean(OrderService.class);
service.run();
// 프로그램이 끝나면서 JVM이 내려가지만
// ctx.close()가 호출되지 않으면 @PreDestroy는 침묵한다
}
}ctx.close()를 명시적으로 부르지 않으면
@PreDestroy가 실행되지 않는다. JVM 자체는
SIGTERM을 받거나 프로그램이 끝나면 종료되지만,
스프링은 그 신호를 못 잡는다.
해결은 registerShutdownHook()이다.
// 선호
public class Main {
public static void main(String[] args) {
var ctx = new AnnotationConfigApplicationContext(AppConfig.class);
ctx.registerShutdownHook(); // JVM 종료 훅에 컨테이너 close 등록
OrderService service = ctx.getBean(OrderService.class);
service.run();
}
}registerShutdownHook()을 호출하면 스프링이 JVM 셧다운
훅에 context.close()를 걸어둔다. SIGTERM이
오거나 main이 끝날 때 컨테이너가 정상적으로 닫히고,
@PreDestroy가 제대로 불린다. Spring Boot는
SpringApplication.run() 내부에서 자동으로 이걸 걸어주기
때문에 대부분의 경우 신경 쓸 일이 없지만, 순수 스프링을
쓰는 라이브러리/배치/테스트 부트스트랩에서는 까먹기 쉽다.
9-4) 함정 3: destroy method inference의 양날검
@Bean에서 destroyMethod를 지정하지 않으면,
스프링은 대상 클래스에서 close() 또는
shutdown()이라는 public 메서드를 자동으로 찾아
소멸 콜백으로 사용한다. 이걸 destroy method
inference라고 부른다.
편리한 기능이지만 의도하지 않은 호출을 유발할 수 있다.
// 의도하지 않은 자동 감지
@Bean
public MyCustomClient client() {
// MyCustomClient에 public void close()가 있으면
// 스프링이 컨테이너 종료 시 자동으로 호출한다
return new MyCustomClient();
}close()가 안전한 소멸 콜백일 수도 있고,
아직 살아 있어야 하는 커넥션을 끊는 위험한 호출일 수도
있다. 의미가 명확하지 않으면 끄는 게 낫다.
// 명시적으로 추론 끄기
@Bean(destroyMethod = "")
public MyCustomClient client() {
return new MyCustomClient();
}destroyMethod = ""(빈 문자열)로 지정하면 추론
자체를 비활성화한다. 명시적으로 소멸 메서드가 필요하면
destroyMethod = "shutdownGracefully"처럼 이름을
직접 적는다. 추론에 기대지 말고 이름을
명시하는 습관이 안전하다.
10) 실무에서 이렇게 읽고 쓴다
- 초기화는
@PostConstruct, DB/트랜잭션 필요하면ApplicationReadyEvent—@PostConstruct는 메모리 안 세팅용이라는 원칙을 지키면 대부분의 초기화 버그가 없다 ApplicationContextAware를 쓰고 싶어지면 설계를 의심한다 — 생성자 주입으로 컨테이너 객체도 받을 수 있다. Service Locator로 가지 말 것BeanPostProcessor는 이해만, 직접 구현은 거의 없다 —@Transactional,@Async,@Cacheable이 이 메커니즘 위에 돈다는 것만 알면 충분하다@Bean으로 BPP 등록할 때는static키워드를 붙인다 — 다른 빈의@Value·AOP 적용에 영향을 주는 함정- prototype 빈의
@PreDestroy는 호출되지 않는다 — 자원 해제는 호출 측이 책임진다. 근본 해결은 4편 스코프 - Spring Boot가 아닌 순수 스프링은
ctx.registerShutdownHook()을 건다 — 비웹 배치/라이브러리 부트스트랩에서 자주 까먹는다 - Spring 6 업그레이드 시
javax.annotation.PostConstruct→jakarta.annotation.PostConstruct— 컴파일은 통과하지만 런타임에는 조용히 무시된다 @Bean(destroyMethod = "")로 destroy method inference를 끈다 —close()/shutdown()자동 감지는 의도치 않은 호출의 원인이 된다
11) 한 줄 정리
빈 라이프사이클은 14단계 암기가 아니라 세 개의
훅(@PostConstruct, BeanPostProcessor,
@PreDestroy)이 어디에 끼어 있는지 이해하는 문제다.
BeanPostProcessor는 AOP 프록시가 태어나는 관문이고,
@PostConstruct에서 @Transactional 호출이
실패하는 건 버그가 아니라 시간 축의 필연이다. 다음 편은
"같은 빈이 몇 개 태어나는가" — 스코프를 본다.
태그: Spring Framework, 빈 라이프사이클, BeanPostProcessor, @PostConstruct, @PreDestroy, AOP 프록시, jakarta.annotation, Aware, Spring 6
'CS > Spring' 카테고리의 다른 글
| 빈 초기화 순서: @DependsOn·@Lazy·@Order로 기동을 통제하기 (0) | 2026.04.12 |
|---|---|
| 조건부 빈 등록: @Conditional과 Auto-configuration의 뿌리 (1) | 2026.04.12 |
| 빈 스코프: singleton이 전부가 아니다 (0) | 2026.04.12 |
| 빈이란 무엇인가: 객체에서 빈으로 (0) | 2026.04.12 |
| 스프링 컨테이너와 IoC/DI/AOP — 빈이 태어나는 자리 (1) | 2026.04.11 |