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
핵심 수치
해결하려는 문제
라이브 방송 중 한정 수량 판매는 몇 초 안에 주문이 몰리고, 재고, 결제, 배송이 서로 다른 서비스에 있습니다. 플래시 세일에서 품절은 일상이라 결제를 먼저 받으면 품절될 때마다 환불과 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가 시작되지 않습니다.
- 트레이드오프
- 키를 교체하면 이전 토큰이 잠시 거부됩니다.
개선 전후 측정
- 무엇을 바꿨나
- 발견한 원인 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 테스트 키로 동작하고 방송 영상은 시뮬레이션입니다
핵심 코드
order-service/…/saga/OrderSagaOrchestrator.ktSaga 상태 머신: hold → 결제 → 확정, 보상, OPEN_ORDER_EXISTS
order-service/…/saga/SagaRecoveryScheduler.kt20초 넘게 멈춘 단계와 결제 기한이 지난 주문을 5초마다 복구
order-service/…/outbox/OutboxRelay.kt병렬 전송, 확인된 연속 앞부분만 PUBLISHED, 첫 실패에서 중단
inventory-service/…/service/InventoryService.ktreserve / confirm / release / expire, 락 없는 품절 거절 뒤 FOR UPDATE
payment-service/…/gateway/TossPaymentGateway.ktToss 승인/취소: timeout, Idempotency-Key, 거절 응답 매핑
api-gateway/…/filter/IdentityRelayFilter.kt클라이언트가 보낸 신원 헤더를 지우고 JWT에서 다시 씀
api-gateway/…/ratelimit/RateLimitFilter.kt, ClientAddress.kt레이트 리밋과 XFF 체인에서 신뢰할 수 있는 IP
auth-service/…/config/SigningKeys.ktenv에서 키 로드, thumbprint kid
delivery-service/…/webhook/WebhookSignature.ktHMAC 서명, timestamp 신선도, event_id 한 번 적용
order-service/…/V5__one_open_order_per_member_product.sql열린 주문 하나 partial unique index
load-test/k6/flash-sale.js200 VU 플래시 세일과 불변식 5개
현재 상태와 다음 단계
재고 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개가 실패한 일도 원인을 찾아 정리했습니다.