목록 조회를 어떻게 반환할지는 이미 정해져 있었다. 계층별 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에 닿을 수 있는 구조라는 뜻이다.


원칙 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();
}

두지 않는다. 두는 순간 안 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);
}

대가는 두 타입에 비슷한 코드가 남는 것이다. 필드를 하나 더할 때 두 곳을 보게 된다.


원칙 3. 커서는 불투명 문자열 하나로 주고받는다

커서가 실어야 하는 건 마지막으로 본 행의 정렬 키 값과 id다. id가 같이 붙는 건 정렬 키 값이 같은 행이 여럿일 때 경계가 흔들리기 때문이다.

WHERE (created_at, id) < ('2026-08-03T10:00:00', 1234)
ORDER BY created_at DESC, id DESC
LIMIT 20

이 재료를 URL에 어떻게 얹느냐가 갈림길이었다.

안 가 — 풀어서 실는다

GET /orders?sort=createdAt,desc&lastCreatedAt=2026-08-03T10:00:00&lastId=1234&size=20

얻는 것은 서버가 파싱할 게 없고 로그를 눈으로 읽을 수 있다는 것이다.

버린 이유는 정렬이 바뀌면 파라미터 이름 자체가 바뀐다는 것이다. 인기순으로 바꾸면 lastCreatedAtlastPopularity가 되어야 한다. 클라이언트가 “지금 정렬이 뭔지” 보고 파라미터 이름을 조립해야 하고, 서버가 정렬 키를 하나 추가하면 클라이언트도 같이 고쳐야 한다. 빠뜨리면 다음 페이지가 조용히 엉뚱한 곳부터 나온다.

골랐다 — 한 파라미터로 묶어 불투명하게 만든다.

GET /orders?sort=createdAt,desc&cursor=eyJ2IjoiMjAyNi0wOC0wM1QxMDowMDowMCIsImlkIjoxMjM0fQ&size=20

클라이언트가 하는 일이 정렬과 무관하게 같아진다 — 응답의 nextCursor를 그대로 되돌려주는 것뿐이다. 조립할 게 없다.

여기서 두 가지를 구분해야 한다. 묶는 이유는 계약이고, 감추는 이유는 그 계약을 지키게 만드는 것이다. 값에 콤마가 들어가는 문제는 퍼센트 인코딩으로도 해결되므로 Base64의 이유가 아니다. Base64를 쓰는 진짜 이유는 읽히면 언젠가 누가 파싱해서 쓰기 때문이다. 그 순간 커서의 내부 구조가 계약이 되어, (createdAt, id)(createdAt, status, id)로 바꾸는 날 클라이언트가 깨진다.

대가는 디버깅할 때 커서를 눈으로 못 읽는 것이다. 로그에 찍힌 커서가 어느 지점인지 알려면 디코딩해야 한다.


원칙 4. 디코딩한 커서를 신뢰하지 않는다

불투명하게 만들면 “클라이언트가 못 건드린다”고 읽히기 쉬운데, Base64는 인코딩이지 서명이 아니다. 누구나 디코딩해서 값을 바꿔 다시 보낼 수 있다.

동적 검색이 MyBatis로 가면서 ${} 바인딩이 열려 있으므로, 디코딩한 값을 그대로 쿼리에 넣으면 커서가 새로운 주입 표면이 된다. 정렬 파라미터를 막아놓고 커서로 뚫리면 막은 의미가 없다.

그래서 디코딩 직후에 검증한다.

// 1. Base64 디코딩 → {"v": "...", "id": 1234}
// 2. v 가 정렬 키의 타입으로 파싱되는가        ← 안 되면 400
// 3. id 가 Long 으로 파싱되는가                ← 안 되면 400
// 4. WHERE (created_at, id) < (?, ?)          ← 반드시 바인딩 파라미터로

정렬을 여는 날 여기에 허용 정렬 키 화이트리스트 대조가 한 줄 더 붙는다. 지금은 정렬 키가 하나라 타입 검증이 전부다.

불투명한 것과 안전한 것은 다르다. 원칙 3이 사는 건 계약 안정성이고, 안전은 이 원칙이 담당한다. 둘을 한 근거로 묶어두면 나중에 “불투명하니까 검증 생략해도 되지 않나”가 나온다.


원칙 5. 확장성은 계약의 모양에서 사고, 구현에서는 사지 않는다

정렬을 지금 열 것인가에서 한 번, 다중 정렬을 지금 허용할 것인가에서 또 한 번 — 같은 갈림길을 두 번 만났고 두 번 다 답이 같았다. 그래서 원칙으로 세운다.

갈림길 지금 연다 계약만 열어둔다
정렬 변경 화이트리스트 + 인덱스 N개를 지금 만든다 커서를 불투명하게 둔다
다중 정렬 허용 조합 열거 + 인덱스 다수 sort를 반복 가능한 파라미터로 받는다

핵심은 계약은 나중에 못 바꾸지만 구현은 언제든 바꿀 수 있다는 비대칭이다. 그래서 확장성은 계약 쪽에서 사는 게 싸다.

다중 정렬이 그대로 예다. HTTP 쿼리 파라미터는 원래 반복 가능하므로, 지금 하나만 읽어도 나중에 여러 개를 읽게 바꾸는 것은 하위 호환이다.

// 지금부터 이렇게 받는다. 읽는 건 첫 번째 하나뿐이다
@RequestParam List<String> sort

기존 클라이언트는 하나만 보내고 그대로 동작한다. 커서도 마찬가지다 — 다중 정렬을 켜면 커서 안 튜플이 (v, id)에서 (v1, v2, id)로 길어지지만 불투명이라 클라이언트는 그걸 모른다.

반대로 지금 다중 정렬을 열면 비용이 즉시 생긴다. 정렬 키 3개를 다중으로 허용하면 컬럼 순서로 15가지, 방향까지 곱하면 78가지다. (a, b)(b, a)가 다른 인덱스이고 ASC/DESC가 섞이면 또 다른 인덱스라서다. 인덱스 78개는 만들 수 없으니 결국 “허용 조합 열거”가 되는데, 그 열거 규칙은 아직 정하지 않은 인덱스 표준의 몫이다.

그리고 열어놓고 인덱스를 안 만드는 것이 제일 위험하다. 표준이 “된다”고 말하는데 실제로는 파일소트로 간다. 이 저장소에서 표준이 허용한 것은 표준이 보장한 것으로 읽히므로, 보장 못 할 것을 허용으로 적으면 그게 함정이 된다.


원칙 6. 페이징 메타는 조회 그 자체에서 나오므로 Repository가 만든다

계층별 DTO 네이밍 표준PagingResultService 접두사가 없는 이유를 이렇게 적었다.

totalCount가 DB의 COUNT 쿼리에서 나오기 때문에 Repository가 그걸 실어 올릴 수밖에 없다.

CursorResult가 들어오면 이 문장으로는 설명이 안 된다. CursorResult에는 totalCount가 없는데도 Repository가 만들어야 하기 때문이다. hasNext를 알려면 size + 1건을 읽어 잘라내야 하고, nextCursor는 마지막 행의 정렬 키 값이라 조회 결과를 봐야만 나온다.

결론은 같은데 근거가 다르다. 그래서 근거를 한 단계 위로 올린다.

페이징 메타는 조회 그 자체에서만 나오므로 Repository가 만든다. 그래서 페이징 봉투에는 Service 접두사를 붙이지 않는다.

totalCount는 그 규칙의 사례 하나였을 뿐이다. 이 문장으로 바꾸면 PagingResultCursorResult가 같은 이유로 설명된다.

기존 글도 함께 고친다. 근거만 남고 사례가 바뀌면 다음에 세 번째 페이징 봉투가 생겨도 같은 규칙이 그대로 통한다.


판단 기준 정리

질문 결론
임의 페이지로 점프해야 하는가 그렇다 PagingResult<T>
  아니다 CursorResult<T>
두 타입에 공통 인터페이스를 둘까 두지 않는다 두면 안 C로 되돌아간다
커서를 어떻게 싣는가 불투명 문자열 한 파라미터 정렬을 바꿔도 계약이 안 바뀐다
불투명하면 안전한가 아니다 디코딩 후 타입 검증 + 바인딩 파라미터
다중 정렬을 허용하는가 계약만 열어둔다 @RequestParam List<String> sort, 읽는 건 하나
페이징 봉투에 Service 접두사를 붙이는가 아니다 메타가 조회에서 나와 Repository가 만든다

이 표준을 정하기까지

중단 지점이 장부에 남아 있었다. 안 A·B·C를 버린 이유까지는 적혀 있고 “안 D가 유력하나 확정 전”에서 끊겨 있었다. 대화는 끊겼는데 파일이 남아서, 안을 처음부터 다시 비교하지 않고 확정 작업부터 시작할 수 있었다.

확정하기 전에 이름 규칙의 근거가 깨진다는 걸 먼저 발견했다. CursorResult에는 totalCount가 없는데 기존 글은 접두사가 없는 이유를 totalCount로 설명하고 있었다. 결론은 같고 근거만 어긋난 경우라 그냥 넘어가기 쉬웠는데, 넘어갔으면 다음 사람이 “얘는 왜 접두사가 없지”를 다시 물었을 것이다. 원칙 6이 여기서 나왔다.

공통 상위 타입을 두지 않는다는 것까지가 안 D임을 못 박았다. 안 D를 고르고 나면 두 타입을 인터페이스로 묶고 싶어지는데, 묶는 순간 안 C가 된다. 고른 안을 적는 것만으로는 부족하고, 그 안이 무너지는 경로까지 막아야 한다는 걸 알았다.

가르는 기준으로는 넷을 비교했다. 화면 종류는 지금 상황과 정확히 맞지만 관리자 화면에 무한 스크롤이 하나 생기면 깨진다. totalCount 표시 여부는 필드와 맞닿아 보이는데 “총 건수만 보여주는 무한 스크롤”이 반례였다. 결과 깊이는 “깊다”의 숫자가 없어서 지금 정하면 근거 없는 상수가 된다. 남은 건 임의 페이지 점프였고, 이게 우연히 맞는 게 아니라 정의상 타입과 1:1로 붙는 유일한 기준이었다.

커서를 인코딩하는 이유를 잘못 알 뻔했다. 처음엔 “콤마가 들어가면 안 되니까 묶는다”로 읽혔는데 순서가 반대였다. 콤마 때문에 묶는 게 아니라 묶었더니 콤마가 문제가 된 것이다. 검문은 한 줄이었다 — 콤마가 문제라면 퍼센트 인코딩으로 끝난다. 그런데도 Base64로 가는 이유가 따로 있다면 그게 진짜 이유다. 그 이유는 “읽히면 파싱된다”였다.

확장성을 어디서 사는지가 두 번 반복돼서 원칙이 됐다. 정렬을 지금 열 것인가에서 한 번, 다중 정렬을 지금 허용할 것인가에서 또 한 번 같은 답이 나왔다. 두 번째에 답이 같은 걸 보고 이게 이 문제만의 판단이 아니라는 걸 알았다. 원칙 5는 그렇게 사후에 이름이 붙은 규칙이다.

다중 정렬의 조합 수를 세어보고 판단이 굳었다. 처음엔 컬럼 순서만 세어 15가지였는데, ASC/DESC가 섞이면 인덱스를 못 탄다는 사실을 넣자 78가지가 됐다. 15도 못 만드는데 78이라는 숫자가 “허용 조합 열거”를 피할 수 없게 만들었고, 그 열거 규칙이 아직 없다는 이유로 미루는 판단이 나왔다.

인덱스 원리를 근거로 쓰면서 그 원리를 설명하지 못한다는 걸 알았다. “복합 인덱스는 선행 컬럼이 빠지면 못 탄다”를 근거로 판단을 여러 번 했는데, B-tree가 그걸 어떻게 저장하길래 그런지는 설명하지 못했다. 정한 것과 이해한 것이 다른 상태였다. 이건 표준으로 닫을 수 있는 종류가 아니라서 공부 목록 페이지를 새로 만들고 거기로 넘겼다.


정리

  • 페이징 방식은 “임의 페이지로 점프할 수 있어야 하는가”로 가른다. 그렇다면 PagingResult<T>, 아니면 CursorResult<T>
  • 타입이 곧 방식이다. 커서 응답에 totalCount를 실을 방법이 존재하지 않게 만드는 것이 안 D의 값이다
  • 두 타입에 공통 상위 타입을 두지 않는다. 두는 순간 타입으로 방식을 구분할 수 없게 되어 되돌아간다
  • 커서는 불투명 문자열 하나로 주고받는다. 묶는 이유는 계약이고, 감추는 이유는 읽히면 파싱되기 때문이다
  • 불투명한 것과 안전한 것은 다르다. 디코딩 후 타입을 검증하고 반드시 바인딩 파라미터로 넣는다
  • 확장성은 계약의 모양에서 사고 구현에서는 사지 않는다. 계약은 나중에 못 바꾸고 구현은 언제든 바꿀 수 있다
  • 페이징 메타는 조회 그 자체에서 나오므로 Repository가 만든다. totalCount는 그 규칙의 사례 하나였다

남겨둔 것이 둘 있다. 정렬이 바뀌면 기존 커서가 무효라는 사실은 여기 적어두지만 처리 방법은 정하지 않았다 — 거부할지 첫 페이지로 되돌릴지는 정렬 키 화이트리스트를 정하는 날 함께 본다. 복합 인덱스의 컬럼 순서도 같은 날로 미뤘다.


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

Claude Code — .claude/skills/paging-strategy-standard/SKILL.md

---
name: paging-strategy-standard
description: 목록 조회의 페이징 방식과 반환 타입 규칙. offset과 커서를 가르는 기준, PagingResult·CursorResult, 커서의 표현과 검증, 정렬 파라미터를 다루거나 리뷰할 때 반드시 적용한다.
---

# 페이징 방식 표준

목록 조회의 반환 타입이 곧 페이징 방식이다. 타입을 나눠 커서 응답에 `totalCount`를 실을 방법 자체를 없앤다.

## 방식을 가른다

- 페이징 방식은 **"임의 페이지로 점프할 수 있어야 하는가"**로 가른다.
- 점프가 필요하면 `PagingResult<T>`, 필요 없으면 `CursorResult<T>`를 반환한다.
- 화면 종류(관리자/사용자), `totalCount` 표시 여부, 결과 깊이를 기준으로 쓰지 않는다.
- 무한 스크롤에 offset 페이징을 쓰지 않는다.

## 반환 타입

- `CursorResult<T>``content`, `nextCursorOrNull`, `size`, `hasNext`를 갖는다.
- `CursorResult``totalCount``page`를 두지 않는다.
- `PagingResult``CursorResult`에 공통 상위 타입이나 인터페이스를 두지 않는다.
- `map()`은 두 타입이 각각 갖는다.
- 두 타입 모두 Repository 인터페이스가 반환한다. `Service` 접두사를 붙이지 않는다.
- `hasNext``size + 1`건을 조회해 판별하고 `size`건만 담는다.

## 커서

- 커서는 `cursor` 파라미터 하나로 주고받는다. 정렬 키 값을 별도 파라미터로 노출하지 않는다.
- 커서는 정렬 키 값과 `id`를 담고 Base64로 인코딩한다.
- 커서 값을 클라이언트가 해석하거나 조립하지 않는다. 응답의 `nextCursor`를 그대로 되돌려 보낸다.
- 디코딩한 커서 값을 신뢰하지 않는다. 정렬 키의 타입으로 파싱되는지 검증하고 실패하면 400으로 응답한다.
- 커서 값은 반드시 바인딩 파라미터로 넣는다. MyBatis에서 `${}`에 넣지 않는다.
- 커서 조건은 `(정렬키, id)` 복합 비교로 만든다. `id`를 생략하지 않는다.

## 정렬 파라미터

- 정렬은 `@RequestParam List<String> sort`로 받는다. 현재는 첫 번째 값만 읽는다.
- 다중 정렬을 허용하지 않는다. 허용 조합과 인덱스가 정해지기 전까지 열지 않는다.
- 정렬 키를 화이트리스트 없이 `ORDER BY`에 넣지 않는다.

GitHub Copilot — .github/instructions/paging-strategy-standard.instructions.md

---
description: 목록 조회의 페이징 방식과 반환 타입 규칙
applyTo: "**/*.java"
---

# 페이징 방식

- 임의 페이지 점프가 필요하면 `PagingResult<T>`, 아니면 `CursorResult<T>`를 반환한다.
- 무한 스크롤에 offset 페이징을 쓰지 않는다.
- `CursorResult<T>``content`, `nextCursorOrNull`, `size`, `hasNext`로 선언한다.
- `CursorResult``totalCount``page`를 두지 않는다.
- `PagingResult``CursorResult`의 공통 상위 타입을 만들지 않는다.
- 페이징 봉투는 Repository 인터페이스가 반환하며 `Service` 접두사를 붙이지 않는다.
- `hasNext``size + 1`건을 조회해 판별한다.
- 커서는 `cursor` 파라미터 하나로 받는다. `lastCreatedAt` 같은 정렬 키별 파라미터를 만들지 않는다.
- 커서는 정렬 키 값과 `id`를 담아 Base64로 인코딩한다.
- 디코딩한 커서는 타입 검증 후 바인딩 파라미터로만 쓴다. `${}`에 넣지 않는다.
- 커서 조건은 `(정렬키, id)` 복합 비교로 작성한다.
- 정렬은 `@RequestParam List<String> sort`로 받고 첫 번째 값만 읽는다.

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

댓글남기기