Spring - 02. container bean lifecycle
Bean이 생성되고 사라지기까지 — 컨테이너와 생명주기의 모든 단계
@PostConstruct 메서드 안에서 null을 역참조해 NullPointerException이 났다. 의존성은 분명 @Autowired로 주입해뒀는데. 원인은 — 그 의존 bean의 @PostConstruct가 아직 실행되지 않았기 때문이다. 생성자에서 같은 의존성을 호출했다면 더 일찍, 더 기이하게 터졌을 것이다.
이런 문제는 생명주기(lifecycle) 없이는 풀 수 없다. bean이 인스턴스화에서 소멸까지 거치는 단계 — 생성자 → 의존성 주입 → @PostConstruct → BeanPostProcessor 후처리 → 사용 → @PreDestroy — 의 어느 시점에 무엇이 보장되고 무엇이 보장되지 않는지를 알아야, "왜 여기서 NPE가 나는가"에 답할 수 있다. 이 글은 그 단계를 순서대로 따라가며, 각 단계가 왜 존재하고 어디서 실패하는지를 묻는다. (Spring Framework Reference - Lifecycle Callbacks)
ApplicationContext는 BeanFactory를 상속한 확장이다
Spring Boot 애플리케이션을 시작하면 로그 한가운데 ApplicationContext가 찍힌다. BeanFactory는? 보이지 않는다. 그렇다고 사라진 건 아니다 — ApplicationContext 안에 숨어 있다.
여기서 핵심을 놓치면 안 된다. ApplicationContext는 BeanFactory를 상속(extends)하는 하위 타입이다. 둘이 경쟁하는 두 컨테이너가 아니라, BeanFactory 위에 엔터프라이즈 기능을 얹은 것이 ApplicationContext다. (Spring Framework 7 Reference - The ApplicationContext)
flowchart TD
BF["BeanFactory<br/>bean 생성·조회·주입 (최소 인터페이스)"]
AC["ApplicationContext<br/>extends BeanFactory<br/>+ 이벤트 · AOP 통합 · 국제화 · 리소스 로딩"]
AC -->|extends| BF
AC --> GAC["GenericApplicationContext<br/>(내부에 DefaultListableBeanFactory 보관)"]
GAC --> Boot["Spring Boot 자동 구성 컨텍스트"]
BeanFactory가 하는 일 — 그리고 안 하는 일
BeanFactory는 bean을 정의·생성·조회·주입하는 최소 인터페이스다. getBean(name)을 부르면 그제야 bean을 만들어 돌려준다(lazy). 여기까지가 전부다.
ApplicationContext는 이 최소 기능 위에 다음을 자동으로 얹는다. (Spring Framework 7 - ApplicationContext interface)
| 기능 | BeanFactory | ApplicationContext |
|---|---|---|
| bean 생성·조회·주입 | O | O (상속) |
BeanPostProcessor 자동 감지·등록 |
X (수동 등록) | O |
BeanFactoryPostProcessor 자동 감지·등록 |
X | O |
이벤트 발행(ApplicationEvent) |
X | O |
국제화(MessageSource) |
X | O |
리소스 로딩(ResourceLoader) |
X | O |
| AOP 통합 | X | O |
왜 이 표가 결정적인가. BeanPostProcessor 자동 등록이 안 되면 — @PostConstruct가 안 불리고, @Autowired 필드 주입이 제대로 안 돌아가고, AOP 프록시가 만들어지지 않는다. BeanFactory를 그냥 쓰면 Spring의 대부분 기능이 조용히 동작을 멈춘다. 흔히 "BeanFactory는 최소 컨테이너"라고 한마디로 넘기지만, 그 말의 실체는 "bean 창고는 있지만 그 위에 붙는 자동화는 다 빠져 있다"는 뜻이다.
왜 eager가 기본인가 — preInstantiateSingletons()
ApplicationContext는 시작 시 preInstantiateSingletons()를 호출해 non-lazy singleton bean을 전부 만든다. (Spring Framework 7 - ConfigurableListableBeanFactory.preInstantiateSingletons) BeanFactory는 이 호출을 하지 않는다 — 누군가 getBean()으로 찾을 때까지 bean은 만들어지지 않는다.
이 차이를 에러 메시지로 체감하자.
eager(ApplicationContext) — 배포 직후, 사용자가 한 명도 없을 때 터진다:
BeanCreationException: Error creating bean with name 'orderService'
defined in class path resource [app/OrderService.class]:
UnsatisfiedDependencyException: No qualifying bean of type 'PaymentGateway' available
...
APPLICATION FAILED TO START
lazy(BeanFactory 가정) — 운영 중, 누군가 처음 getBean("orderService")를 부른 그 순간에 터진다:
NoSuchBeanDefinitionException: No bean named 'orderService' available
at ...DefaultListableBeanFactory.getBean(DefaultListableBeanFactory.java:...)
첫째는 시작 로그 속에서 실패하고, 둘째는 운영 중 결제 버튼을 누른 그 순간 실패한다. ApplicationContext가 eager를 택한 이유는 fail fast — 설정 오류(잘못된 bean 이름, 순환 의존성, 누락된 프로퍼티)를 배포 직후에 드러내는 것이다. 대가는 시작 시간이다. Spring Boot가 3초 걸려 시작하는 비용의 상당 부분이 바로 이 "모든 singleton을 미리 만들고 검증하는" 과정이다.
bean 하나만 lazy로 빼고 싶으면
@Lazy를 붙인다. ApplicationContext 환경에서도 특정 bean은 시작 시 만들지 않고 첫 참조 때 만든다. 단, 그 bean에 의존하는 다른 bean이 시작 시 만들어지면 결국 같이 만들어지므로, 진짜 효과는 아무에게도 의존하지 않는 고립된 bean에만 나타난다. (Spring Framework 7 - @Lazy)
그럼 BeanFactory는 언제 마주치나
직접 new 해서 쓰는 일은 거의 없다. 하지만 간접으로는 계속 마주친다. ApplicationContext의 대표 구현체 GenericApplicationContext는 내부에 DefaultListableBeanFactory — BeanFactory의 표준 구현 — 를 하나 품고 있다. (Spring Framework 7 - GenericApplicationContext) 우리가 applicationContext.getBean(...)이라 부르는 호출은 결국 이 내부 BeanFactory로 넘어간다.
flowchart LR
AC["ApplicationContext<br/>(외부 인터페이스)"] -->|getBean 위임| BF["DefaultListableBeanFactory<br/>(내부, 실제 bean 보관소)"]
정리하면 ApplicationContext는 BeanFactory를 "대체"한 게 아니라 "감싸서" 쓴다. 타입 계층으로는 상속, 런타임 구조로는 합성(composition) — 둘 다 성립한다. 그래서 Spring 문서 곳곳에서 BeanFactory라는 이름이 계속 등장하는 것이다. 사라진 게 아니라, 더 큰 컨테이너 안에 흡수됐을 뿐.
Bean 생명주기 — 전체 단계
flowchart TD
I["1. 인스턴스화<br/>(생성자 호출)"] --> P["2. 프로퍼티 주입<br/>(setter / @Autowired 필드)"]
P --> BN["3. BeanNameAware<br/>setBeanName()"]
BN --> BF["4. BeanFactoryAware<br/>setBeanFactory()"]
BF --> AC2["5. ApplicationContextAware<br/>setApplicationContext()"]
AC2 --> PPB["6. BeanPostProcessor.postProcessBeforeInitialization<br/>(@PostConstruct 처리)"]
PPB --> IS["7. InitializingBean.afterPropertiesSet()<br/>또는 @Bean(initMethod=)"]
IS --> PPA["8. BeanPostProcessor.postProcessAfterInitialization<br/>(AOP 프록시 생성)"]
PPA --> READY["9. Bean 사용 준비 완료"]
READY --> DESTROY["10. 종료 시<br/>@PreDestroy / DisposableBean.destroy()"]
한 bean에 모든 콜백을 넣으면 순서가 드러난다
// Spring 7 / Java 25 — 생명주기 콜백을 모두 가진 bean
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.DisposableBean;
@Component
public class LifecycleDemo implements InitializingBean, DisposableBean {
public LifecycleDemo() {
System.out.println("1. 생성자 호출 (인스턴스화)");
}
@Autowired
public void setDependency(SomeDependency dep) {
System.out.println("2. 의존성 주입 (setter)");
}
@PostConstruct
public void init() {
System.out.println("3. @PostConstruct (초기화)");
}
@Override
public void afterPropertiesSet() {
System.out.println("4. afterPropertiesSet() (InitializingBean)");
}
@PreDestroy
public void cleanup() {
System.out.println("5. @PreDestroy (소멸 전)");
}
@Override
public void destroy() {
System.out.println("6. destroy() (DisposableBean)");
}
}
실행 순서: 생성자 → 의존성 주입 →
@PostConstruct→afterPropertiesSet()→ (사용) →@PreDestroy→destroy(). (Spring Framework Reference - Lifecycle)
@PostConstruct vs 생성자 — 핵심 차이
@Component
public class GoodService {
private final DatabaseClient client;
public GoodService(DatabaseClient client) {
this.client = client;
// 여기서 client를 사용하지 마라 — 아직 완전히 초기화되지 않았을 수 있음
}
@PostConstruct
public void init() {
// 여기서는 모든 의존성이 주입된 상태 — 안전하게 client 사용 가능
client.connect();
}
}
생성자에서는 자신의 의존성은 주입됐지만, 다른 bean의 @PostConstruct가 아직 실행되지 않았을 수 있다. @PostConstruct는 모든 의존성 주입이 완료된 후 실행되므로, 의존 객체를 안전하게 사용할 수 있다.
@PostConstruct와@PreDestroy는 Jakarta EE 9+에서jakarta.annotation패키지로 이동했다 (javax.annotation이 아님). Spring 7에서는import jakarta.annotation.PostConstruct를 사용한다.
BeanPostProcessor — bean 생성 과정에 끼어들기
BeanPostProcessor는 모든 bean의 초기화 전후에 가로채기(intercept)를 수행하는 확장 지점이다.
// Spring 7 / Java 25 — 커스텀 BeanPostProcessor
@Component
public class LoggingPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) {
System.out.println("[BPP] 초기화 전: " + beanName);
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
System.out.println("[BPP] 초기화 후: " + beanName);
return bean; // 다른 객체를 반환하면 원본 bean이 교체됨
}
}
AOP의 프록시 생성이 바로 이 단계에서 일어난다 —
postProcessAfterInitialization에서 원본 bean 대신 프록시 객체를 반환하여 부가 기능(로깅, 트랜잭션, 보안)을 끼워넣는다. (장 06-AOP 상세)
순환 의존성 — 왜 생성자 주입에서 실패하는가
// Spring 7 / Java 25 — 순환 의존성
@Component
class A {
private final B b;
public A(B b) { this.b = b; } // A는 B가 필요
}
@Component
class B {
private final A a;
public B(A a) { this.a = a; } // B는 A가 필요
}
// 애플리케이션 시작 실패: BeanCurrentlyInCreationException
생성자 주입에서 순환 참조가 있으면 Spring은 A를 만들려다 B가 필요하고, B를 만들려다 A가 필요한 상태에 빠진다 — 무한 루프를 감지하고 예외를 던진다. (Spring Reference - Circular Dependencies)
세터/필드 주입에서는 Spring이 3단계 캐싱(singletonFactories)으로 순환 의존성을 해결할 수 있었다. 단, Spring Boot 2.6+부터는 기본적으로
spring.main.allow-circular-references=false로 금지됐다. (Spring Boot Reference)
해결 — 설계 재검토
순환 의존성은 설계 문제의 신호다. 해결 방법:
- 중간 객체 추출 — 공통 로직을 세 번째 클래스로 분리
- 인터페이스 도입 — 직접 참조 대신 이벤트/콜백 패턴
@Lazy— 지연 초기화로 순환 회피 (근본 해결 아님)
bean 정의 상속 — @Bean 메서드의 프로퍼티 상속
// Spring 7 / Java 25 — @Bean으로 공통 설정 공유
@Configuration
public class DataSourceConfig {
@Bean
@Scope("prototype")
public DatabaseClient clientPrototype() {
return new DatabaseClient("jdbc:postgresql://localhost:5432/mydb");
}
}
실습 — bean 생명주기 관찰
// Spring 7 / Java 25 — LifecycleDemo.java (Spring 없이 콜백 순서 시뮬레이션)
import jakarta.annotation.*;
import java.lang.annotation.*;
import java.lang.reflect.*;
public class LifecycleDemo {
public static class MyBean {
public MyBean() { System.out.println("1. 생성자"); }
@PostConstruct public void init() { System.out.println("2. @PostConstruct"); }
@PreDestroy public void cleanup() { System.out.println("4. @PreDestroy"); }
}
public static void main(String[] args) throws Exception {
System.out.println("=== Bean 생성 ===");
var bean = new MyBean();
// @PostConstruct 수동 호출 (Spring이 자동으로 하는 일)
for (var m : bean.getClass().getMethods()) {
if (m.isAnnotationPresent(PostConstruct.class)) m.invoke(bean);
}
System.out.println("3. [Bean 사용 중]");
System.out.println("=== Bean 소멸 ===");
for (var m : bean.getClass().getMethods()) {
if (m.isAnnotationPresent(PreDestroy.class)) m.invoke(bean);
}
}
}
java LifecycleDemo.java
=== Bean 생성 ===
1. 생성자
2. @PostConstruct
3. [Bean 사용 중]
=== Bean 소멸 ===
4. @PreDestroy
확인할 것: 생성자 → @PostConstruct → 사용 → @PreDestroy 순서로 콜백이 실행된다. Spring 컨테이너가 이 과정을 자동으로 수행한다.
@DependsOn — 명시적 초기화 순서
bean 간 의존성이 @Autowired로 연결되지 않았지만, 초기화 순서가 중요한 경우가 있다. 예를 들어 database 초기화 bean이 다른 bean보다 먼저 실행되어야 할 때.
// Spring 7 / Java 25 — @DependsOn으로 순서 제어
@Component
@DependsOn("databaseInitializer")
public class CacheLoader {
@PostConstruct
public void load() {
// databaseInitializer의 @PostConstruct가 먼저 실행됨을 보장
}
}
@Component
public class DatabaseInitializer {
@PostConstruct
public void init() {
// DB 스키마 생성, 시드 데이터 삽입 등
}
}
@DependsOn은@Autowired의존성이 아닌 초기화 순서만 보장한다. 가능하면 명시적 의존성(생성자 주입)으로 설계하는 것이 더 명확하다 —@DependsOn은 필요한 경우에만 쓴다.
SmartLifecycle — 단계적 시작/종료
SmartLifecycle은 start()/stop() 콜백과 단계(phase)를 제공하여, 여러 컴포넌트의 시작/종료 순서를 제어한다:
// Spring 7 / Java 25 — SmartLifecycle
import org.springframework.context.SmartLifecycle;
@Component
public class KafkaConsumerLifecycle implements SmartLifecycle {
private volatile boolean running = false;
@Override
public void start() {
System.out.println("Kafka consumer 시작");
running = true;
}
@Override
public void stop() {
System.out.println("Kafka consumer 중단");
running = false;
}
@Override
public boolean isRunning() { return running; }
@Override
public int getPhase() { return 1; }
// phase가 낮은 것부터 시작, 높은 것부터 종료
// 기본 phase = Integer.MAX_VALUE (가장 마지막에 시작, 가장 먼저 종료)
}
SmartLifecycle은 비동기 리소스(메시지 컨슈머, 네트워크 서버, 스케줄러)의 시작/종료를 컨테이너 라이프사이클에 통합한다.phase로 종료 순서를 제어할 수 있다 — phase가 높은 컴포넌트부터 종료되어 의존성 역순으로 안전하게 해제된다.
요약 — 이 글의 결론
- ApplicationContext가 표준 컨테이너다. BeanFactory의 최소 기능보다 이벤트, AOP, 국제화를 제공한다. singleton bean을 시작 시 즉시 생성하여 설정 오류를 빠르게 발견한다.
- Bean 생명주기는 10단계다. 인스턴스화 → 의존성 주입 → Aware 콜백 →
@PostConstruct→afterPropertiesSet→BeanPostProcessor후처리 → 사용 →@PreDestroy→destroy(). @PostConstruct에서 초기화 로직을 수행한다. 생성자에서는 의존성 주입이 완료되지 않았을 수 있으므로, 의존 객체를 안전하게 사용하려면@PostConstruct를 쓴다.- 순환 의존성은 설계 문제다. 생성자 주입에서 즉시 감지되며, 세터 주입에서는 Spring의 3단계 캐시로 해결할 수 있지만 Spring Boot 2.6+부터는 기본적으로 금지된다.
- BeanPostProcessor가 AOP의 기반이다.
postProcessAfterInitialization에서 프록시 객체를 반환하여 부가 기능을 끼워넣는다.
생각해 볼 문제
ApplicationContext가 bean을 eager로 초기화하는 이유는 무엇인가? lazy의 단점은?@PostConstruct메서드에서 다른 bean을ApplicationContext.getBean()으로 가져오면 어떤 일이 일어나는가?BeanPostProcessor가 원본 bean 대신 완전히 다른 객체를 반환하면 어떻게 되는가?initMethod(@Bean(initMethod="...")),@PostConstruct,InitializingBean의 실행 순서는?- prototype 스코프 bean의 생명주기는 singleton과 어떻게 다른가?
@PreDestroy가 호출되는가?
참고
- Spring Framework 7 - Lifecycle Callbacks - 접근 2026-07-10
- Spring Framework 7 - ApplicationContext - 접근 2026-07-10
- Spring Framework 7 - Circular Dependencies - 접근 2026-07-10
- jakarta.annotation.PostConstruct API - 접근 2026-07-10
'Backend Development > Spring' 카테고리의 다른 글
| Spring - 06. aop (0) | 2026.07.12 |
|---|---|
| Spring - 05. bean scope profile (0) | 2026.07.12 |
| Spring - 04. configuration component-scan (0) | 2026.07.12 |
| Spring - 03. dependency-injection (0) | 2026.07.12 |
| Spring - 01. IoC DI (0) | 2026.07.12 |