티켓팅, 어떻게 몇 만의 동시성을 제어하는가
스키장 리프트권 예매하다 궁금해서 파본 대규모 트래픽 제어법. Redis 분산 락부터 Saga 패턴까지, 동시성 지옥에서 살아남는 설계를 하나씩 정리해보자.
오늘 하려고 했던 작업이 다 끝나고, 멍하니 앉아 오늘은 블로그에 뭘 올려야 하나… 고민을 좀 했다.
나름 ‘하루에 글 하나!!’라는 아주 거창한 계획을 세웠는데, 막상 해보니까 이게 공부량이나 작업량의 문제가 아니더라. 이건 그냥 소재를 찾는 싸움이다. (벌써부터 쓸 게 없는 거 같은 느낌이다. (뭐, 공부를 안하니…))
그러다 문득 오전에 야놀자에서 스키장 리프트권 산 게 생각났다.
“어… 근데 이런 전자 티켓은 결제가 어떤 프로세스로 돌지? 설계는 어떻게 되어 있을까?” 하는 궁금증이 꼬리에 꼬리를 물더라.
특히 아이유나 임영웅 콘서트 같은 티켓팅은 수만, 수십만 명이 동시에 딸깍 할 텐데, 그 미친듯한 동시성을 대체 어떻게 제어하는 건지… 마침 쓸 내용도 없어서 한 번 정리나 해보자 생각하고 파봤다.
동시성 제어: 왜 DB 락은 대규모 트래픽에서 “나락”을 가는가?
우리가 보통 쓰는 Postgres나 MySQL 같은 RDBMS는 데이터를 안전하게 지키는 데는 도사다.
근데 이 ‘안전’이 대규모 트래픽에서는 독이 된다.
DB 락의 치명적인 문제: Connection Starvation
보통 재고 관리할 때 SELECT ... FOR UPDATE 같은 비관적 락(Pessimistic Lock)을 건다. “내가 이 재고 수정할 거니까 아무도 건드리지 마!” 하고 문을 잠그는 거다.
문제는 9시 정각에 10만 명이 동시에 접속했을 때다.
- 커넥션 점유: DB가 수용할 수 있는 커넥션 풀(Connection Pool)은 보통 100~200개 사이다.
- 병목 현상: 락을 잡으려고 대기하는 모든 클라이언트가 이 귀한 커넥션을 하나씩 물고 안 놔준다.
- 서비스 마비: 결국 나머지 99,800명은 로그인도 안 되고, 공지사항도 못 본다. DB 커넥션이 말라비틀어졌으니까. 이걸 전문 용어로 Connection Starvation이라고 한다.
해결책: Redis라는 “초고속 번호표 기계”
그래서 실무에서는 DB 앞단에 Redis를 둔다. Redis는 RAM에서 동작하는 인메모리 DB라 하드디스크 기반인 RDBMS보다 수백 배 빠르다. 특히 Redisson 라이브러리를 활용한 분산 락(Distributed Lock)이 핵심이다.
// 핵심 로직
RLock lock = redissonClient.getLock("concert_ticket_lock");
try {
// 1. 10초 동안 락 획득을 시도함 (Wait Time)
// 2. 획득하면 2초간 점유하고 자동 해제함 (Lease Time) - 프로세스 죽어도 데드락 방지
// 3. Redisson은 Pub/Sub 방식을 써서 Redis 부하를 최소화함
if (lock.tryLock(10, 2, TimeUnit.SECONDS)) {
// 비즈니스 로직: 재고 있는지 확인하고 차감하기
// 여기서 DB에는 락 없이 "업데이트"만 한 번 툭 던짐
}
} catch (InterruptedException e) {
// 락 획득 시도 중 취소됐을 때
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock(); // "다음 손님 들어오세요~"
}
}
이렇게 하면 DB는 편안해진다.
Redis가 밖에서 미친 트래픽을 다 받아주고, 진짜 재고를 잡을 수 있는 애들만 DB 금고 안으로 들여보내 주기 때문이다.
분산 트랜잭션: 결제는 성공했는데 티켓이 없다면?
요즘 서비스는 결제 서버, 티켓 서버, 알림 서버가 쪼개져 있는 MSA(Microservice Architecture)가 대세다. 근데 서버가 쪼개지면 트랜잭션을 하나로 묶는 게 그냥 물리적으로 불가능하다.
만약 결제는 성공해서 내 통장에서 10만 원이 나갔는데, 티켓 서버가 과부하로 죽어서 티켓 발행에 실패했다면? 이건 사장 입장에서는 고소당할 일이고, 엔지니어 입장에서는 짐 싸야할 문제다.
해결책: Saga 패턴의 “보상 트랜잭션”
Saga 패턴은 “모든 게 한 번에 성공할 필요는 없지만, 결국엔 맞아야 한다(Eventual Consistency)“는 철학이다.
- Step 1 (결제 성공): 결제 서버가 돈을 빼고 “결제 완료” 이벤트를 발행한다.
- Step 2 (티켓 발행 실패): 티켓 서버가 이벤트를 받고 티켓을 만들려는데… 에러가 났다.
- Step 3 (보상 트랜잭션): 티켓 서버가 “나 망했음” 이벤트를 던지면, 결제 서버가 이걸 보고 방금 성공한 결제를 즉시 취소하는 API를 강제로 호출한다.
이때 중요한 건 멱등성(Idempotency)이다.
네트워크 불안정으로 취소 요청이 두 번 갈 수도 있다.
Transaction_ID를 체크해서 “이건 이미 환불해 준 건이네?” 하고 한 번만 처리하게 로직을 박아야 500원 환불하려다가 1000원 환불해주는 일이 안 생긴다.
현장 게이트의 IT: “바코드를 찍는 0.5초의 사투”
자, 예매는 성공했다 치자. 이제 스키장에 도착해서 리프트 입구에서 바코드를 딱 찍었다. 우리 눈에는 그냥 “삐빅” 하고 문이 열리는 단순한 동작 같지만, IT 관점에서는 개빡센 정합성 전쟁이 벌어지는 중이다.
일반적인 웹 구조를 쓸 수 없는 이유 (왜 게이트가 멍청해 보일까?)
보통 우리가 만드는 웹 서비스는 Client -> API Server -> DB 순서로 호출한다. 하지만 스키장 게이트에서는 이 구조를 그대로 썼다간 난리가 난다.
- 지연 시간(Latency)의 공포: 영하 15도 추위에서 리프트 타려고 수백 명이 줄 서 있다. 게이트에서 바코드를 찍었는데, 이 요청이 산악 지형의 불안정한 네트워크를 타고 중앙 서버에 갔다 오느라 2~3초가 걸린다? 뒷사람들이 폴대 들고 쫓아온다. 게이트 통과 시간은 0.5초 이내여야 한다.
- 네트워크 신뢰성 0%: 스키장은 산속이다. Wi-Fi든 데이터든 수시로 끊긴다. 만약 중앙 서버가 점검 중이거나 산꼭대기 기지국이 얼어버리면? 수천 명의 고객이 리프트를 못 타서 집단 환불 사태가 벌어진다. Single Point of Failure가 실시간으로 발생하는 장소다.
- 동시 검증의 압박: 리프트가 한두 대가 아니다. 수십 개의 게이트에서 동시에 바코드를 찍어대면 중앙 DB는 “이 티켓 유효함?”이라는 SELECT 쿼리에 깔려 죽는다.
게이트 내부에서 벌어지는 일
그래서 실제로는 Edge Computing 개념이 들어간다. 게이트는 단순히 ‘화면’이 아니라 하나의 ‘독립된 노드’처럼 동작해야 한다.
- Pre-fetching (전처리): 당일 예약된 티켓 리스트를 새벽에(혹은 특정 시간이겠지? 아니면… 몇 분마다 스케쥴러가 넣어줄 수도 있고) 미리 각 게이트의 로컬 메모리(Local Redis 등)로 싹 밀어 넣는다.
- Local Validation: 바코드를 찍는 순간, 중앙 서버를 부르는 게 아니라 자기 로컬 메모리에서 1ms 만에 티켓 존재 여부를 확인한다.
- Write-Back (사후 기록): 일단 문을 열어주고, “이 티켓 썼음”이라는 기록은 큐(Queue)에 담아뒀다가 네트워크가 안정적일 때 중앙 서버로 비동기 전송한다.
결국, 우리가 아는 일반적인 실시간 동기 방식은 스키장 같은 극한 환경에선 쓰레기나 다름없다. 가용성(Availability)을 위해 완벽한 실시간 정합성을 아주 잠시 포기하는 결단이 필요한 거다.
블로그 소재 없어서 시작한 공부였는데, 파다 보니까 진짜 재밌더라.
단순히 코드 몇 줄 짜는 게 중요한 게 아니라, 어떤 상황에서 어떤 기술을 사용해야 가장 최고의 효율을 낼 것인가, 최고의 안정성을 보여줄 것인가를 결정하는 게 진짜 시니어의 영역인 것 같다.
근데 이렇게 이론만 정리하니까 좀 근질근질하다.
시간 날 때 nGrinder나 k6 같은 툴로 대규모 트래픽을 일부러 발생시켜서 테스트를 한번 해볼까 한다.
진짜 DB 락 걸었을 때랑 Redis 분산 락 썼을 때 처리량이 얼마나 차이 나는지.
Saga 패턴에서 서버 하나 죽였을 때 보상 트랜잭션이 얼마나 깔끔하게 도는지.
직접 수치로 확인해 보면 더 꿀잼일 것 같다.
결과 나오면 그건 또 그거대로 하루 날먹할 소재인거고…
마침.