자바에서는 new로 객체를 만들고 GC가 알아서 정리해주는데, 스프링은 왜 객체의 탄생부터 소멸까지 직접 관리하려 할까요?

개념 정의

Bean 라이프사이클 은 스프링 컨테이너가 빈을 생성하고, 의존성을 주입하고, 초기화하고, 사용하고, 최종적으로 소멸시키는 전체 과정을 말합니다. 개발자가 new를 직접 호출하지 않는 대신, 컨테이너가 이 모든 과정을 제어합니다.

왜 필요한가

직접 new로 객체를 만들면 다음 문제가 생깁니다.

  • **의존성 관리가 어렵습니다 **: A가 B를 필요로 하고, B가 C를 필요로 하면 생성 순서를 직접 관리해야 합니다
  • ** 리소스 정리가 누락됩니다 **: DB 커넥션이나 파일 핸들을 언제 닫을지 일일이 관리해야 합니다
  • ** 초기화 타이밍 제어가 안 됩니다 **: 모든 의존성이 주입된 후에야 의미 있는 초기화가 가능한데, 이걸 보장하기 어렵습니다

스프링은 이 모든 것을 컨테이너가 담당하므로, 개발자는 비즈니스 로직에 집중할 수 있습니다.

내부 동작

전체 라이프사이클 흐름

PLAINTEXT
1. 빈 정의 로드 (BeanDefinition)
2. 빈 인스턴스 생성 (Constructor)
3. 의존성 주입 (DI)
4. BeanPostProcessor.postProcessBeforeInitialization()
5. 초기화 콜백 (@PostConstruct → InitializingBean → @Bean(initMethod))
6. BeanPostProcessor.postProcessAfterInitialization()
7. 빈 사용
8. 소멸 콜백 (@PreDestroy → DisposableBean → @Bean(destroyMethod))

1단계: 빈 정의 로드

스프링은 @Component, @Bean, XML 등에서 빈 정의(BeanDefinition)를 수집합니다. 이 시점에서는 아직 객체가 생성되지 않았습니다. 어떤 클래스를 어떤 스코프로, 어떤 의존성과 함께 만들지 메타정보만 모아두는 단계입니다.

2단계: 인스턴스 생성

JAVA
// 스프링이 내부적으로 하는 일 (개념적)
Object bean = constructor.newInstance(args);

생성자를 호출해서 빈 인스턴스를 만듭니다. 생성자 주입을 사용하면 이 시점에 의존성도 함께 전달됩니다.

3단계: 의존성 주입

필드 주입(@Autowired)이나 세터 주입을 사용하는 경우, 인스턴스 생성 후 이 단계에서 의존성이 주입됩니다.

4~6단계: BeanPostProcessor와 초기화

이 부분이 라이프사이클에서 가장 핵심적인 구간입니다.

JAVA
public interface BeanPostProcessor {
    // 초기화 전에 호출
    default Object postProcessBeforeInitialization(Object bean, String beanName) {
        return bean;
    }

    // 초기화 후에 호출
    default Object postProcessAfterInitialization(Object bean, String beanName) {
        return bean;
    }
}

BeanPostProcessor 는 모든 빈의 생성 과정에 끼어들 수 있는 확장 포인트입니다. 대표적인 예시를 보겠습니다.

  • AutowiredAnnotationBeanPostProcessor: @Autowired 처리
  • CommonAnnotationBeanPostProcessor: @PostConstruct, @PreDestroy 처리
  • AOP 프록시 생성도 이 단계에서 일어납니다

초기화 콜백의 우선순위

초기화 콜백은 세 가지 방법이 있고, 호출 순서가 정해져 있습니다.

JAVA
@Component
public class MyService {

    // 1순위: JSR-250 표준 어노테이션
    @PostConstruct
    public void init() {
        System.out.println("1. @PostConstruct");
    }

    // 3순위: @Bean에서 지정한 메서드 (외부 라이브러리용)
    // @Bean(initMethod = "customInit")
    public void customInit() {
        System.out.println("3. initMethod");
    }
}

이어서 나머지 구현 부분입니다.

JAVA
// 2순위: 스프링 인터페이스
@Component
public class MyService2 implements InitializingBean {
    @Override
    public void afterPropertiesSet() {
        System.out.println("2. afterPropertiesSet");
    }
}

실무에서는 @PostConstruct를 가장 많이 씁니다. InitializingBean은 스프링에 의존하게 되고, initMethod는 외부 라이브러리 빈을 등록할 때 유용합니다.

소멸 콜백

소멸도 초기화와 대칭적인 구조입니다.

JAVA
@Component
public class MyService {

    @PreDestroy
    public void cleanup() {
        System.out.println("1. @PreDestroy");
        // 리소스 정리, 커넥션 반환 등
    }
}

호출 순서: @PreDestroyDisposableBean.destroy()@Bean(destroyMethod)

코드 예제

실제로 라이프사이클 전체를 확인해볼 수 있는 예제입니다.

JAVA
@Component
public class LifecycleDemo implements InitializingBean, DisposableBean {

    private final DependencyService dependency;

    // 2단계: 생성자 호출 (+ 의존성 주입)
    public LifecycleDemo(DependencyService dependency) {
        this.dependency = dependency;
        System.out.println("[생성] 생성자 호출");
    }

    // 5단계: 초기화 콜백 (1순위)
    @PostConstruct
    public void postConstruct() {
        System.out.println("[초기화] @PostConstruct");
    }

이어서 나머지 구현 부분입니다.

JAVA
    // 5단계: 초기화 콜백 (2순위)
    @Override
    public void afterPropertiesSet() {
        System.out.println("[초기화] afterPropertiesSet");
    }

    // 8단계: 소멸 콜백 (1순위)
    @PreDestroy
    public void preDestroy() {
        System.out.println("[소멸] @PreDestroy");
    }

    // 8단계: 소멸 콜백 (2순위)
    @Override
    public void destroy() {
        System.out.println("[소멸] DisposableBean.destroy");
    }
}

출력 결과:

PLAINTEXT
[생성] 생성자 호출
[초기화] @PostConstruct
[초기화] afterPropertiesSet
... (애플리케이션 실행) ...
[소멸] @PreDestroy
[소멸] DisposableBean.destroy

BeanPostProcessor 직접 만들기

JAVA
@Component
public class CustomBeanPostProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) {
        if (bean instanceof LifecycleDemo) {
            System.out.println("[BPP] Before Initialization: " + beanName);
        }
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        if (bean instanceof LifecycleDemo) {
            System.out.println("[BPP] After Initialization: " + beanName);
        }
        return bean;
    }
}

이렇게 하면 출력 순서가 다음과 같이 바뀝니다.

PLAINTEXT
[생성] 생성자 호출
[BPP] Before Initialization: lifecycleDemo
[초기화] @PostConstruct
[초기화] afterPropertiesSet
[BPP] After Initialization: lifecycleDemo

AOP 프록시도 postProcessAfterInitialization에서 생성됩니다. 그래서 @PostConstruct 시점에는 아직 프록시가 아닌 원본 객체입니다. 이 점을 모르면 초기화 시점에서 AOP가 안 먹는 버그를 만날 수 있습니다.

실무에서 자주 쓰는 패턴

JAVA
@Component
public class CacheWarmer {

    private final ProductRepository productRepository;
    private final CacheManager cacheManager;

    public CacheWarmer(ProductRepository productRepository, CacheManager cacheManager) {
        this.productRepository = productRepository;
        this.cacheManager = cacheManager;
    }

이어서 나머지 구현 부분입니다.

JAVA
    // 모든 의존성이 주입된 후 캐시를 미리 채움
    @PostConstruct
    public void warmUp() {
        List<Product> products = productRepository.findPopularProducts();
        Cache cache = cacheManager.getCache("products");
        products.forEach(p -> cache.put(p.getId(), p));
    }

    // 애플리케이션 종료 시 캐시 정리
    @PreDestroy
    public void clearCache() {
        cacheManager.getCache("products").clear();
    }
}

주의할 점

1. @PostConstruct 시점에는 AOP 프록시가 아직 적용되지 않았다

AOP 프록시는 BeanPostProcessor.postProcessAfterInitialization()에서 생성되는데, @PostConstruct는 그 직전에 실행됩니다. 따라서 @PostConstruct에서 자기 자신의 @Transactional 메서드를 호출하면 트랜잭션이 적용되지 않습니다. 초기화 시점에 트랜잭션이 필요하면 ApplicationReadyEvent 리스너를 사용해야 합니다.

2. prototype 스코프 빈의 @PreDestroy는 호출되지 않는다

스프링 컨테이너는 prototype 빈의 생성과 주입까지만 관리하고, 소멸 콜백은 호출하지 않습니다. prototype 빈에서 DB 커넥션이나 파일 핸들을 열었다면 @PreDestroy에 정리 코드를 넣어도 실행되지 않아 리소스가 누수됩니다. prototype 빈의 리소스 정리는 클라이언트 코드에서 직접 해야 합니다.

3. @PostConstruct에서 무거운 작업을 하면 애플리케이션 시작이 지연된다

@PostConstruct는 빈 초기화 과정에서 동기적으로 실행됩니다. 여기서 대량 데이터 로드나 외부 API 호출 같은 무거운 작업을 하면 전체 애플리케이션 시작 시간이 늘어납니다. 캐시 워밍업 같은 작업은 ApplicationReadyEvent에서 비동기로 처리하는 것이 시작 시간에 영향을 주지 않습니다.

정리

단계내용개입 가능 지점
1. 빈 정의 로드BeanDefinition 수집
2. 인스턴스 생성생성자 호출생성자 주입
3. 의존성 주입필드/세터 주입@Autowired
4. 초기화 전BeanPostProcessorpostProcessBeforeInitialization
5. 초기화 콜백@PostConstructafterPropertiesSetinitMethod초기화 로직
6. 초기화 후BeanPostProcessorAOP 프록시 생성 시점
7. 사용
8. 소멸 콜백@PreDestroydestroydestroyMethod리소스 정리

prototype 스코프 빈은 8단계(소멸 콜백)가 호출되지 않습니다. @PostConstruct 시점에는 AOP 프록시가 아직 적용되지 않았습니다.

댓글 로딩 중...