멱등한 API 설계 - HTTP 메서드의 의미와 재시도 계약
“PUT은 멱등하고 POST는 아니다”는 자주 인용되는데, 그 문장이 실제로 무엇을 약속하는지는 덜 이야기된다. 멱등성은 구현이 알아서 주는 성질이 아니라 API가 클라이언트와 맺는 계약이고, 계약인 이상 양쪽이 할 일이 있다.
명세가 정의하는 것
RFC 9110은 두 성질을 나눈다.
- 안전(safe): 요청이 서버 상태를 바꾸지 않는다.
GET,HEAD,OPTIONS. - 멱등(idempotent): 같은 요청을 여러 번 보낸 효과가 한 번 보낸 것과 같다.
GET,HEAD,PUT,DELETE,OPTIONS.
POST는 둘 다 아니다. 그래서 브라우저가 새로고침 시 재전송을 경고하고, 프록시와 클라이언트가 POST를 자동 재시도하지 않는다.
여기서 두 가지를 짚어야 한다.
첫째, 멱등은 응답이 같다는 뜻이 아니다. DELETE를 두 번 보내면 처음은 200, 다음은 404일 수 있다. 명세가 말하는 것은 서버 상태의 효과이지 응답 코드가 아니다.
둘째, 명세는 메서드의 의도를 정할 뿐 구현을 검사하지 않는다. PUT /orders/1을 구현하면서 매번 새 이력을 append하면 그 API는 멱등하지 않다. 메서드 이름이 성질을 주지 않는다.
POST를 멱등하게 만드는 것: 멱등 키
결제 생성처럼 “새 리소스를 만드는데 두 번 만들면 안 되는” 경우, 자원 식별자를 클라이언트가 정할 수 없으므로 PUT으로 바꿀 수 없다. 이때 쓰는 것이 멱등 키다.
1
2
3
4
5
POST /payments
Idempotency-Key: 6a3f1b2c-...
Content-Type: application/json
{"amount": 10000, "orderId": "A-1"}
서버는 키를 보고 세 가지 중 하나를 한다.
| 상태 | 동작 |
|---|---|
| 처음 보는 키 | 처리하고 결과를 키와 함께 저장 |
| 처리 중인 키 | 409 또는 대기. 동시 진입을 막는다 |
| 끝난 키 | 저장해 둔 최초 결과를 그대로 반환 |
핵심은 세 번째다. 재시도에 새 결과를 만들지 않고 처음 결과를 돌려준다. 이것이 “여러 번 보낸 효과가 한 번과 같다”의 구현이다.
키의 단위는 둘 중 하나다.
- 요청 단위: 클라이언트가 UUID를 만든다. “이 버튼 누름 한 번”이 단위다. 같은 주문에 대한 서로 다른 결제를 허용한다.
- 엔티티 단위:
payment-1234-refund처럼 결정적으로 만든다. “결제 1234의 환불은 한 번”이 강제된다.
계약의 나머지 절반은 클라이언트가 지킨다
서버가 아무리 잘해도 다음이 지켜지지 않으면 멱등성은 없다.
- 재시도에 같은 키를 쓴다. 재시도마다 새 UUID를 만들면 서버는 서로 다른 의도로 볼 수밖에 없다.
- 호출 전에 키를 저장한다. 응답을 못 받고 프로세스가 죽어도 같은 키로 재시도하려면 키가 먼저 기록돼 있어야 한다.
- 페이로드를 바꾸지 않는다. 같은 키에 다른 금액이 오면 서버는 거부해야 하고(보통 422), 그 검증이 없으면 조용히 첫 결과를 돌려주며 금액 불일치를 숨긴다.
- 성공을 소비하면 키를 해제한다. 다음 요청이 옛 키를 재사용하지 않게.
Airbnb의 Orpheus 리뷰에서 이 다섯 항목이 2019년에 한 목록으로 정리돼 있는 것을 봤다. 서버 설계와 같은 무게로 클라이언트 책임을 적은 것이 그 글의 강점이었다.
키는 얼마나 오래 보관하는가
저장한 결과는 무한히 둘 수 없다. 보존 기간이 계약의 일부다.
- 너무 짧으면: 느린 재시도가 보존 기간을 넘겨 도착해 새 요청으로 처리된다. 이중 결제다.
- 너무 길면: 테이블이 처리량에 비례해 자란다.
기준은 “클라이언트의 최대 재시도 창”이다. 재시도 정책이 최대 24시간이면 보존은 그보다 길어야 한다. 이 둘이 문서에서 연결돼 있지 않은 API가 많다.
이 설명이 깨지는 곳
- 멱등성은 외부 부작용까지 막지 않는다. 저장은 한 번이어도 메일이 두 번 나갈 수 있다. 부작용마다 별도의 멱등 처리가 필요하다.
- 읽기 전용이 곧 안전은 아니다.
GET /export?token=...이 서버에서 파일을 만들고 과금한다면 그것은 안전하지 않다. - 타임아웃은 실패가 아니다. 멱등 키가 있어도 클라이언트가 “실패했으니 새 키로 다시”를 하면 무용지물이다. 타임아웃 뒤에는 재전송이 아니라 조회가 먼저다(ParityPay 3편).
Idempotency-Key는 아직 표준이 아니다. IETF 드래프트 단계이고 벤더마다 헤더 이름과 동작이 다르다. 연동 문서에서 직접 확인해야 한다.
무엇을 재면 확인되는가
- 같은 키로 N번 동시에 보내고 부작용이 정확히 1회인지 센다. 순차 재시도보다 동시 재시도가 더 어려운 조건이다.
- 키 저장을 “조회 후 없으면 삽입”으로 만들고 동시 요청을 보내면 무엇이 깨지는지.
- 보존 기간을 넘긴 재시도가 어떻게 처리되는지.
2번은 ParityPay 4편의 결함 E가 정확히 그 경우였다. 원장 계정을 “조회 후 없으면 삽입”으로 만들었다가 동시 요청에 유니크 제약 위반이 났고, INSERT ... ON CONFLICT DO NOTHING 후 조회로 바꿨다. 멱등 저장소는 이 방식이어야 “읽고 판단하는 사이”가 사라진다.
실무와의 접점
2021년 결제대행 연동에서 “같은 결제를 두 번 보내면 대행사는 어떻게 하는가”를 묻지 않았다. 우리 쪽 멱등 키가 있었는지, 대행사가 그것을 존중했는지 확인하지 않았고, 스케줄러가 한 대라 문제가 나지 않았을 뿐이다. 멱등성은 서버가 혼자 갖는 성질이 아니라 양쪽이 지키는 계약이라는 것이 그때 없던 관점이다.
정리
- 안전과 멱등은 다른 성질이고, 멱등은 응답이 아니라 서버 상태의 효과를 말한다.
- 메서드 이름이 성질을 주지 않는다.
PUT으로 만들어도 append하면 멱등하지 않다. POST를 멱등하게 만드는 것은 멱등 키이고, 끝난 키에는 최초 결과를 돌려주는 것이 핵심이다.- 키의 단위(요청 단위 대 엔티티 단위)가 “무엇이 한 번인가”를 정한다.
- 계약의 절반은 클라이언트가 지킨다. 같은 키 재사용, 호출 전 저장, 페이로드 불변, 성공 후 해제.
- 보존 기간은 클라이언트의 최대 재시도 창보다 길어야 한다.
댓글
아직 댓글이 없습니다