Pagination 성능 원리: ROWNUM·OFFSET·Keyset Pagination
페이지 번호가 깊어질수록 앞 행을 읽고 버리는 ROWNUM·OFFSET 비용과 마지막 키 이후부터 찾는 Keyset Pagination을 비교합니다.
핵심 요약
Pagination은 전체 결과를 여러 구간으로 나누어 조회하는 방식입니다. 성능을 판단할 때는 페이지에 20행을 반환했다는 사실보다, 그 20행에 도달하기 위해 몇 행을 읽고 정렬하고 버렸는지를 확인해야 합니다.
ROWNUM 범위 방식
→ end_row까지 생산할 수 있음
→ 앞의 start_row를 제거
→ 목표 구간 반환
OFFSET·FETCH
→ OFFSET 행을 건너뜀
→ 다음 page_size 행 반환
Keyset Pagination
→ 이전 페이지 마지막 전체 정렬 Key 이후를 탐색
→ 필요한 행을 확보하면 중단 가능
핵심 비교입니다.
| 구분 | ROWNUM 범위 | OFFSET·FETCH | Keyset Pagination |
|---|---|---|---|
| 위치 표현 | 시작·종료 행 번호 | 건너뛸 행 수 | 마지막 정렬 Key |
| 임의 페이지 이동 | 가능 | 가능 | 직접 이동이 어려움 |
| 깊은 페이지 | 상한까지 작업 증가 가능 | OFFSET만큼 선행 작업 증가 가능 | 적절한 Index에서 깊이에 덜 민감 |
| 안정적 ORDER BY | 필요 | 필요 | 필수 |
| 주 사용 UI | 번호형 페이지 | 번호형 페이지 | 더 보기·무한 스크롤·다음 페이지 |
범위
이 이론은 SQLP
SQL 고급 활용 및 튜닝 → 소트 튜닝 → Pagination·Top-N·실행계획 분석범위에서 ROWNUM·OFFSET·Keyset의 의미, Index 활용, 데이터 변경 일관성, 총건수 처리와 실측 검증을 다룹니다.
1. Pagination의 출발점: 결정적인 전체 순서
Oracle의 Row Limiting Clause는 일관된 결과를 위해 결정적인 ORDER BY를 함께 지정할 것을 요구합니다.
다음 SQL은 업무 순서가 정의되지 않습니다.
SELECT order_id, order_date, amount
FROM orders
OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY;
정렬을 지정합니다.
SELECT order_id, order_date, amount
FROM orders
ORDER BY order_date DESC, order_id DESC
OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY;
order_id는 같은 날짜의 상대 순서를 고정하는 고유 Tie-Breaker입니다.
ORDER BY가 고유한 전체 순서를 정의
→ 각 행의 위치가 하나로 결정
→ 페이지 경계가 재실행 시에도 안정적
ORDER BY가 동률을 남김
→ 동률 행의 상대 순서가 달라질 수 있음
→ 페이지 중복·누락 위험 증가
NULL이 정렬 Key에 포함되면 NULLS FIRST·NULLS LAST도 업무 규칙으로 명시합니다.
2. ROWNUM 범위 Pagination
ROWNUM은 Oracle이 행을 선택하는 과정에서 부여하는 Pseudocolumn입니다. 같은 Query Block에서 ROWNUM > 1처럼 양의 정수보다 큰 값만 요구하면 첫 행부터 조건을 만족하지 못하므로 결과가 나오지 않습니다.
따라서 범위 Pagination은 Query Block을 나눕니다.
SELECT order_id, order_date, amount
FROM (
SELECT q.*,
ROWNUM AS rn
FROM (
SELECT order_id, order_date, amount
FROM orders
WHERE status = :status
ORDER BY order_date DESC, order_id DESC
) q
WHERE ROWNUM <= :end_row
)
WHERE rn > :start_row;
페이지 크기 20, 6페이지라면:
start_row = 100
end_row = 120
처리 의미입니다.
정렬 순서 기준으로 최대 120행 생산
→ ROWNUM을 rn으로 노출
→ rn <= 100인 앞쪽 행 제거
→ 101~120번째 행 반환
안쪽 상한은 COUNT STOPKEY 또는 변환된 STOPKEY 형태로 조기 중단에 사용될 수 있습니다. 그러나 바깥 하한은 앞쪽 행을 버리는 조건이므로 페이지가 깊어질수록 선행 처리량이 늘어날 수 있습니다.
3. OFFSET·FETCH의 정확한 의미
Row Limiting Clause의 기본 형태입니다.
SELECT order_id, order_date, amount
FROM orders
WHERE status = :status
ORDER BY order_date DESC, order_id DESC
OFFSET :offset_rows ROWS
FETCH NEXT :page_size ROWS ONLY;
Oracle 공식 의미는 OFFSET만큼 건너뛴 뒤 행 제한을 시작하는 것입니다.
| OFFSET 값 | 의미 |
|---|---|
| 생략 | 0으로 간주, 첫 행부터 제한 |
| 음수 | 0으로 처리 |
| 소수 | 소수 부분 절삭 |
| NULL | 0행 반환 |
| 전체 결과 수 이상 | 0행 반환 |
페이지 크기 20, OFFSET 100,000이라면 논리적으로 앞 100,000행을 건너뛰고 다음 20행을 반환해야 합니다.
논리적 위치 판단 대상 ≈ OFFSET + FETCH
다만 실제 물리 작업량을 항상 정확히 OFFSET + FETCH라고 단정하면 안 됩니다. Filter·Join·Sort·Table Access·병렬 처리와 실행계획에 따라 더 많은 후보나 Block을 처리할 수 있습니다.
또한 row_limiting_clause는 FOR UPDATE와 함께 지정할 수 없습니다. 잠금 선점이 필요한 Queue 처리에서는 별도의 SQL 구조를 설계해야 합니다.
4. 깊은 OFFSET이 비싸지는 이유
정렬 Index가 없다면 Oracle은 후보 집합을 정렬한 뒤 OFFSET 위치를 찾아야 할 수 있습니다.
정렬 Index가 있더라도 다음과 같은 작업이 남을 수 있습니다.
Index 선두부터 OFFSET 위치까지 Entry 통과
→ Index 밖 Filter 확인
→ 필요한 경우 버릴 행도 Table Access
→ OFFSET 이후 page_size 행 반환
따라서 다음 두 SQL이 모두 20행을 반환하더라도 작업량은 다를 수 있습니다.
OFFSET 0 ROWS FETCH NEXT 20 ROWS ONLY
OFFSET 100000 ROWS FETCH NEXT 20 ROWS ONLY
첫 페이지의 응답시간만으로 Pagination 설계를 승인하지 말고, 운영상 허용되는 가장 깊은 페이지까지 측정합니다.
5. Keyset Pagination의 원리
Keyset Pagination은 SQL 문법 이름이 아니라 애플리케이션 Pagination 패턴입니다. 페이지 번호 대신 이전 페이지 마지막 행의 전체 정렬 Key를 다음 요청에 전달합니다.
첫 페이지:
SELECT order_id, order_date, amount
FROM orders
WHERE status = :status
ORDER BY order_date DESC, order_id DESC
FETCH FIRST 20 ROWS ONLY;
첫 페이지 마지막 Key가 다음과 같다고 가정합니다.
last_order_date = 2026-07-20 14:30:00
last_order_id = 87500
다음 페이지:
SELECT order_id, order_date, amount
FROM orders
WHERE status = :status
AND (
order_date < :last_order_date
OR (order_date = :last_order_date
AND order_id < :last_order_id)
)
ORDER BY order_date DESC, order_id DESC
FETCH FIRST 20 ROWS ONLY;
내림차순에서 다음 페이지는 마지막 Key보다 작은 값입니다.
Cursor Key에서 Index 탐색 시작
→ 다음 위치의 Entry부터 읽기
→ 20행 확보
→ 중단
지원 Index 예시입니다.
CREATE INDEX orders_status_page_ix
ON orders(status, order_date DESC, order_id DESC);
status 등치 조건이 첫 범위를 고정하고 뒤의 Key가 정렬과 탐색 시작점을 지원합니다.
6. 복합 정렬 방향을 Keyset 조건에 반영한다
정렬이 다음과 같다면:
ORDER BY score DESC, created_at ASC, id ASC
다음 페이지 조건은 각 Column 방향을 그대로 반영합니다.
WHERE score < :last_score
OR (score = :last_score
AND created_at > :last_created_at)
OR (score = :last_score
AND created_at = :last_created_at
AND id > :last_id)
DESC의 다음 값 → 더 작은 값
ASC의 다음 값 → 더 큰 값
마지막에는 고유 Key를 포함해 전체 순서를 결정합니다.
NULL 정렬 Key는 일반 비교식에서 별도 처리가 필요합니다. 가능한 경우 Pagination Key를 NOT NULL로 설계합니다. NULL을 허용해야 한다면 NULLS FIRST·NULLS LAST와 동일한 순서를 표현하도록 IS NULL 분기와 비교 조건을 설계해야 합니다.
7. Index와 Table Access
Pagination Index는 다음 세 요소를 함께 검토합니다.
1. Equality Predicate
2. ORDER BY Key
3. 반환 Column 또는 추가 Filter
예시:
CREATE INDEX orders_page_cover_ix
ON orders(
status,
order_date DESC,
order_id DESC,
amount
);
amount까지 Index에 있으면 목록이 Index만으로 처리될 가능성이 높아집니다. 그러나 Covering Index는 다음 비용을 증가시킵니다.
- Index 크기와 Cache 사용량
- INSERT·UPDATE·DELETE 유지 비용
- 유사 Index 중복
- Leaf Block Split 가능성
반환 Column을 무조건 모두 Index에 추가하지 말고, 페이지 조회 빈도와 DML 비용을 함께 비교합니다.
Index 밖 Filter가 있으면 깊은 OFFSET에서 버릴 후보까지 Table을 방문할 수 있습니다. Keyset도 첫 20개 후보가 Filter를 통과하지 못하면 더 많은 Entry와 Row를 읽을 수 있습니다.
8. 기능 요구에 따라 방식을 선택한다
| 요구 | 권장 검토 |
|---|---|
| 사용자가 1·20·500페이지를 직접 이동 | OFFSET·ROWNUM |
| 다음 페이지·더 보기·무한 스크롤 | Keyset |
| 매우 깊은 페이지를 자주 조회 | Keyset 우선 검토 |
| 정렬 기준을 사용자가 자유롭게 변경 | 정렬별 Index·Cursor 구조 검토 |
| 이전 페이지로 이동 | 역방향 Cursor 또는 Cursor History |
| 정확히 동일한 전체 Snapshot | Flashback Query 또는 결과 집합 고정 |
Keyset의 제한입니다.
- 임의 페이지 번호로 바로 이동하기 어렵습니다.
- 마지막 Cursor Key를 Client 또는 Server가 보관해야 합니다.
- 정렬 조건이 바뀌면 Cursor 형식과 Index도 달라집니다.
- 이미 본 행의 정렬 Key가 바뀌거나 삭제되면 추가 정책이 필요합니다.
9. 페이지 사이 데이터 변경과 일관성
각 페이지 요청이 별도의 SQL이면 요청 사이에 데이터가 변경될 수 있습니다.
최신순 OFFSET Pagination에서 1페이지 조회 후 새 행이 앞에 삽입되면:
기존 행 위치가 뒤로 이동
→ 2페이지 OFFSET 경계 이동
→ 이전 페이지의 마지막 행이 다시 보일 수 있음
삭제되면 다음 페이지의 일부 행이 건너뛰어질 수 있습니다.
Keyset은 마지막 Key 이후를 찾으므로 앞쪽 Insert로 인한 위치 이동에 덜 민감합니다. 그러나 다음 변화까지 모두 해결하지는 않습니다.
- 이미 본 행의 정렬 Key 변경
- Cursor 행 삭제
- 동일 Key의 비결정적 정렬
- 페이지별 서로 다른 Snapshot
조회 시작 시점의 동일 Snapshot이 필요하면 SCN을 저장하고 각 페이지에서 같은 시점의 Flashback Query를 사용할 수 있습니다.
SELECT ...
FROM orders AS OF SCN :snapshot_scn
WHERE ...
ORDER BY order_date DESC, order_id DESC
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY;
Flashback Query는 Undo 보존, 권한, 지원 Object와 운영 부하를 검토해야 합니다. 오래된 SCN을 무제한 조회할 수 있다고 가정하면 안 됩니다.
10. 전체 건수와 목록 조회
페이지 UI는 전체 건수와 현재 목록을 함께 요구할 수 있습니다.
SELECT COUNT(*)
FROM orders
WHERE status = :status;
SELECT ...
FROM orders
WHERE status = :status
ORDER BY order_date DESC, order_id DESC
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY;
다음처럼 분석 함수로 정확한 총건수를 붙일 수도 있습니다.
SELECT order_id,
order_date,
COUNT(*) OVER () AS total_count
FROM orders
WHERE status = :status
ORDER BY order_date DESC, order_id DESC
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY;
그러나 정확한 COUNT(*) OVER() 값을 계산하려면 전체 조건 만족 집합을 처리해야 할 수 있으므로, 페이지 행만 조기 반환하는 이점이 줄어들 수 있습니다.
업무 요구를 구분합니다.
- 정확한 총건수가 매번 필요한가?
- 다음 페이지 존재 여부만 필요한가?
- 총건수 Cache·비동기 계산·근사값을 허용하는가?
- Count SQL과 목록 SQL의 일관성 수준은 어느 정도인가?
11. 실행계획과 실측 검증
실제 수행 통계를 수집합니다.
SELECT /*+ GATHER_PLAN_STATISTICS */
order_id, order_date, amount
FROM orders
WHERE status = :status
ORDER BY order_date DESC, order_id DESC
OFFSET 100000 ROWS
FETCH NEXT 20 ROWS ONLY;
SELECT *
FROM TABLE(
DBMS_XPLAN.DISPLAY_CURSOR(
NULL,
NULL,
'ALLSTATS LAST +PREDICATE +ALIAS +NOTE'
)
);
확인 항목입니다.
| 항목 | 판단 질문 |
|---|---|
A-Rows | 20행 반환을 위해 하위 Operation이 몇 행을 생산했는가? |
Starts | 상관 Subquery·Join 입력이 몇 번 반복됐는가? |
Buffers | 페이지 깊이에 따라 Logical I/O가 얼마나 늘었는가? |
| Sort | 전체 Sort·STOPKEY·NOSORT 중 어떤 형태인가? |
| Index Scan | Cursor Key에서 시작했는가, 선두 Entry를 통과했는가? |
| Table Access | 버릴 후보까지 Table을 읽었는가? |
| Memory·TEMP | 깊은 페이지에서 Sort가 Spill했는가? |
| Fetch 완료 | 첫 행 응답과 페이지 전체 Fetch 시간을 모두 측정했는가? |
Operation 이름만으로 최적화 성공을 판정하지 않습니다. COUNT STOPKEY나 WINDOW NOSORT STOPKEY가 보여도 하위 Row Source가 많은 후보를 처리할 수 있습니다.
테스트 깊이:
첫 페이지
→ 중간 페이지
→ 운영상 최대 페이지
세 방식은 동일한 Predicate·정렬·반환 Column·데이터 분포에서 비교합니다.
12. 적용 판단 순서
1. 임의 페이지 이동이 필요한지 확인한다.
2. 전체 결과의 결정적 ORDER BY를 정의한다.
3. 고유 Tie-Breaker와 NULL 정렬 규칙을 정한다.
4. 최대 페이지 깊이와 페이지 크기를 확인한다.
5. ROWNUM·OFFSET·Keyset 중 기능에 맞는 방식을 선택한다.
6. Equality Predicate와 정렬을 지원하는 Index를 검토한다.
7. Index 밖 Filter와 반환 Column의 Table Access를 확인한다.
8. 페이지 사이 변경과 Snapshot 요구를 정의한다.
9. 정확한 총건수 요구를 목록 조회와 분리해서 판단한다.
10. 얕은·중간·최대 깊이의 A-Rows·Buffers·응답시간을 비교한다.
개념 확인 문제
문제를 누르면 바로 아래에서 정답과 해설을 확인할 수 있습니다.
01Pagination 결과를 안정적으로 만들기 위해 ORDER BY가 결정적이어야 하는 이유는 무엇인가?
동률을 포함해 각 행의 전체 위치를 하나로 결정해야 페이지 경계가 안정되기 때문입니다. 업무 정렬 Column 뒤에 고유 Tie-Breaker를 추가합니다.
02ROWNUM 1 조건이 같은 Query Block에서 결과를 반환하지 못하는 이유는 무엇인가?
첫 후보 행의 ROWNUM이 1이어서 ROWNUM > 1을 만족하지 못하고, 다음 후보도 다시 첫 행으로 취급되어 ROWNUM 1을 받기 때문입니다. 하한은 별도 Query Block에서 이미 부여된 별칭을 대상으로 적용해야 합니다.
03ROWNUM 범위 Pagination에서 안쪽 상한과 바깥 하한은 각각 어떤 역할을 하는가?
안쪽 ROWNUM <= end_row는 필요한 최대 위치까지의 상한으로 STOPKEY에 활용될 수 있고, 바깥 rn > start_row는 앞쪽 행을 제거해 목표 구간만 남깁니다.
04OFFSET이 NULL이면 Oracle Row Limiting Clause는 어떻게 처리하는가?
0행을 반환합니다. OFFSET을 생략하면 0부터 시작하지만, OFFSET 표현식 자체가 NULL이면 Row Limiting 결과는 0행입니다.
05깊은 OFFSET에서 Index Order가 있어도 비용이 증가할 수 있는 이유는 무엇인가?
목표 위치 앞의 Index Entry를 통과해야 하며, Index 밖 Filter·반환 Column 때문에 버릴 후보까지 Table Access가 발생할 수 있기 때문입니다. 실제 작업량은 실행계획에 따라 OFFSET+FETCH보다 커질 수 있습니다.
06Keyset Pagination의 Cursor는 어떤 값을 포함해야 하는가?
이전 페이지 마지막 행의 전체 ORDER BY Key를 포함해야 합니다. 동률을 없애는 고유 Tie-Breaker까지 Cursor에 포함해야 다음 시작점이 하나로 결정됩니다.
07ORDER BY score DESC, createdat ASC, id ASC의 다음 페이지 비교 방향은 어떻게 되는가?
score는 더 작은 값, created_at과 id는 더 큰 값을 찾습니다. 각 Column의 ASC·DESC 방향과 동일한 사전식 비교 조건을 작성합니다.
08Keyset Pagination이 임의 페이지 이동에 불편한 이유는 무엇인가?
임의 페이지의 시작 Cursor Key를 바로 알 수 없기 때문입니다. 일반적으로 이전 페이지를 순차적으로 조회하거나 Cursor 위치를 별도로 저장해야 합니다.
09페이지별 동일 Snapshot이 필요할 때 검토할 수 있는 Oracle 기능은 무엇인가?
동일한 SCN을 사용한 Flashback Query AS OF SCN을 검토할 수 있습니다. Undo 보존 기간·권한·지원 Object와 운영 비용을 함께 확인해야 합니다.
10Pagination 실측 검증에서 확인해야 할 주요 Runtime 지표는 무엇인가?
하위 Operation의 A-Rows·Starts·Buffers, Sort·STOPKEY 형태, Index 시작 범위, Table Access, Memory·TEMP와 페이지 전체 Fetch 시간을 확인합니다. 첫 페이지만이 아니라 최대 허용 깊이까지 비교합니다.