한국 서비스를 기반으로 시스템 디자인 공부하기 – 인터파크 티켓 (하)
이전 포스트: 한국 서비스를 기반으로 시스템 디자인 공부하기 – 인터파크 티켓 (상)
예상되는 문제점들
티켓 예약 시스템을 설계하면서 예상할 수 있는 주요 문제점은 크게 두 가지입니다.
동시 예매 충돌 문제
A와 B 두 사용자가 동시에 같은 공연의 좌석을 예매하려고 한다고 가정해보겠습니다. 두 사람이 좌석표를 요청하는 시점에는 3열 15번 좌석이 예매 가능 상태였다고 가정했을 때, 두 사람이 거의 동시에 이 좌석을 예매하려고 시도할 수 있습니다.
이 경우, 더 빨리 예매 요청을 완료한 사용자가 좌석을 선점하고, 다른 사용자는 예매에 실패해야 합니다. 중요한 점은 이 실패가 즉각적으로 발생해야 한다는 것입니다. 만약 사용자가 배송지 정보까지 입력한 후 최종 결제 단계에서 좌석이 이미 선점되었다는 사실을 알게 된다면, 사용자 경험은 크게 저하될 것입니다.
이를 해결하기 위해, 인터파크 티켓과 같은 서비스에서는 좌석 선택 후 다음 단계로 진행하기 전, 더 늦게 시도한 사용자에게 ‘이미 선점된 좌석입니다.’라는 알람을 띄운 후 최신 상태의 좌석표를 다시 받아오도록 구현하고 있습니다. 이러한 방식은 불필요한 구매 단계 진행을 방지하고, 사용자 경험을 개선하는 효과가 있습니다.
대규모 트래픽 문제
특정 인기 공연의 예매가 시작되는 순간, 수많은 사용자가 동시에 접속하여 티켓팅을 시도합니다. 이로 인해 서버에 과부하가 발생할 가능성이 높습니다. 15배의 트래픽을 예상하고 미리 서버를 스케일아웃했음에도 불구하고, 실제로 50배의 트래픽이 발생한다면 장애가 발생할 수 있습니다. 더 큰 문제는, 설령 50배의 트래픽을 감당할 수 있도록 스케일아웃한다고 해도, 결제 시스템 등 외부 서비스까지 같은 방식으로 확장할 수 있는지는 또 다른 현실적인 제약이 따릅니다.
추가로 고려해야 할 점은, 트래픽이 특정 이벤트로 인해 급증하더라도, 사이트의 다른 기능은 정상적으로 작동해야 한다는 점입니다. 예를 들어, 콜드플레이 내한 티켓팅이 목요일 저녁 8시에 진행된다 하더라도, 같은 시간에 진행되는 연극의 예매 내역 확인은 정상적으로 작동해야 합니다. 현장에 있는 고객이 티켓을 수령하는데 문제가 있으면 안되니까요.
이를 해결하기 위해, 인터파크 티켓과 같은 서비스에서는 대규모 트래픽이 예상되는 이벤트의 경우, 메인 페이지 대신 별도의 랜딩 페이지를 제공하는 전략을 사용합니다. 이 페이지에서는 트래픽이 예상되는 이벤트의 상세 예매 페이지로 바로 가거나, 아니면 기존 사이트 메인으로 진입할 수 있습니다.
또한 동시에 많은 사람이 예매를 시도할 때, 대기열 시스템을 도입한 경우가 많습니다. 고객들은 현재 대기 순서를 확인할 수 있고, 앞번호부터 차례로 좌석표를 볼 수 있는 화면에 진입하여 예매를 진행하게 됩니다.
Low level Design
앞서 설명한 문제를 해결하기 위해, 기존 설계를 조금 더 발전시켜 보겠습니다.
동시에 같은 자리 예약 막기
동시에 같은 좌석을 두 명이 예약하는 문제를 해결하기 위해 가장 먼저 고려할 수 있는 방법은 Ticket의 Status에 새로운 상태를 추가하는 것입니다.
기본적으로 티켓의 상태는 다음과 같이 존재할 수 있습니다:
- AVAILABLE: 예약 가능
- BOOKED: 예약 완료
- ON HOLD (또는 RESERVED): 예약이 진행 중이며 특정 사용자에게 선점됨
여기에 추가적으로 예약 만료 시간을 저장하는 expired_at 필드를 도입하여, 첫 예매 요청이 들어오면 해당 티켓을 ‘RESERVED’혹은 ‘ON HOLD’와 같은 상태로 만들고, 요청이 들어온 시간부터 특정 시간(예: 5분) 동안 해당 좌석을 예약한 사용자가 독점적으로 예매를 완료할 수 있도록 합니다. 한편, 늦게 요청한 사용자는 현재 티켓이 AVAILABLE상태가 아니기 때문에 요청에 실패하게 됩니다.
이때, 좌석표를 불러오는 단계에서 단순히 AVAILABLE 상태의 티켓만 조회하는 것이 아니라, RESERVED 상태지만 expired_at이 현재 시각보다 지난 티켓도 함께 조회해야 한다는 점을 고려해야 합니다.
이 방식은 일반적인 상황에서는 유효하지만, 대량의 예약이 동시에 이루어지는 경우 성능 문제가 발생할 수 있습니다. 많은 사용자가 동시에 예매를 시도하면 DB에 지속적으로 read/write가 발생하면서 병목이 생길 수 있는 것이죠. 특히 대형 공연의 티켓팅에서는 한 좌석에 대해 수많은 요청이 거의 동시에 발생하는 경우가 흔하기 때문에, 기존 Status 방식만으로는 충분하지 않을 수 있습니다. 이를 해결하기 위한 또 다른 방법으로 분산 락(Distributed Lock)을 도입하는 방식이 있습니다.
Redis Lock을 도입한다면, 티켓이 AVAILABLE상태인지와 동시에 Lock이 존재하는지도 확인합니다. Redis는 Single thread이니 스케일아웃한 상태에서도 데이터 정합성을 유지할 수 있을 것입니다. 만약 이미 락이 존재한다면 다른 사용자에게 선점이 된 상태이므로 이전 화면으로 돌아갑니다. 락이 없다면 새로 락을 생성하되, TTL을 설정하여 해당 시간동안만 독점적으로 예약을 진행할 수 있도록 합니다. 시간 안에 예약이 완료된다면 BOOKED 상태로 변화하기 때문에 더이상 다른 사람들이 예매를 하지 못할 것이고, 시간이 만료될 때 까지 예약이 확정되지 않는다면 락이 자동으로 사라져 다른 유저들이 예약할 수 있는 상태로 돌아갈 것입니다.
물론 인터파크 티켓에서 어떤 방식을 채택하고 있는지는 알 수 없으나, 현재 티켓팅이 시작한 후 몇분 후에 예매 완료가 되지 않은 표들이 동시에 풀리는 일명 ‘오류표’ 현상이 있는 것으로 보아, 또한 해당 오류표 현상이 앞좌석부터 순차적으로 일어나는 것으로 보아 Status 방식을 채택하되 읽을 때 expired at을 체크하기 보다는 별도 cron job이 존재하고 job이 돌아가는 시점에 expired at이 과거인 Ticket들의 Status를 다시 AVAILABLE로 돌리고 있는 것이 아닐까 추측합니다. 각 방식에서 예상되는 장점과 단점을 면접관과 논의해보는 것도 좋을 것 같습니다.
많은 Event 정보 요청을 처리하기 위해 Cache 도입하기
대형 공연이나 인기 이벤트의 티켓팅이 시작되면, 수많은 사용자가 동시에 동일한 이벤트 정보를 요청합니다. 만약 이때 매번 DB에서 이벤트 정보를 조회한다면, 불필요한 부하가 쌓이며 성능 저하가 발생할 수 있습니다. 이를 해결하기 위해 Cache를 적극적으로 활용하는 것이 자연스러운 접근 방식입니다.
특히 이벤트 정보는 한 번 정해지면 쉽게 변경되지 않는 데이터입니다. 공연장, 출연진, 공연 날짜 등의 정보는 변경 가능성이 낮기 때문에 캐시의 TTL을 길게 설정해도 문제가 없습니다. 만약 수정이 발생하는 경우, 자동으로 캐시를 Invalidate(무효화) 하도록 설계하면 최신 정보를 유지하면서도 성능을 최적화할 수 있습니다.
인터파크 티켓의 이벤트 상세 페이지를 보다 보면, 텍스트보다는 이미지 기반으로 구성되어 있다는 점이 눈에 띕니다.
이런 구조라면, 이미지와 같은 정적 콘텐츠를 서버에서 직접 서빙하는 것이 아니라 CDN(Content Delivery Network)으로 분산 처리하는 것이 효과적입니다.
CDN을 활용하면
- 분산된 엣지 서버에서 이미지 파일을 캐싱하여 빠르게 제공할 수 있습니다.
- 요청이 들어올 때마다 원본 서버까지 가지 않고 가까운 CDN 노드에서 응답을 주기 때문에 서버 부하를 줄이고 속도를 개선할 수 있습니다.
- 특히, 트래픽 폭증 시에도 원본 서버가 받는 요청 수를 최소화할 수 있어 티켓팅과 같은 고부하 상황에서도 안정적인 성능을 유지할 수 있습니다.
즉, 이벤트 정보는 DB 대신 Cache에 저장하고, 이미지와 같은 정적 리소스는 CDN을 활용하는 방식으로 설계하면 DB와 원본 서버의 부하를 줄이면서도, 빠른 응답 속도를 제공할 수 있습니다.
이러한 구조는 단순한 티켓팅 시스템뿐만 아니라, 방문자 수가 폭증하는 모든 웹 서비스에서 고려할 만한 중요한 설계 원칙이 될 것입니다.
대기 순번 발급하고 이벤트 격리하기
트래픽이 몰리는 상황에서 모든 사용자가 동시에 예매 가능한 화면으로 진입하게 할 수는 없습니다. 앞서 설명했듯이 대기열 시스템(Waiting Room)을 도입하여, 서버가 감당할 수 있는 숫자만큼만 순차적으로 예매 화면으로 이동하도록 제한하는 것이 필요합니다.
이러한 대기열 시스템은 티켓팅뿐만 아니라 다양한 서비스에서도 활용됩니다.
- 배달 서비스: 쿠폰 발급 시 / 할인 이벤트시
- 커머스 서비스: 플래그쉽 핸드폰같은 주요 제품 발매/예약 시작
- 기차 예매: 명절 직전 코레일 예약 시스템
- 항공권 예매: 특가 항공권 발매
이처럼 트래픽이 폭주하는 환경에서 대기열을 운영하는 것은 보편적인 아키텍처 패턴이며, Cloudflare, AWS와 같은 클라우드 서비스에서도 이를 Virtual Waiting Room이라는 이름으로 솔루션을 제공하고 있습니다.
직접 구현할 경우 배달의 민족이나 Wonderwall의 사례에서 보듯 Redis를 주로 사용합니다. 배달의 민족은 Sorted Set으로, Wonderwall에서는 atomic increment기능을 활용해서 카운터 개념으로 접근을 하고 있는데요, 공통적으로는 먼저 오는 사람 순서대로 대기번호를 받게 하고, 그 순서대로 (Sorted니까 혹은 Counter니까) 입장이 가능하도록 참가열로 이동하거나 토큰을 받게 됩니다. 더 자세한 내용은 두 기업의 구현 내용을 확인하시길 추천드립니다.
이 대기열 방법을 사용하게 되면 백엔드에서도 뒷단이 감당할 수 있을 만큼의 트래픽만 뒤로 보내줄 수 있게 됩니다. 또한 이 Virtual Queue의 상태를 보고 앞으로 소화해야하는 트래픽을 고려해서 스케일 아웃을 실시간으로 진행할 수도 있을 것입니다.
대기열에서 빠져나온 경우, 다른 이벤트와 공유하는 Booking Service가 아닌, 별도의 instance를 띄워두고 해당 instance에서 처리하도록 디자인할 수 있지 않을까 싶습니다. 이렇게 별도로 dedicate 된 instance를 띄어둬야 처리할 수 있는 트래픽의 양을 명확하게 측정해서 대기열에서 얼만큼씩 입장시킬지도 결정하기 편하면서 다른 이벤트의 예매에 영향을 끼치지 않을 것 같습니다.
인터파크 티켓의 경우 대형 트래픽이 예상될 때에는 메인화면부터 바뀌면서 고객이 명시적으로 어느 이벤트를 예매할것인지 선택해서 진행하게 합니다. 이 때부터 모든 것을 eventId 기준으로 처리하고 있지 않을까 예상합니다. 한편, 매달의 민족의 경우 앞에 주문 라우터를 두고 업소 id 리스트를 미리 저장해두고 (치킨 이벤트라면 치킨 업소 id 리스트) 주문이 이 리스트 안의 업소로 들어왔다면 라우팅을 시키는 형태로 구분했다고 하네요.
정리
이 외에도 Low level design에서 다룰 수 있는 주제는 많습니다. 예를 들어 서비스를 오랜 기간에 걸쳐 운영할 경우 공연 정보가 매우 많아질텐데, 그럼 검색에 시간이 많이 걸릴 것 입니다. 이를 최적화하기 위한 방법으로 MySQL의 (앞선 글에서 RDB를 쓴다고 가정했으니까요) Full-text index와 같은 기술을 이야기해볼 수 있을 것입니다. 본인이 경험해본 기술 위주로 이야기를 끌어가는게 인터뷰의 기술이 아닐까 합니다.
제가 준비한 인터파크 티켓 시스템 디자인은 여기까지고, 기회가 되면 다른 서비스도 시스템 디자인 연습해보는 글로 돌아오도록 하겠습니다!