TechFeedTechFeed
Backend

페이지네이션, 오프셋, 키셋, 커서 | 관리자 목록 뒤 페이지가 왜 느려지나?

관리자 목록 뒤 페이지가 느리면 오프셋을 키셋으로 바꾼다. 건너뛴 행도 서버가 만들고, 커서는 마지막 시각과 아이디다. 전체 건수는 매 페이지마다 세지 않는다. PostgreSQL, 백엔드, Next.js, API, 슈퍼베이스, 프리즈마, 드리즐, 개발자, 데이터, 인덱스 기준으로 목록 칸만 보고 오토배큠 글과 자리를 섞지 않는다.

by

관리자 문의 목록에서 뒤 페이지를 눌렀더니 응답이 3초를 넘었습니다. 오프셋은 건너뛴 행도 서버가 먼저 만듭니다. 키셋은 마지막 정렬 값 다음만 읽는 방식입니다. 페이지 번호 버튼이 없으면 커서로 바꿨고, 전체 건수는 매 페이지마다 세지 않습니다. 저는 12개 사이트 파트너십 목록을 그렇게 나눴습니다. 네이버 클라우드 알디에스에서 문의가 몇만 건을 넘기자 페이지 20만 눌러도 인덱스를 타고 건너뛰는 시간이 보였습니다. ORM은 드리즐 글에, 시각 저장은 타임스탬프 글에 있습니다. 여기는 목록이 뒤로 갈수록 느려지는 칸만 봅니다.


숫자는 2026년 8월 포스트그레 18 문서와 프리즈마 페이지네이션 문서 기준입니다.


관리자 목록 200페이지를 눌렀을 때 쿼리가 멈춘 날

페이지 번호만 올리고 오프셋을 키우면, 뒤 페이지일수록 서버가 버리는 행이 늘어납니다. 저는 파트너십 문의 화면에서 그걸 봤습니다.


첫 페이지는 20행만 가져오면 됩니다. 200페이지는 3,980행을 만든 뒤에 버립니다. 포스트그레 18 문서 문구도 같다. 오프셋이 건너뛴 행도 서버 안에서 계산한다고 되어 있고, 그래서 큰 오프셋은 느릴 수 있습니다. 네이버 클라우드 알디에스에서 문의 테이블이 커진 뒤에야 체감이 났습니다. 죽은 튜플이 쌓인 날과 헷갈리면 오토배큠 글입니다. 오늘은 목록 칸입니다.


방식서버가 하는 일페이지 번호남기는 자리
오프셋앞 행을 만들고 버림바로 점프수천 건 이하 관리 화면
키셋마지막 값 다음만 읽음없음무한 스크롤, 다음 버튼
커서 API키셋을 토큰으로 숨김없음앱, 공개 목록
전체 건수조건에 맞는 행을 셈페이지 수 계산용첫 화면 한 번, 또는 추정

새 문의가 들어오는 동안 오프셋 2페이지는 같은 행을 두 번 보여 주거나 한 줄을 건너뜁니다. 문서도 정렬이 없으면 리밋과 오프셋 조합이 매번 다른 부분집합을 줄 수 있다고 적습니다. 정렬은 필수입니다.


오프셋 숫자는 페이지가 아닙니다 | 페이지 200은 오프셋 3,980입니다. 화면 숫자와 쿼리 숫자를 한 칸에 두지 않습니다. 저는 로그에 둘 다 남깁니다.


오프셋이 뒤로 갈수록 스캔이 쌓이는 순서

정렬한 뒤 앞에서부터 세고, 건너뛴 다음 20행만 돌려줍니다. 인덱스가 있어도 건너뛸 자리를 찾는 비용이 남습니다.


유즈 더 인덱스 루크가 오프셋 없이 읽으라고 오래 적어 둔 이유입니다. 시터스 블로그도 리밋 오프셋을 가장 쉽고 가장 위험한 방법으로 둡니다. 제한된 깊이이고 결과가 조금 어긋나도 되면 오프셋을 씁니다. 공개 피드나 결제 내역처럼 뒤로 끝없이 가는 목록은 그 조건이 아닙니다.


오프셋 목록. 페이지가 커질수록 버리는 행이 는다
-- https://www.postgresql.org/docs/current/queries-limit.html -- page=200, size=20 이면 OFFSET 3980 SELECT id, created_at, email, status FROM inquiries WHERE site_id = $1 ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 3980;

익스플레인으로 실제 계획만 보면 됩니다. 오프셋이 클 때 인덱스를 타고도 행을 많이 지나갑니다. 필터가 붙으면 더 그렇습니다. 상태 칸으로 걸러 쓴 문의만 보면, 건너뛴 3,980이 전체 행이 아니라 조건에 맞은 행입니다. 플랜이 시퀀셜 스캔으로 떨어지면 인덱스를 먼저 봅니다. 연결이 모자라는 날은 피지바운서 케이스입니다.


오프셋은 앞 행을 버리고 키셋은 마지막 정렬 값 다음만 읽는 비교
페이지 200은 오프셋 3,980이다. 키셋은 마지막 시각과 아이디 다음만 읽는다

키셋이 페이지 번호 버튼과는 안 맞습니다

키셋은 이전 페이지 마지막 행의 정렬 값을 다음 조건으로 씁니다. 50페이지로 바로 점프하지 못합니다.


관리자 화면에 1, 2, 3 버튼이 있으면 오프셋을 완전히 버리지 못합니다. 대신 다음, 이전만 남기면 키셋으로 충분합니다. 저는 파트너십 목록을 다음 버튼만 남겼습니다. 운영이 특정 날짜를 찾으면 검색 칸을 엽니다. 페이지 번호로 찾지 않습니다.


키셋. 마지막 시각과 아이디보다 작은 행만 읽는다
-- https://use-the-index-luke.com/no-offset -- 이전 페이지 마지막 created_at, id 를 받는다 SELECT id, created_at, email, status FROM inquiries WHERE site_id = $1 AND (created_at, id) < ($2, $3) ORDER BY created_at DESC, id DESC LIMIT 21;

21행을 읽는 이유는 다음 페이지가 있는지 보기 위해서입니다. 21이 오면 20만 그리고 커서를 남깁니다. 20 이하면 다음이 없습니다. 튜플 비교는 정렬 칸 순서와 같아야 합니다. 내림차순이면 작다 기호입니다. 오름차순이면 크다 기호입니다. 한 칸만 비교하면 같은 시각의 다른 아이디가 빠집니다.


아이디만으로 키셋을 두지 않습니다 | 화면이 최신순이면 커서는 시각과 아이디를 같이 가져갑니다. 아이디만 올리면 생성 시각과 어긋난 행이 끼어듭니다. 시퀀스가 구멍 난 테이블에서 더 그렇습니다.


전체 건수를 꼭 찍어야 할까요

전체 건수는 목록 쿼리와 별개입니다. 매 페이지마다 카운트 스타를 붙이면 목록보다 그 줄이 먼저 느려집니다.


프리즈마 문서도 오프셋 페이지네이션에서 스킵과 테이크를 말하고, 전체 수는 따로 셉니다. 필터가 붙은 카운트는 인덱스 없이는 테이블을 훑습니다. 저는 첫 화면에서만 세거나, 통계 칸을 5분 캐시합니다. 대략 수가 필요하면 피지클래스의 렐튜플스 추정으로 대체합니다. 정확한 페이지 수 버튼이 없으면 이 칸을 빼는 편이 낫습니다.


웹훅 워커가 잠금으로 한 줄만 가져가는 일은 멱등 글입니다. 아웃박스 폴링도 아웃박스 글입니다. 둘 다 스킵 락드이지, 화면 목록이 아닙니다.


목록과 건수를 한 왕복에 묶지 않는다
-- 목록은 키셋만 -- 건수는 첫 진입 또는 캐시된 값 SELECT reltuples::bigint AS estimate FROM pg_class WHERE oid = 'public.inquiries'::regclass; -- 정확한 수가 필요할 때만 SELECT count(*) FROM inquiries WHERE site_id = $1 AND status = 'open';
넥스트 앱이 커서 토큰을 쿼리로 넘기고 서버가 키셋 조건으로 다음 20행을 읽는 흐름
커서는 마지막 시각과 아이디를 인코드한 값이다. 페이지 번호는 넣지 않는다

넥스트 앱에서 커서를 쿼리로 넘긴 주

검색 파라미터에 페이지 숫자 대신 커서 문자열을 둡니다. 서버는 디코드한 시각과 아이디로 키셋을 칩니다.


프리즈마는 커서와 테이크를 공식 페이지네이션 절로 둡니다. 커서 행을 빼려면 스킵 1을 붙입니다. 드리즐은 웨어에 튜플 비교를 직접 적습니다. ORM을 고르는 글과 이 칸을 한 커밋에 묶지 않습니다. 슈퍼베이스 클라이언트로 직접 읽으면 알엘에스 정책이 먼저입니다. 그 칸은 알엘에스 글입니다.


앱 라우트에서 커서를 받아 21행을 읽는다
// Prisma cursor: https://www.prisma.io/docs/orm/prisma-client/queries/pagination // 쿼리 ?cursor= 는 base64url(createdAt + '|' + id) export async function GET(req) { const url = new URL(req.url) const raw = url.searchParams.get('cursor') const take = 20 let cursorCreatedAt let cursorId if (raw) { const text = Buffer.from(raw, 'base64url').toString('utf8') const sep = text.lastIndexOf('|') cursorCreatedAt = new Date(text.slice(0, sep)) cursorId = text.slice(sep + 1) } const rows = await prisma.$queryRaw` SELECT id, created_at, email, status FROM inquiries WHERE site_id = ${'tech'} AND ( ${cursorId}::text IS NULL OR (created_at, id) < (${cursorCreatedAt}, ${cursorId}::uuid) ) ORDER BY created_at DESC, id DESC LIMIT ${take + 1} ` const hasNext = rows.length > take const page = hasNext ? rows.slice(0, take) : rows const last = page[page.length - 1] const nextCursor = hasNext && last ? Buffer.from(last.created_at.toISOString() + '|' + last.id).toString('base64url') : null return Response.json({ items: page, nextCursor }) }

커서는 비밀이 아닙니다. 그래도 다른 사람 아이디를 추측해 건너뛰지 못하게, 사이트 아이디 조건을 같이 겁니다. 만료 시간은 보통 안 둡니다. 필터가 바뀌면 커서를 버립니다. 상태 탭을 바꾸면 처음부터입니다. 버셀 서버리스에서 이 쿼리는 한 왕복이면 됩니다. 커넥션 풀은 피지바운서 글을 따릅니다.


정렬 칸이 하나면 같은 시각이 빠집니다

생성 시각만 비교하면 같은 초에 들어온 문의가 잘립니다. 타이브레이커로 아이디를 붙입니다.


마이크로초 타임스탬프츠라도 배치 삽입은 같은 값이 나옵니다. 문서가 리밋을 쓸 때 정렬이 유일한 순서를 만들라고 한 이유가 여기입니다. 인덱스는 정렬과 같은 순서로 만듭니다. 사이트 아이디로 항상 거르면 앞 칸에 사이트 아이디를 넣습니다.


키셋과 같은 순서로 인덱스를 만든다
-- 필터 + 정렬을 한 인덱스로 CREATE INDEX inquiries_site_created_id_idx ON inquiries (site_id, created_at DESC, id DESC); -- 상태 탭이 있으면 부분 인덱스 CREATE INDEX inquiries_site_open_created_id_idx ON inquiries (site_id, created_at DESC, id DESC) WHERE status = 'open';
오늘 바꿀 네 줄. 키셋 조건, 21행, 건수 분리, 복합 인덱스
오프셋을 지우기 전에 인덱스와 커서 칸부터 맞춘다

오늘 맞출 네 줄

목록 쿼리에서 오프셋을 빼고, 마지막 시각과 아이디를 조건에 넣습니다. 21행을 읽어 다음 여부를 보고, 건수는 분리합니다.


제 순서는 이렇습니다. 익스플레인에 큰 오프셋 쿼리를 넣고, 사이트 아이디와 생성 시각과 아이디 인덱스를 만듭니다. 앱은 페이지 파라미터를 커서로 바꿉니다. 카운트 스타는 목록과 같은 함수에서 뺍니다. 관리 화면이 1, 2, 3 버튼을 고집하면 오프셋은 앞 10페이지만 허용하고, 그 뒤는 검색입니다.


마이그레이션으로 인덱스를 넣는 순서는 무중단 마이그레이션 글입니다. 오늘은 목록 쿼리만 바꿉니다.


참고 자료


플랜은 로컬 포스트그레 버전이 우선입니다. 위 표는 2026년 8월 공개 문서 기준입니다.


자주 묻는 질문

오프셋을 지금 전부 지워야 하나?

수천 건 이하 관리 화면은 그대로 둬도 됩니다. 뒤 페이지 응답이 늘어나기 시작한 목록만 키셋으로 바꿉니다. 1, 2, 3 버튼이 필요하면 앞 몇 페이지만 오프셋을 허용합니다.


프리즈마 커서만 쓰면 되나?

유니크 칸 하나면 공식 커서 API로 충분합니다. 최신순 목록은 시각과 아이디를 같이 써야 해서 로 쿼리나 드리즐 웨어가 더 짧습니다. 커서에 아이디만 넣으면 같은 시각 행이 빠집니다.


이전 페이지는 어떻게 하나?

조건을 뒤집습니다. 내림차순 다음이 작다였으면, 이전은 크다입니다. 가져온 행을 다시 내림차순으로 돌립니다. 커서를 두 개 두면 구현이 단순합니다. 다음 커서와 이전 커서입니다.


무한 스크롤도 같은가?

같습니다. 화면이 바닥을 치면 넥스트 커서만 보냅니다. 페이지 번호는 주소에 안 남습니다. 공유 링크가 필요하면 검색 조건과 커서를 쿼리에 같이 둡니다.


슈퍼베이스 클라이언트 레인지로 되나?

레인지는 오프셋입니다. 앞 페이지가 작을 때만 씁니다. 테이블이 커지면 알피시에서 키셋 웨어를 직접 적습니다. 알엘에스 정책은 그대로 둡니다.


카운트가 꼭 맞아야 하나?

결제 대사처럼 숫자가 돈이면 맞춥니다. 문의 목록 하단 전체 N건은 추정이면 충분합니다. 매 클릭마다 카운트 스타를 붙이지 않습니다.


페이지네이션오프셋키셋커서PostgreSQL백엔드Next.jsAPI개발자슈퍼베이스인덱스드리즐

함께 보면 좋은 문제 해결

EXPLORE / Backend

이어서 읽어보기

전체 토픽 둘러보기