[PostgreSQL] 오라클의 ROW_NUMBER() OVER와 같은 기능), 페이징 처리 관련 SQL 작성법
PostgreSQL에서 Oracle의 ROW_NUMBER() OVER (윈도우 함수) 기능과 페이징 처리(Paging)를 구현하는 방법은 거의 동일하거나 훨씬 간결합니다.
상황별 SQL 작성법을 나누어 안내해 드립니다.
1. ROW_NUMBER() OVER
(그룹별 순위 매기기)
PostgreSQL은 Oracle과 동일한 ANSI 표준 윈도우 함수 구문을 완벽하게 지원합니다. 따라서 Oracle에서 쓰시던 문법 그대로 사용하시면 됩니다.
💡 특정 그룹별로 1등만 뽑아오기 (예: 부서별 최고 급여 시용자)
SELECT 부서ID, 사원명, 급여
FROM (
SELECT 부서ID, 사원명, 급여,
ROW_NUMBER() OVER(PARTITION BY 부서ID ORDER BY 급여 DESC) as rn
FROM 사원테이블
) t
WHERE t.rn = 1; -- Oracle과 완벽히 동일한 방식
2. 페이징 처리 (Paging) 구현하기
PostgreSQL에서는 페이징 처리를 할 때 Oracle처럼 ROWNUM을 서브쿼리로 두 번 감싸는 복잡한 방법을 쓸 필요가 없습니다. LIMIT와 OFFSET을 사용하면 단 한 줄로 끝납니다.
페이징 처리 공식
LIMIT: 한 페이지에 보여줄 행의 개수 (pageSize)OFFSET: 건너뛸 행의 개수 ((pageNumber - 1) * pageSize)
예제 1 > 1페이지 (1번~10번 데이터)
SELECT * FROM 게시판
ORDER BY 등록일 DESC
LIMIT 10 OFFSET 0; -- 앞에 0개 건너뛰고 10개 가져옴
예제 2 > 2페이지 (11번~20번 데이터)
SELECT * FROM 게시판
ORDER BY 등록일 DESC
LIMIT 10 OFFSET 10; -- 앞에 10개 건너뛰고 그다음 10개 가져옴
3. 표준 SQL 방식의 페이징 처리 (FETCH + OFFSET)
앞서 배운 표준 SQL 구문을 사용해 페이징을 처리할 수도 있습니다. LIMIT 방식과 성능은 같으며 문법적 취향에 따라 선택하시면 됩니다.
-- 2페이지 (11번~20번 데이터) 예시
SELECT * FROM 게시판
ORDER BY 등록일 DESC
OFFSET 10 ROWS
FETCH NEXT 10 ROWS ONLY;
오라클 vs PostgreSQL 페이징 가독성 비교
| 데이터베이스 | 페이징 쿼리 스타일 |
|---|---|
| Oracle (Old) | SELECT * FROM (SELECT a.*, ROWNUM rnum FROM (...) a WHERE ROWNUM <= 20) WHERE rnum >= 11; (서브쿼리 필수) |
| PostgreSQL | SELECT * FROM 테이블 ORDER BY 기준 LIMIT 10 OFFSET 10; (직관적이고 단순함) |
대용량 테이블에서 OFFSET을 사용할 때 발생하는 성능 저하 문제를 해결하는 최적화 방법(No-Offset 페이징 등)
대용량 테이블에서 OFFSET 1000000 LIMIT 10을 수행하면, 데이터베이스는 앞의 100만 건을 실제로 전부 읽고 정렬한 뒤 버리는 작업을 수행합니다. 이 때문에 페이지가 뒤로 갈수록 쿼리가 급격히 느려집니다.
이를 해결하기 위한 대표적인 최적화 방법 3가지를 안내해 드립니다.
1. No-Offset 페이징 (커서 기반 페이징)
가장 효과적인 방법으로, “내가 마지막으로 본 데이터의 식별자(ID, 날짜 등)를 조건문(WHERE)으로 걸어 그 다음부터 읽는 방식”입니다. 이전 위치를 기억하는 ‘커서’를 쓰기 때문에 아무리 뒷페이지로 가도 늘 첫 페이지만큼 빠릅니다.
- 1페이지 조회sql
SELECT id, title, created_at FROM post ORDER BY id DESC LIMIT 10; -- 결과로 나온 마지막 데이터의 id가 '54321'이라고 가정 - 2페이지 조회 (No-Offset)sql
SELECT id, title, created_at FROM post WHERE id < 54321 -- OFFSET 10 대신 식별자 조건문 사용! ORDER BY id DESC LIMIT 10;
📊 일반 페이징 vs No-Offset 성능 및 특징 비교
| 항목 | 일반 페이징 (OFFSET) | No-Offset (커서 기반) |
|---|---|---|
| 속도 (뒷페이지) | 읽은 양이 많아져 매우 느려짐 | 언제나 첫 페이지처럼 일정하게 빠름 |
| 인덱스 활용 | OFFSET 구간은 인덱스를 타도 스캔 낭비 발생 | WHERE 조건으로 인덱스를 타고 곧바로 점프 |
| 적합한 서비스 | 일반적인 웹 커뮤니티 (1..2..3.. 페이지 번호 클릭) | 무한 스크롤, 더보기 버튼 (인스타그램, 유튜브 등) |
| 데이터 정합성 | 조회 중 새 글이 써지면 데이터 중복 노출됨 | 새 글이 써져도 중복 없이 완벽히 정합성 유지 |
2. 커버링 인덱스 (Covering Index) 활용
만약 서비스 기획상 반드시 ‘페이지 번호(1, 2, 3…)’를 제공해야 해서 OFFSET을 버릴 수 없다면, 커버링 인덱스 기법을 적용해야 합니다.
실제 데이터 레코드(Row) 전체를 읽지 않고, 인덱스에 포함된 컬럼(예: ID와 정렬기준)만 가지고 빠르게 OFFSET을 계산한 뒤, 최종 당첨된 10건만 실제 테이블에서 가져오는 방식입니다.
-- [전제조건] id와 created_at 컬럼으로 복합 인덱스가 생성되어 있어야 합니다.
SELECT p.*
FROM post p
JOIN (
-- 인덱스 안에서만 놀기 때문에 100만 건을 건너뛰어도 매우 빠름
SELECT id
FROM post
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 1000000
) filter ON p.id = filter.id;
3. 최대 페이지 제한 (UI/UX 타협)
구글이나 네이버 검색을 보면 검색 결과가 수억 건이 넘어도 최대 100페이지(또는 1,000건)까지만 노출하고 그 뒤는 보여주지 않습니다.
실제 사용자가 5만 번째 페이지를 클릭할 확률은 극히 낮기 때문에, OFFSET이 감당할 수 있는 마지노선(예: 1만 건)까지만 조회를 허용하고 그 이상은 “검색 기간을 좁혀주세요”라는 메시지로 유도하는 것이 데이터베이스 평화를 지키는 가장 현실적인 방법 중 하나입니다.
현재 개발 중이신 화면이 번호를 누르는 페이징인가요, 아니면 무한 스크롤(더보기) 형태인가요?
무한 스크롤과 번호식 페이징은 데이터베이스 관점에서 최적화 접근 방식이 완전히 다릅니다.
실무에서 바로 사용할 수 있도록 가장 대중적인 상황(게시글 테이블 post, 정렬 기준 등록일시(created_at) DESC + ID DESC)을 기준으로 두 가지 방식의 최적화 SQL과 인덱스 설계법을 모두 정리해 드립니다.
⚡ 선결 과제: 필수 인덱스(Index) 생성
두 방식 모두 최적화 성능을 내려면 정렬 기준과 식별자를 묶은 복합 인덱스가 반드시 필요합니다.
sql
CREATE INDEX idx_post_created_id ON post (created_at DESC, id DESC);
1. 무한 스크롤 / 더보기 (No-Offset 방식)
무한 스크롤은 OFFSET을 아예 쓰지 않는 것이 핵심입니다. 이전 페이지의 마지막 데이터(커서)를 기준으로 그보다 과거의 데이터(WHERE)를 조회합니다.
💻 1페이지 (최초 조회)
SELECT id, title, created_at, writer
FROM post
ORDER BY created_at DESC, id DESC
LIMIT 11; -- 💡 팁: 10개만 보여줄 거라면 11개를 조회합니다. (다음 페이지가 있는지 확인용)
- 결과로 나온 마지막(10번째) 데이터가
created_at = '2026-10-16 12:00:00',id = 54321이라고 가정합니다.
💻 2페이지 (더보기 클릭 시)
직전의 마지막 날짜와 ID를 조건문에 넣어 인덱스를 타고 곧바로 원하는 위치로 점프합니다.
SELECT id, title, created_at, writer
FROM post
WHERE (created_at, id) < ('2026-10-16 12:00:00'::timestamp, 54321) -- 💡 PostgreSQL 행 비교 연산 활용
ORDER BY created_at DESC, id DESC
LIMIT 11;
💡 PostgreSQL 꿀팁 (
Row Constructor)WHERE (created_at, id) < (값, 값)구문은 PostgreSQL이 지원하는 다중 컬럼 비교 방식입니다. “날짜가 더 과거이거나, 날짜가 같다면 ID가 더 작은 것”을 의미하며, 생성한 복합 인덱스를 완벽하게 탑니다.
2. 번호 누르는 페이징 (지연된 조인 / 커버링 인덱스 방식)
사용자가 500번째 페이지(예: LIMIT 10 OFFSET 5000)를 누르면 OFFSET을 피할 수 없습니다. 이때는 테이블의 모든 컬럼(SELECT *)을 읽지 말고, 인덱스만 읽어서 가볍게 ID 10개만 먼저 걸러내는 방식을 사용합니다.
💻 500페이지 조회 (예시)
SELECT p.id, p.title, p.created_at, p.writer
FROM post p
JOIN (
-- 1. 인덱스 내부에서만 OFFSET을 수행하여 ID 10개만 빠르게 추출 (커버링 인덱스)
SELECT id
FROM post
ORDER BY created_at DESC, id DESC
LIMIT 10 OFFSET 5000
) filter ON p.id = filter.id; -- 2. 당첨된 10건에 대해서만 실제 상세 데이터 결합
- 왜 빠를까요? 일반 쿼리는 5,000건의 무거운 전체 레코드를 메모리에 올려서 버리지만, 이 방식은 가벼운 인덱스(ID, 날짜) 안에서만 5,000건을 건너뛰기 때문에 디스크 I/O가 획기적으로 줄어듭니다.
📝 요약 가이드
- 무한 스크롤을 구현할 때는 백엔드 API에서 다음 페이지 유무(
hasNext)를 같이 넘겨주기 위해 무조건LIMIT + 1조회와 행 비교 연산자를 활용하세요. - 번호식 페이징은 대용량 테이블일 때 무조건
FROM 테이블 LIMIT OFFSET을 쓰면 성능 폭탄을 맞습니다.JOIN서브쿼리 형태로 인덱스 스캔을 유도하세요.
[연관자료]
- [PostgreSQL]프로시저 또는 함수내에서 특정 컬럼이나 문자열 찾는 쿼리
- [PostgreSQL] 가장 디스크를 많이 괴롭히는 쿼리 top 10 뽑기
- [PostgreSQL] 통계 쿼리 조건절 컬럼 가공을 제거 해서 인덱스를 타게 하는 방법
- [PostgreSQL] 인덱스 리빌드 (REINDEX)(재생성)하는 대표적인 방법 3가지
- [PostgreSQL] 스왑(swap)을 유발하는 무거운 쿼리 찾는 방법
- [PostgreSQL] 통계 일일 배치 실행시, 월별 누적에 당일(전일) 배치 실행 결과 더해서 처리하는 방법(DO UPDATE SET)
- [PostgreSQL] 서버 재시작하지 않고 무중단으로 SWAP 메모리 초기화 하는 방법 & 이슈 대처 방법 포함




