ORDER BY·LIMIT·OFFSET — 정렬과 페이지네이션
정렬 옵션, LIMIT/OFFSET 의 함정, 그리고 진짜 빠른 keyset pagination.
"최근 글 10개" "가격 낮은 순" "다음 페이지" — 거의 모든 화면이 ORDER BY 와 LIMIT 로 만들어집니다. 7편은 둘의 기본부터, 그리고 데이터가 커지면 반드시 부딪치는 OFFSET 의 느림 과 그 해결책 keyset pagination 까지.
ORDER BY — 정렬 기본
-- 한 컬럼
SELECT * FROM posts ORDER BY created_at DESC;
-- 여러 컬럼 (앞 컬럼 먼저, 같으면 다음)
SELECT * FROM users ORDER BY active DESC, age ASC;
-- 컬럼 번호 (가독성 나쁨 — 권장 X)
SELECT id, name FROM users ORDER BY 2;
-- 표현식 정렬
SELECT * FROM users ORDER BY lower(email);
-- 정렬 결과 컬럼만 가져오면 인덱스 활용도 ↑
SELECT id FROM posts ORDER BY created_at DESC LIMIT 10;
NULLS FIRST / LAST
-- 기본: ASC 는 NULL 마지막, DESC 는 NULL 처음
SELECT name, phone FROM users ORDER BY phone; -- NULL 마지막
SELECT name, phone FROM users ORDER BY phone DESC; -- NULL 처음
-- 명시
SELECT name, phone FROM users ORDER BY phone NULLS FIRST;
SELECT name, phone FROM users ORDER BY phone DESC NULLS LAST;
왜 명시. 사용자 화면에서 "전화번호 없는 사람 먼저" 같은 의도가 분명할 때 NULLS FIRST/LAST 가 가독성이 훨씬 좋습니다. 그리고 인덱스에도 같은 NULLS 옵션을 줘야 정렬이 인덱스를 100% 활용합니다(16편).
LIMIT · OFFSET — 기본 페이지네이션
-- 처음 10개
SELECT * FROM posts ORDER BY created_at DESC LIMIT 10;
-- 두 번째 페이지 (10개씩, 10번째 다음부터)
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 10;
-- 페이지 N — 1-base
-- offset = (N - 1) * size
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET (3 - 1) * 10; -- 3페이지
-- 표준 SQL 표현 (FETCH FIRST)
SELECT * FROM posts
ORDER BY created_at DESC
FETCH FIRST 10 ROWS ONLY;
OFFSET 의 함정 — 페이지가 깊을수록 느려진다
-- 99,900 행을 skip 하고 100개 가져오기
EXPLAIN ANALYZE
SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 100 OFFSET 99900;
왜 느릴까. OFFSET 은 정확히 "앞의 N 개 행을 스캔하고 버린다" 입니다. 100만 행 중 페이지 1000(OFFSET 99,000)을 가져오려면 99,000 행을 읽고 버린 뒤 100 개를 더 읽습니다. 페이지가 깊을수록 선형으로 느려져요. 페이지 5,000 은 거의 안 됩니다.
keyset pagination — 진짜 빠른 방식
-- 1페이지 (가장 최근 10개)
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 10;
-- 결과의 마지막 행: created_at='2026-05-17 02:00', id=12345
-- 2페이지 — 마지막 행 "이후" 부터
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-05-17 02:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 10;
"앞 페이지의 마지막 행보다 작은 것" 이라는 명확한 조건이 인덱스 검색에 정확히 들어맞습니다. 몇 천 페이지를 가도 첫 페이지와 같은 속도. (created_at, id) 같은 복합 인덱스가 있으면 완벽합니다.
| 방식 | 장점 | 단점 |
|---|---|---|
| OFFSET | "X 페이지로 이동" 임의 점프 가능 | 깊은 페이지 매우 느림 + 중간 INSERT 시 중복 |
| keyset | 속도 일정, 중간 변동에 안전 | "X 페이지로 점프" 불가능 (다음/이전만) |
UX 권장. 무한 스크롤·다음 버튼 UI 는 keyset 이 정답. 1-2-3-4-5 페이지 번호 UI 는 OFFSET 이 자연스러우니 데이터 1만 건 이하에서만 유지. 그 이상은 검색·필터로 좁힌 다음 keyset.
"몇 페이지 있는지" — COUNT 의 비용
-- 정확한 총 행 수 — 느림 (전체 스캔)
SELECT count(*) FROM posts;
-- 정확하지 않아도 OK 면 통계 사용 (즉시)
SELECT reltuples::bigint FROM pg_class WHERE relname = 'posts';
-- 페이지네이션과 함께 (전체 count 호출은 권장 X)
-- 화면에 "X 페이지 중 Y" 를 정말 보여줘야 한다면 캐시
대규모 테이블의 COUNT(*) 는 페이지 1을 그릴 때마다 부르면 안 됩니다. UX 적으로도 "총 53,427개 중 1-10" 보다 "더 보기" 가 보통 더 자연스럽습니다.
8편 — GROUP BY 와 집계 함수
COUNT·SUM·AVG·HAVING, "그룹별 통계" 의 정석.
이전: 6편 WHERE · 현재: 7편 (SQL 기초) · 다음 → 8편 GROUP BY · 진행: 7/24