핵심 한 줄
결제 취소처럼 돈이 움직이는 도메인에서 중복 요청 한 번은 곧 이중 환불·정산 불일치·외부 API 비용으로 이어진다. 클라이언트 신뢰에 기대지 않고 서버가 요청 내용을 해싱(request_hash) 해 자율로 식별하고, DB UNIQUE KEY로 동시 INSERT를 차단해 risk·PG사 호출 이전 단계에서 중복을 원천 차단했다.
관련
DDD와 헥사고날 아키텍처 (애그리거트=트랜잭션 경계)
0. 비즈니스 시나리오 (가정)
이커머스 결제 도메인에서 부분 취소가 가능한 주문을 운영한다고 하자. 사용자는 한 결제(paymentKey) 안의 여러 상품(paymentItem)을 골라 취소할 수 있다.
이 도메인에서 중복 취소 요청은 일상적으로 발생한다.
- 결제 완료 후 응답이 느려 사용자가 취소 버튼을 두 번 누른다
- 모바일 네트워크가 끊겨 클라이언트 재시도 라이브러리가 같은 요청을 다시 쏜다
- CS가 어드민에서 취소를 누른 직후 사용자도 앱에서 취소를 누른다
중복이 그대로 처리되면 비즈니스 비용이 즉시 발생한다.
| 중복 처리 결과 | 비즈니스 영향 |
|---|---|
| risk 한도 이중 차감 | 정산·여신 데이터 오염, 회계 마감 시 수기 정정 |
| PG사 취소 API 중복 호출 | 호출당 과금·레이트리밋 소모, PG사 측 오류 응답 |
| 환불 이중 집행 위험 | 고객 과환불 → 손실, 또는 PG 거절 → CS 인입 |
즉 멱등성은 “기술 욕심”이 아니라 금전 손실과 운영 비용을 막는 안전장치다. 핵심 요구는 하나다 — 중복 요청을 risk·PG사에 도달하기 전에 끊어라.
1. 배경 — 문제 정의
결제 취소 요청은 네트워크 재시도나 클라이언트 중복 클릭으로 동일 요청이 여러 번 들어올 수 있는 도메인이다. 중복이 그대로 흐르면 두 가지 문제가 생긴다.
- 이중 처리: risk 한도가 두 번 차감되거나 PG사 취소 API가 중복 호출된다
- 불필요한 외부 호출 비용: 같은 요청이 차단 없이 risk·PG사까지 도달해 자원을 낭비한다
따라서 중복 요청을 risk·PG사 호출 이전 단계에서 원천 차단하는 멱등성 설계가 필요했다.
2. 결정 사항
- request_hash 기반 서버 측 멱등성:
SHA-256(paymentKey + paymentItemIds 오름차순 정렬)로 서버가 해시 생성 - DB UK 제약:
cancel_request (payment_id, request_hash)UNIQUE KEY로 동시 중복 INSERT 차단 - FAILED 재시도: 새 INSERT가 아닌 기존 건 UPDATE로 UK 유지·이력 보존
3. 대안 비교
| 방식 | 장점 | 단점 | 채택 |
|---|---|---|---|
| Idempotency-Key (클라이언트 생성 UUID) | 클라이언트가 요청을 고유 식별, 의도된 재요청과 단순 재시도를 키로 구분 가능 | 재시도 시 클라이언트가 UUID를 새로 만들면 동일 요청도 신규 처리됨, 클라이언트 신뢰에 의존 | X |
| request_hash (서버가 요청 내용 해싱) | 동일 내용이면 무조건 동일 해시라 서버 자율로 중복 식별 | 정당한 재요청 구분 불가 | O |
4. 선택 이유
클라이언트 신뢰가 아니라 서버 자율 식별이 핵심이다. Idempotency-Key 방식은 클라이언트가 같은 요청에 같은 키를 붙여줘야 작동한다. 그런데 네트워크 재시도 라이브러리·클라이언트 버그·사용자의 새로고침처럼 재시도마다 새 UUID가 생성되는 경로가 너무 많아 멱등성이 깨질 여지가 크다.
request_hash는 요청 내용 자체가 식별자라 클라이언트가 어떻게 호출하든 동일 내용이면 동일 해시가 보장된다. 서버가 자율적으로 중복을 판단할 수 있고, 클라이언트 구현 표준화 부담도 없다. “정당한 재요청을 구분 못 한다”는 단점은 인정하되, 이 프로젝트 범위에선 동일 paymentKey의 동일 아이템 재취소가 정상 시나리오가 아니라고 전제하고 수용했다.
5. 설계 상세
해시 생성
요청이 들어오면 paymentKey와 paymentItemIds(오름차순 정렬)를 결합해 SHA-256 해시를 만든다. 정렬을 강제하는 이유는 동일한 아이템 집합이 다른 순서로 들어와도 같은 해시가 나오게 하기 위함이다.
request_hash = SHA-256( paymentKey + sort(paymentItemIds).join(",") )
예) {p_123, items:[5,2,9]} → 정렬 [2,5,9] → 동일 해시
{p_123, items:[9,5,2]} → 정렬 [2,5,9] → 동일 해시 ✅ 충돌
왜 정렬이 멱등성의 전제인가
정렬을 빼면
[5,2,9]와[2,5,9]가 다른 해시가 되어 같은 의도의 요청이 중복 통과한다. 멱등성의 식별자는 “표현”이 아니라 “의미”여야 하므로, 입력을 정규화(canonicalization) 한 뒤 해싱하는 것이 핵심이다.
동시 요청 차단
cancel_request 테이블의 (payment_id, request_hash)에 UNIQUE KEY를 걸었다. 동시에 들어온 중복 요청 중 하나가 먼저 INSERT에 성공하면, 나머지는 UK 충돌로 DB 레벨에서 차단된다. 차단된 요청은 기존 건의 상태를 조회해 응답을 반환하며, 외부 모듈·PG사 호출 단계까지 도달하지 못한다.

요청 A가 먼저 INSERT하며 UK 락을 잡으면, 요청 B의 INSERT는 대기하다 A 커밋 후 UK 충돌로 실패한다. B는 새 처리를 시작하는 대신
cancel_request를 조회해 PENDING 상태를 그대로 반환한다 — 애플리케이션 락이 아니라 DB 제약 자체가 동시성 게이트가 된다.
FAILED 재시도 처리
FAILED 건을 재시도할 때 새 INSERT를 시도하면 UK 충돌이 난다. 새 row를 만드는 대신 기존 FAILED 건을 PENDING으로 UPDATE한다. 이렇게 하면 UK 제약을 유지하면서도 같은 cancel_request_id로 이력이 보존된다.
상태 전이: PENDING ──실패──▶ FAILED ──재시도──▶ PENDING (같은 row UPDATE)
└ 새 INSERT (X) → UK 충돌
6. 결과
보장된 속성
- 동시 중복 요청이 DB UK 레벨에서 차단되어 risk·PG사 호출이 단 한 번만 발생한다
- 클라이언트 구현과 무관하게 서버가 자율적으로 중복을 식별한다
- FAILED 재시도가 UK를 깨지 않으면서 이력을 보존한다
감수한 한계
- 정당한 재요청 구분 불가: 이미 정상 완료된 취소(COMPLETED)에 같은 paymentKey·동일 아이템 조합으로 재취소 요청이 오면, 기존 결과를 반환하는 멱등 응답으로 처리되어 새 취소가 생성되지 않는다. (FAILED는 PENDING UPDATE로 재처리되므로 영향 없음.) 일반 결제 취소 도메인에선 “완료된 취소를 다시 취소”가 정상 시나리오가 아니라 감수했다.
- 확장 시 보완 방향: 취소 복원 후 재취소처럼 동일 조합의 정당한 재요청이 정상 플로우인 도메인이라면, 클라이언트 Idempotency-Key로 요청을 구분하고 서버 해시는 내용 검증용으로 결합하는 이중 구조가 적절하다.
7. 한 줄 회고
동시성 제어를 애플리케이션 락이 아니라 DB UNIQUE 제약으로 끌어내린 게 핵심이었다. 비즈니스 불변식(“같은 취소는 한 번만”)을 스키마에 박아두면, 코드 경로가 늘어도 무결성이 깨지지 않는다 — DDD와 헥사고날 아키텍처의 애그리거트=일관성 경계와 같은 결의 결정.