본문으로 건너뛰기

안동민 개발노트

본문 시작

빈 생명주기 콜백

일반 초기화 콜백과 외부 시작 경계를 분리하고, 정상 종료와 초기화 실패에서 자원을 정확히 한 번 정리하는 계약을 실행 테스트로 검증합니다.

Spring 빈의 생성은 생성자 호출만으로 끝나지 않습니다.

컨테이너는 의존성을 채운 뒤 초기화 콜백을 실행하고, 그 과정이 성공한 인스턴스만 다른 빈이 사용할 수 있도록 공개합니다. 정상적으로 등록된 싱글톤은 컨텍스트 종료 때 소멸 콜백을 받습니다.

여기서 두 경계를 섞지 않는 것이 중요합니다.

  • 생성자는 필수 참조와 값 범위를 고정합니다.
  • 일반 초기화 콜백은 구성 상태를 검증하고 로컬 자료 구조를 준비합니다.
  • 네트워크 연결, 백그라운드 작업, 오래 걸리는 준비는 모든 일반 싱글톤 초기화 뒤의 별도 시작 경계가 맡습니다.
  • 정상 종료는 컨테이너의 소멸 콜백이 맡지만, 초기화 도중 얻은 자원은 획득한 코드가 즉시 되돌립니다.

초기화에 성공한 빈만 공개된다

@PostConstruct@PreDestroy를 사용하는 클래스는 Spring 전용 인터페이스에 결합되지 않습니다. 현재 Java 스택에서는 javax.annotation이 아니라 jakarta.annotation 패키지를 사용하며, jakarta.annotation-api가 클래스패스에 있어야 합니다.

다음 픽스처는 생성자, 초기화, 업무 호출, 소멸의 순서를 실제 컨텍스트에서 고정합니다. 초기화 작업은 외부 접속이 아니라 로컬 인덱스 준비로 제한했습니다.

src/test/java/example/lifecycle/ManagedCallbackSequenceTest.java
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으로 붙여 첫 원인을 보존합니다.

src/test/java/example/lifecycle/InitializationFailureCleanupTest.java
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로 바꾸는 종료 구현은 재진입이나 중복 호출에서도 같은 자원을 두 번 닫지 않게 합니다. 초기화 실패 경로에도 이 멱등성 규칙을 적용하면 테스트 정리와 컨텍스트 실패 복구가 겹쳐도 소유권이 흔들리지 않습니다.

싱글톤 빈이 로컬 초기화를 마친 뒤 공개되는 정상 경로와, 초기화 도중 얻은 자원을 같은 초기화 메서드가 즉시 되돌리는 실패 경로를 비교합니다.

SINGLETON PUBLICATION · TWO CLEANUP BOUNDARIES

빈은 초기화 뒤 공개되고 정상 종료와 초기화 실패는 서로 다른 경계에서 자원을 정리한다

컨테이너는 초기화가 성공한 빈만 준비된 싱글톤으로 공개합니다. 정상 종료는 등록된 destroy callback이 맡지만, 초기화 도중 얻었다가 실패한 로컬 자원은 그 초기화 메서드가 직접 되돌려야 합니다.

NORMAL PATH · PUBLISH THEN DESTROY

로컬 준비가 끝난 뒤 사용되고 컨텍스트 종료 때 정리된다

  1. 생성·의존성 채우기

    컨테이너가 인스턴스를 만들고 필요한 의존성과 설정 값을 채웁니다.

  2. 로컬 검증·준비

    @PostConstruct 같은 일반 init callback은 싱글톤 생성 lock 안에서 설정을 검증하고 로컬 자료구조를 준비한 뒤 반환합니다.

  3. 공개·업무 사용

    초기화와 후처리가 성공해야 준비된 싱글톤으로 공개되고 다른 빈과 업무 호출이 사용합니다.

  4. 정상 종료

    컨텍스트를 정상적으로 닫으면 등록된 @PreDestroy 같은 destroy callback이 소유 자원을 정리합니다.

강제 프로세스 종료에서는 callback 실행을 보장할 수 없습니다. 애플리케이션이 컨텍스트를 정상적으로 닫는 경로를 가져야 합니다.

POST-SINGLETON STARTUP

외부·고비용 시작은 일반 init callback 밖에 둔다

  1. 일반 init의 책임

    의존성 확인, 값 검증, 로컬 캐시 조립처럼 짧고 실패 범위가 빈 하나에 가까운 준비를 수행합니다.

  2. 모든 regular singleton 이후

    BeanFactory bootstrap마다 한 번이면 SmartInitializingSingleton을 사용합니다. ContextRefreshedEvent는 계층 전파와 반복 발행을 source로 구분합니다.

  3. 시작·정지 참여

    외부 연결이나 백그라운드 작업처럼 명시적 시작·정지 계약이 필요하면 SmartLifecycle로 컨텍스트 lifecycle에 참여시킵니다.

목표는 무조건 늦게 실행하는 것이 아니라, 싱글톤 생성 lock 안의 로컬 준비와 컨텍스트 수준의 시작 책임을 분리하는 것입니다.

INIT FAILURE · LOCAL ROLLBACK

초기화가 실패하면 아직 등록되지 않은 로컬 자원을 그 자리에서 되돌린다

  1. 후보 자원 열기

    init method가 아직 필드에 공개하지 않은 candidate를 얻고 로컬 변수로 보관합니다.

  2. 검증·준비 실패

    후속 준비가 예외를 던지면 이 빈의 초기화는 완료되지 않았고 준비된 싱글톤으로 공개되지 않습니다.

  3. 같은 경계에서 즉시 정리

    candidate.close()를 시도하고, 정리 예외가 나면 최초 초기화 예외에 suppressed exception으로 붙여 원인을 보존합니다.

  4. 원래 실패 다시 던지기

    초기화가 끝나지 않아 이 인스턴스의 일반 destroy 등록에 의존할 수 없으므로 원래 예외를 다시 던집니다. refresh 중이면 컨텍스트 시작 실패로 전파됩니다.

필드는 모든 준비가 성공한 마지막 순간에만 대입하고, 정리 로직은 중복 호출에도 안전하게 설계합니다. refresh 중 실패했다면 이미 등록된 다른 싱글톤의 종료는 컨테이너 rollback 경로가 별도로 처리합니다.

성공한 빈은 “초기화 → 공개·사용 → 정상 destroy”를 따르고, 실패한 초기화의 임시 자원은 “획득 → 실패 → 로컬 rollback → 원인 보존”으로 닫힙니다.


수정할 수 없는 타입의 콜백

직접 소유한 애플리케이션 클래스에는 @PostConstruct@PreDestroy가 의도에 가깝습니다. 소스를 수정할 수 없는 라이브러리 타입에는 @Bean(initMethod, destroyMethod)로 공개된 메서드를 지정할 수 있습니다.

Java 구성의 @Bean은 기본적으로 public no-argument close 또는 shutdown을 소멸 메서드로 추론하며 AutoCloseableCloseable도 인식합니다. destroyMethod = ""는 이 추론과 명시적 사용자 소멸 메서드를 끄지만, 객체가 직접 구현한 DisposableBean.destroy() 계약까지 지우지는 않습니다.

src/test/java/example/lifecycle/BeanMethodLifecycleTest.java
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은 다음 순서로 실행합니다.

구간첫 번째두 번째세 번째
초기화@PostConstructInitializingBean.afterPropertiesSet()사용자 initMethod
소멸@PreDestroyDisposableBean.destroy()사용자 destroyMethod

같은 메서드 이름을 두 메커니즘에 동시에 등록한 경우에는 그 메서드를 한 번만 호출합니다. 이 규칙이 있다고 해서 한 자원을 여러 콜백에 나눠 닫는 설계가 좋아지는 것은 아닙니다. 애플리케이션 클래스는 한 대표 방식을 고르고, 조합 규칙은 프레임워크나 어댑터 경계를 검증할 때 사용합니다.

src/test/java/example/lifecycle/CombinedLifecycleMechanismsTest.java
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 방식을 고르고, 결합된 callback의 정확한 호출 순서와 의존 관계가 만드는 생성·역순 종료를 설명합니다.

CALLBACK MECHANISM · DEPENDENCY ORDER

콜백 방식은 소유권에 맞춰 고르고 @Order가 아닌 의존 관계로 초기화 순서를 정한다

애플리케이션 소유 타입에는 annotation callback이 기본 선택이고, 수정할 수 없는 SDK 객체에는 @Bean method가 경계를 제공합니다. 여러 빈의 선후 관계는 실제 의존성 또는 @DependsOn으로 표현합니다.

COMBINED CALLBACK ORDER

여러 방식을 함께 쓰면 정해진 순서로 호출하고 같은 메서드는 한 번만 부른다

  1. 초기화 1

    @PostConstruct

  2. 초기화 2 · InitializingBean

    afterPropertiesSet()

  3. 초기화 3

    @Bean(initMethod = "…") 같은 custom init method

  1. 종료 1

    @PreDestroy

  2. 종료 2

    DisposableBean.destroy()

  3. 종료 3

    @Bean(destroyMethod = "…") 같은 custom destroy method

동일한 메서드를 annotation과 custom method 등 여러 방식에 중복 지정해도 그 메서드는 한 번만 호출됩니다.

CREATE FORWARD · DESTROY REVERSE

실제 의존성을 먼저 표현하고 side-effect 선행 조건만 @DependsOn으로 보충한다

  1. 직접 의존성

    생성자나 @Bean factory parameter로 Worker → Client를 표현하면 Client가 먼저 생성·초기화됩니다.

  2. 간접 선행 조건

    객체 참조는 없지만 다른 빈의 초기화 side effect가 반드시 먼저여야 할 때만 @DependsOn을 사용합니다.

  3. 싱글톤 역순 종료

    컨텍스트 종료에서는 dependent singleton인 Worker를 먼저 파괴하고 그 dependency인 Client를 나중에 파괴합니다.

@Order는 주입 컬렉션의 정렬 같은 우선순위 계약입니다. 싱글톤의 생성·초기화 순서를 정하지 않습니다.

OWNERSHIP-BASED SELECTION

타입 소유권과 시작 범위에 맞는 mechanism 하나를 우선 고른다

소유권과 작업 범위에 따른 초기화·종료 callback 선택
소유·상황 대표 lifecycle 계약 경계·주의
애플리케이션 소유 타입

초기화 · @PostConstruct

종료 · @PreDestroy

Spring interface 결합 없이 타입 안에서 로컬 준비와 소유 자원 정리를 표현합니다.
Spring 결합을 의도한 타입

초기화 · InitializingBean.afterPropertiesSet()

종료 · DisposableBean.destroy()

프레임워크 callback interface에 대한 결합이 설계상 의도일 때 사용합니다.
수정 불가 SDK 객체

초기화 · @Bean(initMethod = "…")

종료 · @Bean(destroyMethod = "…")

구성 경계에서 public no-arg method를 지정합니다. 기본 destroy inference는 public close 또는 shutdown을 찾습니다.
외부·고비용 시작

후처리 · SmartInitializingSingleton

event · ContextRefreshedEvent

시작 · SmartLifecycle.start()

중지 · SmartLifecycle.stop()

모든 regular singleton 이후의 일회성 작업이나 컨텍스트 수준 start/stop 계약으로 분리합니다. 정상 종료에서는 lifecycle stop 뒤 소유 자원의 destroy callback을 실행합니다.

@Bean(destroyMethod = "")는 custom·inferred close를 끕니다. 객체가 구현한 DisposableBean.destroy()까지 끄는 설정은 아닙니다.

callback mechanism은 “누가 타입을 소유하고 언제 시작·종료해야 하는가”로 고르고, 빈 사이의 선후 관계는 “누가 누구를 실제로 필요로 하는가”로 표현합니다.


외부 시작과 빈 의존 순서

일반 초기화 콜백은 컨테이너의 싱글톤 생성 잠금 안에서 실행됩니다. 이 시점에 다른 빈을 조회하거나 오래 걸리는 외부 작업을 시작하면 초기화 교착과 느린 부팅을 만들 수 있습니다.

모든 일반 싱글톤이 준비된 뒤 해당 BeanFactory의 bootstrap마다 한 번 실행할 작업은 SmartInitializingSingleton.afterSingletonsInstantiated()을 고려합니다. ContextRefreshedEvent는 컨텍스트가 초기화되거나 다시 refresh될 때마다 발행되고 자식 컨텍스트 이벤트가 부모로 전파될 수도 있으므로, 한 번만 처리해야 한다면 이벤트 source와 중복 실행을 직접 제한합니다. 자동 시작, 중지, phase, 비동기 종료 대기가 필요하면 (Smart)Lifecycle이 더 직접적인 계약입니다.

SmartLifecycle의 낮은 phase는 먼저 시작하고 나중에 멈춥니다. 정상 컨텍스트 종료에서는 lifecycle stop이 일반 소멸 콜백보다 먼저 진행되지만, 취소된 시작 같은 비정상 경로에서는 stop 없이 destroy가 올 수도 있으므로 destroy 자체도 안전해야 합니다.

src/test/java/example/lifecycle/ExternalStartupAndDependencyOrderTest.java
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는 이 생성·소멸 순서를 정하는 애노테이션이 아닙니다. 앞 문서에서 본 컬렉션 주입과 정렬 우선순위의 계약을 생명주기 시작 순서로 옮기지 않습니다.

연습 문제: 실패에도 한 번만 닫는 작성기

PostArchiveWriter는 로컬 보관 파일을 열어 헤더를 준비하고, 종료할 때 flush와 close를 수행합니다. 헤더 쓰기가 실패하면 열린 후보를 즉시 닫고, flush가 실패해도 close를 건너뛰지 않아야 합니다.

다음 완결 테스트는 성공, 초기화 실패, flush 실패와 중복 종료를 모두 실행합니다. Spring은 소멸 콜백 예외를 기록하고 다른 빈의 정리를 계속할 수 있으므로 context.close()가 그 예외를 호출자에게 그대로 던진다고 주장하지 않습니다. flush 실패 테스트는 종료 메서드를 직접 호출해 메서드 수준의 첫 실패 보존과 close 보장을 검증하고, 이어지는 컨텍스트 종료가 이미 넘긴 소유권을 다시 정리하지 않는지 확인합니다.

src/test/java/example/lifecycle/PostArchiveWriterExerciseTest.java
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");
    }
}

이 연습의 핵심은 try/finally라는 문법 하나가 아니라 소유권 전환 시점입니다. 후보가 필드에 들어가기 전에는 초기화 코드가 책임지고, 필드에 공개된 뒤에는 멱등한 종료 코드가 책임집니다. 두 경로 모두 첫 실패를 보존하면서 가능한 정리를 끝까지 시도합니다.

다음 문서에서는 싱글톤보다 짧은 수명이 정말 필요한 객체를 프로토타입과 요청 스코프로 등록하고, 긴 수명의 싱글톤이 짧은 수명의 객체를 사용할 때 생기는 소유권 불일치를 다룹니다.