대용량 공공데이터 조회 성능 최적화: 커버링 인덱스를 활용한 지연 조인(Deferred Join)과 No-Offset 페이징 설계

대용량 공공데이터 조회 성능 최적화: 커버링 인덱스를 활용한 지연 조인(Deferred Join)과 No-Offset 페이징 설계
대용량 공공데이터 조회 성능 최적화: 커버링 인덱스를 활용한 지연 조인(Deferred Join)과 No-Offset 페이징 설계

전국 단위의 약국, 공장, 사업장 등 수백만 건에 달하는 대용량 공공데이터를 RDBMS에 적재하여 서비스할 때, 뒷페이지로 이동할수록(OFFSET 100000 이상) DB CPU 점유율이 100%로 치솟거나 Response Time이 급증하는 성능 병목을 마주하게 됩니다.

이는 RDBMS 옵티마이저가 OFFSET 위치까지의 모든 레코드를 메모리로 읽어 들인 후 버리는 동작 방식 때문입니다. 이번 글에서는 커버링 인덱스(Covering Index)를 활용한 지연 조인(Deferred Join) 및 No-Offset(Seek Method) 페이징 설계를 통해 대용량 조회 성능을 극대화하는 방법을 다룹니다.

1. 대용량 오프셋 페이징(OFFSET/LIMIT)의 내부 동작 메커니즘

전국 사업자 정보 300만 건이 적재된 public_business 테이블을 가정해 보겠습니다. 사용자가 25,000번째 페이지(페이지당 20건)를 조회할 때 실행되는 전형적인 쿼리는 다음과 같습니다.

-- 일반적인 OFFSET 페이징 쿼리 (500,000번째 레코드부터 20건 조회)
SELECT id, business_name, owner_name, status, registered_at, address
FROM public_business
WHERE status = 'ACTIVE'
ORDER BY id ASC
LIMIT 20 OFFSET 500000;

이 쿼리를 실행했을 때 데이터베이스 옵티마이저가 500,000개의 레코드를 건너뛰고 바로 500,001번째 레코드부터 읽어올 것이라고 기대하기 쉽습니다. 그러나 InnoDB 스토리지 엔진의 동작 방식은 전혀 다릅니다.

  • 모든 선행 레코드의 데이터 페이지 탐색: 데이터베이스 엔진은 정렬 조건과 조건절을 만족하는 레코드를 500,020건까지 순차적으로 읽어야 합니다.
  • 불필요한 클러스터드 인덱스 역참조: SELECT 절에 포함된 컬럼(business_name, address 등)을 가져오기 위해, 보조 인덱스를 사용하더라도 매 레코드마다 기본키(PK)를 통해 클러스터드 인덱스의 실제 데이터 페이지를 조회(Random I/O)합니다.
  • 버퍼 풀 적중률 하락 및 메모리 오염: 읽어 들인 500,000건의 행은 클라이언트에게 전달되지 않고 즉시 폐기되지만, 이를 읽는 과정에서 버퍼 풀의 LRU 리스트가 오염되고 디스크 읽기가 강제됩니다.

2. 해결 패턴 1: No-Offset (Keyset Pagination) 설계

딥 페이징 문제를 근본적으로 해결하는 가장 효과적인 방법은 OFFSET 키워드 자체를 제거하는 No-Offset(또는 Keyset) 페이징입니다. 직전 페이지에서 확인한 마지막 레코드의 식별자(주로 자동 증가 PK 또는 고유 정렬 키)를 다음 페이지 요청 시 조건절로 전달하는 방식입니다.

-- No-Offset 페이징 쿼리 (직전 페이지의 마지막 id가 582410인 경우)
SELECT id, business_name, owner_name, status, registered_at, address
FROM public_business
WHERE status = 'ACTIVE'
  AND id > 582410
ORDER BY id ASC
LIMIT 20;

이 방식의 성능 차이는 B+Tree 인덱스의 탐색 구조에서 비롯됩니다. 데이터베이스는 500,000개의 이전 레코드를 순차 스캔하는 대신, B+Tree 루트 노드에서부터 id = 582410인 위치를 즉시 탐색(Point Lookup)한 뒤, 인덱스 리프 노드의 연결 리스트를 따라 정확히 20개의 레코드만 읽어옵니다.

# FastAPI / SQLAlchemy 기반 No-Offset 구현 예시
from fastapi import APIRouter, Query, Depends
from sqlalchemy.orm import Session
from typing import List, Optional

router = APIRouter()

@router.get("/api/v1/public-business")
def get_businesses(
    last_id: Optional[int] = Query(None, description="직전 페이지의 마지막 ID"),
    limit: int = Query(20, le=100),
    db: Session = Depends(get_db)
):
    query = db.query(PublicBusiness).filter(PublicBusiness.status == "ACTIVE")
    
    if last_id is not None:
        query = query.filter(PublicBusiness.id > last_id)
        
    items = query.order_by(PublicBusiness.id.asc()).limit(limit).all()
    next_cursor = items[-1].id if len(items) == limit else None
    
    return {
        "items": items,
        "next_cursor": next_cursor,
        "has_more": next_cursor is not None
    }

3. 해결 패턴 2: 커버링 인덱스 기반 지연 조인(Deferred Join)

관리자 콘솔이나 검색 화면 등 특정 페이지 번호를 직접 선택해야 하는 비즈니스 요구사항이 있는 경우에는 No-Offset을 도입하기 어렵습니다. 이때 사용할 수 있는 아키텍처 패턴이 커버링 인덱스(Covering Index)를 활용한 지연 조인(Deferred Join)입니다.

  1. 1단계 (커버링 인덱스 서브쿼리): SELECT 절에 id(PK)만 명시하고 보조 인덱스를 탐색합니다. 테이블의 데이터 블록을 전혀 건드리지 않고 메모리상의 인덱스 트리만 읽어 500,000건을 빠르게 건너뛴 뒤 대상 20건의 id만 추출합니다. (실행 계획: Using index)
  2. 2단계 (클러스터드 인덱스 조인): 서브쿼리에서 확정된 단 20개의 id를 메인 테이블과 조인하여 나머지 상세 컬럼을 가져옵니다. 실제 데이터 페이지 역참조 비용이 500,020회에서 단 20회로 줄어듭니다.
-- 복합 인덱스 생성: (status, id)
CREATE INDEX idx_business_status_id ON public_business(status, id);

-- 커버링 인덱스를 활용한 지연 조인 (Deferred Join) 쿼리
SELECT b.id, b.business_name, b.owner_name, b.status, b.registered_at, b.address
FROM public_business b
JOIN (
    SELECT id
    FROM public_business
    WHERE status = 'ACTIVE'
    ORDER BY id ASC
    LIMIT 20 OFFSET 500000
) AS temp ON b.id = temp.id;

4. 실무 아키텍처 구축 시 핵심 체크리스트

  • 전체 레코드 수(COUNT(*)) 쿼리의 분리: 페이지네이션 네비게이터를 그리기 위해 매번 SELECT COUNT(*)를 호출하면 페이징 최적화의 효과가 반감됩니다. 조건절에 일치하는 전체 카운트는 Redis 등의 인메모리 저장소에 TTL을 부여하여 캐싱하거나, 첫 페이지 조회 시에만 비동기로 계산하는 구조를 권장합니다.
  • 복합 인덱스 컬럼 순서 원칙 준수: 지연 조인을 사용할 경우 인덱스는 (필터링 컬럼 = 조건) -> (정렬 컬럼) -> (PK) 순서로 배치되어야 인덱스 컨디션 푸시다운(ICP)과 정렬 생략(Filesort 방지)의 이점을 온전히 활용할 수 있습니다.
  • 최대 조회 페이지 상한(Limit Cap) 설정: 실무 시스템에서는 일반 사용자가 10,000페이지 이상을 직접 조회하는 행위는 비정상적인 데이터 스크래핑 시도일 확률이 높습니다. 비즈니스 정책상 최대 탐색 가능 페이지(예: 100페이지)를 제한하는 가드레일을 설정하는 것이 데이터베이스 보호에 효과적입니다.

5. 결론

수백만 건 규모의 공공데이터를 서비스할 때 OFFSET/LIMIT 기반의 단순 페이징은 시스템 자원을 비효율적으로 고갈시키는 요인이 됩니다. 실시간 피드나 데이터 수집·동기화 워커라면 No-Offset 패턴을 최우선으로 도입해야 하며, 관리자 페이지 등 번호 기반 페이지 네비게이션이 반드시 필요한 시스템이라면 커버링 인덱스를 활용한 지연 조인(Deferred Join)을 적용하여 쿼리 실행 계획을 개선하는 것이 필수적인 아키텍처 원칙입니다.


출처 및 1차 참고 기술 문서

Similar Posts

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다