핵심 한 줄

7·8편의 외부호출 설계는 전부 Fake 어댑터 위에서 그렸다. 그런데 Fake는 내가 정해준 답만 돌려준다 — 타임아웃도, 인증 실패도, 멱등도 전부 ‘가정’이었을 뿐이다. 실제 RestClient로 로컬 목(4갈래) → 실 PG(승인·취소·멱등)까지 붙여보니, 설계가 실물 앞에서 그대로 서는지 비로소 증명됐다. 그리고 실물은 목이 절대 안 가르쳐주는 것들을 가르쳐준다.

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


1. Fake는 반박하지 않는다

7·8편에서 UNKNOWN·재시도·멱등을 설계했다. 테스트는 전부 초록불이었다. 그런데 그 초록불의 정체를 뜯어보면 이렇다 — 내가 짠 Fake 어댑터에게 “이번엔 UNKNOWN 줘”라고 시켰고, Fake가 시킨 대로 UNKNOWN을 줬다.

fakePg.willReturnInOrder(
    PGResult.unknown("200", "REF"),   // 내가 정해준 답
    PGResult.unknown("200", "REF"),
    PGResult.ok("200", "REF")
);

Fake는 반박하지 않는다. 내 시나리오를 검증한 것이지, 현실을 검증한 게 아니다. 진짜 타임아웃이 진짜 예외를 던지는지, 그 예외를 내 매핑이 UNKNOWN으로 옮기는지, 진짜 PG가 같은 멱등키를 정말로 존중하는지 — 이 셋은 전부 가정으로 남아 있었다. 가정을 증거로 바꾸는 길은 하나뿐이다. 실물에 붙인다.

2. 진짜 타임아웃은 진짜 소켓만 준다

먼저 로컬 목(httpbin)으로 실제 HTTP 4갈래를 밟았다. RestClient에 connect/read 타임아웃을 5초로 걸고, 목의 각 엔드포인트를 친다.

진짜 HTTP→ PGStatus재시도
/status/200COMPLETED
/status/400FAILED❌ 금지
/status/500UNKNOWN
/delay/10 (5초 타임아웃)UNKNOWN

/delay/10은 목이 10초를 끌기 때문에 read 타임아웃 5초가 먼저 터진다. 8편에서 “타임아웃은 모름이다”라고 했던 것을, 여기서 진짜 소켓 타임아웃으로 밟았다.

그런데 실물에 붙이자마자 목으로는 안 보이던 함정이 하나 튀어나왔다. RestClient.retrieve()는 2xx가 아니면 예외를 던진다 — 4xx는 HttpClientErrorException, 5xx는 HttpServerErrorException, 타임아웃·연결끊김은 ResourceAccessException. 그럼 catch에서 뭘 해야 하나? 처음엔 이렇게 썼다.

} catch (ResourceAccessException e) {   // 타임아웃
    throw new IllegalArgumentException("time out");   // ← 틀렸다
}

던지면 안 된다. 재시도 루프는 refund()가 리턴한 PGResult.status()를 보고 재시도를 결정한다. 예외를 던지면 그 값이 루프 위로 뚫고 나가버려서, 루프는 UNKNOWN을 영영 못 본다. catch는 던지는 게 아니라 돌려줘야 한다.

} catch (HttpClientErrorException e) {          // 4xx: 명백한 요청 오류 → 재시도 금지
    return PGResult.fail(String.valueOf(e.getStatusCode().value()));
} catch (HttpServerErrorException e) {          // 5xx: 처리 여부 불확실 → 재시도 대상
    return PGResult.unknown(String.valueOf(e.getStatusCode().value()), null);
} catch (ResourceAccessException e) {           // 타임아웃/연결실패: 응답 없음 → 재시도 대상
    return PGResult.unknown("TIMEOUT", null);
}

8편에서 “예외 대신 상태값”이라고 설계해 놓고도, 실제 라이브러리가 예외를 던지는 지점에서 나도 모르게 예외로 되받을 뻔했다. 실물이 아니었으면 이 틈은 안 보였다.

3. 시크릿은 소스 어디에도 없다

목엔 인증이 없지만 실 PG엔 있다. 토스는 HTTP Basic 인증인데 살짝 특이하다 — 시크릿 키를 아이디로, 비밀번호는 빈칸으로 쓴다.

String raw = secretKey + ":";               // 콜론 뒤는 비워둔다
String authHeader = "Basic " + Base64.getEncoder()
        .encodeToString(raw.getBytes(StandardCharsets.UTF_8));

여기서 규칙 하나를 근육으로 새겼다 — 시크릿은 소스에도 yml에도 박지 않는다. 테스트 키라도 계정에 묶여 있고, 하물며 이 글 같은 공개 기록에 그대로 샐 수 있다. yml엔 값이 아니라 참조만 두고, 실행할 때 환경변수로 주입한다.

pg:
  secret-key: ${PG_SECRET_KEY:}   # 환경변수에서 읽음, 코드엔 키 문자열 0

작은 함정도 하나 밟았다. 이 pg:spring: 아래에 뒀더니 실제 경로가 spring.pg.secret-key가 되어 @Value("${pg.secret-key}")가 못 찾고 앱이 안 떴다. spring: 네임스페이스는 프레임워크 전용이다. 커스텀 설정은 최상위에 둔다.

4. 401이 아니라 404가 반가웠다

첫 실 호출은 존재하지 않는 결제로 취소를 쐈다. 돌아온 건 404. 그리고 나는 이 404가 반가웠다.

  • 401이 왔다면 → 인증 실패, 키가 틀린 것.
  • 404가 왔다면 → 인증·헤더·바디·경로가 전부 제대로 토스에 닿았고, 그저 결제가 없을 뿐. 플러밍이 성공했다는 뜻.

일부러 실패할 요청을 던져서 성공을 확인하는 셈이다. “없는 결제로 404를 받는다”가 곧 “인증이 통했다”의 증거가 된다. 실패의 종류를 읽으면, 성공을 역으로 증명할 수 있다.

5. 멱등이 ‘주장’에서 ‘증거’가 되는 순간

설계 문서에선 이렇게 적었다 — cancelId를 멱등키로 PG에 보내면, 재시도가 이중 환불로 이어지지 않는다.” 문장이었다. 이걸 실물에서 증거로 바꿨다.

브라우저 결제창으로 테스트 결제를 하나 승인시켜 진짜 paymentKey를 얻고, 그걸 취소했다. COMPLETED, 진짜 승인번호가 ref에 담겼다. 그리고 결정적 실험 — 이미 취소된 그 결제에, 같은 cancelId로 취소를 한 번 더 쳤다.

PGResult first  = toss.refund(paymentKey, cancelId);
PGResult second = toss.refund(paymentKey, cancelId);   // 같은 멱등키
 
assertThat(first.status()).isEqualTo(PGStatus.COMPLETED);
assertThat(second.status()).isEqualTo(PGStatus.COMPLETED);
assertThat(second.ref()).isEqualTo(first.ref());        // ← 같은 트랜잭션

둘 다 COMPLETED, ref도 동일. 토스는 두 번째 요청에 새 취소를 만들지 않고 원래 결과를 그대로 재생해 돌려줬다. Idempotency-Key 헤더 하나가, 8편의 설계 문장을 실 API에서 동작하는 실증으로 바꾼 순간이다. 이중 환불이 막히는 걸 말이 아니라 두 번의 COMPLETED와 같은 ref로 봤다.

6. 목과 실물 — 테스트도 두 층으로 갈렸다

실물에 붙이면서 테스트의 성격도 둘로 나뉘었다.

  • 목(httpbin) 테스트 — 반복 가능. 언제 돌려도 같은 4갈래가 재현된다. CI에 넣는다.
  • 실 샌드박스 테스트 — 1회용. paymentKey는 승인·취소하면 소진되어 재실행이 안 된다. 그래서 이건 CI가 아니라 수동 스모크, 이정표를 찍을 때만 손으로 돌린다.

여기서 마지막 교훈 하나. 목 테스트를 @SpringBootTest로 짰더니 앱 전체 컨텍스트가 뜨면서 MySQL이 없다고 죽었다. HTTP 하나 검증하는데 DB까지 끌어온 것이다. 테스트는 자기가 실제로 쓰는 것만 세워야 한다. RestClient 하나만 있으면 되니, 컨텍스트 없이 그 빈만 직접 만들어 격리했다.

다음

Fake로 그린 설계도가 실물 앞에서 그대로 섰다 — 다만 서면서, 목이 삼켰던 세 가지(예외로 되받을 뻔한 틈, 시크릿의 자리, 실패의 종류로 읽는 성공)를 실물이 마저 가르쳐줬다. 설계는 실물에 붙기 전까지는 ‘아마도’다.

카타는 계속된다. 아직 손대지 않은 벽들 — 날짜 경계, 할인 배분의 잔돈, 상태머신의 전이 가드 — 이 각각 또 하나의 “여기서 이렇게 틀렸다”가 될 것이다.

이 시리즈는 계속 이어진다.

13.분명히 잡았는데 터졌다