핵심 한 줄
PG 환불 HTTP가 타임아웃났다. 성공도 실패도 아니고 모른다. “타임아웃=실패”로 단정하고 재시도하면 이중 환불이다. 그래서 상태는 둘이 아니라 셋 — COMPLETED·FAILED·UNKNOWN. FAILED는 확정이라 재시도 금지, UNKNOWN만 재시도한다. 그리고 그 재시도가 안전한 건 멱등키 덕분이다.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
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. 타임아웃은 성공인가 실패인가
7편에서 HTTP를 트랜잭션 밖으로 꺼냈다. 커넥션은 더 이상 외부 응답을 기다리지 않는다. 그런데 밖으로 꺼내는 순간, 새 질문이 따라온다 — 그 응답이 안 오면?
PG에 환불을 요청한다. HTTP를 쐈고, 타임아웃이 났다. 자, 이 환불은 성공인가 실패인가?
모른다. 이게 정답이다.
타임아웃은 “실패했다”가 아니라 “응답을 못 받았다”일 뿐이다. 요청은 PG 서버에 도착했을 수도, 안 했을 수도 있다. 도착했다면 PG는 이미 환불을 끝냈을 수도 있다. 다만 그 결과를 담은 응답이 네트워크 어딘가에서 사라졌을 뿐이다. 우리 쪽에서 보이는 건 “침묵”이고, 침묵은 성공도 실패도 아니다.
여기서 흔한 사고가 터진다. “타임아웃 났으니 실패겠지” 하고 다시 환불을 쏜다. PG 입장에선 두 번째 환불 요청이다. 고객은 돈을 두 번 돌려받는다. 이중 환불.
2. 그래서 상태는 셋이다
문제의 뿌리는 상태를 둘로만 봤다는 데 있다. 성공 아니면 실패. 이 이분법이 “응답 없음”을 강제로 한쪽에 밀어넣게 만든다.
응답의 종류는 셋이다:
- COMPLETED — PG가 “환불했다”고 명시적으로 답했다. 성공 확정.
- FAILED — PG가 “환불 못 한다”고 명시적으로 거절했다. 실패 확정.
- UNKNOWN — 아무 답이 없다. 타임아웃·커넥션 끊김. 모름.
public enum RefundStatus {
READY, UNKNOWN, COMPLETED, FAILED
}확정(COMPLETED·FAILED)과 미확정(UNKNOWN)은 다루는 법이 다르다.
- FAILED는 재시도하지 않는다. PG가 명시적으로 거절한 건 영구적 실패다. 다시 쏴도 또 거절이다.
- UNKNOWN은 재시도한다. 답을 못 받은 건 일시적 문제다. 네트워크가 회복되면 답이 온다.
이 “일시적 vs 영구적” 구분은 새로운 게 아니다. 6편에서 IN_PROGRESS를 두고 “일시적 경합이면 재시도, 영구적 중복이면 거절”을 나눴다. 그때 로직에 있던 구분이, 이번엔 상태 이름 자체로 굳었다. UNKNOWN은 “일시적”의 다른 이름이다.
3. 이중 환불을 막는 두 겹
UNKNOWN을 재시도하기로 했다. 그런데 재시도가 곧 이중 환불의 원인 아니었나? 재시도가 안전하려면 방어막이 필요하다. 두 겹으로 친다.
첫 겹 — 멱등키 (쓰기 방어). 환불 요청에 cancelId를 실어 PG에 넘긴다. PG는 이 키를 기억한다. 같은 키로 다시 요청이 오면, PG는 환불을 또 하지 않고 원래 결과만 다시 돌려준다. 재생(replay)이다. 그래서 UNKNOWN에서 같은 요청을 다섯 번 쏴도 실제 환불은 한 번뿐이다. 재시도가 안전한 건 이 멱등키 덕분이다. 이게 없으면 재시도는 그냥 이중 환불 기계다.
둘째 겹 — 조회·정산 (읽기 확인). 재시도를 다 했는데도 끝내 UNKNOWN이면, 그건 여전히 “모름”으로 남는다. 이건 나중에 PG 상태조회 API로 “그 cancelId, 결국 어떻게 됐냐”고 물어 확정으로 바꾼다. 쓰기가 못 끝낸 확정을 읽기가 마저 채운다.
쓰기는 멱등키로 중복을 막고, 읽기는 조회로 진실을 확인한다. 재시도는 이 두 겹 위에서만 안전하다.
4. 재시도 루프는 PG 호출만 감싼다
재시도에는 함정이 하나 더 있다. 무엇을 루프 안에 넣느냐다.
public PGResult refund(String pgKey, String cancelId, Money refund) {
int maxRetry = 5;
PGResult pgresult = null;
// 선점(claim)은 루프 밖, 딱 한 번
try {
refundProcessor.changeStatusToReady(cancelId, refund, pgKey);
} catch (DataIntegrityViolationException e) {
Refund alreadyDone = refundRepository.findByCancelId(cancelId)
.orElseThrow(() -> new IllegalArgumentException("not found"));
if (Objects.equals(alreadyDone.getStatus(), RefundStatus.COMPLETED)) {
return PGResult.ok("200", alreadyDone.getRef());
}
throw new DuplicateKeyException("멱등키 중복");
}
// 재시도는 PG 호출(UNKNOWN)만 감싼다
for (int attempt = 1; attempt <= maxRetry; attempt++) {
pgresult = refundPort.refund(pgKey, cancelId);
if (pgresult.status().equals(PGStatus.UNKNOWN)) {
backoff(attempt);
} else {
break; // COMPLETED/FAILED는 확정 → 즉시 탈출
}
}
return refundProcessor.changeStatusChangeCausePGResult(cancelId, pgresult);
}선점(changeStatusToReady, READY 레코드를 유니크 제약으로 심는 TX1)은 루프 밖에서 딱 한 번이다. 만약 이걸 루프 안에 넣으면? 첫 시도에서 READY 레코드를 만들고, 재시도 두 번째에 또 만들려다 자기가 방금 만든 레코드와 유니크 충돌이 난다. 자기 자신에게 “이미 처리 중”을 던지는 꼴이다. 4편에서 IN_PROGRESS를 재시도할 때 배운 것과 똑같은 함정이다.
그래서 경계가 분명하다. 선점은 한 번, 불확실한 외부 호출만 반복. 루프가 감싸야 할 건 오직 UNKNOWN이 나올 수 있는 refundPort.refund 뿐이다. 확정(COMPLETED·FAILED)이 나오면 즉시 break — 확정된 걸 다시 물을 이유가 없다.
5. PG의 상태와 우리의 상태는 주인이 다르다
코드에 enum이 둘 있다. PGStatus와 RefundStatus. 값이 겹쳐 보인다 — 둘 다 UNKNOWN·COMPLETED·FAILED가 있다. 그럼 하나로 합쳐도 되지 않나?
안 된다. 주인이 다르기 때문이다.
public enum PGStatus { UNKNOWN, COMPLETED, FAILED }
public enum RefundStatus { READY, UNKNOWN, COMPLETED, FAILED }PGStatus는 PG 시스템의 것이다. PG가 우리에게 통보하는 결과. 우리는 못 바꾼다.RefundStatus는 우리 도메인의 것이다. 우리 환불 레코드의 생애. 우리가 정한다.
결정적 증거가 READY다. PG는 READY라는 상태를 모른다. 이건 “우리가 환불을 시작하려고 선점만 해둔” 순수하게 우리만의 상태다. 두 enum은 독립적으로 진화한다. PG가 내일 PENDING을 추가해도 우리 도메인은 그대로일 수 있고, 우리가 CANCELLED_BY_ADMIN을 넣어도 PG는 모른다.
값이 우연히 겹친다고 타입을 합치면, PG 시스템의 변경이 우리 도메인을 흔들게 된다. 그래서 두 타입으로 분리하고, 둘 사이엔 번역 지점을 둔다. 그 지점이 TX2다:
private RefundStatus translate(PGStatus pg) {
return switch (pg) {
case COMPLETED -> RefundStatus.COMPLETED;
case FAILED -> RefundStatus.FAILED;
case UNKNOWN -> RefundStatus.UNKNOWN;
};
}지금은 매핑이 1:1이라 심심해 보인다. 하지만 이 translate가 있다는 것 자체가 **“여기가 두 세계의 국경”**이라는 선언이다. 외부의 어휘를 우리 어휘로 바꾸는 한 지점. 값이 겹쳐 보여도 타입은 분리한다.
6. 여기까지가 외부 세계다
1편의 돈 한 덩어리에서 시작해, 외부 세계의 근본적 불확실성까지 왔다. “응답을 못 받았다 ≠ 실패했다” — 이 한 줄이 상태를 셋으로, 재시도를 조건부로, 어휘를 둘로 갈랐다. 분산 시스템에서 확실한 건 하나다. 확실한 게 없다는 것.
여기서 방향을 한 번 튼다. 지금까지는 “틀리면 안 되는 것”(불변식·경합·경계)을 쫓았다. 다음부터는 ORM이 편하다는 이유로 조용히 감춰온 문제 — 성능으로 간다. 목록 하나를 그렸을 뿐인데 쿼리가 1+N개 나가는, 그 익숙한 함정부터.