본문 바로가기
DB/PostgreSQL

[PostgreSQL] 인덱스를 파괴하는 3대 안티 패턴과 Sargable 쿼리 최적화 기법

by coding_whale 2026. 6. 11.
반응형

1. 들어가며: 규모의 한계를 넘어서는 쿼리 최적화

단순히 테이블을 생성하고 컬럼에 인덱스(CREATE INDEX)를 걸어두는 것만으로 대규모 트래픽 환경의 성능이 보장되던 시대는 지났다. 비즈니스가 고도화되고 데이터의 누적 속도가 임계점에 다다르면, 무심코 작성한 단 한 줄의 WHERE 조건절이나 정렬(ORDER BY) 구문이 옵티마이저의 판단을 흐리게 하여 전체 시스템을 순식간에 다운시킬 수 있다.

데이터베이스 튜닝의 핵심은 물리적 디스크와 메모리의 연산을 최소화하는 일이다. 특히 PostgreSQL은 강력한 확장성을 지닌 반면, 쿼리 엔진의 동작 규칙을 명확히 인지하지 못하면 인덱스를 전혀 타지 못하는 안티 패턴에 빠지기 쉽다.

 

[PostgreSQL] JSONB 유연 모델링부터 5대 인덱스 아키텍처 및 스캔 최적화까지

1. 들어가며: 관계형 데이터베이스와 유연한 문서 모델의 융합현대 이커머스나 콘텐츠 서비스의 백엔드 시스템은 단순히 규격화된 테이블 정보만을 처리하지 않는다. 가전제품의 '소비전력, 용

myblog01150.tistory.com

 

본 가이드에서는 실무에서 대용량 데이터를 다루는 이커머스 글로벌 주문 결제 내역(order_sales_perf) 도메인을 기준으로, 인덱스 작동 유실을 유발하는 안티 패턴 분석, 딥 오프셋 페이징의 한계 극복법, 통계 분석 및 정렬 노드 튜닝, 그리고 범위(Range) 및 목록(List) 테이블 파티셔닝 기법을 정밀하게 다룬다.

 

 

2. 인덱스를 무력화하는 3대 안티 패턴과 Sargable 튜닝

인덱스는 사전에 완전히 정렬되어 보관된 고유한 물리적 트리 파일이다. 따라서 조회 성능을 최적화할 수 있도록 형태를 가공하지 않는 쿼리인 'Sargable(Search Argument Able) 조건'을 엄격히 충족해야 인덱스 탐색의 가속을 얻을 수 있다.

 

2.1 안티 패턴 ①: 인덱스 컬럼의 함수 및 연산 적용

인덱스가 걸려 있는 컬럼값을 함수나 수식으로 가공해 버리면, 플래너는 정렬된 트리 내부의 물리 인덱스 구조를 가동할 수 없게 된다.

-- ❌ [안티 패턴]: EXTRACT 함수 사용으로 기껏 생성한 idx_perf_created_at 인덱스 무력화
EXPLAIN ANALYZE SELECT * FROM order_sales_perf 
WHERE EXTRACT(YEAR FROM created_at) = 2024;

-- 65535건의 테이블 풀 스캔(Seq Scan) 발생! 약 145ms 소요

--  [Sargable 튜닝]: 함수를 제거하고 범위 조건(Range Query)을 명시적으로 선언
EXPLAIN ANALYZE SELECT * FROM order_sales_perf 
WHERE created_at >= '2024-01-01 00:00:00+09' AND created_at < '2025-01-01 00:00:00+09';

-- Index Range Scan 작동 완료! 단 1.8ms 수준으로 개선

 

2.2 안티 패턴 ②: 묵시적 형변환(Implicit Type Cast)의 함정

컬럼 타입과 쿼리로 비교 대상이 되는 조건 인자 값의 데이터 타입이 불일치할 때 데이터베이스 엔진이 내부적으로 수행하는 묵시적 형변환도 인덱스를 완전히 마비시키는 원인이 된다.

-- ❌ [안티 패턴]: user_id(integer)를 조건 연산을 위해 text 타입으로 묵시적/명시적 형변환 시도
EXPLAIN 
SELECT * FROM order_sales_perf 
WHERE user_id::text = '42';

-- [분석]: 모든 행의 user_id를 텍스트 타입으로 형변환하는 불필요한 전체 시퀀셜 스캔 유발

--  [Sargable 튜닝]: DB 스키마에 부합하는 본연의 데이터 타입(Integer) 인자로 정확하게 비교 수행
EXPLAIN 
SELECT * FROM order_sales_perf 
WHERE user_id = 42;

-- Index Scan을 통해 단 한 단계로 타겟 TID 검출 성공!

 

2.3 안티 패턴 ③: 무분별한 LIKE 패턴 매칭과 text_pattern_ops

PostgreSQL은 기본 문자열 정렬 순서(Collation)에 기반해 B-Tree를 생성한다. 이 때문에 LIKE 쿼리 활용 시 고유한 콜레이션 규칙으로 인해 일반 B-Tree가 정상 반응하지 못하는 상황이 발생한다.

-- ❌ [주의]: 일반 B-Tree 인덱스 생성 상태에서의 후방 조건 검색
EXPLAIN ANALYZE 
SELECT * FROM order_sales_perf 
WHERE sku_code LIKE 'sku_prod_%';

-- [특이사항]: Collation 정책에 따라 Index Scan을 거치지 못하고 Seq Scan을 수반할 확률 존재

--  [GIN/B-Tree 튜닝]: text_pattern_ops 연산자 클래스를 묶어 전방 일치 고속 탐색 전용 인덱스 빌드
CREATE INDEX idx_perf_sku_code_pattern ON order_sales_perf (sku_code text_pattern_ops);

--  [안티 패턴 해소]: 전방 패턴 검색이 최적의 성능으로 작동
EXPLAIN ANALYZE 
SELECT * FROM order_sales_perf 
WHERE sku_code LIKE 'sku_prod_%';

-- ⚠️ [한계]: 단, '%'가 문자열 최전방에 위치하는 접미사 패턴 검색('%%sku_prod')은 GIN이나 특수 인덱스 없이는 무조건 풀 스캔이다.
EXPLAIN ANALYZE 
SELECT * FROM order_sales_perf 
WHERE sku_code LIKE '%sku_prod';

 

 

3. 대용량 페이징 성능 극대화: Offset Paging vs Keyset Paging

조회 기반 서비스에서 대량의 목록을 내려줄 때 개발자들이 가장 흔하게 사용하는 OFFSET과 LIMIT 페이징은 수백만 건 단위의 엔터프라이즈 프로덕션 환경에서 장애를 초래하는 뇌관과 같다.

3.1 Offset Paging의 구조적 한계와 비효율성

이 방식은 데이터를 처음부터 순서대로 다 세어 나가다가 지정한 오프셋 수치에 이르면 비로소 정해진 한계치만큼 데이터를 취하는 방식이다. 데이터베이스 레벨에서의 시간 복잡도가 $O(N)$에 수렴한다.

-- ① [첫 페이지 조회]: 극소수 페이지만 가져오므로 메모리와 디스크 부하가 거의 없음
EXPLAIN 
SELECT * FROM order_sales_perf ORDER BY created_at LIMIT 20 OFFSET 0;

-- Cost: 0.42..1.96 (가볍고 신속함)

-- ② [50만 번째 딥 페이지 스캔]: 50만 건을 메모리에 일단 전부 적재하고 버린 뒤 20건을 추출
EXPLAIN 
SELECT * FROM order_sales_perf ORDER BY created_at LIMIT 20 OFFSET 500000;

-- Cost: 38390.18..38391.71 (물리 I/O 고부하 유발, 수 초 이상 지연 발생!)

 

3.2 Keyset Pagination (No-Offset)을 통한 $O(\log N)$ 고속화

키셋 페이징은 "마지막으로 반환받은 특정 고유 기준값 이후부터 읽어가라"고 쿼리를 유도하는 무오프셋 방식이다. B-Tree 정렬 특성을 따라가므로 시간 복잡도는 단 $O(\log N)$ 수준으로 축소된다.

-- [Keyset Paging]: 이전 페이지에서 획득한 최종 생성 시각 '2024-10-15 14:20:00+09'를 조건절에 기입
EXPLAIN 
SELECT * FROM order_sales_perf 
WHERE created_at > '2024-10-15 14:20:00+09' 
ORDER BY created_at 
LIMIT 20;

-- [분석]: 이미 created_at 기준 정렬된 B-Tree 인덱스를 이용해 지정 일자 인덱스로 즉각 진입한 뒤,
-- 단 20개의 데이터 주소만 획득해 읽으므로 무제한 딥 페이지 스캔 시에도 동일한 1ms 이내 고속 응답 유지!

 

 

4. PostgreSQL 내부 통계 분석과 옵티마이저 Sort Node 제거

PostgreSQL의 실행 계획 수립은 전적으로 카탈로그 데이터 기반의 통계 엔진(pg_stats)에 의존한다. 내부 통계 현황을 진단하고 정렬 알고리즘의 낭비를 거둬내는 방법을 조망하자.

4.1 pg_stats 뷰를 활용한 물리 분포와 상관관계 측정

pg_stats 테이블은 데이터베이스가 보관 중인 값의 고유성(n_distinct), 최고 빈도 분포, 그리고 디스크 물리적 정렬도와 값의 논리적 순서가 얼마나 일치하는지를 나타내는 '상관관계(correlation)' 등 핵심 통계를 간직한다.

-- 특정 테이블의 user_id 필드 통계 지표 들여다보기
SELECT attname, n_distinct, most_common_vals, correlation 
FROM pg_stats 
WHERE tablename = 'order_sales_perf' 
  AND attname = 'user_id';

💡 correlation 수치의 숨은 의미: 이 값이 1에 수렴하면 논리적인 데이터 순서대로 디스크에 차곡차곡 인접 저장되어 있음을 의미해 Index Scan 성능이 극대화된다. 반면 0에 가깝다면 극심하게 흩어진 상태(파편화)로 무작위 Random Read I/O가 누적되므로 데이터 재빌드(CLUSTER)나 ANALYZE 갱신이 요망됨을 가리킨다.

 

4.2 Sort Node 제거를 통한 자원 소모 제로화전술

정렬(ORDER BY)을 처리할 때 메모리 상의 정렬인 Sort Node 가 계획에 침투하면 디스크 공간을 활용한 2차 튜닝이 개입해 부하가 생긴다. B-Tree 인덱스가 지닌 정렬 특성을 이용해 이 비용을 지워버릴 수 있다.

-- ① [정적 정렬]: 인덱스 순서와 완벽히 정합되는 쿼리 계획
EXPLAIN 
SELECT * FROM order_sales_perf 
ORDER BY created_at 
LIMIT 20;

-- [실행 계획 결과]: Sort Node 없이 곧바로 'Index Scan' 혹은 'Backward Index Scan'으로 정렬 소요 제로!

-- ② [수식/오퍼레이션으로 인한 Index 정렬 무력화 유발]
EXPLAIN 
SELECT * FROM order_sales_perf 
ORDER BY created_at + INTERVAL '0' 
LIMIT 20;

-- [실행 계획 결과]: created_at 연산 가공으로 인해 인덱스 순서 적용 불가!
-- 계획 중간에 무거운 'Sort (cost=... rows=...)' 노드가 강제 침투하는 대참사 발생.

 

 

5. 수억 건 빅데이터 분할: 범위(Range) & 목록(List) 파티셔닝

테이블 파티셔닝(Table Partitioning)은 물리적으로 비대해진 하나의 논리 테이블을 독립된 여러 서브 디스크 테이블 파일로 갈기갈기 쪼개어 가공하는 분할정복(Divide and Conquer) 아키텍처다.

 

5.1 범위(Range) 파티셔닝 구현과 슬라이딩 타겟 분기

시간 흐름에 맞춰 상품 결제 주문 로그가 생성되는 데이터 분석 도메인은 특정 연도/월 단위로 물리 파티션을 나누어 탐색 공간을 극단적으로 단축시킬 수 있다.

-- ① 범위 기반 파티셔닝 마스터 테이블 설계
CREATE TABLE order_sales_part (
    order_id    BIGSERIAL,
    user_id     INTEGER,
    status      VARCHAR(10) NOT NULL,
    amount      NUMERIC(15, 2),
    sku_code    TEXT,
    created_at  TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);

-- ② 연도별 물리 독립 파티션 테이블 생성 및 마스터 바인딩
CREATE TABLE order_sales_part_2023 
    PARTITION OF order_sales_part
    FOR VALUES FROM ('2023-01-01 00:00:00+09') TO ('2024-01-01 00:00:00+09');

CREATE TABLE order_sales_part_2024 
    PARTITION OF order_sales_part
    FOR VALUES FROM ('2024-01-01 00:00:00+09') TO ('2025-01-01 00:00:00+09');

CREATE TABLE order_sales_part_2025 
    PARTITION OF order_sales_part
    FOR VALUES FROM ('2025-01-01 00:00:00+09') TO ('2026-01-01 00:00:00+09');

⚠️ RANGE 파티셔닝 실무 주의점과 예외 오류 사태

사전에 정의된 바인딩 범위를 벗어나는 시각(created_at = '2026-03-19') 데이터가 인서트되는 즉시 아래의 치명적인 예외가 터져 트랜잭션이 롤백된다.

[23514] ERROR: no partition of relation "order_sales_part" found for row
Detail: Partition key of the failing row contains (created_at) = (2026-03-19 05:20:08+00).

🛡️ 방어 가이드: 미래 데이터를 유연하게 담을 수 있는 'DEFAULT' 폴백 파티션 테이블(CREATE TABLE order_sales_part_default PARTITION OF order_sales_part DEFAULT;)을 사전에 같이 엮어 선언해두거나, 배치가 기동하며 다음 타겟 기간의 파티션 테이블을 동적으로 밀어 넣어주는 자동화 장치를 세팅하는 것이 안전하다.

-- ③ [파티션 프루닝(Partition Pruning)]: 최적화 쿼리 작동 검증
EXPLAIN ANALYZE
SELECT * FROM order_sales_part 
WHERE created_at >= '2024-01-01 00:00:00+09' AND created_at < '2024-04-01 00:00:00+09';

-- [분석]: 전체 연도 테이블을 일절 열어보지 않고, 오직 'order_sales_part_2024' 파일 세그먼트만 타겟팅해 탐색하는 'Partition Pruning' 마법이 실현됨을 증명!

 

5.2 목록(List) 파티셔닝을 통한 데이터 세그먼테이션 격리

상태 분기나 특정 유통 거점 코드 등 이산 데이터 세트의 속성에 따라 로우를 물리적으로 분기 적재할 때는 목록 파티셔닝이 명답이 된다.

-- ① 특정 상태값 목록 기준 리스트 파티셔닝 전제 테이블 선언
CREATE TABLE order_sales_by_status (
    order_id    BIGSERIAL,
    user_id     INTEGER,
    status      VARCHAR(10) NOT NULL,
    amount      NUMERIC(15, 2),
    sku_code    TEXT,
    created_at  TIMESTAMPTZ
) PARTITION BY LIST (status);

-- ② 판매 완료('completed') 거래 건만 쌓이는 독립 고속 색인 세그먼트 분리
CREATE TABLE order_sales_completed
    PARTITION OF order_sales_by_status
    FOR VALUES IN ('completed');

-- ③ 판매 취소('cancelled') 건만 쌓여 대사 전용 백업으로 취급될 세그먼트 분리
CREATE TABLE order_sales_cancelled
    PARTITION OF order_sales_by_status
    FOR VALUES IN ('cancelled');

 

 

6. 마치며: 대규모 시스템 튜닝을 지탱하는 실무 체크리스트

이커머스 및 로그 성격의 대용량 트래픽 데이터베이스를 유연하고 빠르게 가동하기 위한 핵심 최적화 요약을 제시한다.

실무 정합성 핵심 체크 포인트 3종

  1. 인덱스 파괴 구문 자동 린팅: 정기적인 DB 모니터링을 가동해 쿼리 플랜 속 Seq Scan 유무를 모니터링하고, 개발 단의 인입 쿼리 중 컬럼을 함수로 파싱하거나 묵시적 타입 변환이 침투하는 조건절이 존재한다면 즉각 차단 가이드라인을 송출할 것.
  2. 딥 오프셋 페이징의 폐기: 비즈니스 기획 단계부터 딥 오프셋 페이징의 하
  3. 네스 지연 한계를 인식시키고, 피드백성이 잦은 리스트 조회 아키텍처는 마지막 일자 혹은 ID를 역추적하는 No-Offset 키셋 페이징으로 기본 전환 적용을 규약할 것.
  4. 영속화 데이터의 물리 다이어트: 하나의 로우에 기하급수적인 데이터가 밀려오는 벌크성 마스터 테이블은 시스템의 전체 다운을 막기 위해 범위 파티셔닝이나 목록 파티셔닝 설계를 필수 적용하여, 쿼리 엔진이 단 하나의 타겟 파티션 블록만 들여다보도록 물리 프루닝 통제를 영속화할 것.

 

 

 

본 이미지들은 AI를 이용하여 생성하였습니다.

반응형