운영 중 발생한 RedisConnectionFailureException의 원인과 graceful shutdown 해결법

Choi Wang Gyu·2025년 5월 25일

문제 상황

배포 후 어느 순간 에러 알림이 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에 접근하면서 문제가 발생한 것입니다.


문제 원인 정리

1. Spring 컨텍스트가 종료되기 시작함

  • ApplicationContext가 종료 신호를 수신하고 내부적으로 Bean 정리를 시작

2. Lettuce (Redis 클라이언트) 관련 Bean이 먼저 종료됨

  • Spring은 Bean의 종료 순서를 보장하지 않음
  • 초기화된 순서의 역순으로 종료되기 때문에, LettuceConnectionFactory가 먼저 shutdown됨

3. 하지만 @Scheduled 작업은 ThreadPoolTaskScheduler의 별도 스레드 풀에서 실행 중

  • 이 스레드들은 Spring 컨텍스트가 내려가는 중에도 잠시 살아 있음
  • 해당 작업이 Redis에 접근하려고 시도

4. Redis는 이미 종료됨 → 예외 발생

IllegalStateException: Cannot connect, Event executor group is terminated

해결 방안 정리

❓ @PreDestroy는 언제 실행되는가?

Spring에서는 애플리케이션 종료 시 다음 순서로 Bean이 정리됩니다:
1. ApplicationContext 종료 신호 수신
2. @PreDestroy 메서드 호출 ← 지금 우리가 사용하는 단계
3. SmartLifecycle.stop() 호출
4. DisposableBean.destroy() 호출
5. 그 이후 Bean 하나씩 소멸 (예: RedisConnectionFactory, DataSource 등)
즉, @PreDestroy는 해당 Bean이 소멸되기 직전에 호출되는 Hook 메서드입니다.

shutdown 전에 먼저 스케줄러를 종료하면 문제 없음

1. @PreDestroy → gracefulShutdownHandler → taskScheduler.shutdown()
2. 실행 중이던 스케줄러 작업 graceful 종료
3. Spring Context 종료 진행
4. LettuceConnectionFactory (Redis) Bean 종료
5. 종료 완료

왜 @PreDestroy만으로 충분한가?

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., 트랜잭션 아웃박스 패턴, 실패 로그 큐 기반 재시도)이 필요합니다.

0개의 댓글