본문 바로가기
Coding/시스템 디자인

티켓 예매 서버 시스템 디자인

by Hide­ 2024. 10. 13.
반응형

What is an online movie ticket booking system?

영화 티켓 예약 시스템은 온라인을 통해 사람들이 좌석을 예매할 수 있는 기능을 제공한다. 티켓팅 시스템은 유저들이 언제 어디서나 현재 상영되는 영화를 둘러보고 좌석을 예매할 수 있도록 해준다.

Requirements and Goals of the System

Functional Requirements

  1. 제휴 영화관이 위치하는 다양한 도시를 보여줄 수 있어야 한다.
  2. 유저가 도시를 선택하면 해당 도시에서 개봉한 영화를 보여줘야 한다.
  3. 유저가 영화를 선택하면 해당 영화를 상영하는 영화관과 상영 시간을 보여줘야 한다.
  4. 유저는 특정 영화관을 선택하고 티켓을 예매할수도 있다.
  5. 유저에게 좌석 배치도를 보여줄 수 있어야 한다. 또한 유저는 자신의 선호에 따라 여러 좌석을 선택할 수 있어야 한다.
  6. 유저가 이미 예약된 좌석과 예약되지 않은 좌석을 구분할 수 있어야 한다.
  7. 유저가 결제를 완료하기전까지 5분동안 좌석을 선점할 수 있어야 한다.
  8. 다른 유저가 선점한 좌석이 5분이 지나 만료되는 경우 다른 유저가 해당 좌석을 선점할 수 있어야 한다.
  9. 기다리는 유저들은 선착순으로 처리되어야 한다.

Non-Functional Requirements

  1. 높은 동시성을 만족시켜야 한다. 특정 시점에 동일한 좌석에 대해 여러개의 예약 요청이 있을 수 있다. 이러한 상황에서 정상적으로 처리되어야 한다.
  2. 서비스의 핵심은 금융 거래이다. 따라서 보안성을 갖춰야하고 데이터베이스 ACID를 준수해야 한다.

Some Design Considerations

  1. 단순화를 위해 유저 인증은 없다고 가정한다.
  2. 부분 티켓 예약은 처리하지 않는다. 모든 예약이 성공하거나 실패한다고 가정한다. 
  3. 공정성은 필수이다.
  4. 어뷰징 방지를 위해 한명의 유저가 최대 예약 가능한 좌석은 10개이다.
  5. 인기있는 영화가 개봉하는 경우 트래픽이 스파이크성으로 칠 수 있고 해당 경우, 좌석이 빠르게 예매될 수 있다. 따라서 이 시스템은 스파이크성을 포함한 대규모 트래픽을 안정적으로 다룰 수 있어야 한다.

System APIs

우리는 시스템의 요구사항을 위해 SOPA 또는 REST API를 사용할 수 있다. 아래 명세는 영화를 검색하거나 좌석을 예매하는 기능이다.

영화 검색

SearchMovies(
    api_dev_key, 
    keyword,
    city,
    lat_long,
    radius,
    start_datetime,
    end_datetime,
    postal_code,
    includeSpellcheck,
    results_per_page,
    sorting_order
)
  • api_dev_key(string): 등록된 계정의 API 개발자 키이다. 이를 통해 할당된 할당량을 기준으로 스로틀링을 걸 수 있다.
  • keyword(string): 검색 대상 키워드
  • city(string): 무비가 상영되는 도시
  • lat_long(string): 위/경도
  • start_datetime(string): 상영 시작 날짜
  • end_datetime(string): 상영 종료 날짜
  • postal_code(string): 우편번호
  • includeSpellCheck(Enum: "yes" or "no"): 맞춤법 검사 여부
  • results_per_page(number): 결과 개수. 최대 30개
  • sorting_order(string): 결과 정렬 관련 필터값
[
	{    
        "MovielD": 1 ,
        "ShowID":1 ,
        "Title": "Cars 2 " , "Description": "About c a r s " ,
        "Duration": 120,
        "Genre": "Animation", "Language": "English", "ReleaseDate":"8thOct.2014", "Country": USA,
        "StartTime": "14:00",
        "EndTime": "16:00",
        "Seats":
        [
        	{
                "Type": "Regular",
                "Price": 14.99,
                "Status: "Almost Full"
            },
            {
                "Type": "Premium",
                "Price":24.99,
                "Status:"Available"
            },
        ]
    },
    {
        "MovieID":1 ,
        "ShowID":2 ,
        "Title": "Cars 2 " , "Description": "About cars", "Duration": 120,
        "Genre": "Animation",
        "Language": "English", "ReleaseDate": "8th Oct. 2014", "Country": USA,
        "StartTime": "16:30", "EndTime": "18:30",
        "Seats":
        [
        	{
				"Type": "Regular",
                "Price": 14.99,
                "Status: "Full"
            },
			{
            	"Type": "Premium",
                "Price": 24.99,
                "Status: "Almost Full"
            }
        ]
    }
}

좌석 예매

ReserveSeats(
	api_dev_key,
    session_id,
    movie_id,
    show_id,
    seats_to_reserve[]
)
  • api_dev_key(string): 위와 동일
  • session_id(string): 예매를 추적하기 위한 유저 세션 ID. 예매 시간이 만료되면 서버에 저장된 유저의 예약은 session_id를 사용하여 제거된다.
  • movid_id(string): 예약 대상 영화
  • show_id(string): 예약 대상 쇼
  • seats_to_reserve(number): 좌석 ID를 포함한 배열

Database Design

  1. 각 도시는 여러개의 극장을 가질 수 있다.
  2. 각 극장은 여러개의 상영관을 가질 수 있다.
  3. 각 영화는 여러개의 상영을 가지고 있고 각 상영은 여러개의 예약이 있다.
  4. 유저는 여러개의 예약을 가질 수 있다.

High Level Design

우리의 웹 서버는 유저 세션을 관리하고 어플리케이션 서버들은 모든 티켓 관리를 처리하고 데이터베이스에 데이터를 저장하며 캐시 서버 또는 예약 프로세스와 작업한다.

Detailed Component Design

먼저, 단일 서버로 운영된다고 가정해보자. 

  1. 유저가 영화를 검색한다.
  2. 유저가 영화를 선택한다.
  3. 유저는 영화의 상영 시간을 확인한다.
  4. 유저는 상영 시간을 선택한다.
  5. 유저는 선택할 좌석의 개수를 입력한다.
  6. 선택한 좌석들의 개수가 유효하다면 좌석표를 통해 실제 좌석을 확인한다. 그렇지 않다면 8번으로 간다.
  7. 유저가 좌석을 선택하면 시스템은 해당 좌석들을 예약하려 시도한다.
  8. 좌석이 예약 불가능하다면 다음과 같은 선택지가 있다.
    1. 상영관이 꽉 찬 경우 유저에게 에러 메시지를 노출한다.
    2. 유저가 원하는 좌석이 예약 불가능하지만 다른 좌석은 예약 가능한 경우 유저를 좌석표 화면으로 이동시켜서 다른 좌석을 선택하게 만든다.
    3. 예약 가능한 좌석이 아예 없지만 모든 좌석이 예매 완료가 아닌 경우 다른 유저들이 예매중인 상황이다. 이 때 유저는 다른 좌석이 예매 가능한 상황이 될 때 까지 기다릴 수 있는 대기 페이지로 이동한다. 이러한 대기는 다음과 같은 옵션이 있다.
      1. 필요한 좌석이 예약 가능한 상태가 되면 유저는 좌석표로 이동하여 좌석을 선택할 수 있게 된다.
      2. 대기중에 모든 좌석이 예매 완료 되거나 유저가 원하는 좌석수보다 적게 남아있는 경우 유저에게 에러 메시지를 노출한다.
      3. 유저는 대기를 취소하고 영화 검색 페이지로 이동할 수 있다.
      4. 유저는 최대 1시간까지 대기할 수 있다. 그 이후는 유저의 세션이 만료되고 해당 유저는 영화 검색 페이지로 이동된다.
  9. 좌석 예매가 성공했다면 유저에게는 결제를 위해 최대 5분의 시간이 주어진다. 결제가 완료되면 예매는 성공 상태로 변한다. 유저가 5분 내로 결제를 완료하지 않는 경우 선택한 좌석은 예매 가능 상태로 변경된다.

서버가 아직 예약이 완료되지 않은 활성 좌석을 어떻게 추적할 수 있나요? 그리고 어떻게 대기중인 모든 유저를 관리할 수 있나요?

우리는 2개의 데몬 시스템이 필요하다. 하나는 모든 활성 예약을 추적하고 시스템에서 만료된 예약을 제거하는 서비스로 ActiveReservationService라고 부른다. 다른 서비스는 모든 대기 유저의 요청을 추적하고 선택한 개수의 좌석이 예매 가능 상태로 돌아가면 유저에게 좌석을 선택하라고 알려준다. 이 서비스는 WaitingUserService라고 부른다.

ActiveReservationsService

데이터베이스에 모든 데이터를 저장하는 방법 외에도 Linked HashMap이나 TreeMap과 같은 자료구조를 활용하여 인메모리에 데이터를 저장할 수 있다. 예약이 완료되면 해당 예약을 제거하거나 삭제할 수 있는 Linked HashMap같은 자료구조가 필요하다. 또한 각 예약은 만료 시간을 가지고 있기 때문에 HashMap의 HEAD는 시간 초과가 도래하면 예약이 만료될 수 있도록 가장 오래된 예약 데이터를 바라보고 있어야 한다.

모든 상영의 모든 예약 정보를 저장하기 위해 우리는 HashTable을 사용할 수 있다. 키에 상영ID를, 값에 예약ID와 타임스탬프값을 포함한 LinkedHashMap를 저장한다. 

데이터베이스에서는 예약 테이블에 예약 정보를 저장하고 시간 컬럼에 만료 시간을 저장할 것이다. 최초 상태 필드는 예약중임을 나타내는 RESERVED(1)로 시작한다. 그리고 예약이 완료되는 경우 완료 상태인 BOOKED(2)로 변경되고 LinkedHashMap에서 예약 데이터를 제거한다. 예약이 만료되는 경우 상태 필드를 EXPIRED(3)으로 변경하고 LinkedHashMap에서도 제거한다.

ActiveReservationService는 유저의 결제 처리를 위해 외부 시스템과도 연동된다. 예매가 완료되거나 만료되는 경우 WaitingUserService는 콜백등을 통해 신호를 받고 대기중인 유저는 예매를 진행할 수 있게 된다.

WaitingUserService

ActiveReservationService처럼 LinkedHashMap이나 TreeMap을 통해 인메모리에 대기유저를 관리할 수 있다. 유저가 요청을 취소할 때 해당 유저를 삭제할 수 있도록 LinkedHashMap과 같은 자료구조가 필요하다. 또한 우리는 선착순으로 서비스를 제공하기 때문에 LinkedHashMap의 HEAD는 항상 가장 오래 기다린 우저를 가리켜 좌석이 예매 가능해질 때 공정하게 서비스를 제공할 수 있게 된다.

우리는 각 상영에 대해 대기중인 유저를 저장할 해시 테이블을 가질 것이다. 키는 상영ID로, 값은 유저ID와 대기 시작 시간을 사용한다.

클라이언트는 롱 폴링을 사용하여 자신의 상태를 지속적으로 확인할 수 있다. 그리고 좌석이 예매 가능한 상태로 변경되면 서버는 클라이언트에게 알림을 보낼 수 있다.

Reservation Expiration

서버에서는 ActiveReservationService가 활성 예약 만료 시간을 추적한다. 클라이언트는 만료 시간에 대한 잔여 시간을 표시한다. 이는 실제 서버의 잔여 시간과 약간의 오차가 있을 수 있다. 이로 인해 클라이언트가 서버보다 늦게 만료되어 성공적인 구매를 방해하지 않도록 서버에 5초 정도의 버퍼를 추가하여 유저 경험을 개선할 수도 있다.

Concurrency

두명의 유저가 동시에 동일한 좌석을 예매할 수 없도록 하는 방법은 무엇일까? 우리는 데이터베이스의 트랜잭션을 사용할 수 있다. 예를 들어 SQL서버를 사용하는 경우 트랜잭션 격리 수준을 활용하여 로우를 업데이트하기 전에 락을 걸 수 있다. 

SERIALIZABLE은 가장 높은 격리수준으로써 Dirty, Non repeatable read, phantom read등의 데이터 부정합 문제를 방지한다. 한가지 주목할점은 트랜잭션 내에서 로우를 읽는 경우 해당 로우에 대한 쓰기 잠금이 설정되어 다른 유저가 수정할 수 없다는 점이다. 

Fault Tolerance

ActiveReservationService 또는 WaitingUserService에 장애가 발생하면 어떻게 되는가?

ActiveReservationService에 장애가 발생하는 경우 예매 테이블에서 모든 데이터를 읽으면 된다. 예매가 완료되기 전까지는 상태 컬럼이 RESERVED(1)인 점을 기억하자. 다른 옵션으로는 마스터-슬레이브 구조로 데이터베이스를 운영하고 마스터가 죽는다면 슬레이브를 마스터로 승격시키는 것이다. 우리는 대기 유저를 데이터베이스에 저장하고 있지 않기 때문에 WaitingUserService에 장애가 발생할 때 마스터-슬레이브 구조가 아니면 데이터를 복구할 수 있는 방법이 없다. 따라서 대기 서비스 또한 마스터-슬레이브 구조를 통해 내결함성을 만들 수 있다.

Data Partitioning

Database partitioning: 영화ID로 파티셔닝한다면 영화의 모든 상영 정보는 단일 서버에 존재하게 된다. 또한 인기가 굉장히 많은 영화의 경우 특정 서버로의 트래픽 쏠림 현상이 발생할 수 있다. 따라서 상영ID를 기반으로 파티셔닝하는게 더 좋은 선택지이다. 이 방법은 각 트래픽을 여러대의 서버로 분산시킬 수 있다.

ActiveReservationService와 WaitingUserService 파티셔닝: 우리의 웹서버는 모든 활성 유저의 세션을 관리하고 모든 통신을 처리한다. 우리는 안정 해시를 사용하여 상영ID를 기반으로 ActiveReservationService와 WaitingUserService에 어플리케이션 서버를 할당할 수 있다. 이 방법은 특정 상영에 대한 모든 예약과 대기 유저를 특정 서버군에서 처리할 수 있도록 해준다. 로드 밸런싱을 위해 안정 해시가 하나의 상영에 대해 3개의 서버를 할당한다고 가정하면, 예약이 만료될 때 해당 예약을 관리하고 있는 서버는 다음과 같은 작업을 수행하게 된다.

  1. 예약을 제거하기 위해 데이터베이스를 수정하고(또는 만료 처리하고) Show_Seats 테이블에서 좌석의 상태를 업데이트한다. 
  2. LinkedHashMap에서 예약 데이터를 제거한다.
  3. 유저에게 해당 유저의 예약이 만료되었다고 알린다.
  4. 해당 상영을 대기중인 유저를 관리하는 모든 WaitingUserService 서버에 메시지를 보내 가장 오래 기다린 유저를 찾아낸다. 안정 해시는 어떤 서버가 해당 유저들을 관리하고 있는지 알려준다.

예약이 완료되면 아래 작업이 발생한다.

  1. 해당 예약을 보유하고 있는 서버는 해당 상영의 대기 유저를 보유하고 있는 모든 서버에 메시지를 보내 현재 예약 가능한 개수보다 더 많은 좌석이 필요한 유저들을 만료시킨다.
  2. 위 메시지를 받으면 대기 유저를 보유하고 있는 모든 서버는 데이터베이스에 현재 몇개의 좌석이 예약 가능한지 확인한다. 캐싱은 이러한 쿼리를 1번만 수행될 수 있도록하는 좋은 선택지이다.
  3. 예약 가능한 좌석보다 많은 좌석을 요구하는 모든 대기 유저를 만료시킨다. 이를 위해 WaitingUserService는 모든 대기 유저의 LinkedHashMap을 순회한다.