--- name: query-optimization-catalog description: "SQL 쿼리 최적화 카탈로그. 인덱스 전략(B-Tree/Hash/GIN/GiST), 실행 계획 분석, N+1 문제 해결, 파티셔닝 전략, 슬로우 쿼리 패턴별 최적화 기법을 제공하는 performance-analyst 확장 스킬. '쿼리 최적화', '인덱스 설계', '실행 계획', 'N+1 문제', '파티셔닝', '슬로우 쿼리' 등 DB 성능 분석 시 사용한다. 단, 데이터 모델링이나 보안 설정은 이 스킬의 범위가 아니다." --- # Query Optimization Catalog — SQL 쿼리 최적화 카탈로그 performance-analyst 에이전트가 성능 최적화 시 활용하는 인덱스 전략, 실행 계획 분석, 쿼리 안티패턴 해결 레퍼런스. ## 대상 에이전트 `performance-analyst` — 이 스킬의 최적화 기법을 성능 분석과 인덱스 설계에 직접 적용한다. ## 인덱스 전략 ### 인덱스 유형별 선택 가이드 | 인덱스 유형 | 적합 쿼리 | DBMS | 특징 | |-----------|----------|------|------| | **B-Tree** | `=`, `<`, `>`, `BETWEEN`, `ORDER BY` | 전체 | 범용, 기본값 | | **Hash** | `=` 동등 비교만 | PostgreSQL, MySQL | 범위 검색 불가 | | **GIN** | 배열, JSONB, 전문검색 | PostgreSQL | 다중값 인덱싱 | | **GiST** | 공간(geometry), 범위 | PostgreSQL | PostGIS, 범위 타입 | | **BRIN** | 시계열, 자연 정렬 데이터 | PostgreSQL | 매우 작은 크기 | | **Fulltext** | 전문검색 | MySQL, PostgreSQL | LIKE '%word%' 대체 | ### 복합 인덱스 설계 원칙 #### 왼쪽 접두사 규칙 (Leftmost Prefix) ```sql INDEX idx_abc ON table(a, b, c) -- 활용 가능: WHERE a = 1 -- O (a만) WHERE a = 1 AND b = 2 -- O (a, b) WHERE a = 1 AND b = 2 AND c = 3 -- O (전체) WHERE a = 1 AND c = 3 -- 부분 (a만, c 건너뜀) -- 활용 불가: WHERE b = 2 -- X (a 누락) WHERE c = 3 -- X (a, b 누락) ``` #### 컬럼 순서 결정 규칙 1. **WHERE 등호(=) 조건** 컬럼 먼저 2. **정렬(ORDER BY)** 컬럼 다음 3. **범위(<, >, BETWEEN)** 컬럼 마지막 4. **카디널리티** 높은 것 먼저 (단, 규칙 1~3 우선) ### 커버링 인덱스 인덱스만으로 쿼리 결과를 반환 (테이블 접근 불필요). ```sql -- 커버링 인덱스 CREATE INDEX idx_covering ON orders(user_id, status, created_at); -- 이 쿼리는 인덱스만으로 응답 (Index Only Scan) SELECT status, created_at FROM orders WHERE user_id = 123; ``` ### 인덱스 생성/삭제 판단 | 상황 | 인덱스 추가? | 이유 | |------|-----------|------| | WHERE 절에 자주 사용되는 컬럼 | O | 검색 속도 향상 | | JOIN ON 절의 FK 컬럼 | O | JOIN 성능 | | ORDER BY 자주 사용되는 컬럼 | O | 정렬 회피 | | 매우 낮은 카디널리티 (boolean 등) | X | 효과 미미 | | 자주 UPDATE되는 컬럼 | 신중히 | 쓰기 성능 저하 | | 작은 테이블 (1만 행 미만) | X | 풀스캔이 더 빠름 | ## 슬로우 쿼리 안티패턴 & 해결 ### 1. N+1 문제 ```sql -- 안티패턴: 루프에서 개별 쿼리 SELECT * FROM users; -- 각 user마다: SELECT * FROM orders WHERE user_id = ?; -- N번 반복! -- 해결: JOIN 또는 IN SELECT u.*, o.* FROM users u LEFT JOIN orders o ON u.id = o.user_id; -- 또는 배치 로딩 SELECT * FROM orders WHERE user_id IN (1, 2, 3, ...); ``` ### 2. SELECT * ```sql -- 안티패턴 SELECT * FROM products WHERE category = 'electronics'; -- 해결: 필요한 컬럼만 SELECT id, name, price FROM products WHERE category = 'electronics'; -- 커버링 인덱스 활용 가능 ``` ### 3. 함수 사용으로 인덱스 무효화 ```sql -- 안티패턴: 인덱스 컬럼에 함수 적용 WHERE YEAR(created_at) = 2025 -- 해결: 범위 조건으로 변환 WHERE created_at >= '2025-01-01' AND created_at < '2026-01-01' ``` ### 4. OR 조건의 인덱스 무효화 ```sql -- 안티패턴 WHERE status = 'active' OR category = 'books' -- 해결: UNION ALL SELECT * FROM products WHERE status = 'active' UNION ALL SELECT * FROM products WHERE category = 'books' AND status != 'active' ``` ### 5. 서브쿼리 vs JOIN ```sql -- 안티패턴: 상관 서브쿼리 SELECT * FROM orders o WHERE o.total > (SELECT AVG(total) FROM orders WHERE user_id = o.user_id); -- 해결: JOIN + 집계 SELECT o.* FROM orders o JOIN (SELECT user_id, AVG(total) as avg_total FROM orders GROUP BY user_id) a ON o.user_id = a.user_id WHERE o.total > a.avg_total; ``` ### 6. OFFSET 페이지네이션 ```sql -- 안티패턴: 깊은 OFFSET SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 100000; -- 해결: 커서 기반 (Keyset) SELECT * FROM products WHERE id > 100000 ORDER BY id LIMIT 20; ``` ### 7. 대량 IN 절 ```sql -- 안티패턴: 수천 개 ID WHERE id IN (1, 2, 3, ..., 10000) -- 해결: 임시 테이블 또는 JOIN -- PostgreSQL: VALUES 또는 ANY(ARRAY[...]) -- 범용: 배치 처리 (500개씩) ``` ## EXPLAIN 분석 가이드 (PostgreSQL) ### 실행 계획 읽기 ```sql EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT * FROM orders WHERE user_id = 123 AND status = 'completed'; ``` ### 주요 노드 유형 | 노드 | 의미 | 성능 | |------|------|------| | Seq Scan | 전체 테이블 스캔 | 느림 (대량 데이터) | | Index Scan | 인덱스 + 테이블 접근 | 보통 | | Index Only Scan | 인덱스만으로 응답 | 빠름 | | Bitmap Index Scan | 비트맵으로 인덱스 스캔 | 보통 | | Nested Loop | 중첩 루프 조인 | 소량 데이터에 적합 | | Hash Join | 해시 테이블 조인 | 대량 동등 조인 | | Merge Join | 정렬 병합 조인 | 대량 정렬 데이터 | | Sort | 정렬 연산 | 메모리/디스크 주의 | ### 경고 신호 - `Seq Scan` on 대형 테이블 → 인덱스 필요 - `Sort` with `external merge Disk` → work_mem 부족 - `actual rows` >> `estimated rows` → 통계 오래됨 (ANALYZE 필요) - `Nested Loop` with 대형 테이블 → Hash Join 유도 ## 파티셔닝 전략 ### 파티셔닝 필요 신호 - 테이블 크기 > 수억 행 - 시계열 데이터 (로그, 이벤트, 메트릭) - 오래된 데이터 주기적 삭제/아카이빙 - 특정 기간 쿼리가 대부분 ### 파티셔닝 유형 | 유형 | 분할 기준 | 적합 | 예시 | |------|----------|------|------| | **Range** | 값 범위 | 시계열 | 월별/연별 파티션 | | **List** | 값 목록 | 카테고리 | 지역별, 상태별 | | **Hash** | 해시값 | 균등 분산 | user_id % N | ### Range 파티셔닝 예시 (PostgreSQL) ```sql CREATE TABLE events ( id BIGINT, event_time TIMESTAMPTZ, data JSONB ) PARTITION BY RANGE (event_time); CREATE TABLE events_2025_q1 PARTITION OF events FOR VALUES FROM ('2025-01-01') TO ('2025-04-01'); ``` ## 캐싱 전략 | 레벨 | 도구 | 적합 데이터 | TTL | |------|------|-----------|-----| | **쿼리 캐시** | Redis/Memcached | 자주 읽히는 조회 결과 | 30초~5분 | | **ORM 캐시** | Prisma/TypeORM 캐시 | 엔티티 단위 | 1~5분 | | **집계 캐시** | Materialized View | 통계/대시보드 | 1시간+ | | **CDN 캐시** | CloudFront/CloudFlare | 정적 API 응답 | 5~60분 | ### 캐시 무효화 전략 - **TTL 기반**: 만료 시간 후 자동 갱신 - **이벤트 기반**: 데이터 변경 시 즉시 삭제 - **Write-Through**: 쓰기 시 캐시도 함께 갱신 - **Cache-Aside**: 읽기 시 캐시 없으면 DB 조회 후 캐시 저장