Post

POST로 검색하던 시대의 끝 — HTTP QUERY 메서드(RFC 10008)

복잡한 검색을 GET으로 보내면 URL 길이와 로그 노출에 막히고, POST로 도망치면 safe·idempotent·cacheable을 잃는다. RFC 10008의 QUERY 메서드가 이 오래된 타협을 어떻게 끝내는지 정리한 글이다.

복잡한 검색 API를 만들 때마다 같은 갈림길에 선다.

조건이 늘어난 검색을 GET으로 보낼 것인가, POST로 보낼 것인가.

GET은 의미상 정확하다. 검색은 서버 상태를 바꾸지 않는 읽기 동작이니까. 그런데 조건이 조금만 복잡해져도 GET은 현실에서 무너진다. 필터, 정렬, 중첩된 조건, 좌표 배열 같은 걸 전부 쿼리스트링에 밀어 넣다 보면 URL이 수 KB를 넘기고, 어느 순간 프록시나 서버가 그 요청을 잘라버린다.

그래서 다들 POST로 도망친다. 바디에 JSON으로 검색 조건을 담아 보내면 길이 문제는 사라진다. 하지만 그 순간 우리는 “이건 그냥 조회일 뿐”이라는 의미를 잃는다.

이번 주제를 한 문장으로 요약하면 이렇다.

우리는 오랫동안 검색의 안전성(safe·idempotent·cacheable) 을 URL 길이와 맞바꿔 왔다.

이 글에서 말하고 싶은 핵심은 단순하다.

문제는 HTTP에 메서드가 부족했다는 게 아니다. “바디를 가지면서도 읽기 전용임이 보장되는 메서드”가 없었다는 게 문제였다. RFC 10008의 QUERY는 정확히 그 빈칸을 채운다.

GET이 현실에서 무너지는 세 지점

GET으로 복잡한 검색을 보내려고 하면 세 가지 벽에 부딪힌다.

  1. URL 길이 제한 RFC 자체는 URI 길이 상한을 못 박지 않지만, 현실의 서버·프록시·CDN은 대개 8KB 안팎에서 요청 라인을 거부한다. 검색 조건이 사용자 입력으로 불어나는 순간 이 상한은 추상적인 숫자가 아니라 실제 장애가 된다.

  2. 로그에 박히는 민감한 조건 쿼리스트링은 액세스 로그, 프록시 로그, 브라우저 히스토리, Referer 헤더에 거의 그대로 남는다. 검색 조건에 이메일·전화번호·내부 식별자가 섞여 있으면 그게 전부 평문으로 흩어진다.

  3. 바디를 못 쓴다 GET에 바디를 실으면 안 된다는 건 아니지만, 중간 장비들이 GET 바디를 무시하거나 버리도록 만들어져 있어서 사실상 쓸 수 없다.

세 번째가 핵심이다. GET은 애초에 “주소만으로 자원을 가리키는” 메서드라서 구조적으로 바디를 위한 자리가 없다.

POST로 도망쳤을 때 우리가 잃는 것

POST는 바디를 자유롭게 쓸 수 있으니 길이 문제는 깔끔하게 사라진다. 그래서 대부분의 검색 API가 POST /search 형태로 굳어졌다.

하지만 POST는 의미론적으로 “이 요청이 서버 상태를 바꿀 수 있다”고 선언하는 메서드다. 그 선언에는 비용이 따라온다.

  • 재시도 불가 네트워크가 흔들려 응답을 못 받았을 때, POST는 함부로 다시 보낼 수 없다. 같은 요청이 두 번 처리되면 무슨 일이 벌어질지 메서드 의미만으로는 알 수 없기 때문이다. 그냥 검색이었는데도 클라이언트는 보수적으로 굴어야 한다.

  • 캐시 불가 POST 응답은 기본적으로 캐시되지 않는다. 같은 검색을 백 번 보내도 매번 오리진까지 내려가 다시 계산한다. 캐시 계층(CDN, 리버스 프록시)이 통째로 놀게 된다.

정리하면 이렇다.

 GETPOSTQUERY
요청 바디
safe (상태 안 바꿈)
idempotent (재시도 안전)
캐시 가능

POST 검색은 “동작하긴 한다”. 다만 그건 의미론을 포기한 대가로 얻은 편의일 뿐이고, 그 포기가 재시도 로직과 캐시 전략에 두고두고 청구서를 보낸다.

QUERY가 채우는 빈칸

RFC 10008이 정의하는 QUERY는 새로운 HTTP 메서드다. 한 줄로 말하면 바디를 가질 수 있는 GET이다.

명세가 보장하는 성질은 두 가지다.

  • safe — 대상 자원의 상태를 바꾸지 않는다.
  • idempotent — 자동으로 재시도하거나 다시 시작해도 안전하다.

즉 클라이언트와 중간 장비는 QUERY 요청을 보고 “이건 읽기 전용이고, 끊기면 그냥 다시 보내도 된다”고 메서드 이름만으로 판단할 수 있다. POST에서는 절대 가정할 수 없던 것이다.

요청 모양은 이렇게 된다.

1
2
3
4
5
6
7
8
9
10
QUERY /products HTTP/1.1
Host: example.com
Content-Type: application/json
Accept: application/json

{
  "filter": { "category": "laptop", "priceMax": 1500000 },
  "sort": [{ "field": "price", "order": "asc" }],
  "page": { "size": 20 }
}

Content-Type은 필수다. 바디의 의미를 서버가 해석해야 하므로, 타입 없는 QUERY는 4xx로 거부된다. 서버가 그 포맷을 모르면 415 Unsupported Media Type으로 응답한다.

명세가 함께 들고 온 것들

QUERY는 메서드 하나만 추가하고 끝나지 않는다. 이 메서드가 실제로 굴러가려면 몇 가지 협상·표현 장치가 필요한데, RFC 10008은 그것까지 같이 정의한다.

  • Accept-Query 응답 헤더 서버가 “나는 이 자원에서 QUERY를 지원하고, 이런 미디어 타입을 받는다”고 광고하는 헤더다. Structured Fields 문법으로 지원 포맷을 나열한다. 클라이언트는 무작정 QUERY를 던지는 대신, 상대가 지원하는지 먼저 알 수 있다.

  • 캐시 키에 바디가 들어간다 QUERY 응답은 캐시 가능하다. 단, 캐시 키는 URL만이 아니라 요청 바디(content)와 관련 메타데이터까지 포함해야 한다. 같은 URL에 다른 검색 조건이 오면 다른 응답이어야 하니 당연한 요구지만, 이건 캐시 인프라 입장에서 꽤 무거운 숙제다. 뒤에서 다시 짚는다.

  • 결과를 가리키는 방법

    • 303 See Other — 결과를 별도 자원으로 돌려보내는 간접 응답
    • Content-Location — 이 쿼리 결과를 가져올 수 있는 URI
    • Location — 같은 쿼리를 GET으로 반복할 수 있는 URI

마지막 항목이 특히 실용적이다. 무거운 검색을 한 번 QUERY로 실행한 뒤, 서버가 그 결과에 대응하는 GET 가능한 URL을 돌려주면, 그 뒤로는 평범한 GET + 캐시로 같은 결과를 재사용할 수 있다.

“캐시 키에 바디” 가 진짜 어려운 부분

QUERY가 GET처럼 캐시 가능하다는 말은 매력적이지만, 실무에서 캐시는 거의 항상 URL을 키로 동작해왔다.

리버스 프록시, CDN, 브라우저 캐시 전부 “메서드 + URL”을 전제로 설계돼 있다. 바디까지 키에 넣으려면 캐시 계층이 요청 본문을 읽고 정규화해서 해시를 떠야 하는데, 이건 기존 캐시 미들웨어가 기본으로 해주던 일이 아니다.

그래서 QUERY의 캐시 이득은 명세가 허용한다고 곧바로 생기는 게 아니라, 캐시 인프라가 바디 기반 키를 구현해줘야 비로소 실현된다. 바로 이 지점이 QUERY가 어디서부터 퍼질지를 결정한다.

그래서 내일부터 쓸 수 있는가

새 RFC가 나왔다고 전 세계 HTTP가 하루아침에 바뀌지는 않는다. 표준 문서는 종이일 뿐이고, 실제 전파는 구현체를 타고 흐른다.

여기서 RFC 10008의 저자 명단이 일종의 예고편이다. 공동 저자는 Julian Reschke(greenbytes), James M. Snell(Cloudflare), Mike Bishop(Akamai)이다. 셋 중 둘이 대형 CDN 소속이다. 바디 기반 캐시 키라는 가장 무거운 숙제를 푸는 데 가장 동기가 큰 쪽이 바로 CDN이라는 점을 생각하면, 전파 순서는 대략 이렇게 그려진다.

  1. CDN·엣지Accept-Query와 바디 기반 캐시를 먼저 구현한다. 가장 빠르다.
  2. HTTP 클라이언트·서버 라이브러리 — curl, 각 언어 HTTP 스택이 메서드를 허용한다.
  3. 애플리케이션 프레임워크 — 라우팅 DSL에 QUERY 매핑이 들어온다.

그리고 이 전파가 기존 인터넷을 깨지 않는 이유가 Accept-Query다. 서버가 QUERY를 지원한다고 광고할 때만 클라이언트가 QUERY를 쓰고, 모르는 쪽에는 기존 GET·POST로 폴백한다. “발행 = 즉시 강제 전환”이 아니라, 양쪽이 합의할 때만 켜지는 점진적 도입이다.

당장은 검색 API를 전부 QUERY로 갈아엎을 일은 아니다. 다만 지금 POST /search를 설계하고 있다면, 적어도 이건 기억해둘 만하다. 그 POST는 “어쩔 수 없어서” 고른 타협이었지, 검색에 맞는 메서드여서가 아니었다.

한 줄로 남기는 교훈

QUERY가 흥미로운 진짜 이유는 새 메서드가 하나 생겨서가 아니다.

메서드의 의미론(safe·idempotent)은 주석이나 문서가 아니라, 캐시·재시도 같은 인프라가 실제로 활용하는 계약이다.

POST로 검색을 보내는 순간, 우리는 그 계약을 비워둔 채로 요청을 던져왔다. QUERY는 그 빈 계약서를 채워서, 인프라가 다시 일하게 만든다. 검색은 원래 읽기였다는, 당연한 사실을 메서드 차원에서 되찾는 일이다.

This post is licensed under CC BY 4.0 by the author.