Search

데이터베이스 인덱스와 페이지네이션 최적화

인덱스 종류

클러스터 인덱스 (Clustered Index)

테이블 데이터 자체가 정렬되어 저장되는 인덱스입니다.
특징
테이블당 1개만 존재
InnoDB에서는 PK가 곧 클러스터 인덱스
리프 노드에 실제 데이터 전체가 저장됨
데이터가 물리적으로 정렬됨
책의 본문 자체가 페이지 순서대로 정렬된 것과 비슷하다고 보면 됩니다
CREATE TABLE orders ( order_id BIGINT PRIMARY KEY, -- 클러스터 인덱스 user_id BIGINT, product_name VARCHAR(255), amount INT, created_at DATETIME );
SQL
복사
구조
[Root Node] ↓ [Branch Nodes: order_id 범위] ↓ [Leaf Nodes] order_id=1 → [전체 행: 1, 123, '상품A', 10000, '2025-01-01'] order_id=2 → [전체 행: 2, 456, '상품B', 20000, '2025-01-02'] order_id=3 → [전체 행: 3, 123, '상품C', 30000, '2025-01-03']
Plain Text
복사

세컨더리 인덱스 (Secondary Index)

클러스터 인덱스가 아닌 모든 일반 인덱스를 말합니다. 특징
테이블당 여러 개 생성 가능
리프 노드에 PK 값(클러스터 키) 저장
데이터 조회 시 2단계 탐색: 세컨더리 인덱스 → 클러스터 인덱스
책의 색인(목차)과 같음
-- 세컨더리 인덱스 생성 CREATE INDEX idx_user ON orders(user_id); CREATE INDEX idx_created ON orders(created_at);
SQL
복사
구조
[Secondary Index: idx_user] ↓ [Leaf Nodes] user_id=123 → order_id=1 ─┐ user_id=123 → order_id=3 ─┤ user_id=456 → order_id=2 │ ↓ [Clustered Index로 이동] order_id=1 → 전체 데이터 조회 order_id=3 → 전체 데이터 조회
Plain Text
복사
조회 과정
SELECT * FROM orders WHERE user_id = 123; -- 실행 단계: -- 1. idx_user 스캔 → order_id=1, order_id=3 획득 -- 2. 클러스터 인덱스에서 order_id=1 조회 → 전체 데이터 -- 3. 클러스터 인덱스에서 order_id=3 조회 → 전체 데이터
SQL
복사
세컨더리 인덱스로 조회를 해도 느린이유.
SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
SQL
복사
실행 과정:
1.
idx_user_created 세컨더리 인덱스 스캔 -- → 10,020개의 order_id 획득
2.
각 order_id로 클러스터 인덱스 접근 (10,020번!)
3.
10,000개는 버리고 20개만 반환
4.
데이터가 많을수록 세컨더리 인덱스 스캔 자체는 큰 차이가 없지만, 클러스터 인덱스로 10,020번 랜덤 접근하는 게 병목이 됩니다

커버링 인덱스 (Covering Index)

쿼리에 필요한 모든 컬럼이 인덱스에 포함되어 테이블 접근 없이 처리되는 상태를 말합니다.
핵심 개념
인덱스 종류가 아니라 쿼리 실행 최적화 상태
SELECT, WHERE, ORDER BY의 모든 컬럼이 인덱스에 포함
세컨더리 인덱스만으로 쿼리 완료 (클러스터 인덱스 접근 불필요)
EXPLAIN의 Extra에서 "Using index" 확인
-- 일반 세컨더리 인덱스 CREATE INDEX idx_user ON orders(user_id); SELECT * FROM orders WHERE user_id = 123; -- 2단계 탐색 필요 (인덱스 → 테이블) -- 커버링 인덱스로 설계 CREATE INDEX idx_user_created_id ON orders(user_id, created_at, order_id); SELECT user_id, created_at, order_id FROM orders WHERE user_id = 123 ORDER BY created_at DESC; -- 1단계만 (인덱스 스캔으로 완료) - 커버링 인덱스!
SQL
복사
구조
[Covering Index: idx_user_created_id] ↓ [Leaf Nodes에 필요한 모든 데이터 포함] (user_id=123, created_at='2025-01-03', order_id=3) (user_id=123, created_at='2025-01-01', order_id=1) ↓ 테이블 접근 없이 바로 결과 반환!
Plain Text
복사

인덱스 비교표

구분
클러스터 인덱스
세컨더리 인덱스
커버링 인덱스
정의
데이터 저장 방식
일반 인덱스
쿼리 최적화 상태
개수
테이블당 1개
여러 개 가능
쿼리마다 다름
리프노드
전체 행 데이터
PK 값만
인덱스 컬럼들
테이블 접근
불필요 (자체가 테이블)
필요 (2단계)
불필요
생성
PK 지정 시 자동
CREATE INDEX
쿼리에 맞게 설계
EXPLAIN Extra
-
-
"Using index"

페이지네이션 최적화

문제 상황: OFFSET의 성능 저하

-- 느린 쿼리 SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
SQL
복사
문제점
10,020개 행을 읽어야 함
세컨더리 인덱스 → 10,020번 클러스터 인덱스 접근
OFFSET이 클수록 기하급수적으로 느려짐

최적화 방법 1: 커버링 인덱스 + Deferred Join

커버링 인덱스로 PK만 빠르게 추출한 다음 메인 테이블과 JOIN하는 방식입니다. 인덱스 설계
CREATE INDEX idx_user_created_id ON orders(user_id, created_at DESC, order_id);
SQL
복사
최적화 쿼리
SELECT o.* FROM orders o INNER JOIN ( -- 이 서브쿼리가 커버링 인덱스로 동작 SELECT order_id FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20 OFFSET 10000 ) sub ON o.order_id = sub.order_id ORDER BY o.created_at DESC;
SQL
복사
동작 과정
1.
서브쿼리: idx_user_created_id 스캔 (커버링 인덱스)
10,020개 행을 인덱스에서만 처리
인덱스는 테이블보다 훨씬 작고 빠름
최종 20개의 order_id만 반환
2.
JOIN: 클러스터 인덱스 접근
20개 order_id로 테이블 조회
10,020개가 아닌 20개만 테이블 접근
성능 개선
Before: 5-10초
After: 0.1-0.5초
EXPLAIN 확인
EXPLAIN SELECT o.* FROM orders o INNER JOIN ( SELECT order_id FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20 OFFSET 10000 ) sub ON o.order_id = sub.order_id;
SQL
복사
확인 포인트:
서브쿼리 Extra: Using index ← 커버링 인덱스 동작
서브쿼리 type: ref 또는 range
JOIN type: eq_ref ← PK 조인으로 최적
rows: 최소화

최적화 방법 2: No Offset (Seek Method)

OFFSET 대신 마지막 값을 기준으로 다음 페이지를 조회하는 방식입니다.
-- 기존 방식 SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20 OFFSET 100; -- 120개 읽고 100개 버림 -- No Offset 방식 SELECT * FROM orders WHERE user_id = 123 AND created_at < '2025-01-05 10:00:00' -- 이전 페이지의 마지막 값 ORDER BY created_at DESC LIMIT 20; -- 20개만 읽음!
SQL
복사
복합 조건 (동일 시간 처리)
SELECT * FROM orders WHERE user_id = 123 AND (created_at < '2025-01-05' OR (created_at = '2025-01-05' AND order_id < 1000)) ORDER BY created_at DESC, order_id DESC LIMIT 20;
SQL
복사
장점
OFFSET 없이 항상 일정한 성능
첫 페이지나 마지막 페이지나 속도 동일
커버링 인덱스와 함께 사용하면 최고 성능
인덱스 설계
CREATE INDEX idx_user_created_id ON orders(user_id, created_at DESC, order_id DESC);
SQL
복사
Spring Boot 구현
@Service public class OrderService { // 첫 페이지 public PageResponse getFirstPage(Long userId, int size) { List<Order> orders = orderRepository .findByUserIdOrderByCreatedAtDesc(userId, PageRequest.of(0, size)); LocalDateTime lastCreatedAt = orders.isEmpty() ? null : orders.get(orders.size() - 1).getCreatedAt(); return new PageResponse(orders, lastCreatedAt, null); } // 다음 페이지 (No Offset) public PageResponse getNextPage(Long userId, LocalDateTime lastCreatedAt, int size) { List<Order> orders = orderRepository .findByUserIdAndCreatedAtLessThanOrderByCreatedAtDesc( userId, lastCreatedAt, PageRequest.of(0, size)); LocalDateTime newLastCreatedAt = orders.isEmpty() ? null : orders.get(orders.size() - 1).getCreatedAt(); return new PageResponse(orders, newLastCreatedAt, lastCreatedAt); } } // Repository public interface OrderRepository extends JpaRepository<Order, Long> { @Query(value = """ SELECT o.* FROM orders o INNER JOIN ( SELECT order_id FROM orders WHERE user_id = :userId AND created_at < :lastCreatedAt ORDER BY created_at DESC LIMIT :size ) sub ON o.order_id = sub.order_id ORDER BY o.created_at DESC """, nativeQuery = true) List<Order> findNextPageOptimized( @Param("userId") Long userId, @Param("lastCreatedAt") LocalDateTime lastCreatedAt, @Param("size") int size); }
Java
복사

최적화 방법 3: 파티셔닝 + 커버링 인덱스

대용량 데이터에서 파티션 프루닝과 커버링 인덱스를 함께 활용하는 방법입니다.
테이블 생성
CREATE TABLE orders ( order_id BIGINT AUTO_INCREMENT, user_id BIGINT, created_at DATETIME, amount INT, PRIMARY KEY (order_id, created_at), -- 파티션 키 포함 필수 INDEX idx_user_created_id (user_id, created_at DESC, order_id) ) PARTITION BY RANGE (YEAR(created_at)) ( PARTITION p2023 VALUES LESS THAN (2024), PARTITION p2024 VALUES LESS THAN (2025), PARTITION p2025 VALUES LESS THAN (2026), PARTITION p2026 VALUES LESS THAN (2027) );
SQL
복사
최적화 쿼리
SELECT o.* FROM orders o INNER JOIN ( SELECT order_id FROM orders WHERE user_id = 123 AND created_at >= '2025-01-01' -- 파티션 p2025만 스캔 AND created_at < '2026-01-01' ORDER BY created_at DESC LIMIT 20 OFFSET 5000 ) sub ON o.order_id = sub.order_id ORDER BY o.created_at DESC;
SQL
복사
효과
전체 데이터: 1억 건
파티션 프루닝: 2025년 데이터만 (1000만 건)
커버링 인덱스: 인덱스만 스캔
결과: 수십 초 → 1초 미만

최적화 방법 4: 페이지 번호 제한

실용적인 접근으로 무한 스크롤이나 페이지 제한을 두는 방법입니다.
-- 처음 1000페이지까지만 허용 SELECT o.* FROM orders o INNER JOIN ( SELECT order_id FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20 OFFSET :offset ) sub ON o.order_id = sub.order_id WHERE :offset < 20000 -- 1000페이지 * 20개 ORDER BY o.created_at DESC;
SQL
복사
또는 "다음 페이지" 버튼만 제공하는 방식으로 구현할 수도 있습니다 (No Offset 방식).

성능 비교

테스트 환경
데이터: 500만 건
조건: user_id = 123 (10만 건)
페이지: OFFSET 10000, LIMIT 20
방법
실행시간
읽은 행
일반 쿼리 (OFFSET)
8-12초
10,020행
커버링 인덱스 + JOIN
0.3-0.8초
20행
No Offset
0.1-0.3초
20행
파티셔닝 + 커버링
0.05-0.2초
20행

실전 체크리스트

1단계: 현재 상태 파악

EXPLAIN SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
SQL
복사
확인 사항
type이 ALL이면 인덱스 없음
Extra에 Using filesort면 정렬 느림
rows가 10,020보다 크면 비효율

2단계: 커버링 인덱스 추가

CREATE INDEX idx_user_created_id ON orders(user_id, created_at DESC, order_id);
SQL
복사

3단계: Deferred Join 적용

SELECT o.* FROM orders o INNER JOIN ( SELECT order_id FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20 OFFSET 10000 ) sub ON o.order_id = sub.order_id;
SQL
복사

4단계: 측정 & 개선

실행 시간 측정
EXPLAIN으로 Using index 확인
필요시 No Offset 방식으로 전환

주의사항

커버링 인덱스 설계 시

SELECT 절의 모든 컬럼이 인덱스에 포함되어야 함
WHERE, ORDER BY 조건도 인덱스 순서와 일치해야 함
인덱스가 너무 크면 메모리 효율성 저하

No Offset 방식 사용 시

특정 페이지로 직접 이동 불가 (순차 탐색만 가능)
무한 스크롤, 다음/이전 버튼 UI에 적합
API 응답에 다음 페이지 커서(마지막 값) 포함 필요

파티셔닝 사용 시

파티션 키가 WHERE 조건에 포함되어야 프루닝 효과
PK에 파티션 키 포함 필수
파티션 개수가 너무 많으면 오히려 성능 저하