앤디 블로그
  • 모두
  • 아키텍처
  • 기술
  • 자바
  • 스프링
  • 인프라
  • 카프카
  • 데이터베이스
  • 컨퍼런스
  • 개발 문화
책
짧은 글
이력서
  • 모두
  • 아키텍처
  • 기술
  • 자바
  • 스프링
  • 인프라
  • 카프카
  • 데이터베이스
  • 컨퍼런스
  • 개발 문화
책
짧은 글
이력서
  • [아키텍처] KTX 예약 시스템 설계

    • 1. 핵심 가치 및 기능
      • 1.1 기능
      • 1.2 규모 예측
    • 2. 간단한 설계
    • 2.1 예매 대기 방법
    • 2.2 예매 시 동시성 해결
    • 3. 상세한 설계
    • 3.1 예매 대기 서버
      • 3.1.1 Redis vs Kafka
      • 3.1.2 Redis 장애 처리 (RDB vs AOF)
      • 3.1.3 예매 대기 순위 확인 방법 (websocket vs polling)
    • 3.2 예매 서버
      • 3.2.1 MQ 로 DB 부하 감소 및 디커플링
    • 4. 구현
    • 4.1 구현 디렉토리 구조
      • 기능 구성
    • 5. 참고 자료

[아키텍처] KTX 예약 시스템 설계

2026년 1월 23일
  • 1. 핵심 가치 및 기능
    • 1.1 기능
    • 1.2 규모 예측
  • 2. 간단한 설계
  • 2.1 예매 대기 방법
  • 2.2 예매 시 동시성 해결
  • 3. 상세한 설계
  • 3.1 예매 대기 서버
    • 3.1.1 Redis vs Kafka
    • 3.1.2 Redis 장애 처리 (RDB vs AOF)
    • 3.1.3 예매 대기 순위 확인 방법 (websocket vs polling)
  • 3.2 예매 서버
    • 3.2.1 MQ 로 DB 부하 감소 및 디커플링
  • 4. 구현
  • 4.1 구현 디렉토리 구조
    • 기능 구성
  • 5. 참고 자료

아키텍처 항목의 첫 포스트로 KTX 예약 시스템을 설계해보겠다. KTX 예약 시스템은 동시성을 관리하는 데에 있어서는 극한이라고 볼 수 있다. 명절 예매를 하기 위해 수백만 명의 접속이 동시에 몰리는데 하나의 오차도 없이 들어온 순서대로 좌석 선택과 예매를 진행해야 하기 때문이다.

1. 핵심 가치 및 기능

내가 생각하는 핵심가치는 다음과 같다.

  • 모든 사용자는 예기치 않은 서버 오류없이 좌석 선택 - 예매 - 결제를 완료해야 한다.
  • 먼저 들어온 사용자는 이후 사용자보다 먼저 좌석을 선택할 수 있어야 한다.

즉, 장애 대응과 순서 보장이다.

1.1 기능

예매 대기

  • 사용자 중 1만명은 예매 페이지로 진입할 수 있다.
  • 나머지 사용자는 예매 대기 페이지에서 순서를 기다린다. 이때 예매 대기 페이지로 진입한 순서대로 예매 페이지로 진입한다.
  • 예매 대기 페이지의 사용자는 자신의 순서와 총 대기자 수를 알 수 있다.
  • 예매 대기 페이지의 사용자가 해당 페이지를 이탈하면 순서에서 빠진다. (재진입 시 제일 후순위로 진입한다.)

예매

  • 사용자는 예매 페이지에서 호차와 좌석을 선택할 수 있다.
  • 좌석을 선택하고 결제 버튼을 누르는 시점에 해당 좌석을 선점한다.
  • 예매는 5분안에 완료되어야 한다. (시간 연장은 고려하지 않는다.)

결제

  • 결제는 10분 안에 완료되어야 하며 결제되지 않으면 좌석인 비선점 상태로 복구된다.

1.2 규모 예측

(명절을 기준으로 한다.)

  • 명절 열차는 당일의 D-28일 12시에 예매할 수 있다.
  • 예매를 시도하려는 사람은 200만 명이며 100만명은 1분 이내에, 나머지 100만명은 10분 이내에 모두 접속한다.
  • 접속 후 예매 및 결제 플로우는 다음과 같다.
    • 예매 페이지 접속 -> 호차 선택 -> 좌석 선택 -> 결제
    • 접속이 원활하다면 1분 정도 걸리는 플로우이다.
  • 예매 대기 페이지에 1분 내에 100만 명이 동시에 접속한다면 평균적인 TPS 를 구하기 어려우나, 최대 100만 TPS 라고 가정한다.
  • 예매 페이지는 최대 1만명이 사용할 수 있다. 1만 명이 1분 동안 5~6개의 API 를 호출하므로 TPS 는 10,000 x 6 / 60 = 1000TPS 이다.

2. 간단한 설계

image-20260128091822121

컴포넌트는 총 3개로 나뉜다.

  • 예매 대기 서버 : 예매 페이지 진입 전 대기를 한다. Redis 에서 사용자의 대기상태를 확인하고 active 토큰을 발급한다.
  • 예매 서버 : 예매(좌석 선택) 및 예매 확인을 한다. 좌석 선점 여부는 Redis 에 저장하여 잦은 DB 방문을 지양한다.
  • 결제 서버 : 결제를 담당한다. 예매 서버와 장애를 분리하도록 따로 서버를 구성한다.

2.1 예매 대기 방법

예매 대기는 Redis 의 zset 을 사용한다. zset 은 score 를 통해 sorting 을 할 수 있고 timestamp 로 들어온 순서대로 사용자를 추가할 수 있어 예매 대기에 적합하다.

# 들어온 순서대로 추가 (score가 작을수록 먼저)
ZADD queue 1737950000123 user:1
ZADD queue 1737950000456 user:2

# 특정 사용자의 순서(0부터 시작)
ZRANK queue user:1

# 1등~10등 뽑기
ZRANGE queue 0 9 WITHSCORES

그리고 5초마다 active 사용자 목록의 개수를 확인하고 최대 1000명씩 입장시킨다. 이때 1000명의 ID 를 key 로 TTL 을 5분으로 설정하여 저장한다. 1만 명이 입장 가능한데 1000명씩만 입장시키는 이유는 갑자기 1만명의 트래픽이 동시에 몰리지 않게 하기 위함이다.

active 사용자 목록에 사용자를 추가하고 토큰을 발급하여 예매 서버에서 인증될 수 있도록 한다.

2.2 예매 시 동시성 해결

DB

예매 시 동시성은 좌석별 비관적 락으로 보장한다. 좌석 테이블이 가지는 필드값은 개략적으로 다음과 같다.

create table seat(
   seat_id bigint primary key, # increment 
   seat_number varchar, # 1D, 3B ...
   status varchar, # AVAILABLE, HELD, PAID
   hold_by varchar, # user_id 
   hold_until timestamp(6) # held 상태라면 언제까지 결제해야 하는지
   car_number bigint # 기차의 특정 량 fk
)

따라서 예매 시 다음과 같이 특정 seat 의 비관적 락을 획득한다. (for update)

select * from seat where seat_id = 1 and status = 'AVAILABLE' For Update;

비관적 락을 사용하는 이유는 "DB 정합성과 안정성이 크게 요구되고", "충돌이 많이 일어날 것으로 예상되기" 때문이다.

Redis

하지만 예매 대기 인원이 최대 1만 명이므로 DB 에 조회 및 업데이트에 많은 부하가 예상된다. 따라서 차량의 예매 결과를 실시간으로 Redis 에 업데이트되고 사용자는 Redis 에서 데이터를 확인하는 방식으로 구성한다.

따라서 예매가 시작되기 전에 Redis 에 기차의 차량별 예매 정보를 적재하는 과정도 필요하다.

3. 상세한 설계

각 컴포넌트별로 추가 사항을 확인해보자

3.1 예매 대기 서버

3.1.1 Redis vs Kafka

"들어온 순서대로"에 집중하면 Kafka 를 사용할 수도 있다. 하지만 Redis 는 다음과 같은 점에서 Kafka 보다 더 좋은 선택지가 된다.

  • 전체 사용자와 순위 : zset 으로 특정 key 의 순위와 전체 사용자 수를 쉽게 구할 수 있다.
  • 멱등성 : kafka 에서 동일한 사용자가 재접속한다고 해서 topic 에서 중복으로 처리되지 않는다. (즉, 멱등성이 보장되지 않는다.) 하지만 Redis 의 zset 에 동일한 key 로 입력하면 덮어씌워지기 때문에 새로고침한 사용자를 후순위로 미루기 쉽다.
  • 중간 사용자 제거 : polling 요청이 올 때마다 대기열의 TTL 을 초기화시킨다. 만약 대기에 지친 사용자가 나간다면 TTL(짧게 15초 정도) 이 만료되면서 대기열에서 제거할 수 있다. 반면 kafka 에서는 중간에 있는 메시지를 제거할 수 없다.

3.1.2 Redis 장애 처리 (RDB vs AOF)

RDB (Snapshot)

  • 일정 주기/조건에 따라 메모리 스냅샷을 디스크에 저장한다.
  • 장점: 성능 부담이 비교적 적고 파일이 작아 복구가 빠르다.
  • 단점: 스냅샷 사이 구간은 유실될 수 있어, 장애 시 최근 대기열 일부가 사라질 수 있다.
  • 적합: “약간의 유실을 허용”하고, 빠른 재시작/복구를 원하는 경우.

AOF (Append Only File)

  • Redis에 들어온 쓰기 명령을 로그로 기록해 재시작 시 재생한다.
  • 장점: RDB보다 유실 폭을 줄일 수 있다(설정에 따라 초 단위).
  • 단점: 디스크 I/O 부담이 늘고, 파일이 커질 수 있어 재생 시간이 길어질 수 있다(Rewrite 필요).
  • 적합: 대기열/토큰 등의 유실을 최대한 줄이고 싶을 때.

권장 선택

  • 예매 대기열은 정합성은 중요하지만 금융 장부처럼 0 유실까지는 보통 요구되지 않으므로,
    • AOF everysec + 필요 시 RDB 병행을 권장한다.
  • 단, 최종적으로 1만명 게이팅/토큰/순번은 Failover 후에도 시스템이 계속 동작하는 것이 더 중요하므로,
    • 영속화(RDB/AOF)보다 Replication + Sentinel(또는 Cluster) 구성이 장애 대응의 핵심이다.

주의사항

  • Redis 복제는 보통 비동기이므로, Failover 순간에는 AOF를 켜도 최근 데이터 일부 유실 가능성이 남는다.
  • 따라서 대기열/토큰 처리 로직은 재시도/중복 등록을 허용하는 멱등성을 전제로 설계한다(예: ZADD member=userId로 덮어쓰기)

3.1.3 예매 대기 순위 확인 방법 (websocket vs polling)

사용자는 실시간으로 자신의 예매 대기 순위와 전체 대기 사용자 수를 확인할 수 있어야 한다. 이때 예매 대기 서버로부터 websocket 이나 SSE 로 정보를 받을 수도 있고 polling 방식으로 정보를 받을 수도 있다. 여기서 나는 polling 방식을 선택했다. 그 이유는 다음과 같다.

연결 유지 비용

SSE/웹소켓은 기본적으로 클라이언트당 장시간 연결을 유지한다. 200만 명이 동시 접속하면 200만 개 연결을 서버(또는 LB)가 계속 들고 있어야 한다. 따라서 메모리, 커널 네트워크 버퍼, keep-alive, TLS 세션 등 비용이 크다. 폴링은 연결을 짧게 쓰고 끊기 때문에 인프라가 훨씬 다루기 쉽니다.

장애/재연결(운영 리스크)

SSE/웹소켓은 네트워크가 흔들리면 동시에 재연결 스톰(reconnect storm)이 터지기 쉽고, 그게 대기 서버에 큰 부하를 줄 수 있다. 반면 폴링은 원래 “주기적 요청”이라 재연결 스톰의 형태가 덜하고, 서버가 429로 간격 조절을 유도하기도 쉽다.

대기열은 실시간성이 그렇게까지 필요 없음

대기 순번은 보통 1초 단위 실시간이 아니라 2~5초 갱신만 해도 UX가 충분히 납득된다. 따라서 이 정도는 폴링이 가장 단순하고 효과적이다.

3.2 예매 서버

3.2.1 MQ 로 DB 부하 감소 및 디커플링

만일 DB 의 부하가 더 발생한다면 Message Queue 로 부하를 감소시킬 수 있다.

4. 구현

  • 구현된 git repo : https://github.com/hobeen-kim/architecture/tree/main/ktx

4.1 구현 디렉토리 구조

.
├── infra
│   ├── init.sql # 최초 db 실행 시 필요한 테이블 정보는 열차 정보
│   ├── test.js # 부하를 발생시키는 테스트 코드
│   └── docker-compose.yml # db, redis, mq, api-server, web-server
├── api-server
│   ├── reservation-wait-api
│   ├── reservation-api 
│   └── auth # jwt 인증 관련 모듈, 공통 사용
├── web-server
│   └── index.html
└── README.md

결제 서버는 구현하지 않는다.

  • infra : 필요한 db 및 서버 인프라 설정 파일, 부하 테스트 코드 (k6)
  • api server : 예매 대기 서버와 예매 서버, 인증 모듈 총 3개의 모듈로 구성되어 있다. / kotlin 으로 구현
  • web server : 열차 대기 및 예매 페이지, 나의 예매 확인 페이지 / react 로 구현

기능 구성

예매 화면

image-20260130084729081

테스트를 위한 대기자 강제 추가

image-20260130084847910

예매 대기

image-20260130084927511

좌석 선택 및 확인

image-20260130084944525

5. 참고 자료

  • https://www.youtube.com/watch?v=c-ERjEodn_o