Backend Development/Spring

Spring - 02. container bean lifecycle

Bean이 생성되고 사라지기까지 — 컨테이너와 생명주기의 모든 단계

@PostConstruct 메서드 안에서 null을 역참조해 NullPointerException이 났다. 의존성은 분명 @Autowired로 주입해뒀는데. 원인은 — 그 의존 bean의 @PostConstruct가 아직 실행되지 않았기 때문이다. 생성자에서 같은 의존성을 호출했다면 더 일찍, 더 기이하게 터졌을 것이다.

이런 문제는 생명주기(lifecycle) 없이는 풀 수 없다. bean이 인스턴스화에서 소멸까지 거치는 단계 — 생성자 → 의존성 주입 → @PostConstructBeanPostProcessor 후처리 → 사용 → @PreDestroy — 의 어느 시점에 무엇이 보장되고 무엇이 보장되지 않는지를 알아야, "왜 여기서 NPE가 나는가"에 답할 수 있다. 이 글은 그 단계를 순서대로 따라가며, 각 단계가 왜 존재하고 어디서 실패하는지를 묻는다. (Spring Framework Reference - Lifecycle Callbacks)

ApplicationContext는 BeanFactory를 상속한 확장이다

Spring Boot 애플리케이션을 시작하면 로그 한가운데 ApplicationContext가 찍힌다. BeanFactory는? 보이지 않는다. 그렇다고 사라진 건 아니다 — ApplicationContext 안에 숨어 있다.

여기서 핵심을 놓치면 안 된다. ApplicationContextBeanFactory상속(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)");
    }
}

실행 순서: 생성자 → 의존성 주입 → @PostConstructafterPropertiesSet() → (사용) → @PreDestroydestroy(). (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)

해결 — 설계 재검토

순환 의존성은 설계 문제의 신호다. 해결 방법:

  1. 중간 객체 추출 — 공통 로직을 세 번째 클래스로 분리
  2. 인터페이스 도입 — 직접 참조 대신 이벤트/콜백 패턴
  3. @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 — 단계적 시작/종료

SmartLifecyclestart()/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 콜백 → @PostConstructafterPropertiesSetBeanPostProcessor 후처리 → 사용 → @PreDestroydestroy().
  • @PostConstruct에서 초기화 로직을 수행한다. 생성자에서는 의존성 주입이 완료되지 않았을 수 있으므로, 의존 객체를 안전하게 사용하려면 @PostConstruct를 쓴다.
  • 순환 의존성은 설계 문제다. 생성자 주입에서 즉시 감지되며, 세터 주입에서는 Spring의 3단계 캐시로 해결할 수 있지만 Spring Boot 2.6+부터는 기본적으로 금지된다.
  • BeanPostProcessor가 AOP의 기반이다. postProcessAfterInitialization에서 프록시 객체를 반환하여 부가 기능을 끼워넣는다.

생각해 볼 문제

  1. ApplicationContext가 bean을 eager로 초기화하는 이유는 무엇인가? lazy의 단점은?
  2. @PostConstruct 메서드에서 다른 bean을 ApplicationContext.getBean()으로 가져오면 어떤 일이 일어나는가?
  3. BeanPostProcessor가 원본 bean 대신 완전히 다른 객체를 반환하면 어떻게 되는가?
  4. initMethod(@Bean(initMethod="...")), @PostConstruct, InitializingBean의 실행 순서는?
  5. prototype 스코프 bean의 생명주기는 singleton과 어떻게 다른가? @PreDestroy가 호출되는가?

참고

'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