개인 프로젝트/영화예매사이트

[Spring Boot 프로젝트] 영화 예매 사이트 개발기 (5) - 성능 개선 리팩토링 (스케줄러에서 Redis TTL 이벤트 & Warm-up으로 진화하기)

sik-code 2026. 10. 10. 03:42

안녕하세요! 

영화 예매 시스템 개발기  5편을 들고 왔습니다! 🎉

 

앞서 Redisson 분산 락까지 붙여가며 "아, 이제 진짜 예매 시스템 구실 좀 하네?" 하고 뿌듯해하고 있었는데요. 마음속 한구석에 아주 거슬리는 녀석이 하나 남아있었습니다. 바로 "결제 안 하고 튀어버린 사용자들의 PENDING 상태 좌석을 도대체 어떻게 깔끔하게 회수할 것인가?" 하는 문제였죠.

 

처음에 아무 생각 없이 짰던 스케줄러를 쿨하게 걷어내고, Redis의 만료 이벤트(TTL)를 써먹어 보겠다고 고군분투했던 리팩토링 과정을 날것 그대로 풀어보겠습니다.

 

1. 🚨 일단 짰던 스케줄러, 근데 이제 찝찝함을 곁들인...

처음에는 뭣도 모르고 스프링 부트의 @Scheduled를 써서 이렇게 구현했었습니다.

@Slf4j
@Component
@RequiredArgsConstructor
public class ReservationScheduler {
    private final ReservationService reservationService;

    /**
     * 1분마다 실행하여 10분이 지난 PENDING 예매 및 결제 데이터를 만료 처리(CANCELLED)
     */
    @Scheduled(fixedDelay = 60000)
    public void cleanupExpiredPendingReservations() {
        try {
            reservationService.cancelExpiredPendingReservations();
        } catch (Exception e) {
            log.error("[Scheduler] 만료된 PENDING 예매 자동 취소 처리 중 오류 발생: {}", e.getMessage(), e);
        }
    }
}

@Transactional
public void cancelExpiredPendingReservations() {
    LocalDateTime tenMinutesAgo = LocalDateTime.now().minusMinutes(10);
    long canceledCount = reservationRepository.bulkCancelExpiredPendingReservations(tenMinutesAgo);

    if (canceledCount > 0) {
        log.info("[Automation] 만료된 PENDING 예매 및 결제 {}건 일괄 CANCELLED 처리 완료", canceledCount);
    }
}
  • 써보니까 뭐가 문제였냐면요..
    1. DB가 너무 열일함: 취소할 예매가 단 한 건도 없는데도, 1분마다 주기적으로 DB에 쿼리를 날려서 bulkCancelExpiredPendingReservations를 돌려야 했습니다. 데이터가 쌓일수록 "이거 나중에 트래픽 몰리면 DB 터지는 거 아니야?"라는 쫄림이 밀려왔죠.
    2. 시간이 딱 안 맞음: 10분이 지난 건 맞는데 스케줄러 주기가 1분이라 최대 1분까지 밀려서 취소 처리가 되는 아쉬움도 있었습니다.

이미 동시성 제어 때문에 Redis를 쓰다보니 "아니, 1분마다 DB 긁지 말고... 레디스 기능을 활용하면 만료 처리도 훨씬 깔끔하게 짤 수 있지 않을까?" 라는 합리적인 의심(?)에서 리팩토링이 시작되었습니다.

2. 💡 Redis TTL 이벤트랑 Warm-up으로 노선 변경

고민 끝에 찾아낸 방법이 바로 Redis의 Key-Space Notifications(TTL 만료 이벤트)였습니다.

  • 어떻게 바꿨냐면요:
    • 예매가 생성되는 순간 Redis에 10분짜리 TTL 키(expired:reservation:{id})를 하나 꽂아둡니다.
    • 사용자가 결제를 무사히 마치면 이 키를 곧바로 지워버립니다(DEL).
    • 만약 10분이 지나도록 결제를 안 해서 키가 만료되면? Redis가 "나 만료됐다!" 하고 이벤트를 쏴줍니다. 그걸 리스너가 캐치해서 DB 상태를 CANCELLED로 바꿔버리는 거죠!
    • 근데 여기서 함정 🕳️: 서버가 갑자기 꺼졌다가 켜지면 레디스에 있던 타이머가 다 날아가 버리잖아요? 그래서 서버가 켜질 때(CommandLineRunner) DB를 싹 뒤져서 아직 살아있는 PENDING 건들을 다시 레디스에 등록해 주는 Warm-up 로직도 같이 짜 넣었습니다.

3. 🛠️ 뚝딱뚝딱 구현해 본 코드

① Redisson 설정에 리스너 컨테이너 한 스푼 추가 (RedissonConfig)

@Configuration
public class RedissonConfig {
    @Value("${spring.data.redis.host:localhost}")
    private String redisHost;

    @Value("${spring.data.redis.port:6379}")
    private int redisPort;

    private static final String REDIS_HOST_PREFIX = "redis://";

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
                .setAddress(REDIS_HOST_PREFIX + redisHost + ":" + redisPort);

        return Redisson.create(config);
    }

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        return container;
    }
}

② 서버 켜질 때 정신 차리게 만드는 Warm-up (ReservationRedisWarmUp)

@Slf4j
@Component
@RequiredArgsConstructor
public class ReservationRedisWarmUp implements CommandLineRunner {

    private final ReservationRepository reservationRepository;
    private final StringRedisTemplate redisTemplate;
    private final ReservationService reservationService;

    private static final String REDIS_KEY_PREFIX = "expired:reservation:";
    private static final long EXPIRATION_MINUTES = 10;

    @Override
    @Transactional
    public void run(String... args) {
        log.info("[Redis Warm-up] 서버 기동 중: PENDING 상태의 예매 데이터 동기화 시작");
        List<Reservation> pendingReservations = reservationRepository.findByReservationStatus("PENDING");
        LocalDateTime now = LocalDateTime.now();

        for (Reservation reservation : pendingReservations) {
            LocalDateTime createdAt = reservation.getCreatedAt();
            long minutesElapsed = Duration.between(createdAt, now).toMinutes();

            if (minutesElapsed >= EXPIRATION_MINUTES) {
                // 서버 꺼진 사이에 10분이 이미 지났다면 곧바로 취소 처리
                reservationService.expireReservation(reservation.getId());
            } else {
                // 아직 시간이 남았다면 남은 시간만큼만 Redis TTL 재설정
                long remainingSeconds = Duration.between(now, createdAt.plusMinutes(EXPIRATION_MINUTES)).getSeconds();
                String redisKey = REDIS_KEY_PREFIX + reservation.getId();
                redisTemplate.opsForValue().set(redisKey, "PENDING", Duration.ofSeconds(remainingSeconds));
            }
        }
        log.info("[Redis Warm-up] PENDING 데이터 동기화 완료");
    }

    public void addPendingReservationKey(Long reservationId) {
        String redisKey = REDIS_KEY_PREFIX + reservationId;
        redisTemplate.opsForValue().set(redisKey, "PENDING", Duration.ofMinutes(EXPIRATION_MINUTES));
    }

    public void removePendingReservationKey(Long reservationId) {
        String redisKey = REDIS_KEY_PREFIX + reservationId;
        redisTemplate.delete(redisKey);
    }
}

③ 레디스가 보내는 만료 신호를 낚아채는 리스너 (RedisKeyExpirationListener)

@Slf4j
@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {

    private final ReservationService reservationService;
    private static final String REDIS_KEY_PREFIX = "expired:reservation:";

    public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer, ReservationService reservationService) {
        super(listenerContainer);
        this.reservationService = reservationService;
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String expiredKey = message.toString();

        if (expiredKey.startsWith(REDIS_KEY_PREFIX)) {
            String reservationIdStr = expiredKey.substring(REDIS_KEY_PREFIX.length());
            
            try {
                Long reservationId = Long.valueOf(reservationIdStr);
                
                // 혹시라도 그새 결제가 끝났을지 모르니 DB를 한 번 더 체크하는 방어 로직!
                reservationService.processExpirationIfPending(reservationId);

            } catch (NumberFormatException e) {
                log.error("[Redis Event] 잘못된 만료 키 형식입니다: {}", expiredKey);
            }
        }
    }
}

 

4. 🎯 리팩토링을 마치며 (솔직한 소회)

  • 데이터가 많아질 때 1분마다 주기적으로 벌크 쿼리를 날리는 스케줄러 방식은 나중에 분명 문제가 될 것 같았는데, 마침 동시성 제어 때문에 쓰고 있던 Redis의 TTL 기능을 활용하니까 만료 처리를 훨씬 깔끔하게 처리할 수 있었습니다.
  • 물론 레디스는 인메모리 기반이라 서버가 다운되면 타이머 데이터가 다 날아간다는 치명적인 약점이 있어서, 서버가 켜질 때 DB 상태를 조회해 맞추는 Warm-up 로직까지 고민해 볼 수 있었습니다.
  • notify-keyspace-events Ex 설정 빼먹어서 처음에 이벤트가 안 잡혀가지고 30분 동안 머리 쥐어뜯은 건 안 비밀.. 😅

직접 부딪혀가며 기존 스케줄러 방식을 개선하고 예외 상황까지 고민해 본 덕분에, 백엔드 개발자로서 한 층 더 성장한 것 같습니다!

 

5. 📚 다음 포스팅 예고

지금까지 스케줄러에서 Redis TTL 이벤트 & Warm-up으로 리팩토링을 하는 과정을 살펴보았습니다. 다음 6편에서는 프로젝트가 끝난 건 아니지만 지금까지 진행하며 느낀 후기? 깨달은 것들을 주제로 이야기를 해보겠습니다.

다음 포스팅에서 만나요! 🚀