Spring Bean 라이프사이클 완전 정복
생성부터 소멸까지 — Bean이 거치는 모든 단계를 코드로 확인하기
들어가며
Spring을 사용하면 대부분의 객체 생명주기를 컨테이너가 관리한다. new로 직접 생성하지 않고 @Component나 @Bean으로 등록하면, Spring이 알아서 객체를 만들고, 의존성을 주입하고, 초기화하고, 소멸시킨다. 이 과정의 기반이 되는 JVM 클래스 로딩 메커니즘을 이해하면 Spring이 어떻게 바이트코드 레벨에서 빈을 관리하는지 더 깊이 파악할 수 있다.
하지만 “정확히 어떤 순서로?” 라는 질문에 답하려면 Bean 라이프사이클을 제대로 이해해야 한다. 특히 AOP 프록시 생성 시점이나 초기화 로직을 넣을 위치를 결정할 때 이 지식이 직접적으로 필요하다.
Bean 라이프사이클 전체 흐름
Spring Bean이 생성되고 소멸되기까지의 전체 흐름은 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
1. 빈 인스턴스화 (Instantiation)
↓
2. 의존성 주입 (Dependency Injection)
↓
3. BeanNameAware.setBeanName()
↓
4. BeanFactoryAware.setBeanFactory()
↓
5. ApplicationContextAware.setApplicationContext()
↓
6. BeanPostProcessor.postProcessBeforeInitialization()
↓
7. @PostConstruct
↓
8. InitializingBean.afterPropertiesSet()
↓
9. @Bean(initMethod = "...")
↓
10. BeanPostProcessor.postProcessAfterInitialization()
← AOP 프록시가 여기서 생성됨
↓
===== 빈 사용 =====
↓
11. @PreDestroy
↓
12. DisposableBean.destroy()
↓
13. @Bean(destroyMethod = "...")
단계가 많아 보이지만, 실무에서 주로 사용하는 것은 @PostConstruct / @PreDestroy, InitializingBean / DisposableBean, BeanPostProcessor 세 가지 그룹이다.
초기화 콜백: 세 가지 방법
1. @PostConstruct / @PreDestroy (권장)
Jakarta EE 표준 어노테이션으로, 가장 간결하고 가독성이 좋다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Component
@Slf4j
public class CacheWarmer {
private final ProductRepository productRepository;
private Map<Long, Product> cache;
@Autowired
public CacheWarmer(ProductRepository productRepository) {
this.productRepository = productRepository;
// 이 시점에서는 아직 DI가 완료되지 않았을 수 있음
log.info("[1] 생성자 호출 — productRepository: {}", productRepository);
}
@PostConstruct
public void warmUp() {
// DI 완료 후 호출 — 안전하게 의존 객체를 사용할 수 있다
log.info("[2] @PostConstruct — 캐시 워밍업 시작");
cache = productRepository.findAll().stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));
log.info("[2] @PostConstruct — {}개 상품 캐시 완료", cache.size());
}
@PreDestroy
public void clearCache() {
log.info("[3] @PreDestroy — 캐시 정리");
cache.clear();
}
}
왜 생성자가 아닌 @PostConstruct에서 초기화하는가? 생성자 호출 시점에는 필드 주입(@Autowired 필드)이 아직 완료되지 않았을 수 있다. @PostConstruct는 모든 의존성 주입이 끝난 후 호출되므로 안전하다. 위 예시처럼 생성자 주입을 사용하면 생성자에서도 의존 객체에 접근할 수 있지만, 다른 빈의 @PostConstruct가 아직 실행되지 않았을 수 있으므로 초기화 로직은 @PostConstruct에 두는 것이 원칙이다.
2. InitializingBean / DisposableBean
Spring 전용 인터페이스를 구현하는 방식이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Component
@Slf4j
public class DatabaseHealthChecker implements InitializingBean, DisposableBean {
private final DataSource dataSource;
private ScheduledExecutorService scheduler;
public DatabaseHealthChecker(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void afterPropertiesSet() throws Exception {
log.info("InitializingBean.afterPropertiesSet() — DB 헬스체크 스케줄러 시작");
scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(this::checkHealth, 0, 30, TimeUnit.SECONDS);
}
@Override
public void destroy() throws Exception {
log.info("DisposableBean.destroy() — 스케줄러 종료");
scheduler.shutdown();
}
private void checkHealth() {
try (Connection conn = dataSource.getConnection()) {
conn.isValid(3);
} catch (SQLException e) {
log.error("DB 헬스체크 실패", e);
}
}
}
이 방식은 Spring 프레임워크에 직접 의존하게 되므로, 프레임워크 독립적인 코드가 필요할 때는 @PostConstruct/@PreDestroy를 사용하는 것이 좋다.
3. @Bean(initMethod, destroyMethod)
외부 라이브러리 클래스처럼 소스를 수정할 수 없는 경우에 유용하다.
1
2
3
4
5
6
7
8
@Configuration
public class AppConfig {
@Bean(initMethod = "init", destroyMethod = "close")
public ExternalConnectionPool connectionPool() {
return new ExternalConnectionPool("jdbc:mysql://localhost/mydb");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// 외부 라이브러리 클래스 — 어노테이션을 붙일 수 없음
public class ExternalConnectionPool {
public ExternalConnectionPool(String url) { /* ... */ }
public void init() {
// 커넥션 풀 초기화
}
public void close() {
// 커넥션 풀 정리
}
}
실행 순서 확인: 전체 라이프사이클 로그
세 가지 방식을 모두 적용한 빈을 만들어 실행 순서를 확인해 보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Component
@Slf4j
public class LifecycleDemo implements InitializingBean, DisposableBean,
BeanNameAware, ApplicationContextAware {
private String beanName;
public LifecycleDemo() {
log.info("[1] 생성자 호출");
}
@Override
public void setBeanName(String name) {
this.beanName = name;
log.info("[2] BeanNameAware.setBeanName() — {}", name);
}
@Override
public void setApplicationContext(ApplicationContext ctx) {
log.info("[3] ApplicationContextAware.setApplicationContext()");
}
@PostConstruct
public void postConstruct() {
log.info("[4] @PostConstruct");
}
@Override
public void afterPropertiesSet() {
log.info("[5] InitializingBean.afterPropertiesSet()");
}
@PreDestroy
public void preDestroy() {
log.info("[6] @PreDestroy");
}
@Override
public void destroy() {
log.info("[7] DisposableBean.destroy()");
}
}
실행 결과:
1
2
3
4
5
6
7
8
[1] 생성자 호출
[2] BeanNameAware.setBeanName() — lifecycleDemo
[3] ApplicationContextAware.setApplicationContext()
[4] @PostConstruct
[5] InitializingBean.afterPropertiesSet()
===== 애플리케이션 실행 =====
[6] @PreDestroy
[7] DisposableBean.destroy()
@PostConstruct가 InitializingBean.afterPropertiesSet()보다 먼저 호출된다는 점을 기억하자. 이는 @PostConstruct가 BeanPostProcessor의 postProcessBeforeInitialization 단계에서 처리되기 때문이다.
BeanPostProcessor 활용
BeanPostProcessor는 모든 빈의 초기화 전후에 개입할 수 있는 강력한 확장 포인트다. Spring 내부적으로도 AOP 프록시 생성, @Autowired 처리, @PostConstruct 처리 등이 모두 BeanPostProcessor를 통해 이루어진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
@Slf4j
public class CustomBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName)
throws BeansException {
if (bean.getClass().isAnnotationPresent(MonitoredComponent.class)) {
log.info("[BPP Before] {} — 모니터링 대상 빈 감지", beanName);
}
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName)
throws BeansException {
if (bean.getClass().isAnnotationPresent(MonitoredComponent.class)) {
log.info("[BPP After] {} — 초기화 완료, 모니터링 등록", beanName);
MonitoringRegistry.register(beanName, bean);
}
return bean;
}
}
1
2
3
4
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface MonitoredComponent {
}
1
2
3
4
5
@Component
@MonitoredComponent
public class PaymentGateway {
// 이 빈이 생성될 때 CustomBeanPostProcessor가 개입한다
}
AOP 프록시와 BeanPostProcessor
Spring AOP 글에서 설명했듯이, AOP 프록시는 BeanPostProcessor의 postProcessAfterInitialization 단계에서 생성된다. 즉, @PostConstruct가 실행되는 시점에는 아직 프록시가 아닌 원본 객체다.
1
2
3
4
5
@PostConstruct → 원본 객체에서 실행
↓
postProcessAfterInit → 여기서 프록시로 교체
↓
빈 등록 → 프록시 객체가 빈으로 등록됨
이 때문에 @PostConstruct에서 this를 출력하면 프록시가 아닌 원본 클래스명이 나온다.
스코프별 라이프사이클 차이
| 스코프 | 생성 시점 | 소멸 시점 |
|---|---|---|
| singleton (기본) | 컨테이너 시작 시 | 컨테이너 종료 시 |
| prototype | 요청할 때마다 | 컨테이너가 관리하지 않음 |
| request | HTTP 요청 시 | HTTP 응답 완료 시 |
| session | 세션 생성 시 | 세션 종료 시 |
prototype 스코프 주의: Spring은 prototype 빈의 소멸을 관리하지 않는다. @PreDestroy가 호출되지 않으므로, 리소스 정리가 필요하다면 직접 처리해야 한다.
1
2
3
4
5
6
7
8
9
@Component
@Scope("prototype")
public class PrototypeBean {
@PreDestroy
public void cleanup() {
// 이 메서드는 호출되지 않는다!
}
}
실전 주의사항
-
생성자에서 무거운 작업을 하지 마라 — 생성자는 빈 인스턴스화 단계이므로, 아직 다른 빈이 초기화되지 않았을 수 있다. 무거운 초기화는
@PostConstruct에서 수행하자. -
@PostConstruct에서 예외가 발생하면 애플리케이션이 기동되지 않는다 — 초기화 실패 시 빈 등록이 취소되고, 의존하는 다른 빈도 연쇄적으로 실패한다. 외부 시스템 연결 같은 불안정한 작업은
@PostConstruct대신 별도의 헬스체크 메커니즘을 고려하자. -
순환 참조와 라이프사이클 — A → B → A 순환 참조가 있으면 Spring은 아직 완전히 초기화되지 않은 빈의 참조를 주입할 수 있다. 생성자 주입을 사용하면 순환 참조를 컴파일 타임에 감지할 수 있다.
-
@Lazy와 초기화 시점 —
@Lazy가 붙은 빈은 처음 사용될 때까지 초기화가 지연된다. 애플리케이션 기동 시간을 줄이는 데 유용하지만, 런타임에 초기화 에러가 발생할 수 있다는 단점이 있다.
마무리
Spring Bean 라이프사이클의 핵심은 “의존성 주입 완료 후 초기화” 라는 원칙이다. 생성자는 객체 생성만, 비즈니스 초기화 로직은 @PostConstruct에서 처리하는 패턴을 기본으로 삼자. BeanPostProcessor는 프레임워크 확장이나 커스텀 어노테이션 처리에 강력한 도구이며, AOP 프록시가 여기서 생성된다는 점은 Spring 내부를 이해하는 데 중요한 열쇠다.