빈 생명 주기 콜백
일반 초기화 콜백과 외부 시작 경계를 분리하고, 정상 종료와 초기화 실패에서 자원을 정확히 한 번 정리하는 계약을 실행 테스트로 검증합니다.
Spring 빈은 생성자를 호출하는 것으로 다 만들어지지 않습니다. 보통의 생성 경로에서 컨테이너는 의존성을 채우고 초기화 콜백을 실행한 뒤, 초기화가 끝난 빈을 돌려줍니다. 정상적으로 등록된 싱글톤은 컨텍스트가 닫힐 때 소멸(destroy) 콜백을 받습니다.
이때 일을 나누는 기준을 분명히 해야 합니다. 생성자는 필수 참조와 값의 범위를 정하고, 일반 초기화 콜백은 구성 상태를 검사하고 로컬 자료 구조를 준비합니다. 네트워크 연결이나 백그라운드 작업, 오래 걸리는 준비는 모든 일반 싱글톤의 초기화가 끝난 뒤의 별도 시작 단계가 맡습니다. 정상 종료는 컨테이너의 소멸 콜백이 맡지만, 초기화 도중에 얻은 자원은 그것을 얻은 코드가 그 자리에서 되돌려야 합니다. 테스트 파일은 앞 글과 같은 프로젝트의 src/test/java에 둡니다.
일반적인 생성 경로는 초기화를 마친 빈을 반환한다
순환 참조를 허용하는 구성에서는 만들어지는 중인 싱글톤의 조기 참조(early reference)가 초기화 전에 다른 빈에 전달될 수 있습니다. 아래 예제는 그런 예외가 없는 정상 경로의 콜백 순서를 다루며, 조기 참조를 받았다고 그 빈이 초기화에 성공했거나 안전하게 쓸 수 있다는 뜻은 아닙니다.
@PostConstruct와 @PreDestroy를 쓰는 클래스는 Spring 전용 인터페이스에 묶이지 않습니다. 지금의 Java 생태계에서는 javax.annotation이 아니라 jakarta.annotation 패키지를 쓰며, jakarta.annotation-api가 클래스 경로에 있어야 합니다. 다음 픽스처는 생성자, 초기화, 업무 호출, 소멸의 순서를 실제 컨텍스트에서 확인합니다. 초기화 작업은 외부 접속이 아니라 로컬 인덱스 준비로 제한했습니다.
package example.lifecycle;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
class ManagedCallbackSequenceTest {
static final class Events {
private final List<String> values = new ArrayList<>();
void add(String value) {
values.add(value);
}
List<String> snapshot() {
return List.copyOf(values);
}
}
static final class ManagedIndex {
private final Events events;
private boolean ready;
ManagedIndex(Events events) {
this.events = events;
events.add("constructor");
}
@PostConstruct
void prepareLocalIndex() {
events.add("post-construct");
ready = true;
}
void lookup() {
if (!ready) {
throw new IllegalStateException("index is not ready");
}
events.add("use");
}
@PreDestroy
void releaseLocalIndex() {
if (!ready) {
return;
}
ready = false;
events.add("pre-destroy");
}
}
@Configuration(proxyBeanMethods = false)
static class Config {
@Bean
Events events() {
return new Events();
}
@Bean
ManagedIndex managedIndex(Events events) {
return new ManagedIndex(events);
}
}
@Test
void 성공한_초기화_뒤_사용되고_close에서_한_번_정리된다() {
var context = new AnnotationConfigApplicationContext(Config.class);
var events = context.getBean(Events.class);
var index = context.getBean(ManagedIndex.class);
assertThat(events.snapshot()).containsExactly(
"constructor", "post-construct");
index.lookup();
context.close();
context.close();
assertThat(events.snapshot()).containsExactly(
"constructor",
"post-construct",
"use",
"pre-destroy");
}
}@PostConstruct가 예외를 던지면 그 빈의 생성이 실패합니다. 반대로 @PreDestroy는 정상적으로 등록된 싱글톤을 컨텍스트가 닫을 때 호출됩니다. 프로세스가 강제로 종료되거나 전원이 끊기면 소멸 콜백은 보장되지 않으므로, 데이터 정합성이나 이미 승인된 업무의 기록, 외부 시스템과의 합의를 종료 콜백 하나에 맡겨서는 안 됩니다.
초기화 실패는 획득 지점에서 되돌린다
소멸 콜백은 빈의 초기화가 성공한 뒤에 등록됩니다. 그러니 초기화 메서드가 닫아야 할 자원 후보를 만든 뒤에 실패했다면, 나중에 @PreDestroy가 처리해 주리라고 기대할 수 없습니다. 그래서 후보는 지역 변수에 두고, 준비가 모두 끝난 뒤에만 필드에 대입합니다. 준비에 실패하고 정리에도 실패하면, 정리 예외를 원래 예외의 억제된 예외(suppressed exception)로 붙여 첫 원인을 남깁니다. 억제된 예외가 원인(cause)과 어떻게 다른지는 try-with-resources와 공통 예외 처리에서 다룹니다.
package example.lifecycle;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
class InitializationFailureCleanupTest {
static final class Events {
private final List<String> values = new ArrayList<>();
void add(String value) {
values.add(value);
}
List<String> snapshot() {
return List.copyOf(values);
}
}
static final class Candidate implements AutoCloseable {
private final Events events;
private final boolean failPrepare;
private final boolean failClose;
private boolean closed;
Candidate(Events events, boolean failPrepare, boolean failClose) {
this.events = events;
this.failPrepare = failPrepare;
this.failClose = failClose;
}
void prepareLocalState() {
events.add(failPrepare ? "prepare-failed" : "prepared");
if (failPrepare) {
throw new IllegalStateException("prepare rejected");
}
}
@Override
public void close() {
if (closed) {
return;
}
closed = true;
events.add("candidate-close");
if (failClose) {
throw new IllegalStateException("close rejected");
}
}
}
interface CandidateFactory {
Candidate open();
}
static final class SafeLocalIndex {
private final CandidateFactory factory;
private Candidate candidate;
SafeLocalIndex(CandidateFactory factory) {
this.factory = factory;
}
@PostConstruct
void initialize() {
var opened = factory.open();
try {
opened.prepareLocalState();
candidate = opened;
} catch (RuntimeException failure) {
try {
opened.close();
} catch (RuntimeException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
throw failure;
}
}
@PreDestroy
void shutdown() {
var current = candidate;
candidate = null;
if (current != null) {
current.close();
}
}
}
@Test
void 초기화와_정리가_함께_실패해도_원래_예외를_유지한다() {
var events = new Events();
var opened = new Candidate(events, true, true);
var context = new AnnotationConfigApplicationContext();
context.registerBean(
SafeLocalIndex.class,
() -> new SafeLocalIndex(() -> {
events.add("open");
return opened;
}));
Throwable failure = catchThrowable(context::refresh);
assertThat(failure).isInstanceOf(BeanCreationException.class);
var root = ((BeanCreationException) failure).getMostSpecificCause();
assertThat(root).hasMessage("prepare rejected");
assertThat(root.getSuppressed())
.extracting(Throwable::getMessage)
.containsExactly("close rejected");
context.close();
assertThat(events.snapshot()).containsExactly(
"open", "prepare-failed", "candidate-close");
}
}종료 메서드가 필드를 먼저 null로 바꾸게 하면, 다시 들어오거나 두 번 호출되어도 같은 자원을 두 번 닫지 않습니다. 초기화 실패 경로에도 이렇게 몇 번 호출해도 결과가 같은(idempotent) 규칙을 적용하면, 테스트의 정리와 컨텍스트의 실패 복구가 겹쳐도 누가 자원을 닫는지가 흔들리지 않습니다.
빈은 초기화 뒤 공개되고 정상 종료와 초기화 실패는 서로 다른 경계에서 자원을 정리한다
생성·의존성·init 준비·공개/사용·destroy와 init 실패의 로컬 자원 정리를 실제 소유 범위로 나눈다.
SINGLETON PUBLICATION · TWO CLEANUP BOUNDARIES
빈은 초기화 뒤 공개되고 정상 종료와 초기화 실패는 서로 다른 경계에서 자원을 정리한다
컨테이너는 초기화가 성공한 빈만 준비된 싱글톤으로 공개합니다. 정상 종료는 등록된 destroy callback이 맡지만, 초기화 도중 얻었다가 실패한 로컬 자원은 그 초기화 메서드가 직접 되돌려야 합니다.
정상 경로와 실패 경로는 서로 다른 소유권 경계입니다. 실패한 후보는 초기화 코드가 즉시 닫고, 정상 등록된 빈은 컨텍스트 종료가 정리합니다. 원래 실패를 정리 실패로 덮지 않습니다.
NORMAL PATH · PUBLISH THEN DESTROY
로컬 준비가 끝난 뒤 사용되고 컨텍스트 종료 때 정리된다
생성·의존성 채우기
컨테이너가 인스턴스를 만들고 필요한 의존성과 설정 값을 채웁니다.
로컬 검증·준비
@PostConstruct같은 일반 init callback은 싱글톤 생성 lock 안에서 설정을 검증하고 로컬 자료구조를 준비한 뒤 반환합니다.공개·업무 사용
초기화와 후처리가 성공해야 준비된 싱글톤으로 공개되고 다른 빈과 업무 호출이 사용합니다.
정상 종료
컨텍스트를 정상적으로 닫으면 등록된
@PreDestroy같은 destroy callback이 소유 자원을 정리합니다.
강제 프로세스 종료에서는 callback 실행을 보장할 수 없습니다. 애플리케이션이 컨텍스트를 정상적으로 닫는 경로를 가져야 합니다.
POST-SINGLETON STARTUP
외부·고비용 시작은 일반 init callback 밖에 둔다
일반 init의 책임
의존성 확인, 값 검증, 로컬 캐시 조립처럼 짧고 실패 범위가 빈 하나에 가까운 준비를 수행합니다.
모든 regular singleton 이후
BeanFactory bootstrap마다 한 번이면
SmartInitializingSingleton을 사용합니다.ContextRefreshedEvent는 계층 전파와 반복 발행을 source로 구분합니다.시작·정지 참여
외부 연결이나 백그라운드 작업처럼 명시적 시작·정지 계약이 필요하면
SmartLifecycle로 컨텍스트 lifecycle에 참여시킵니다.
목표는 무조건 늦게 실행하는 것이 아니라, 싱글톤 생성 lock 안의 로컬 준비와 컨텍스트 수준의 시작 책임을 분리하는 것입니다.
INIT FAILURE · LOCAL ROLLBACK
초기화가 실패하면 아직 등록되지 않은 로컬 자원을 그 자리에서 되돌린다
후보 자원 열기
init method가 아직 필드에 공개하지 않은
candidate를 얻고 로컬 변수로 보관합니다.검증·준비 실패
후속 준비가 예외를 던지면 이 빈의 초기화는 완료되지 않았고 준비된 싱글톤으로 공개되지 않습니다.
같은 경계에서 즉시 정리
candidate.close()를 시도하고, 정리 예외가 나면 최초 초기화 예외에 suppressed exception으로 붙여 원인을 보존합니다.원래 실패 다시 던지기
초기화가 끝나지 않아 이 인스턴스의 일반 destroy 등록에 의존할 수 없으므로 원래 예외를 다시 던집니다. refresh 중이면 컨텍스트 시작 실패로 전파됩니다.
필드는 모든 준비가 성공한 마지막 순간에만 대입하고, 정리 로직은 중복 호출에도 안전하게 설계합니다. refresh 중 실패했다면 이미 등록된 다른 싱글톤의 종료는 컨테이너 rollback 경로가 별도로 처리합니다.
성공한 빈은 “초기화 → 공개·사용 → 정상 destroy”를 따르고, 실패한 초기화의 임시 자원은 “획득 → 실패 → 로컬 rollback → 원인 보존”으로 닫힙니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 조기 참조 예외가 없는 정상 경로는 생성과 의존성 채움, 로컬 초기화 성공, 공개·업무 사용, 정상 컨텍스트 close의 destroy로 이어진다. 별도 실패 경로는 init이 candidate를 획득하고 준비가 실패한 뒤 같은 init에서 close를 시도하고 정리 예외를 suppressed로 추가하여 원래 실패를 rethrow한다. 실패한 이 빈의 destroy 등록이나 강제 프로세스 종료의 콜백에 의존하지 않고, 이미 등록된 다른 빈의 refresh rollback은 별도 컨테이너 경로다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 7 | 조기 참조 예외가 없는 정상 경로는 생성과 의존성 채움, 로컬 초기화 성공, 공개·업무 사용, 정상 컨텍스트 close의 destroy로 이어진다. 별도 실패 경로는 init이 candidate를 획득하고 준비가 실패한 뒤 같은 init에서 close를 시도하고 정리 예외를 suppressed로 추가하여 원래 실패를 rethrow한다. 실패한 이 빈의 destroy 등록이나 강제 프로세스 종료의 콜백에 의존하지 않고, 이미 등록된 다른 빈의 refresh rollback은 별도 컨테이너 경로다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 생성과 로컬 준비 빈 생성과 의존성 주입 뒤 init이 자원을 획득해 준비합니다. | 정상: 초기화 진행 · 실패: 같은 init에서 정리 | 공개 전에 로컬 자원의 책임을 집니다. |
| 성공 공개·실패 정리 성공한 빈만 업무에 공개하고 실패 시 close·suppressed 뒤 원 실패를 던집니다. | 성공: 공개·사용 · 실패: 로컬 close·rethrow | 실패한 빈의 destroy 등록에 기대지 않습니다. |
| 정상 context 종료 정상 close에서 등록된 destroy가 실행됩니다. | 정상: destroy · 강제 종료: callback 보장 없음 | 정상 공개 자원의 컨테이너 수명이 끝납니다. |
빈은 초기화 뒤 공개되고 정상 종료와 초기화 실패는 서로 다른 경계에서 자원을 정리한다
생성·의존성·init 준비·공개/사용·destroy와 init 실패의 로컬 자원 정리를 실제 소유 범위로 나눈다.
SINGLETON PUBLICATION · TWO CLEANUP BOUNDARIES
빈은 초기화 뒤 공개되고 정상 종료와 초기화 실패는 서로 다른 경계에서 자원을 정리한다
컨테이너는 초기화가 성공한 빈만 준비된 싱글톤으로 공개합니다. 정상 종료는 등록된 destroy callback이 맡지만, 초기화 도중 얻었다가 실패한 로컬 자원은 그 초기화 메서드가 직접 되돌려야 합니다.
정상 경로와 실패 경로는 서로 다른 소유권 경계입니다. 실패한 후보는 초기화 코드가 즉시 닫고, 정상 등록된 빈은 컨텍스트 종료가 정리합니다. 원래 실패를 정리 실패로 덮지 않습니다.
NORMAL PATH · PUBLISH THEN DESTROY
로컬 준비가 끝난 뒤 사용되고 컨텍스트 종료 때 정리된다
생성·의존성 채우기
컨테이너가 인스턴스를 만들고 필요한 의존성과 설정 값을 채웁니다.
로컬 검증·준비
@PostConstruct같은 일반 init callback은 싱글톤 생성 lock 안에서 설정을 검증하고 로컬 자료구조를 준비한 뒤 반환합니다.공개·업무 사용
초기화와 후처리가 성공해야 준비된 싱글톤으로 공개되고 다른 빈과 업무 호출이 사용합니다.
정상 종료
컨텍스트를 정상적으로 닫으면 등록된
@PreDestroy같은 destroy callback이 소유 자원을 정리합니다.
강제 프로세스 종료에서는 callback 실행을 보장할 수 없습니다. 애플리케이션이 컨텍스트를 정상적으로 닫는 경로를 가져야 합니다.
POST-SINGLETON STARTUP
외부·고비용 시작은 일반 init callback 밖에 둔다
일반 init의 책임
의존성 확인, 값 검증, 로컬 캐시 조립처럼 짧고 실패 범위가 빈 하나에 가까운 준비를 수행합니다.
모든 regular singleton 이후
BeanFactory bootstrap마다 한 번이면
SmartInitializingSingleton을 사용합니다.ContextRefreshedEvent는 계층 전파와 반복 발행을 source로 구분합니다.시작·정지 참여
외부 연결이나 백그라운드 작업처럼 명시적 시작·정지 계약이 필요하면
SmartLifecycle로 컨텍스트 lifecycle에 참여시킵니다.
목표는 무조건 늦게 실행하는 것이 아니라, 싱글톤 생성 lock 안의 로컬 준비와 컨텍스트 수준의 시작 책임을 분리하는 것입니다.
INIT FAILURE · LOCAL ROLLBACK
초기화가 실패하면 아직 등록되지 않은 로컬 자원을 그 자리에서 되돌린다
후보 자원 열기
init method가 아직 필드에 공개하지 않은
candidate를 얻고 로컬 변수로 보관합니다.검증·준비 실패
후속 준비가 예외를 던지면 이 빈의 초기화는 완료되지 않았고 준비된 싱글톤으로 공개되지 않습니다.
같은 경계에서 즉시 정리
candidate.close()를 시도하고, 정리 예외가 나면 최초 초기화 예외에 suppressed exception으로 붙여 원인을 보존합니다.원래 실패 다시 던지기
초기화가 끝나지 않아 이 인스턴스의 일반 destroy 등록에 의존할 수 없으므로 원래 예외를 다시 던집니다. refresh 중이면 컨텍스트 시작 실패로 전파됩니다.
필드는 모든 준비가 성공한 마지막 순간에만 대입하고, 정리 로직은 중복 호출에도 안전하게 설계합니다. refresh 중 실패했다면 이미 등록된 다른 싱글톤의 종료는 컨테이너 rollback 경로가 별도로 처리합니다.
성공한 빈은 “초기화 → 공개·사용 → 정상 destroy”를 따르고, 실패한 초기화의 임시 자원은 “획득 → 실패 → 로컬 rollback → 원인 보존”으로 닫힙니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 조기 참조 예외가 없는 정상 경로는 생성과 의존성 채움, 로컬 초기화 성공, 공개·업무 사용, 정상 컨텍스트 close의 destroy로 이어진다. 별도 실패 경로는 init이 candidate를 획득하고 준비가 실패한 뒤 같은 init에서 close를 시도하고 정리 예외를 suppressed로 추가하여 원래 실패를 rethrow한다. 실패한 이 빈의 destroy 등록이나 강제 프로세스 종료의 콜백에 의존하지 않고, 이미 등록된 다른 빈의 refresh rollback은 별도 컨테이너 경로다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 7 | 조기 참조 예외가 없는 정상 경로는 생성과 의존성 채움, 로컬 초기화 성공, 공개·업무 사용, 정상 컨텍스트 close의 destroy로 이어진다. 별도 실패 경로는 init이 candidate를 획득하고 준비가 실패한 뒤 같은 init에서 close를 시도하고 정리 예외를 suppressed로 추가하여 원래 실패를 rethrow한다. 실패한 이 빈의 destroy 등록이나 강제 프로세스 종료의 콜백에 의존하지 않고, 이미 등록된 다른 빈의 refresh rollback은 별도 컨테이너 경로다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 생성과 로컬 준비 빈 생성과 의존성 주입 뒤 init이 자원을 획득해 준비합니다. | 정상: 초기화 진행 · 실패: 같은 init에서 정리 | 공개 전에 로컬 자원의 책임을 집니다. |
| 성공 공개·실패 정리 성공한 빈만 업무에 공개하고 실패 시 close·suppressed 뒤 원 실패를 던집니다. | 성공: 공개·사용 · 실패: 로컬 close·rethrow | 실패한 빈의 destroy 등록에 기대지 않습니다. |
| 정상 context 종료 정상 close에서 등록된 destroy가 실행됩니다. | 정상: destroy · 강제 종료: callback 보장 없음 | 정상 공개 자원의 컨테이너 수명이 끝납니다. |
그림의 왼쪽은 정상 경로입니다. 빈이 만들어지고 의존성이 채워진 뒤 로컬 초기화에 성공하면 준비된 싱글톤으로 공개되어 업무에 쓰이고, 컨텍스트를 정상적으로 닫을 때 등록된 소멸 콜백이 실행됩니다. 오른쪽은 별도의 실패 경로로, 초기화 메서드가 아직 필드에 공개하지 않은 후보를 얻은 뒤 준비에 실패하면 같은 메서드 안에서 후보를 닫고, 정리 예외는 억제된 예외로 붙여 원래 예외를 다시 던집니다. 실패한 빈의 소멸 콜백에 기대지 않으며, 그 사이 이미 등록된 다른 싱글톤의 정리는 컨테이너가 시작을 되돌리는 별도의 경로가 맡습니다.
수정할 수 없는 타입의 콜백
직접 작성한 애플리케이션 클래스에는 @PostConstruct와 @PreDestroy가 의도를 가장 잘 드러냅니다. 소스를 고칠 수 없는 라이브러리 타입에는 @Bean(initMethod, destroyMethod)로 공개된 메서드를 지정할 수 있습니다. Java 구성의 @Bean은 소멸 메서드를 따로 지정하지 않으면 인자 없는 public 메서드 close나 shutdown을 찾아 소멸 메서드로 추론하고, AutoCloseable과 Closeable도 알아봅니다. destroyMethod = ""는 이 추론과 사용자 지정 소멸 메서드를 끄지만, 객체가 직접 구현한 DisposableBean.destroy()까지 끄지는 않습니다.
package example.lifecycle;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
class BeanMethodLifecycleTest {
static final class Events {
private final List<String> values = new ArrayList<>();
void add(String value) {
values.add(value);
}
List<String> snapshot() {
return List.copyOf(values);
}
}
static final class VendorCodec {
private final Events events;
VendorCodec(Events events) {
this.events = events;
}
public void prepare() {
events.add("vendor-prepare");
}
public void shutdown() {
events.add("vendor-shutdown");
}
}
static final class StandardClient implements AutoCloseable {
private final Events events;
StandardClient(Events events) {
this.events = events;
}
@Override
public void close() {
events.add("auto-close");
}
}
static final class InferenceDisabledClient {
private final Events events;
InferenceDisabledClient(Events events) {
this.events = events;
}
public void shutdown() {
events.add("disabled-shutdown");
}
}
static final class SpringDisposable implements DisposableBean {
private final Events events;
SpringDisposable(Events events) {
this.events = events;
}
@Override
public void destroy() {
events.add("spring-destroy");
}
}
@Configuration(proxyBeanMethods = false)
static class Config {
@Bean
Events events() {
return new Events();
}
@Bean(initMethod = "prepare", destroyMethod = "shutdown")
VendorCodec vendorCodec(Events events) {
return new VendorCodec(events);
}
@Bean
StandardClient standardClient(Events events) {
return new StandardClient(events);
}
@Bean(destroyMethod = "")
InferenceDisabledClient inferenceDisabledClient(Events events) {
return new InferenceDisabledClient(events);
}
@Bean(destroyMethod = "")
SpringDisposable springDisposable(Events events) {
return new SpringDisposable(events);
}
}
@Test
void 명시_추론_비활성화와_인터페이스_콜백을_구분한다() {
var context = new AnnotationConfigApplicationContext(Config.class);
var events = context.getBean(Events.class);
assertThat(events.snapshot()).containsExactly("vendor-prepare");
context.close();
assertThat(events.snapshot()).containsExactlyInAnyOrder(
"vendor-prepare",
"vendor-shutdown", "auto-close", "spring-destroy");
assertThat(events.snapshot()).doesNotContain("disabled-shutdown");
}
}메서드 이름을 문자열로 지정했다면, 라이브러리를 올릴 때 그 메서드가 아직 있는지 실행 테스트로 확인합니다. 바깥의 클라이언트 객체를 애플리케이션이 소유하지 않는다면, 추론을 끄기 전에 누가 그 객체를 닫아야 하는지부터 정해야 합니다.
같은 빈에 메커니즘을 겹쳤을 때
한 빈에 이름이 다른 콜백 방식을 함께 선언하면 Spring은 다음 순서로 실행합니다.
| 구간 | 첫 번째 | 두 번째 | 세 번째 |
|---|---|---|---|
| 초기화 | @PostConstruct | InitializingBean.afterPropertiesSet() | 사용자 지정 initMethod |
| 소멸 | @PreDestroy | DisposableBean.destroy() | 사용자 지정 destroyMethod |
같은 메서드 이름을 두 방식에 함께 등록했다면 그 메서드는 한 번만 호출됩니다. 그렇다고 한 자원을 여러 콜백에 나누어 닫는 설계가 좋아지는 것은 아닙니다. 애플리케이션 클래스는 한 가지 방식을 고르고, 이 조합 규칙은 프레임워크나 어댑터를 확인할 때 씁니다.
package example.lifecycle;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
class CombinedLifecycleMechanismsTest {
static final class Events {
private final List<String> values = new ArrayList<>();
void add(String value) {
values.add(value);
}
List<String> snapshot() {
return List.copyOf(values);
}
}
static final class CombinedCallbacks
implements InitializingBean, DisposableBean {
private final Events events;
CombinedCallbacks(Events events) {
this.events = events;
}
@PostConstruct
void annotatedInit() {
events.add("post-construct");
}
@Override
public void afterPropertiesSet() {
events.add("after-properties-set");
}
public void customInit() {
events.add("custom-init");
}
@PreDestroy
void annotatedDestroy() {
events.add("pre-destroy");
}
@Override
public void destroy() {
events.add("disposable-destroy");
}
public void customDestroy() {
events.add("custom-destroy");
}
}
static final class SharedMethodCallbacks {
private final Events events;
SharedMethodCallbacks(Events events) {
this.events = events;
}
@PostConstruct
public void initialize() {
events.add("initialize-once");
}
@PreDestroy
public void cleanup() {
events.add("cleanup-once");
}
}
@Configuration(proxyBeanMethods = false)
static class CombinedConfig {
@Bean
Events events() {
return new Events();
}
@Bean(initMethod = "customInit", destroyMethod = "customDestroy")
CombinedCallbacks combinedCallbacks(Events events) {
return new CombinedCallbacks(events);
}
}
@Configuration(proxyBeanMethods = false)
static class SharedNameConfig {
@Bean
Events events() {
return new Events();
}
@Bean(initMethod = "initialize", destroyMethod = "cleanup")
SharedMethodCallbacks sharedMethodCallbacks(Events events) {
return new SharedMethodCallbacks(events);
}
}
@Test
void 서로_다른_메서드는_공식_순서로_실행된다() {
var context = new AnnotationConfigApplicationContext(
CombinedConfig.class);
var events = context.getBean(Events.class);
assertThat(events.snapshot()).containsExactly(
"post-construct",
"after-properties-set",
"custom-init");
context.close();
assertThat(events.snapshot()).containsExactly(
"post-construct",
"after-properties-set",
"custom-init",
"pre-destroy",
"disposable-destroy",
"custom-destroy");
}
@Test
void 같은_메서드_이름은_두_번_호출하지_않는다() {
var context = new AnnotationConfigApplicationContext(
SharedNameConfig.class);
var events = context.getBean(Events.class);
assertThat(events.snapshot()).containsExactly("initialize-once");
context.close();
assertThat(events.snapshot()).containsExactly(
"initialize-once", "cleanup-once");
}
}CALLBACK MECHANISM · DEPENDENCY ORDER
콜백 방식은 소유권에 맞춰 고르고 @Order가 아닌 의존 관계로 초기화 순서를 정한다
애플리케이션 소유 타입에는 annotation callback이 기본 선택이고, 수정할 수 없는 SDK 객체에는 @Bean method가 경계를 제공합니다. 여러 빈의 선후 관계는 실제 의존성 또는 @DependsOn으로 표현합니다.
첫 줄은 필요로 하는 방향, 다음 두 줄은 각각 시작·종료의 선후입니다. @Order는 이 선후를 정하지 않습니다. 직접 참조가 없다면 실제 side effect 선행 조건에만 @DependsOn을 사용합니다.
COMBINED CALLBACK ORDER
여러 방식을 함께 쓰면 정해진 순서로 호출하고 같은 메서드는 한 번만 부른다
초기화 1
@PostConstruct초기화 2 ·
InitializingBeanafterPropertiesSet()초기화 3
@Bean(initMethod = "…")같은 custom init method
종료 1
@PreDestroy종료 2
DisposableBean.destroy()종료 3
@Bean(destroyMethod = "…")같은 custom destroy method
동일한 메서드를 annotation과 custom method 등 여러 방식에 중복 지정해도 그 메서드는 한 번만 호출됩니다.
CREATE FORWARD · DESTROY REVERSE
실제 의존성을 먼저 표현하고 side-effect 선행 조건만 @DependsOn으로 보충한다
직접 의존성
생성자나
@Beanfactory parameter로Worker → Client를 표현하면Client가 먼저 생성·초기화됩니다.간접 선행 조건
객체 참조는 없지만 다른 빈의 초기화 side effect가 반드시 먼저여야 할 때만
@DependsOn을 사용합니다.싱글톤 역순 종료
컨텍스트 종료에서는 dependent singleton인
Worker를 먼저 파괴하고 그 dependency인Client를 나중에 파괴합니다.
@Order는 주입 컬렉션의 정렬 같은 우선순위 계약입니다. 싱글톤의 생성·초기화 순서를 정하지 않습니다.
OWNERSHIP-BASED SELECTION
타입 소유권과 시작 범위에 맞는 mechanism 하나를 우선 고른다
| 소유·상황 | 대표 lifecycle 계약 | 경계·주의 |
|---|---|---|
| 애플리케이션 소유 타입 | 초기화 · 종료 · |
Spring interface 결합 없이 타입 안에서 로컬 준비와 소유 자원 정리를 표현합니다. |
| Spring 결합을 의도한 타입 | 초기화 · 종료 · |
프레임워크 callback interface에 대한 결합이 설계상 의도일 때 사용합니다. |
| 수정 불가 SDK 객체 | 초기화 · 종료 · |
구성 경계에서 public no-arg method를 지정합니다. 기본 destroy inference는 public close 또는 shutdown을 찾습니다. |
| 외부·고비용 시작 | 후처리 · event · 시작 · 중지 · |
모든 regular singleton 이후의 일회성 작업이나 컨텍스트 수준 start/stop 계약으로 분리합니다. 정상 종료에서는 lifecycle stop 뒤 소유 자원의 destroy callback을 실행합니다. |
소유권과 작업 범위에 따른 초기화·종료 callback 선택
애플리케이션 소유 타입
대표 lifecycle 계약
초기화 · @PostConstruct
종료 · @PreDestroy
경계·주의
Spring 결합을 의도한 타입
대표 lifecycle 계약
초기화 · InitializingBean.afterPropertiesSet()
종료 · DisposableBean.destroy()
경계·주의
수정 불가 SDK 객체
대표 lifecycle 계약
초기화 · @Bean(initMethod = "…")
종료 · @Bean(destroyMethod = "…")
경계·주의
close 또는 shutdown을 찾습니다.외부·고비용 시작
대표 lifecycle 계약
후처리 · SmartInitializingSingleton
event · ContextRefreshedEvent
시작 · SmartLifecycle.start()
중지 · SmartLifecycle.stop()
경계·주의
@Bean(destroyMethod = "")는 custom·inferred close를 끕니다. 객체가 구현한 DisposableBean.destroy()까지 끄는 설정은 아닙니다.
callback mechanism은 “누가 타입을 소유하고 언제 시작·종료해야 하는가”로 고르고, 빈 사이의 선후 관계는 “누가 누구를 실제로 필요로 하는가”로 표현합니다.
그림의 위쪽은 Worker가 Client를 필요로 하는 관계 하나를 세 줄로 나누어 보여 줍니다. 의존하는 방향은 Worker에서 Client로, 초기화는 Client가 먼저, 정상 종료에서는 Worker가 먼저입니다. 세 줄은 같은 관계의 서로 다른 뜻이며, 본문의 다음 테스트는 이름만 Coordinator와 Infrastructure로 바꾸어 같은 순서를 확인합니다. 가운데는 앞의 표와 같은 콜백 조합 순서를, 아래쪽 표는 타입을 누가 소유하고 언제 시작하는지에 따라 콜백 방식을 고르는 기준을 정리합니다.
외부 시작과 빈 의존 순서
일반 초기화 콜백은 컨테이너의 싱글톤 생성 잠금 안에서 실행됩니다. 이때 다른 빈을 조회하거나 오래 걸리는 외부 작업을 시작하면 초기화가 서로를 기다리며 멈추거나 부팅이 느려질 수 있습니다. 그래서 모든 일반 싱글톤이 준비된 뒤 그 빈 팩토리가 시작할 때마다 한 번 실행할 작업은 SmartInitializingSingleton.afterSingletonsInstantiated()를 고려합니다. ContextRefreshedEvent는 컨텍스트가 초기화되거나 다시 refresh될 때마다 발행되고, 자식 컨텍스트의 이벤트가 부모로 전해질 수도 있으므로, 한 번만 처리해야 한다면 이벤트의 출처와 중복 실행을 직접 제한합니다. 자동 시작과 중지, 단계(phase), 비동기 종료 대기가 필요하면 SmartLifecycle이 더 직접적인 방법입니다.
SmartLifecycle은 단계 값이 낮을수록 먼저 시작하고 나중에 멈춥니다. 컨텍스트를 정상적으로 닫으면 생명 주기의 stop이 일반 소멸 콜백보다 먼저 진행되지만, 시작이 취소되는 것 같은 비정상 경로에서는 stop 없이 destroy가 올 수도 있으므로 destroy 자체도 안전해야 합니다.
package example.lifecycle;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.context.SmartLifecycle;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
class ExternalStartupAndDependencyOrderTest {
static final class Events {
private final List<String> values = new ArrayList<>();
void add(String value) {
values.add(value);
}
List<String> snapshot() {
return List.copyOf(values);
}
}
static final class LocalIndex {
private final Events events;
LocalIndex(Events events) {
this.events = events;
}
@PostConstruct
void prepare() {
events.add("local-prepare");
}
}
static final class PostSingletonTask
implements SmartInitializingSingleton {
private final Events events;
PostSingletonTask(Events events) {
this.events = events;
}
@Override
public void afterSingletonsInstantiated() {
events.add("after-singletons");
}
}
static final class ManagedWorker implements SmartLifecycle {
private final Events events;
private final String name;
private final int phase;
private volatile boolean running;
ManagedWorker(Events events, String name, int phase) {
this.events = events;
this.name = name;
this.phase = phase;
}
@Override
public void start() {
if (running) {
return;
}
running = true;
events.add(name + "-start");
}
@Override
public void stop() {
if (!running) {
return;
}
running = false;
events.add(name + "-stop");
}
@Override
public void stop(Runnable callback) {
stop();
callback.run();
}
@Override
public boolean isRunning() {
return running;
}
@Override
public boolean isAutoStartup() {
return true;
}
@Override
public int getPhase() {
return phase;
}
@PreDestroy
void destroy() {
if (running) {
stop();
}
events.add(name + "-destroy");
}
}
static final class Infrastructure {
private final Events events;
Infrastructure(Events events) {
this.events = events;
events.add("infrastructure-create");
}
@PreDestroy
void destroy() {
events.add("infrastructure-destroy");
}
}
static final class Coordinator {
private final Events events;
Coordinator(Events events) {
this.events = events;
events.add("coordinator-create");
}
@PreDestroy
void destroy() {
events.add("coordinator-destroy");
}
}
@Configuration(proxyBeanMethods = false)
static class StartupConfig {
@Bean
Events events() {
return new Events();
}
@Bean
PostSingletonTask postSingletonTask(Events events) {
return new PostSingletonTask(events);
}
@Bean
ManagedWorker earlyWorker(Events events) {
return new ManagedWorker(events, "early", -100);
}
@Bean
ManagedWorker lateWorker(Events events) {
return new ManagedWorker(events, "late", 100);
}
@Bean
LocalIndex localIndex(Events events) {
return new LocalIndex(events);
}
}
@Configuration(proxyBeanMethods = false)
static class DependencyConfig {
@Bean
Events events() {
return new Events();
}
@Bean
@DependsOn("infrastructure")
Coordinator coordinator(Events events) {
return new Coordinator(events);
}
@Bean
Infrastructure infrastructure(Events events) {
return new Infrastructure(events);
}
}
@Test
void 외부_시작은_일반_싱글톤_초기화_뒤에_오고_destroy보다_먼저_멈춘다() {
var context = new AnnotationConfigApplicationContext(
StartupConfig.class);
var events = context.getBean(Events.class);
assertThat(events.snapshot()).containsExactly(
"local-prepare",
"after-singletons",
"early-start",
"late-start");
context.close();
var afterClose = events.snapshot();
assertThat(afterClose).hasSize(8);
assertThat(afterClose.subList(0, 6)).containsExactly(
"local-prepare",
"after-singletons",
"early-start",
"late-start",
"late-stop",
"early-stop");
assertThat(afterClose.subList(6, 8)).containsExactlyInAnyOrder(
"early-destroy", "late-destroy");
}
@Test
void dependsOn은_생성_순서와_싱글톤_역순_정리를_함께_고정한다() {
var context = new AnnotationConfigApplicationContext(
DependencyConfig.class);
var events = context.getBean(Events.class);
assertThat(events.snapshot()).containsExactly(
"infrastructure-create", "coordinator-create");
context.close();
assertThat(events.snapshot()).containsExactly(
"infrastructure-create",
"coordinator-create",
"coordinator-destroy",
"infrastructure-destroy");
}
}빈 사이의 순서는 먼저 실제 참조를 생성자 파라미터로 나타내는 것이 원칙입니다. 직접 참조는 없지만 인프라의 순서만 필요할 때 @DependsOn을 쓰면 의존 대상이 먼저 만들어지고, 싱글톤을 정리할 때는 의존하는 쪽이 먼저 정리됩니다. @Order는 이런 생성과 소멸 순서를 정하는 애노테이션이 아닙니다. 앞 글에서 본 컬렉션 주입의 정렬 순서를 생명 주기의 시작 순서로 옮겨 생각하지 않습니다.
정리
- 보통의 생성 경로에서 컨테이너는 의존성을 채우고 초기화 콜백을 실행한 뒤에야 빈을 공개하며, 정상적으로 등록된 싱글톤은 컨텍스트를 닫을 때 소멸 콜백을 받습니다.
- 소멸 콜백은 초기화가 성공한 뒤에 등록되므로, 초기화 도중 얻은 자원은 그 초기화 코드가 실패한 자리에서 닫고 원래 예외를 남깁니다.
- 직접 작성한 클래스에는
@PostConstruct와@PreDestroy를, 고칠 수 없는 타입에는@Bean(initMethod, destroyMethod)를 쓰며,@Bean은close와shutdown을 소멸 메서드로 추론합니다. - 일반 초기화 콜백에서는 짧은 로컬 준비만 하고, 외부 연결이나 백그라운드 작업은
SmartInitializingSingleton이나SmartLifecycle로 옮깁니다. - 빈의 생성 순서는 실제 의존성이나
@DependsOn으로 나타내며, 정리는 그 역순입니다.@Order는 생성 순서를 정하지 않습니다.
연습 문제
1. 실패에도 한 번만 닫는 작성기
PostArchiveWriter는 로컬 보관 파일을 열어 헤더를 준비하고, 종료할 때 flush와 close를 실행합니다. 헤더를 쓰다가 실패하면 열린 후보를 바로 닫고, flush가 실패해도 close를 건너뛰지 않아야 합니다. 성공, 초기화 실패, flush 실패와 중복 종료를 모두 실행하는 테스트를 작성하세요. Spring은 소멸 콜백의 예외를 기록하고 다른 빈의 정리를 계속할 수 있으므로, context.close()가 그 예외를 호출한 쪽에 그대로 던진다고 가정하지 않습니다.
해설 보기
flush 실패 테스트는 종료 메서드를 직접 호출해 첫 실패가 남는지와 close가 실행되는지를 확인하고, 이어지는 컨텍스트 종료가 이미 넘긴 자원을 다시 정리하지 않는지도 확인합니다.
package example.lifecycle;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowable;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
class PostArchiveWriterExerciseTest {
static final class Events {
private final List<String> values = new ArrayList<>();
void add(String value) {
values.add(value);
}
List<String> snapshot() {
return List.copyOf(values);
}
}
interface ArchiveFile {
void writeHeader();
void flush();
void close();
}
interface ArchiveFiles {
ArchiveFile open();
}
static final class FakeArchiveFile implements ArchiveFile {
private final Events events;
private final boolean failHeader;
private final boolean failFlush;
private boolean closed;
FakeArchiveFile(
Events events,
boolean failHeader,
boolean failFlush) {
this.events = events;
this.failHeader = failHeader;
this.failFlush = failFlush;
}
@Override
public void writeHeader() {
events.add(failHeader ? "header-failed" : "header");
if (failHeader) {
throw new IllegalStateException("header rejected");
}
}
@Override
public void flush() {
events.add(failFlush ? "flush-failed" : "flush");
if (failFlush) {
throw new IllegalStateException("flush rejected");
}
}
@Override
public void close() {
if (closed) {
return;
}
closed = true;
events.add("close");
}
}
static final class PostArchiveWriter {
private final ArchiveFiles files;
private ArchiveFile writer;
PostArchiveWriter(ArchiveFiles files) {
this.files = files;
}
@PostConstruct
void initialize() {
var candidate = files.open();
try {
candidate.writeHeader();
writer = candidate;
} catch (RuntimeException failure) {
try {
candidate.close();
} catch (RuntimeException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
throw failure;
}
}
@PreDestroy
void shutdown() {
var current = writer;
writer = null;
if (current == null) {
return;
}
RuntimeException pending = null;
try {
current.flush();
} catch (RuntimeException flushFailure) {
pending = flushFailure;
}
try {
current.close();
} catch (RuntimeException closeFailure) {
if (pending == null) {
pending = closeFailure;
} else {
pending.addSuppressed(closeFailure);
}
}
if (pending != null) {
throw pending;
}
}
}
private static AnnotationConfigApplicationContext contextFor(
Events events,
FakeArchiveFile file) {
var context = new AnnotationConfigApplicationContext();
context.registerBean(
PostArchiveWriter.class,
() -> new PostArchiveWriter(() -> {
events.add("open");
return file;
}));
return context;
}
@Test
void 성공하면_header_flush_close를_한_번씩_실행한다() {
var events = new Events();
var file = new FakeArchiveFile(events, false, false);
var context = contextFor(events, file);
context.refresh();
context.close();
context.close();
assertThat(events.snapshot()).containsExactly(
"open", "header", "flush", "close");
}
@Test
void header가_실패하면_등록되지_않은_후보를_즉시_닫는다() {
var events = new Events();
var file = new FakeArchiveFile(events, true, false);
var context = contextFor(events, file);
Throwable failure = catchThrowable(context::refresh);
assertThat(failure).isInstanceOf(BeanCreationException.class);
assertThat(((BeanCreationException) failure).getMostSpecificCause())
.hasMessage("header rejected");
context.close();
assertThat(events.snapshot()).containsExactly(
"open", "header-failed", "close");
}
@Test
void shutdown_구현은_flush가_실패해도_close하고_다시_정리하지_않는다() {
var events = new Events();
var file = new FakeArchiveFile(events, false, true);
var context = contextFor(events, file);
context.refresh();
var writer = context.getBean(PostArchiveWriter.class);
Throwable failure = catchThrowable(writer::shutdown);
assertThat(failure).hasMessage("flush rejected");
context.close();
assertThat(events.snapshot()).containsExactly(
"open", "header", "flush-failed", "close");
}
}./gradlew test --tests example.lifecycle.PostArchiveWriterExerciseTest로 실행합니다. 이 연습의 핵심은 try/finally라는 문법보다, 자원을 정리할 쪽이 바뀌는 시점입니다. 후보가 필드에 들어가기 전에는 초기화 코드가 정리를 맡고, 필드에 들어간 뒤에는 여러 번 호출해도 안전한 종료 코드가 맡습니다. 두 경로 모두 첫 실패를 남기면서 할 수 있는 정리를 끝까지 시도합니다.
다음 글 프로토타입과 요청 스코프에서는 싱글톤보다 짧은 수명이 정말 필요한 객체를 프로토타입과 요청 스코프로 등록하고, 오래 사는 싱글톤이 수명이 짧은 객체를 쓸 때 생기는 문제를 다룹니다.