핵심 한 줄

결제 취소 요청이 네트워크 재시도로 두 번 도착한다. 두 번 취소되면 환불이 두 번 나간다. 필요한 건 f(f(x)) == f(x)멱등성이다. 정답은 “같은 요청인지”를 서버가 추측하는 게 아니라, 클라이언트가 멱등키로 선언하고 그 키를 DB 유니크 제약에 걸어 두 번째 INSERT를 원자적으로 튕겨내는 것이다. check-then-act가 아니라 INSERT-first.

시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)


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 자체가 안 일어나면 제약은 구경만 한다. IdemRecordPersistable을 구현하고 있는 이유가 바로 이것인데 — 다음 편이다.

05.save가 INSERT가 아니었다