이 글에서 다루는 서비스는 텍스트 요청을 받아 이미지 생성을 시작하고, 생성된 결과에 대한 후처리를 이어서 수행한다.
요청을 처리한 스레드가 후처리까지 모두 기다리지 않고, 요청 상태를 저장한 뒤 트랜잭션 커밋 이후 후처리를 비동기로 넘기는 구조다.
처리 흐름은 다음과 같다.
이를 코드로 단순화하면 다음과 같다.
@Async(AsyncProperties.ASYNC_LISTENER)
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void validate(TextToImageCreatedEvent event) {
...
}@Async는 그 리스너를 요청 처리 스레드와 분리해 별도 executor에서 실행한다.@Bean(name = AsyncProperties.ASYNC_LISTENER)
public Executor asyncListenerExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
...
executor.setCorePoolSize(corePoolSize);
executor.setMaxPoolSize(maxPoolSize);
executor.setQueueCapacity(queueCapacity);
executor.setThreadNamePrefix("listener-");
executor.initialize();
return executor;
}서버가 종료되더라도 이미 들어온 작업은 끝까지 처리하도록 설정도 켰다.
Baeldung — Spring Boot graceful shutdown
taskExecutor.setWaitForTasksToCompleteOnShutdown(true);
taskExecutor.setAwaitTerminationSeconds(30);그런데 이 설정이 켜져 있었는데도, 서버가 내려가는 동안 이미지 생성 후처리가 끝까지 처리되지 않은 사례를 확인했다.
처음에는 이렇게 생각했다.
“shutdown phase의 timeout까지 설정해 두면, 서버가 내려가기 전에 후처리 작업이 끝날 때까지 기다려 주는 것 아닌가?”
이 질문에 답하려면 Spring이 종료 과정에서 실제로 무엇을 기다리는지부터 확인해야 했다.
@Async executor는 shutdown 때 남은 작업을 어떻게 처리하는가?② lifecycle stop은 phase가 큰 빈부터 멈추고, phase 묶음마다 timeout이 따로 걸린다. ③ Bean destroy는 생성의 역순으로, 스레드 하나가 하나씩 처리한다.② lifecycle stop으로 변경)이번 편에서는 ContextClosedEvent, SmartLifecycle의 phase별 stop, Bean destroy가 실제로 어떤 순서로 실행되는지 테스트 코드와 로그로 확인한다.
이어지는 글에서는 이 종료 순서 안에서 @Async 풀이 언제 작업을 기다리고, 그때 외부 자원이 어떤 상태인지 이어서 확인한다.
server.shutdown: gracefulspring.lifecycle.timeout-per-shutdown-phase: 35s어떻게 동작하는지 직접 확인하기 위해 종료 과정을 찍어 보는 작은 환경을 만들었다.
실험 코드는 Spring Shutdown Playground에서 확인할 수 있다.
spring-context 6.2를 사용하고
context.close()를 직접 부른다(shutdown hook이 부르는 메서드)[경과ms] [스레드] 이벤트로 한 줄씩 남긴다
① 종료 이벤트 · ② lifecycle stop · ③ Bean destroyDefaultLifecycleProcessor 의 DEBUG 로그도 같이 켰다timeline [ 0ms] [Test worker] ② stop phase=1000 web-like ← 실험장이 남기는 줄
spring [Test worker] DefaultLifecycleProcessor - Stopping beans in phase 1000 ← Spring 로그Spring의 종료는 3단계로 진행된다.
ECS가 태스크를 내리면 SIGTERM을 JVM에 전달한다.
JVM은 shutdown hook을 실행하고,
Spring이 등록해 둔 hook이 ApplicationContext.close()를 부른다.
그러면, 크게 3개의 메서드를 실행한다.
// AbstractApplicationContext.doClose()
publishEvent(new ContextClosedEvent(this)); // ① 종료 이벤트 발행
this.lifecycleProcessor.onClose(); // ② lifecycle stop
destroyBeans(); // ③ Bean destroy
| 단계 | 누가 불리나 | 성격 |
|---|---|---|
| ① 종료 이벤트 | ApplicationListener<ContextClosedEvent> | 컨텍스트가 닫힌다는 걸 알려주는 알림 |
② lifecycle stop | Lifecycle · SmartLifecycle 의 stop() | 하던 일을 멈춘다. 다시 start() 할 수 있다 |
③ Bean destroy | @PreDestroy · DisposableBean.destroy() · close() | 빈을 종료한다. 이후로 쓸 수 없다 |
정말 이 순서인지 확인하기 위해
SmartLifecycle 세 개를 등록하고 컨텍스트를 닫아보면?
...
timeline [ 0ms] ① ContextClosedEvent 수신
...
spring DefaultLifecycleProcessor - Stopping beans in phase 1000
timeline [ 0ms] ② stop phase=1000 web-like
spring DefaultLifecycleProcessor - Stopping beans in phase 0
timeline [ 0ms] ② stop phase=0 redis-like
spring DefaultLifecycleProcessor - Stopping beans in phase -1000
timeline [ 0ms] ② stop phase=-1000 mq-like
...
timeline [ 0ms] ③ destroy third
timeline [ 0ms] ③ destroy second
timeline [ 1ms] ③ destroy first재현:
./gradlew s1 --tests '*threeStagesInOrder'· 테스트 코드
③ Bean destroy는 Bean을 만든 순서의 역순이다.③ Bean destroy 가 시작될 때 ② lifecycle stop 처리는 끝난다.
② lifecycle stop 과 phaseLifecycle은 start, stop, isRunning 메서드를 가지고 있다.
그리고, 이 Lifecycle을 상속하는 SmartLifecycle이 존재한다.
SmartLifecycle은 phase를 통해 시작·종료 순서를 정할 수 있다.
우리가 흔히 사용하는 빈들은 아래 phase를 쓰고 있다. (소스 코드로 확인)

| phase | 빈 | 역할 |
|---|---|---|
Integer.MAX_VALUE | Rabbit 리스너 컨테이너 | 일을 만드는 쪽 |
MAX − 100 | Kafka 리스너 컨테이너 | 일을 만드는 쪽 |
MAX − 1024 | 웹 graceful (Boot) | 일을 만드는 쪽 |
MAX − 2048 | 웹 서버 정지 (Boot) | 일을 만드는 쪽 |
MAX / 2 | ThreadPoolTaskExecutor · ThreadPoolTaskScheduler | 일을 처리하는 쪽 |
0 | LettuceConnectionFactory (Redis), 일반 Lifecycle | 일이 쓰는 자원 |
Integer.MIN_VALUE | Rabbit CachingConnectionFactory, Kafka DefaultKafkaProducerFactory | 일이 쓰는 자원 |
통상, 만드는 쪽 → 처리하는 쪽 → 자원 순서로 멈춘다.
MAX, 연결 팩토리가 MIN)Lifecycle을 구현하지 않은 빈은 phase 0이 아니다.
lifecycle 대상이 아니라서③ Bean destroy만 받는다.
SmartLifecycle 은 getPhase() 로 자기 차례를 정한다.
그리고, stop(callback)이 추가되어, 종료 후 callback을 실행시켜줄 수 있다.
같은 phase 의 빈은 한 묶음으로 stop(callback) 을 받고,
묶음 전체의 callback 이 올 때까지 기다린 뒤 다음 phase 로 넘어간다.
// `org.springframework.context.support.DefaultLifecycleProcessor`
private void stopBeans() {
Map<String, Lifecycle> lifecycleBeans = getLifecycleBeans();
Map<Integer, LifecycleGroup> phases = new TreeMap<>(Comparator.reverseOrder()); // ← 2번도 여기
lifecycleBeans.forEach((beanName, bean) -> {
int shutdownPhase = getPhase(bean);
// ← 1. phase 로 그룹핑
phases.computeIfAbsent(shutdownPhase,
phase -> new LifecycleGroup(...)
).add(beanName, bean);
});
// ← 2 & 5. 높은 순서로 그룹 순회
if (!phases.isEmpty()) {
phases.values().forEach(LifecycleGroup::stop);
}
}
SmartLifecycle 빈들을 phase 값으로 그룹핑stop(callback) 을 차례대로 호출
모든 콜백이 들어올 때까지 or 지정된 시간이 될 때까지 블로킹 대기
각 phase에서 stop(callback) 호출을 마친 뒤 콜백을 기다리는 상한 시간이다.
시간이 지나면 callback을 호출하지 못한 남은 빈을 그대로 두고 다음 phase로 넘어간다.
callback을 끝내 부르지 않는 빈(phase 100)을 넣고 timeout을 500ms로 두면 아래 로그가 발생한다.
timeline [ 0ms] ② stuck(phase 100) stop 시작 — callback 을 부르지 않는다
spring DefaultLifecycleProcessor - Shutdown phase 100 ends with 1 bean still running after timeout of 500ms: [stuck]
timeline [ 511ms] ② stop phase=0 after-stuck재현:
./gradlew s1 --tests '*phaseTimeoutMovesOn'· 테스트 코드
100 phase는 500ms가 지나서 끝났지만, 하나의 빈이 여전히 동작 중이라고 로그를 남긴다.
그러므로, phase가 여러 개면 상한도 여러 번 걸린다.
timeout-per-shutdown-phase 를 길게 설정하면, 각 페이즈마다 오래 대기해 종료가 오래 걸릴 수 있다.
phase 순서에는 예외가 하나 있다. 어떤 빈을 멈추기 전에, 그 빈에 의존하는 빈을 먼저 멈춘다.
// DefaultLifecycleProcessor.doStop() 요약 (spring-context 6.2)
String[] dependentBeans = getBeanFactory().getDependentBeans(beanName);
for (String dependentBean : dependentBeans) {
// 나에게 의존하는 빈부터 멈춘다
doStop(lifecycleBeans, dependentBean, latch, countDownBeanNames);
}
...
smartLifecycle.stop(callback);phase 1000 인 target 에 phase -1000 인 dependent 가 @DependsOn 으로 의존하게 두고,
그 사이에 phase 0 인 middle 을 넣었다.
timeline [ 0ms] ② stop phase=-1000 dependent
timeline [ 0ms] ② stop phase=1000 target
timeline [ 0ms] ② stop phase=0 middle재현:
./gradlew s1 --tests '*dependentStopsBeforeItsPhase'· 테스트 코드
target 차례(phase 1000)에 dependent 가 target 보다 먼저 멈췄다. (phase 0 인 middle 보다 앞)
③ Bean destroydestroy 메서드는 하나의 스레드가 처리한다.
// DefaultSingletonBeanRegistry.destroySingletons()
// 등록 역순
for (int i = disposableBeanNames.length - 1; i >= 0; i--) {
// 끝나야 다음 빈
destroySingleton(disposableBeanNames[i]);
}destroy 에 300ms 씩 걸리는 빈 두 개로 확인해 보면
timeline [ 0ms] [Test worker] ③ slow-2 destroy 시작
timeline [ 308ms] [Test worker] ③ slow-2 destroy 끝
timeline [ 308ms] [Test worker] ③ slow-1 destroy 시작
timeline [ 612ms] [Test worker] ③ slow-1 destroy 끝재현:
./gradlew s1 --tests '*destroyIsSequentialOnClosingThread'· 테스트 코드
Test worker) 하나가 처리한다.=> 한 빈의 destroy 가 막혀 있으면 그 뒤 빈들은 전부 기다리게 된다.
@DependsOn, 생성자 주입 등).한 빈에 @PreDestroy, DisposableBean.destroy(), @Bean(destroyMethod = …) 를 전부 걸면
어떤 순서로 실행이 될까?
timeline [ 0ms] ③ @PreDestroy
timeline [ 0ms] ③ DisposableBean.destroy()
timeline [ 0ms] ③ destroyMethod(customClose)재현:
./gradlew s1 --tests '*destroyCallbacksInsideOneBean'· 테스트 코드
셋 다 ③ Bean destroy 에서, 한 빈을 정리할 때 연달아 불린다.
흔히
@PreDestroy를 "종료 신호를 받자마자 불리는 곳" 으로 생각하나,③ Bean destroy에서 불린다.
그 전에② lifecycle stop처리가 끝난다. timeout이 발생했다면 종료되지 않은 작업이 남아 있을 수 있다.
@Bean으로 등록한 객체에 close(), shutdown()이 있으면, Spring이 destroy로 추론해서 부른다.Redis, Kafka, DB 와 같은 자원들은 언제 정리될까?
자원이 정리되는 시점은 'Lifecycle 구현 여부', 'destroy 콜백 등록 방식', '각 메서드 내부 동작'에 따라 달라진다.
-> Lifecycle 종료와 destroy 콜백이 모두 호출될 수 있고, 구현을 확인해야만 한다.
3.1까지는 Redis가 ③ Bean destroy에서 닫혔다.
3.2부터 ② lifecycle stop으로 옮겨 갔다.
| Spring Data Redis | LettuceConnectionFactory | Redis 가 닫히는 단계 |
|---|---|---|
| 3.1.x | InitializingBean, DisposableBean 뿐 | ③ Bean destroy |
| 3.2.x 부터 | SmartLifecycle 추가 (phase 0) | ② lifecycle stop |
같은 코드라도 Spring Data Redis 버전을 올리면서 Redis가 닫히는 시점이 한 단계 앞당겨졌다.
컴파일 에러나 경고만으로는 이런 변화를 알아차리기 어렵다.
② lifecycle stop 에서 종료처리를 했더라도, 재연결을 해서 처리해줄 수도 있다.
② lifecycle stop 에서 연결을 정리하지만, 다음 요청이 오면 다시 연결한다.STARTED 가 아니면 연결 요청을 거절한다.// DefaultKafkaProducerFactory (spring-kafka 3.3.16)
public int getPhase() {
return Integer.MIN_VALUE;
}
public void stop() {
this.running.set(false);
// 만들어 둔 producer 를 닫는다 (거절 표시는 남기지 않는다)
destroy();
}
private Producer<K, V> doCreateProducer(String txIdPrefix) {
...
// 닫혀서 비어 있으면 새로 만든다.
if (this.producer == null) {
this.producer = new CloseSafeProducer<>(createKafkaProducer(), ...);
}
return this.producer;
}// LettuceConnectionFactory (spring-data-redis 3.2.x 이후)
private int phase = 0;
public RedisConnection getConnection() {
// STOPPED 면 IllegalStateException:
// LettuceConnectionFactory has been STOPPED
assertStarted();
...이런 lifecycle 변경은 종료 중 Redis를 사용하는 후처리나 정리 작업에도 영향을 줄 수 있다!
Redis Connection Factory Earlier Graceful Shutdown after Spring 3.2 Upgrade
Spring Boot 3.2로 업그레이드한 뒤,@PreDestroy에서LettuceConnectionFactory has been STOPPED예외가 발생한 사례를 다룬 이슈
이번 편에서 확인한 것은 세 가지다.
① 종료 이벤트 → ② lifecycle stop → ③ Bean destroy 순서로 종료 처리한다.timeout-per-shutdown-phase 는 각 phase 에서 콜백을 기다리는 상한이다. (종료를 기다려주는 최대 시간이 아님)그럼 마지막 질문으로 돌아가 보자. @Async 풀은 남은 작업을 어느 단계에서 기다릴까?
앞에서 본 작업 완료 대기 설정을 켰을 때, 작업이 기다리는 동안 Redis 연결은 살아 있을까?
이 질문은 후속 글에서 이어서 확인한다.
AbstractApplicationContext, DefaultLifecycleProcessor, DefaultSingletonBeanRegistry