[프롬프트] SQL 쿼리 최적화 프롬프트 - 최적 프롬프트 레시피
💡 한 줄 요약: "이 SQL 쿼리 빠르게 튜닝해줘"라는 단순 질의 시 터져 나오는 '인덱스 맹목적 추가'와 '불필요한 서브쿼리 제거' 식의 겉핥기 조언을 완전히 타파하고, Principal DBA 관점의 실행 계획(EXPLAIN ANALYZE) 해체, 카디널리티 기반 조인 재설계, 커버링 인덱스, Keyset No-Offset 페이지네이션까지 완벽히 도출하는 실무 종결 프롬프트 레시피입니다.
1. 페인포인트 & 작업 목표
- 실무 상황: 서비스 사용자가 급증하면서 일일 수천만 건 테이블의 집계 쿼리가 수 초 이상 지연되거나, 백오피스/모바일 피드의 깊은 페이지네이션(Deep OFFSET)으로 인해 DB CPU 사용률이 100%에 달해 커넥션 풀이 고갈되는 위기 상황.
-
단순 프롬프트 요청 시 발생하는 4대 전형적 문제:
- 실행 계획 부재로 인한 맹목적 인덱스 남발(Blind Index Creation): 테이블 데이터 분포(Cardinality)나 블록 I/O 통계를 모른 채
WHERE절 컬럼마다 단일 인덱스를 걸라고 조언하여, 쓰기(CUD) 성능을 파괴하고 인덱스 스캔 효율을 망가뜨림. - RDBMS 엔진 특성 무시(Engine Agnostic Fallacy): PostgreSQL 16의 병렬 해시 조인(Parallel Hash Join)이나 JIT 컴파일 특성, MySQL 8.0의 인덱스 컨디션 푸시다운(ICP) 및 버퍼 풀 메커니즘을 고려하지 않고 범용 ANSI-SQL 문법 수정만 제시함.
- Deep OFFSET 페이징 병목 방치: 수십만 번째 페이지를 조회할 때 앞선 수십만 행을 전부 읽고 버리는 OFFSET의 디스크 I/O 낭비를 인식하지 못하고, 단순히
LIMIT수치만 줄이라는 무의미한 답을 제시함. - 메모리 버퍼 캐시 및 임시 테이블 누수 간과: 정렬(
ORDER BY)이나 임시 테이블 생성이 디스크(On-Disk Temp Table, WorkMem Spill)로 쏟아지는 현상을 진단하지 못함.
- 실행 계획 부재로 인한 맹목적 인덱스 남발(Blind Index Creation): 테이블 데이터 분포(Cardinality)나 블록 I/O 통계를 모른 채
-
해결 목표:
- Principal DBA & Query Tuning Architect 페르소나 주입: 실행 계획의 Cost 트리, 행 추정치(Est. Rows) vs 실제 행(Actual Rows) 오차, 버퍼 히트율(Shared Hit)을 정밀 분석.
- 3단계 쿼리 튜닝 아키텍처 강제: [실행 계획 병목 원인 규명 → 인덱스 및 스키마 물리적 최적화 → SQL 문장 및 커서 재작성].
- 유형별 전용 레시피 분리: 대용량 다중 조인/집계 병목을 위한 종합 튜닝(레시피 B)과 5,000만 건 대규모 페이징/슬로우 쿼리 10초 킬러(레시피 C)의 이원화.
2. 프롬프트 레시피 비교
3. 실행 결과 비교: 레시피 A vs B vs C 실전 대조
❌ 레시피 A 실행 결과: [단순 질의의 한계] 실행 계획 없는 맹목적 인덱스 추가 조언
레시피 A에 1,000만 건 주문-결제 조인 슬로우 쿼리를 입력했을 때의 결과입니다:
-- ❌ AI의 단순 답변: WHERE 조건 컬럼들에 개별 인덱스를 마구 추가하라는 조언 CREATE INDEX idx_orders_status ON orders(status); CREATE INDEX idx_orders_created_at ON orders(created_at); CREATE INDEX idx_payments_method ON payments(method); SELECT o.id, o.user_id, o.total_amount, p.status FROM orders o JOIN payments p ON o.id = p.order_id WHERE o.status = 'COMPLETED' AND o.created_at >= '2026-01-01' AND p.method = 'CREDIT_CARD';
•
orders.status의 카디널리티가 낮아(COMPLETED가 전체의 85% 차지) 인덱스를 생성해도 PostgreSQL 옵티마이저는 인덱스를 무시하고 풀 테이블 스캔(Seq Scan)을 선택합니다.• 개별 단일 인덱스는 인덱스 머지(Bitmap Index Scan) 비용만 가중시키며, 테이블 랜덤 I/O를 전혀 줄여주지 못해 응답 속도가 4.8초에서 거의 개선되지 않습니다.
⚡ 레시피 B 실행 결과: [실전 사례 1] 1,000만 건 주문·결제 다중 조인 및 집계 슬로우 쿼리 튜닝
PostgreSQL 16 환경에서 18만 개 디스크 블록을 읽던 Full Seq Scan을 부분 복합 커버링 인덱스와 인덱스 온리 스캔으로 전환하여 4.8초에서 12ms로 400배 단축시킨 사례입니다.
1) 1차 입력 텔레메트리 주입
- 환경: PostgreSQL 16.2, SSD NVMe,
shared_buffers = 8GB - 볼륨:
orders1,000만 행 (COMPLETED 85%, PENDING 15%),payments1,000만 행 - 실행 계획:
Gather (cost=214500.00..385400.00 rows=350000 width=32) (actual time=120.4..4810.2ms)→Seq Scan on orders(Buffers: shared read=184,200)
2) AI 생성 출력 결과 (핵심 발췌)
- 병목 지점:
orders테이블에서 184,200개 디스크 블록을 읽는 Full Sequential Scan 발생 (4,810ms 중 4,100ms 소모). - 플래너 원인:
status='COMPLETED'의 낮은 선택도(Selectivity 85%)로 인해 단일 컬럼 인덱스를 포기하고 디스크 순차 스캔으로 폴백됨. - 버퍼 상태: Buffer Shared Hit 12%에 불과하여 매번 디스크 NVMe I/O를 직접 유발함.
전체 테이블이 아닌 상태값과 기간을 커버하는 부분 복합 인덱스(Partial Composite Covering Index) 생성:
-- 1. 무중단 부분 커버링 인덱스 생성 CREATE INDEX CONCURRENTLY idx_orders_completed_date_covering ON orders (created_at DESC) INCLUDE (id, user_id, total_amount) WHERE status = 'COMPLETED'; -- 2. 조인 외래키 인덱스 CREATE INDEX CONCURRENTLY idx_payments_order_id_method ON payments (order_id) INCLUDE (status) WHERE method = 'CREDIT_CARD';
인덱스 온리 스캔(Index Only Scan)을 유도하고 조인 대상 튜플을 선행 필터링:
SELECT /*+ IndexOnlyScan(o) */
o.id,
o.user_id,
o.total_amount,
p.status AS payment_status
FROM orders o
INNER JOIN payments p ON p.order_id = o.id AND p.method = 'CREDIT_CARD'
WHERE o.status = 'COMPLETED'
AND o.created_at >= '2026-01-01 00:00:00'
ORDER BY o.created_at DESC
LIMIT 50;
- 수행 방식:
Seq Scan(4,810ms) →Index Only Scan using idx_orders_completed_date_covering(11.8ms) - 블록 I/O:
shared read=184,200→shared hit=42, read=0(메모리 버퍼 100% 히트) - 지연시간 개선: 4.8초에서 12ms로 400배 성능 향상 달성
3) 후속 프롬프트 체이닝 (2종)
① 파티셔닝(Partitioning) 확장 체이닝:
👉 "이 테이블이 연간 5,000만 건 이상으로 증가할 경우를 대비하여 created_at 기준 분기별 PostgreSQL Range Partitioning 및 파티션 프루닝(Partition Pruning) 적용 DDL 스크립트를 작성해줘."
② TypeORM / Prisma ORM 최적화 체이닝:
👉 "위 인덱스 온리 스캔 힌트와 조인 방식을 유지한 채 NestJS Prisma ORM의 $queryRaw 타입 안전 쿼리로 변환해줘."
4) 4단계 적용 워크플로우
- EXPLAIN (ANALYZE, BUFFERS) 캡처: 디스크 읽기 블록 수와 선택도 오차 식별.
- 레시피 B 프롬프트 진단: 부분 인덱스 조건(
WHERE status='COMPLETED') 및 INCLUDE 컬럼 산출. - 무중단 CONCURRENT DDL 배포: 프로덕션 락 없이 백그라운드 인덱스 빌드.
- 인덱스 온리 스캔 검증: Actual Rows가 50행에서 즉각 멈추는지(Early Stop) 확인.
🚀 레시피 C 실행 결과: [실전 사례 2] 5,000만 건 게시글 Deep Pagination (OFFSET 500000) 병목 해결
MySQL 8.0 환경에서 50만 건 행을 디스크에서 순차 스캔하느라 3.2초가 걸리던 Deep OFFSET을 Keyset 커서 비교로 전환하여 2.1ms로 1,500배 향상시킨 사례입니다.
1) 1차 입력 텔레메트리 주입
- 환경: MySQL 8.0.36 InnoDB
- 슬로우 쿼리:
SELECT id, title, author_id, view_count, created_at FROM articles WHERE category_id = 4 ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 500000; - 현상: 50만 번째 행을 건너뛰기 위해 앞선 50만 건의 B-Tree 클러스터드 인덱스를 순회하느라 쿼리 1건당 3.2초 소모 및 CPU 급증
2) AI 생성 출력 결과 (핵심 발췌)
OFFSET 500000을 물리적으로 제거하고 직전 페이지 마지막 튜플의 (created_at, id) 튜플 비교 탐색으로 전환:
-- 프론트엔드에서 직전 페이지의 마지막 항목 (:last_created_at, :last_id) 전달받음
SELECT
id,
title,
author_id,
view_count,
created_at
FROM articles
WHERE category_id = :category_id
AND (created_at < :last_created_at
OR (created_at = :last_created_at AND id < :last_id))
ORDER BY created_at DESC, id DESC
LIMIT 20;
Keyset 튜플 정렬과 필터링을 한 번의 인덱스 탐색(Index Seek)으로 끝내기 위한 복합 인덱스 생성:
ALTER TABLE articles ADD INDEX idx_category_created_id (category_id, created_at DESC, id DESC), ALGORITHM=INPLACE, LOCK=NONE;
- Rows Examined: 500,020행 탐색 → 정확히 20행 탐색 (99.99% 축소)
- 실행 시간: 3,240ms → 2.1ms (약 1,500배 속도 향상)
- 정렬 비용: Filesort 발생 없음 (B-Tree 인덱스 순서 그대로 반환)
3) 후속 체이닝: 커버링 인덱스 결합 지연 조인(Deferred Join) 체이닝
사용자 추가 프롬프트:
👉 "만약 모바일 앱 클라이언트가 특정 페이지 번호로 다이렉트 점프(Direct Page Jump)를 반드시 요구한다면, OFFSET을 쓰더라도 지연 조인(Deferred Join)을 적용해 데이터 블록 Random Access를 최소화하는 서브쿼리 최적화 SQL을 작성해줘."
💡 결과: AI가 SELECT ... FROM articles a JOIN (SELECT id FROM articles WHERE ... LIMIT 20 OFFSET 500000) AS sub ON a.id = sub.id 구조를 도출하여, 50만 건에 대해 기본키 인덱스만 스캔하게 만들어 실행 시간을 3.2초에서 180ms로 18배 단축시키는 절충안을 제시함.
4) 3단계 긴급 최적화 워크플로우
- OFFSET 패턴 감지: API 로그에서 10,000페이지 이상의 비정상 딥 페이지네이션 확인.
- Keyset SQL 및 복합 인덱스 도출: 레시피 C로 No-Offset 조건식 및 In-Place DDL 확보.
- 무중단 적용 및 벤치마크:
LOCK=NONE인덱스 생성 후 응답 시간 2ms 확정.
4. 옵티마이저 튜닝 메커니즘 심층 분석
-
1. Sargable(Search Argument Able) 조건식의 절대성:
•WHERE YEAR(created_at) = 2026또는WHERE status || '' = 'ACTIVE'와 같이 컬럼을 가공하는 식은 인덱스 컬럼의 정렬 순서를 무력화하여 무조건 Full Table Scan을 발생시킵니다.
• 프롬프트는 이를WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'형태의 범위 비교로 강제 전환하여 B-Tree Index Range Scan을 복원합니다. -
2. 인덱스 스킵 스캔(Index Skip Scan)과 선두 컬럼 규칙:
• 복합 인덱스(A, B, C)에서 선두 컬럼A없이WHERE B = 1로만 조회할 경우, 최신 MySQL 8.0 이상에서 Index Skip Scan이 작동할 수 있으나 유니크 카디널리티가 높으면 효율이 급감합니다.
• 프롬프트는 쿼리 패턴의 실제 필터링 빈도를 기반으로 복합 인덱스 컬럼의 최적 선행 배치 순서를 자동으로 재조정합니다. -
3. Driving Table(선행 테이블) 선정 메커니즘:
• Nested Loop Join 시 가장 작은 결과 집합을 가진 테이블이 Outer Driving Table이 되어야 Inner 테이블의 인덱스를 타고 $O(\log N)$으로 탐색할 수 있습니다.
• AI는 카디널리티 통계를 바탕으로STRAIGHT_JOIN이나pg_hint_plan의 선행 힌트를 제시하여 플래너의 잘못된 조인 순서를 교정합니다.
5. 실전 투입용 플레이스홀더 템플릿
실무에서 슬로우 쿼리가 발생했을 때 즉각 복사하여 값만 채워 넣을 수 있는 최종 템플릿입니다:
[역할] 당신은 대규모 RDBMS 성능 튜닝을 전담하는 Principal DBA입니다. [시스템 환경] - RDBMS: [MySQL 8.0 / PostgreSQL 16 / Oracle 19c] - 버퍼 풀 / WorkMem 크기: [예: innodb_buffer_pool_size=16GB / work_mem=64MB] [테이블 명세 & 데이터 규모] - 메인 테이블: [테이블명] (약 [건수] 행) - 조인 대상 테이블: [테이블명] (약 [건수] 행) - 기존 복합 인덱스 목록: * [인덱스명]: ([컬럼1], [컬럼2]) [슬로우 쿼리] ```sql [여기에 쿼리 붙여넣기] ``` [실행 계획 출력] ```text [EXPLAIN ANALYZE 결과 붙여넣기] ``` [도출 요구사항] 1. 실제 디스크 I/O를 유발하는 핵심 병목 노드 분석 (Seq Scan, Filesort, Temp Spill 여부) 2. 무중단(Non-blocking) 인덱스 생성 DDL 제안 (컬럼 순서 및 커버링 INCLUDE 반영) 3. 재작성된 고성능 SQL 및 예상 지연시간 단축치 제시
6. 프롬프트 활용 팁 & 주의사항
-
1. ANALYZE 갱신 여부 확인: 통계 데이터가 낡아 있으면 옵티마이저가 엉뚱한 실행 계획을 짭니다. 대규모 데이터 변경 후에는 PostgreSQL
ANALYZE tablename;, MySQLANALYZE TABLE tablename;을 먼저 실행한 뒤 프롬프트에 플랜을 넣으십시오. -
2. 민감 정보 마스킹: WHERE 절에 고객 주민번호, 계좌번호, 이메일 리터럴이 들어가 있다면
:user_email형태의 바인드 변수로 치환하여 프롬프트에 입력하십시오. -
3. 무중단 옵션 필수 확인: 프로덕션 테이블에 인덱스를 걸 때 PostgreSQL은
CONCURRENTLY, MySQL은ALGORITHM=INPLACE, LOCK=NONE이 없으면 트래픽이 몰리는 순간 서비스 전체가 멈춥니다. AI에게 항상 무중단 DDL 키워드를 강제하십시오. - 4. 인덱스는 다다익선이 아님: 인덱스가 1개 추가될 때마다 INSERT/UPDATE/DELETE 시 B-Tree 분할(Page Split) 비용이 누적됩니다. 기존에 존재하던 중복/접두사 인덱스를 반드시 정리(Drop Unused Indexes)하십시오.