Skip to main content

Ticket-Lab : 티켓 예매 시스템 구축기


Intro

1. 0.1초라도 빠르게! 티켓 예매 시스템

인기 있는 콘서트나 스포츠 경기의 예매가 시작되는 순간, 시스템은 평소의 수백 배에 달하는 트래픽 문제를 마주합니다. 이는 평소에도 야구 티켓을 가끔 예매했던 나도 자주 겪던 상황입니다. 수만 명의 유저가 동일한 좌석을 점유하려 할때, 단 하나의 좌석도 중복 판매되지 않으면서도 고가용성을 보장하는 것이 이번 프로젝트 Ticket-Lab의 핵심 과제였습니다.

해당 프로젝트는 도메인 특성을 고려하여 가장 중요한 목표를 다음 3가지로 정했습니다.

  • 데이터 정합성(+트랜잭션 일관성)
  • 멱등성 보장
  • 동시성 제어

단순한 기능 구현을 넘어, 분산 환경에서의 데이터 정합성과 시스템 가용성을 확보하기 위한 기술적 선택들과 고민들을 기록해봤습니다.


2. 핵심 설계 원칙 및 아키텍처

아키텍쳐

2.1 Facade 패턴을 통한 인프라 추상화

비즈니스 로직이 Redis나 Kafka 같은 특정 인프라 기술에 섞이는 것을 방지하기 위해 Facade 계층을 두었다. 서비스 레이어는 오직 도메인 비즈니스 로직에만 집중하고, Redis 분산 락의 획득이나 Kafka 이벤트 발행과 같은 인프라적 흐름은 Facade에서 제어하도록 했다.

2.2 Rich Domain Model

엔티티가 단순히 데이터 전달자역할만 수행하는 것이 아니라, 스스로의 상태를 검증하고 비즈니스 규칙을 수행하도록 설계했다.


3. 동시성 제어: Why Redisson?

단일 서버라면 다른 선택지가 있을 수도 있지만 다중 서버 분산 환경에서는 이를 해결하기 위해 Redisson 분산 락이 필수적입니다.

Redisson 분산 락의 선택 이유

  1. Redisson은 Pub/Sub 기반으로 락 해제 메시지를 받았을 때만 재시도를 수행하므로 Redis에 가해지는 부하가 현저히 적다.
  2. 락 획득 타임아웃과 리스 타임: 무한정 락을 보유하여 발생하는 데드락을 방지하기 위해 waitNextleaseTime을 설정하여 안정성을 높였다.

4. 공정한 대기열 시스템: Redis ZSet

수만명이 몰리는 도메인 특성상 DB의 부하를 최소한으로 만들어야한다. 모든 유저를 한꺼번에 DB로 보내면 당연히 커넥션 풀이 고갈되고 DB 서버가 마비됩니다. 이를 막기 위해 입구에서부터 트래픽을 제어하는 대기열 시스템을 구축했습니다.

Sorted Set(ZSet)을 활용한 우선순위 큐

  • 진입: 유저가 예매 페이지에 접근하면 현재 시간을 Score로 하여 ZSet에 저장합니다. 이는 자연스럽게 FIFO 구조를 형성합니다.
  • 스케줄링: 1초마다 실행되는 스케줄러가 현재 활성 유저(Active User) 수를 확인하고, 여유 공간만큼 대기열 유저를 활성 상태로 전환합니다.
  • 상태 전환: 활성 유저는 개별 키에 TTL을 부여하여 관리합니다. TTL을 설정하지 않는다면 계속해서 데드락이 발생하고 시스템 성능 저하와 장애를 유발합니다.

5. 이벤트 기반 아키텍처 (Kafka)

예매 완료 시 수행되어야 하는 외부 API 등의 부가작업(이메일 발송, SMS 알림)은 예매 완료 트랜잭션과 함께 묶여서는 안됩니다.

비동기 처리를 통한 사용자 경험 최적화

기존의 동기 방식에서는 외부 메일 API 서버가 느려지면 사용자에게 응답이 가는 시간도 길어진다. Kafka를 도입하여 예매 성공 이벤트를 발행하고 즉시 응답을 반환함으로써 사용자 응답 속도를 밀리초 단위로 단축했다.

  • 신뢰성 보장: acks=all 설정을 통해 Kafka 클러스터 내 모든 복제본에 메시지가 기록됨을 확인한다.
  • 순서 보장: seatId를 메시지 키로 사용하여 동일 좌석에 대한 이벤트 순서가 섞이지 않도록 보장했다.

6. 부하 테스트 진행

설계한 아키텍처가 실제 극한의 상황에서도 의도대로 동작하는지 확인하기 위해 대규모 부하 테스트를 진행했다. 이번 테스트의 핵심은 **' 분산 락을 통한 정합성 보장'**과 ' 대기열 시스템의 트래픽 제어 능력' 을 검증하는 것입니다.

6.1 테스트 진행 과정 및 실시간 모니터링

테스트는 실제 유저 1,000명이 100개의 좌석을 놓고 동시에 경쟁하는 시나리오로 설계했다.

1️⃣ 대기열 인입 및 스케줄링 테스트 시작과 동시에 Redis Zset 대기열에 1,000명의 유저 정보가 저장됩니다. 큐 스케줄러는 1초마다 대기열을 확인하여 순차적으로(FIFO) 초당 100명씩 Active 상태로 전환합니다.

2️⃣ 좌석 선점 및 캐싱 전략 선점이 이루어지거나 예매가 완료된 좌석 정보는 즉시 Redis에 캐싱합니다. 이는 후속 유저들이 불필요하게 DB에 접근하여 중복 조회를 하지 않도록 차단하는 역할을 합니다.

Redis 좌석 상태 (Seat Status)Redis 대기열 (Waiting Queue)

3️⃣ 실시간 모니터링 대기열에서 활성 상태로 전환된 유저들의 예매 성공/실패 지표를 Grafana로 실시간 모니터링했습니다. 시간이 지남에 따라 Success 지표가 계단식으로 안정적으로 상승하는 것을 확인했습니다.

그라파나 대시보드: 예매 성공 지표의 점진적 상승 확인

4️⃣ Redis 캐싱 상태 검증 자리 선점 및 예매가 완료된 좌석들이 Redis 내에 CONFIRMED 상태로 정확히 기록되는 것을 확인했습니다. 이 상태의 좌석은 다른 유저가 접근할 수 없도록 철저히 격리됩니다.

CONFIRMED: 예매가 완료되어 접근이 차단된 좌석 상태

SELECTED만 캐싱해서 접근을 막으면 되고 어차피 예약된 좌석은 막혀있어서 예약 자체 시도를 못할텐데 굳이 CONFIRMED까지 캐싱해야할 이유가 있을까? 라는 생각을 했는데 예약된 좌석을 캐싱해놔야 유저들이 좌석을 새로고침할때마다 트랜잭션을 일으키지 않고 캐싱된 데이터를 가져오기 때문에 필수적으로 해놔야한다!


6.2 트러블슈팅

예상 시나리오와 다르게 100개의 좌석이 다 팔리지 않은 문제 발생

1차 테스트 결과, 성공 지표가 예상치인 100건에 못 미치는 79건에서 멈춰버렸다. 또한 전체 유저 중 고작 270명만이 Active 큐에 진입하고 나머지는 탈락하는 현상이 발생했습니다.

원인 분석: 권한 부여와 요청 타이밍의 불일치

데이터를 뜯어본 결과, 원인은 스케줄러와 테스트 에이전트 간의 미스매치이었습니다.

  • 스케줄러: Redis ZSet의 Score(진입 시간)가 낮은 순서대로 권한을 부여합니다. (예: 유저 7, 55, 45 순)
  • 테스트 코드: 단순히 유저 ID 1번부터 순차적으로 API 요청을 보냈습니다.
  • 결과: 아직 스케줄러로부터 Active 권한을 얻지 못한 유저(순서가 늦은 유저)가 먼저 요청을 보내면서 서버에서 403 Forbidden 에러를 뱉었고, 이로 인해 유저들이 정상적으로 프로세스를 완료하지 못한 채 이탈했습니다.

✅ 해결 방법: 폴링(Polling) 기반 대기 로직

테스트 코드를 실제 유저의 행동 패턴과 유사하게 수정했습니다. 유저는 대기열에 들어간 후, 자신의 상태가 'Active'가 될 때까지 주기적으로 상태를 확인(Polling)합니다. 오직 권한을 획득한 시점에만 예매 API를 호출하도록 로직을 변경하여 모든 유저가 정상적으로 기회를 얻도록 설정했습니다.


6.3 최종 테스트 결과 : 전석 예매 완료

로직 수정 후 진행된 재테스트에서는 모든 지표가 설계한 의도대로 정확히 맞아떨어졌습니다.

재테스트 모니터링 1재테스트 모니터링 2
  • 최종 상태: 티켓 100개 전량 매진 성공.
  • 프로세스 완결성: [대기 → 활성화 → 선점 → 예매]로 이어지는 유저 흐름이 의도대로 유기적으로 작동함을 확인했습니다.
재테스트 성공 지표서버 성공 로그

💾 DB-Redis 정합성 확인

테스트 종료 후 DB의 상태를 최종 점검했다. Redis 캐시와 DB 간의 오차 없이 모든 좌석 상태와 예매 내역이 일치함을 확인하여 정합성을 확인했습니다.

좌석(Seat) DB 결과예매(Reservation) DB 결과

후기 및 회고

실무에서 대규모 트래픽을 마주한 경험이 거의 없었기에 언젠가 해보려고 했던 주제이고, 기회가 생겨서 진행하게 되었습니다. 프로젝트를 시작하기 전에는 "Redis는 빠르니까 쓴다", "Kafka는 비동기 처리에 좋다" 정도의 추상적인 느낌만 가지고 있었습니다.

하지만 실제 설계 단계에 들어가니 질문은 훨씬 구체적이어야 했습니다. 아직 적절한 TTL 시간, 스케쥴러 설정, Redis 고가용성 등 고민할 거리가 더 많이 남아서 개선중입니다. 계기가 있어 시작한 프로젝트지만 평소에도 언젠가 해보고 싶은 주제였고 며칠동안 만들면서 정말 정말 재밌었다!