대용량 목록 조회 최적화
목록 쿼리 응답 3.8s→0.3s1,400만 건 게시글 깊은 페이지 조회를 Covering Index로 최적화
JavaSpringMySQLCovering Index
아키텍처
개요
- 1,400만 건 게시글 테이블의 깊은 페이지 목록 조회 성능 최적화
- OFFSET 스캔 자체보다 본문 테이블 Access 비용 축소에 집중
문제
OFFSET 2,999,970구간까지 읽고 버리는 구조로 조회 비용 증가- Secondary Index 조회 후 본문 테이블을 다시 읽는 랜덤 I/O 발생
- 깊은 페이지 조회에서 실제 본문 row 접근 범위 과도
- 임의 페이지 이동 요구로 cursor 기반 keyset pagination만 적용하기 어려운 화면 구조
해결 전략
(board_id, article_id)커버링 인덱스로 게시글 ID 목록 먼저 조회- 조회된 ID만 본문 테이블과 JOIN해 테이블 접근 범위 축소
- OFFSET 스캔 비용은 남지만 ID를 먼저 조회한 뒤 JOIN해 본문 row 접근과 랜덤 I/O 비용 축소
기술 선택 이유
-
Covering Index
- 깊은 페이지에서 OFFSET으로 건너뛰는 구간을 인덱스 엔트리 중심으로 처리
- 필요한 ID를 찾은 뒤 본문 테이블에 접근해 랜덤 I/O 범위 축소
- 정렬 기준과 필터 조건을 함께 만족하는 인덱스 설계 가능
-
Keyset Pagination 제외
- 마지막 조회 ID 기반으로 다음 페이지를 넘기는 구조는 성능상 유리
- 임의 페이지 번호 이동 UI 요구와 맞지 않아 기본 목록 조회 방식에서 제외
검증
테스트 설정
- 게시글 약 1,400만 건 적재
- 동일 board 기준 깊은 페이지 조회
- 기존 OFFSET 쿼리와 개선 쿼리(ID 선 조회 후 JOIN) 실행 시간 비교
- 임의 페이지 이동 요구가 있는 조건에서 동일 offset 기준 비교
측정 결과
- 목록 쿼리 응답 3.8초에서 0.3초로 감소
- OFFSET 스캔은 유지되지만 본문 테이블 접근 범위 축소
핵심 쿼리
- 인덱스로 ID 목록 먼저 조회하는 구조
- 필요한 ID만 본문 테이블과 JOIN
SELECT a.article_id,
a.title,
a.content,
a.board_id,
a.writer_id,
a.created_at,
a.modified_at
FROM (
SELECT article_id
FROM article
WHERE board_id = :boardId
ORDER BY article_id DESC
LIMIT :limit OFFSET :offset
) page
JOIN article a ON a.article_id = page.article_id;
배운 점
- 응답 시간의 핵심 비용은 인덱스에서 찾은 PK로 본문 테이블을 다시 읽을 때 발생하는 random I/O
- Covering Index로 필요한 30건만 본문에 접근하게 해 random I/O를 크게 축소