전체 프로젝트운영 중

LiveCommerce

라이브 방송 중 한정 수량 특가를 처리하는 플랫폼: 재고 100개에 구매 시도 1,000건, 초과 판매 0건

이 프로젝트가 증명하는 것

  • 정확성: 재고 100개에 구매 시도 1,000건, 3회 반복에서 초과 판매 0, 결제·판매 불일치 0, HTTP 오류 0. 매 실행 끝에 불변식 5개 확인
  • 장애 내성: Kafka나 결제 DB를 실제로 멈춰도 이벤트 유실 0, 이중 결제 0 (카오스 테스트)
  • 측정으로 개선: 판매 완료 1분 27초 → 약 23초, 주문 생성 p95 5.72초 → 2.68초. 커넥션 풀 가설은 측정으로 기각
  • 프로덕션의 비즈니스 위험을 증거로 차단: 한 계정이 미결제 주문으로 재고 전체를 잡던 문제를 partial unique index로 막음(15 → 0에서 15 → 5)
  • 경계 방어: 로그인, 가입, 주문 레이트 리밋. 신뢰할 수 있는 프록시 체인에서 클라이언트 IP를 뽑아 위조한 X-Forwarded-For로는 429를 피할 수 없음(프로덕션 확인)
  • 운영: SHA 태그 배포, health 기반 rollback, 프로덕션 양방향 훈련, 재시작 뒤에도 토큰이 유효한 JWT 키
역할
단독 개발: 아키텍처, 서비스 7개, 프론트엔드, 테스트, 부하 테스트, 배포와 운영 (커밋 127개)
기간
2026.09 – 2026.10
기술 스택
  • Kotlin
  • Spring Boot
  • Spring Cloud Gateway
  • Spring Data JPA
  • PostgreSQL
  • Apache Kafka
  • WebSocket (STOMP)
  • Flyway
  • Toss Payments
  • OpenTelemetry
  • Jaeger
  • Next.js
  • TypeScript
  • Tailwind CSS
  • JUnit 5
  • k6
  • Docker
  • Caddy

핵심 수치

항목값구분증빙
초과 판매 (재고 100, 시도 1,000, 3회 모두)0측정값증빙 보기
판매 = 결제100 / 100측정값증빙 보기
주문 생성 p952.68 s측정값증빙 보기
결제 요청 p950.63 s측정값증빙 보기
판매 완료 시간23 s측정값증빙 보기
재고 알림 도달률 (시청자 500명)99.2 %측정값증빙 보기
시청자 수 메시지 감소98 %측정값증빙 보기
카오스 테스트: 이벤트 유실 / 이중 결제0측정값증빙 보기

해결하려는 문제

라이브 방송 중 한정 수량 판매는 몇 초 안에 주문이 몰리고, 재고, 결제, 배송이 서로 다른 서비스에 있습니다. 플래시 세일에서 품절은 일상이라 결제를 먼저 받으면 품절될 때마다 환불과 PG 수수료가 생기고, 서비스 하나가 멈추면 이벤트 유실이나 이중 결제가 생길 수 있습니다.

아키텍처

api-gateway(Spring Cloud Gateway)가 JWT를 검증하고 신원 헤더를 다시 쓰며 레이트 리밋과 WebSocket 프록시를 맡습니다. order-service의 Saga 오케스트레이터가 재고를 먼저 10분 잡고 결제를 받습니다(AWAITING_STOCK → AWAITING_PAYMENT → PAYMENT_CONFIRMING → CONFIRMING_STOCK → COMPLETED, 결제 창 5분). inventory(선점, 확정, 반환, 만료 정리), payment(Toss 승인과 취소, Idempotency-Key), live(STOMP 채팅, 실시간 재고, 시청자 수), auth(RS256 JWT, JWKS, 리프레시 토큰 교체), delivery(배송, HMAC 택배 웹훅)가 각자의 PostgreSQL을 쓰고, 서로의 데이터는 API나 Kafka 이벤트로만 읽습니다. 이벤트는 각 서비스의 transactional outbox에서 나가며 traceparent를 함께 저장해 Kafka를 거쳐도 Saga 전체가 한 Jaeger 트레이스로 이어집니다.

데이터 모델

핵심 제약 조건과 인덱스

  • database per service: PostgreSQL 5개, 서비스 간 FK 없음, order_id / member_id UUID와 Kafka 이벤트로만 연결 (Flyway 마이그레이션 21개)
  • orders.uq_orders_one_open_per_member_product: 열린 상태 partial UNIQUE (V5)
  • orders.version (@Version), 인덱스 (status, updated_at), partial payment_deadline WHERE status='AWAITING_PAYMENT'
  • product_stock.quantity_available CHECK (>= 0): 코드가 틀려도 DB가 초과 판매를 막음, unit_price는 서버 측
  • inventory_reservations.order_id UNIQUE: 중복 이벤트가 재고를 두 번 줄이지 않음, partial index expires_at WHERE status='HELD'
  • payments.order_id UNIQUE: 주문 하나에 결제 하나
  • tracking_events.event_id UNIQUE: 택배 웹훅 재전송도 한 번만 적용
  • refresh_tokens.token_hash UNIQUE, family_id 인덱스: 재사용된 토큰은 가족 전체를 폐기
  • outbox_events.trace_parent (W3C), partial index created_at WHERE status='PENDING'

핵심 설계 결정

재고를 먼저 잡고 결제 (hold-then-pay)

상황
플래시 세일에서 품절은 일상이라, 먼저 결제를 받으면 품절마다 환불과 PG 수수료가 생깁니다.
결정
재고를 10분 선점한 뒤 5분 결제 창을 열고, 결제가 확정되면 선점을 확정합니다.
트레이드오프
재고가 10분 동안 묶이므로 구매자당 열린 주문 하나 규칙이 필요해졌습니다.

코레오그래피가 아닌 오케스트레이션

상황
상태 머신, 복구, 관리자 재시도를 한곳에서 다뤄야 합니다.
결정
order-service가 Saga를 조율하고 멈춘 단계를 복구 스케줄러가 5초마다 다시 진행합니다.
트레이드오프
order-service가 중심 지점이 됩니다.

Transactional outbox, 브로커 확인 뒤에만 PUBLISHED

상황
Kafka가 멈춰도 이벤트를 잃으면 안 됩니다.
결정
상태 변경과 이벤트를 한 트랜잭션에 기록하고, 릴레이가 병렬로 보낸 뒤 연속으로 확인된 앞부분만 PUBLISHED로 표시합니다.
트레이드오프
중복이 생길 수 있어 소비자를 모두 멱등하게 만들었습니다.

Toss 호출은 트랜잭션 밖, 결과는 outbox와 같은 트랜잭션

상황
외부 API가 느려질 때 DB 연결을 붙잡고 있으면 안 됩니다.
결정
TransactionTemplate로 Toss 승인은 밖에서 호출하고, 결과와 outbox 이벤트를 한 트랜잭션에 기록합니다.
트레이드오프
결과가 기록되지 않으면 복구 스케줄러가 같은 Idempotency-Key로 다시 시도합니다.

품절은 락 없는 스칼라 조회로 먼저 거절

상황
품절 뒤에도 모든 요청이 FOR UPDATE 대기열에 서서 p95가 5.8초였습니다.
결정
락 전에 숫자 하나만 읽어 0이면 거절합니다. 엔티티를 먼저 읽으면 Hibernate 1차 캐시가 다음 FOR UPDATE에서 오래된 값을 돌려줘 초과 판매가 날 수 있어 엔티티는 읽지 않습니다.
트레이드오프
재고가 반환되는 순간 품절 응답을 받을 수 있지만 초과 판매는 일어나지 않습니다.

열린 주문 하나: partial unique index

상황
계정 하나가 결제 없이 재고 전체를 잡을 수 있었고, 동시 요청에서도 DB가 보장해야 했습니다.
결정
열린 상태에 대해 (member_id, product_id) partial unique index(V5)를 두고, 위반하면 409 OPEN_ORDER_EXISTS와 기존 orderId를 돌려줍니다.
트레이드오프
구매자는 새 주문 전에 기존 주문을 결제하거나 취소해야 하며, UI가 기존 주문으로 안내합니다.

레이트 리밋은 gateway 메모리, 주문은 사용자 기준

상황
Redis 없이 단순하게 가고, 한국에서는 많은 구매자가 같은 CGNAT IP로 나옵니다.
결정
로그인 10/분, 가입 5/시간은 IP 기준, 주문 20/분은 사용자 기준. IP는 XFF 체인의 가장 오른쪽 공인 주소를 씁니다.
트레이드오프
gateway가 하나일 때만 정확하고, IP 기준 로그인 제한은 여러 IP에서 한 계정을 노리는 공격을 막지 못합니다.

JWT 키는 .env, kid는 RFC 7638 thumbprint

상황
재시작과 배포 뒤에도 토큰이 유효하고, gateway의 JWKS 캐시와 맞아야 합니다.
결정
JWT_SIGNING_KEY를 서버 .env(chmod 600)에서 읽고, 같은 키는 항상 같은 kid를 냅니다. 키가 없으면 compose가 시작되지 않습니다.
트레이드오프
키를 교체하면 이전 토큰이 잠시 거부됩니다.

개선 전후 측정

개선 전sell-out 1 min 27 s · order p95 5.72 s증빙 보기
개선 후sell-out ~23 s · order p95 2.68 s증빙 보기
무엇을 바꿨나
발견한 원인 4개를 고쳤습니다: 품절 뒤에도 FOR UPDATE 대기 → 락 전 스칼라 조회, 토픽당 파티션 1개와 컨슈머 1개(재고 선점 p95 17.8초) → 파티션 6개와 스레드 6개(키 orderId), outbox 릴레이가 500ms마다 하나씩 확인하며 전송 → 100ms 주기 병렬 배치, 시청자 500명에게 입장마다 인원 수 전송(O(n²)) → 바뀔 때만 초당 최대 1회. 결제 요청 p95 0.71 → 0.63초, 재고 선점까지 p95 17.77 → 4.80초, 완료까지 p95 33.55 → 11.04초, 시청자 수 메시지 454,583 → 8,151. '이전' 지연은 k6가 15–20초 뒤 느린 주문을 기다리지 않아 잘린 값이라 실제 차이는 더 큽니다.

장애 대응 기록

증상
2026-10-09 프로덕션에서 lc-hoard-check.sh로 테스트 계정 하나가 미결제 주문을 연달아 만들자 2×201, 재고 15 → 0이 되었습니다. 한 사용자가 10분 hold 동안 방송 상품 전체를 다른 사람에게서 막을 수 있었습니다.
원인
Saga가 POST /api/orders마다 별도의 hold를 열었고, '구매자 한 명에 열린 주문 하나' 규칙이 어디에도 없었습니다.
해결
V5 마이그레이션: 열린 상태(AWAITING_STOCK, AWAITING_PAYMENT, PAYMENT_CONFIRMING, CONFIRMING_STOCK)에 대한 (member_id, product_id) partial unique index. 컨트롤러가 먼저 확인하고, 경합 시에는 DataIntegrityViolationException을 인덱스 이름으로 구분해 409 OPEN_ORDER_EXISTS와 기존 orderId를 돌려주며, 프론트엔드는 그 주문을 이어 갑니다.
재발 방지
로컬 Postgres에서 동시 트랜잭션 두 개 중 두 번째가 1.5초 기다린 뒤 거부되는 것을 확인했고, 프로덕션 재검사에서 1×201 + 409, 재고 5에서 멈췄습니다. 동시 요청 10개 중 정확히 1개만 201인지 확인하는 통합 테스트를 넣었고, k6 flash-sale.js는 VU마다 자기 사용자를 쓰도록 바꿨습니다(USERS ≥ VUS 검사).

테스트와 품질

테스트 수
46
커버리지
—
CI
—
테스트 도구
JUnit 5 (통합 24, 카오스 3, gateway 단위 14, SigningKeys 5), 실행 중인 전체 스택에 실제 HTTP, k6 3종

배포와 운영

배포 환경
iwinv VPS의 docker-compose.prod.yml: 컨테이너 16개(Spring 서비스 7, Next.js, PostgreSQL 5, Kafka KRaft, Jaeger, Caddy edge). 어떤 컨테이너도 포트를 publish하지 않고 edge만 외부 proxy 네트워크에 연결되며 /api/*와 /ws/*는 gateway, 나머지는 frontend로 보냅니다. JVM은 USER app(비 root), 서비스마다 메모리 제한(Spring 512MB, PostgreSQL 256MB, Kafka 768MB). PAYMENTS_FAKE_ENABLED=false. deploy.sh: 충돌 표시 검사 → git pull --ff-only → SHA 태그 이미지 8개 빌드 → up -d → 모든 서비스의 /actuator/health와 frontend를 edge 안에서 60 × 3초 확인 → 성공 시 APP_TAG와 .deploy-history 기록. 2026-10-10 측정: 배포 2분 38초(빌드 116초), 중단 약 27초.
백업
서버 공통 cron 백업(매일 03:00, 7일 보관)이 livecommerce PostgreSQL 5개를 덤프하는 것이 백업 로그에 남아 있습니다. 복원 시험은 아직 하지 않았습니다.
롤백
rollback.sh <sha>: 이미지 8개가 모두 있는지 확인한 뒤 빌드 없이 up -d --no-deps --force-recreate. 마이그레이션은 Flyway로 앞으로만 가며(V5는 인덱스만 추가해 이전 코드도 동작), 프로덕션 훈련에서 rollback 48초(중단 약 36초), 재전진 39초(약 26초)였습니다.
모니터링
lc-diagnose.sh 한 번으로 컨테이너 상태, restart/OOM, 메모리, 24시간 ERROR 수, 공개 엔드포인트 지연, 상태별 주문, 15분 넘게 멈춘 주문, outbox PENDING, 재고, DB 크기, Kafka lag를 확인합니다. 2026-10-09: 9일 연속 가동, restart 0, OOM 0, ERROR 0, Kafka lag 0, PENDING 0. Jaeger(OpenTelemetry, 샘플링 20%)로 Saga 전체 트레이스.

스크린샷

한계와 회고

현재 한계

  • 부하 수치는 노트북 한 대에서 잰 값(±25% 변동)으로 운영 처리량이 아닙니다. 다음: 별도 부하 머신과 운영 유사 환경
  • V5 이후 flash-sale.js를 다시 재지 않아 전후 표는 이전 스크립트 기준입니다. 다음: 새 스크립트로 3회 중앙값 재측정
  • live-service 상태(시청자, 세션)가 메모리에 있어 인스턴스 하나뿐이고 재시작하면 시청자가 다시 연결합니다. 다음: Redis pub/sub나 broker relay
  • 레이트 리미터가 gateway 메모리에 있어 gateway를 늘리면 한도가 배수로 늘고 재시작하면 0이 됩니다. 로그인 제한은 IP 기준이라 여러 IP에서 한 계정을 노리는 공격은 막지 못합니다. 다음: Redis 리미터와 계정 기준 제한
  • JWT 키가 env에 있고 KMS가 아니며 교체 절차가 없습니다. 다음: JWKS에 키 두 개를 두는 교체와 secret manager
  • 배포 때 약 26–36초 중단(인스턴스 하나, rolling/blue-green 없음)이 있고, 그동안 gateway가 503이 아니라 500을 돌려줘 클라이언트가 재시도 여부를 알 수 없습니다
  • outbox_events와 refresh_tokens를 정리하지 않아 시간이 지나면 커집니다. 다음: PUBLISHED와 만료 행 retention job
  • 다른 프로젝트와 공유하는 8GB VPS에서 swap 813MB를 쓰고 있어 메모리 압박 시 지연이 늘 수 있습니다
  • CI(GitHub Actions billing), 알림, 백업 복원 훈련, 프론트엔드 자동 테스트가 없습니다
  • 결제는 Toss 테스트 키로 동작하고 방송 영상은 시뮬레이션입니다

핵심 코드

현재 상태와 다음 단계

재고 100개에 구매 시도 1,000건(200 VU)을 3회 실행해 매번 초과 판매 0건, 결제와 판매 수량 불일치 0건, HTTP 오류 0건이었고, 매 실행 끝에 불변식 5개를 admin API로 확인했습니다. 측정으로 병목을 찾아 판매 완료 시간을 1분 27초에서 약 23초로 줄였고, 커넥션 풀 가설은 측정으로 기각했습니다. 프로덕션에서는 계정 하나가 미결제 주문 2건으로 재고 전체(15 → 0)를 잡을 수 있던 문제를 partial unique index로 막았고(15 → 5), auth 재시작 뒤 같은 토큰에 서비스마다 다른 응답(401/200)이 나던 JWT 키 문제(0eeaf2c)와 로그인·주문에 제한이 없던 문제(085ded0)를 고쳤습니다. 그 밖에 브로커가 없을 때 outbox 트랜잭션이 몇 분씩 락을 잡던 문제(881afda), 파티션 증설 뒤 컨슈머가 새 파티션을 못 보던 문제, 이전 실행의 hold가 만료돼 판매가 101로 보이던 k6 불변식 오류, 다른 프로젝트 컨테이너가 8080 포트를 잡아 통합 테스트 14개가 실패한 일도 원인을 찾아 정리했습니다.

LiveCommerce | javohir.dev