핵심 한 줄
결제 취소 요청이 네트워크 재시도로 두 번 도착한다. 두 번 취소되면 환불이 두 번 나간다. 필요한 건
f(f(x)) == f(x)— 멱등성이다. 정답은 “같은 요청인지”를 서버가 추측하는 게 아니라, 클라이언트가 멱등키로 선언하고 그 키를 DB 유니크 제약에 걸어 두 번째 INSERT를 원자적으로 튕겨내는 것이다. check-then-act가 아니라 INSERT-first.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
00.카타 서문 · 01.돈을 primitive로 두면 언젠가 샌다 · 02.불변식은 누가 지키나 · 03.동시에 사면 초과판매된다 · 04.두 번 취소해도 한 번만 · 05.save가 INSERT가 아니었다 · 06.기다리게 하지 말고 거절하라 · 07.트랜잭션 안에서 HTTP를 부르지 마라 · 08.응답을 못 받았다는 실패가 아니다 · 09.요청 하나에 쿼리 1+N · 10.5만 행을 1행으로 · 11.같은 걸 두 번 묻지 않기 · 12.가짜는 내가 시킨 답만 한다 · 13.분명히 잡았는데 터졌다 · 14.5개 주세요 했는데 5만 개를 읽었다 · 15.순수하게 만들었더니 저장이 사라졌다 · 16.설정 한 줄 바꿨더니 세 번 터졌다 · … 그리고 계속
1. 문제 — 같은 취소가 두 번 온다
결제 취소 API를 만든다. 클라이언트가 “이 주문 취소해줘”를 보내고, 서버는 PG에 환불을 요청한다. 여기까지는 깔끔하다.
그런데 클라이언트가 응답을 못 받는다. 타임아웃, 끊긴 와이파이, 로드밸런서의 재시도 — 이유는 많다. 클라이언트가 아는 건 하나뿐이다. “응답이 안 왔으니 다시 보낸다.” 그래서 같은 취소 요청이 두 번 서버에 도착한다.
두 번 다 곧이곧대로 처리하면? 환불이 두 번 나간다. 돈이 두 번 빠져나간다. 취소는 두 번 실행돼도 한 번과 같아야 한다. 수학으로 쓰면 f(f(x)) == f(x) — 멱등(idempotent)이다. 이게 L3의 두 번째 질문, “두 번 오면?” 이다.
2. “같은 요청인지”를 서버가 어떻게 아나
멱등의 핵심은 결국 하나다. 두 요청이 같은 요청인지를 판별할 수 있어야 한다. 두 갈래가 있다.
(1) 요청 내용을 비교한다 (휴리스틱). 주문번호와 취소할 orderline들을 정렬해 보고, “내용이 같으면 같은 취소”라고 본다. 그럴듯하지만 무너진다. 사용자가 진짜로 취소를 두 번 하고 싶었다면? 방금 취소한 걸 되살렸다가 다시 취소한 거라면? 그래서 “5분 안에 같은 내용이면 중복, 지나면 별개”처럼 시간 창을 둔다. 하지만 그 5분은 임의값이다. 4분 59초와 5분 1초 사이에 진실이 갈리지 않는다. 내용 비교는 서버가 사용자의 의도를 추측하는 일이고, 추측은 딱 떨어지지 않는다.
(2) 클라이언트가 멱등키로 선언한다 (정답). 클라이언트가 요청마다 UUID를 하나 만들어 헤더에 실어 보낸다(Idempotency-Key). 이 키가 같으면 “이건 같은 요청이다”라는 클라이언트의 선언이다. 재시도는 같은 키로, 새 취소는 새 키로. 판별의 근거를 서버의 추측이 아니라 클라이언트의 선언으로 옮긴다. 그래서 클라이언트 키가 필요하다 — 서버 혼자서는 절대 확실히 알 수 없는 것이다.
3. 결정 — 유니크 제약에 INSERT-first
키가 생겼다. 이제 “이 키를 이미 처리했나”를 판정해야 한다. 순진한 방법은 이렇다.
if (repository.existsById(key)) return 저장된_응답; // 확인
else 처리하고_저장(key); // 행동
이게 check-then-act다. 그리고 이건 3편(동시성)에서 이미 깨진 패턴이다. 재시도 두 개가 동시에 도착하면 둘 다 existsById에서 “없음”을 보고, 둘 다 처리로 넘어간다. 확인과 행동 사이의 틈으로 중복이 새어 나간다.
정답은 3편의 원자적 UPDATE와 같은 철학이다. 먼저 넣어보고 충돌로 판별한다(INSERT-first).
- 멱등키를
@Column(unique = true)로 저장한다. - 요청이 오면 일단 그 키로 선점 INSERT를 시도한다.
- 첫 번째는 성공한다 → 내가 처리 주인이다.
- 두 번째는 유니크 제약 위반으로 실패한다 → DB가 원자적으로 튕겨낸다. 그 예외를 잡아 저장된 결과를 돌려준다.
핵심은 판정을 애플리케이션이 아니라 DB에 맡긴다는 것이다. “있나 확인하고 없으면 넣기”는 동시에 둘 다 통과시키지만, 유니크 제약 INSERT는 DB 엔진이 단 하나만 통과시킨다. 경합의 승자를 가리는 일은 원래 DB가 제일 잘한다.
4. 코드 근거
IdemRecord가 키를 유니크로 못박는다.
@Id
@Column(unique = true)
private String idemKey;IdemService.identityHeaderKey가 INSERT-first를 실행한다.
try {
idemProcessor.claimInProgress(headerKey); // 선점 INSERT 시도
claimed = true;
} catch (DataIntegrityViolationException e) { // 유니크 위반 = 두 번째 도착
IdemRecord alreadyDone = repository.findById(headerKey)
.orElseThrow(() -> new IllegalArgumentException("not found"));
if (Objects.equals(alreadyDone.getStatus(), IdemProcessor.Status.COMPLETED)) {
return alreadyDone.getResponse(); // 저장된 응답을 그대로 반환
}
throw new DuplicateKeyException("in progress");
}흐름을 말로 풀면 이렇다. 선점 INSERT가 성공하면 내가 주인이니 취소를 실행하고 complete로 응답을 저장한다. 실패하면 이미 누가 이 키를 잡았다는 뜻이다. 그 레코드를 찾아 COMPLETED면 저장된 응답을 그대로 돌려준다 — 취소를 다시 실행하지 않는다. 두 번째 요청은 첫 번째의 결과를 복사해 받을 뿐이다. 이게 f(f(x)) == f(x)다.
(아직 COMPLETED가 아니라 진행 중이면 DuplicateKeyException("in progress")를 던진다. “먼저 온 게 처리 중”이라는 세 번째 상태가 남는데, 그 상태기계는 6편의 몫이다.)
5. 남은 것 — 원리와 함정
정리하면 두 조각이다.
- 클라이언트 키 = 선언. 서버는 “같은 요청인지”를 추측하지 않는다. 클라이언트가 UUID로 선언한다.
- 유니크 제약 = 원자적 dedup. 판정을 DB에 위임한다. check-then-act의 틈을 없앤다.
이 발상은 스택을 넘는다. 언어가 Java든 Go든, DB가 MySQL이든 Postgres든, “중복을 애플리케이션 if로 막지 말고 유니크 제약으로 막아라”는 감각은 그대로 남는다.
그런데 — 이 코드는 처음 짰을 때 테스트가 초록불이었다. 그런데도 실제로는 중복을 못 막고 있었다. 두 번째 요청이 유니크 위반으로 튕기지 않았다. 이유는 save()가 INSERT를 안 했기 때문이다. 유니크 제약은 INSERT할 때만 걸린다. INSERT 자체가 안 일어나면 제약은 구경만 한다. IdemRecord가 Persistable을 구현하고 있는 이유가 바로 이것인데 — 다음 편이다.