-
Notifications
You must be signed in to change notification settings - Fork 5
Redis Sorted Set을 활용한 예매 대기열 구축
인기 많은 공연의 티켓팅을 하다 보면 아래와 같은 팝업을 마주하곤 한다.
더 좋은 자리를 선점하기 위해 예매 오픈과 동시에 대량의 사용자가 예매를 시도하기 때문이다. 만약 한 번에 대량의 트래픽이 몰릴 경우, 디비에 요청이 몰리면서 병목 현상이 발생할 수 있다. 이 병목 현상은 서버에 오는 모든 요청의 응답을 느려지게 하고, 사용자에게 정상적인 예매 서비스를 제공할 수 없게 된다.
따라서 대기열 서비스를 통해 이를 방지할 필요성이 있다.
위의 상황을 대비하여, 우리는 아래의 목표들을 세웠다.
- 디비에 부하가 가지 않도록 요청 수 조절
- 예매가 정상적으로 이루어지도록 전반적인 플로우 설계
- 다양한 유저 시나리오에 대한 대응 - 한 아이디, 여러 브라우저로 접속 / 중간에 이탈 / 등등..
우리는 위 목표를 이루기 위해 큐 시스템을 도입해 대기열을 구축해야하는데, 필요한 기능은 다음과 같다.
- 진입하는 브라우저들에게 대기 순서 발급
- 대기열에 참가
- 대기열 사용자에게 현재 대기 순서 알려줌
- 내 순서가 되면 대기열에서 참가열로 이동
- 예매를 위한 토큰 발급
- 이후 예매 진행...
그럼 어떤 방법을 이용해 구현할 수 있을까?
- Redis를 이용해 큐를 구현하는 방법
- AWS의 SQS를 사용하는 방법
우리는 앱에서 이미 캐시를 사용하고 있는 상황이었고, redis는 입/출력이 빠른 검증된 저장소이다. 또한 클라우드 서비스는 지원을 안해주는 상황이었기 때문에, 1번 방법을 채택하기로 했다.
우선 대기열, 참가열 모두 Sorted Set 자료구조를 활용했다. 그 이유는 입장 순서에 따른 정렬(ZADD)과 현재 내 순위에 대한 조회(ZRANK)가 필요했다. 또한 참가열에 빈 자리 수만큼 대기열에서 이동 시켜주어야 했다(ZREMRANGEBYSCORE + ZADD).
그리고 언급된 Redis 명령어의 각 시간복잡도는 아래와 같다.
명령어 | 동작 | 시간복잡도 |
---|---|---|
ZADD | 데이터 추가 시 부여한 스코어에 따라 정렬 | O(log(N)) |
ZRANK | 현재 순위 조회 | O(log(N)) |
ZREMRANGEBYSCORE | 특정 점수 범위에 속하는 모든 요소 제거 | O(log(N) + M) |
전체적인 플로우는 아래 사진과 같다.
브라우저마다 고유한 세션ID를 발급한다. [예매하기] 버튼을 클릭하면 이 세션ID를 Booking-Session-Id
헤더에 담아 대기열 참가를 요청한다. 그러면 대기열 Redis SortedSet 자료구조에 세션ID-대기번호 튜플을 저장하고, 해당 브라우저는 대기열에 참가하게 된다.
참고) 빙터파크의 Redis SortedSet 구조는 다음과 같다.
각 브라우저는 N초마다 내 대기열 순서를 조회할 수 있다. 이때, 요청이 들어올 때마다 지정 시간보다 오래 머무른 경우, 브라우저를 이탈했다고 판단하여 세션 아이디를 정리해 주는 작업을 먼저 진행 후 내 순서를 조회하고 있다.
만약 내 순서가 참가열에 들어갈 수 있는 순서가 되면 대기열에서 나와 참가열로 이동하게 된다.
2번 내 대기열 순서 조회 과정에서 참가열에 진입 가능한지에 대한 여부를 응답받을 수 있다. 참가열에 진입하고나서는 예매를 시작할 수 있는데, 모든 예매 과정에선 추가적으로 예매를 위한 Booking-Authorization
헤더에 토큰이 필요하다. 예매 토큰은 세션 아이디 정보가 포함되어 생성된다. 토큰의 만료 시간은 7분이므로 모든 예매 과정은 7분 이내로 마무리 되어야 한다.
❗ 예매 토큰을 도입한 이유: 예매 토큰을 가지고 있다면 예매창을 벗어날 때까지는 다시 대기열에 진입할 필요가 없도록 하기 위함이다.
❗ 토큰의 Payload에 브라우저 Session ID를 포함한 이유: 같은 사용자가 여러 브라우저를 열어 예매할 경우를 고려했다
참가열에 진입 후 토큰을 발급받고 이어지는 첫 요청이다. 사용자는 좌석 목록 조회부터 시작하여 좌석 선점 과정을 통해 구매할 좌석을 선택할 수 있다. 좌석 선점 기능은 Redis를 함께 이용하여 구축하였기 때문에, 약 7분 동안만 선점이 가능하도록 되어있다.
예매 과정에서는 선택한 좌석이 요청이 들어온 세션의 선점 좌석인지 더블 체킹을 하고 있기 때문에, 선점 과정없이 예매 생성은 불가능하다.
예매 가능한 좌석, 수령 방법 등을 선택하면 예매 정보가 생성된다. 이때, 참가열에 있던 세션 정보가 제거된다.
이제 예매자가 결제를 진행하면 예매가 완료된다. 예매가 최종적으로 완료되면 더 이상 예매 토큰이 필요하지 않아 클라이언트는 저장된 예매 토큰을 제거한다.
유저의 예매 플로우를 한눈에 정리한 그림은 다음과 같다
이 외에도 예매 생성후 별도로 이탈 처리를 위한 api를 타지 않은 경우 및 자정까지 결제되지 않은 예매의 좌석 release를 위해 별도로 상태를 bulk update 해주는 배치도 사용하고 있다.
배치 코드@Transactional
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
List<Ticket> tickets = ticketRepository.findAll(LocalDateTime.now(), BookingStatus.WAITING_FOR_PAYMENT);
ticketRepository.updateSeatStatusBulk(EventSeatStatus.AVAILABLE, tickets.stream().map(Ticket::getId).toList());
return RepeatStatus.FINISHED;
}
참고) 테스트를 위해 참가열 큐 사이즈를 2로 설정했다.
- 현재 4개의 브라우저가 예매를 시도하고 있다. 이때, 참가열 큐 사이즈=2 이므로 두 개의 브라우저만 좌석을 조회할 수 있다.
- 하단의 두 브라우저는 대기열에서 대기하며 1초에 한 번씩 대기 순서 조회 요청을 보내고 있다.
- 동시성 처리를 위해 Redis로 비관적 락을 구현해서 좌석 선점 처리를 해주고 있다. 그래서 한 브라우저가 선점한 좌석은 다른 브라우저에서 접근할 수 없다.
- 좌석 선택 후 회차 정보, 구매자 정보, 수령 방법 등을 입력하고
다음
버튼을 클릭하면 예매 주문정보가 생성된다. 이때, 해당 세션은 참가열에서 제거된다. - 참가열에 자리가 생겨 왼쪽 하단 브라우저는 참가열에 입장하고, 오른쪽 하단의 브라우저는 대기 순서가 1로 변하였다.
- 왼쪽 상단 브라우저에서 결제를 완료하면 최종적으로 예매가 완료된다.
- 사용자가 몰리지 않는 경우에도 무조건 대기열 거쳐야 한다.
- 대기열 서비스가 전반적으로 Redis에 의존하고 있기 때문에, 만약 Redis 서버가 다운되면 정상적으로 동작할 수 없다.
- 요청수가 많아질 수록 Redis 성능이 저하된다.