배포 후 어느 순간 에러 알림이 Slack으로 도착했습니다.
RedisConnectionFailureException: Unable to connect to Redis; nested exception is IllegalStateException: Cannot connect, Event executor group is terminated.
이는 Spring Boot 애플리케이션이 graceful shutdown 과정에서 Redis 커넥션이 이미 종료된 상태에서 스케줄러가 실행되었기 때문에 발생한 예외입니다.
현재 Redis를 통해 ShedLock 기반의 분산락과 로직에서 사용하고 있어, Redis가 먼저 종료되면 큰 문제가 됩니다.
그런데 왜 Redis가 먼저 종료되었을까요?
스케줄러는 왜 여전히 돌고 있었을까요?
스프링 컨텍스트는 종료되고 있었지만,
ThreadPoolTaskScheduler 내부의 스레드는 Spring과 별개로 동작하고 있었기 때문에,
이미 종료된 Redis에 접근하면서 문제가 발생한 것입니다.
IllegalStateException: Cannot connect, Event executor group is terminated
Spring에서는 애플리케이션 종료 시 다음 순서로 Bean이 정리됩니다:
1. ApplicationContext 종료 신호 수신
2. @PreDestroy 메서드 호출 ← 지금 우리가 사용하는 단계
3. SmartLifecycle.stop() 호출
4. DisposableBean.destroy() 호출
5. 그 이후 Bean 하나씩 소멸 (예: RedisConnectionFactory, DataSource 등)
즉, @PreDestroy는 해당 Bean이 소멸되기 직전에 호출되는 Hook 메서드입니다.
1. @PreDestroy → gracefulShutdownHandler → taskScheduler.shutdown()
2. 실행 중이던 스케줄러 작업 graceful 종료
3. Spring Context 종료 진행
4. LettuceConnectionFactory (Redis) Bean 종료
5. 종료 완료
Redis가 먼저 죽지 않도록 Bean의 shutdown 순서를 명시적으로 제어하는 것은 현실적으로 어렵습니다.
왜냐하면 Lettuce Redis 클라이언트는 Spring 내부에서 자동으로 정리되기 때문입니다.
@PreDestroy는 해당 Bean이 소멸되기 직전에 실행되는 메서드에 붙이는 애노테이션입니다.
따라서 이 메서드 안에서 스케줄러를 명시적으로 먼저 shutdown하고,
이미 실행 중인 작업이 있다면 그것까지 graceful하게 마무리한 후,
그다음 Spring Context가 종료되도록 유도할 수 있습니다.
→ 즉, Redis는 그 후에 안전하게 종료되므로 예외가 발생하지 않습니다.
| 요소 | 이유 |
|---|---|
| 스케줄러 | @PreDestroy에서 shutdown() 호출 → 더 이상 작업이 실행되지 않음 |
| 실행 중인 작업 | setWaitForTasksToCompleteOnShutdown(true) 설정 시 graceful하게 대기 가능 |
| Redis | 아직 살아 있는 상태에서 스케줄러 종료 → Redis 접근 중 예외 발생하지 않음 |
| Lettuce 종료 | Spring Context 종료 이후 이루어지므로 안전하게 종료됨 |
@EnableSchedulerLock(defaultLockAtLeastFor = XXXX, defaultLockAtMostFor = XXXX)
@EnableScheduling
@Configuration
@RequiredArgsConstructor
@Slf4j
public class SchedulerConfig implements SchedulingConfigurer {
private static final int POOL_SIZE = X개;
private final Environment environment;
private final ThreadPoolTaskScheduler threadPoolTaskScheduler;
@Override
public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
threadPoolTaskScheduler.setPoolSize(POOL_SIZE);
threadPoolTaskScheduler.setThreadNamePrefix(SCHEDULER_THREAD_NAME_PREFIX);
// graceful shutdown 설정
threadPoolTaskScheduler.setWaitForTasksToCompleteOnShutdown(true);
threadPoolTaskScheduler.setAwaitTerminationSeconds(30);
threadPoolTaskScheduler.initialize();
taskRegistrar.setTaskScheduler(threadPoolTaskScheduler);
}
@Bean
public LockProvider lockProvider(RedisConnectionFactory redisConnectionFactory) {
String lockEnv = environment.getProperty(PROFILE_ACTIVE_KEY);
return new RedisLockProvider(redisConnectionFactory, lockEnv);
}
@PreDestroy
public void shutdownScheduler() {
log.info("graceful shutdown: 스케줄러 종료 시작");
threadPoolTaskScheduler.shutdown(); // 명시적 종료
log.info("graceful shutdown: 스케줄러 종료 완료");
}
}
운영 배포를 진행해보니,
Redis 관련 예외 없이 안정적으로 graceful하게 종료되는 것을 확인할 수 있었습니다.
❓그렇다면 graceful하지 않은 경우에는?
예: 서버가 정전되거나 장애로 인해 강제 종료된다면?
이 경우에는 @PreDestroy가 호출되지 않기 때문에 graceful shutdown이 불가능합니다.
이런 상황을 대비해서 작업 로그 저장 및 재처리 전략 (e.g., 트랜잭션 아웃박스 패턴, 실패 로그 큐 기반 재시도)이 필요합니다.