포스트

멱등한 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의 환불은 한 번”이 강제된다.

계약의 나머지 절반은 클라이언트가 지킨다

서버가 아무리 잘해도 다음이 지켜지지 않으면 멱등성은 없다.

  1. 재시도에 같은 키를 쓴다. 재시도마다 새 UUID를 만들면 서버는 서로 다른 의도로 볼 수밖에 없다.
  2. 호출 전에 키를 저장한다. 응답을 못 받고 프로세스가 죽어도 같은 키로 재시도하려면 키가 먼저 기록돼 있어야 한다.
  3. 페이로드를 바꾸지 않는다. 같은 키에 다른 금액이 오면 서버는 거부해야 하고(보통 422), 그 검증이 없으면 조용히 첫 결과를 돌려주며 금액 불일치를 숨긴다.
  4. 성공을 소비하면 키를 해제한다. 다음 요청이 옛 키를 재사용하지 않게.

Airbnb의 Orpheus 리뷰에서 이 다섯 항목이 2019년에 한 목록으로 정리돼 있는 것을 봤다. 서버 설계와 같은 무게로 클라이언트 책임을 적은 것이 그 글의 강점이었다.

키는 얼마나 오래 보관하는가

저장한 결과는 무한히 둘 수 없다. 보존 기간이 계약의 일부다.

  • 너무 짧으면: 느린 재시도가 보존 기간을 넘겨 도착해 새 요청으로 처리된다. 이중 결제다.
  • 너무 길면: 테이블이 처리량에 비례해 자란다.

기준은 “클라이언트의 최대 재시도 창”이다. 재시도 정책이 최대 24시간이면 보존은 그보다 길어야 한다. 이 둘이 문서에서 연결돼 있지 않은 API가 많다.

이 설명이 깨지는 곳

  • 멱등성은 외부 부작용까지 막지 않는다. 저장은 한 번이어도 메일이 두 번 나갈 수 있다. 부작용마다 별도의 멱등 처리가 필요하다.
  • 읽기 전용이 곧 안전은 아니다. GET /export?token=...이 서버에서 파일을 만들고 과금한다면 그것은 안전하지 않다.
  • 타임아웃은 실패가 아니다. 멱등 키가 있어도 클라이언트가 “실패했으니 새 키로 다시”를 하면 무용지물이다. 타임아웃 뒤에는 재전송이 아니라 조회가 먼저다(ParityPay 3편).
  • Idempotency-Key는 아직 표준이 아니다. IETF 드래프트 단계이고 벤더마다 헤더 이름과 동작이 다르다. 연동 문서에서 직접 확인해야 한다.

무엇을 재면 확인되는가

  1. 같은 키로 N번 동시에 보내고 부작용이 정확히 1회인지 센다. 순차 재시도보다 동시 재시도가 더 어려운 조건이다.
  2. 키 저장을 “조회 후 없으면 삽입”으로 만들고 동시 요청을 보내면 무엇이 깨지는지.
  3. 보존 기간을 넘긴 재시도가 어떻게 처리되는지.

2번은 ParityPay 4편의 결함 E가 정확히 그 경우였다. 원장 계정을 “조회 후 없으면 삽입”으로 만들었다가 동시 요청에 유니크 제약 위반이 났고, INSERT ... ON CONFLICT DO NOTHING 후 조회로 바꿨다. 멱등 저장소는 이 방식이어야 “읽고 판단하는 사이”가 사라진다.

실무와의 접점

2021년 결제대행 연동에서 “같은 결제를 두 번 보내면 대행사는 어떻게 하는가”를 묻지 않았다. 우리 쪽 멱등 키가 있었는지, 대행사가 그것을 존중했는지 확인하지 않았고, 스케줄러가 한 대라 문제가 나지 않았을 뿐이다. 멱등성은 서버가 혼자 갖는 성질이 아니라 양쪽이 지키는 계약이라는 것이 그때 없던 관점이다.

정리

  • 안전과 멱등은 다른 성질이고, 멱등은 응답이 아니라 서버 상태의 효과를 말한다.
  • 메서드 이름이 성질을 주지 않는다. PUT으로 만들어도 append하면 멱등하지 않다.
  • POST를 멱등하게 만드는 것은 멱등 키이고, 끝난 키에는 최초 결과를 돌려주는 것이 핵심이다.
  • 키의 단위(요청 단위 대 엔티티 단위)가 “무엇이 한 번인가”를 정한다.
  • 계약의 절반은 클라이언트가 지킨다. 같은 키 재사용, 호출 전 저장, 페이로드 불변, 성공 후 해제.
  • 보존 기간은 클라이언트의 최대 재시도 창보다 길어야 한다.

참고

결제·정산 정합성 장애 대응과 관측 아키텍처와 마이그레이션
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다