목록 조회를 어떻게 반환할지는 이미 정해져 있었다. 계층별 DTO 네이밍 표준이 PagingResult<T>를 만들었고, Repository부터 api까지 그 타입 하나로 올라간다.
public class PagingResult<T> {
private final List<T> content;
private final int page;
private final int size;
private final long totalCount; // 이 필드가 문제였다
}
totalCount가 있다는 건 매 목록 조회에 COUNT 쿼리를 한 번 친다는 뜻이다. 타입이 그걸 요구하기 때문에 안 칠 수가 없다. 그리고 무한 스크롤이나 커서 페이징으로 가려면 totalCount가 없어야 한다.
즉 이 타입은 필드 하나로 페이징 방식을 하나로 못 박아 놓은 상태였다. 그런데 표준 어디에도 그 대가가 적혀 있지 않았다. 고른 안의 대가를 안 적으면 나중에 그게 선택이었다는 사실 자체가 사라진다.
이 글은 그 못을 뽑고, 무엇을 기준으로 페이징 방식을 가를지를 정한다.
먼저: 화면 요구가 둘 다였다
방식을 하나 고르는 문제인 줄 알고 시작했는데, 실제 화면을 확인해 보니 둘 다 필요했다.
- 관리자 화면 — 페이지 번호가 필요하다. “3페이지로 가라”, “총 1,234건 중”
- 사용자 화면 — 무한 스크롤이다. 아래로 계속 내려간다
그래서 이 글의 질문이 바뀐다. “offset이냐 커서냐”가 아니라 “둘을 어떤 타입 구조로 담을 것인가”다.
그리고 판단을 좌우한 제약이 하나 더 있다. 동적 검색은 MyBatis로 간다고 이미 정해져 있어서 ${} 바인딩(값을 검증 없이 SQL 문자열에 그대로 끼워 넣는 방식)이 열려 있다. 정렬 키나 커서 값이 검증 없이 SQL에 닿을 수 있는 구조라는 뜻이다.
원칙 1. 페이징 방식은 “임의 페이지로 점프할 수 있어야 하는가”로 가른다
정해야 했던 건 이거였다 — 두 방식을 한 타입에 담을 것인가, 타입을 나눌 것인가.
안 A — offset만 쓰고, 무한 스크롤도 page를 늘려 호출한다
// 무한 스크롤도 page 를 1, 2, 3 으로 늘려 부른다
PagingResult<OrderDomain> findAll(int page, int size);
얻는 것은 표준을 하나도 안 건드린다는 것이다. PagingResult가 그대로 남고 Controller도 한 모양이다.
버린 이유는 무한 스크롤과 offset이 최악의 조합이기 때문이다. 무한 스크롤은 깊이 들어가는 게 정상 사용인데, OFFSET이 커질수록 앞 행을 전부 읽고 버린다. 게다가 스크롤하는 동안 새 행이 들어오면 경계가 밀려서 이미 본 항목이 다시 나온다. 사용자가 같은 게시물을 두 번 보게 되는 건 성능 문제가 아니라 정확성 문제다.
안 B — 커서만 쓴다
CursorResult<OrderDomain> findAll(String cursorOrNull, int size);
얻는 것은 깊이 들어가도 비용이 일정하고, 타입이 하나로 유지된다는 것이다.
버린 이유는 임의 페이지 점프가 원리적으로 불가능하기 때문이다. 커서는 “마지막으로 본 행 다음부터”라고 말하는 방식이라 “3페이지”를 표현할 수단이 없다. 관리자 화면이 성립하지 않는다.
안 C — PagingResult 하나에 totalCount·page를 nullable로 둔다
public class PagingResult<T> {
private final List<T> content;
private final Integer pageOrNull; // 커서면 null
private final Long totalCountOrNull; // 커서면 null
private final String nextCursorOrNull; // offset 이면 null
}
얻는 것은 타입이 하나로 유지되고 Controller가 한 모양이라는 것이다.
버린 이유는 한 벌인 척하는 두 벌이 되기 때문이다. 어느 방식으로 만들어진 결과인지 타입으로 알 수 없어서 컴파일러가 아무것도 못 막는다. 커서 결과에 totalCount를 채워 넣는 코드를 짜도 통과하고, 관리자 화면에서 pageOrNull을 안 보고 넘어가도 통과한다. null 검사가 사람의 기억에 달린다.
안 D — CursorResult<T>를 따로 만든다
public class CursorResult<T> {
private final List<T> content;
private final String nextCursorOrNull; // 다음이 없으면 null
private final int size;
private final boolean hasNext;
}
골랐다 — 안 D. 안 A는 정확성을 잃고, 안 B는 화면 하나를 못 그리고, 안 C는 컴파일러가 못 가른다. 안 D는 타입이 곧 방식이라 커서 응답에 totalCount를 실을 방법이 아예 존재하지 않는다. 잘못 쓸 수 있는 문을 없앤 것이지 잘못 쓰지 말라고 적은 게 아니다.
대신 Controller가 두 모양이 되는 것을 감수한다. 목록 API의 응답 모양이 전역에서 같아지는 이점을 여기서 일부 잃는다.
가르는 기준은 “임의 페이지로 점프할 수 있어야 하는가”다. 그렇다면 PagingResult, 아니면 CursorResult다.
원칙 1-1. 가르는 기준으로 화면 종류나 결과 깊이를 쓰지 않는다
기준 후보가 넷이었다. 셋을 버린 이유가 남아 있어야 나중에 다시 꺼내지 않는다.
화면 종류(관리자/사용자)로 가른다 — 지금 상황과 정확히 일치하고 판단이 제일 쉽다. 버린 이유는 관리자 화면에 무한 스크롤이 하나 생기는 순간 깨진다는 것이다. “관리자”는 권한 개념이지 페이징 특성이 아니라서, 기준과 타입이 우연히 맞아 있었을 뿐이다.
totalCount를 화면에 표시하는가로 가른다 — 필드 유무와 기준이 그대로 맞닿아 직관적이다. 버린 이유는 “총 1,234건”만 띄우고 스크롤은 무한인 화면이 성립하기 때문이다. 이 기준은 offset을 고르는데 실제로 필요한 건 커서 + 별도 카운트다.
결과 집합에 깊게 들어가는가로 가른다 — 성능 문제의 원인을 그대로 기준으로 삼는 점이 정직하다. 버린 이유는 “깊다”의 숫자가 없다는 것이다. 그 숫자는 슬로우 쿼리 기준인데 아직 정한 적이 없다. 지금 정하면 근거 없는 상수가 박힌다.
골랐다 — 임의 페이지 점프. 나머지 셋이 “대체로 맞는 신호”인 데 비해, 이 기준은 타입이 할 수 있는 일과 1:1로 붙는다. 점프는 page 번호가 있어야만 가능하고 커서는 원리적으로 못 한다. 우연히 맞는 게 아니라 정의상 맞는다.
대신 화면 요구가 바뀌면 반환 타입이 바뀐다. 관리자 화면을 무한 스크롤로 돌리기로 하면 Controller 시그니처가 바뀐다. 안 D를 고르며 받아들인 “Controller가 두 모양”의 연장이다.
원칙 2. 두 반환 타입에 공통 상위 타입을 두지 않는다
안 D를 고르고 나면 바로 유혹이 온다 — 두 타입이 content를 공유하니 위에 인터페이스를 하나 두고 싶어진다.
// 이렇게 하지 않는다
public interface PageableResult<T> {
List<T> getContent();
}
한 줄씩 따라가면 — interface는 클래스와 달리 “이런 메서드가 있어야 한다”는 약속만 적고 실제 동작 코드는 적지 않는 틀이다(인터페이스 참고). 여기서 PageableResult<T>는 getContent()라는 메서드가 있어야 한다는 약속만 걸어 두고, PagingResult와 CursorResult가 각자 그 약속을 구현하게 만드는 모양이다. 이렇게 두면 Controller가 두 타입을 PageableResult<T> 하나로 받을 수 있게 되는데, 그 순간 “어느 방식으로 만들어진 결과인지 타입만 보고 알 수 없는” 상태로 되돌아간다.
두지 않는다. 두는 순간 안 D가 조용히 안 C로 돌아가기 때문이다. Controller가 PageableResult<T>로 받기 시작하면 다시 “어느 방식인지 타입으로 알 수 없는” 상태가 된다. 안 D를 고른 이유가 “타입이 곧 방식”이었으니, 그 이유를 지우는 추상화는 안 D의 일부가 아니라 안 D의 취소다.
그래서 map()도 두 타입이 각자 갖는다. 중복처럼 보이지만 여기서 줄이려는 중복이 곧 구분이다.
public <R> CursorResult<R> map(Function<T, R> mapper) {
return new CursorResult<>(content.stream().map(mapper).toList(), nextCursorOrNull, size, hasNext);
}
메서드 이름 앞의 <R>은 클래스 전체가 갖는 제네릭 <T>와 다르다 — map()이라는 메서드 하나만 쓰는 별도의 타입 자리로, 이 메서드를 부를 때마다 “이번에는 무엇으로 바꿀지”를 새로 정할 수 있게 비워 둔 것이다. 매개변수 타입 Function<T, R>은 자바가 기본으로 제공하는 함수형 인터페이스로, “T 하나를 받아 R 하나를 돌려주는 함수”라는 약속만 걸어 둔 틀이다. 그래서 mapper 자리에는 t -> someR처럼 T를 받아 R을 돌려주는 람다나 메서드를 아무거나 넘길 수 있고, map()은 그 함수를 받아 실제로 리스트의 각 원소에 적용한다.
한 줄씩 따라가면 — content.stream()은 리스트 content를 원소 하나씩 처리할 수 있는 스트림으로 바꾼다. .map(mapper)는 그 스트림의 원소마다 mapper 함수를 적용해 타입 T를 타입 R로 바꾼다. .toList()는 그렇게 바뀐 원소들을 다시 리스트로 모은다. 그래서 이 메서드는 타입 T로 된 content를 받아 mapper로 하나씩 타입 R로 바꾼 뒤, 나머지 필드(nextCursorOrNull·size·hasNext)는 그대로 둔 새 CursorResult<R>을 만들어 돌려준다.
대가는 두 타입에 비슷한 코드가 남는 것이다. 필드를 하나 더할 때 두 곳을 보게 된다.
원칙 3. 커서는 불투명 문자열 하나로 주고받는다
커서가 실어야 하는 건 마지막으로 본 행의 정렬 키 값과 id다. id가 같이 붙는 건 정렬 키 값이 같은 행이 여럿일 때 경계가 흔들리기 때문이다.
WHERE (created_at, id) < ('2026-08-03T10:00:00', 1234)
ORDER BY created_at DESC, id DESC
LIMIT 20
한 줄씩 따라가면 이렇다. WHERE (created_at, id) < ('2026-08-03T10:00:00', 1234)는 created_at과 id 두 값을 괄호로 묶어 한 쌍으로 놓고, “이 쌍보다 작은 행만 가져와라”고 묻는 조건이다. 한 컬럼씩 따로 비교하지 않고 쌍으로 비교하기 때문에, created_at이 똑같은 행이 여러 개 있어도 id가 기준이 되어 경계가 흔들리지 않는다. ORDER BY created_at DESC, id DESC는 그렇게 걸러진 행을 최신순으로 정렬하고, LIMIT 20은 정렬된 결과 중 앞에서 20건만 잘라 가져온다.
이 재료를 URL에 어떻게 얹느냐가 갈림길이었다.
안 가 — 풀어서 실는다
GET /orders?sort=createdAt,desc&lastCreatedAt=2026-08-03T10:00:00&lastId=1234&size=20
이 주소에서 GET은 “가져와라”는 뜻의 HTTP 메서드고, 물음표 뒤에 &로 이어진 sort=...·lastCreatedAt=...·lastId=...·size=...는 각각 쿼리 파라미터다.
얻는 것은 서버가 파싱할 게 없고 로그를 눈으로 읽을 수 있다는 것이다.
버린 이유는 정렬이 바뀌면 파라미터 이름 자체가 바뀐다는 것이다. 인기순으로 바꾸면 lastCreatedAt이 lastPopularity가 되어야 한다. 클라이언트가 “지금 정렬이 뭔지” 보고 파라미터 이름을 조립해야 하고, 서버가 정렬 키를 하나 추가하면 클라이언트도 같이 고쳐야 한다. 빠뜨리면 다음 페이지가 조용히 엉뚱한 곳부터 나온다.
골랐다 — 한 파라미터로 묶어 불투명하게 만든다.
GET /orders?sort=createdAt,desc&cursor=eyJ2IjoiMjAyNi0wOC0wM1QxMDowMDowMCIsImlkIjoxMjM0fQ&size=20
클라이언트가 하는 일이 정렬과 무관하게 같아진다 — 응답의 nextCursor를 그대로 되돌려주는 것뿐이다. 조립할 게 없다.
여기서 두 가지를 구분해야 한다. 묶는 이유는 계약이고, 감추는 이유는 그 계약을 지키게 만드는 것이다. 값에 콤마가 들어가는 문제는 퍼센트 인코딩으로도 해결되므로 Base64의 이유가 아니다. Base64를 쓰는 진짜 이유는 읽히면 언젠가 누가 파싱해서 쓰기 때문이다. 그 순간 커서의 내부 구조가 계약이 되어, (createdAt, id)를 (createdAt, status, id)로 바꾸는 날 클라이언트가 깨진다.
대가는 디버깅할 때 커서를 눈으로 못 읽는 것이다. 로그에 찍힌 커서가 어느 지점인지 알려면 디코딩해야 한다.
원칙 3-1. id는 정렬 기준과 같은 방향으로 붙인다
원칙 3의 SQL은 ORDER BY created_at DESC, id DESC였다. 정렬 기준이 내림차순이라 id도 내림차순이다. 그런데 이것이 규칙인지, 예시가 마침 그렇게 생긴 것인지는 적혀 있지 않았다. 목록 요청 계약 표준을 주니어 독자로 읽혔을 때 「id의 방향이 적혀 있지 않다」는 지적이 나온 자리다.
정해야 했던 건 이거였다 — 정렬 끝에 붙이는 id의 방향을 정렬 기준에 묶을지.
안 A — id는 늘 정렬 기준과 같은 방향으로 붙인다.
-- 이렇게 한다
ORDER BY created_at DESC, id DESC -- 정렬 기준이 DESC면 id도 DESC
ORDER BY total_amount ASC, id ASC -- 정렬 기준이 ASC면 id도 ASC
-- 이렇게 하지 않는다
ORDER BY created_at DESC, id ASC
한 줄씩 보면, 위 두 줄은 id가 앞의 정렬 기준과 같은 방향을 따라간 모양이고, 마지막 줄은 둘이 엇갈린 모양이다.
얻는 것은 두 곳이 함께 맞는다는 점이다. 하나는 인덱스다. 목록 쿼리가 쓰는 인덱스는 (정렬 기준, id) 모양이다(목록 요청 계약 표준 원칙 6-1). 인덱스는 정렬된 목록이라 처음부터 또는 끝부터 차례로 읽을 수 있다. 그래서 인덱스 하나는 처음부터 읽으면 둘 다 ASC, 끝부터 읽으면 둘 다 DESC인 순서만 준다(같은 표준 원칙 5). 방향이 엇갈리면 인덱스로 순서를 바로 얻지 못해, DB가 조건에 맞는 행을 모아 다시 정렬하는 단계가 더 붙는다. 행이 많으면 그만큼 느려진다(얼마나 느려지는지는 재지 않았다). 다른 하나는 커서 조건이다. 원칙 3의 (created_at, id) < (?, ?)는 두 칸을 한 쌍으로 비교한다 — created_at이 더 작거나, 같으면 id가 더 작은 행이다. 이 조건은 두 칸이 모두 내림차순일 때의 「다음 행들」과 정확히 겹친다. 두 칸이 모두 오름차순(total_amount ASC, id ASC)이면 다음 행은 쌍이 더 큰 쪽이라 조건도 (total_amount, id) > (?, ?)로 부등호만 뒤집힌다. 어느 쪽이든 두 칸이 같은 방향이어야 쌍 비교 하나로 경계가 잡힌다. 내림차순에서 id만 ASC로 정렬하면 같은 created_at 묶음 안에서 다음 행은 id가 더 큰 쪽인데 조건은 더 작은 쪽을 가져오므로, 그 묶음 안에서 경계가 어긋난다.
안 B — 규칙을 두지 않고 예시만 둔다. 얻는 것은 규칙 줄이 하나 줄고, 예시가 이미 같은 방향이라 대부분은 그대로 따라 짠다는 점이다. 버린 이유는 누가 방향을 엇갈리게 짜도 막을 근거가 없다는 것이다. 리뷰에서 「예시와 다르다」고는 말할 수 있어도 「규칙 위반」이라고는 못 한다.
골랐다 — 안 A. 안 B가 예시에 맡겨 둔 것을 규칙으로 묶어, 인덱스와 커서 조건이 함께 맞는 모양 하나만 남긴다. 대신 같은 정렬 기준 값 안에서 id의 방향을 따로 고를 수는 없다. 엇갈린 방향에서 실제로 정렬 단계가 붙는지는 실행 계획으로 재지 않았다 — 근거는 목록 요청 계약 표준 원칙 5가 인용한 PostgreSQL 문서다.
원칙 4. 디코딩한 커서를 신뢰하지 않는다
불투명하게 만들면 “클라이언트가 못 건드린다”고 읽히기 쉬운데, Base64는 인코딩이지 서명이 아니다. 누구나 디코딩해서 값을 바꿔 다시 보낼 수 있다.
동적 검색이 MyBatis로 가면서 ${} 바인딩이 열려 있으므로, 디코딩한 값을 그대로 쿼리에 넣으면 커서가 새로운 주입 표면이 된다. 정렬 파라미터를 막아놓고 커서로 뚫리면 막은 의미가 없다.
그래서 디코딩 직후에 검증한다.
// 1. Base64 디코딩 → {"v": "...", "id": 1234}
// 2. v 가 정렬 키의 타입으로 파싱되는가 ← 안 되면 400
// 3. id 가 Long 으로 파싱되는가 ← 안 되면 400
// 4. WHERE (created_at, id) < (?, ?) ← 반드시 바인딩 파라미터로 (오름차순이면 >)
한 줄씩 따라가면 — 디코딩한 v나 id가 기대한 타입으로 안 바뀌면 그 요청을 거부하고 400을 돌려준다. 마지막 줄의 “바인딩 파라미터”는 「먼저: 화면 요구가 둘 다였다」와 이 원칙 첫머리에서 본 ${} 바인딩과 이름이 같아 보이지만 반대다. ${}는 값을 SQL 문자열에 그대로 끼워 넣어 검증 없이 통과시키는 방식이고, 여기서 말하는 바인딩 파라미터(위 SQL의 ? 자리, MyBatis 문법으로는 #{})는 값을 SQL 문법과 분리해 따로 전달해서 DB가 “이건 코드가 아니라 값”이라고 구분하게 만드는 방식이다. 그래서 ${}는 주입 표면이 되고 #{}(바인딩 파라미터)는 안전하다.
정렬을 여는 날 여기에 허용 정렬 키 화이트리스트 대조가 한 줄 더 붙는다. 지금은 정렬 키가 하나라 타입 검증이 전부다.
불투명한 것과 안전한 것은 다르다. 원칙 3이 사는 건 계약 안정성이고, 안전은 이 원칙이 담당한다. 둘을 한 근거로 묶어두면 나중에 “불투명하니까 검증 생략해도 되지 않나”가 나온다.
원칙 5. 확장성은 계약의 모양에서 사고, 구현에서는 사지 않는다
정렬을 지금 열 것인가에서 한 번, 다중 정렬을 지금 허용할 것인가에서 또 한 번 — 같은 갈림길을 두 번 만났고 두 번 다 답이 같았다. 그래서 원칙으로 세운다.
| 갈림길 | 지금 연다 | 계약만 열어둔다 |
|---|---|---|
| 정렬 변경 | 화이트리스트 + 인덱스 N개를 지금 만든다 | 커서를 불투명하게 둔다 |
| 다중 정렬 | 허용 조합 열거 + 인덱스 다수 | sort를 반복 가능한 파라미터로 받는다 |
핵심은 계약은 나중에 못 바꾸지만 구현은 언제든 바꿀 수 있다는 비대칭이다. 그래서 확장성은 계약 쪽에서 사는 게 싸다.
다중 정렬이 그대로 예다. HTTP 쿼리 파라미터는 원래 반복 가능하므로, 지금 하나만 읽어도 나중에 여러 개를 읽게 바꾸는 것은 하위 호환이다.
// 지금부터 이렇게 받는다. 읽는 건 첫 번째 하나뿐이다
@RequestParam List<String> sort
한 줄씩 따라가면 — @RequestParam은 주소의 물음표 뒤에 붙은 값을 메서드 인자로 받는 표시다. 타입을 List<String>으로 받으면 같은 이름의 파라미터가 sort=a&sort=b처럼 여러 번 와도 리스트로 모아 받을 수 있는데, 지금은 그렇게 모은 리스트 중 첫 번째 값만 읽어서 쓴다.
기존 클라이언트는 하나만 보내고 그대로 동작한다. 커서도 마찬가지다 — 다중 정렬을 켜면 커서 안 튜플이 (v, id)에서 (v1, v2, id)로 길어지지만 불투명이라 클라이언트는 그걸 모른다.
반대로 지금 다중 정렬을 열면 비용이 즉시 생긴다. 정렬 키 3개를 다중으로 허용하면 컬럼 순서로 15가지, 방향까지 곱하면 78가지다. (a, b)와 (b, a)가 다른 인덱스이고 ASC/DESC가 섞이면 또 다른 인덱스라서다. 인덱스는 그 절반인 39개면 된다 — 리프를 뒤에서부터 읽으면 모든 컬럼의 방향이 한꺼번에 뒤집혀서, 인덱스 하나가 자기 자신과 전체 반전을 함께 커버한다.
왜 뒤에서 읽으면 전체가 한꺼번에 뒤집히는지 짚어보면 이렇다. 인덱스는 리프를 오름차순으로 쭉 이어 둔 모양이다. 예를 들어 (a ASC, b ASC)로 만든 인덱스는 리프에 a가 작은 것부터, a가 같으면 b가 작은 것부터 순서대로 쌓여 있다. 이 리프를 앞에서부터 읽으면 그대로 (a ASC, b ASC) 순서가 나오고, 뒤에서부터 거꾸로 읽으면 가장 큰 a부터 나오고 a가 같은 구간 안에서도 가장 큰 b부터 나온다 — 즉 (a DESC, b DESC) 순서가 그대로 나온다. 컬럼이 몇 개든 같은 원리다. 리프를 거꾸로 읽는 동작 하나가 그 안에 든 모든 컬럼의 방향을 동시에 뒤집기 때문에, (a ASC, b ASC)용으로 인덱스 하나를 만들면 (a DESC, b DESC)는 그 인덱스를 거꾸로 읽기만 하면 되고 따로 만들 필요가 없다. 78가지 조합은 이렇게 “자기 자신”과 “모든 컬럼을 뒤집은 쌍”으로 둘씩 짝지어지고, 짝마다 인덱스 하나면 되니 78의 절반인 39개가 된다.
그래도 39개는 만들 수 없으니 결국 “허용 조합 열거”가 되는데, 그 열거 규칙은 아직 정하지 않은 인덱스 표준의 몫이다.
그리고 열어놓고 인덱스를 안 만드는 것이 제일 위험하다. 표준이 “된다”고 말하는데 실제로는 파일소트로 간다. 이 저장소에서 표준이 허용한 것은 표준이 보장한 것으로 읽히므로, 보장 못 할 것을 허용으로 적으면 그게 함정이 된다.
원칙 6. 페이징 메타는 조회 그 자체에서 나오므로 Repository가 만든다
계층별 DTO 네이밍 표준은 PagingResult에 Service 접두사가 없는 이유를 이렇게 적었다.
totalCount가 DB의 COUNT 쿼리에서 나오기 때문에 Repository가 그걸 실어 올릴 수밖에 없다.
CursorResult가 들어오면 이 문장으로는 설명이 안 된다. CursorResult에는 totalCount가 없는데도 Repository가 만들어야 하기 때문이다. hasNext를 알려면 size + 1건을 읽어 잘라내야 하고, nextCursor는 마지막 행의 정렬 키 값이라 조회 결과를 봐야만 나온다.
결론은 같은데 근거가 다르다. 그래서 근거를 한 단계 위로 올린다.
페이징 메타는 조회 그 자체에서만 나오므로 Repository가 만든다. 그래서 페이징 봉투에는
Service접두사를 붙이지 않는다.
totalCount는 그 규칙의 사례 하나였을 뿐이다. 이 문장으로 바꾸면 PagingResult와 CursorResult가 같은 이유로 설명된다.
기존 글도 함께 고친다. 근거만 남고 사례가 바뀌면 다음에 세 번째 페이징 봉투가 생겨도 같은 규칙이 그대로 통한다.
원칙 7. 자라는 테이블은 페이지나 limit 없이 List로 통째로 돌려주지 않는다
원칙 1~6은 나눠서 돌려주는 목록의 모양을 정했다. 나누지 않고 돌려주는 목록은 이 글이 말한 적이 없다. 그런데 그 자리가 비어 있었다.
API 쪽 문은 닫혀 있다. 목록 요청 계약 표준 원칙 3이 size의 전역 상한을 100으로 정해서, 클라이언트가 size=100000을 보내면 400으로 거절된다. 그 상한은 size를 받는 메서드에만 걸린다. Repository 안에 처음부터 size가 없는 메서드가 있으면 상한이 닿을 데가 없다.
// 이렇게 하지 않는다 — 간병인은 날마다 는다
List<CaregiverDomain> findAllOrderByCaregiverNoAsc();
이 메서드는 이름 그대로 간병인 테이블의 행을 전부 가져온다. 오늘 300명이면 300행, 3년 뒤 3만 명이면 3만 행이다. 부르는 쪽 코드는 한 글자도 안 바뀌는데 한 번 부를 때 DB가 읽어 보내는 행도, 그 행을 담느라 서버 메모리에 올라가는 객체도 테이블 크기를 따라 자란다 — 3만 행이면 응답이 느려지고, 여러 요청이 겹치면 메모리가 찬다. 원칙 1의 반환 타입도, 목록 요청 계약의 상한도 이 메서드를 지나가지 않는다.
실제로 있었다. API 비용을 숫자로 적자는 이슈(#47)를 쓸 때, 이 표준을 실제로 쓰는 프로젝트 하나를 세어 보니, 페이지도 limit도 없이 List를 돌려주는 Repository 메서드가 13곳이었다. 그중 지역·공통코드처럼 크기가 업무로 정해진 참조 테이블도 있었지만, Caregiver·Competitor·ContentText는 자라는 테이블이었다. 둘을 가르는 말이 코드 어디에도 없었다. 13곳을 열어 봐도 어느 것이 위험한지 알려면 테이블마다 업무를 다시 따져야 했다.
정해야 했던 건 이거였다 — 크기 상한이 없는 조회를 어떻게 드러내고 막는가.
안 A — 모든 엔드포인트에 비용 어노테이션을 단다. 이슈(#47)가 처음 낸 안이다. 엔드포인트마다 @Cost(queries = "O(1)", rows = "O(page)")처럼 DB 왕복 수와 반환 행 수의 상한을 적는다. 얻는 것은 페이지가 없는 조회가 rows = "O(전체)"로 적힐 수밖에 없어 드러난다는 점이다. O(1)은 「데이터가 늘어도 한 번」, O(page)는 「한 페이지만큼」, O(전체)는 「테이블 전부」라는 뜻의 Big-O 표기다. 버린 이유는 소음이다. 같은 이슈가 먼저 버린 안 — 자바 메서드마다 반복문의 Big-O를 적자 — 과 같은 병이다. 그 안은 그 프로젝트의 자바 파일 552개 전부에 적게 하면 548개가 O(1)·O(n)으로 채워지고, 반복문이 겹쳐 실제로 위험한 파일 4개가 그 속에 묻힌다는 이유로 버렸다(위의 Repository 13곳과는 따로 센 숫자다). 엔드포인트마다 @Cost를 달면 대부분이 O(page)로 채워지고, 위험한 몇 곳이 똑같이 묻힌다.
골랐다 — 위험한 모양 하나만 규칙으로 막는다. 자라는 테이블에서 페이지나 limit 없이 List를 돌려주는 Repository 메서드를 만들지 않는다. 예외는 크기가 업무로 정해진 참조 테이블 — 사용자가 쓸수록 커지지 않는 표 — 뿐이고, 그 메서드 위에 왜 상한이 있는지를 주석으로 적는다.
// 상한이 있는 이유: 공통코드 그룹 하나의 코드는 운영자가 정의한 목록이고 사용자 행동으로 늘지 않는다
List<CommonCodeDomain> findAllByGroup(String group);
한 줄씩 보면, 메서드 자체는 위의 간병인 조회와 모양이 같다 — 페이지 없이 List를 돌려준다. 다른 것은 위의 주석 한 줄이다. 그 주석이 「이 테이블은 사용자가 쓸수록 커지지 않는다」는 판단을 메서드 옆에 남긴다. 주석이 없는 페이지 없는 List 메서드는 그래서 판단을 빠뜨린 자리로 읽힌다.
안 A와 견주면, 이 규칙은 엔드포인트 전부가 아니라 위험한 모양 하나만 겨눈다. 안전한 대다수에는 아무것도 적지 않으니 소음이 생기지 않고, 예외에는 이유가 붙어서 「참조 테이블이라 괜찮은 것」과 「자라는 테이블인데 빠뜨린 것」이 갈린다.
안건의 다른 축은 아직 정하지 않았다. 안 A의 queries 축, 곧 엔드포인트 하나가 DB를 몇 번 오가는지는 그 프로젝트에서 엔드포인트별 쿼리 수를 재 본 뒤 정한다. 그래서 이슈(#47)는 열어 둔다.
대가는 「참조 테이블인가」를 사람이 판단한다는 것이다. 지금은 공통코드였던 테이블이 나중에 사용자 입력으로 늘기 시작하면, 주석은 그대로 남아 틀린 말을 한다. 그리고 이 규칙을 무엇으로 강제할지는 정하지 않았다 — 지금은 리뷰가 본다.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 임의 페이지로 점프해야 하는가 | 그렇다 | PagingResult<T> |
| 아니다 | CursorResult<T> |
|
| 두 타입에 공통 인터페이스를 둘까 | 두지 않는다 | 두면 안 C로 되돌아간다 |
| 커서를 어떻게 싣는가 | 불투명 문자열 한 파라미터 | 정렬을 바꿔도 계약이 안 바뀐다 |
id는 어느 방향으로 붙이나 |
정렬 기준과 같은 방향 | 인덱스로 순서가 나오고 (정렬키, id) 비교가 맞는다 |
| 불투명하면 안전한가 | 아니다 | 디코딩 후 타입 검증 + 바인딩 파라미터 |
| 다중 정렬을 허용하는가 | 계약만 열어둔다 | @RequestParam List<String> sort, 읽는 건 하나 |
페이징 봉투에 Service 접두사를 붙이는가 |
아니다 | 메타가 조회에서 나와 Repository가 만든다 |
페이지 없이 List를 돌려주는 Repository 메서드를 두나 |
자라는 테이블에는 두지 않는다 | 참조 테이블만 예외이고 메서드 위에 상한의 이유를 주석으로 적는다 |
이 표준을 정하기까지
중단 지점이 장부에 남아 있었다. 안 A·B·C를 버린 이유까지는 적혀 있고 “안 D가 유력하나 확정 전”에서 끊겨 있었다. 대화는 끊겼는데 파일이 남아서, 안을 처음부터 다시 비교하지 않고 확정 작업부터 시작할 수 있었다.
확정하기 전에 이름 규칙의 근거가 깨진다는 걸 먼저 발견했다. CursorResult에는 totalCount가 없는데 기존 글은 접두사가 없는 이유를 totalCount로 설명하고 있었다. 결론은 같고 근거만 어긋난 경우라 그냥 넘어가기 쉬웠는데, 넘어갔으면 다음 사람이 “얘는 왜 접두사가 없지”를 다시 물었을 것이다. 원칙 6이 여기서 나왔다.
공통 상위 타입을 두지 않는다는 것까지가 안 D임을 못 박았다. 안 D를 고르고 나면 두 타입을 인터페이스로 묶고 싶어지는데, 묶는 순간 안 C가 된다. 고른 안을 적는 것만으로는 부족하고, 그 안이 무너지는 경로까지 막아야 한다는 걸 알았다.
가르는 기준으로는 넷을 비교했다. 화면 종류는 지금 상황과 정확히 맞지만 관리자 화면에 무한 스크롤이 하나 생기면 깨진다. totalCount 표시 여부는 필드와 맞닿아 보이는데 “총 건수만 보여주는 무한 스크롤”이 반례였다. 결과 깊이는 “깊다”의 숫자가 없어서 지금 정하면 근거 없는 상수가 된다. 남은 건 임의 페이지 점프였고, 이게 우연히 맞는 게 아니라 정의상 타입과 1:1로 붙는 유일한 기준이었다.
커서를 인코딩하는 이유를 잘못 알 뻔했다. 처음엔 “콤마가 들어가면 안 되니까 묶는다”로 읽혔는데 순서가 반대였다. 콤마 때문에 묶는 게 아니라 묶었더니 콤마가 문제가 된 것이다. 검문은 한 줄이었다 — 콤마가 문제라면 퍼센트 인코딩으로 끝난다. 그런데도 Base64로 가는 이유가 따로 있다면 그게 진짜 이유다. 그 이유는 “읽히면 파싱된다”였다.
확장성을 어디서 사는지가 두 번 반복돼서 원칙이 됐다. 정렬을 지금 열 것인가에서 한 번, 다중 정렬을 지금 허용할 것인가에서 또 한 번 같은 답이 나왔다. 두 번째에 답이 같은 걸 보고 이게 이 문제만의 판단이 아니라는 걸 알았다. 원칙 5는 그렇게 사후에 이름이 붙은 규칙이다.
다중 정렬의 조합 수를 세어보고 판단이 굳었다. 처음엔 컬럼 순서만 세어 15가지였는데, ASC/DESC가 섞이면 인덱스를 못 탄다는 사실을 넣자 78가지가 됐다. 15도 못 만드는데 78이라는 숫자가 “허용 조합 열거”를 피할 수 없게 만들었고, 그 열거 규칙이 아직 없다는 이유로 미루는 판단이 나왔다. 2026-08-05에 인덱스 수를 다시 셌다 — 뒤에서 읽기 덕에 조합 78가지에 인덱스는 39개다. 39도 못 만드는 수라 이 판단은 그대로다.
인덱스 원리를 근거로 쓰면서 그 원리를 설명하지 못한다는 걸 알았다. “복합 인덱스는 선행 컬럼이 빠지면 못 탄다”를 근거로 판단을 여러 번 했는데, B-tree가 그걸 어떻게 저장하길래 그런지는 설명하지 못했다. 정한 것과 이해한 것이 다른 상태였다. 이건 표준으로 닫을 수 있는 종류가 아니라서 공부 목록 페이지를 새로 만들고 거기로 넘겼다.
예시로만 있던 id의 방향을 규칙으로 올렸다(2026-10-21). 원칙 3의 SQL은 처음부터 created_at DESC, id DESC였지만, 같은 방향이어야 한다는 말은 어디에도 없었다. 목록 요청 계약 표준을 주니어 독자로 읽혔더니 「id의 방향이 적혀 있지 않다」가 나왔다. 예시만 두는 안은 엇갈리게 짠 코드를 막을 근거가 없어서 버렸고, 인덱스 (정렬 기준, id)가 앞뒤 두 순서만 주는 것과 커서의 복합 비교가 두 칸이 같은 방향일 때만 맞는 것을 근거로 「id는 정렬 기준과 같은 방향」을 원칙 3-1로 세웠다.
페이지 없는 전체 조회를 이 글에 더했다(2026-10-21, #47). API 비용을 엔드포인트마다 숫자로 적자는 안건에서 rows 축만 먼저 닫았다. API의 size 상한은 목록 요청 계약이 막았지만 Repository 안의 전체 조회는 막는 규칙이 없었고, 그 프로젝트에 그런 메서드가 13곳 있었다. 모든 엔드포인트에 @Cost를 다는 안은 자바 Big-O를 모든 파일에 적는 안과 같은 소음이라 버리고, 위험한 모양 하나만 규칙으로 막았다(원칙 7). 이 글에 둔 것은 「목록을 어떻게 나눠 돌려주나」를 정하는 글이라 「나누지 않고 돌려주는 경우」도 같은 질문의 반대편이기 때문이다. queries 축은 쿼리 수를 재 본 뒤에 정한다.
정리
- 페이징 방식은 “임의 페이지로 점프할 수 있어야 하는가”로 가른다. 그렇다면
PagingResult<T>, 아니면CursorResult<T> - 타입이 곧 방식이다. 커서 응답에
totalCount를 실을 방법이 존재하지 않게 만드는 것이 안 D의 값이다 - 두 타입에 공통 상위 타입을 두지 않는다. 두는 순간 타입으로 방식을 구분할 수 없게 되어 되돌아간다
- 커서는 불투명 문자열 하나로 주고받는다. 묶는 이유는 계약이고, 감추는 이유는 읽히면 파싱되기 때문이다
id는 정렬 기준과 같은 방향으로 붙인다. 엇갈리면 인덱스로 순서가 안 나오고 커서 비교가 어긋난다- 불투명한 것과 안전한 것은 다르다. 디코딩 후 타입을 검증하고 반드시 바인딩 파라미터로 넣는다
- 확장성은 계약의 모양에서 사고 구현에서는 사지 않는다. 계약은 나중에 못 바꾸고 구현은 언제든 바꿀 수 있다
- 페이징 메타는 조회 그 자체에서 나오므로 Repository가 만든다.
totalCount는 그 규칙의 사례 하나였다 - 자라는 테이블은 페이지나
limit없이List로 돌려주지 않는다. API의size상한은 Repository 안의 전체 조회에 닿지 않는다. 참조 테이블만 예외이고 상한의 이유를 주석으로 남긴다
남겨둔 것이 하나 있다. 정렬이 바뀌면 기존 커서가 무효라는 사실은 여기 적어두지만 처리 방법은 정하지 않았다 — 거부할지 첫 페이지로 되돌릴지는 정렬 키 화이트리스트를 정하는 날 함께 본다. 목록 쿼리가 쓰는 인덱스의 모양 (정렬 기준, id)는 이 글이 아니라 목록 요청 계약 표준 원칙 6-1에서 정했다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기