"페이지를 나누고 나면 클라이언트는 곧 세 가지를 더 요구해. 'X로 걸러 줘', 'Y 순서로 정렬해 줘', 'A·B·C 필드만 줘.' 곧바로 GraphQL로 갈 필요는 없어. 먼저 일관된 쿼리 계약을 만들면 돼."
목록 API의 네 가지 쿼리 축
목록 엔드포인트가 성장하면 보통 네 종류의 쿼리 매개변수가 쌓여:
- 필터링 — 결과 집합 좁히기:
?status=active&role=admin - 정렬 — 결과 순서 정하기:
?sort=-created_at,name - 부분 필드 선택 — 각 항목의 표현 줄이기:
?fields=id,name - 관련 리소스 포함 — 관계를 한 응답에 확장하기:
?include=orders,profile
REST 자체가 이 네 축의 보편 문법을 정해 주지는 않아. 여러 관례 가운데 하나를 고르고, 의미와 오류 규칙을 문서화한 뒤 API 전체에서 일관되게 적용해.
필터링 관례
단순 동등 비교: ?status=active는 상태가 active인 항목을 뜻하고, ?status=active&role=admin은 보통 두 조건을 모두 만족한다는 뜻으로 정의해. 해석과 문서화가 쉬워서 많은 기본 요구를 처리할 수 있어.
IN 조건: ?status=active,pending처럼 쉼표로 값을 나열할 수 있어. 반복 매개변수인 ?status=active&status=pending를 쓰는 API도 흔해. 값 자체에 쉼표가 들어갈 수 있는지, 중복 값과 빈 값은 어떻게 처리할지도 정해야 해.
범위 조건: ?created_at_gte=2026-01-01&created_at_lt=2026-06-01처럼 연산자를 접미사로 붙일 수 있어. Django의 __gte, _gte, [gte]= 등 관례가 다양하므로 하나를 선택해.
복잡한 표현식: RSQL이나 FIQL은 ?filter=status==active;role==admin;created_at=ge=2026-01-01처럼 미니 쿼리 언어를 제공해. 실제로 이 표현력이 필요한 소비자와 안전한 파서, 비용 제한이 없다면 복잡도만 늘 수 있어.
본문이 필요한 검색: 중첩 불리언 로직, 매우 큰 IN 목록, 전문 검색 연산자처럼 쿼리 문자열로 다루기 어려운 조건은 POST와 JSON 본문을 사용하는 검색 작업으로 분리할 수 있어. URL 제한은 브라우저, 프록시, CDN, 서버마다 다르고 GET 요청 본문은 상호운용성이 낮아. POST는 HTTP에서 safe 메서드로 정의되지 않으므로 이 엔드포인트가 읽기 전용이라는 점, 캐시와 재시도 의미, 비용 제한을 계약에 명시해.
정렬 관례
흔한 패턴은 셋이야:
- 부호 접두사:
?sort=-created_at,name— 내림차순 필드에는 minus를 붙이고 여러 필드는 쉼표로 나열해. 짧고 널리 쓰이는 형태야. - 콜론 접미사:
?sort=created_at:desc,name:asc— 방향이 명시적이어서 조금 길지만 읽기 쉬워. - JSON:API 스타일:
?sort=-created_at,name— JSON:API가 자체 형식 안에서 부호 접두사 규칙을 정의해. REST 전체의 표준이라는 뜻은 아니야.
서버는 정렬 가능한 필드를 허용 목록으로 제한해야 해. 임의의 데이터베이스 열을 노출하면 인덱스 없는 대규모 정렬로 자원을 소모하거나 내부 스키마를 새게 만들 수 있어. {created_at, updated_at, name}처럼 지원할 필드를 명시하고, 나머지는 설명이 담긴 400으로 거절해. 허용 목록만으로 충분한지 실행 계획과 쿼리 비용 제한도 함께 점검하고.
부분 필드 선택 — GraphQL 없이 표현 줄이기
클라이언트가 필드 몇 개만 필요할 때가 있어. 자동 완성 목록에 {id, display_name}만 필요하다면 전체 사용자 표현을 매번 내려받을 이유가 없지.
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는 리소스 타입별 sparse fieldsets를 공식화해. ?fields[user]=name,email&fields[order]=total은 개념을 단순화한 예이고, 실제 키에는 해당 JSON:API 문서가 정의한 리소스 타입 이름을 써야 해. 여러 타입이 함께 포함된 응답에서 특히 유용해.
응답이 크고 소비자가 요약 필드만 쓴다면 대역폭과 직렬화 비용을 크게 줄일 수 있어. 대신 서버는 필드 권한과 필수 식별자를 검증하고 직렬화 단계에서 선택을 적용해야 해. 캐시에서는 Vary: query 같은 지시어를 만들면 안 돼. Vary는 Vary: Accept-Encoding처럼 요청 헤더 필드 이름을 나열하는 표준 헤더야. 쿼리 문자열은 보통 URI와 함께 캐시 키에 들어가므로, CDN이나 역방향 프록시가 fields 값을 무시하지 않도록 캐시 키 정책을 정확히 설정해.
관련 리소스 포함 — 요청 증폭 줄이기
사용자와 그 주문을 함께 표시하는 클라이언트를 생각해 봐. 두 방식이 있어:
- N+1 요청: GET /users/42 뒤에 각 주문마다 GET /orders/{id}를 호출하면 관련 항목 수만큼 왕복이 늘어날 수 있어.
- 포함 매개변수: GET /users/42?include=orders처럼 사용자와 관련 주문을 한 응답에 묶으면 왕복을 줄일 수 있어. 대신 응답 크기와 조인 비용, 포함 가능한 관계의 깊이를 제한해야 해.
JSON:API는 최상위 included 배열로 관련 리소스 포함을 정의해. 자체 형식의 API는 본문에 관계를 중첩하기도 하고. 어떤 형태든 요청 증폭을 줄이는 이득과 응답 폭증 위험을 함께 관리해야 해.
GraphQL이나 POST 본문 쿼리를 고려할 때
클라이언트가 사용자 정보와 최근 주문 열 개, 주문별 일부 필드, 계정 잔고를 한 번에 서로 다른 모양으로 반복 요청한다면 GraphQL이나 구조화된 POST 검색 계약을 검토할 만해. 단순 쿼리 매개변수만으로 중첩 선택을 계속 확장하면 자체 쿼리 언어를 어설프게 다시 만들게 돼.
복잡도가 한계를 넘고 있다는 신호는 이래:
- URL이 실제 브라우저·프록시·CDN·서버 한도에 가까워지거나 운영 환경마다 잘릴 때.
- 필터에 중첩된 AND/OR/NOT 논리가 필요할 때.
- 클라이언트가 여러 매개변수를 반복 조합하면서 같은 문법 해석 코드를 복제할 때.
- 별도의 긴 "쿼리 문법" 문서와 파서, 비용 모델이 필요할 때.
GraphQL은 다양한 클라이언트가 서로 다른 응답 모양을 자주 요구하고, 스키마·인가·쿼리 비용·캐시 운영까지 감당할 준비가 있을 때 선택해. 많은 HTTP API에서는 일관된 쿼리 매개변수와 제한된 필드 선택·포함만으로도 충분해.
cwkPippa의 쿼리 현실
?brain=claude, ?archived=true, ?folder_id=...가 있고, 정렬은 created_at 내림차순으로 고정돼 있어. 응답이 가벼워 필드 선택이 없고, React 클라이언트의 현재 접근 방식에서는 관련 포함도 필요하지 않아. Council 목록에는 기존 오프셋 페이지 나누기와 ?status=active 필터를 조합해. 요구가 입증되기 전에는 쿼리 언어를 키우지 않는 의도적인 선택이야.