C.W.K.
Stream
Lesson 06 of 07 · published

Filtering, Sorting, Sparse Fieldset — Query 도구상자

~10 min · rest-design, filtering, sorting, fieldsets

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"Pagination 있으면 client 가 다음에 묻는 세 가지: 'X 로 filter,' 'Y 로 sort,' '필드 A, B, C 만 줘.' GraphQL 필요 없어 — 일관된 query convention 필요해."

흔한 query 면 네 가지

List endpoint 가 성장하면서 query parameter 네 종류 누적:

  • Filtering — set 좁힘: ?status=active&role=admin
  • Sorting — set 순서: ?sort=-created_at,name
  • Sparse fieldset — 각 항목 trim: ?fields=id,name
  • 관련 include — 관련 resource 확장: ?include=orders,profile

각각 경쟁 convention 여럿. 하나 골라서 전체 API 에 고수; 어느 specific convention 골랐는지보다 일관성이 더 중요.

Filtering convention

단순 equality (가장 흔함): ?status=active 가 "status 가 active 인 항목" 의미. ?status=active&role=admin 이 AND. Parse 쉽고, 문서 쉽고, 80% 의 필요 커버.

IN 에 comma-separated: ?status=active,pending 이 "status 가 이 중 하나" 의미. 어떤 API 는 대신 반복 param (?status=active&status=pending) 씀 — 둘 다 흔함.

Range 엔 operator 접미사: ?created_at_gte=2026-01-01&created_at_lt=2026-06-01. 접미사 convention 다양 (Django 는 __gte, REST API 는 자주 _gte[gte]=).

복잡 query 엔 RSQL / FIQL: ?filter=status==active;role==admin;created_at=ge=2026-01-01. 미니 query 언어. 진짜 "power user" segment 없으면 복잡도 가치 거의 없음.

Filtering 에 query body 필요할 때: filter 가 진짜 복잡 (중첩 boolean 로직, 큰 IN list, operator 가진 full-text) 면 POST + JSON body 로 전환. URL 길이 limit 실제 (대부분 server 8KB cap). 더 이상 GET 아님 — state 안 바꾸는 POST, 정신 면에서 safety 위반 인데 실용적 우회. 일탈 문서화.

Sorting convention

흔한 패턴 셋:

  • Sign prefix (terse): ?sort=-created_at,name — descending 에 minus 접두사, multi-field 에 comma. 가장 compact; 널리 이해.
  • Colon suffix: ?sort=created_at:desc,name:asc — 명시적, 더 장황, 일부 tooling 에 친절.
  • JSON:API 스타일: ?sort=-created_at,name (sign prefix 와 같음). JSON:API 가 표준화.

허용 sort 필드를 server 쪽에서 validate — client 가 임의 column 에 sort 하게 두면 index 없는 sort 통한 denial-of-service 만든 거. Whitelist: {created_at, updated_at, name} 허용; 나머지는 400 으로 거부.

Sparse fieldset — GraphQL 없이 byte 절약

가끔 client 가 필드 몇 개만 필요. Typeahead 가 {id, display_name} 만 필요한데 왜 full user record download?

GET /users/42?fields=id,display_name
→ {"id":"u_42","display_name":"Pippa"}

GET /users?fields=id,display_name,avatar_url
→ {"items":[{"id":"u_42","display_name":"Pippa","avatar_url":"..."}, ...]}

JSON:API 가 type 당 fieldset 공식화: ?fields[user]=name,email&fields[order]=total. Include 통해 여러 resource type 포함하는 response 에 유용.

요약만 필요한 client 한테 bandwidth 절약 10x+ 가능. 구현 비용: server 가 serializer 에서 필드 filter; caching 층이 다른 필드 선택이 stale 안 서빙되게 Vary: query (혹은 구체적으로 Vary: Accept-Encoding + query-aware caching) 필요.

관련 include — 'N+1 회피' query

User 가 order 가짐. User 표시하는 client 가 자주 order 도 필요. 두 방식:

  • N+1 (나쁨): GET /users/42, 그 다음 각 order 마다 GET /orders/{id}. User 하나에 request N 개.
  • Include parameter (좋음): GET /users/42?include=orders 가 user 와 관련 order 한 번에 돌려줌.

JSON:API 가 최상위 included 배열로 표준화; ad-hoc API 는 inline 으로 관련 resource nest. 요점 같음: request-amplification trap 피하기.

Query convention 이 계약 — 일관되게. /users endpoint 가 ?sort=-created_at 쓰면 /orders endpoint 가 ?sort_by=created_at&order=desc 쓰면 안 됨. Protocol 이 client 한테 도움 안 줘; 네 일관성만이 줘. 축 당 convention 하나 골라 어디나 적용.

GraphQL 이나 POST-body query 잡는 때

Client 가 정기적으로 "이 user, 그 마지막 order 10 개, order 당 특정 필드만, 더하기 user 의 계정 잔고" 원하면 — GraphQL 영토 혹은 POST-body-query 영토. Query parameter 가 그 복잡도 위해 절대 설계 안 됨.

Query parameter 자랐다는 신호:

  • URL 이 2KB 접근 (혹은 CDN/proxy limit 침).
  • 중첩 AND/OR/NOT 가진 filter 로직.
  • Client 가 정기적으로 query parameter 5+ 조립.
  • 별개 "query 문법" 페이지 필요한 문서.

여러 다른 query 필요 가진 많은 client 있을 때만 GraphQL 잡아. 대부분 REST API 엔 query parameter + sparse fieldset + include 가 충분.

cwkPippa 의 query 현실

cwkPippa 의 filtering 면 최소: session list 에 ?brain=claude, ?archived=true, ?folder_id=.... Sorting parameter 없음 (기본 created_at desc); fieldset 없음 (response 이미 lean); include 없음 (React client 이 다 local 이라 N+1 괜찮). Council list 가 round 100 넘었을 때 기존 offset pagination + ?status=active filter 가 처리. Query 복잡도 낮게 유지; query convention 단순 유지. 운 아냐 — 의도적 "필요 증명 없이 feature 키우지 마" 입장.

Code

Query, sort, field, include 한 endpoint 에 조합·bash
# 흔한 query 면 네 가지 — 한 endpoint 에서

# Filtering (equality + IN list + range)
curl 'https://api.example.com/users?role=admin&status=active,pending&created_at_gte=2026-01-01'

# Sorting (multi-field, descending 에 sign-prefix)
curl 'https://api.example.com/users?sort=-created_at,name'

# Sparse fieldset (각 항목 trim)
curl 'https://api.example.com/users?fields=id,display_name,avatar_url'

# 관련 include (N+1 회피)
curl 'https://api.example.com/users/42?include=orders,profile'

# 위에 pagination (Lesson 3.5)
curl 'https://api.example.com/users?cursor=usr_abc&limit=20&sort=-created_at&fields=id,name'
FastAPI: 모든 query parameter 를 whitelist 에 validate·python
# FastAPI — 모든 면 네 가지 validation 과 함께 연결
from fastapi import FastAPI, Query, HTTPException
from typing import Annotated

app = FastAPI()

ALLOWED_SORT_FIELDS = {'created_at', 'updated_at', 'name'}
ALLOWED_FIELDS = {'id', 'name', 'email', 'role', 'created_at'}
ALLOWED_INCLUDES = {'orders', 'profile'}

@app.get('/users')
async def list_users(
    role: str | None = None,                          # filter
    status: str | None = None,                        # filter (comma-sep IN)
    created_at_gte: str | None = None,                # filter (range)
    sort: str = '-created_at',                        # sign prefix 가진 sort
    fields: str | None = None,                        # sparse fieldset
    include: str | None = None,                       # 관련
    limit: int = Query(20, ge=1, le=100),
    cursor: str | None = None,
):
    # Sort 필드 validate
    sort_fields = [s.lstrip('-+') for s in sort.split(',')]
    invalid = set(sort_fields) - ALLOWED_SORT_FIELDS
    if invalid:
        raise HTTPException(400, detail=f'sort: 잘못된 필드 {invalid}, 허용: {ALLOWED_SORT_FIELDS}')

    # Field whitelist validate
    if fields:
        requested = set(fields.split(','))
        if not requested.issubset(ALLOWED_FIELDS):
            raise HTTPException(400, detail=f'fields: {requested - ALLOWED_FIELDS} 허용 안 됨')

    # Include validate
    if include:
        wanted = set(include.split(','))
        if not wanted.issubset(ALLOWED_INCLUDES):
            raise HTTPException(400, detail=f'include: {wanted - ALLOWED_INCLUDES} 미지원')

    # ... 그 다음 query 빌드, fetch, 필드 projection 적용, include eager-load
    return await fetch_users(
        filters={'role': role, 'status': status, 'created_at_gte': created_at_gte},
        sort=sort_fields,
        fields=requested if fields else ALLOWED_FIELDS,
        includes=wanted if include else set(),
        limit=limit,
        cursor=cursor,
    )
탈출구: query parameter 가 복잡도 못 운반할 때 POST /search·python
# Query parameter 가 GET 넘었을 때 — POST + JSON body 로 전환
from fastapi import FastAPI

app = FastAPI()

# 중첩 boolean 로직 가진 복잡 search — query string 엔 너무 많음
@app.post('/users/search')
async def search_users(query: dict):
    """
    POST /users/search
    {
      "where": {
        "or": [
          {"status": "active"},
          {"and": [{"role": "admin"}, {"last_login_gte": "2026-05-01"}]}
        ]
      },
      "sort": [{"field": "created_at", "dir": "desc"}],
      "fields": ["id", "display_name", "role"],
      "include": ["orders"],
      "limit": 20
    }
    """
    # State 안 바꾸는 body 가진 POST 가 기술적으로 un-RESTful (POST 가 safe 아니어야).
    # 일탈 문서화; 8KB URL 보다 나음.
    return await complex_query(query)

External links

Exercise

이전 lesson 의 FastAPI users endpoint 받아. Role 과 status 로 filtering, -created_at (기본) sort, ?fields= 통한 sparse fieldset, ?include=orders 관련-fetch 추가. 모든 parameter 를 whitelist 에 validate; 잘못되면 유용한 메시지 가진 400 돌려줘. 그 다음 일부러 ?sort=password?fields=password 요청. Validation 이 둘 다 잡는 거 봐. 보너스: 중첩 AND/OR 로직 가진 JSON body 받는 작은 POST /users/search endpoint 만들고 — 같은 query 를 query parameter 에 맞추는 것보다 JSON shape 가 얼마나 깔끔한지 관찰.
Hint
Whitelist 강제가 load-bearing 부분: ALLOWED_FIELDS = {'id', 'name', 'email', 'created_at'} 이 password (혹은 다른 secret column) 가 ?fields= 통해 절대 새지 못 한다 의미. Sort 도 같은 로직: ALLOWED_SORT_FIELDS 가 공격자가 index 없는 column 에 sort 해서 DB 침몰시키는 거 막아. POST /search 보너스가 GET query parameter 가 왜 실용적 limit 있는지 보여줘 — 중첩 boolean 갖는 순간 URL 이 읽을 수 없게 됨.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.