← 목록으로

600개 필터와 414: .NET 10에서 드디어 빛을 본 HTTP QUERY 메서드

2026. 7. 27.

600개 필터와 414: .NET 10에서 드디어 빛을 본 HTTP QUERY 메서드

점점 늘어나는 제품 검색 필터 목록, 그리고 정말 오랜만에 마주한 낯선 HTTP 상태 코드. 제 앞에 놓인 문제는 그리 단순하지 않았습니다. 일반적으로 필터는 쿼리 스트링에 담겨 전달되죠. 늘 그랬듯이 아무 문제 없이 작동하는 듯 보였습니다. 하지만 누군가 수백 개의 SKU를 포함하는 "필터 세트"를 저장하면서, 엔드포인트는 갑자기 414 에러를 뱉어내기 시작했습니다.

저는 정확히 어느 지점에서 문제가 발생하는지 파악하기 위해 작은 테스트 버전을 만들어봤습니다. 동일한 검색 기능에 ?sku= 형태로 반복되는 필터 값을 100개씩 늘려가며 요청을 보냈죠.

1) GET 요청, 필터는 URL에 포함
    100개 필터 | 요청 라인  1534 바이트 | 200 OK
    200개 필터 | 요청 라인  3034 바이트 | 200 OK
    300개 필터 | 요청 라인  4534 바이트 | 200 OK
    400개 필터 | 요청 라인  6034 바이트 | 200 OK
    500개 필터 | 요청 라인  7534 바이트 | 200 OK
    600개 필터 | 요청 라인  9034 바이트 | 414 RequestUriTooLong

Kestrel의 기본 최대 요청 라인 길이는 8KB입니다. 여기서 요청 라인은 HTTP 메소드, URL, 그리고 HTTP 버전 정보까지 모두 포함하는 개념이죠. 500개에서 600개 필터 사이 어딘가에서, 제 URL은 더 이상 URL이 아니게 되어버렸습니다. 제가 실무에서 이 부분을 테스트해 봤을 때, 8KB라는 Kestrel의 기본 제한은 생각보다 빠르게 다가왔습니다. 단순히 URL이 길어진다고만 생각했는데, HTTP 메소드와 버전 정보까지 포함된 '요청 라인' 전체가 계산된다는 사실을 다시 한번 상기하게 되더군요.

제가 알고 있던 모든 해결책은 타협점을 내포하고 있었습니다. GET 요청에 바디를 포함하는 것은 스펙에 정의되어 있지 않아 일부 프록시는 조용히 무시해버리기도 합니다. POST 요청은 작동하지만, POST는 "뭔가를 변경할 수 있다"는 의미를 내포하기 때문에 캐시가 이를 건너뛰고, 게이트웨이가 자동으로 재시도하지 않으며, API 문서를 읽는 개발자는 POST /search가 정말 단순한 검색인지 헷갈릴 수 있습니다. 필터들을 헤더에 욱여넣는 아이디어는 하루 정도는 그럴싸하게 들리지만, 곧바로 비효율적이고 지저분하다는 것을 깨닫게 됩니다.

드디어 나타난 그 메서드: QUERY

RFC 10008은 QUERY 메서드를 정의하며, 이 메서드는 바로 이런 상황을 위해 존재했습니다. 쿼리의 내용은 요청 바디에 담깁니다. 이 메서드는 안전하고 멱등(idempotent)성을 보장하므로, 연결이 끊어진 후에도 패닉 없이 재시도할 수 있습니다. 응답은 캐시 가능하며, 스펙에서는 캐시 키가 "요청 내용과 관련 메타데이터"로부터 구성되어야 한다고 명시하고 있습니다. 응답 측면에서도 멋진 특징이 있는데, Content-Location 헤더를 통해 해당 검색 결과를 일반 GET 요청으로 가져올 수 있는 URL을 가리킬 수 있습니다.

제가 항상 사람들에게 설명하는 한 줄짜리 정의는 이렇습니다. QUERY는 '바디를 가진 GET'이고, 이것이 바로 핵심입니다.

ASP.NET Core 10에서 QUERY 연동하기

.NET 10은 QUERY의 기본적인 기능을 제공하지만, 아직 편의 기능을 완벽하게 지원하진 않습니다. MapQuery[HttpQuery] 애트리뷰트 같은 설탕(sugar)은 없으니, 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);

Kestrel은 이미 HTTP 동사를 파싱하므로, 별도로 더 이상 설정할 필요는 없었습니다. 저는 앞서 URL을 망가뜨렸던 600개의 SKU 대신, 5,000개의 SKU가 담긴 동일한 필터 목록을 던져봤습니다. 결과는 어땠을까요?

2) QUERY 요청, 필터는 바디에 포함
   바디 65025 바이트 | 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 처리
   Content-Type 없음   -> 400 BadRequest
   text/plain        -> 415 UnsupportedMediaType
   application/json  -> 200 OK

Content-Type이 누락되면 400, 잘못된 미디어 타입이면 415. 이 부분은 정말 사소해 보이지만, 제가 놓쳤다가 한참을 디버깅했던 경험이 있습니다. 클라이언트가 헤더를 깜빡했을 때 친절한 400/415 에러 대신 엉뚱한 역직렬화 예외가 터지면, 밤샘 디버깅은 물론이고 API를 사용하는 개발팀과의 관계도 삐걱거릴 수 있거든요. 작은 노력이 큰 문제를 막아줍니다.

제가 착각했던 부분

스펙은 QUERY 응답이 캐시 가능하다고 명시합니다. 그래서 저는 세 개의 엔드포인트 모두에 .CacheOutput()을 적용하고, 각 엔드포인트를 세 번씩 호출한 뒤 서버가 검색을 실제로 몇 번 실행했는지 보고하도록 했습니다.

4) 출력 캐싱 (실제 검색이 실행될 때만 실행 번호가 증가)
   GET   x3 -> 실행 8, 8, 8  => 캐시됨
   POST  x3 -> 실행 9, 10, 11  => 캐시 안 됨
   QUERY x3 -> 실행 12, 13, 14  => 캐시 안 됨

GET은 캐시되었습니다. 하지만 QUERY는 그렇지 않았습니다. 출력 캐싱은 GET과 HEAD 요청만을 캐시 가능한 것으로 간주하는데, 이는 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의 출력 캐싱이 제공하지 않는 후크입니다. 제 생각은 이렇습니다: 직접 구현하려 하지 마세요. 프레임워크가 바디에 기반하여 캐시 키를 만들 수 있을 때까지 QUERY는 캐시하지 않는 편이 낫습니다. 왜냐하면 잘못된 검색 결과를 자신 있게 반환하는 캐시는 없는 것보다 못하다는 걸 뼈저리게 느꼈기 때문입니다.

그래서 지금 당장 사용해야 할까?

여러분이 제어하는 서비스 간의 내부 API라면, 네, 적극적으로 사용하세요. URL 길이 문제 하나만으로도 도입할 가치가 충분합니다. 게다가 정직한 의미론을 무료로 얻을 수 있는데, 이는 생각보다 훨씬 중요합니다. .NET은 QUERY를 멱등성으로 처리하므로, 복원력 정책(resilience policies)이 GET을 재시도하는 방식과 동일하게 QUERY도 재시도할 것입니다. POST /search를 재시도하는 것이 안전한지에 대해 더 이상 논쟁할 필요가 없습니다.

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

전체 실행 가능한 샘플은 다음 링크에서 확인할 수 있습니다: https://github.com/ssukhpinder/dev-to-code-samples/tree/main/003-http-query-method

혹시 QUERY 요청을 실제 CDN을 통해 전송해 본 분이 계신가요? 어떤 결과가 나왔는지 듣고 싶습니다.

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


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