본문 바로가기

프로젝트/티켓팅서비스

[티켓팅 #5] HOLD 만료 해제 스케줄링

1. 문제 상황

HOLD API를 만들고 나서 바로 다음 문제가 생겼다.

사용자가 좌석을 선점한 뒤 결제 없이 그냥 나가버리면 그 좌석은 영원히 HELD 상태로 남는다.

5분 만료 시간을 expiresAt 컬럼에 저장해두긴 했는데 저장만 해놓고 실제로 만료 처리를 하는 로직이 없었다.

결과적으로 이런 상황이 발생한다.

  • 사용자 A가 1번 좌석을 HOLD
  • 5분 뒤 만료됐지만 DB에는 HELD 상태 그대로
  • 다른 사용자 B는 1번 좌석을 선택할 수 없음

expiresAt은 그냥 기록용 데이터가 되어버렸다.


2. 왜 이게 문제였는가

처음 구현할 때는 "HOLD 생성 시 만료 시간을 저장하면 되겠지"라고 생각했다.

근데 만료 판단을 어디서 하느냐가 문제였다.

방법 1 — 조회 시점 만료 처리 좌석 조회 API에서 expiresAt < now이면 그때 AVAILABLE로 바꾸는 방식.

구현이 단순하지만 문제가 있다. 조회가 없으면 만료 처리도 없다.

아무도 그 좌석을 조회하지 않으면 HELD 상태가 영구 유지된다.

또 조회 API가 readOnly 트랜잭션이라 상태 변경과 섞이면 설계가 지저분해진다.

방법 2 — 별도 스케줄러 주기적으로 만료된 HOLD를 일괄 처리.

조회 요청과 무관하게 동작하고 만료 처리 책임이 명확하게 분리된다.

방법 2를 선택했다. 만료 처리가 "언제 조회되느냐"에 의존하면 안 된다고 판단했다.


3. 오픈소스 / 레퍼런스 분석

Spring의 @Scheduled는 내부적으로 TaskScheduler를 사용한다.

공식 문서에서 fixedDelay와 fixedRate의 차이를 명확히 정의하고 있다.

  • fixedRate: 이전 실행 시작 시점 기준으로 다음 실행 예약
  • fixedDelay: 이전 실행 완료 시점 기준으로 다음 실행 예약

만료 처리처럼 DB 작업이 포함된 경우 처리 시간이 길어지면 fixedRate는 동시에 여러 실행이 겹칠 수 있다.

fixedDelay는 이전 실행이 끝난 뒤에 다음 실행이 시작되므로 처리 중 상태를 안전하게 다룰 수 있다.

이 프로젝트는 fixedDelay = 30000(30초)으로 설정했다.

HOLD 만료 시간이 5분이므로 최대 30초 이내에 만료 처리가 완료된다.

실시간 정확성이 중요하지 않은 상황에서 30초는 충분히 허용 가능한 지연이다.

한 가지 더 고민한 부분이 있다.

단일 인스턴스 환경에서는 문제없지만 서버가 여러 대로 늘어나면 @Scheduled가 각 인스턴스에서 동시에 실행된다.

같은 HOLD를 두 인스턴스가 동시에 만료 처리하면 중복 업데이트가 발생한다.

이 문제를 해결하는 도구가 ShedLock이다.

ShedLock은 스케줄러 실행 전 DB 또는 Redis에 락을 잡아서 단 하나의 인스턴스만 실행되도록 보장한다.

현재 프로젝트는 단일 인스턴스 기준이라 미적용 상태지만 실제 운영 환경이라면 반드시 고려해야 할 포인트다.


4. 고민했던 선택지와 최종 결정

방식 장점 단점

조회 시점 만료 처리 구현 단순 조회 없으면 만료 안 됨, readOnly 트랜잭션 오염
@Scheduled 스케줄러 책임 분리, 안정적 다중 인스턴스 중복 실행 위험
메시지 큐 (Kafka 등) 정확한 만료 시점 처리 인프라 복잡도 증가

포트폴리오 프로젝트 범위에서 Kafka는 오버스펙이라 판단했다.

@Scheduled + fixedDelay로 충분하고 다중 인스턴스 문제는 ShedLock 도입 여지를 남겨두는 방향으로 결론을 냈다.


5. 적용한 방식

스케줄러는 얇게 유지했다. 실행 주기만 정의하고, 실제 처리는 HoldExpirationService에 위임한다.

@Component
@RequiredArgsConstructor
public class HoldExpirationScheduler {

    private final HoldExpirationService holdExpirationService;

    @Scheduled(fixedDelay = 30000)
    public void expireHolds() {
        holdExpirationService.expireHolds(LocalDateTime.now());
    }
}

HoldExpirationService에서 실제 만료 처리를 담당한다. 핵심은 fetch join으로 showtimeSeat를 한 번에 로드하는 부분이다.

public void expireHolds(LocalDateTime now) {

    List<Hold> expiredTargets =
        holdRepository.findAllByStatusAndExpiresAtBeforeWithSeat(HoldStatus.ACTIVE, now);

    for (Hold hold : expiredTargets) {
        hold.expire();                        // ACTIVE → EXPIRED
        hold.getShowtimeSeat().markAvailable(); // HELD → AVAILABLE
    }

    log.info("Expired holds processed. count={}", expiredTargets.size());
}

fetch join 없이 루프를 돌면 Hold 수만큼 showtimeSeat 조회 쿼리가 추가로 발생한다. 만료 대상이 100건이면 쿼리가 101번 나간다. Repository에서 미리 join해서 가져오면 쿼리 1번으로 해결된다.

@Query("select h from Hold h join fetch h.showtimeSeat " +
       "where h.status = :status and h.expiresAt < :now")
List<Hold> findAllByStatusAndExpiresAtBeforeWithSeat(
    @Param("status") HoldStatus status,
    @Param("now") LocalDateTime now
);


6. 적용 후 달라진 점

이전에는 expiresAt이 단순 기록용 컬럼이었다.

스케줄러 도입 후 30초 이내에 만료된 HOLD가 실제로 해제되고 해당 좌석이 다시 AVAILABLE 상태로 돌아온다.

만료 처리 지연은 최대 30초다.

fixedDelay = 30000이므로 이전 실행이 끝난 뒤 30초 후에 다음 실행이 시작된다.

HOLD 만료 시간이 5분인 상황에서 30초 오차는 실용적으로 허용 가능한 수준이다.


7. 배운 점

fixedRate vs fixedDelay 구분이 중요하다.

DB 처리가 포함된 스케줄러는 실행 시간이 간격보다 길어질 수 있다.

fixedRate를 쓰면 이전 실행이 끝나기 전에 다음 실행이 시작될 수 있어서 상태 변경 로직이 겹치는 상황이 발생한다.

fixedDelay는 이전 실행 완료 후 대기하므로 더 안전하다.

스케줄러를 얇게 유지하는 것도 설계다.

처음에는 스케줄러 클래스 안에 만료 처리 로직을 다 넣을까 생각했다.

하지만 @Scheduled가 붙은 클래스는 실행 주기만 담당하고

실제 처리는 서비스로 분리하는 게 테스트와 책임 분리 측면에서 낫다.

서비스 단위 테스트 시 스케줄러 없이 HoldExpirationService.expireHolds()만 독립적으로 호출할 수 있다.

N+1은 의도적으로 막아야 한다.

fetch join을 명시하지 않으면 루프 안에서 showtimeSeat 접근할 때마다 쿼리가 나간다.

만료 대상이 적을 때는 눈에 띄지 않지만 처리 건수가 늘면 바로 문제가 된다.

쿼리를 의도적으로 설계하는 습관이 필요하다.

다중 인스턴스 문제는 지금 당장 발생하지 않아도 인식하고 있어야 한다.

단일 인스턴스에서는 @Scheduled로 충분하지만

수평 확장을 고려하면 ShedLock이나 Quartz 같은 분산 스케줄링 솔루션이 필요하다.

지금은 미적용 상태지만 실제 운영 환경에서 인스턴스를 늘리는 순간 중복 실행 문제가 바로 생긴다.


8. 참고 자료