← Blog
Computer Science / 웹 / HTTP

HTTP 메서드 — 안전성, 멱등성, 그리고 중간 참여자들과의 약속

메서드를 다루는 글은 대개 "GET은 조회, POST는 생성, PUT은 수정, DELETE는 삭제"로 시작한다. 틀린 말은 아닌데, 이 설명은 정작 가장 먼저 떠오르는 질문에 답하지 못한다.

서버는 결국 내가 짠 코드대로 동작하는데, 왜 그 구분을 지켜야 하는가?

GET /posts/1/delete라는 경로를 만들고 그 안에서 글을 지워도 HTTP는 아무것도 막지 않는다. 요청은 정상적으로 도착하고, 서버는 지우고, 200이 나간다. 프로토콜 어디에도 "GET 핸들러에서 삭제 쿼리를 실행하면 에러"라는 규칙은 없다.

그런데도 이 구분은 지켜야 한다. 이유가 서버 쪽에 있지 않기 때문이다.

메서드는 서버에 대한 명령이 아니라, 중간 참여자들에게 하는 약속이다.

브라우저·프록시·CDN·크롤러·로드밸런서·API 게이트웨이는 내 요청의 내용을 모른다. 바디를 열어보지도 않고, 그 경로가 무슨 일을 하는지도 모른다. 그들이 아는 것은 메서드 한 단어뿐이고, 그 한 단어로 캐시할지·재시도할지·미리 받아둘지·사용자에게 경고를 띄울지를 판단한다.

그래서 메서드를 잘못 쓰면 내 서버만 조금 이상해지고 마는 게 아니다. 나를 믿고 자기 일을 하는 생태계 전체가 오작동한다. 이 관점이 글 전체의 척추다.

순서는 이렇다 — 메서드를 가르는 것은 용도가 아니라 세 가지 속성이라는 것(정의) → 그 속성이 왜 필요한가(이유) → 경계에서 무엇을 고를 것인가(판단).

메서드가 요청 메시지의 시작 줄 첫 토큰, 즉 서버가 요청을 해석할 때 가장 먼저 읽는 한 단어라는 것은 HTTP — 무엇을 정하는 규약인가에서 다뤘다. 이 글은 그 한 칸에 무엇을 넣을지 고르는 기준에 관한 것이다.


1. 정의 — 용도가 아니라 세 가지 속성으로 가른다

1-1. 메서드를 가르는 축은 세 개다

스펙이 메서드에 부여하는 성질은 "무슨 일을 하는가"가 아니라 아래 세 가지다.

속성한 문장
안전 (Safe)서버의 상태를 바꾸지 않는다보내도 아무 일도 안 일어난다
멱등 (Idempotent)여러 번 보내도 최종 상태가 한 번 보낸 것과 같다여러 번 와도 괜찮다
캐시 가능 (Cacheable)응답을 저장해두었다가 재사용해도 된다다음번엔 안 물어봐도 된다

앞의 두 속성 사이에는 포함 관계가 있다.

안전하면 반드시 멱등이다.

아무것도 바꾸지 않는 것을 백 번 반복해도 여전히 아무것도 바뀌지 않으니까. 논리적으로 자동이다.

역은 성립하지 않는다. DELETE /posts/42는 42번을 지우므로 안전하지 않지만, 몇 번을 보내도 최종 상태는 "42번이 없다"로 동일하므로 멱등이다. 그래서 멱등은 안전보다 넓은 범주다.

<!-- IMAGE: 위쪽은 안전 ⊂ 멱등 포함 관계를 동심원으로 — 안쪽 원(안전): GET·HEAD·OPTIONS, 바깥 원(멱등이되 안전은 아님): PUT·DELETE, 원 밖(보장 없음): POST·PATCH. 아래쪽은 메서드별 안전·멱등·캐시 가능 여부 표와 각 메서드의 한 줄 의미 -->

메서드의 세 가지 속성

정리하면 세 겹이 된다.

  • 안전 + 멱등 + 캐시 가능GET, HEAD
  • 안전하지만 캐시하지 않음OPTIONS (프리플라이트에 쓰이는 그 메서드다. 언제 날아가는지는 Simple Request vs Preflight에서 다룬다)
  • 안전하지 않지만 멱등PUT, DELETE
  • 아무 보장 없음POST, PATCH

캐시 가능성이 실제로 어떤 헤더로 제어되는지는 브라우저 캐싱에서 다뤘다. 여기서는 "메서드가 그 판단의 입구"라는 것까지만 본다.

1-2. 오해 정정 ① — 멱등성은 "응답이 같다"가 아니다

가장 자주 어긋나는 지점이 여기다. 멱등성을 **"같은 요청을 반복하면 같은 응답이 온다"**로 이해하면 곧바로 모순에 부딪힌다.

DELETE /posts/42를 두 번 보내보자.

DELETE /posts/42 HTTP/1.1
Host: api.example.com
HTTP/1.1 204 No Content     # 첫 번째 — 지웠다
HTTP/1.1 404 Not Found      # 두 번째 — 지울 게 없다

응답이 다르다. 그런데 DELETE는 멱등이 맞다. 모순이 아니라, 멱등성이 응답에 대한 이야기가 아니기 때문이다.

멱등성은 응답이 아니라 서버의 최종 상태에 대한 약속이다.

한 번 보냈든 다섯 번 보냈든, 끝나고 나면 "42번 글이 존재하지 않는다"는 상태로 같다. 그것만이 멱등성이 보장하는 전부다. 응답 코드가 달라지는 것도, 응답 바디에 매번 다른 타임스탬프가 실리는 것도 멱등성과는 무관하다.

이 구분이 중요한 이유는 뒤에서 드러난다. 재시도가 안전한지 판단할 때 알고 싶은 것은 "같은 답을 받는가"가 아니라 "두 번 처리돼도 괜찮은가"이기 때문이다.

1-3. 그런데 이 속성들을 강제하는 것은 아무것도 없다

여기서 처음의 질문으로 돌아온다. HTTP는 이 속성들을 검사하지 않는다. GET 핸들러 안에서 데이터를 지워도 프로토콜은 침묵한다. 컴파일 에러도, 런타임 경고도, 프록시의 거부도 없다.

그러니 이 속성들은 강제되는 제약이 아니라 선언된 약속이다. 그리고 약속의 수신자는 서버가 아니다. 서버는 내 코드니까 애초에 약속이 필요 없다.

수신자는 요청의 내용을 볼 수 없는 중간 참여자들이다. 그들에게 메서드는 이렇게 읽힌다.

메서드를 보고이렇게 판단한다
GET마음대로 보내도 된다. 미리 받아둬도 되고, 캐시해도 되고, 크롤링해도 된다
PUT / DELETE함부로 보내면 안 되지만, 응답이 없으면 다시 보내도 된다
POST / PATCH보내는 것도 조심해야 하고, 다시 보내서도 안 된다

이 판단은 요청 내용을 한 글자도 보지 않고 내려진다. 그래서 약속을 어기면 대가는 내 서버가 아니라 약속을 믿고 행동한 쪽에서 터진다.


2. 이유 — 약속을 어기면 무슨 일이 일어나는가

2-1. 안전성 — 아무도 공격하지 않아도 데이터가 사라진다

"GET으로 삭제하면 안 된다"는 말에 가장 흔히 붙는 이유가 있다. "주소만 알면 공격당하니까."

이 설명은 초점이 어긋나 있다. 그 이야기는 뒤에서 다시 하고, 실제로 벌어진 일부터 보자.

2005년, 구글 Web Accelerator

페이지 로딩을 빠르게 해준다는 브라우저 확장이었다. 원리는 단순했다. 사용자가 지금 보고 있는 페이지의 링크들을 미리 요청해서 받아두는 것이다. 사용자가 실제로 클릭하면 이미 받아둔 것을 보여주니 즉시 열린다.

출시 직후, 여러 웹 서비스에서 데이터가 대량으로 사라지기 시작했다. 경위는 이랬다.

  • 관리자 페이지에 "삭제" 링크가 있었다
  • 그 링크는 <a href="/admin/posts/42/delete">, 즉 GET이었다
  • Web Accelerator는 페이지의 모든 링크를 미리 눌러봤다
  • 관리자가 목록 페이지를 여는 순간, 목록에 있던 항목이 전부 지워졌다

구글은 이 문제를 포함한 여러 이유로 결국 이 서비스를 접었다.

여기서 못 박아야 할 결론이 있다.

아무도 잘못하지 않았다.

Web Accelerator는 스펙대로 행동했다. GET은 안전하다고 선언된 메서드이고, 안전한 메서드를 미리 보내는 것은 완전히 정당한 최적화다. 크롤러도 마찬가지다. 검색 엔진이 사이트의 GET 링크를 전부 따라가는 것은 그러라고 만든 규칙을 따른 것이다.

약속을 어긴 쪽은 삭제 기능을 GET으로 만든 개발자다. 그리고 이 사고에서 눈여겨볼 점은, 그 개발자의 서버 코드에 버그가 하나도 없었다는 것이다. 삭제 로직은 완벽하게 동작했다. 문제는 "이건 안전한 요청이다"라고 잘못 선언한 것 하나뿐이었다.

같은 부류의 참여자는 지금도 계속 늘어난다.

  • 브라우저의 프리페치·프리렌더 — 사용자가 클릭할 것 같은 링크를 미리 요청한다
  • 메신저·SNS의 링크 미리보기 — 채팅방에 URL을 붙여넣는 순간 그 주소로 GET이 날아간다
  • 검색 엔진 크롤러 — 사이트의 모든 GET 링크를 순회한다
  • 보안 스캐너·모니터링 봇 — 발견한 엔드포인트를 주기적으로 찔러본다
  • 브라우저의 뒤로 가기·새로고침 — GET이면 조용히 다시 보내고, POST면 "양식을 다시 제출하시겠습니까?" 경고를 띄운다

마지막 항목이 이 약속의 성격을 잘 보여준다. 브라우저는 경고를 띄울지 말지를 메서드 하나로 결정한다. 결제 요청을 GET으로 만들었다면, 사용자가 F5를 눌렀을 때 브라우저는 아무 경고 없이 결제를 한 번 더 시도한다.

오해 정정 ② — 진짜 위험은 공격이 아니다

이제 앞의 "공격당하니까"로 돌아가자. 이 설명이 약한 이유는 간단하다.

공격자는 어차피 POST도 위조한다.

메서드를 POST로 바꾸는 것은 공격 방어책이 아니다. 사이트 간 요청 위조는 POST를 대상으로도 성립하며, 그 방어는 토큰이나 SameSite 쿠키처럼 별도의 장치가 담당한다. 메서드를 바꿔서 막을 수 있는 문제가 아니다. (공격 원리와 방어는 XSS vs CSRF에서 다룬다.)

GET을 안전하지 않게 만들었을 때의 진짜 위험은 이것이다.

아무도 공격하지 않아도 데이터가 사라진다.

공격자는 없어도 된다. 규칙대로 성실하게 자기 일을 하는 크롤러와 프리페처와 미리보기 봇, 즉 선의의 참여자들만으로 충분하다. 그들은 내 사이트를 해치려는 게 아니라 내가 한 선언을 믿었을 뿐이다.

2-2. 멱등성 — 재시도는 선택이 아니라 필연이다

안전성 다음은 멱등성이다. 여기서도 같은 질문이 나온다. 같은 요청이 여러 번 도달하는 일이 실제로 그렇게 흔한가?

흔하다. 그리고 그 이유는 네트워크의 근본적인 성질에서 나온다.

출발점: 클라이언트가 구분할 수 없는 두 상황

주문 생성 요청을 보냈는데 타임아웃이 났다고 하자. 지금 서버 쪽에서는 둘 중 하나가 일어난 것이다.

  1. 요청이 서버에 닿지 못했다 — 아무 일도 일어나지 않았다
  2. 처리는 끝났는데 응답이 유실됐다 — 주문은 이미 만들어졌다

문제는 이것이다.

클라이언트 입장에서 두 상황은 완전히 똑같이 보이는데, 해야 할 대응은 정반대다.

①이면 다시 보내야 하고, ②면 절대 다시 보내면 안 된다. 그런데 구분할 방법이 없다. 응답이 없다는 사실만으로는 어느 쪽인지 알 수 없기 때문이다. 타임아웃은 "실패했다"는 정보가 아니라 "모르겠다"는 정보다.

<!-- IMAGE: 위쪽은 타임아웃의 두 시나리오 — ① 요청이 서버에 닿지 못한 경우와 ② 서버가 처리를 마쳤으나 응답이 유실된 경우를 나란히 놓고, 클라이언트가 보는 것은 양쪽 모두 "응답 없음"으로 동일함을 강조. 아래쪽은 같은 Idempotency-Key로 재시도했을 때 서버가 키 저장소에서 첫 응답을 찾아 재처리 없이 그대로 돌려주는 흐름 -->

타임아웃의 불확실성과 Idempotency-Key

이 불확실성 때문에 시스템 곳곳에서 자동 재시도가 일어난다

구분할 수 없으니 대부분의 구성 요소는 "일단 다시 보낸다"를 기본 전략으로 삼는다. 재시도하는 주체를 나열해보면 생각보다 많다.

  • HTTP 클라이언트 라이브러리 — 연결 실패나 타임아웃 시 자동 재시도한다. 그리고 보통 멱등한 메서드만 재시도하도록 구현돼 있다. 그 판단 기준이 정확히 메서드 한 단어다
  • 로드밸런서·리버스 프록시 — 백엔드가 응답하지 않으면 다른 인스턴스로 요청을 넘긴다. 앞의 서버가 이미 처리를 끝냈을 수도 있는데
  • 서비스 메시·API 게이트웨이 — 재시도 정책이 인프라 레벨에 설정돼 있다. 애플리케이션 코드는 재시도가 일어나는지도 모른다
  • 모바일 환경 — 셀룰러와 와이파이를 오가며 연결이 끊기고, 앱이 다시 보낸다
  • 메시지 큐의 at-least-once 전달 — "최소 한 번"을 보장할 뿐 중복을 막지는 않는다. 컨슈머는 같은 메시지를 두 번 받을 수 있다 (메시지 큐)
  • 사용자 — 응답이 느리면 새로고침을 누르고, 버튼을 한 번 더 누른다

앞의 다섯은 내가 코드를 아무리 잘 짜도 통제할 수 없는 영역이다. 인프라 설정이거나, 남의 라이브러리이거나, 사용자의 손가락이다.

그래서 결론은 이렇게 된다.

멱등성은 이론적 미덕이 아니라, 분산 시스템에서 중복 요청이 필연이기 때문에 필요한 안전장치다.

"여러 번 와도 괜찮다"가 보장돼야 위의 모든 참여자가 마음 놓고 재시도할 수 있다. 그리고 그들은 그 보장 여부를 메서드를 보고 판단한다. PUT으로 선언해두면 재시도해주고, POST로 선언해두면 재시도를 포기하고 에러를 위로 올린다.

여기서 자연스럽게 다음 질문이 나온다. 그러면 재시도가 위험한 요청들, 즉 결제나 주문 같은 POST는 어떻게 하는가? 그건 3장에서 다룬다.


3. 판단 — 경계에서 무엇을 고르는가

3-1. POST vs PUT — URI를 누가 정하는가

둘 다 "새 리소스를 만든다"에 쓸 수 있다. 그런데 하나는 멱등이 아니고 하나는 멱등이다. 무엇이 갈랐을까.

POST — 어디에 생길지 클라이언트가 모른다

POST /orders HTTP/1.1
Host: api.example.com
Content-Type: application/json

{"item":"keyboard","qty":1}
HTTP/1.1 201 Created
Location: /orders/42

POST /orders는 "이 컬렉션에 하나 추가해줘"라는 요청이다. 어느 주소에 생길지는 서버가 정한다. 그래서 서버가 만들고 나서 Location 헤더로 "여기에 만들었다"고 알려준다.

이제 이 요청을 두 번 보내면 어떻게 되는가. 서버는 두 번째 요청도 "하나 추가해줘"로 읽는다. 그래서 42번과 43번이 각각 생긴다. 최종 상태가 다르다 → 멱등이 아니다.

PUT — 클라이언트가 주소를 지정한다

PUT /orders/42 HTTP/1.1
Host: api.example.com
Content-Type: application/json

{"item":"keyboard","qty":1}

PUT /orders/42는 "42번을 이 내용으로 만들어라"다. 없으면 만들고, 있으면 덮어쓴다. 백 번을 보내도 42번의 최종 상태는 같다. → 멱등이다.

정리하면 이렇다.

같은 "생성"이라도, URI를 누가 정하느냐가 멱등성을 가른다.

서버가 정하면 매 요청이 새로운 자리를 하나씩 만들어내므로 멱등일 수 없고, 클라이언트가 정하면 목적지가 고정되므로 몇 번을 보내도 그 자리 하나만 바뀐다.

201Location이 왜 이 상황의 답인지, 200과는 무엇이 다른지는 HTTP 상태 코드에서 다룬다.

오해 정정 ③ — POST는 "생성"이 아니다

POST를 "생성 메서드"로 외우면 곧 설명되지 않는 것들이 나온다. 로그인은 무엇을 생성하는가? 조건이 복잡해서 POST로 보내는 조회는? 이메일 발송이나 배치 작업 실행은?

스펙에서 POST의 정의는 훨씬 넓다. "이 리소스에 대해, 그 리소스의 의미에 따라 처리해달라" 정도다. 즉 다른 메서드로 표현되지 않는 모든 처리를 담는 자리이고, 생성은 그중 가장 흔한 사례일 뿐이다.

그래서 POST의 성격은 이렇게 잡는 편이 정확하다.

POST는 "생성"이 아니라 "아무 보장도 하지 않겠다"는 선언이다.

안전하지도 않고 멱등하지도 않다고 미리 밝혀두는 것. 그래서 중간 참여자들은 POST를 보면 캐시하지 않고, 프리페치하지 않고, 재시도하지 않고, 새로고침할 때 사용자에게 물어본다. 가장 제약이 많은 메서드이기 때문에, 무슨 일을 하든 안전하게 담을 수 있는 그릇이 된다.

3-2. PUT vs PATCH — 치환인가 부분 변경인가

PUT은 보내지 않은 필드를 지운다

가장 자주 사고가 나는 지점이다. PUT은 "수정"이 아니라 **"치환"**이다. "42번을 이걸로 만들어라"이므로, 보내지 않은 것은 없는 것이 된다.

원래 상태가 이렇다고 하자.

{ "name": "keyboard", "price": 39000, "stock": 12 }

여기서 이름만 바꾸려고 이렇게 보내면,

PUT /products/7 HTTP/1.1
Host: api.example.com
Content-Type: application/json

{"name":"mouse"}

결과는 이렇게 된다.

{ "name": "mouse" }
// price와 stock이 사라졌다. 버그가 아니라 PUT의 정의대로 동작한 것이다

같은 바디를 PATCH로 보내면 다르다.

PATCH /products/7 HTTP/1.1
Host: api.example.com
Content-Type: application/json

{"name":"mouse"}
{ "name": "mouse", "price": 39000, "stock": 12 }
// 보낸 것만 바뀌고 나머지는 그대로다

실무 경고 — 현실의 API 대부분은 PUT을 PATCH처럼 쓴다

스펙이 이렇게 명확한데도, 실제로 만나는 API 상당수는 PUT을 받아놓고 보내지 않은 필드를 그대로 둔다. 부분 수정 의미로 PUT을 쓰는 것이다. 스펙 위반이지만 워낙 흔해서 관행에 가깝다.

진짜 문제는 이 관행이 일관되지 않다는 것이다. 어떤 API는 스펙대로 치환하고, 어떤 API는 병합한다. 그래서 남의 API에 PUT을 보낼 때는 반드시 문서를 확인해야 한다. 확인하지 않고 일부 필드만 보내면, 스펙을 지키는 쪽 API에서는 나머지 데이터가 통째로 날아간다.

반대로 내가 API를 만든다면 선택은 이렇다. 부분 수정을 제공하고 싶으면 PATCH로 노출하고, PUT은 진짜 치환일 때만 쓴다. 관행을 따라 PUT을 병합으로 구현하기로 했다면 문서에 그렇다고 명시해야 한다. 상대는 문서를 안 보면 스펙대로 기대할 것이기 때문이다.

오해 정정 ④ — PATCH는 본질적으로 멱등하지 않은 것이 아니다

PATCH를 설명할 때 "PATCH는 멱등하지 않다"고 단정하는 경우가 많은데, 정확하지 않다. 스펙이 말하는 것은 **"멱등하지 않을 수 있다"**이고, 실제 멱등 여부는 바디를 어떻게 쓰느냐에 달렸다.

멱등한 PATCH — 절대값을 준다

PATCH /products/7 HTTP/1.1
Content-Type: application/merge-patch+json

{"stock":10}

몇 번을 보내도 재고는 10이다. 멱등하다.

멱등하지 않은 PATCH — 현재 상태를 기준으로 한 상대적 변경

PATCH /products/7 HTTP/1.1
Content-Type: application/json

{"op":"increment","field":"stock","value":-1}

한 번이면 -1, 세 번이면 -3이다. 최종 상태가 횟수에 의존하므로 멱등이 아니다.

갈림길은 명확하다. 바디가 "결과를 무엇으로 만들지"를 말하면 멱등하고, "현재 값에서 무엇을 할지"를 말하면 멱등하지 않다.

그런데 여기에 진짜 중요한 마무리가 붙는다.

내 PATCH가 실제로 멱등한지는 나만 안다.

프로토콜은 메서드 이름만 본다. 앞의 두 요청은 중간 참여자에게 똑같이 "PATCH"일 뿐이고, 바디를 열어보고 절대값인지 상대값인지 판별해주는 프록시는 없다. 그래서 참여자들은 최악을 가정하고 재시도하지 않는다. 실제로는 멱등한데도 재시도의 이점을 받지 못하는 것이다.

여기서 판단이 나온다.

  • 멱등하게 만들 수 있는 변경이라면 — 치환 의미가 맞다면 PUT을 쓰거나, PATCH를 쓰되 아래 3-3의 키로 직접 보장하는 편이 낫다
  • 본질적으로 상대적인 변경이라면 — PATCH가 맞다. 다만 재시도가 위험하다는 것을 알고 설계해야 한다

PATCH 바디의 두 표준

PATCH는 바디 형식을 스펙이 정해두지 않았다. 그래서 Content-Type으로 형식을 밝히는 표준이 둘 있다.

형식특징
application/merge-patch+json바꿀 필드만 담은 JSON. null은 "이 필드를 삭제하라"는 뜻이라, null 자체를 저장해야 할 때 표현할 방법이 없다
application/json-patch+jsonop/path/value 연산의 배열. 배열 원소 추가, 이동, 조건부 적용까지 표현할 수 있다

JSON Patch에는 눈여겨볼 연산이 하나 있다. test다.

[
  { "op": "test",    "path": "/version", "value": 7  },
  { "op": "replace", "path": "/stock",   "value": 10 }
]

test가 실패하면 뒤의 연산들이 전부 적용되지 않는다. "내가 알고 있는 상태가 아직 그대로일 때만 바꿔라"는 뜻이고, 이것은 낙관적 락과 정확히 같은 발상이다. 잠그지 않고 진행하되, 반영 직전에 전제가 깨졌는지 확인한다.

3-3. POST의 멱등성은 설계로 확보한다 — Idempotency-Key

지금까지의 논의를 뒤집어 보면 불편한 사실이 남는다.

정작 가장 위험한 것이 POST다.

결제, 주문, 송금, 포인트 차감. 두 번 실행되면 실제 돈이 두 번 나가는 요청들이 전부 POST이고, 전부 멱등이 아니다. 2-2절에서 본 참여자들은 POST를 재시도하지 않도록 돼 있지만, 그건 자동 재시도만의 이야기다. 사용자의 새로고침과 버튼 재클릭, 앱의 자체 재시도, 큐의 중복 전달은 그대로 남는다.

프로토콜이 보장해주지 않으니 애플리케이션이 직접 보장하는 수밖에 없다. 그 방법이 Idempotency-Key다.

동작

  1. 클라이언트가 요청마다 고유한 키를 만들어 헤더에 붙인다
  2. 서버는 그 키를 저장소에 기록해두고 처리한다
  3. 같은 키가 다시 오면 재처리하지 않고, 저장해둔 첫 응답을 그대로 돌려준다
POST /payments HTTP/1.1
Host: api.example.com
Idempotency-Key: 8f3d9a1c-4b21-4e7a-9c55-1d2e3f4a5b6c
Content-Type: application/json

{"orderId":"ORD-20260731-0042","amount":39000}

서버 쪽 처리 순서를 코드로 보면 이렇다.

type Result = { status: number; body: unknown };
type Entry =
  | { state: 'in_progress'; fingerprint: string }
  | { state: 'done'; fingerprint: string; result: Result };

async function createPayment(req: Request, res: Response) {
  const key = req.header('Idempotency-Key');
  if (!key) {
    return res.status(400).json({ code: 'IDEMPOTENCY_KEY_REQUIRED' });
  }

  // 같은 키로 다른 내용이 오는 것을 잡아내기 위해 바디의 지문을 함께 보관한다
  const fingerprint = sha256(JSON.stringify(req.body));

  // ① 이미 끝난 키인가 — 재처리하지 않고 저장해둔 첫 응답을 그대로 돌려준다
  const found: Entry | null = await keyStore.get(key);
  if (found?.state === 'done') {
    if (found.fingerprint !== fingerprint) {
      return res.status(422).json({ code: 'KEY_REUSED_WITH_DIFFERENT_BODY' });
    }
    return res.status(found.result.status).json(found.result.body);
  }

  // ② 키를 선점한다 — 원자적 삽입이라 동시에 도착한 둘 중 하나만 성공한다
  const acquired = await keyStore.insertIfAbsent(key, {
    state: 'in_progress',
    fingerprint,
    ttlSec: 24 * 60 * 60,
  });
  if (!acquired) {
    // 첫 요청이 아직 처리 중이다. 결과를 모르므로 응답을 지어내지 않는다
    return res.status(409).json({ code: 'REQUEST_IN_PROGRESS' });
  }

  try {
    // ③ 실제 처리
    const payment = await paymentService.charge(req.body);
    const result: Result = {
      status: 201,
      body: { paymentId: payment.id, orderId: req.body.orderId },
    };

    // ④ 결과를 키에 붙여 저장한다 — ③과 같은 트랜잭션에 묶어야
    //    "결제는 됐는데 키 저장이 실패해 재시도 때 또 결제되는" 틈이 없다
    await keyStore.complete(key, result);

    return res.status(result.status).json(result.body);
  } catch (e) {
    // 처리에 실패했으면 키를 풀어준다. 안 그러면 정당한 재시도가 영원히 막힌다
    await keyStore.release(key);
    throw e;
  }
}

가장 자주 틀리는 지점 — 재시도할 때 키를 새로 만들면 안 된다

키를 요청 직전에 생성하도록 짜면 재시도할 때마다 새 키가 붙는다. 그러면 서버 입장에서는 매번 처음 보는 요청이라 전부 새로 처리한다. 키를 붙였는데도 중복 결제가 나는 전형적인 경우다.

같은 논리적 요청은 재시도 내내 같은 키를 유지해야 한다.

그래서 보통 사용자가 결제 화면에 진입하는 시점에 한 번 생성해두고, 그 화면을 벗어나기 전까지의 모든 시도가 그것을 재사용한다. 키의 수명은 "네트워크 요청"이 아니라 **"사용자가 하려는 하나의 행위"**에 맞춰야 한다.

몇 가지 세부

  • 동시 도착 — 첫 요청이 처리 중일 때 같은 키가 또 오면 409를 준다. 이때 첫 요청의 결과를 아직 모르므로 성공 응답을 지어내면 안 된다. 클라이언트가 잠시 후 다시 물어보게 하는 것이 맞다
  • 키의 TTL — 영구 보관할 이유가 없다. 재시도가 일어날 만한 시간(보통 24시간 정도)만 유지하고 만료시킨다. 저장소가 무한히 커지는 것을 막는다
  • 헤더 이름 — 주요 결제 API들이 하나같이 Idempotency-Key를 쓰면서 사실상 표준이 됐다. 공식 표준으로 등록된 헤더는 아니지만, 이 이름을 벗어날 이유는 없다

이 절의 요점을 한 줄로 줄이면 이렇다.

멱등성은 메서드가 주는 것이 아니라, 필요하면 설계로 만들어내는 것이다.

3-4. GET vs POST의 진짜 차이

마지막으로 가장 기본적인 대비로 돌아간다. 흔한 설명은 이렇다. "GET은 데이터를 URL에, POST는 바디에 담는다."

맞는 말이다. 다만 결과일 뿐 이유가 아니다. 이 설명만 알고 있으면 "그럼 데이터가 길면 POST를 쓰면 되는 건가" 같은 결론으로 새기 쉽다.

진짜 차이는 안전성이다.

GET은 "이 요청은 아무것도 바꾸지 않는다"는 선언이고, POST는 "아무것도 보장하지 않는다"는 선언이다.

그래서 브라우저·프록시·CDN·크롤러가 GET은 마음대로 보내도 된다고 믿는다. URL에 담기는 것은 그 믿음의 결과다. 안전한 요청이니까 주소창에 노출돼도, 북마크돼도, 서버 접근 로그에 남아도, 링크로 공유돼도 괜찮다는 전제가 먼저 있는 것이다.

거꾸로 보면 이 전제가 판단 기준을 준다. URL에 남아서 곤란한 것(비밀번호, 토큰, 개인정보)을 GET 쿼리에 실으면 안 되는 이유도 여기서 나온다. 안전한 요청은 여기저기 기록되고 공유되는 것을 전제로 하기 때문이다.

GET에 바디를 실으면 안 되는가

스펙상 GET 요청에 바디를 넣는 것 자체가 금지되진 않는다. 다만 의미가 정의돼 있지 않다. 그래서 서버·프록시·클라이언트 라이브러리마다 처리가 제각각이다. 어떤 쪽은 무시하고, 어떤 쪽은 요청을 거부하며, 캐시는 바디를 캐시 키에 넣지 않아 서로 다른 요청을 같은 것으로 취급하기도 한다. 사실상 쓸 수 없다고 보는 편이 맞다.

조회 조건이 복잡해서 URL에 담기 어려운 경우가 실제로 생기는데, 이때는 대체로 POST로 보낸다. 안전한 요청을 안전하지 않은 메서드로 보내는 것이므로 캐시와 재시도의 이점을 포기하는 셈이고, 그 대가를 알고 하는 선택이어야 한다.

3-5. 정리

이 글을 다섯 줄로 압축하면 이렇다.

  1. 메서드를 가르는 것은 용도가 아니라 안전·멱등·캐시 가능이라는 세 속성이다. 안전하면 반드시 멱등이고, 역은 아니다.
  2. 멱등성은 응답이 같다는 뜻이 아니라 최종 상태가 같다는 뜻이다. DELETE는 204 다음에 404를 주지만 여전히 멱등이다.
  3. 안전성을 어기면 공격자가 없어도 데이터가 사라진다. 규칙대로 행동하는 프리페처와 크롤러만으로 충분하다.
  4. 멱등성이 필요한 이유는 타임아웃을 구분할 수 없기 때문이다. 그 불확실성 때문에 시스템 곳곳이 재시도하고, 그 판단 기준이 메서드다.
  5. 프로토콜이 보장해주지 않는 멱등성은 설계로 만든다. Idempotency-Key가 그 자리다.

처음의 질문에 답하면 이렇게 된다.

메서드를 지켜야 하는 이유는 서버가 강제하기 때문이 아니라, 요청의 내용을 볼 수 없는 참여자들이 그 한 단어만 보고 행동하기 때문이다. 메서드는 내 서버에 내리는 명령이 아니라, 나를 대신해 판단할 쪽에 남기는 유일한 정보다.


관련 글