포스트

Spring Bean Lifecycle 정리

왜 생명주기를 알아야 하는가

스프링 빈은 단순히 new로 객체를 만들고 끝나는 대상이 아니다. 생성, 의존성 주입, 초기화, 후처리, 사용, 소멸까지 컨테이너가 관리한다. 그래서 어느 시점에 어떤 코드가 실행되는지 알아야 다음 같은 문제를 제대로 디버깅할 수 있다.

  • @PostConstruct가 왜 안 불리지?
  • AOP 프록시는 언제 감싸지지?
  • 초기화 시점에 다른 빈을 써도 안전한가?
  • 종료 훅은 어디에 두는가

스프링을 오래 쓰다 보면 기능 자체보다 시점 문제가 더 어렵다. 같은 코드라도 “생성자 안에서 호출했는가”, “초기화 콜백에서 호출했는가”, “프록시가 만들어진 뒤 호출했는가”에 따라 동작이 달라지기 때문이다.

큰 흐름부터 보면

  1. 컨테이너 시작
  2. BeanDefinition 로딩
  3. 빈 인스턴스 생성
  4. 의존성 주입
  5. Aware 콜백
  6. BeanPostProcessor 적용
  7. 초기화 콜백
  8. 빈 사용
  9. 종료 시 소멸 콜백

이 순서를 그냥 외우기보다, “메타데이터 준비 -> 객체 생성 -> 의존성 연결 -> 후처리 -> 초기화 완료 -> 사용 -> 종료”의 흐름으로 잡아두는 편이 훨씬 오래 간다.

1. 컨테이너 초기화와 BeanDefinition 준비

ApplicationContext가 생성되고 설정 정보가 읽힌다. 이 단계에서 어떤 빈들이 존재하는지에 대한 메타정보, 즉 BeanDefinition이 준비된다.

BeanDefinition에는 클래스 정보, 스코프, 의존관계, 초기화 메서드, 소멸 메서드 같은 정보가 들어 있다. 아직 실제 객체가 전부 생성된 것은 아니고, 스프링이 “무엇을 어떻게 만들지”를 아는 상태가 먼저 준비된다고 보면 된다.

이 단계가 중요한 이유는 스프링이 객체를 직접 코드로 작성하는 대신 메타데이터를 기반으로 조립하는 컨테이너라는 점을 보여주기 때문이다.

2. 빈 생성

그다음 실제 객체가 만들어진다. 보통은 생성자 호출로 인스턴스가 생성된다.

여기서 먼저 기억할 점은, 생성자는 “객체가 막 생긴 시점”이지 “스프링 빈으로서 완전히 준비된 시점”이 아니라는 것이다. 아직 모든 후처리가 끝난 것이 아니고, 프록시가 감싸지기 전일 수도 있으며, 다른 라이프사이클 콜백도 남아 있다.

그래서 생성자에서 무거운 로직을 수행하거나, 라이프사이클 후반에 보장되는 상태를 가정하고 동작시키면 꼬이기 쉽다.

3. 의존성 주입

이후 실제 객체가 생성되고 생성자 주입, 필드 주입, 세터 주입이 수행된다. 단일 생성자 주입이 가장 권장되는 이유도 이 단계가 가장 명확하게 드러나기 때문이다.

생성자 주입은 “생성 시점에 필요한 의존성이 모두 준비되어야 한다”는 제약이 있고, 그 제약 덕분에 불완전한 객체를 만들 가능성이 줄어든다. 반대로 필드 주입이나 세터 주입은 라이프사이클 상 더 늦은 주입이 가능하지만, 객체 입장에서 의존성이 언제 완성되는지 덜 명확해진다.

실무에서 빈 초기화 문제를 줄이려면 생성자 주입을 기본으로 생각하는 게 맞다.

4. Aware 콜백

스프링은 특정 인터페이스를 구현한 빈에게 컨테이너 관련 정보를 주입한다. 대표적으로 다음이 있다.

  • BeanNameAware
  • BeanFactoryAware
  • ApplicationContextAware

이들은 의존성 주입 이후, 초기화 이전에 호출된다. 즉, “내가 스프링 컨테이너 안에 들어와 있구나”라는 사실을 빈이 인지할 수 있게 해주는 구간이다.

다만 ApplicationContextAware 같은 인터페이스를 남용하면 비즈니스 객체가 컨테이너에 강하게 결합된다. 필요할 때만 쓰고, 일반적인 의존관계 해결 수단으로 쓰는 것은 피하는 편이 좋다.

5. BeanPostProcessor가 개입하는 지점

이 구간이 스프링 생명주기에서 가장 중요하다. 많은 고수준 기능이 여기서 구현되기 때문이다.

BeanPostProcessor는 빈 초기화 전후에 개입할 수 있다. 대표적인 메서드는 다음 두 개다.

  • postProcessBeforeInitialization
  • postProcessAfterInitialization

스프링이 제공하는 다양한 기능은 이 확장 포인트를 활용한다. 예를 들어 AOP, @Transactional, @Async, @Autowired 처리 일부, @PostConstruct 처리도 모두 넓게 보면 후처리 메커니즘 위에서 돌아간다.

즉, 우리가 주입받는 객체가 항상 원본 클래스 그 자체는 아닐 수 있다. 후처리 과정에서 프록시 객체로 대체될 수 있다.

6. 초기화 콜백

초기화 시점에 쓰는 대표 수단은 다음과 같다.

  • @PostConstruct
  • InitializingBean#afterPropertiesSet
  • @Bean(initMethod = "...")

실무에서는 프레임워크 의존성이 덜한 @PostConstruct를 많이 쓴다. InitializingBean은 스프링 인터페이스에 직접 의존하므로 보통 우선순위가 낮다. initMethod는 외부 라이브러리 객체를 @Bean으로 등록할 때 유용하다.

초기화 콜백은 “의존성 주입이 끝나고, 이제 안전하게 준비 작업을 할 수 있는 시점”에 가깝다. 예를 들면:

  • 캐시 warm-up
  • 외부 클라이언트 연결 확인
  • 설정값 검증
  • 메모리 내 자료구조 준비

다만 여기서도 주의할 점이 있다. 초기화 콜백은 애플리케이션 시작 경로 위에 있기 때문에, 너무 무거운 네트워크 호출이나 대량 적재를 넣으면 기동 시간이 길어지고 장애 전파 범위도 커진다.

7. 프록시와 라이프사이클을 같이 봐야 하는 이유

@Transactional이 왜 어떤 메서드에서는 동작하고 어떤 메서드에서는 안 동작하는지 이해하려면, 프록시가 생명주기의 어느 시점에 적용되는지를 같이 봐야 한다.

스프링은 보통 후처리 단계에서 프록시를 만든다. 즉, 빈 내부 메서드 호출 시점에는 아직 프록시를 거치지 않을 수 있고, 초기화 시점의 자기 자신 호출도 기대와 다르게 동작할 수 있다.

대표적인 예가 self-invocation 문제다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
public class OrderService {

    @PostConstruct
    public void init() {
        saveOrder(); // 프록시를 통한 외부 호출이 아니므로 기대한 트랜잭션이 안 걸릴 수 있다.
    }

    @Transactional
    public void saveOrder() {
        // ...
    }
}

이 코드가 위험한 이유는 @Transactional이 메서드 선언만으로 마법처럼 적용되는 것이 아니라, 프록시를 통해 호출될 때 의미가 생기기 때문이다. 라이프사이클을 모르면 이 문제를 “왜 어노테이션이 무시되지?” 정도로만 보게 된다.

8. 빈 사용 단계와 스코프 차이

초기화가 끝난 뒤 빈은 실제 애플리케이션에서 사용된다. 그런데 이 구간도 스코프에 따라 감각이 달라진다.

Singleton

기본 스코프다. 컨테이너당 한 번 생성되고, 애플리케이션 종료 시점까지 유지된다. 대부분의 서비스, 리포지토리, 설정 빈이 여기에 속한다.

Prototype

요청할 때마다 새 객체를 만든다. 중요한 점은 스프링이 생성과 초기화까지만 관리하고, 이후 소멸은 일반적으로 관리하지 않는다는 것이다. 그래서 prototype 빈에 외부 리소스 정리 책임이 있다면 별도 관리가 필요하다.

Web Scope

request, session, application 같은 웹 스코프는 HTTP 생명주기와 연결된다. 이 경우 빈 생명주기를 스프링 컨테이너만의 시간축으로 보면 오해하기 쉽다. 웹 요청의 시작과 끝이 사실상 생성/소멸 조건이 된다.

9. 종료 시점

컨테이너가 내려갈 때는 @PreDestroy, DisposableBean, destroyMethod 등이 사용된다. 커넥션 정리, 외부 리소스 해제, 버퍼 flush 같은 작업이 이 구간에 들어간다.

여기서도 선택지는 초기화 콜백과 비슷하다.

  • @PreDestroy
  • DisposableBean#destroy
  • @Bean(destroyMethod = "...")

실무에서는 보통 @PreDestroydestroyMethod가 더 선호된다. 특히 외부 라이브러리 객체를 @Bean으로 등록할 때 destroyMethod는 깔끔하다.

다만 종료 콜백은 “정상 종료”를 전제하는 경우가 많다. 프로세스 강제 종료, 컨테이너 kill, 장애 상황에서는 기대만큼 호출되지 않을 수 있다. 그래서 중요한 정합성을 종료 훅 하나에만 의존하는 설계는 위험하다.

초기화와 소멸 방식은 무엇을 선택해야 하는가

우선순위를 실무 감각으로 정리하면 대체로 이렇다.

  • 애플리케이션 코드: @PostConstruct, @PreDestroy
  • 외부 라이브러리 빈 등록: @Bean(initMethod = ..., destroyMethod = ...)
  • 스프링 인터페이스 기반 콜백: 특별한 이유가 없으면 후순위

이 기준이 좋은 이유는 코드가 프레임워크에 덜 묶이고, 의도가 더 명확하기 때문이다.

실무에서 자주 부딪히는 문제

  • 초기화 로직은 짧고 명확해야 한다.
  • 다른 빈의 상태에 과하게 의존하는 초기화 코드는 위험하다.
  • 프록시가 개입하는 시점을 이해해야 @Transactional 동작을 해석할 수 있다.
  • 종료 콜백이 항상 호출된다고 낙관하면 안 된다. 강제 종료 상황도 있다.

조금 더 구체적으로 적어보면 다음과 같다.

생성자에서 외부 호출을 하지 말아야 하는 이유

생성자는 아직 빈이 완전히 준비되기 전이다. 여기서 DB 호출, 이벤트 발행, 자기 자신 메서드 호출까지 넣으면 후처리 이전 상태와 섞여 예상 밖 동작이 나온다.

@PostConstruct는 만능 초기화 지점이 아니다

의존성 주입은 끝났지만, 시스템 전체가 “서비스 가능한 상태”라는 보장은 없다. 다른 빈도 초기화 중일 수 있고, 애플리케이션 전체 기동 완료 이벤트 이전일 수 있다. 애플리케이션 전체가 올라온 뒤 실행되어야 하는 작업은 ApplicationRunner, CommandLineRunner, ApplicationReadyEvent 같은 다른 훅이 더 적절할 수 있다.

프록시 기반 기능은 외부 호출 경로를 타야 한다

@Transactional, @Async, @Cacheable 같은 기능은 프록시를 통과해야 적용된다. 같은 클래스 내부에서 직접 호출하면 기대한 부가기능이 동작하지 않을 수 있다.

전체 순서를 한 번에 다시 보면

정확한 내부 세부 순서는 스프링 버전과 상황에 따라 더 복잡하지만, 실무적으로는 아래 흐름으로 정리해두면 대부분의 문제를 설명할 수 있다.

1
2
3
4
5
6
7
8
9
BeanDefinition 준비
-> 생성자 호출로 인스턴스 생성
-> 의존성 주입
-> Aware 콜백
-> BeanPostProcessor before
-> @PostConstruct / afterPropertiesSet / initMethod
-> BeanPostProcessor after
-> 프록시 포함 최종 빈 사용
-> 종료 시 @PreDestroy / destroy / destroyMethod

정리

빈 생명주기를 이해하면 스프링이 “객체를 언제 만들고, 언제 연결하고, 언제 감싸고, 언제 정리하는지”가 보인다. 그 순간 초기화 시점, 프록시 적용, 트랜잭션 미적용, 종료 훅 누락 같은 문제가 단순 현상이 아니라 순서 문제로 읽히기 시작한다.

스프링을 사용할 때 중요한 것은 어노테이션 이름을 많이 아는 것이 아니라, 그 어노테이션이 생명주기 어디에 걸리는지를 아는 것이다. 그 감각이 잡히면 디버깅 속도가 확실히 달라진다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다