Post

SpringBean LifeCycle

SpringBean LifeCycle에 대해서 적어봤습니다.

SpringBean LifeCycle

주제 선정 이유

프로젝트를 진행하면서 Spring을 사용하다 보면 @Service, @Transactional과 같은 기능들을 자연스럽게 적용하고, 의존성 주입을 통해 객체 간의 관계를 구성하는 것에도 익숙해지게 되지만 이러한 기능들이 내부적으로 어떤 과정을
거쳐 동작하는지에 대해서는 깊게 고민하지 않은 채 사용하는 경우가 많습니다.

실제로 기능은 문제없이 동작하지만, ApplicationContext가 빈을 언제, 어떤 방식으로 생성하고 관리하는지, 그리고
왜 주입받는 객체가 실제 객체가 아니라 프록시 객체로 동작하는지에 대해서는 명확히 설명하기 어렵다는 점을 느끼게 되는데 이러한 흐름 속에서 스프링 컨테이너는 단순히 객체를 생성하는 역할을 넘어서, 다양한 내부 메커니즘을 통해
빈을 관리하고 있다는 사실을 인식하게 되었습니다.

그래서 이번 글에서는 Spring Bean이 생성되는 전체 과정과 내부 동작을 BeanDefinition부터 프록시 생성,
싱글톤 캐시 구조까지 하나의 흐름으로 정리하고
, 이를 통해 스프링 컨테이너의 동작 원리를 정리해보고자 합니다.

Spring Bean LifeCycle이 뭘까?

스프링에서 빈을 생성한다는 말은 단순히 객체를 new로 만드는 것을 의미하지 않고 먼저 객체를 만들기 위한 설계도를 준비하고, 그 설계도에 따라 인스턴스를 생성한 뒤 의존성을 주입하고, 필요하다면 프록시로 감싸고, 최종적으로
싱글톤 캐시에 저장해 재사용하는데 설계 → 생성 → 주입 → 후처리 → 캐싱의 전 과정을 통틀어 Spring Bean
생명주기라고 합니다.

BeanDefinition

스프링은 설정 클래스, 컴포넌트 스캔 등을 통해 빈 후보를 읽어들인 뒤, 이를 BeanDefinition이라는 메타데이터 객체로 정리합니다.

  • 실제 클래스 타입
  • 스코프 (singleton, prototype 등)
  • 생성자 정보
  • 의존성 정보
  • 초기화 메서드
  • Lazy 여부

중요한 점은, 이 단계에서는 아직 객체가 생성되지 않았다는 것이며 스프링 컨테이너에서 이 설계도를 기반으로 나중에 실제 빈을 생성합니다.

BeanFactory

BeanFactoryBeanDefinition을 보관하고 있다가, getBean() 요청이 들어오면 실제 객체를 생성하거나 반환하고 순서는 다음과 같습니다.

  1. 싱글톤 캐시에 이미 존재하는지 확인
  2. 존재하면 반환
  3. 없으면 createBean() 호출
  4. 생성 + 의존성 주입 + 초기화
  5. 싱글톤 캐시에 저장

Spring Boot에서 사용하는 ApplicationContext도 내부적으로는 BeanFactory를 확장한 구조이며, 실제 생성 로직의 중심에는 defaultListableBeanFactory가 위치합니다.

BeanPostProcessor

빈은 생성과 의존성 주입이 끝났다고 바로 완료되지 않으며 초기화 전후로 후처리 과정이 존재하며, 여기서 개입하는게 BeanPostProcessor입니다.

  • postProcessBeforeInitialization()
  • postProcessAfterInitialization()

특히 초기화 이후 단계에서 AOP 프록시 생성이 이루어지며 @Transactional이 붙은 빈이 실제 객체가 아닌 프록시
객체로 바뀌는 이유가 바로 이 지점 때문입니다.

CGLIB Enhancement

AOP 프록시는 두 가지 방식으로 생성되고 인터페이스 기반이면 JDK 동적 프록시며 클래스 기반이면 CGLIB
생성되며 상속을 이용해 원본을 확장한 새로운 클래스를 생성하고, 메서드 호출을 가로채 부가 기능을 삽입합니다.

결과적으로 우리가 주입받는 객체는 다음과 같은 형태가 될 수 있으며 이 객체가 실제 비즈니스 로직 호출을 감싸며
트랜잭션 시작, 커밋, 롤백 같은 처리를 수행합니다.

1
UserService$$EnhancerBySpringCGLIB

싱글톤 캐시 3단계 구조

스프링은 싱글톤 관리를 위해 단일 캐시 하나만 사용하지 않고 다음 세 가지를 사용합니다.

  • 1단계
    • 완전히 생성된 싱글톤 객체 저장
  • 2단계
    • 미완성 객체를 임시로 저장
  • 3단계
    • 필요 시 프록시를 생성할 수 있는 ObjectFactory 저장

이 구조는 순환참조 해결과 직접적으로 연결되며 객체가 완성되기 전이라도, 특정 조건에서는 그 객체를 미리 노출해
다른 빈이 참조할 수 있도록 허용하며 이 조기 노출 전략이 없다면 순환 구조에서 생성이 멈추게 됩니다.

순환참조 해결 방식

스프링이 순환참조를 해결할 수 있는 이유는 미완성 객체를 먼저 노출하는 전략 때문이지만 모든 경우가 가능한 것은
아닙니다.

  • 생성자 주입 순환참조는 해결 불가
  • 필드 주입 / setter 주입은 조건부 해결 가능

생성자 주입은 객체가 완성되기 전에는 인스턴스 자체가 존재하지 않기 때문에 조기 노출이 불가능한 반면에 필드나 setter 방식은 인스턴스 생성 이후 의존성을 주입하므로, 객체만 먼저 만들어두고 참조를 전달하는 전략이 가능합니다.

다만, 최근 스프링에서는 순환참조를 기본적으로 허용하지 않는 방향으로 정책이 변경되었고 구조 개선이 권장됩니다.

그래서 어떻게 쓰라고?

무조건 무상태로 설계

스프링 빈의 기본 스코프는 싱글톤이라 컨테이너가 한 인스턴스를 캐시에 올려두고 계속 재사용하기 때문에
Service / Repository에 요청별 상태를 인스턴스 변수로 들고 있으면 바로 동시성 문제가 터집니다.

  • 인스턴스 변수에 userId, requestDto 같은 값 저장 금지
  • 상태가 필요하면 메서드 지역 변수로 처리
  • 정말 요청 단위 상태가 필요하면 request scope, session scope 또는 별도 컨텍스트 객체를 사용

순환참조는 해결하지 말고 구조를 바꿀 신호

3단계 캐시 덕분에 setter / 필드 주입에서는 순환참조가 될 때도 있지만, 이건 스프링의 편의 기능에 기대는 구조인데 순환참조가 보이면 보통 책임이 잘못 나뉘었거나 계층이 꼬였다는 뜻입니다.

  • 공통 로직을 Facade/Helper 같은 별도 컴포넌트로 분리
  • A가 B를 직접 호출하지 말고 이벤트 (ApplicationEventPublisher)로 느슨하게 연결
  • 꼭 필요하면 @Lazy로 한쪽 의존만 지연 주입

@Transactional은 프록시를 통해 호출될 때만 동작

@Transactional은 클래스에 붙는 게 아니라, 빈 생성 후 후처리 (BeanPostProcessor)에서 프록시가 만들어지면서
적용되기 때문에 아래 상황에서는 트랜잭션이 안 걸립니다.

  • 같은 클래스 내부에서 자기 메서드를 호출
  • private 메서드에 트랜잭션을 걸어놓은 경우
  • new로 직접 객체 생성해서 스프링 컨테이너 밖에서 호출한 경우

해결 방식

  • 트랜잭션 경계를 외부에서 호출되는 public 메서드에 두기
  • 내부 호출이 필요하면 구조를 분리해서 다른 빈으로 빼고 그 빈을 통해 호출하기

빈이 이상하면 BeanPostProcessor / AOP를 의심

디버깅하다가 클래스명이 아래와 같다면 대부분 원본 객체가 아니라 프록시입니다.

1
2
xxx$$EnhancerBySpringCGLIB
com.sun.proxy.$Proxy...

Lazy / 초기화 시점 튜닝으로 언제 생성되는지 결정

빈이 언제 만들어지는지 알면 성능 튜닝 포인트가 생기는데 기본은 컨테이너 초기화 시점에 싱글톤을 미리 생성하고
무거운 외부 연동은 @Lazy 고려해서 초기화 로직이 크면 @PostConstruct 남발하지 말고 분리 /비동기 설계 고민하면 됩니다.

마무리

Spring Bean 생명주기를 정리하면서 느낀 핵심을 다시 정리해보면 다음과 같습니다.

  1. 스프링은 단순히 객체를 생성해주는 DI 컨테이너가 아니라, BeanDefinition·BeanFactory·BeanPostProcessor
    ·프록시·싱글톤 캐시까지 이어지는 정교한 객체 생명주기 관리 엔진이라는 점을 이해하게 되었습니다.
  2. @Transactional 같은 기능은 애노테이션 자체의 마법이 아니라, 빈 생성 이후 후처리 과정에서 프록시가 생성되고 그 프록시가 호출을 가로채면서 동작하는 구조적 결과물이라는 점이 명확해졌습니다.
  3. 싱글톤 3단계 캐시와 순환참조 해결 방식은 스프링 내부 설계의 정수를 보여주는 부분이며, 내부 흐름을 알고 나니 왜 이런 구조가 필요한지 자연스럽게 이해할 수 있게 되었습니다.
This post is licensed under CC BY 4.0 by the author.