메서드는 계약이다

HTTP 메서드는 단순히 요청 종류를 구분하는 이름표가 아니라, 클라이언트와 서버 사이의 약속이다. GET을 보내면 서버는 자원을 조회만 하고 아무것도 바꾸지 않는다고 약속하고, DELETE를 보내면 그 자원이 사라진다고 약속한다. 이 약속이 지켜져야 브라우저의 뒤로가기, 캐시 서버의 재사용, 프록시의 재시도 같은 주변 장치들이 안심하고 동작한다. 이번 편에서는 각 메서드가 실제로 어떤 요청과 응답을 주고받는지, 왜 그런 규칙이 붙었는지를 하나씩 짚는다.


메서드 선택을 틀리면 눈에 보이는 오류가 나지 않을 때가 많아서 골치 아프다. 조회 화면에 POST를 써도 화면은 똑같이 뜨고, 삭제 링크를 GET으로 걸어도 클릭하면 똑같이 지워진다. 문제는 몇 달 뒤 캐시 서버를 도입하거나 검색엔진 크롤러가 그 링크를 타고 들어올 때 터진다.


GET이 짊어진 제약

GET은 조회를 뜻하지만, 그 이상으로 캐시가 가능한 유일한 기본 메서드라는 성격이 붙는다. 브라우저와 CDN은 GET 요청과 응답을 URL 단위로 저장해두고 똑같은 URL이 다시 오면 서버를 거치지 않고 저장된 응답을 그대로 돌려준다. POST나 PATCH는 원칙적으로 이 대상에서 빠진다. 자주 조회되고 결과가 잘 안 바뀌는 데이터일수록 GET으로 노출해야 캐시의 이점을 받는다.


GET은 본문을 쓰지 않고 조건을 모두 URL에 얹기 때문에 길이 제약을 받는다. 표준 자체에는 길이 상한이 없지만, 브라우저와 서버, 프록시 상당수가 실무에서 2000자 안팎을 넘기면 요청을 자르거나 거부한다. 검색 조건이 여러 개 겹치는 화면에서 필터 값을 쿼리스트링에 욱여넣다가 이 한계에 걸리는 경우가 실제로 있다.


GET /search?category=laptop&price_min=500000&price_max=1500000&brand=samsung&brand=lg&sort=price_asc HTTP/1.1


또 하나, GET으로 보낸 값은 서버 로그와 브라우저 히스토리, 프록시 접근 기록에 그대로 남는다. 로그인 토큰처럼 노출되면 안 되는 값을 쿼리스트링에 얹으면 그 값이 여러 로그 파일에 평문으로 흩어진다. 민감한 값은 GET의 URL이 아니라 요청 본문이나 헤더로 옮겨야 한다.


POST와 재시도 문제

POST는 새 자원을 만드는 메서드다. 성공하면 상태코드 201과 함께 새로 생긴 자원의 위치를 Location 헤더에 담아 돌려주는 것이 정석이다.


POST /orders HTTP/1.1
Content-Type: application/json

{"product_id": 42, "quantity": 2}

HTTP/1.1 201 Created
Location: /orders/9081


문제는 응답을 못 받았을 때다. 결제 요청을 보냈는데 네트워크가 끊겨 응답이 안 오면, 클라이언트는 요청이 서버에 도달했는지조차 알 방법이 없다. 안전하게 재전송하려면 서버가 같은 요청인지 구분할 수 있어야 하는데, 이때 쓰는 값이 Idempotency-Key 헤더다. 요청을 만들 때 고유한 키를 발급해 헤더에 실어 보내면, 서버는 같은 키로 두 번째 요청이 들어와도 처음 처리한 결과를 그대로 돌려주고 주문을 다시 만들지 않는다.


POST /orders HTTP/1.1
Idempotency-Key: 3f29a7c1-88e2
Content-Type: application/json


결제, 송금처럼 중복되면 손해가 나는 POST일수록 이 키를 넣는 습관이 필요하다. 조회 통계를 남기는 정도의 POST라면 이 장치까지 붙이지 않아도 큰 문제가 되지 않는다.


PUT과 PATCH가 나뉘는 지점

PUT은 자원 전체를 통째로 교체한다. 요청 본문에 없는 필드는 서버가 비어 있는 값으로 간주해 지워버린다. 게시글 제목만 고치려고 PUT을 쓰면서 태그 필드를 빼먹으면, 그 필드가 통째로 사라지는 사고가 난다.


PUT /articles/42 HTTP/1.1
Content-Type: application/json

{"title": "수정된 제목", "body": "..", "tags": []}


PATCH는 바뀐 부분만 보낸다는 점에서 다르지만, 그 형식이 표준으로 고정되어 있지는 않다. 실무에서 가장 흔한 방식은 바뀐 필드만 담은 JSON을 보내고 서버가 기존 값 위에 덮어쓰는 병합 방식이다.


PATCH /articles/42 HTTP/1.1
Content-Type: application/json

{"title": "수정된 제목"}


이 방식은 필드를 지우고 싶을 때 애매해진다. 값을 null로 보내면 지우라는 뜻인지, 안 보낸 필드로 취급해 무시하라는 뜻인지 API마다 해석이 갈린다. 이 모호함을 없애려고 만든 표준이 JSON Patch다. 필드와 값을 나열하는 대신 연산 자체를 배열로 적는다.


PATCH /articles/42 HTTP/1.1
Content-Type: application/json-patch+json

[{"op": "replace", "path": "/title", "value": "수정된 제목"}, {"op": "remove", "path": "/draft_note"}]


어느 방식을 쓰든 팀 안에서는 하나로 통일해야 한다. 병합 방식과 연산 방식을 섞어 쓰면 클라이언트를 만드는 쪽에서 매번 문서를 다시 확인해야 하는 부담이 생긴다.


잘 안 보이는 메서드들

HEAD는 GET과 똑같이 동작하지만 본문 없이 헤더만 돌려받는다. 파일을 실제로 내려받기 전에 크기를 확인하고 싶을 때 쓴다.


curl -I https://example.com/videos/lecture01.mp4
HTTP/1.1 200 OK
Content-Length: 483920112
Content-Type: video/mp4


본문을 받지 않고 Content-Length만 확인해 4억 바이트가 넘는 파일이라는 걸 알면, 모바일 환경에서 다운로드 전에 사용자에게 미리 경고를 띄울 수 있다.


OPTIONS는 그 자원에 어떤 메서드가 허용되는지 물어보는 메서드다. 브라우저가 다른 도메인으로 PATCH나 커스텀 헤더가 붙은 요청을 보내기 직전에 이 메서드로 먼저 허용 여부를 확인하는데, 이를 프리플라이트라 부른다. 서버는 Allow 헤더에 허용된 메서드 목록을 담아 응답한다.


OPTIONS /articles/42 HTTP/1.1

HTTP/1.1 204 No Content
Allow: GET, PATCH, DELETE


TRACE와 CONNECT는 실무에서 직접 다룰 일이 거의 없다. TRACE는 요청이 중간 서버를 거치며 어떻게 변형되는지 되돌려주는 진단용 메서드인데, 공격자가 쿠키 값을 우회해 읽어내는 수단으로 악용된 사례가 있어 대부분의 서버가 아예 꺼둔다. CONNECT는 프록시에게 터널을 뚫어 달라고 요청할 때 쓰이며, HTTPS 트래픽이 프록시를 통과할 때 내부적으로 오간다.


폼과 메서드 오버라이드

HTML의 기본 form 태그는 method 속성에 GET과 POST만 받는다. PUT이나 DELETE를 적어도 브라우저는 조용히 GET으로 처리해버린다. 자바스크립트 없이 순수 폼만으로 삭제 화면을 만들던 시절에는 이 제약을 피하려고 숨겨진 필드에 실제 의도를 적어 보내는 방식을 썼다.


<form method="post" action="/articles/42">
<input type="hidden" name="_method" value="delete">
</form>


서버는 실제로는 POST로 들어온 요청이라도 _method 필드나 X-HTTP-Method-Override 헤더 값을 보고 DELETE로 처리해준다. 지금은 fetch나 axios 같은 요청 함수가 원하는 메서드를 그대로 지정해 보낼 수 있어서, 순수 폼만 쓰는 오래된 화면이 아니라면 이 우회 장치를 쓸 일은 줄었다.


실제로 어떤 메서드가 오갔는지 헷갈리면 개발자 도구의 Network 탭에서 요청을 클릭해 Request Method 항목을 보면 된다. 화면에 보이는 버튼 이름과 실제 전송된 메서드가 다른 경우, 특히 폼에 오버라이드 필드가 섞여 있다면 이 항목을 직접 확인하는 편이 가장 정확하다.