카테고리 없음

[Challenge Story] 주문·결제 동시성 제어 — 6개 문제를 연쇄 발견한 설계 여정 (2주차 · 6팀 · 조수현)

Coding-Su 2026. 5. 22. 16:12
728x90

TL;DR

주문/결제 시스템에서 동시성 문제를 하나씩 해결할 때마다 새로운 문제가 연쇄적으로 드러났다.
SAGA 패턴 → 취소 시 Lost Update → 데드락 → 이중 취소까지 6개 문제를 차례로 발견하고,
SELECT FOR UPDATE와 정렬 기반 락 획득 순서로 모두 해결했다.

문제 1: 결제 실패 중 새 주문 → 트랜잭션 분리로 보상 범위 명확화 (보상 자동 실행은 미구현)
    ↓
문제 2: 주문 취소 시 재고 Lost Update → 취소에도 SELECT FOR UPDATE 추가
    ↓
문제 3: 여러 상품 주문 시 데드락 → productId 오름차순 정렬로 해결
    ↓
문제 4: 이중 취소 요청 → Order에 SELECT FOR UPDATE 추가
    ↓
문제 5: PENDING 방치 → 배치 자동 취소 (잠재 리스크)
    ↓
문제 6: 결제 확정 주체 → 클라이언트 confirm 호출 (B방식, Webhook 예정)

Context (배경 및 목표)

  • 어떤 시스템을 만드는가?: 외부 PG(결제대행사)와 연동하는 주문/결제 API. 재고 차감 → PG 결제 → 주문 확정의 흐름을 여러 사용자가 동시에 요청해도 안전하게 처리해야 한다.
  • 가장 큰 기술적 도전 과제는?: PG 호출이 트랜잭션 경계 밖에 있어 기존 단일 트랜잭션 방식으로는 대응이 불가능하고, 주문·취소·재시도가 동시에 쏟아질 때 재고 정합성을 유지하는 것이다.

Design & Implementation (설계 및 구현)

핵심 기술 선택: 비관적 락 (SELECT FOR UPDATE)

낙관적 락(@Version)과 비교했을 때 재고 도메인에서는 비관적 락이 필수였다.

낙관적 락 비관적 락
DB 음수 저장 발생 안 함 발생 안 함
재고 있는데 주문 실패 발생 가능 (충돌 시 재시도 반복 → 결국 실패) 발생 안 함 (대기 후 처리)
Throughput 높음 낮음 (대기 발생)
적합한 상황 충돌 빈도 낮은 데이터 충돌 빈도 높은 데이터

선택 근거: 재고는 충돌 빈도가 높고, 재고가 있으면 반드시 주문이 성공해야 한다는 요구사항이 핵심이다. 낙관적 락의 "충돌 → 재시도 → 또 충돌" 패턴은 재고가 남아 있는데 고객이 실패를 보는 상황을 만들어낸다.

Logic Flow

트랜잭션을 세 구간으로 분리한 SAGA 패턴을 기반으로 한다.

[TX 1] 재고 차감 + 주문 생성 (PENDING) → 커밋
    ↓ 트랜잭션 종료 후
PG 결제 요청 (외부 시스템, 트랜잭션 외부)
    ↓
결제 성공 → 클라이언트 confirm 호출 → [TX 2] PENDING → CONFIRMED
결제 실패 → 클라이언트 취소 호출 → [TX 3] 재고 복구 + CANCELLED

Engineering Challenges (트러블슈팅 및 최적화)

🔴 문제 1. 보상 트랜잭션이 다른 주문의 차감량을 덮어쓴다

처음 설계는 재고 차감 + PG 결제 + 주문 생성을 하나의 트랜잭션에 묶는 구조였다. 두 가지 문제가 있었다. PG 응답이 느린 동안 DB 락이 그대로 유지되고, 보상 트랜잭션 실행 중에 새 주문의 차감량까지 덮어쓸 수 있었다.

1번 주문: 재고 차감 → 결제 실패 → 보상 트랜잭션 실행 중
                                    ↓ (동시에)
                    2번 주문: 같은 상품 재고 차감
                                    ↓
1번 보상 완료 → 2번의 차감량까지 복구해버림

트랜잭션이 길수록 보상 범위가 불명확해진다. 외부 시스템(PG)을 트랜잭션 안에 넣으면 트랜잭션 길이를 통제할 수 없다.

트랜잭션 경계를 분리해 각 TX를 독립적으로 만들었고, 보상 범위가 명확해졌다.

적용 여부 비고
로컬 트랜잭션 분리 TX1 / TX2 / TX3 분리
보상 트랜잭션 자동 실행 TX3(취소)는 클라이언트가 수동 호출해야 실행됨

완전한 SAGA가 되려면 실패를 감지하고 보상을 자동으로 트리거하는 메커니즘(이벤트 or 오케스트레이터)이 필요하다. 현재는 클라이언트가 호출하지 않으면 보상이 일어나지 않으며, 이것이 문제 3(PENDING 방치)의 근본 원인이기도 하다. Webhook 전환 시 PG가 서버에 직접 결과를 전달하므로 보상 자동화가 가능해진다.


🔴 문제 2. 재고 락 밖에서 생긴 두 가지 문제

재고 경로에는 락이 있었다. 문제는 그 바깥이었다.

데드락 — 여러 상품을 동시에 주문할 때

정렬 없이 items 순서대로 락을 잡으면 두 요청이 서로를 기다리는 순환 대기가 생긴다.

주문 A: [상품 1 락] 획득 → [상품 2 락] 대기
주문 B: [상품 2 락] 획득 → [상품 1 락] 대기 → 데드락

productId 오름차순 정렬로 해결했다. 주문 생성·취소 양쪽 모두 적용했다.

이중 취소 — Order 행에도 락이 필요했다

재고에는 락을 걸었는데 Order 상태 확인은 락 없이 읽고 있었다. 클라이언트 재시도 또는 추후 도입될 배치 자동 취소가 동시에 실행되면 두 요청이 모두 PENDING을 보고 재고를 두 번 복구한다.

취소 A: Order 읽기 (PENDING) → 재고 복구 중...
취소 B: Order 읽기 (PENDING) → 재고 복구 시작 → 중복 복구!

Order 조회에도 SELECT FOR UPDATE를 추가했다. 두 번째 요청은 반드시 CANCELLED를 보고 400을 반환한다.


🟡 문제 3. PENDING 방치 (클라이언트 미응답)

클라이언트가 confirm도 취소도 안 하면 재고가 차감된 채 PENDING이 영구 유지된다. B 방식(클라이언트 confirm)의 구조적 한계다. 배치 자동 취소 도입이 필요하다. 실제 PG 연동 시 Webhook으로 교체할 예정이다.

배치 스케줄러: PENDING 상태이고 생성 후 N분 경과한 주문 조회
→ 자동 취소 처리 (재고 복구 + CANCELLED)

🟡 문제 4. 결제 확정 주체 선택

방식 흐름 문제
A (Admin 수동) Admin이 PG 대시보드 확인 후 confirm 주문량 많으면 현실적으로 불가능
B (Client 호출) 클라이언트가 PG 성공 후 confirm 호출 클라이언트 신뢰에 의존, 결제 없이 confirm 가능
C (Webhook) PG가 서버에 자동 notify 가장 안전하나 현재 구현 범위 초과

현재 PG 연동이 없으므로 서버가 결제 여부를 검증할 방법이 없다. B 방식으로 흐름을 구현하고, 실제 PG 연동 시 Webhook으로 교체한다.

변경된 엔드포인트: PATCH /api/v1/orders/{orderId}/confirm (USER 권한)


Verification & Insight (검증)

문제 해결 방법 결과
보상 트랜잭션 범위 불명확 SAGA 패턴 (TX 분리) 보상 범위 명확화
취소 시 Lost Update 취소 경로에 SELECT FOR UPDATE 추가 Lost Update 제거
다중 상품 데드락 productId 오름차순 정렬 순환 대기 제거
이중 취소 중복 복구 Order 행 SELECT FOR UPDATE 직렬화로 중복 제거
처리량(Throughput) 영향 [미측정 — 부하 테스트 필요]

잠재 리스크

리스크 현재 대응 추후 개선
PENDING 방치 (클라이언트 미응답) 배치 자동 취소 도입 필요 (미구현) Webhook으로 교체
클라이언트 신뢰 의존 (결제 없이 confirm 호출) 현재 허용 PG Webhook 수신 후 서버 검증
상품 수 많을수록 락 보유 시간 증가 productId 정렬로 데드락만 방지 배치/비동기 이벤트 방식 검토

Lessons Learned

  1. 락은 쓰는 경로뿐 아니라 읽는 경로도 확인해야 한다. 주문 생성에만 락이 있어도 취소 경로에 락이 없으면 Lost Update가 생긴다. read-modify-write 패턴이 있는 모든 경로를 빠짐없이 점검해야 한다.

  2. 트랜잭션 경계가 락 전략보다 먼저다. 외부 시스템(PG)을 트랜잭션 안에 넣으면 락 전략이 아무리 정교해도 락 보유 시간이 제어 불가능해진다. SAGA처럼 트랜잭션을 짧게 유지하는 것이 먼저다.

  3. 동시성 설계는 전체 흐름을 시나리오 단위로 반복 검토해야 허점이 없다. 주문 생성 직렬화 → 취소 Lost Update → 데드락 → 이중 취소의 순서처럼, 해결하고 나면 숨어 있던 다음 문제가 나온다.

  4. SAGA의 본질은 트랜잭션 분리가 아니라 보상 자동화다. 트랜잭션을 쪼개는 것만으로는 SAGA가 아니다. 실패를 감지하고 보상을 자동으로 트리거하는 메커니즘이 없으면, 보상이 클라이언트 몫이 되고 PENDING 방치 같은 구조적 허점이 생긴다.

728x90