Skip to content
xxwon.log
Go back

'이미 선택된 자리입니다', 그 많던 좌석은 다 어디로 갔을까

최종 보스는 이.선.좌

지루하고 끝나지 않을 것 같은 대기열을 어찌저찌 뚫고 들어간다고 해도 내 자리는 없다. 분명 선택할 수 있는 좌석이었는데 막상 선택하면 “이미 선택된 좌석입니다” 라는 문구가 여지없이 뜬다. 대기열은 순서를 정해줬지만, 입장한 수천 명이 동시에 같은 좌석을 누르는 순간 새로운 경쟁이 시작된 것이다.


왜 이런 일이 생길까

여러 요청이 동시에 같은 좌석을 읽는다. 각자 “아직 빈 좌석이다”라고 판단하고 모두 예약을 시도한다. DB는 먼저 도착한 요청을 처리하지만, 나머지 요청도 이미 “빈 좌석”이라는 판단을 내린 상태라 예약을 진행한다. 결과적으로 한 좌석에 여러 예약이 생길 수 있는 것이다.

흔히 말하는 레이스 컨디션1이다. 이를 해결하는 두 가지 방법을 직접 구현하며 비교해봤다.


V1: Redisson 분산 락으로 제어하기

첫 번째로 생각한 것은 SELECT FOR UPDATE(비관적 락)이었다. 먼저 도착한 유저의 예매가 끝날 때까지 다른 요청을 막으면 중복 예약이 생기지 않을 것이다. 하지만 DB 락 경합은 고동시성에서 병목이 된다. 수만 명이 동시에 같은 좌석에 접근하면 락 대기가 쌓이고 DB 커넥션을 오래 점유하게 되며 그 결과 서버가 터질 것이다.

그러면 DB 앞에 완충 역할을 해줄 것이 있으면 좋지 않을까? Redisson이 그 역할을 해준다. 예매 요청을 RabbitMQ 큐에 넣으면 즉시 응답을 반환한다. 사용자는 빠르게 선점 결과를 받고, 실제 처리는 컨슈머가 Redisson 락을 잡아 Redis Set을 확인하고 DB에 쓸 수 있다.

문제는 Redisson 위치를 어디에 두느냐이다. 컨트롤러에서 락을 잡고 Redis를 차감한 뒤 큐에 넣으면 확정된 요청만 큐에 들어가 컨슈머 로직이 단순해진다. 반대로 큐에 먼저 넣고 컨슈머에서 락을 잡으면 컨트롤러가 락 대기 없이 빠르게 응답할 수 있다. 나는 후자를 택했다.

컨트롤러 (200 VU 동시)
  └─ Redis isMember 확인 → 모두 true
  └─ RabbitMQ 발송 → 즉시 202 응답

RabbitMQ
  └─ 200개 메시지 대기

BookingConsumer (순차 처리)
  ├─ VU-1: Redisson 락 획득 → SREM 성공 → DB 저장 (PAYMENT_PENDING)
  ├─ VU-2: 락 대기 → 락 획득 → SREM 실패 → REJECTED
  ├─ VU-3: 락 대기 → 락 획득 → SREM 실패 → REJECTED
  └─ ...

다만 이 방식의 한계가 있다. 같은 좌석에 대한 중복 요청이 큐에 쌓이고, 경쟁에서 진 요청들도 락을 획득했다 반환하는 과정을 거친다. 요청이 몰릴수록 이 비용이 커진다.


V2: Lua Script로 락 없애기

두 번째 접근은 락 자체를 없애는 것이었다. Redis에서 원자적으로 처리하면 락이 필요 없다. Lua Script로 SREMSETEX를 하나의 명령으로 묶었다. Redis는 단일 스레드로 명령을 처리하기 때문에 이 두 작업 사이에 다른 요청이 끼어들 수 없다. 좌석 ID가 Set에 없으면 스크립트가 실패를 반환하고, 이후 요청은 전부 거부된다.

V1과 달리 경쟁에서 진 요청은 컨트롤러에서 즉시 409를 받는다. 불필요한 MQ 발송도 없고, 락 획득 대기도 없다.


검증

ExecutorService로 스레드 200개를 띄우고 CountDownLatch로 동시에 출발시켰다. 모두 같은 좌석을 예매 시도한다.

int threadCount = 200;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);

for (int i = 0; i < threadCount; i++) {
    long userId = i;
    executorService.submit(() -> {
        try {
            redisLockFacade.createBookingWithLock(userId, seatId, concertId);
        } catch (Exception ignored) {
        } finally {
            latch.countDown();
        }
    });
}

latch.await();

// DB에 예약이 정확히 1건이어야 함
assertThat(bookingRepository.count()).isEqualTo(1);

// Redis Set에서 해당 좌석이 제거되어야 함
assertThat(redisTemplate.opsForSet().isMember(cacheKey, seatId.toString())).isFalse();

200개 스레드가 동시에 진입했지만 DB 예약은 정확히 1건, Redis Set에서도 해당 좌석 ID가 제거됐다.


트러블슈팅: 구현하면서 발견한 구멍들

1. 유저당 중복 예매 방지가 빠져있었다.

V2 Lua Script는 같은 좌석을 두 번 예매하는 건 막아준다. SREM이 이미 없는 값이면 0을 반환하니까. 그런데 같은 유저가 다른 좌석 여러 개를 동시에 요청하면 둘 다 성공했다. Lua Script가 좌석 단위 중복은 막아주지만, 유저 단위 중복은 체크하지 않았기 때문이다.

이를 발견하고 나서 booking:result:{userId} 키의 존재 여부를 먼저 확인하도록 스크립트에 한 줄을 추가했다.

if redis.call('EXISTS', KEYS[2]) == 1 then return 0 end

이 조건으로 이미 예매가 진행 중인 유저는 다른 좌석을 요청해도 즉시 거부된다.

2. 좌석 선점 타임아웃을 구현한 줄 알았는데 아니었다.

booking:result:{userId}에 TTL 600초를 걸었으니 선점 타임아웃이 된다고 생각했다. 그런데 그 키는 예매 상태값이 만료되는 것이지, 좌석이 풀리는 게 아니었다. Lua Script에서 SREM으로 좌석을 Redis Set에서 제거하고 DB에 RESERVED로 저장하는 순간 좌석은 영구적으로 잠겼다. 결제를 안 해도 자동으로 돌아오는 로직이 없었다.

RabbitMQ의 TTL + Dead Letter Exchange로 해결했다. 예매 성공 시 10분짜리 만료 메시지를 별도 큐에 넣는다. 10분 후 메시지가 만료되면 Dead Letter Exchange를 통해 처리 큐로 이동하고, 컨슈머가 DB를 확인해 아직 PAYMENT_PENDING이면 좌석을 반납한다. 이미 결제가 완료됐으면 아무것도 하지 않는다.


대기열 쉽지 않다..

처음에 V1과 V2를 둘 다 구현한 건 비교하기 위해서였다. 락 기반과 원자적 연산 기반이 실제로 어떻게 다른지 직접 확인하고 싶었고, 그 대표적인 두가지로 동시성 문제가 해결될 거라는 막연한 믿음이 있었다.

하지만 동시성 문제를 어느정도 해결한다 하더라도 이미 선택된 좌석입니다 를 완전히 막지 못한다.

V1/V2는 서버 레벨에서 이중 예약을 막지만 유저 입장에서는 여전히 좌석을 선택하고 결제를 시도했다가 거부당하는 경험을 한다. 동시성 제어와 유저 경험은 또 다른 문제인 것이다.

이를 해결하려면 Soft Hold가 필요한데, 이 방식은 유저가 좌석을 선택하는 순간 Redis에 임시 잠금을 걸고, 다른 유저에게는 해당 좌석을 비활성화로 표시한다. 결제를 완료하면 영구 예약으로 전환하고, 일정 시간 내 결제하지 않으면 잠금을 해제해 좌석을 반납한다. Soft Hold를 사용하게 되면 유저는 선택한 좌석이 결제 전에 사라지는 경험을 하지 않아도 된다. 실제 서비스에서 좌석 선택 후 타이머가 뜨는 UX가 이런 구조이다.

비록 완벽하게 동시성을 제어하지 못하고, ‘이미 선택된 좌석입니다’를 피할 수 있는 방법을 찾을 수 없었지만 이 프로젝트를 통해 배운 것들이 꽤 많다.

완벽한 기술이나 방법이 없기 때문에 구현 목적에 최대한 부합하도록 로직을 트레이드 오프 해야하는 것도 이 프로젝트를 하면서 배웠다. 큐의 메시지 복구 로직을 의도적으로 구현하지 않은 것도 이에 해당한다. RabbitMQ 컨슈머가 처리 중 실패하면 해당 예매는 버려진다. 일반적인 시스템이라면 재시도나 DLQ가 필요하겠지만, 인기 경기 예매의 특성상 실패한 예매를 복구하는 것보다 다음 사람이 빠르게 예매할 수 있는 게 비즈니스적으로 맞다고 판단했다. 실패한 요청을 재처리하는 동안 그 좌석은 잠겨있는 셈이고, 그 시간에 다른 사람이 예매하는 게 훨씬 낫다고 생각했다.

또 설계의 중요성을 한 번 더 느꼈다. 테스트 코드를 작성하면서 그제야 구멍을 발견했다. 유저당 중복 예매 방지도, 좌석 선점 타임아웃도 없었던 것이다. 특히 타임아웃은 “TTL을 걸었으니 됐겠지”라고 넘어갔다가 나중에 들여다보니 엉뚱한 키에 걸려있었다. 설계 단계에서 예외 케이스를 충분히 고민했더라면 더 일찍 확인 할 수 있었던 부분이다.

비록 실제 환경과 다르게 제한된 환경임에도 불구하고 이렇게 고려할 부분이 많고 복잡해서 대규모 서비스를 경험한다는 것을 높게 평가한다는것을 몸소 느꼈다.

언젠가는이 지루한 대기열을 통과해서 누구나 빠르게 예매 가능한 좌석들을 만날 수 있는 시스템을 경험해보고 싶다.


Footnotes

  1. 여러 개의 프로세스나 스레드가 공유 데이터에 동시에 접근할 때, 접근 순서에 따라 실행 결과가 달라지는 상황. 마치 마지막 남은 야구 티켓 한 장을 두고 두 명의 구매자가 동시에 ‘결제’ 버튼을 눌러 서로 먼저 처리되려고 경주(Race)를 벌이는 것과 같다.


Share this post on:

Previous Post
우울해서 운세봇을 만들었어
Next Post
현재 대기순번 56898번째, 나는 왜 항상 밀렸을까