DB·SQL 실무 가이드 · Part 9

페이징과 대량 조회 성능

목록 화면이 뒤 페이지로 갈수록 느려지는 문제 해결하기

작성 기준2026년 7월버전과 지원 현황은 이후 달라질 수 있으니 공식 문서를 함께 확인하세요.

이 파트에서 다루는 내용

페이징의 비용Oracle 페이징 3세대ROWNUM 함정부분범위 처리키셋 페이징바인드 변수
01

페이징은 뒤로 갈수록 느려집니다

1000번째 페이지를 보려면 DB는 앞의 9990건을 읽고 버린 뒤 10건을 줍니다. 건너뛰는 것이 아니라 읽고 버리는 것입니다.

그래서 첫 페이지는 빠르고 뒤로 갈수록 느려집니다. 목록 화면이 처음엔 괜찮다가 사용자가 뒤로 넘길수록 느려진다면 이 구조 때문입니다.

정렬이 인덱스로 처리되지 않으면 더 나쁩니다. 전체를 다 읽어 정렬한 뒤 그중 10건을 주게 됩니다.

02

Oracle 페이징 문법은 세 세대가 공존합니다

12c에서 표준 문법이 들어오기 전까지 Oracle에는 LIMIT 같은 것이 없었습니다. 그래서 ROWNUM을 쓴 관용적인 형태가 굳어졌고, 지금도 레거시 코드의 대부분은 이 형태입니다.

1세대: ROWNUM 중첩 인라인뷰sql
SELECT *
FROM (
  SELECT a.*, ROWNUM AS rnum
  FROM (
    SELECT order_id, order_dt, amount
    FROM   orders
    WHERE  status_cd = 'DONE'
    ORDER BY order_dt DESC
  ) a
  WHERE ROWNUM <= 30      -- 끝 지점: 여기서 미리 잘라 냅니다
)
WHERE rnum > 20;          -- 시작 지점

3중 구조인 이유가 있습니다. 가장 안쪽에서 정렬하고, 중간에서 ROWNUM으로 끝까지만 잘라 내고, 바깥에서 시작 지점을 거릅니다. 중간 단계에서 미리 자르는 것이 성능의 핵심이라 이 형태를 함부로 단순화하면 느려집니다.

2세대: ROW_NUMBERsql
SELECT order_id, order_dt, amount
FROM (
  SELECT order_id, order_dt, amount,
         ROW_NUMBER() OVER (ORDER BY order_dt DESC) AS rnum
  FROM   orders
  WHERE  status_cd = 'DONE'
)
WHERE rnum BETWEEN 21 AND 30;

읽기는 쉽지만 ROWNUM 방식처럼 중간에서 잘라 내는 최적화가 덜 걸릴 수 있습니다. 건수가 많으면 실행계획을 비교해 봅니다.

3세대: 표준 문법 (12c 이상)sql
SELECT order_id, order_dt, amount
FROM   orders
WHERE  status_cd = 'DONE'
ORDER BY order_dt DESC
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;

가장 간결하고 MSSQL과도 같은 문법입니다. 다만 운영 DB가 11g 이하면 못 씁니다. 대상 버전을 먼저 확인하고, 레거시 시스템이면 1세대 형태를 읽을 줄 알아야 합니다.

03

ROWNUM은 부여 시점 때문에 함정이 있습니다

ROWNUM은 조건을 통과한 행에 순서대로 붙는 번호입니다. 미리 매겨져 있는 것이 아니라 행이 통과할 때마다 1부터 붙습니다.

그래서 `WHERE ROWNUM > 10` 은 결과가 항상 0건입니다. 첫 행이 1번을 받으려는데 조건에 맞지 않아 탈락하고, 다음 행도 다시 1번을 시도하는 일이 반복되기 때문입니다.

ROWNUM 함정sql
-- 항상 0건 (오류가 아니라 결과가 없습니다)
SELECT * FROM orders WHERE ROWNUM > 10;

-- 정렬보다 ROWNUM이 먼저 적용됩니다
SELECT * FROM orders
WHERE  ROWNUM <= 10
ORDER BY amount DESC;
--> 금액 상위 10건이 아니라, 아무 10건을 뽑아 정렬한 결과입니다

-- 의도한 상위 10건
SELECT * FROM (
  SELECT * FROM orders ORDER BY amount DESC
)
WHERE ROWNUM <= 10;

두 번째 쿼리가 특히 위험합니다. 오류 없이 그럴듯한 결과를 주기 때문에 잘못을 알아채기 어렵습니다. 상위 N건은 반드시 정렬을 인라인뷰 안에 넣습니다.

04

무한 스크롤에는 키셋 페이징이 낫습니다

OFFSET 방식은 뒤로 갈수록 느려지고, 그사이 데이터가 추가되면 같은 행이 두 번 나오거나 건너뛰는 문제도 있습니다.

마지막으로 본 행의 키를 기준으로 그다음을 가져오면 이 두 문제가 함께 해결됩니다. 몇 페이지든 같은 속도이고, 데이터가 추가돼도 흔들리지 않습니다.

OFFSET 방식
페이지 번호 필요

게시판처럼 페이지 번호를 눌러 이동하는 화면에 씁니다. 뒤 페이지 성능은 감수합니다.

키셋 방식
무한 스크롤

더 보기, 무한 스크롤, 대량 배치 순회에 적합합니다. 일정한 속도가 나옵니다.

총 건수 조회
숨은 비용

'전체 1234건' 표시를 위한 COUNT는 조건에 맞는 전체를 세야 해서 목록 조회보다 느릴 수 있습니다. 정말 필요한지, 근사치로 충분한지 먼저 정합니다.

키셋 페이징sql
-- 첫 페이지
SELECT order_id, order_dt, amount
FROM   orders
WHERE  status_cd = 'DONE'
ORDER BY order_dt DESC, order_id DESC
FETCH FIRST 10 ROWS ONLY;

-- 다음 페이지: 마지막으로 본 값을 조건으로
SELECT order_id, order_dt, amount
FROM   orders
WHERE  status_cd = 'DONE'
AND    (order_dt < :last_order_dt
        OR (order_dt = :last_order_dt AND order_id < :last_order_id))
ORDER BY order_dt DESC, order_id DESC
FETCH FIRST 10 ROWS ONLY;

정렬 키가 유일하지 않으면 동점 처리를 위해 보조 키를 함께 써야 합니다. 대신 특정 페이지로 바로 점프하는 것은 안 되므로, 페이지 번호가 필요한 화면에는 쓸 수 없습니다.

05

바인드 변수를 쓰면 파싱 비용이 사라집니다

Part 1에서 SQL이 파싱과 최적화를 거친다고 했습니다. Oracle은 같은 SQL 문장을 공유 풀에 캐싱해 두고 재사용합니다.

그런데 값을 문자열로 이어 붙여 SQL을 만들면 값이 바뀔 때마다 다른 문장이 됩니다. 캐시가 안 맞고 매번 하드 파싱이 일어납니다. 동시 접속이 많은 시스템에서는 이것만으로 CPU가 치솟습니다.

바인드 변수를 쓰면 문장은 하나로 유지되고 값만 바뀝니다. 성능과 함께 SQL 인젝션 방어까지 됩니다.

값이 몰린 컬럼은 예외
주의

특정 값에 데이터가 몰린 컬럼은 바인딩하면 옵티마이저가 분포를 못 봅니다. 이런 컬럼은 오히려 상수가 나을 수 있습니다.

IN 목록의 개수
함정

IN 절의 항목 수가 매번 달라지면 문장도 매번 달라집니다. 개수를 일정 단위로 맞추거나 임시 테이블을 검토합니다.

확인 방법
진단

V$SQL 에서 SQL_TEXT 가 값만 다른 문장이 잔뜩 보이면 바인딩이 안 되고 있는 것입니다.

문자열 결합 대신 바인딩sql
-- 나쁨: 값마다 다른 SQL이 됩니다
SELECT * FROM orders WHERE customer_id = 100;
SELECT * FROM orders WHERE customer_id = 101;
SELECT * FROM orders WHERE customer_id = 102;
--> 공유 풀에 세 개의 서로 다른 문장이 쌓입니다

-- 좋음: 문장 하나를 재사용합니다
SELECT * FROM orders WHERE customer_id = :customer_id;

MyBatis에서는 #{} 가 바인드 변수, ${} 는 문자열 치환입니다. 값에 ${} 를 쓰면 하드 파싱과 SQL 인젝션이 함께 따라옵니다. 컬럼명이나 정렬 방향처럼 구조가 바뀌는 곳에만 제한적으로 씁니다.

트랙 B 마무리

성능 문제는 대개 이 순서로 좁혀집니다. 실행계획으로 어디가 느린지 찾고, 예상과 실제의 차이로 통계를 의심하고, 인덱스를 못 타는 패턴을 걷어내고, 그래도 안 되면 쿼리 구조나 설계를 바꿉니다. 힌트는 마지막입니다. 트랙 C부터는 여러 사람이 동시에 접근할 때 생기는 문제를 다룹니다.

체크

이 파트 완료 기준