페이징 방식 표준나가는 쪽을 정했다. PagingResultCursorResult, 커서의 모양, hasNext 판별까지.

들어오는 쪽은 비어 있었다.

GET /orders?page=0&size=100000&sort=password,desc
             ↑        ↑              ↑
        0부터인가?  상한이 없다    ORDER BY 로 그대로 간다

세 파라미터 다 규칙으로 정한 적이 없다. page가 0부터라는 건 예시 코드의 @Schema(description = "페이지 번호 (0부터)") 한 줄에만 있었고, size 상한은 아예 없었고, sort는 “화이트리스트 없이 ORDER BY에 넣지 않는다”까지만 있고 그 화이트리스트가 어디 사는지가 없었다.

이 글은 그 셋을 정한 기록이다. 그리고 셋이 전부 같은 곳으로 번역된다는 것이 이 표준의 결론이다 — 인덱스 비용이다.


먼저: 이미 정해져 있어 다시 고르지 않은 것

여기서 새로 정한 것과 이미 서 있던 것을 갈라두지 않으면, 나중에 읽을 때 전부 이 글의 결정으로 보인다.

계층 사슬은 고정이다. DTO 계층 표준패키지 구조 표준이 이미 못 박았다.

XxxSearchRequest (api) → XxxQuery (application) → XxxSearchCondition (domain) → Repository

검증은 세 층으로 갈려 있다. 검증 표준이 형식은 Controller, 비즈니스 규칙은 Service, DB를 봐야 하는 것은 Domain으로 나눴다. 그리고 형식 검증은 컨트롤러 메서드에 들어가기 전에 어노테이션으로 끝난다.

커서는 불투명하다. 페이징 표준 원칙 3이 커서를 Base64 문자열 하나로 묶었고, 원칙 4가 “디코딩한 커서를 신뢰하지 않는다”를 세웠다. 이 두 개가 아래 원칙 8에서 값을 한다.

인덱스의 물리 구조도 이미 세워뒀다. 복합 인덱스는 (선행, 후행) 쌍의 사전식 정렬이고, B-tree가 하는 일은 그 순서에서 시작점 하나를 찍고 읽는 것뿐이다. 그래서 뒤로 읽기는 모든 컬럼에 한꺼번에 걸린다. 이 사실이 원칙 5와 6의 근거 전부다.


원칙 1. page는 0부터 센다

새로 고른 것이 아니라 이미 그렇게 쓰던 것을 규칙으로 올린 것이다. 그런데 올리는 김에 왜인지는 적어둔다.

안 가 — 1부터 센다. 사람이 화면에서 보는 페이지 번호와 같아진다. 클라이언트가 변환할 게 없다.

버린 이유는 Spring Data Pageable이 0부터라는 것이다. 1부터로 받으면 경계마다 -1이 붙고, 그 변환은 Controller·Service·Repository 어디에나 놓일 수 있다. 한 군데서 빠뜨리면 조용히 한 페이지씩 어긋난다. 예외도 안 나고 결과 개수도 맞아서, 사용자가 “2페이지에 1페이지 내용이 나온다”고 말하기 전까지 아무도 모른다.

골랐다 — 안 나, 0부터 센다. 프레임워크와 같은 기준을 쓰면 변환이 0곳이 되고, 빠뜨릴 자리 자체가 없어진다.

대가는 API 문서를 읽는 사람이 한 번 걸린다는 것이다. 그건 @Schema에 적어 넘긴다.


원칙 2. size 상한을 넘기면 깎지 않고 거부한다

상한이 필요한 건 분명했다. 애그리거트 경계 표준이 “애그리거트 크기가 곧 매 조회의 비용”이라고 못 박았는데, size=100000 한 번이면 그 문장이 무의미해진다.

갈림길은 넘겼을 때다.

// 안 가 — 깎는다
size = Math.min(size, 100);      // size=1000 → 100건이 조용히 돌아온다

// 안 나 — 거부한다
@Max(100) private Integer size;  // size=1000 → BindException → 400 + errors

안 가가 얻는 것은 화면이 안 깨진다는 것이다. size=1000을 보내는 클라이언트는 대개 “가능한 많이”를 뜻하는데, 400을 주면 그 목록이 아예 안 뜬다.

버린 이유는 둘이다. 하나는 응답이 조용히 요청과 달라지는 것이다. 클라이언트는 1000건을 기대하고 100건을 받는데, 그게 “데이터가 100건뿐”인지 “상한에 걸렸다”인지 구별할 수 없다. 둘은 제약이 어노테이션 밖으로 나가는 것이다. Swagger 문서화 표준이 검증 어노테이션을 제약의 단일 출처로 삼는데, Math.min은 문서에 안 나타난다.

골랐다 — 안 나, 거부한다. 안 가가 못 지켜주는 것은 클라이언트가 자기 실수를 아는 것이다. 400을 받으면 즉시 고치지만, 100건을 받으면 영영 모른다.

대가는 안 가의 장점 그대로다 — 모르고 큰 값을 보낸 화면이 통째로 안 뜬다. 감수하는 이유는 그게 배포 전에 드러나는 종류의 실패이기 때문이다.


원칙 3. 상한은 전역 기본값을 두고 내리는 것만 허용한다

숫자를 정하기 전에 누가 정하느냐가 먼저였다. 조회마다 한 행의 무게가 다르기 때문이다 — 자식 컬렉션을 물고 오는 목록과 XxxView 몇 필드짜리 목록에 같은 상한이 맞을 리 없다.

안 가 — 전역 상한 하나로 고정한다. 외우기 쉽고 예외가 없다. 버린 이유는 무거운 조회와 가벼운 조회가 같은 상한을 갖는 것이다. 100건이 어떤 조회에는 과하고 어떤 조회에는 옹색하다.

안 나 — 엔드포인트마다 정한다. 가장 정확하다. 버린 이유는 안 적은 조회에는 상한이 없어지는 것이다. 지금 이 항목이 열려 있던 이유가 정확히 “규칙에 @Max가 없어서”인데, 같은 구멍이 엔드포인트 단위로 재발한다.

골랐다 — 안 다, 전역 기본값을 두고 내리는 것만 허용한다. 안 나가 못 지켜주는 것은 빠뜨렸을 때의 바닥이다. 안 적으면 전역값이 걸리므로 “상한 없는 조회”가 생길 수 없고, 무거운 조회는 낮출 수 있다.

@Min(1) @Max(100) private Integer size;   // 기본 20
@Min(1) @Max(20)  private Integer size;   // 무거운 조회는 낮춘다

대가가 하나 있고, 이건 기계가 못 막는다. 누가 @Max(500)이라고 쓰면 아무도 안 막는다. “내리는 것만”은 사람이 계속 기억해야만 지켜지는 규칙이고, 이 저장소가 보통 안을 버릴 때 쓰는 근거가 바로 그 모양이다. 그래도 택한 이유는 대안이 더 나쁘기 때문이다 — 안 나는 빠뜨리면 상한이 아예 사라지지만, 이 안은 빠뜨려도 전역값이 남는다.


원칙 4. 정렬 화이트리스트는 domain이 소유하고 api가 겨눠 검증한다

“화이트리스트를 쓴다”는 페이징 표준에 이미 있었다. 없는 건 그게 어디 사는지였다.

먼저 왜 검증 표준의 축으로 답이 안 나오는지부터 봐야 한다.

형식인가?           아니다 — 어떤 컬럼이 있는지 알아야 한다
비즈니스 규칙인가?   아니다 — 도메인 규칙이 아니라 기술 제약이다
DB 를 봐야 하나?     아니다 — 스키마는 알아야 하지만 조회할 필요는 없다

세 층 어디에도 안 맞는다. 그래서 층을 고르는 대신 소유와 검증을 갈랐다.

안 가 — api의 Request가 목록을 갖는다. 검증 표준 원칙 5 그대로다. 컨트롤러에 들어가기 전에 errors 400이 나가고 Swagger도 같은 어노테이션에서 나온다.

버린 이유는 허용 키를 정하는 근거가 거기 없다는 것이다. 정렬 키를 하나 늘리면 마이그레이션에 CREATE INDEX가 하나 는다. 인덱스를 지우는 사람과 목록을 고치는 사람이 다른 파일을 본다. 게다가 컨트롤러 패키지 분리 표준apiweb을 갈라놨으므로, 같은 도메인을 둘이 조회하면 목록이 두 벌이 되고 한쪽만 고쳐지면 어긋난다.

안 나 — domain이 갖고 Service가 검증한다. 인덱스와 같은 층이고 XxxSearchCondition이 이미 거기 있어 자리가 자연스럽다.

버린 이유는 검증이 늦다는 것이다. XxxQueryXxxSearchCondition 변환에서 터지므로 이미 컨트롤러 안이고, 검증 표준이 설계한 errors 배열 400이 아니라 다른 경로로 나간다.

골랐다 — 안 다, domain이 소유하고 api가 그걸 겨눈다.

// domain — 키와 컬럼 매핑을 소유한다. 인덱스와 같은 층이다
public enum OrderSortKey {
    CREATED_AT("created_at"), TOTAL_AMOUNT("total_amount");
}

// api — 그 enum 을 겨눠 어노테이션으로 검증한다
public class OrderSearchRequest {
    @SortKey(OrderSortKey.class)
    private List<String> sort;
}

안 가와 안 나가 각각 하나씩 못 지켜주는 것을 둘 다 가져간다 — 단일 출처와 이른 검증이다. api → domain 참조는 새로 여는 것이 아니다. 조회 전용 타입 표준이 “XxxView를 그대로 응답으로 내보내지 않는다”고 해서 컨트롤러가 이미 domain을 import한다.

대가는 커스텀 검증 어노테이션을 만들어야 하는 것이다. @SortKeycommon에 둔다.


원칙 5. 허용 단위는 정렬 키가 아니라 키와 방향의 쌍이다

화이트리스트에 createdAt을 넣는 것과 createdAt,desc를 넣는 것은 다르다. 인덱스가 그렇게 생겼기 때문이다.

(a, b) 인덱스의 리프가 이렇게 누워 있다고 하자.

(1,10) (1,20) (1,30) (2,10) (2,20) (3,40)

거꾸로 읽으면 (3,40) (2,20) (2,10) (1,30) (1,20) (1,10)이다. a만이 아니라 b도 같이 내림차순이 됐다. 사전식으로 줄 세운 것을 통째로 뒤집으면 모든 자리가 같이 뒤집힌다.

그래서 인덱스 하나가 주는 순서는 앞으로 하나, 뒤로 하나 — 딱 둘이다. 컬럼마다 방향을 따로 정할 수 없다.

ORDER BY a ASC,  b ASC     인덱스를 그대로 읽는다
ORDER BY a DESC, b DESC    거꾸로 읽는다
ORDER BY a ASC,  b DESC    ← 이 인덱스로는 안 된다

마지막 줄을 하려면 a=1 블록의 끝을 찾아 거꾸로 읽고, a=2 블록의 끝으로 점프해 또 거꾸로 읽어야 한다. 탐색을 a의 서로 다른 값 개수만큼 반복하는 것이라 “시작점 하나를 찍고 읽는다”가 아니고, MariaDB에 그 실행 방식 자체가 없다. 실제로는 정렬만 포기하고 filesort가 붙는다.

그러니 허용 목록의 단위는 createdAt이 아니라 createdAt,desc다. 키만 열거하면 방향이 열린 채로 남아, 인덱스가 없는 방향을 클라이언트가 그냥 요청할 수 있다.

대신 인덱스 하나가 조합 둘을 커버한다. 자기 자신과 전체 반전이다. 그래서 허용 조합 수의 절반이 인덱스 개수가 된다.


원칙 6. 다중 정렬은 열지 않는다 — 인덱스 39개는 만들 수 없다

페이징 표준은 다중 정렬을 “허용 조합과 인덱스가 정해지기 전까지” 닫아뒀다. 미룬 것이지 정한 것이 아니었다. 이제 그 셈이 나왔으므로 거부로 확정한다.

정렬 키 3개를 다중 정렬로 열면 조합이 이렇게 된다.

키 1 개 : 3 × 2방향                          =  6
키 2 개 : 3×2 순열 × 4방향조합               = 24
키 3 개 : 3! 순열 × 8방향조합                = 48
                                        합계  78

순서가 조합에 들어가는 이유는 (a, b)(b, a)가 서로 다른 인덱스이기 때문이다. 복합 인덱스는 쌍의 사전식 정렬이라 후행 컬럼은 선행 값이 같은 구간 안에서만 정렬돼 있다. 순서를 바꾸면 다른 자료다.

원칙 5에서 인덱스 하나가 조합 둘을 커버하므로 필요한 인덱스는 39개다.

만들 수 있는 수가 아니다. 인덱스는 공짜가 아니라 쓰기마다 갱신되고 디스크를 먹는다. 정렬 키 3개짜리 목록 하나에 39개를 붙일 수는 없다.

그래서 다중 정렬을 여는 것은 “여러 개를 받는다”가 아니라 “허용 조합을 열거한다”다. 열거할 수 있는 수까지만 열 수 있고, 지금은 그 목록을 만들 근거가 없다.

sort는 계속 List<String>으로 받되 첫 번째 값만 읽는다. 타입을 단수로 바꾸지 않는 이유는 페이징 표준 원칙 5가 정한 것과 같다 — 확장성은 계약의 모양에서 사두고 구현에서는 사지 않는다. 나중에 열 때 URL 계약이 안 바뀐다.


원칙 6-1. 단일 정렬에서는 허용 키 하나당 인덱스 하나다

39라는 수 때문에 “정렬을 허용하려면 컬럼마다 인덱스를 깔아야 하나”로 읽히기 쉬운데, 아니다. 그 수는 다중 정렬을 열었을 때만 나온다.

  허용 조합 필요한 인덱스
단일 정렬, 키 3개 6 3
다중 정렬, 키 3개 78 39

단일 정렬에서 키 하나당 하나로 끝나는 이유가 둘이다.

방향이 공짜다. 원칙 5에서 본 대로 인덱스 하나를 앞뒤로 읽으면 asc·desc가 둘 다 나온다.

id도 공짜다. 커서 조건이 (정렬키, id) 복합 비교라 (created_at, id) 인덱스가 필요해 보이는데, InnoDB 보조 인덱스는 리프에 PK를 담는다. 물리 주소가 아니라 열쇠를 담는 구조라서, (created_at) 인덱스가 실제로는 (created_at, id) 순으로 누워 있다.

-- 정렬 키 3개를 단일 정렬로 허용하면 이게 전부다
CREATE INDEX idx_orders_created_at   ON orders (created_at);
CREATE INDEX idx_orders_total_amount ON orders (total_amount);
CREATE INDEX idx_orders_status       ON orders (status);

테이블을 만들 때 한꺼번에 까는 것도 아니다. 스키마 마이그레이션 표준CREATE INDEX를 마이그레이션에 두게 했으므로, 정렬 키를 화이트리스트에 하나 추가하는 마이그레이션에 인덱스 하나가 같이 들어간다. 허용하지 않은 컬럼은 ORDER BY에 못 들어가니 인덱스도 필요 없다.

WHERE 조건용 인덱스는 이것과 별개고 훨씬 비싸다. 조건 5개짜리 검색 화면 하나를 완전히 덮는 데만 10개가 들고, 범위 조건이 끼면 그것도 모자란다. 그건 이 표준이 아니라 인덱스 설계 쪽 문제다.


원칙 7. 정렬과 페이징 파라미터를 XxxSearchRequest로 묶는다

원칙 4가 어노테이션 검증을 고른 순간 따라 나온 것이다. 어노테이션은 객체의 필드에 붙지 낱개 @RequestParam에는 안 붙는다.

// 전 — 낱개로 받는다. @SortKey 를 붙일 자리가 없다
public ResponseEntity<...> search(
        @RequestParam List<String> sort,
        @RequestParam Integer page,
        @RequestParam Integer size) { ... }

// 후 — 묶는다. 검증이 컨트롤러 앞에서 끝난다
public ResponseEntity<...> search(@ModelAttribute @Valid OrderSearchRequest request) { ... }

새로 열 것이 없다는 게 이 원칙의 근거다. 예외 분류 표준에 이미 BindException(@ModelAttribute 검증 실패) → 400 + errors 자리가 있다. 묶는 순간 size 상한 위반과 정렬 키 위반이 둘 다 그 경로로 나간다 — 필드별로 무엇이 틀렸는지 돌려주는, 이 저장소가 이미 설계해둔 400이다.

그리고 이 묶음이 XxxQuery로 변환된다. RequestCommand와 같은 구조라 새 규칙이 아니다.


원칙 8. 커서에 정렬 키를 담아 무효를 감지한다

정렬을 열면 커서가 조용히 틀리는 경로가 생긴다.

1페이지   sort=createdAt,desc   cursor=없음
2페이지   sort=totalAmount,desc cursor=X     ← X 는 createdAt 값을 담고 있다

지금 커서는 {"v": ..., "id": ...}무슨 키로 만든 커서인지 모른다. 그대로 두면 totalAmount 컬럼과 createdAt 값을 비교하게 되고, 400도 500도 안 나고 결과만 엉뚱하다.

감지할 재료를 커서가 들고 있어야 한다.

전   {"v": "2026-08-03T10:00:00", "id": 1234}
후   {"k": "createdAt,desc", "v": "2026-08-03T10:00:00", "id": 1234}

여기서 페이징 표준 원칙 3이 값을 한다. 커서를 불투명하게 만들어둔 덕에 내부 구조를 바꿔도 클라이언트가 안 깨진다. 원칙 3이 “읽히면 언젠가 누가 파싱해서 쓰고, 그 순간 내부 구조가 계약이 된다”고 적어둔 그 상황을 미리 막아놨기 때문이다. 풀어서 실었더라면 지금 파라미터 이름이 바뀌었을 것이다.

담는 것은 컬럼명이 아니라 API 이름이다. 커서는 클라이언트에게 나갔다 돌아오는 물건이라, 컬럼명을 담으면 Base64를 벗기는 순간 스키마가 드러난다.


원칙 9. 커서와 sort가 어긋나면 거부하고 도메인별 ErrorCode를 발급한다

감지되면 무엇을 하는가.

안 가 — 커서를 무시하고 첫 페이지를 준다. 정렬을 바꾸면 처음부터 보는 게 사용자 의도에 가깝고 화면이 안 깨진다. 버린 이유는 서버가 요청과 다른 것을 조용히 돌려주는 것이다. 원칙 2에서 size를 깎지 않기로 한 것과 같은 이유이고, 클라이언트 버그가 영영 안 드러난다.

안 나 — 커서가 정렬을 이긴다. cursor가 있으면 그 안의 정렬 키를 쓰고 요청의 sort를 무시한다. 페이지를 넘는 중에 정렬이 바뀔 일이 없어 결과가 일관된다. 버린 이유는 정렬을 바꾸려는 요청이 무시되는 것이다. 사용자에게는 “정렬이 안 먹힌다”로 보인다.

골랐다 — 안 다, 400으로 거부한다. 안 가와 안 나가 못 지켜주는 것은 같다 — 요청과 응답이 어긋난 채로 성공하지 않는 것이다.

그리고 errors 배열이 아니라 새 ErrorCode를 발급한다. 이게 원칙 7과 다른 경로인 이유가 있다.

정렬 키가 목록 밖이다     → 클라이언트 코드가 틀렸다. 사람이 고쳐야 한다
커서가 정렬과 어긋난다     → cursor 를 비우고 재요청하면 된다. 기계가 고칠 수 있다

커서 무효는 클라이언트가 자동으로 복구할 수 있는 유일한 실패다. errors 배열은 필드별 메시지라 사람이 읽는 것이지 기계가 분기하는 것이 아니다. 코드로 구별돼야 “커서를 버리고 첫 페이지부터 다시”를 클라이언트가 짤 수 있다.

코드는 도메인별로 발급한다. 예외 처리 표준이 “ErrorCode는 식별 가능한 업무 도메인까지 세분화한다, COMMON(0)은 도메인을 식별할 수 없는 최후의 실패에만 쓴다”고 했고, 커서 무효는 어느 조회에서 났는지 알 수 있다.

대가는 커서 페이징을 쓰는 도메인마다 같은 실패에 코드를 하나씩 발급해야 하는 것이다. 주문·정산·알림이 각각 400001XXX·400002XXX·400003XXX를 갖는다. COMMON으로 하나만 두면 그 부담은 사라지지만, 그 순간 로그에서 “어느 목록의 커서가 깨지는가”를 코드로 못 가른다.


판단 기준 정리

질문
page는 몇부터 0부터 Pageable과 같아야 ±1 변환이 0곳이 된다
size 상한 초과 400으로 거부 깎으면 클라이언트가 자기 실수를 영영 모른다
상한을 누가 정하나 전역 기본값, 내리는 것만 빠뜨려도 상한이 사라지지 않는다
화이트리스트 소유 domain의 enum 허용 키를 정하는 근거가 인덱스라서
화이트리스트 검증 api가 어노테이션으로 컨트롤러 앞에서 끝나야 errors 400이 된다
허용 단위 키와 방향의 쌍 인덱스 하나가 주는 순서는 앞뒤 둘뿐이다
다중 정렬 열지 않는다 키 3개면 인덱스 39개다
단일 정렬의 인덱스 값 허용 키 하나당 하나 방향과 id가 인덱스 하나에 딸려 온다
sort 타입 List<String> 유지 계약의 모양은 사두고 구현은 안 산다
커서에 무엇을 더 담나 정렬 키 안 담으면 어긋남을 감지조차 못 한다
커서가 어긋나면 400 + 도메인별 코드 자동 복구가 가능한 실패라 코드로 갈라야 한다

이 표준을 정하기까지

두 항목을 같이 열었다. 장부의 9번(page·size 계약)과 10번(정렬 키 경로)인데, 둘 다 「들어오는 쪽」이라 따로 정하면 같은 요청을 두 글이 나눠 갖게 된다.

size에서 기존 표준이 이미 답을 갖고 있었다. “상한 넘으면 깎을까 거부할까”를 물었는데, 검증 표준이 “형식은 어노테이션으로”이고 Swagger 표준이 “어노테이션이 제약의 단일 출처”라 @Max 하나로 답이 정해져 있었다. 깎는 쪽을 고르려면 두 표준에 예외를 만들어야 했다. 새로 정한 것은 상한이 아니라 상한을 어디에 적느냐였다.

숫자를 묻기 전에 단위를 물은 것이 갈림길이었다. “100인가 200인가”로 갔으면 다음 조회에서 또 정해야 했다. 물어야 했던 건 전역이냐 엔드포인트별이냐였고, 거기서 “엔드포인트별은 안 적은 곳에 상한이 없어진다”가 나왔다 — 지금 이 항목이 열려 있던 이유가 그대로 재발하는 모양이었다.

10번에서는 검증 표준의 축이 아예 안 먹혔다. 형식도 비즈니스 규칙도 DB 조회도 아니라서 세 층 어디에도 안 맞았다. 장부에 그렇게 적어뒀던 것이 맞았고, 층을 고르는 대신 소유와 검증을 가르는 것으로 풀었다. 이게 가능했던 건 api가 이미 domain을 import하고 있어서다 — XxxViewXxxResponse로 바꾸느라 그렇게 되어 있었고, 확인해보기 전에는 새 의존을 여는 줄 알았다.

공부 목록이 여기서 값을 했다. 원칙 5와 6은 새로 판단한 게 아니라 인덱스 구조를 파고들어 닫아둔 결론을 그대로 가져다 쓴 것이다. 그게 없었으면 “다중 정렬을 열까”에 답할 수 없었고, 열었을 것이다. 78조합에 인덱스 39개라는 수가 나오고 나서야 거부가 결론이 됐다.

커서 쪽은 정렬을 열자마자 따라 나왔다. 정렬이 하나뿐일 때는 커서가 무슨 키로 만들어졌는지 물을 필요가 없었는데, 여는 순간 조용히 틀리는 경로가 생겼다. 그리고 그걸 고칠 수 있었던 건 페이징 표준이 커서를 불투명하게 만들어뒀기 때문이다 — 그때 산 것을 지금 쓴 셈이다.

마지막에 경로가 둘로 갈렸다. 화이트리스트 위반과 커서 무효를 같은 400으로 묶으려다, 하나는 사람이 고쳐야 하고 하나는 기계가 고칠 수 있다는 차이를 봤다. errors 배열은 사람이 읽는 것이라 자동 복구를 못 짠다. 그래서 커서 무효만 ErrorCode를 발급한다.


정리

  • page는 0부터. Pageable과 어긋나면 ±1 변환이 여기저기 생기고 빠뜨리면 조용히 틀린다
  • size 상한을 넘기면 거부한다. 깎으면 클라이언트가 자기 실수를 영영 모른다
  • 상한은 전역 기본값을 두고 내리는 것만 허용한다. 빠뜨려도 상한이 사라지지 않는다
  • 정렬 화이트리스트는 domain이 소유하고 api가 겨눠 검증한다. 단일 출처와 이른 검증을 둘 다 가져간다
  • 허용 단위는 키가 아니라 키와 방향의 쌍이다. 인덱스 하나가 주는 순서는 앞뒤 둘뿐이다
  • 다중 정렬은 열지 않는다. 키 3개면 인덱스 39개고, 그건 만들 수 있는 수가 아니다
  • 단일 정렬이면 허용 키 하나당 인덱스 하나다. 모든 컬럼에 까는 것이 아니고, 방향과 id는 딸려 온다
  • 커서에 정렬 키를 담는다. 안 담으면 어긋남을 감지조차 못 하고 결과만 조용히 틀린다
  • 커서가 어긋나면 도메인별 ErrorCode로 거부한다. 자동 복구가 가능한 유일한 실패라 코드로 갈라야 한다

AI 코드 어시스턴트에 바로 적용하기

Claude Code — .claude/skills/list-request-contract-standard/SKILL.md

---
name: list-request-contract-standard
description: 목록 조회 요청의 page·size·sort 계약과 정렬 키 화이트리스트 규칙. 목록 조회 API를 만들거나, 정렬을 열거나, 검색 Request DTO를 작성하거나 리뷰할 때 반드시 적용한다.
---

# 목록 조회 요청 계약 표준

나가는 쪽은 페이징 방식 표준이 정한다. 이 표준은 들어오는 쪽을 정한다.

## 파라미터를 하나로 묶는다

- 목록 조회의 `page`·`size`·`sort`와 검색 조건은 `XxxSearchRequest`로 묶어 `@ModelAttribute @Valid`로 받는다.
- 낱개 `@RequestParam`으로 받지 않는다. 어노테이션 검증을 붙일 자리가 없어진다.
- `XxxSearchRequest``api`에 두고 `XxxQuery`로 변환해 Service에 넘긴다. `Request``Command`와 같은 구조다.

## page 와 size

- `page`**0부터** 센다. 1부터로 받지 않는다.
- `size``@Min(1)``@Max`를 함께 붙인다. 기본값은 20이다.
- **전역 상한은 100이다.** 조회마다 `@Max(100)`을 붙이고, 한 행이 무거운 조회만 그보다 **낮게** 잡는다.
- **전역 상한보다 높은 `@Max`를 쓰지 않는다.**
- 상한을 넘긴 요청은 **깎지 않고 거부한다.** `Math.min`으로 자르지 않는다.
- `size`는 offset·커서 페이징 양쪽에서 같은 규칙을 쓴다. `page`는 offset 페이징에만 있다.

## 정렬 키 화이트리스트

- 허용 정렬 키는 **`domain`의 `XxxSortKey` enum**이 소유한다. 상수마다 실제 컬럼명을 함께 담는다.
- `api`는 그 enum을 겨눠 검증한다. `common``@SortKey(XxxSortKey.class)``XxxSearchRequest``sort` 필드에 붙인다.
- 허용 목록을 `api`에 문자열로 다시 적지 않는다. 소유는 `domain` 한 곳이다.
- 허용 단위는 **정렬 키와 방향의 쌍**이다. `createdAt`이 아니라 `createdAt,desc`를 열거한다.
- 클라이언트가 보내는 이름은 **API 이름**이다. 컬럼명을 URL에 노출하지 않는다. 매핑은 enum이 안다.
- 기본 정렬을 enum이 지정한다. `sort`가 없는 요청은 그 기본값을 쓴다.
- 정렬 키를 하나 추가할 때 같은 마이그레이션에 그 키의 `CREATE INDEX`를 함께 넣는다.

## 다중 정렬

- **다중 정렬을 허용하지 않는다.** `sort``List<String>`으로 받되 첫 번째 값만 읽는다.
- `sort` 타입을 단수로 바꾸지 않는다. 나중에 열 때 URL 계약이 바뀌지 않아야 한다.
- 여러 값을 보내면 두 번째부터 무시한다. 400으로 거부하지 않는다.

## 커서와 정렬의 어긋남

- 커서에 **정렬 키를 함께 담는다.** `{"k": "createdAt,desc", "v": ..., "id": ...}`.
- 커서에 담는 정렬 키는 **API 이름**이다. 컬럼명을 담지 않는다.
- 요청의 `sort`와 커서 안의 정렬 키가 다르면 **400으로 거부한다.** 첫 페이지로 되돌리거나 커서를 우선하지 않는다.
- 이 실패는 `errors` 배열이 아니라 **`ErrorCode`로 낸다.** 클라이언트가 코드로 분기해 커서를 비우고 재요청할 수 있어야 한다.
- `ErrorCode`**도메인별로 발급한다.** `COMMON`에 하나만 두지 않는다.
- 화이트리스트 위반은 이 경로가 아니다. 어노테이션 검증이 `BindException` → 400 + `errors`로 처리한다.

GitHub Copilot — .github/instructions/list-request-contract-standard.instructions.md

---
description: 목록 조회 요청의 page·size·sort 계약과 정렬 키 화이트리스트 규칙
applyTo: "**/*.java"
---

# 목록 조회 요청 계약

- 목록 조회의 `page`·`size`·`sort`와 검색 조건은 `XxxSearchRequest`로 묶어 `@ModelAttribute @Valid`로 받는다. 낱개 `@RequestParam`으로 받지 않는다.
- `XxxSearchRequest``api`에 두고 `XxxQuery`로 변환해 Service에 넘긴다.
- `page`는 0부터 센다. 1부터로 받지 않는다.
- `size``@Min(1)``@Max`를 함께 붙이고 기본값은 20이다.
- 전역 상한은 100이다. 한 행이 무거운 조회만 그보다 낮게 잡는다. 100보다 높은 `@Max`를 쓰지 않는다.
- 상한을 넘긴 요청은 깎지 않고 거부한다. `Math.min`으로 자르지 않는다.
- 허용 정렬 키는 `domain``XxxSortKey` enum이 소유하고 상수마다 실제 컬럼명을 담는다.
- `api``common``@SortKey(XxxSortKey.class)``sort` 필드에 붙여 검증한다. 허용 목록을 `api`에 문자열로 다시 적지 않는다.
- 허용 단위는 정렬 키와 방향의 쌍이다. `createdAt`이 아니라 `createdAt,desc`를 열거한다.
- 클라이언트가 보내는 정렬 이름은 API 이름이다. 컬럼명을 URL이나 커서에 노출하지 않는다.
- 기본 정렬을 enum이 지정하고, `sort`가 없는 요청은 그 기본값을 쓴다.
- 정렬 키를 추가할 때 같은 마이그레이션에 그 키의 `CREATE INDEX`를 함께 넣는다.
- 다중 정렬을 허용하지 않는다. `sort``List<String>`으로 받되 첫 번째 값만 읽고 나머지는 무시한다. 타입을 단수로 바꾸지 않는다.
- 커서에 정렬 키를 함께 담는다. `{"k": "createdAt,desc", "v": ..., "id": ...}`.
- 요청의 `sort`와 커서 안의 정렬 키가 다르면 400으로 거부한다. 첫 페이지로 되돌리거나 커서를 우선하지 않는다.
- 이 실패는 `errors` 배열이 아니라 도메인별 `ErrorCode`로 낸다. `COMMON`에 하나만 두지 않는다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기