콘텐츠로 이동

빈 생명주기 콜백


왜 필요한가

애플리케이션이 시작되면 DB 커넥션 풀, 외부 API 연결 등을 미리 준비해야 하는 경우가 있다. 반대로 종료 시점에는 열어둔 연결을 안전하게 닫아야 한다.

그런데 스프링 빈은 이런 과정이 어떤 순서로 일어나는가?

스프링 컨테이너 생성
빈 생성 (객체 new)
의존관계 주입 (DI)   ← 이 시점이 되어야 모든 의존관계가 준비됨
[초기화 콜백] ← 여기서 초기화 코드 실행 가능 (DB 연결, 소켓 열기 등)
애플리케이션 동작
[소멸 전 콜백] ← 여기서 정리 코드 실행 가능 (연결 닫기 등)
스프링 종료

초기화 코드는 DI가 완료된 이후에 실행해야 한다. 그래야 주입된 의존 객체를 안전하게 사용할 수 있다. 스프링은 이 시점(DI 완료 직후, 소멸 직전)에 개발자가 원하는 코드를 실행할 수 있는 콜백을 제공한다.


생성자에서 초기화하면 안 되는가?

생성자에서 초기화(DB 연결, 소켓 열기 등)를 하면 안 된다. 단일 책임 원칙(SRP) 위반이며 테스트가 어려워진다.

// 이렇게 하면 안 됨
public class NetworkClient {
    private String url;

    public NetworkClient(String url) {
        this.url = url;
        connect(); // 생성자에서 초기화 — 비권장
    }

    private void connect() {
        System.out.println("connect: " + url);
    }
}

단일 책임 원칙(SRP) 위반이다. 생성자의 역할은 객체를 만드는 것에 집중해야 한다. 초기화는 외부 연결, 파일 오픈 등 무거운 작업이 많다. 생성자에서 무거운 작업을 하면 테스트가 어려워지고, 유지보수가 힘들어진다.

또한 생성자 주입의 경우 DI와 빈 생성이 동시에 일어나므로, 생성자 실행 시점에 아직 DI가 완료되지 않았을 수 있다.

생성자: 필드에 값 넣는 것만. 초기화: @PostConstruct 등 별도 메서드에서.


초기화/소멸 콜백 3가지 방법

방법 1: @PostConstruct / @PreDestroy (권장)

가장 간결하고 표준적인 방법이다.

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;

@Component
public class NetworkClient {
    private String url;

    @Autowired
    public void setUrl(String url) {
        this.url = url;
    }

    @PostConstruct // DI 완료 직후 자동 호출
    public void init() {
        System.out.println("NetworkClient.init — 연결 시작: " + url);
        connect();
    }

    @PreDestroy // 빈 소멸 직전 자동 호출
    public void close() {
        System.out.println("NetworkClient.close — 연결 종료");
        disconnect();
    }

    private void connect() {
        System.out.println("connect: " + url);
        // 실제로는 소켓 열기, HTTP 클라이언트 초기화 등
    }

    private void disconnect() {
        System.out.println("close: " + url);
        // 실제로는 소켓 닫기, 연결 해제 등
    }
}
앱 시작:
    NetworkClient 빈 생성
    DI (url 주입)
    @PostConstruct → init() 호출 → connect: http://hello.dev

앱 종료:
    @PreDestroy → close() 호출 → close: http://hello.dev
    빈 소멸

@PostConstruct / @PreDestroy가장 권장하는 방법. Jakarta EE 표준으로 스프링에 종속되지 않음.

권장하는 이유:

이유 설명
표준 기술 Jakarta EE(구 javax) 표준 — 스프링에 종속되지 않음
간결함 어노테이션 하나로 해결, 별도 설정 코드 불필요
컴포넌트 스캔 친화적 @Bean 속성 지정 없이 동작
가독성 초기화/소멸 로직이 클래스 안에 명확히 보임

단점: 외부 라이브러리에는 적용 불가. 내가 소스 코드를 수정할 수 없는 외부 라이브러리 클래스에는 어노테이션을 달 수 없다.


방법 2: @Bean initMethod / destroyMethod

외부 라이브러리처럼 내 코드를 직접 수정할 수 없을 때 사용한다. @Bean 어노테이션의 속성으로 초기화/소멸 메서드 이름을 지정한다.

// 외부 라이브러리 (수정 불가 가정)
public class NetworkClient {
    private String url;

    public void setUrl(String url) { this.url = url; }

    public void init() { // 메서드 이름은 자유
        System.out.println("NetworkClient.init");
        connect();
    }

    public void close() { // 메서드 이름은 자유
        System.out.println("NetworkClient.close");
        disconnect();
    }

    private void connect() { System.out.println("connect: " + url); }
    private void disconnect() { System.out.println("close: " + url); }
}
// @Bean 속성으로 연결
@Configuration
public class LifeCycleConfig {

    @Bean(initMethod = "init", destroyMethod = "close")
    public NetworkClient networkClient() {
        NetworkClient client = new NetworkClient();
        client.setUrl("http://hello.dev");
        return client;
    }
}

장점:

장점 설명
메서드 이름 자유 원하는 이름으로 지정 가능
스프링 무관 NetworkClient가 스프링에 의존하지 않음
외부 라이브러리 적용 가능 소스 수정 없이 설정만으로 가능

destroyMethod 기본값 추론 기능:

@Bean(initMethod = "init")
// destroyMethod를 생략하면 기본값 "(inferred)"
// → 빈 클래스에서 "close" 또는 "shutdown" 이름의 메서드를 자동으로 찾아서 호출
// → HikariCP, JDBC Connection Pool 등 대부분의 외부 라이브러리가 close()를 가지므로 편리

// 추론 기능을 끄려면
@Bean(destroyMethod = "") // 빈 문자열 지정

방법 3: InitializingBean / DisposableBean (레거시, 비권장)

import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;

public class NetworkClient implements InitializingBean, DisposableBean {

    @Override
    public void afterPropertiesSet() throws Exception {
        // DI 완료 후 호출 — 메서드 이름이 고정됨
        connect();
    }

    @Override
    public void destroy() throws Exception {
        // 소멸 전 호출 — 메서드 이름이 고정됨
        disconnect();
    }
}

단점: - 스프링 전용 인터페이스에 의존 → 스프링 없이 쓸 수 없음 - 메서드 이름 변경 불가 (afterPropertiesSet, destroy 고정) - 외부 라이브러리에 적용 불가

스프링 초창기(2.x 이전) 방식으로 현재는 사용하지 않는다.


세 가지 방법 비교

구분 @PostConstruct / @PreDestroy @Bean initMethod / destroyMethod InitializingBean / DisposableBean
권장 여부 강력 권장 외부 라이브러리에 사용 비권장 (레거시)
스프링 의존 없음 (Jakarta 표준) 없음 있음
외부 라이브러리 적용 불가 가능 불가
메서드 이름 자유 자유 고정
코드 간결성 가장 간결 설정 코드 필요 중간

실제 활용 예제

DB 커넥션 풀 초기화 / 종료

@Component
public class DatabaseConnectionPool {
    private List<Connection> pool = new ArrayList<>();

    @PostConstruct
    public void init() {
        // 앱 시작 시 커넥션 10개 미리 생성 — DB와 연결을 미리 맺어둠
        for (int i = 0; i < 10; i++) {
            pool.add(createConnection());
        }
        System.out.println("커넥션 풀 초기화 완료: " + pool.size() + "개");
    }

    @PreDestroy
    public void close() {
        // 앱 종료 시 모든 커넥션 반환 — DB 연결을 정리
        for (Connection conn : pool) {
            closeConnection(conn);
        }
        System.out.println("커넥션 정리 완료");
    }
}

외부 라이브러리 (HikariCP DataSource) — @Bean 방식

@Configuration
public class DataSourceConfig {

    @Bean(destroyMethod = "close") // HikariDataSource.close() 자동 호출
    public DataSource dataSource() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl("jdbc:mysql://localhost:3306/mydb");
        ds.setUsername("root");
        ds.setPassword("password");
        ds.setMaximumPoolSize(10);
        return ds;
        // destroyMethod = "close" → 앱 종료 시 HikariDataSource.close() 호출
        // → 커넥션 풀에 있는 모든 연결을 DB에 반환
    }
}

캐시 워밍업 — 모든 빈 초기화 완료 후 실행

@PostConstruct는 해당 빈의 DI가 완료된 직후에 실행된다. 그런데 어떤 경우에는 다른 빈들의 초기화도 모두 완료된 이후에 실행해야 할 수 있다. 예: 캐시에 데이터를 넣으려면 ProductRepository 빈이 완전히 초기화되어 있어야 함.

이때는 ApplicationReadyEvent를 사용한다.

@Component
public class CacheWarmup {

    private final ProductRepository productRepository;

    public CacheWarmup(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // @PostConstruct ← 이걸 쓰면 다른 빈이 아직 초기화 중일 수 있음

    @EventListener(ApplicationReadyEvent.class)
    // 모든 빈 초기화 완료, 앱이 완전히 구동된 후에 실행됨
    public void warmup() {
        List<Product> popularProducts = productRepository.findTop100ByOrderBySalesDesc();
        // 캐시(Redis, EhCache 등)에 저장...
        System.out.println("캐시 워밍업 완료: " + popularProducts.size() + "건");
    }
}
@PostConstruct         해당 빈의 DI 완료 직후 (다른 빈의 초기화 상태 보장 없음)
ApplicationReadyEvent  모든  초기화 완료, 서버 준비 완료  (완전히 안전)

캐시 워밍업처럼 다른 빈이 완전히 초기화된 이후에 실행돼야 하는 작업은 ApplicationReadyEvent를 사용한다.


소멸 콜백 주의사항

싱글톤 빈:   스프링 컨테이너 종료 시 소멸 콜백 호출 O
프로토타입 빈: 스프링이 소멸 콜백을 호출하지 않음!
             → 클라이언트(사용자)가 직접 close() 해줘야 함

프로토타입 빈은 스프링이 생성과 DI까지만 관여하고, 이후 관리는 클라이언트 책임이다. 자세한 내용은 빈 스코프 참고.


빈 생명주기 전체 타임라인

[앱 시작]
  ① 스프링 컨테이너 생성
  ② 빈 생성 (new)
  ③ 의존관계 주입 (DI)
  ④ @PostConstruct (초기화 콜백)
  ⑤ 앱 구동 완료 → ApplicationReadyEvent 발생
  ⑥ 서비스 운영 중 (HTTP 요청 처리 등)

[앱 종료]
  ⑦ @PreDestroy (소멸 콜백)
  ⑧ 스프링 컨테이너 종료

초기화 코드는 ③ DI가 완료된 이후에 실행된다는 것이 핵심이다. DI가 완료되어야 주입된 의존 객체를 안전하게 사용할 수 있기 때문이다.


내부 동작 원리

BeanPostProcessor — 스프링의 핵심 확장 포인트

BeanPostProcessor는 빈 생성 전후에 스프링이 제공하는 확장 포인트(Extension Point)다. 스프링 내부적으로 @PostConstruct, @PreDestroy, AOP 프록시 생성 등이 모두 이 인터페이스를 통해 구현된다.

// BeanPostProcessor 인터페이스
public interface BeanPostProcessor {
    // 빈의 초기화 메서드(@PostConstruct, afterPropertiesSet) 호출 전
    default Object postProcessBeforeInitialization(Object bean, String beanName) {
        return bean;
    }

    // 빈의 초기화 메서드 호출 후
    default Object postProcessAfterInitialization(Object bean, String beanName) {
        return bean;
    }
}

스프링 내부에서 등록된 주요 BeanPostProcessor:

BeanPostProcessor 하는 일 실행 단계
CommonAnnotationBeanPostProcessor @PostConstruct, @PreDestroy 처리 Before
AutowiredAnnotationBeanPostProcessor @Autowired, @Value 처리 프로퍼티 주입 단계
AnnotationAwareAspectJAutoProxyCreator AOP 프록시 객체 생성 After

@PostConstruct가 실제로 처리되는 방법

@PostConstructCommonAnnotationBeanPostProcessor가 처리한다.

빈 생성 완료
@Autowired 주입 (AutowiredAnnotationBeanPostProcessor)
CommonAnnotationBeanPostProcessor.postProcessBeforeInitialization() 호출
  → 빈 클래스에서 @PostConstruct 달린 메서드를 리플렉션으로 탐색
  → 발견 시: method.invoke(bean) — 실제 초기화 메서드 실행
InitializingBean.afterPropertiesSet() 호출 (있는 경우)
@Bean(initMethod="...") 호출 (있는 경우)
AnnotationAwareAspectJAutoProxyCreator.postProcessAfterInitialization() 호출
  → @Transactional, @Aspect 등이 붙은 빈이면 CGLIB 프록시로 교체
최종 빈 완성 → singletonObjects에 저장

완전한 빈 생명주기 12단계

[빈 생성 및 초기화]
① BeanDefinition 등록
   컨테이너 시작 시 설계도(메타데이터) 수집

② 인스턴스 생성 (Constructor 호출)
   new 객체가 힙에 생성됨 (DI는 아직 안 됨)

③ 프로퍼티 설정 — setter / 필드 DI 처리

④ BeanNameAware.setBeanName() 호출
   빈이 자신의 이름을 알아야 할 때 (드물게 사용)

⑤ BeanFactoryAware.setBeanFactory() 호출
   빈이 BeanFactory를 직접 사용해야 할 때 (드물게 사용)

⑥ ApplicationContextAware.setApplicationContext() 호출
   빈이 ApplicationContext를 직접 사용해야 할 때 (드물게 사용)

⑦ BeanPostProcessor.postProcessBeforeInitialization()
   ← @PostConstruct 실행 (CommonAnnotationBeanPostProcessor)

⑧ InitializingBean.afterPropertiesSet() 호출 (구현한 경우)

⑨ @Bean(initMethod="...") 실행 (지정한 경우)

⑩ BeanPostProcessor.postProcessAfterInitialization()
   ← AOP 프록시 생성 (AnnotationAwareAspectJAutoProxyCreator)
   ← @Transactional, @Aspect 등이 있으면 실제 빈이 프록시로 교체됨

⑪ 빈 사용 (사용자 코드)

[빈 소멸]
⑫ 소멸 단계:
   @PreDestroy 실행 → DisposableBean.destroy() → @Bean(destroyMethod="...")

실무에서 자주 쓰는 것: ②, ③, ⑦(@PostConstruct), ⑩(AOP), ⑫(@PreDestroy) ④⑤⑥은 스프링 내부 빈에서 주로 사용하고, 일반 비즈니스 코드에서는 거의 쓰지 않는다.