본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
3장 : 컨테이너·빈·생명주기·스코프

빈 생명주기 콜백

생성자 이후 초기화와 컨텍스트 종료 콜백의 정확한 순서를 검증하고 외부 자원 획득 실패까지 누수 없이 처리합니다.

HTTP 클라이언트, 스레드 풀, 파일 작성기처럼 사용 전 준비와 사용 후 정리가 필요한 객체는 생성만으로 끝나지 않습니다. 생성자에서 네트워크 연결을 시작하면 객체 조립 실패와 외부 장애가 섞이고, 종료 콜백을 빠뜨리면 개발 재시작과 테스트 반복에서도 자원이 남습니다. Spring 생명주기는 자원 소유자가 준비·사용·반납 시점을 명시하게 합니다.


생성자와 불변 조건

생성자는 필수 참조와 값 범위를 확인하기 좋은 위치입니다. 외부 서버가 응답할 때까지 기다리거나 백그라운드 스레드를 시작하는 작업은 아직 이릅니다. 객체가 Spring 후처리를 받기 전이고, 생성 중 예외가 나면 어떤 자원까지 얻었는지 정리하기 어렵기 때문입니다.

게시판 내보내기 클라이언트는 엔드포인트와 타임아웃을 생성자에서 검증하고 실제 연결 확인은 초기화 콜백에서 수행합니다.

src/main/java/board/export/ManagedPostExporter.java
package board.export;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import java.net.URI;
import java.time.Duration;
import java.util.Objects;

public final class ManagedPostExporter {
    private final URI endpoint;
    private final Duration timeout;
    private final ExportTransport transport;
    private boolean ready;

    public ManagedPostExporter(
            URI endpoint,
            Duration timeout,
            ExportTransport transport) {
        this.endpoint = Objects.requireNonNull(endpoint);
        this.timeout = Objects.requireNonNull(timeout);
        this.transport = Objects.requireNonNull(transport);
        if (timeout.isZero() || timeout.isNegative()) {
            throw new IllegalArgumentException(
                    "timeout must be positive");
        }
    }

    @PostConstruct
    void initialize() {
        transport.open(endpoint, timeout);
        ready = true;
    }

    public void export(PostBatch batch) {
        if (!ready) {
            throw new IllegalStateException(
                    "exporter is not ready");
        }
        transport.send(batch);
    }

    @PreDestroy
    void shutdown() {
        ready = false;
        transport.close();
    }
}

Spring 프레임워크 7은 Jakarta 애노테이션 패키지를 사용합니다. 예전 javax.annotation.PostConstruct 가져오기를 복사하면 현재 스택과 맞지 않습니다. 의존성에 jakarta.annotation-api가 포함되어 있어야 하며 Spring Boot 웹 애플리케이션에서는 관리되는 의존성을 사용합니다.

컨텍스트 생명주기 순서

다음 픽스처는 생성자, @PostConstruct, 업무 호출, @PreDestroy 순서를 기록합니다.

src/test/java/board/lifecycle/ManagedLifecycleTest.java
package board.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 ManagedLifecycleTest {
    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 ManagedExporter {
        private final Events events;

        ManagedExporter(Events events) {
            this.events = events;
            events.add("constructor");
        }

        @PostConstruct
        void initialize() {
            events.add("post-construct");
        }

        void export() {
            events.add("export");
        }

        @PreDestroy
        void shutdown() {
            events.add("pre-destroy");
        }
    }

    @Configuration(proxyBeanMethods = false)
    static class LifecycleConfig {
        @Bean
        Events events() {
            return new Events();
        }

        @Bean
        ManagedExporter managedExporter(Events events) {
            return new ManagedExporter(events);
        }
    }

    @Test
    void refresh에서_초기화하고_close에서_정리한다() {
        var context = new AnnotationConfigApplicationContext(
                LifecycleConfig.class);
        var exporter = context.getBean(ManagedExporter.class);
        var events = context.getBean(Events.class);

        assertThat(events.snapshot()).containsExactly(
                "constructor", "post-construct");

        exporter.export();
        context.close();

        assertThat(events.snapshot()).containsExactly(
                "constructor",
                "post-construct",
                "export",
                "pre-destroy");
    }
}

@PostConstruct는 의존성 주입과 빈 후처리 과정 안에서 호출되어 빈이 정상 사용되기 전에 끝나야 합니다. 콜백이 예외를 던지면 새로 고침이 실패하고 그 빈은 사용할 수 없습니다. @PreDestroy는 컨텍스트를 정상적으로 닫을 때 호출됩니다. 프로세스 강제 종료나 전원 차단에서도 실행된다고 보장할 수 없으므로 데이터 정합성을 오직 종료 콜백에 의존하지 않습니다.

실행 결과
ManagedLifecycleTest
  > refresh에서_초기화하고_close에서_정리한다() PASSED
events = [constructor, post-construct, export, pre-destroy]
BUILD SUCCESSFUL

외부 라이브러리 콜백

소스를 수정할 수 없는 SDK 클래스에는 애노테이션을 붙일 수 없습니다. 구성의 @Bean에 메서드 이름을 지정합니다.

src/main/java/board/config/ExportClientConfig.java
package board.config;

import java.net.URI;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import board.export.VendorExportClient;

@Configuration(proxyBeanMethods = false)
class ExportClientConfig {
    @Bean(
            initMethod = "connect",
            destroyMethod = "shutdown")
    VendorExportClient vendorExportClient(
            ExportProperties properties) {
        var client = new VendorExportClient(
                URI.create(properties.endpoint()));
        client.setConnectTimeout(Duration.ofSeconds(2));
        client.setReadTimeout(Duration.ofSeconds(5));
        return client;
    }
}

Spring은 공개 close 또는 shutdown 메서드를 추론해 종료 콜백으로 사용할 수도 있습니다. 그러나 공급자 API의 자원 소유권이 중요한 경우 메서드를 명시하면 구성만 읽어도 생명주기가 보입니다. 이미 다른 컴포넌트가 클라이언트를 닫는다면 destroyMethod = ""로 추론을 끄는 선택도 가능하지만, 누가 소유자인지 문서와 테스트로 남겨야 합니다.

표준 AutoCloseable을 구현한 어댑터를 직접 만들어 공급자 클라이언트를 감싸면 애플리케이션 코드가 외부 메서드 이름을 몰라도 됩니다.

src/main/java/board/export/ExportClient.java
public final class ExportClient implements AutoCloseable {
    private final VendorExportClient delegate;

    public ExportClient(VendorExportClient delegate) {
        this.delegate = delegate;
    }

    public void send(PostBatch batch) {
        delegate.send(batch);
    }

    @Override
    public void close() {
        delegate.shutdown();
    }
}

초기화 실패 시 자원 정리

초기화가 끝난 빈은 컨텍스트가 추적해 종료할 수 있습니다. 그러나 초기화 메서드가 자원을 일부 획득한 뒤 예외로 끝나면 그 객체가 정상 싱글톤으로 등록되지 않았으므로 종료 콜백만 믿어서는 안 됩니다. 획득과 검증을 하나의 실패 안전 구간으로 묶습니다.

src/main/java/board/export/SafeExportGateway.java
package board.export;

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

public final class SafeExportGateway {
    private final ConnectionFactory factory;
    private ExportConnection connection;

    public SafeExportGateway(ConnectionFactory factory) {
        this.factory = factory;
    }

    @PostConstruct
    void initialize() {
        var candidate = factory.open();
        try {
            candidate.handshake();
            connection = candidate;
        } catch (RuntimeException failure) {
            candidate.close();
            throw failure;
        }
    }

    @PreDestroy
    void shutdown() {
        var current = connection;
        connection = null;
        if (current != null) {
            current.close();
        }
    }
}
src/test/java/board/export/FailedInitializationCleanupTest.java
package board.export;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

class FailedInitializationCleanupTest {
    @Test
    void handshake가_실패해도_획득한_connection을_닫는다() {
        var connection = new FakeConnection();
        connection.failHandshake();
        var context = new AnnotationConfigApplicationContext();
        context.registerBean(
                SafeExportGateway.class,
                () -> new SafeExportGateway(() -> connection));

        assertThatThrownBy(context::refresh)
                .hasRootCauseMessage("handshake rejected");
        assertThat(connection.isClosed()).isTrue();

        context.close();
    }
}

정리 중 예외가 원래 초기화 예외를 가리지 않게 억제된 예외 처리나 로깅 정책도 정해야 합니다. 종료를 여러 번 호출할 가능성이 있는 어댑터는 멱등하게 만드는 편이 안전합니다.

생명주기 콜백 선택

Spring은 여러 생명주기 방식을 지원합니다.

방식장점주의점
@PostConstruct / @PreDestroy클래스 자체에 의도가 가까움Jakarta 애노테이션 의존
InitializingBean / DisposableBean메서드 이름이 컴파일 시점 계약애플리케이션 클래스가 Spring에 결합
@Bean(initMethod, destroyMethod)외부 라이브러리에도 적용문자열 메서드 이름
AutoCloseable + 추론된 소멸Java 표준과 결합소유권·추론 여부 확인
컨텍스트 생명주기 이벤트여러 빈 준비 뒤 반응개별 자원 종료 대체가 아님

한 빈에 @PreDestroy, DisposableBean.destroy, 사용자 정의 소멸 메서드를 모두 붙이면 여러 콜백이 실행될 수 있습니다. 같은 자원을 중복 종료해도 우연히 통과하는 구현에 기대지 말고 한 방식을 대표 계약으로 고릅니다.

초기화 순서를 맞추려고 @DependsOn을 넓게 붙이는 것도 신중해야 합니다. 실제 의존성이면 생성자 파라미터로 표현하면 Spring이 자연스럽게 먼저 만듭니다. 직접 참조는 없지만 인프라 시작 순서만 필요한 특수 상황에서만 명시적 순서 지정을 검토합니다.

연습 문제

PostArchiveWriter가 임시 파일을 열고 헤더를 쓰며, 종료 시 플러시와 종료를 수행하게 만드세요. 헤더 쓰기가 실패해도 열린 파일이 닫혀야 하고 컨텍스트를 두 번 닫아도 종료 횟수는 한 번이어야 합니다.

해설 보기

초기화 중에는 로컬 변수로 자원을 보관하고 모든 준비가 성공한 뒤 필드에 할당합니다.

@PostConstruct
void initialize() {
    var candidate = files.open(path);
    try {
        candidate.writeHeader();
        writer = candidate;
    } catch (RuntimeException failure) {
        candidate.close();
        throw failure;
    }
}

@PreDestroy
void shutdown() {
    var current = writer;
    writer = null;
    if (current != null) {
        current.flush();
        current.close();
    }
}

성공 테스트에서는 open -> header -> flush -> close 순서를, 실패 테스트에서는 open -> header-failed -> close 순서를 기록합니다. 필드를 먼저 null로 바꾸면 콜백이 재진입하거나 실수로 다시 호출돼도 같은 작성기를 두 번 정리하지 않습니다. 플러시 실패 때도 종료가 필요하다면 try/finally로 둘을 감쌉니다.

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