[아키텍처] KTX 예약 시스템 설계
아키텍처 항목의 첫 포스트로 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. 간단한 설계
![]()
컴포넌트는 총 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 병행을 권장한다.
- AOF
- 단, 최종적으로 1만명 게이팅/토큰/순번은 Failover 후에도 시스템이 계속 동작하는 것이 더 중요하므로,
- 영속화(RDB/AOF)보다 Replication + Sentinel(또는 Cluster) 구성이 장애 대응의 핵심이다.
주의사항
- Redis 복제는 보통 비동기이므로, Failover 순간에는 AOF를 켜도 최근 데이터 일부 유실 가능성이 남는다.
- 따라서 대기열/토큰 처리 로직은 재시도/중복 등록을 허용하는 멱등성을 전제로 설계한다(예:
ZADDmember=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 로 구현
기능 구성
예매 화면

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

예매 대기

좌석 선택 및 확인

5. 참고 자료
- https://www.youtube.com/watch?v=c-ERjEodn_o