Daily-It

개발, AI, 인프라, 자동화와 일상 IT 제품 후기를 직접 써보며 정리하는 기술 블로그입니다.

대규모 예매 시스템 아키텍처: 대기열·좌석 선점·결제 분리

얼마 전 지인과 명절 예매 이야기를 하다가, “접속자를 수십만 명씩 받는 서비스는 결국 Redis를 앞에 두면 끝나는 것 아니냐”는 말이 나왔다. 처음에는 나도 그렇게 단순하게 생각했는데, 이야기를 이어가다 보니 문제는 Redis 자체보다 어디까지를 기다리게 하고, 언제부터 진짜 예매로 취급할지에 있었다.

이 글은 수백만 동시접속 예매 서비스를 직접 운영한 결과 보고서는 아니다. 회사 솔루션과 업무 시스템을 개발·운영하며 익힌 관점으로, 공개된 대규모 예매 아키텍처 사례를 보며 지인과 정리한 설계 메모다. 그래서 “정답 구조”보다, 설계 전에 꼭 합의해야 할 경계와 실패 지점을 중심으로 적어 둔다.

연관 글

친구가 묻고, 내가 다시 생각한 질문

“예매 버튼을 누르면 DB에 예약을 넣으면 되지 않아?”라는 질문이 가장 익숙하다. 평소 트래픽에서는 맞는 말이다. 하지만 오픈 시각에 사람이 한꺼번에 몰리면 API 서버를 늘려도 마지막에 DB, 재고 행, 결제 연동처럼 공유 자원이 한 번에 흔들린다.

그래서 대규모 예매는 보통 기능을 세 덩어리로 나눠 생각하는 편이 낫다. 대기열 등록, 예매 가능 사용자로의 입장, 그리고 실제 좌석·수량 처리다. 이 셋을 한 API에 섞으면 “대기 순번을 보여 주는 가벼운 요청”까지 예약 트랜잭션과 같은 비용을 내게 된다.

사용자 → 대기열 등록/상태 조회 → 입장 허가 → 좌석·수량 선점 → 예약 확정 → 결제

여기서 중요한 것은 대기열이 단순한 장식이 아니라 뒤쪽 시스템이 감당할 양만 통과시키는 밸브라는 점이다. DB가 초당 100건을 안정적으로 확정할 수 있다면, 입장 처리량도 그보다 여유 있는 수준으로 조절해야 한다. “많이 받는 것”보다 “다음 계층을 무너지지 않게 받는 것”이 먼저다.

대기열은 MQ보다 Redis가 항상 맞을까?

대화 중에 바로 나온 반론은 “FIFO면 Kafka나 MQ로 줄 세우면 되지 않나?”였다. 뒤쪽 비동기 처리에는 MQ가 아주 잘 맞는다. 다만 사용자 대기열은 메시지를 순서대로 빼는 것만으로 끝나지 않는다. 사용자는 새로고침 뒤에도 자신의 순번을 보고 싶고, 중복 클릭은 한 번의 대기로 합쳐져야 하며, 취소나 만료도 처리돼야 한다.

이런 요구에는 사용자 식별자와 순번을 함께 다루기 쉬운 Redis Sorted Set 같은 구조가 편하다. 순번 조회, 전체 대기 수, 중복 등록 방지, 특정 사용자 제거를 한 흐름으로 다룰 수 있기 때문이다. 반대로 영구 저장·후속 알림·재처리는 MQ가 맡는 편이 자연스럽다. 앞단은 대기 상태 관리, 뒷단은 비동기 처리라는 역할 분리가 핵심이다.

다만 시간값만 점수로 쓰면 여러 서버의 시계나 같은 순간의 순서 문제가 남는다. 순번은 서비스가 발급한 단조 증가 값 또는 원자적 등록 결과로 결정하고, 재시도에는 같은 사용자·같은 예매 시도 키가 같은 결과를 내도록 멱등성을 설계해야 한다.

좌석이 아니라 ‘선점’이 어려운 이유

친구가 “좌석 하나 남았으면 하나 빼면 되는 것 아니냐”고 하자, 내가 더 오래 설명한 부분이 여기였다. 잔여 수량 확인과 차감이 분리되면 두 요청이 동시에 같은 좌석을 보고 둘 다 성공할 수 있다. 이 구간은 조회와 차감을 한 덩어리로 처리해야 한다.

수량만 고르는 예매라면 잔여 수량을 조건부로 차감하는 원자 연산이 출발점이 된다. 실제 좌석을 고르는 서비스라면 seat:{공연}:{회차}:{좌석번호} 같은 키를 짧은 TTL과 함께 선점하는 방식도 생각할 수 있다. 단, 선점 성공은 결제가 끝났다는 뜻이 아니다. 사용자가 브라우저를 닫거나 결제가 실패하면 선점은 반드시 만료되거나 보상 처리돼야 한다.

내가 먼저 적어 둘 규칙

  • 한 사람이 같은 예매 요청을 여러 번 보내도 결과는 한 번만 반영한다.
  • 선점에는 만료 시간이 있고, 만료·실패·취소 뒤 수량 복구 경로가 있다.
  • Redis의 선점 결과와 RDB의 확정 예약이 어긋났을 때 누가 복구할지 정한다.
  • 결제 성공 콜백이 중복 도착해도 예약이 중복 확정되지 않는다.

결제를 뒤로 미루는 이유

대규모 오픈 순간에 좌석 선택, 쿠폰 검증, 카드 승인, 영수증 발행까지 한 트랜잭션에 넣고 싶어지지만, 그러면 가장 비싼 외부 의존성이 가장 뜨거운 구간에 들어온다. 예매 가능 여부를 먼저 짧게 결정하고, 한정된 선점 사용자에게만 결제 단계를 열면 초기 폭발을 훨씬 작게 만들 수 있다.

그렇다고 결제를 “나중에 붙이면 된다”는 뜻은 아니다. 예약 상태를 HOLD → PAYMENT_PENDING → CONFIRMED처럼 나누고, 결제 시간 초과·웹훅 중복·승인 뒤 예약 저장 실패를 어떻게 되돌릴지 정해야 한다. 업무 시스템에서 상태 전이를 애매하게 시작하면, 결국 운영 화면에서 사람이 예외 건을 붙잡게 된다.

대기 순번을 보여 주는 통신도 아키텍처다

대기 화면이라면 WebSocket이 더 실시간처럼 보인다. 그런데 수십만 연결을 계속 잡아 두는 비용과 재접속 폭풍까지 생각하면, 단순한 순번 조회에는 짧은 HTTP 폴링이 더 현실적일 때가 있다. 핵심은 고정된 1초 폴링이 아니다. 대기 순번이 멀 때는 간격을 길게, 가까워질수록 짧게 하고 사용자별로 작은 지터를 줘서 같은 순간에 요청이 몰리지 않게 하는 것이다.

WebSocket·SSE·폴링 비교

비교 항목 짧은 HTTP 폴링 SSE WebSocket
연결 모델 요청·응답 후 연결 종료 서버 → 클라이언트 단방향 지속 연결 양방향 지속 연결
대기열에서 강점 무상태 API로 수평 확장이 쉽고, 순번처럼 작은 상태 조회에 단순하다. 입장 허가·상태 변경을 서버가 바로 밀어 줄 수 있다. 실시간 좌석 지도와 양방향 이벤트에 적합하다.
운영 시 주의점 고정 주기면 요청이 몰린다. 서버 제어 간격과 지터가 필요하다. 연결 수, 프록시, 타임아웃을 관리해야 한다. 사용자 요청은 별도 API가 필요하다. 연결 유지·재접속·세션 라우팅을 운영해야 한다.
우선 검토할 상황 순번·입장 가능 여부처럼 주기적 확인으로 충분한 화면 단방향 상태 갱신과 즉시 알림 비중이 큰 대기 화면 좌석 상태가 자주 바뀌고 사용자의 즉각적 상호작용도 필요한 화면

대기열에는 하나만 정답인 통신 방식이 있는 것이 아니다. 단순한 순번 조회라면 적응형 폴링부터 검토하고, 서버가 즉시 알려야 하는 이벤트가 많으면 SSE, 양방향 실시간 상호작용까지 필요하면 WebSocket을 검토하는 순서가 현실적이다.

대기 상태 조회는 DB를 읽는 API가 아니라 메모리 저장소에서 순번·입장 가능 여부만 빠르게 돌려주는 가벼운 API여야 한다. 여기서 무거운 인증, 추천, 상세 조회까지 같이 태우면 대기열을 만든 의미가 희미해진다.

설계보다 먼저 해 볼 부하 테스트

지인과 결론은 의외로 단순했다. “Redis, Kafka, Lua 중 무엇을 쓰느냐”보다 먼저, 어떤 실패를 견딜 것인지 정해야 한다는 것이다. 정상 트래픽만 보지 말고 중복 클릭, 브라우저 이탈, 입장 후 무응답, 결제 웹훅 재전송, 대기열 저장소 일부 장애를 시나리오에 넣어야 한다.

그리고 입장 허가량은 고정 숫자가 아니라 DB 커밋 지연, MQ 적체, 결제 실패율, 선점 만료율을 보며 조절할 수 있어야 한다. 이 지표가 없으면 대기열은 줄을 세우는 화면일 뿐이고, 실제로는 뒤쪽에 부하를 한꺼번에 넘기는 장치가 되기 쉽다.

마무리

대규모 예매 시스템은 “빠른 Redis” 하나로 설명하기 어렵다. 대기열은 사용자의 불안을 줄이는 UX이면서 동시에 뒤쪽을 보호하는 유량 조절 장치이고, 좌석 선점은 재고 차감보다 상태 복구 규칙에 가깝다. 결제는 더 뒤로 보낼수록 단순해지지만, 그만큼 선점과 확정 사이의 상태 전이가 중요해진다.

나는 이런 설계를 볼 때 기술 이름부터 고르기보다, 먼저 어떤 요청을 지금 확정으로 볼지, 어디까지 기다리게 할지, 실패하면 누가 무엇을 되돌릴지를 적어 본다. 그 문장이 정리된 뒤에야 Redis든 MQ든 RDB 락이든 제 역할을 하기 시작한다.

참고 자료