← 목록으로

600개 필터가 일으킨 414 에러? .NET 10의 '숨겨진' QUERY 메서드로 API 한계 돌파하기!

2026. 7. 28.

600개 필터가 일으킨 414 에러? .NET 10의 '숨겨진' QUERY 메서드로 API 한계 돌파하기!

제품 검색, 끝없이 늘어나는 필터 목록, 그리고 몇 년 만에 마주한 낯선 HTTP 상태 코드. 이런 조합은 개발자라면 누구나 한 번쯤 겪어봤을 법한 상황이죠. 필터는 늘 그랬듯이 쿼리 스트링에 들어갔습니다. 수백 개의 SKU를 담은 '필터 세트'를 누군가 저장하기 전까지는 아무 문제가 없었죠. 하지만 그 순간부터 엔드포인트는 414 응답을 뱉어내기 시작했습니다. 이런 문제는 대규모 데이터를 다루는 서비스에서 종종 발생하는데, 특히 검색 기능에서 필터 옵션이 늘어날 때마다 개발팀의 숙제였습니다.

정확히 어떤 지점에서 한계에 부딪히는지 확인하기 위해 작은 재현 버전을 만들어 테스트해봤습니다. 동일한 검색 기능에 ?sku= 값을 반복해서 필터로 사용하고, 그 개수를 100개씩 늘려가며 요청을 보냈죠.

1) GET 요청, 필터는 URL에
    100개 필터 | request line  1534 bytes | 200 OK
    200개 필터 | request line  3034 bytes | 200 OK
    300개 필터 | request line  4534 bytes | 200 OK
    400개 필터 | request line  6034 bytes | 200 OK
    500개 필터 | request line  7534 bytes | 200 OK
    600개 필터 | request line  9034 bytes | 414 RequestUriTooLong

결과를 보니 케스트렐(Kestrel)의 기본 최대 요청 라인 길이는 8KB였습니다. 요청 라인에는 HTTP 메서드, URL, HTTP 버전이 모두 포함되죠. 결국 500개에서 600개 사이의 필터에서 제 URL은 더 이상 URL로서의 역할을 하지 못하게 된 겁니다.

제가 알고 있던 모든 해결책은 타협점이 명확했습니다. GET 요청에 바디를 붙이는 건 HTTP 스펙에 정의되지 않은 방식이라 일부 프록시는 소리 없이 해당 바디를 무시해 버리곤 합니다. POST 방식은 작동은 하지만, '뭔가를 변경할 수 있다'는 의미를 내포하기 때문에 캐시가 이 요청을 건너뛰고, 게이트웨이는 자동 재시도를 하지 않죠. 게다가 API 문서를 보는 개발자는 POST /search가 정말 검색 기능인지 아니면 데이터를 생성하는 기능인지 추측해야만 합니다. 필터를 헤더에 쑤셔 넣는 방법은 하루 정도는 기발하게 들리겠지만, 금방 후회할 아이디어입니다.

놓쳐서는 안 될 새로운 메서드: QUERY

RFC 10008이 바로 이 QUERY 메서드를 정의하고 있습니다. 마치 이 자리를 기다리고 있었던 것처럼요. QUERY는 요청 바디에 질의 내용을 담습니다. 이 메서드는 안전하고 멱등성을 가지므로, 네트워크 연결이 끊겨도 안심하고 재시도할 수 있습니다. 응답도 캐시 가능하며, 스펙에서는 캐시 키가 '요청 콘텐츠와 관련 메타데이터'로부터 구축되어야 한다고 명시합니다. 여기에 응답 측면에서 한 가지 멋진 기능이 더 있습니다. Content-Location 헤더를 통해 해당 검색 결과를 일반 GET으로 다시 가져올 수 있는 URL을 알려줄 수 있다는 점이죠.

제가 주변 개발자들에게 늘 한 문장으로 요약해서 말해주곤 하는데, 바로 '바디를 가진 GET'이라고 생각하시면 됩니다. 그게 이 메서드의 핵심입니다.

ASP.NET Core 10에서 QUERY 메서드 연결하기

.NET 10은 이 QUERY 메서드의 원시적인 기능은 제공하지만, 아직 편의성(sugar)까지는 아닙니다. MapQuery[HttpQuery] 같은 어트리뷰트는 없으므로, MapMethods를 통해 접근해야 합니다. 제가 직접 이 코드를 작성해보니, 초기 버전에서는 이런 수동적인 설정이 필요하다는 점이 조금 아쉽긴 하지만, 핵심 기능 구현에는 전혀 문제가 없었습니다.

app.MapMethods("/products/search", [HttpMethods.Query], async (HttpContext ctx) =>
{
    var filter = await JsonSerializer.DeserializeAsync<Filter>(ctx.Request.Body);
    var result = RunSearch(filter!.Skus, filter.MaxPrice);

    ctx.Response.Headers.ContentLocation = $"/products/search-results/{result.Execution}";
    return Results.Ok(result);
});

HttpMethods.QueryHttpMethods.IsQuery는 이제 실제 API로 사용할 수 있습니다. IsQuery("query")는 대소문자를 구분하지 않고 true를 반환하죠. 클라이언트 측에서도 HttpMethod.Query가 존재합니다.

using var req = new HttpRequestMessage(HttpMethod.Query, "/products/search");
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
using var resp = await http.SendAsync(req);

케스트렐은 이미 동사를 파싱하므로, 별다른 추가 설정은 필요 없었습니다. 저는 600개의 필터 때문에 URL이 깨졌던 그 목록을 다시 QUERY 메서드에 던져봤습니다. 이번에는 600개가 아니라 5,000개의 SKU였습니다.

2) QUERY 요청, 필터는 바디에
   body 65025 bytes | 200 OK | {"matches":3752,"execution":6}
   Content-Location: /products/search-results/6

65KB에 달하는 필터들이었지만, 414 에러는 더 이상 없었습니다. 완벽하게 작동하더군요.

반드시 직접 강제해야 할 한 가지 규칙

스펙은 이 부분에 대해 단호합니다. 서버는 Content-Type이 누락되거나 콘텐츠와 일치하지 않으면 요청을 실패시켜야 합니다. ASP.NET Core는 MapMethods 핸들러에서 이 부분을 자동으로 처리해주지 않으므로, 아래 네 줄의 코드를 한 번만 작성해주면 됩니다.

if (string.IsNullOrEmpty(ctx.Request.ContentType))
    return Results.Problem("QUERY requires a Content-Type.", statusCode: 400);
if (!ctx.Request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase))
    return Results.StatusCode(StatusCodes.Status415UnsupportedMediaType);
3) Content-Type 처리
   no Content-Type   -> 400 BadRequest
   text/plain        -> 415 UnsupportedMediaType
   application/json  -> 200 OK

Content-Type이 없으면 400 BadRequest, 잘못된 미디어 타입이면 415 UnsupportedMediaType을 반환하도록 했습니다. 이 부분을 놓치기 쉬운데, 이걸 건너뛰면 헤더를 잊은 첫 번째 클라이언트가 응답 대신 역직렬화 예외(deserialization exception)를 받게 됩니다.

제가 잘못 생각했던 부분

QUERY 응답은 캐시 가능하다는 스펙을 읽고, 세 개의 엔드포인트 모두에 .CacheOutput()을 붙인 후 각 요청을 세 번씩 보냈습니다. 그리고 검색이 실제로 몇 번 실행되었는지 서버 로그를 확인했습니다.

4) 출력 캐싱 (실제 실행 횟수는 캐시 미스 시에만 증가)
   GET   x3 -> executions 8, 8, 8  => 캐시됨
   POST  x3 -> executions 9, 10, 11  => 캐시 안 됨
   QUERY x3 -> executions 12, 13, 14  => 캐시 안 됨

GET은 캐시되었지만, QUERY는 캐시되지 않았습니다. 출력 캐싱은 GETHEAD만 캐시 가능한 것으로 간주하는데, 이 코드가 작성될 당시 QUERY 메서드가 존재하지 않았음을 감안하면 충분히 이해할 수 있는 부분입니다.

그래서 저는 QUERY를 캐시 대상에 포함시키는 커스텀 IOutputCachePolicy를 작성했습니다. 스무 줄 정도 되는 간단한 클래스였지만, 이젠 캐시가 잘 되는 듯 보였습니다. 그러나 같은 URL에 두 개의 다른 바디를 보내자 문제가 발생했습니다.

5) QUERY를 캐시하는 커스텀 정책 (동일 URL, 두 개의 다른 바디)
   바디 A (SKU 2개)  -> {"matches":2,"execution":15}
   바디 B (SKU 4개)  -> {"matches":2,"execution":15}

바디 B는 4개의 SKU에 대해 질의했지만, 캐시에서 가져온 바디 A의 응답을 받았습니다. 실행 번호까지 똑같았죠. 출력 캐시 키는 URL과 vary-by 규칙에서 생성됩니다. QUERY의 경우 URL이 질의 내용이 아니라 바디가 질의 내용인데, 제 정책은 요청 정보가 없는 URL 부분을 캐시 키로 사용하도록 가르친 셈이었습니다.

이를 올바르게 처리하려면 정규화된 요청 콘텐츠를 해싱하여 캐시 키에 포함해야 합니다. RFC에서 언급하는 바로 그 내용이지만, ASP.NET Core의 출력 캐싱은 현재 이 부분에 대한 훅(hook)을 제공하지 않습니다. 결론적으로 말씀드리자면, 프레임워크가 바디 기반 캐시 키를 지원할 때까지 QUERY는 캐시하지 않는 것이 현명합니다. 확신에 차서 잘못된 검색 결과를 반환하는 캐시는 아예 없는 것보다 나쁘기 때문입니다.

지금 바로 QUERY 메서드를 사용해야 할까?

여러분이 직접 제어하는 서비스 간 내부 API라면 당연히 '예'입니다. URL 길이 문제만으로도 충분히 도입할 가치가 있죠. 게다가 정직한 의미론(honest semantics)까지 얻게 되는데, 이게 생각보다 중요합니다. .NET은 QUERY를 멱등성 있는 메서드로 취급하기 때문에, 장애 복구 정책(resilience policies)은 GET 요청을 재시도하는 것처럼 QUERY도 재시도할 것입니다. POST /search를 재시도하는 것이 안전한지에 대해 더 이상 논쟁할 필요가 없어지죠.

하지만 공개 API의 경우에는 좀 더 신중해야 합니다. 저는 이 기능을 컨테이너 내부에서 케스트렐을 대상으로만 테스트했습니다. 여러분의 CDN, WAF 또는 기업 프록시가 낯선 HTTP 동사에 어떻게 반응할지는 전혀 알 수 없습니다. 배포한 후에 확인하기보다는 배포 전에 반드시 확인해볼 가치가 있습니다. 또한, OpenAPI 3.1에는 경로 항목(path item)에 query 필드가 없기 때문에, 클라이언트 팀에게 이 엔드포인트가 생성된 문서에 나타날 것이라고 약속하기 전에 직접 확인해보세요. POST 경로를 함께 유지하는 것은 거의 비용이 들지 않으면서도 비상시를 위한 탈출구를 마련해 줍니다.

완전히 실행 가능한 샘플 코드는 여기에서 확인할 수 있습니다.

혹시 실제로 QUERY 요청을 CDN을 통해 전송해보신 분이 있다면, 어떤 결과가 나왔는지 꼭 공유해주시면 좋겠습니다!

— 아무도 시키지 않은 것들을 여전히 측정하며


원문: https://dev.to/ssukhpinder/600-filters-and-a-414-the-new-query-method-in-net-10-5bib 수집일: 2026-07-28 01:18:06