LabHub
배우기 러닝패스 코스

은행 현장의 언어 · 재시도가 만든 이중 이체 · 이론

응답을 못 받은 클라이언트는 다시 보낸다

LabHub 에서 이어서 보기

한 줄 요약

멱등 키는 클라이언트가 붙여 보내는 요청의 이름표이고, 서버는 그 이름표로 "이 요청은 이미 처리했다" 를 기억해 두 번째 실행을 막고 첫 번째 응답을 그대로 돌려준다.

왜 이게 필요했나

이체 API 에 요청을 보낸 클라이언트가 응답을 못 받는 일은 드물지 않다. 게이트웨이의 타임아웃이 서버 처리보다 짧았거나, 로드밸런서가 연결을 끊었거나, 휴대폰이 지하로 들어갔을 수 있다. 이때 클라이언트가 아는 사실은 하나뿐이다 — 응답을 못 받았다. 돈이 나갔는지 안 나갔는지는 모른다.

여기서 클라이언트에게 남은 선택지는 둘이다. 포기하거나, 다시 보내거나. 포기하면 사용자는 "이체가 안 됐다" 고 믿고 다시 누른다. 결국 요청은 어차피 한 번 더 간다. 그래서 재시도 자체를 없애는 것은 답이 아니고, 재시도가 안전해지게 만드는 것이 답이다.

[RFC 9110 의 9.2.2 절](https://www.rfc-editor.org/rfc/rfc9110.html)은 이 성질을 메서드 단위로 정의한다. 같은 요청을 여러 번 보냈을 때 서버에 의도된 효과가 한 번 보낸 것과 같으면 그 메서드는 멱등(idempotent)이고, PUT·DELETE 와 안전한 메서드가 여기 속한다. 같은 절은 멱등 메서드가 응답을 읽기 전에 통신이 끊겼을 때 자동으로 재시도할 수 있기 때문에 구별된다고 적고, 멱등이 아닌 메서드는 클라이언트가 함부로 자동 재시도해서는 안 된다고 못 박는다. 이체는 POST 다. 즉 프로토콜이 주는 보증은 없다. 보증은 우리가 만들어야 한다.

어떻게 동작하나

만드는 방법은 오래전에 결론이 났다. 클라이언트가 요청마다 유일한 키를 하나 붙이고, 서버는 그 키를 저장한다. 이 관행을 문서로 정리한 것이 [IETF 초안 draft-ietf-httpapi-idempotency-key-header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) 다. 초안은 표준이 아니다 — RFC 번호가 없고 내용도 바뀔 수 있다. 그래도 지금 이 문제를 다루는 가장 정리된 글이라 실무의 공통 언어 노릇을 한다.

초안이 정하는 뼈대는 네 가지다.

오류 코드도 초안이 제안한다. 같은 키에 다른 본문이 오면 422(Unprocessable Content), 앞 요청이 아직 처리 중이면 409(Conflict) 다. 둘의 차이는 클라이언트가 할 일이 다르다는 데 있다. 422 는 요청을 고쳐야 하고, 409 는 고칠 것이 없고 잠시 뒤 다시 물어보면 된다.

저장소 쪽에서 이 설계의 핵심은 한 줄이다. 키 표에 UNIQUE 제약을 걸고, 두 번째 INSERT 가 실패하는 것 자체를 판정으로 쓴다. "먼저 조회해서 없으면 넣는다" 는 조회와 삽입 사이에 틈이 있어서, 같은 순간 들어온 재시도 두 건이 둘 다 "없다" 를 보고 둘 다 실행한다. [SQLite 의 ON CONFLICT 절](https://www.sqlite.org/lang_conflict.html)은 제약을 어겼을 때 어떻게 할지를 ROLLBACK·ABORT·FAIL·IGNORE·REPLACE 다섯으로 정하고 기본값은 ABORT 다. 여기서 주의할 것은 INSERT OR IGNORE 다. 충돌한 행을 조용히 건너뛰므로, 재시도인지 새 요청인지 코드가 알 수 없게 된다. 우리에게 필요한 것은 예외다 — 파이썬에서는 [sqlite3](https://docs.python.org/3/library/sqlite3.html) 의 IntegrityError 로 올라온다.

요청 + Idempotency-Key      │      ├─ 키 선점 INSERT 성공  →  이체 실행 → 응답 저장(completed) → 201      └─ UNIQUE 위반          →  지문 다름  → 422                                 처리 중    → 409                                 완료됨    → 저장한 응답을 그대로 재생

현장에서 만나는 모습

첫째, 지문을 안 보는 구현이 가장 많다. 키만 보고 "이미 있으니 성공" 이라고 답하면, 창구 직원이 금액을 고쳐 같은 화면에서 다시 보낸 요청이 조용히 무시된다. 고객은 30만원을 보낸 줄 알지만 실제로 나간 것은 20만원이다. 이 사고는 로그에도 오류로 남지 않는다.

둘째, 본문 비교를 문자열로 한다. 클라이언트 라이브러리가 JSON 필드 순서나 공백을 바꾸면 같은 요청이 다른 지문이 되어 422 가 쏟아진다. 그래서 지문은 정규화한 뒤 계산한다. [RFC 8785(JSON Canonicalization Scheme)](https://www.rfc-editor.org/rfc/rfc8785.html)이 이 정규화를 규정한다 — 객체 키를 코드 포인트 순서로 정렬하고, 공백을 없애고, 숫자와 문자열의 표기를 한 가지로 고정한 뒤 UTF-8 로 직렬화한다. 파이썬에서는 json.dumps(obj, sort_keys=True, separators=(",", ":")) 가 실무에서 쓸 만한 근사치다(RFC 8785 의 숫자 표기 규칙까지 그대로 따르지는 않는다).

셋째, 처리 중 상태가 없다. 키를 "완료" 로 먼저 적어 놓고 이체를 실행하면, 실행 도중 프로세스가 죽었을 때 키만 남는다. 그 뒤의 재시도는 "이미 처리됨" 이라는 답을 받고, 돈은 영영 안 나간다. 반대로 이체를 먼저 하고 키를 나중에 적으면 중복이 난다. 그래서 선점(in_progress)과 완료(completed)를 갈라 두 단계로 기록한다.

넷째, 키의 범위를 안 정한다. 키가 전역이면 다른 고객이 우연히 같은 문자열을 보냈을 때 남의 응답을 받는다. 범위는 보통 (고객, 엔드포인트, 키) 세 가지로 잡는다. 보관 기간도 함께 정해야 한다 — 위 초안도 만료 정책을 문서로 공개하라고 적는다. 기간이 지나 키를 지우면 그 뒤의 재시도는 새 요청이 되므로, 보관 기간은 클라이언트의 재시도 상한보다 넉넉해야 한다.

실무에서 진짜 중요한 것

다음 실습에서 할 것

그날의 요청 로그를 직접 만들어, 멱등 보호가 없던 시스템이 만들어 낸 이중 이체를 먼저 집계한다. 그다음 멱등 키 표와 UNIQUE 제약, 정규화 지문, 처리 중 상태, 응답 재생, 키 범위와 보관 기간을 차례로 붙인다. 채점기는 매번 다른 계좌·금액·키로 여러분의 엔드포인트를 실제로 실행해 응답과 잔액을 대조하고, 같은 순간에 들어온 재시도 두 건도 보낸다. 마지막에는 하루치 요청을 하나도 빼지 않고 다시 흘려보내 이중 이체가 0 이 됨을 증명한다.