thenullpage.com
목록을 잘라야 하는 이유
글이 십만 건 쌓인 게시판에서 목록을 요청했을 때 전부 내려온다면, 서버는 십만 건을 읽어 형식을 갖춰 만들고 클라이언트는 그것을 전부 받아 화면에 그린다. 응답 하나가 수십 메가바이트가 되고 화면은 그동안 멈춘다. 그래서 목록 응답은 한 번에 몇 건까지만 내주고 나머지는 다음 요청으로 미룬다. 이 잘라 내는 규칙을 정하는 일이 페이지네이션이다.
자르는 방식은 크게 둘이다. 몇 번째부터 몇 건이라고 위치로 지정하는 방식과, 여기 다음부터 몇 건이라고 마지막 지점을 기준으로 지정하는 방식이다.
몇 번째부터 몇 건
가장 흔한 형태는 페이지 번호와 한 페이지 크기를 쿼리스트링에 싣는 것이다. 서버는 이 값을 건너뛸 개수로 바꿔 조회한다. 3페이지에 20건이면 앞의 40건을 버리고 그다음 20건을 읽는다.
GET /articles?page=3&limit=20
SELECT id, title FROM articles
ORDER BY created_at DESC
LIMIT 20 OFFSET 40
이 방식의 장점은 분명하다. 1부터 마지막까지 번호가 매겨진 목록을 만들 수 있고 5페이지로 바로 건너뛰는 것도 된다. 주소만 봐도 지금 어디인지 드러나서 링크를 공유하거나 북마크로 남기기도 쉽다.
목록이 움직이면 어긋난다
문제는 목록이 고정돼 있지 않을 때 나온다. 1페이지를 본 다음 2페이지를 부르는 사이에 새 글이 하나 올라왔다고 하자. 최신순 정렬이라면 모든 항목이 한 칸씩 뒤로 밀린다. 1페이지 맨 끝에 있던 스무 번째 글이 스물한 번째가 되고, 2페이지는 그 스물한 번째부터 시작한다. 방금 본 글을 한 번 더 보게 된다.
반대로 글이 하나 지워지면 항목들이 한 칸씩 앞으로 당겨진다. 원래 스물한 번째였던 글이 스무 번째로 올라가 1페이지 영역에 들어가는데, 사용자는 이미 1페이지를 지나온 뒤다. 그 글은 어느 페이지에서도 보이지 않는다.
중복은 눈에 띄지만 누락은 알아채기 어렵다. 없는 것이 없다는 사실은 화면만 봐서는 드러나지 않기 때문이다.
뒤로 갈수록 느려진다
성능도 걸린다. OFFSET 40은 40건을 건너뛴다는 뜻이지만, 데이터베이스가 그 40건을 세지 않고 넘어갈 방법은 없다. 정렬된 순서대로 읽으면서 40번까지 세어 버린 다음 그다음부터 담는다. 페이지 번호가 커질수록 버리는 양이 늘어난다. 5000페이지라면 10만 건을 읽어 9만 9980건을 버리고 20건을 내주는 셈이다.
첫 페이지는 즉시 나오는데 뒷 페이지만 유독 느린 목록이 있다면 대개 이 구조 때문이다.
마지막 지점을 기준으로 자르기
다른 방식은 위치 대신 값을 기준으로 삼는다. 방금 받은 마지막 항목의 값을 다음 요청에 실어 보내고, 서버는 그 값보다 뒤에 오는 것부터 내준다.
GET /articles?limit=20&cursor=101
SELECT id, title FROM articles
WHERE id < 101
ORDER BY id DESC LIMIT 20
건너뛸 것이 없으니 조건에 맞는 자리를 색인에서 곧장 찾아 20건만 읽는다. 몇 페이지째든 읽는 양이 같아서 뒤로 갈수록 느려지지 않는다. 사이에 글이 늘거나 줄어도 기준값 자체는 그대로라 중복이나 누락도 생기지 않는다.
이 방식을 커서 기반 또는 키셋 기반이라고 부른다. 대신 포기하는 것이 있다. 5페이지로 바로 건너뛸 수 없고 전체 페이지 수를 매긴 번호 목록도 만들 수 없다. 앞뒤로 한 칸씩만 이동한다. 그래서 아래로 계속 이어지는 화면과는 잘 맞고, 번호를 눌러 옮겨 다니는 게시판 형태와는 맞지 않는다.
커서에 담기는 값
커서는 정렬 기준으로 쓰는 값이어야 한다. 최신순이면 작성 시각, 번호순이면 번호다. 정렬 기준과 다른 값을 커서로 쓰면 조건과 순서가 어긋나 결과가 뒤엉킨다.
주의할 자리는 같은 값이 여럿일 때다. 작성 시각이 초 단위인데 같은 초에 세 건이 들어왔다면, 기준 시각보다 이전이라는 조건은 그 세 건을 통째로 건너뛴다. 이럴 때는 절대 겹치지 않는 값을 하나 더 붙여 순서를 끝까지 결정지어야 한다.
WHERE (created_at, id) < ('2026-07-31 10:00:00', 5120)
ORDER BY created_at DESC, id DESC
커서 값을 그대로 노출하는 대신 인코딩해 한 덩어리 문자열로 내보내는 구현이 많다. 감추려는 목적이 아니라 정렬 조건이 늘어나도 클라이언트가 형식을 다시 배우지 않게 묶어 두는 것이다. 값이 드러나도 문제될 것이 없어야 하므로 커서 안에 비밀 값을 넣어서는 안 된다.
응답에 함께 실을 것
클라이언트가 다음 요청을 스스로 만들 수 있도록 필요한 정보를 응답에 담아 준다.
{
"data": [ ... ],
"paging": {
"next_cursor": "eyJpZCI6MTAxfQ",
"has_more": true
}
}
마지막인지 알려 주는 값이 있어야 요청을 언제 멈출지 판단할 수 있다. 받은 건수가 요청한 수보다 적으면 끝이라고 보는 방식도 쓰이지만, 마지막이 정확히 20건으로 떨어지면 빈 요청을 한 번 더 보내게 된다. 서버에서 한 건을 더 읽어 남는지 확인한 뒤 그 한 건은 빼고 내보내면 정확히 알려 줄 수 있다.
전체 개수는 넣을지 말지 판단이 필요하다. 총 몇 건인지 세려면 조건에 맞는 행을 전부 훑어야 해서 목록을 가져오는 것보다 비싼 경우가 있다. 화면에 꼭 필요한 숫자가 아니라면 빼거나, 따로 떼어 필요할 때만 부르게 한다.
한도를 정해 두기
한 번에 몇 건을 달라는 값은 클라이언트가 보내는 것이라 그대로 믿으면 안 된다. limit에 1000000이 들어오면 그 요청 하나로 서버가 멈춘다. 받아 줄 최대치를 정해 두고 넘으면 잘라 내거나 400으로 거절한다. 값이 없을 때 쓸 기본값과 음수나 문자가 들어왔을 때의 처리도 함께 정해 둔다. page에 0이나 -3이 들어오면 건너뛸 개수가 음수가 되어 그대로 오류로 이어진다.
확인하는 방법
중복과 누락은 목록을 움직여 보면 드러난다. 1페이지를 부르고, 새 글을 하나 쓴 다음, 2페이지를 부른다. 두 응답의 id를 모아 놓으면 겹치는 값이 나온다. 반대로 1페이지를 부른 뒤 그 안의 글을 하나 지우고 2페이지를 부르면 경계에 있던 id 하나가 양쪽 어디에도 없다. 같은 절차를 커서 방식으로 밟으면 두 현상 모두 나타나지 않는다.
깊은 페이지 성능은 응답 시간을 재서 비교한다.
curl -o /dev/null -s -w "%{time_total}\n" "https://example.com/api/articles?page=1&limit=20"
curl -o /dev/null -s -w "%{time_total}\n" "https://example.com/api/articles?page=500&limit=20"
같은 20건인데 아래쪽이 눈에 띄게 오래 걸린다면 건너뛰기 방식이 그대로 쓰이고 있다는 뜻이다. 개발자 도구 Network 탭에서 목록 요청의 소요 시간을 페이지별로 비교해도 같은 것이 보인다.
한도는 이상한 값을 넣어 보면 알 수 있다. limit을 아주 크게 넣었을 때 그만큼 다 돌아오는지, page에 0이나 음수를 넣었을 때 500이 나오는지 확인한다. 정리된 서버라면 잘라 낸 건수만 돌아오거나 400으로 이유를 밝힌다.