Spring Bean의 일생 — Container가 객체를 키워내는 순서
@PostConstruct,InitializingBean,init-method가 모두 있는데 정확히 어떤 순서로 호출되는지,BeanPostProcessor는 어디에서 끼어드는지, AOP 프록시는 도대체 언제 만들어지는지 — 이 글은 Spring Container가 Bean 하나를 만들어 사용하고 종료하기까지의 전체 흐름을 풀어냅니다. Spring을 어느 정도 써봤지만 "그래서 정확히 무슨 순서로 일어나는 거지?"가 궁금했던 분들을 대상으로 합니다.
1. 왜 생명주기를 알아야 하는가
Spring을 처음 배울 때는 @Service나 @Component만 붙이면 객체가 알아서 만들어졌습니다. @Autowired가 알아서 의존성을 채워주고, @PostConstruct가 알아서 초기화해줬습니다. 거의 마법에 가까웠습니다.
그런데 어느 시점부터 다음과 같은 문제들을 겪게 됩니다.
@PostConstruct안에서@Async메서드를 호출했는데 동기로 동작합니다.@PostConstruct에서 자기 자신의@Transactional메서드를 호출했는데 트랜잭션이 적용되지 않습니다.- 직접 만든
BeanPostProcessor안에서@Autowired가 동작하지 않습니다. prototype스코프 빈의@PreDestroy가 호출되지 않습니다.- 컨테이너 종료 시 백그라운드 스레드가 강제로 끊겨서 인 플라이트 작업이 유실됩니다.
이 모든 문제의 답은 한 곳에 있습니다. 빈이 만들어지고 사용되고 죽는 순서입니다. 호출 순서를 알면 어떤 시점에 무엇이 가능하고 무엇이 불가능한지 자연스럽게 보입니다.
이 글은 그 순서를 처음부터 끝까지 따라갑니다.
2. 큰 그림 — Bean의 일생을 한눈에
먼저 전체 흐름을 한 장의 다이어그램으로 봅니다. 이 그림이 글 전체의 지도입니다.
flowchart TD
Start([Container Refresh]) --> BFPP[BeanFactoryPostProcessor<br/>BeanDefinition 가공]
BFPP --> Inst[1. Instantiate<br/>constructor call]
Inst --> Pop[2. Populate Properties<br/>setter / field DI]
Pop --> Aware[3. invokeAwareMethods<br/>BeanNameAware / ClassLoaderAware / FactoryAware]
Aware --> BPPBefore[4. BPP.postProcessBeforeInitialization<br/>ApplicationContextAware / @PostConstruct]
BPPBefore --> Init[5. invokeInitMethods<br/>InitializingBean.afterPropertiesSet → init-method]
Init --> BPPAfter[6. BPP.postProcessAfterInitialization<br/>AOP proxy wrapping here]
BPPAfter --> Ready([7. Bean Ready])
Ready --> Use[8. In Use]
Use --> Shutdown([Container Close])
Shutdown --> Pre[9. DestructionAware BPP<br/>@PreDestroy]
Pre --> Disp[10. DisposableBean.destroy]
Disp --> Custom[11. custom destroy-method]
Custom --> End([Bean Discarded])
용어 두 가지만 미리 정리합니다.
- BeanPostProcessor (BPP): 빈 인스턴스가 만들어진 뒤, 초기화 콜백 앞뒤로 끼어드는 훅. 모든 빈에 적용됩니다.
- BeanFactoryPostProcessor (BFPP): 빈 인스턴스가 만들어지기 전,
BeanDefinition자체를 수정하는 훅. 인스턴스가 아니라 메타데이터를 다룹니다.
이 둘은 이름이 비슷해서 자주 헷갈립니다. 본문에서 각각의 역할을 분리해 다룹니다.
3. 인스턴스화와 의존성 주입
생성자 호출
가장 먼저 일어나는 일은 단순합니다. 빈의 생성자가 호출됩니다.
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
OrderService가 싱글턴으로 등록되어 있다면, 컨테이너가 refresh되는 시점에 new OrderService(...)가 단 한 번 호출됩니다. 이때 생성자에 필요한 의존성(OrderRepository)도 함께 해석되어 주입됩니다. 생성자 주입은 인스턴스화 단계와 DI가 한 번에 일어납니다.
생성자 안에서는 의존성을 사용해도 안전합니다. 생성자가 끝났다는 것은 모든 필수 의존성이 채워졌다는 뜻입니다. 다만 이 시점에는 아직 AOP 프록시가 만들어지지 않았습니다. this는 원본 객체입니다.
세터/필드 주입
생성자가 끝나면 세터와 필드 주입이 추가로 진행됩니다.
@Service
public class OrderService {
@Autowired
private NotificationService notificationService;
}
이 시점에서 컨테이너는 빈을 "초기 인스턴스"로 만들어두고, 채워야 할 프로퍼티를 하나씩 해석합니다. 이 과정이 populateBean입니다.
순환 참조와 setter 주입
A가 B를 주입받고, B가 A를 주입받아야 한다면 생성자 주입만으로는 닭이 먼저냐 달걀이 먼저냐 문제가 생깁니다. Spring은 이를 위해 setter 주입에 한해 early reference를 노출합니다. 인스턴스화는 끝났지만 아직 초기화가 끝나지 않은 미완성 빈을 다른 빈에게 잠시 빌려주는 방식입니다. 생성자 주입은 이 트릭을 쓸 수 없으므로 순환 참조가 있으면 컨테이너 기동 시점에 즉시 실패합니다.
이는 생성자 주입을 권장하는 이유 중 하나이기도 합니다. 순환 참조라는 설계 결함이 런타임이 아닌 시작 시점에 드러납니다.
4. Aware 콜백 — 컨테이너와 빈의 첫 대화
DI가 끝나면 initializeBean이 호출됩니다. 그 첫 단계가 invokeAwareMethods입니다. 이 메서드는 빈이 컨테이너의 일부 정보를 알아야 할 때 사용하는 콜백을 호출합니다.
AbstractAutowireCapableBeanFactory.invokeAwareMethods는 정확히 세 종류의 Aware만 처리합니다.
if (bean instanceof BeanNameAware) {
((BeanNameAware) bean).setBeanName(beanName);
}
if (bean instanceof BeanClassLoaderAware) {
((BeanClassLoaderAware) bean).setBeanClassLoader(classLoader);
}
if (bean instanceof BeanFactoryAware) {
((BeanFactoryAware) bean).setBeanFactory(this);
}
호출 순서는 BeanNameAware → BeanClassLoaderAware → BeanFactoryAware입니다.
그러면 ApplicationContextAware는?
자주 쓰는 ApplicationContextAware, EnvironmentAware, ResourceLoaderAware, MessageSourceAware는 위 목록에 없습니다. 이들은 별도의 BeanPostProcessor인 ApplicationContextAwareProcessor가 다음 단계인 postProcessBeforeInitialization에서 처리합니다.
이 구분이 중요한 이유는 호출 시점이 미묘하게 다르기 때문입니다. 같은 "Aware"라는 이름을 달고 있지만 컨테이너 안에서는 두 개의 다른 그룹으로 처리됩니다.
5. BeanPostProcessor — 모든 빈에 끼어드는 훅
인터페이스
Bean 생명주기에서 가장 강력한 확장 포인트입니다.
public interface BeanPostProcessor {
default Object postProcessBeforeInitialization(Object bean, String beanName) { return bean; }
default Object postProcessAfterInitialization(Object bean, String beanName) { return bean; }
}
두 메서드 모두 빈을 받아 빈을 반환합니다. 다른 객체를 돌려주면 컨테이너는 그 새 객체를 빈으로 등록합니다. AOP 프록시가 만들어지는 자리도 바로 여기입니다. 원본을 받아 프록시를 돌려주면 컨테이너에는 프록시가 등록됩니다.
before vs after
호출 위치가 다음과 같습니다.
postProcessBeforeInitialization (모든 BPP에 대해)
invokeInitMethods (afterPropertiesSet → init-method)
postProcessAfterInitialization (모든 BPP에 대해)
기억해야 할 한 가지: before는 초기화 콜백 직전에, after는 초기화 콜백 직후에 호출됩니다. AOP 프록시는 after 단계에서 만들어집니다. 즉 초기화 콜백 안에서 보는 this는 아직 원본 객체이고, 프록시로 감싸진 빈은 그 다음 단계에 비로소 만들어집니다.
컨테이너가 자동 등록하는 BPP들
직접 만들지 않아도 ApplicationContext는 다음과 같은 내장 BPP를 등록합니다.
| BeanPostProcessor | 역할 |
|---|---|
ApplicationContextAwareProcessor |
ApplicationContextAware, EnvironmentAware 등 처리 |
CommonAnnotationBeanPostProcessor |
@PostConstruct, @PreDestroy, @Resource 처리 |
AutowiredAnnotationBeanPostProcessor |
@Autowired, @Value 처리 |
AbstractAutoProxyCreator 계열 |
AOP 프록시 생성 |
ScheduledAnnotationBeanPostProcessor |
@Scheduled 등록 |
AsyncAnnotationBeanPostProcessor |
@Async 프록시 생성 |
이 표에서 한 가지 사실이 드러납니다. @PostConstruct는 어노테이션 자체가 마법인 게 아니라, 그 어노테이션을 찾아서 메서드를 호출하는 BPP가 따로 있다는 것입니다. Spring 안에서 @PostConstruct는 CommonAnnotationBeanPostProcessor.postProcessBeforeInitialization 안에서 호출됩니다. 즉 엄밀히 말하면 BPP의 before 단계 안에 들어 있습니다.
직접 만들기
실전에서 자주 쓰는 패턴을 보겠습니다.
@Component
public class TimingBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean.getClass().isAnnotationPresent(MeasureTime.class)) {
return Proxy.newProxyInstance(
bean.getClass().getClassLoader(),
bean.getClass().getInterfaces(),
(proxy, method, args) -> {
long start = System.nanoTime();
try {
return method.invoke(bean, args);
} finally {
log.info("{}.{} took {} ns",
beanName, method.getName(), System.nanoTime() - start);
}
});
}
return bean;
}
}
@MeasureTime이 붙은 빈만 골라서 호출 시간을 측정하는 프록시로 감쌉니다. AOP에 비하면 단순하지만 동작 원리는 동일합니다.
순서 — 그리고 잘 알려진 함정
여러 BPP가 있을 때 순서는 다음과 같이 결정됩니다.
PriorityOrdered인터페이스를 구현한 BPP가 가장 먼저- 다음으로
Ordered인터페이스 구현체 - 마지막으로 일반 BPP
여기서 한 가지 함정이 있습니다. @Order 어노테이션은 BPP에는 적용되지 않습니다. 공식 문서가 명시하듯, BPP의 우선순위를 제어하려면 Ordered 또는 PriorityOrdered를 직접 구현해야 합니다. 또한 BeanFactory에 프로그래밍 방식으로 등록한 BPP는 등록 순서대로 적용되며, Ordered 같은 정렬 의미가 무시됩니다.
또 하나의 함정. BPP는 자기 자신에게는 적용되지 않습니다. 따라서 BPP 빈 안에서 @Autowired로 다른 빈을 주입받는 것이 늦게 동작하거나 아예 동작하지 않을 수 있습니다. 다른 BPP들이 BPP 자기 자신을 처리하지 못하는 시점에 BPP가 먼저 만들어지기 때문입니다. 만약 BPP에서 다른 빈이 정말 필요하다면 BeanFactory로부터 lazy하게 가져오는 패턴을 씁니다.
6. 초기화 콜백 3총사 — @PostConstruct, InitializingBean, init-method
호출 순서
같은 빈에 세 가지 초기화 콜백을 모두 걸어두면 다음 순서로 호출됩니다.
@PostConstruct메서드 (BPP의 before 단계 안에서)InitializingBean.afterPropertiesSet()@Bean(initMethod = "init")또는init-method속성
@Component
public class LifecycleDemo implements InitializingBean {
@PostConstruct
public void postConstruct() {
System.out.println("1. @PostConstruct");
}
@Override
public void afterPropertiesSet() {
System.out.println("2. afterPropertiesSet");
}
public void init() {
System.out.println("3. init-method");
}
}
@Configuration
class Config {
@Bean(initMethod = "init")
LifecycleDemo demo() { return new LifecycleDemo(); }
}
출력은 정확히 1, 2, 3 순서로 나옵니다.
왜 세 개나 있는가
세 가지 메커니즘이 공존하는 이유는 역사적입니다.
InitializingBean: 가장 오래된 방식. 인터페이스 구현이 필요하므로 빈이 Spring에 결합됩니다.init-method: XML 설정 시대의 산물. POJO를 그대로 두고 외부에서 초기화 메서드를 지정할 수 있어 결합도가 낮습니다.@PostConstruct: JSR-250 표준 어노테이션. Spring이 아닌 다른 컨테이너로 옮겨도 같은 코드가 동작합니다.
어떤 것을 쓸 것인가
특별한 이유가 없으면 @PostConstruct 하나만 사용합니다. 표준 어노테이션이고 가장 짧으며 의도가 명확합니다. InitializingBean은 Spring API를 직접 import하므로 권장하지 않습니다. init-method는 외부 라이브러리의 빈을 등록할 때처럼 어노테이션을 추가할 수 없는 상황에서만 씁니다.
초기화 안에서의 예외
초기화 콜백 안에서 예외가 발생하면 컨테이너는 BeanCreationException으로 감싸서 던집니다. 이 예외는 ApplicationContext가 refresh를 완료하지 못하게 만들고, 결과적으로 애플리케이션이 시작에 실패합니다. 이는 의도된 동작입니다. 잘못된 상태의 빈을 절반만 만들어 두는 것보다, 시작 자체를 실패시키는 편이 안전하다는 철학입니다.
7. AOP 프록시는 언제 만들어지는가
AbstractAutoProxyCreator도 BPP다
Spring AOP의 핵심인 AbstractAutoProxyCreator(과 그 자식인 AnnotationAwareAspectJAutoProxyCreator)도 결국 BeanPostProcessor입니다. 이 클래스의 postProcessAfterInitialization 안에서 wrapIfNecessary 메서드를 호출해, 매칭되는 어드바이스가 있는 빈을 프록시로 감쌉니다.
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean != null) {
Object cacheKey = getCacheKey(bean.getClass(), beanName);
if (this.earlyProxyReferences.remove(cacheKey) != bean) {
return wrapIfNecessary(bean, beanName, cacheKey);
}
}
return bean;
}
wrapIfNecessary가 매칭되는 어드바이저(advisor)를 찾아내면, 컨테이너에는 원본이 아니라 프록시 객체가 등록됩니다.
그래서 @PostConstruct 안의 this는 누구인가
여기서 자주 부딪히는 함정이 명확해집니다. @PostConstruct는 BPP before 단계 안에서 실행됩니다. 프록시는 BPP after 단계에서 만들어집니다. 즉 @PostConstruct 메서드가 실행되는 시점에 this는 아직 프록시로 감싸지지 않은 원본 객체입니다.
@Service
public class ReportService {
@PostConstruct
public void warmup() {
generate(); // 같은 클래스의 @Async 메서드 호출
}
@Async
public void generate() {
// 비동기로 실행되어야 하지만 동기로 실행됨
}
}
@Async도 @Transactional도 @Cacheable도 모두 프록시에 의해 동작합니다. 그런데 @PostConstruct 안의 this는 프록시가 아니므로 자기 자신의 메서드를 호출해도 프록시를 거치지 않고, 결국 어드바이스가 적용되지 않습니다. @Async라면 동기로 동작하고, @Transactional이라면 트랜잭션이 시작되지 않으며, @Cacheable이라면 캐시가 무시됩니다.
이 함정을 피하는 방법은 두 가지입니다.
- 자기 자신을 빈으로 다시 주입받아서 그쪽을 호출 (자기 주입은 컨테이너가 프록시를 넣어줌)
- 초기화는
ApplicationListener<ApplicationReadyEvent>처럼 컨테이너 기동이 완료된 뒤 실행되는 이벤트 핸들러로 옮김
근본 원인은 @PostConstruct가 프록시 생성보다 먼저 호출된다는 단 한 가지 사실입니다. 이걸 알면 함정이 함정이 아니게 됩니다.
8. BeanFactoryPostProcessor — 더 일찍 끼어드는 훅
BeanDefinition 단계의 훅
BeanFactoryPostProcessor(BFPP)는 BPP와 이름이 비슷하지만 일이 전혀 다릅니다. 빈 인스턴스가 아니라 빈의 정의(BeanDefinition) 를 가공합니다. 호출 시점도 BPP보다 한참 앞입니다.
public interface BeanFactoryPostProcessor {
void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory);
}
이 시점에는 빈 정의는 등록되어 있지만 어떤 빈도 인스턴스화되지 않은 상태입니다. BFPP는 빈 정의를 추가하거나, 프로퍼티 값을 바꾸거나, 새로운 빈 정의를 등록할 수 있습니다.
대표적인 BFPP
ConfigurationClassPostProcessor:@Configuration클래스의@Bean메서드들을BeanDefinition으로 변환합니다. Spring의 자바 기반 설정 전체가 이 BFPP에 의존합니다.PropertySourcesPlaceholderConfigurer:${...}플레이스홀더를 실제 값으로 치환합니다.EventListenerMethodProcessor:@EventListener메서드를 찾아 등록합니다.
BPP와 한눈에 비교
| 구분 | BeanFactoryPostProcessor | BeanPostProcessor |
|---|---|---|
| 다루는 대상 | BeanDefinition (메타데이터) |
빈 인스턴스 |
| 호출 시점 | 모든 빈 인스턴스화 전 | 각 빈 초기화 앞뒤 |
| 호출 횟수 | 컨테이너당 1회 | 빈마다 1회씩 |
| 대표 사용처 | 빈 정의 추가/수정, 프로퍼티 치환 | AOP 프록시, @PostConstruct 처리 |
이름은 비슷하지만 책임이 완전히 다릅니다. "정의를 손보는 것"과 "인스턴스를 손보는 것"은 다른 시점에 일어나는 다른 작업입니다.
9. 소멸 단계 — graceful shutdown의 진짜 의미
ApplicationContext.close()(또는 JVM 종료 훅)가 호출되면 컨테이너는 등록된 빈들을 역순으로 정리합니다. 정리는 다음 순서로 이뤄집니다.
DestructionAwareBeanPostProcessor.postProcessBeforeDestruction호출 —@PreDestroy메서드가 여기서 실행됩니다 (CommonAnnotationBeanPostProcessor에 의해).- 빈이
DisposableBean을 구현했으면destroy()호출. - 빈 정의에
destroy-method(또는@Bean(destroyMethod = "..."))가 지정되어 있으면 그 메서드 호출.
@Component
public class ConnectionPool implements DisposableBean {
@PreDestroy
public void preDestroy() {
System.out.println("1. @PreDestroy");
}
@Override
public void destroy() {
System.out.println("2. DisposableBean.destroy");
}
}
@Configuration
class Config {
@Bean(destroyMethod = "shutdown")
ConnectionPool pool() { return new ConnectionPool(); }
// ConnectionPool 안에 shutdown() 메서드가 있다면 마지막에 호출됩니다.
}
prototype 스코프의 함정
여기서 한 가지 중요한 사실이 있습니다. prototype 스코프 빈의 소멸 콜백은 호출되지 않습니다. 컨테이너는 prototype 빈의 인스턴스화와 초기화는 책임지지만, 그 이후의 라이프사이클은 호출자의 몫이라고 명시합니다. 즉 @PreDestroy도 DisposableBean도 호출되지 않습니다. 자원을 해제해야 한다면 호출자가 직접 해야 합니다.
10. Lifecycle / SmartLifecycle — 컨테이너 자체의 시작·종료 훅
Bean 생명주기와 다른 층위
지금까지 본 콜백들은 빈 자체의 라이프사이클 — 만들어지고 사라지는 시점 — 에 관한 것입니다. 그런데 실전에서 더 자주 필요한 것은 다음과 같은 시점입니다.
- 컨테이너 기동이 모두 끝난 뒤에 메시지 컨슈머를 시작하고 싶다.
- 컨테이너를 종료할 때 컨슈머가 처리 중인 메시지를 마무리할 시간을 주고 싶다.
이건 빈 초기화 단계가 아니라 컨테이너 전체가 준비된 시점과 컨테이너 전체가 종료되는 시점의 문제입니다. 이를 다루는 것이 Lifecycle과 SmartLifecycle입니다.
Lifecycle 인터페이스
public interface Lifecycle {
void start();
void stop();
boolean isRunning();
}
ApplicationContext가 start()를 호출하면 이 인터페이스를 구현한 빈들의 start()가 호출됩니다. 단 Lifecycle만 구현한 빈은 자동으로 시작되지 않고, 누군가가 명시적으로 context.start()를 불러야 합니다.
SmartLifecycle
대부분의 실전 코드가 사용하는 것은 Lifecycle을 확장한 SmartLifecycle입니다.
public interface SmartLifecycle extends Lifecycle, Phased {
default boolean isAutoStartup() { return true; }
default void stop(Runnable callback) { stop(); callback.run(); }
default int getPhase() { return Integer.MAX_VALUE; }
}
세 가지가 추가됐습니다.
isAutoStartup():true면 컨테이너refresh직후 자동으로start()가 호출됩니다. 기본값이true이므로 보통 그대로 둡니다.stop(Runnable callback): 비동기 종료를 위한 콜백 변형.LifecycleProcessor는SmartLifecycle구현체에 대해 항상 이 메서드를 호출합니다. 호출자는 콜백이 실행될 때까지 기다리므로, 종료 작업이 완료된 시점에callback.run()을 호출해야 합니다.getPhase(): 같은 컨테이너 안에 여러SmartLifecycle빈이 있을 때 시작·종료 순서를 결정합니다.
getPhase()로 순서 결정하기
규칙은 단순합니다.
- 시작 시: 낮은 phase 값을 가진 빈이 먼저 시작됩니다.
- 종료 시: 높은 phase 값을 가진 빈이 먼저 종료됩니다.
직관적입니다. 마지막에 켜진 것을 먼저 끕니다. 예를 들어 메시지 큐 컨슈머의 phase를 100, 데이터베이스 커넥션 풀의 phase를 0으로 두면, 시작은 DB → 컨슈머 순으로, 종료는 컨슈머 → DB 순으로 일어납니다. 컨슈머가 처리 중인 메시지가 DB에 접근하는 동안 DB가 살아 있다는 것이 보장됩니다.
graceful shutdown의 실제 동작
Spring Boot의 server.shutdown=graceful 설정도 결국 이 메커니즘 위에서 동작합니다. 웹 서버를 감싼 컴포넌트가 SmartLifecycle을 구현하고, 종료 콜백을 받으면 새 요청 수락을 중단한 뒤 처리 중인 요청이 끝날 때까지 기다리고, callback.run()으로 컨테이너에 "이제 다음 빈을 종료해도 된다"고 알립니다. spring.lifecycle.timeout-per-shutdown-phase 설정은 이 콜백 대기의 최대 시간을 정의합니다.
따라서 graceful shutdown은 단순히 빈의 @PreDestroy만으로는 보장되지 않습니다. 진짜 graceful shutdown이 필요하다면 SmartLifecycle로 phase와 콜백을 설계해야 합니다.
11. 실전에서 자주 부딪히는 다섯 가지 함정
지금까지 본 흐름이 어떻게 함정으로 이어지는지 짧게 정리합니다.
함정 1. @PostConstruct 안의 self-invocation
이미 7장에서 다뤘습니다. @PostConstruct는 프록시 생성 전에 호출되므로 this로 자기 자신의 @Async/@Transactional/@Cacheable 메서드를 호출하면 어드바이스가 적용되지 않습니다.
함정 2. BeanPostProcessor 안의 @Autowired가 동작하지 않는다
BPP는 다른 BPP(AutowiredAnnotationBeanPostProcessor 포함)보다 먼저 만들어지는 경우가 많습니다. 결과적으로 BPP 자신은 @Autowired로 주입을 받지 못하거나, 일부 어드바이스가 적용되지 않은 상태로 의존성이 들어옵니다. BPP에서 다른 빈이 필요하다면 BeanFactoryAware로 팩토리를 주입받아 lazy하게 꺼내는 패턴을 씁니다.
함정 3. prototype 빈의 @PreDestroy가 호출되지 않는다
9장에서 본 대로 컨테이너는 prototype 빈의 소멸 콜백을 호출하지 않습니다. 만약 자원을 들고 있는 prototype 빈이 필요하다면 try-finally로 호출자가 직접 정리하거나 try-with-resources에 들어갈 수 있도록 AutoCloseable을 구현합니다.
함정 4. 컨테이너 기동 직후 실행하고 싶은 작업
서비스가 기동된 직후 외부 시스템에 헬스체크 신호를 보내거나, 캐시를 워밍업하거나, 메시지 컨슈머를 시작해야 할 때가 있습니다. @PostConstruct는 이 용도로 부적절합니다. 그 시점에는 자기 자신만 초기화됐을 뿐 컨테이너 전체가 준비됐다는 보장이 없기 때문입니다. 정답은 ApplicationListener<ApplicationReadyEvent> 또는 SmartLifecycle.start()입니다.
함정 5. 종료 시 백그라운드 작업이 잘려나간다
ExecutorService를 빈으로 등록했다면 @PreDestroy에서 shutdown()을 호출하지 않으면 컨테이너 종료 시 작업이 그대로 잘릴 수 있습니다. @Bean(destroyMethod = "shutdown")을 사용하거나 SmartLifecycle로 명시적으로 종료 절차를 정의합니다. 조금 더 안전한 방법은 shutdown() 호출 후 awaitTermination(timeout, unit)로 잠깐 기다리는 것입니다.
다섯 가지 모두 패턴이 같습니다. "무엇이 언제 일어나는지"를 알면 함정이 함정이 아니게 됩니다.
12. 마치며
Spring Bean의 일생은 한 줄로 요약됩니다.
인스턴스화 → DI → Aware → BPP before → init 콜백 → BPP after → 사용 → @PreDestroy → DisposableBean.destroy → destroy-method.
이 흐름 위에 BeanFactoryPostProcessor라는 더 이른 훅과 SmartLifecycle이라는 더 큰 단위의 훅이 얹혀 있습니다. 이 셋을 구분해서 머릿속에 그릴 수 있다면 Spring 컨테이너에서 일어나는 거의 모든 시점 관련 문제를 추론할 수 있습니다.
@Async가 동작하지 않는 이유, @Transactional이 무시되는 이유, prototype 빈의 자원이 새는 이유, graceful shutdown이 graceful하지 않은 이유 — 모두 이 한 장의 다이어그램 위 어느 화살표에 있는지 짚으면 답이 나옵니다.
Spring을 잘 쓴다는 것은 결국 이 흐름을 머릿속에 가지고 있다는 뜻입니다.
참고자료
- Spring Framework Reference — Customizing the Nature of a Bean: https://docs.spring.io/spring-framework/reference/core/beans/factory-nature.html
- Spring Framework Reference — Container Extension Points: https://docs.spring.io/spring-framework/reference/core/beans/factory-extension.html
- Spring Framework Javadoc —
BeanPostProcessor: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/beans/factory/config/BeanPostProcessor.html - Spring Framework Javadoc —
BeanFactoryPostProcessor: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/beans/factory/config/BeanFactoryPostProcessor.html - Spring Framework Javadoc —
SmartLifecycle: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/context/SmartLifecycle.html - Spring Framework Javadoc —
AbstractAutowireCapableBeanFactory: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/beans/factory/support/AbstractAutowireCapableBeanFactory.html - Spring Framework Source —
AbstractAutoProxyCreator: https://github.com/spring-projects/spring-framework/blob/main/spring-aop/src/main/java/org/springframework/aop/framework/autoproxy/AbstractAutoProxyCreator.java - JSR-250 Common Annotations: https://jcp.org/en/jsr/detail?id=250

