본문으로 건너뛰기
버튼 하나 뒤에서

1장

티켓팅

표는 100장인데 500명이 동시에 누르면, 서버는 몇 장을 팔까요?

  1. 1공연 표는 딱 100장. 9시 정각에 판매를 시작해요.

  2. 29시가 되자 500명이 같은 순간에 "예매" 버튼을 눌러요.

  3. 3서버는 한 명 한 명에게 "아직 자리 있어요"라고 답해요. 같은 순간이라 다 맞는 말처럼 보여요.

  4. 4표를 산 500명이 공연장에 왔어요. 그런데 자리는 표 100장만큼뿐이에요.

무대통로 (원래 비어 있어야 해요)

기록 불러오는 중…

앉은 사람
0 / 자리 100개
자리 없이 들어온 사람
0명

실제 서버와 데이터베이스에서 500명이 한꺼번에 예매해요. 1초도 안 걸려요.

고치는 방법 세 가지

같은 조건(표 100장, 500명 동시)에서 세 가지 방법을 실제로 돌린 결과예요. 셋 다 자리 없이 들어온 사람은 0명이지만, 걸리는 시간과 수고가 달라요.

줄 세우기

무대

매표소 창구를 하나만 열고, 한 사람씩 들어와서 남은 자리를 확인하고 표를 받게 해요.

정확하지만, 모두가 한 줄로 기다려야 해요.

기록 불러오는 중…

눈치 보고 재도전

무대

일단 다 같이 시도하고, 내가 보는 사이 누가 먼저 가져갔으면 처음부터 다시 해요.

사람이 적을 땐 가볍지만, 몰리면 다시 하기가 폭증해요.

기록 불러오는 중…

사람이 몰리면 수 초~20초 넘게 걸릴 수 있어요.

한 번에 처리

무대

"자리 확인"과 "표 주기"를 매표소가 한 동작으로 끝내요. 그 사이에 누가 끼어들 틈이 없어요.

가장 빠르고 정확해요. 조건이 단순할 때 쓸 수 있어요.

기록 불러오는 중…

같은 방법이라도 돌릴 때마다, 그리고 컴퓨터와 설정에 따라 걸린 시간과 다시 시도한 횟수가 달라져요. 그래서 카드의 숫자와 아래 "숫자로 본 결과"의 숫자가 다를 수 있어요.

숫자로 본 결과

400명 → 0명
자리 없이 들어온 사람
표 100장에 500명. 고치기 전엔 400명이 더 들어왔고, 세 방법 모두 0명.
약 4배
"한 번에 처리"가 "줄 세우기"보다 빠름
0.41초 vs 1.70초 (각 3회 측정의 중간값)
14,140번
"눈치 보고 재도전"의 다시 시도 횟수
사람이 한꺼번에 몰리면 대부분 부딪혀요. 30번 넘게 실패한 440명은 자리가 남았는데도 못 샀어요 (표 100장 중 60장만 팔림).
출처: docs/benchmarks/ticketing-coupon.md — 맥북(M1 Pro) 한 대에서 Docker MySQL로 측정. 같은 기계에서 서버·DB를 함께 돌린 상대 비교라 실행마다 ±30% 정도 흔들려요. 위 그림의 재생 기록은 다른 날·다른 DB 설정이라 시간이 다를 수 있어요(예: 그 기록에선 재도전이 9,015번 만에 모두 성공).
개발자용 자세히전략별 SQL, DB 상태 변화 차트, 요청 500개 기록, 실제 DB 행 확인

흐름 따라가기

비유 ↔ 용어

아무 규칙 없음
제어 없음: 읽고 → 판단하고 → 쓰기 (read-modify-write)
줄 세우기
비관적 락 SELECT … FOR UPDATE
눈치 보고 재도전
낙관적 락: version 비교 후 갱신, 충돌하면 재시도 (최대 30회)
한 번에 처리
원자적 조건부 UPDATE … WHERE issuedCount < totalQuantity

요청 흐름

  1. 브라우저 → API 버튼을 누르면 runSimulation()이 POST /coupons/simulate를 보내요. 본문은 { users: 500, quantity: 100, strategy? }예요.
  2. API CouponsController.simulate가 runSimulation 관문을 먼저 지나요.
    • 다른 실험이 돌고 있으면 409
    • 쿨다운(끝나고 3초) 중이면 429 simulation_cooldown + Retry-After
    • IP당 10초에 6번을 넘으면 429 (ThrottlerGuard)
  3. API → MySQL CouponStrategies.simulate가 쿠폰 sim-<전략>을 리셋하고, 가상 사용자 500명의 issue()를 동시에 던져요. 전략을 안 고르면 4개를 차례로 돌려요.
  4. API → MySQL 요청마다 시작·끝 시각, 재시도 횟수, 락 대기 시간을 기록해요. 따로 연 커넥션 (connection_limit=1)이 약 25ms마다 실제 행을 읽어 스냅숏도 남겨요.
  5. API → 브라우저 200 { results: [...] }로 돌려줘요. 전략마다 최종 행 상태, 요약, 스냅숏, 요청 500개 기록이 들어 있어요.
  6. 브라우저 실측 타임라인을 2.5~6초로 늘여서 다시 재생해요 (useReplay).
  7. 브라우저 → API 실험실의 DB 패널은 GET /coupons/:name/state?limit=10으로 실제 행을 다시 읽어요.

DB에는 이렇게 남아요

Coupon 행 (한 번 실행한 예)
idnametotalQuantityissuedCountversionupdatedAtCouponIssue 행 수
10sim-none100102026-10-20 04:59:48.114500 (초과 400)
11sim-pessimistic10010002026-10-20 04:59:48.525100
13sim-optimistic1001001002026-10-20 04:59:49.781100
12sim-atomic10010002026-10-20 04:59:49.925100

500명 / 100장으로 전략 4개를 돌린 뒤 읽었어요. 마지막 칸은 CouponIssue 행 수를 센 값이고, 시각은 UTC예요.

표를 이렇게 읽어요

  • 제어 없음은 500명이 모두 "아직 0장"을 읽고 발급했어요. 카운터는 서로 덮어써서 issuedCount가 1에 머물렀어요 (lost update).
  • 낙관적 락만 version이 100까지 올라가요. 성공한 갱신마다 1씩 올리기 때문이에요.

한계·운영에선

  • 비관적 락은 모두가 한 줄로 서서, 기다리는 트랜잭션이 DB 커넥션을 붙잡아요.
  • 낙관적 락은 한 행에 몰리면 재시도가 폭증하고, 30회를 넘기면 쿠폰이 남아도 실패해요.
  • 원자적 UPDATE는 조건이 한 문장으로 표현될 때만 써요. 1인 1매 같은 규칙은 unique 제약과 같이 써야 해요.
  • 남용 방지: 실행이 끝나면 서버 전체 3초 쿨다운, 동시 실행 1개, IP당 10초에 6번. 이 한도는 API 한 대의 메모리에 있어요.

개발자 도구로 보면

  • Network 탭에는 POST /coupons/simulate 한 건만 보여요. 500명은 브라우저가 아니라 서버 안에서 동시에 돌아요.
  • 응답이 커요 (전략마다 요청 기록 500개). 재생 중에는 추가 요청이 없어요.

코드·문서

어떻게: 남은 수량을 읽고 → 남았으면 발급하고 → 카운터를 (읽은 값 + 1)로 저장합니다.

장점: 가장 단순한 코드. 단점: 여러 요청이 동시에 "아직 남았다"를 읽고 모두 통과 → 초과 발급. 카운터도 서로 덮어써서(lost update) 실제 발급 수와 어긋납니다.

이 전략으로 실행해 보세요.