13장 : 쿼리 최적화와 실행 계획
쿼리 튜닝 실전
실행 계획을 읽을 수 있게 되었으니, 실제로 느린 쿼리를 찾아 개선하는 방법을 다룹니다. 쿼리 튜닝은 DB 성능 개선에서 가장 효과적인 수단입니다.
느린 쿼리 찾기
Oracle — AWR, ASH, V$SQL
-- 최근 실행된 SQL 중 경과 시간이 긴 것
SELECT sql_id, elapsed_time/1000000 AS elapsed_sec,
executions, buffer_gets,
ROUND(buffer_gets/NULLIF(executions,0)) AS gets_per_exec,
SUBSTR(sql_text, 1, 100) AS sql_text
FROM v$sql
WHERE elapsed_time/1000000 > 1 -- 1초 초과
ORDER BY elapsed_time DESC
FETCH FIRST 10 ROWS ONLY;
-- Buffer Gets가 비정상적으로 많은 SQL
SELECT sql_id, buffer_gets, executions,
ROUND(buffer_gets/NULLIF(executions,0)) AS avg_gets
FROM v$sql
WHERE buffer_gets/NULLIF(executions,0) > 100000
ORDER BY buffer_gets DESC;| Oracle 진단 도구 | 용도 |
|---|---|
| AWR (Automatic Workload Repository) | 주기적 성능 스냅샷, Top SQL 분석 |
| ASH (Active Session History) | 실시간 활성 세션 분석 |
V$SQL | 현재 캐시된 SQL 정보 |
V$SESSION | 현재 세션 상태 |
| SQL Monitoring | 실행 중인 SQL 실시간 모니터링 |
| SQL Tuning Advisor | 자동 튜닝 권고 |
-- 튜닝 태스크 생성
DECLARE
task_name VARCHAR2(100);
BEGIN
task_name := DBMS_SQLTUNE.CREATE_TUNING_TASK(
sql_id => 'abc123def456',
scope => DBMS_SQLTUNE.SCOPE_COMPREHENSIVE
);
DBMS_SQLTUNE.EXECUTE_TUNING_TASK(task_name);
END;
-- 권고안 확인
SELECT DBMS_SQLTUNE.REPORT_TUNING_TASK('task_name')
FROM DUAL;
-- → 인덱스 생성 제안, SQL 프로파일, 구조 변경 등MySQL — Slow Query Log
-- my.cnf
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1 -- 1초 초과 쿼리 기록
log_queries_not_using_indexes = 1 -- 인덱스 미사용 쿼리
-- 동적으로 활성화
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5; -- 0.5초 초과
-- Performance Schema로 느린 쿼리 확인
SELECT DIGEST_TEXT,
COUNT_STAR AS exec_count,
ROUND(SUM_TIMER_WAIT/1000000000000, 3) AS total_sec,
ROUND(AVG_TIMER_WAIT/1000000000000, 3) AS avg_sec,
SUM_ROWS_EXAMINED,
SUM_ROWS_SENT,
SUM_ROWS_EXAMINED / NULLIF(SUM_ROWS_SENT, 0) AS rows_ratio
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;Buffer Gets(논리적 읽기) / 반환 행
→ 1행 반환에 10,000 Gets → 비효율적
→ 1행 반환에 5 Gets → 효율적
Rows Examined / Rows Sent
→ MySQL에서 검사한 행 vs 반환한 행
→ 1000행 검사, 1행 반환 → 인덱스 재검토 필요
비효율 기준
* Gets per Row > 1000 → 인덱스 필요
* Full Table Scan + 대규모 테이블 → 인덱스 필요
* 동일 SQL 반복 실행 → 캐싱 또는 배치로 전환자주 만나는 안티패턴
| 안티패턴 | 문제 | 개선 |
|---|---|---|
| SELECT * | 불필요한 컬럼까지 읽어 I/O 증가 | 필요한 컬럼만 명시 |
| N+1 문제 | 목록 조회 후 각 행마다 추가 쿼리 | JOIN 또는 IN으로 한 번에 |
| 불필요한 서브쿼리 | 서브쿼리가 매 행마다 실행 | JOIN으로 변환 |
| OR 조건 남용 | 인덱스 사용 불가 | UNION ALL로 분리 |
| LIKE '%검색%' | 인덱스 사용 불가 | 전문 검색 인덱스 |
| 함수 적용 | WHERE에서 컬럼에 함수 → 인덱스 무효 | 함수 기반 인덱스 또는 조건 변형 |
| 암묵적 형변환 | 타입 불일치 → 인덱스 무효 | 타입 일치시키기 |
| 불필요한 DISTINCT | 데이터 모델 설계 문제 | JOIN 조건, 중복 원인 제거 |
N+1 문제
-- 안티패턴: N+1
-- 쿼리 1: SELECT id FROM orders WHERE user_id = 1; → 100건
-- 쿼리 2~101:
-- SELECT * FROM order_items WHERE order_id = 1;
-- SELECT * FROM order_items WHERE order_id = 2;
-- ...
-- SELECT * FROM order_items WHERE order_id = 100;
-- → 총 101번 쿼리 실행!
-- 개선 1: JOIN으로 한 번에
SELECT o.id, o.order_date, oi.product_id, oi.quantity
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
WHERE o.user_id = 1;
-- 개선 2: IN 절 (ORM에서 자주 사용)
SELECT * FROM order_items
WHERE order_id IN (1, 2, 3, ..., 100);
-- → 2번의 쿼리로 해결함수에 의한 인덱스 무효화
-- 안티패턴: 컬럼에 함수 적용 → 인덱스 사용 불가
SELECT * FROM employees
WHERE UPPER(name) = 'KIM'; -- name 인덱스 사용 불가
WHERE TO_CHAR(hire_date, 'YYYY') = '2024'; -- hire_date 인덱스 사용 불가
WHERE salary + 1000 > 50000; -- salary 인덱스 사용 불가
-- 개선 1: 조건을 변형하여 컬럼에 함수 제거
WHERE hire_date >= DATE '2024-01-01'
AND hire_date < DATE '2025-01-01';
WHERE salary > 49000;
-- 개선 2: 함수 기반 인덱스 (Oracle)
CREATE INDEX idx_emp_upper_name ON employees(UPPER(name));
SELECT * FROM employees WHERE UPPER(name) = 'KIM'; -- 인덱스 사용 가능암묵적 형변환
-- phone_number는 VARCHAR 타입, 인덱스 있음
-- 안티패턴: 숫자로 비교 → 암묵적 형변환 → 인덱스 무효
SELECT * FROM customers WHERE phone_number = 01012345678;
-- → DB가 내부적으로 TO_NUMBER(phone_number) 수행
-- → 인덱스 사용 불가!
-- 개선: 타입을 일치시킴
SELECT * FROM customers WHERE phone_number = '01012345678';
-- → 인덱스 정상 사용Oracle 힌트
힌트(Hint)는 옵티마이저에게 실행 방법을 제안하는 주석입니다. 옵티마이저가 최적이 아닌 계획을 선택할 때 사용합니다.
-- Full Table Scan 강제
SELECT /*+ FULL(e) */ * FROM employees e WHERE dept_id = 10;
-- 특정 인덱스 사용 강제
SELECT /*+ INDEX(e IDX_EMP_DEPT) */ * FROM employees e WHERE dept_id = 10;
-- 인덱스 사용 금지
SELECT /*+ NO_INDEX(e IDX_EMP_DEPT) */ * FROM employees e WHERE dept_id = 10;
-- 조인 순서 지정
SELECT /*+ LEADING(d e) USE_NL(e) */
e.name, d.dept_name
FROM departments d
JOIN employees e ON d.id = e.dept_id;
-- 병렬 처리
SELECT /*+ PARALLEL(o, 4) */ COUNT(*) FROM orders o;| 힌트 | 용도 | 주의 |
|---|---|---|
| FULL(t) | Full Table Scan 강제 | 소규모 테이블에 유효 |
| INDEX(t idx) | 특정 인덱스 사용 | 인덱스명 정확히 |
| NO_INDEX(t idx) | 특정 인덱스 사용 금지 | 잘못된 인덱스 회피 |
| LEADING(t1 t2) | 조인 순서 지정 | 테이블 별칭 사용 |
| USE_NL(t) | Nested Loop 조인 | 소량 결과 시 유리 |
| USE_HASH(t) | Hash 조인 | 대량 결과 시 유리 |
| USE_MERGE(t) | Sort Merge 조인 | 정렬된 대량 데이터 |
| PARALLEL(t, n) | n개 병렬 프로세스 | OLAP 쿼리에만 |
| FIRST_ROWS(n) | 처음 n행 빠르게 | 페이징에 유리 |
| ALL_ROWS | 전체 처리량 최적 | 기본값 |
1. 힌트는 최후의 수단
→ 먼저: 인덱스 생성, 통계 갱신, 쿼리 구조 변경
→ 그래도 안 될 때: 힌트 사용
2. 힌트를 사용하면 유지보수 비용 증가
→ 데이터 분포 변경 시 힌트가 오히려 역효과
→ 주석으로 힌트 사용 이유 문서화
3. 힌트 문법 오류 시 무시됨 (에러 아님!)
→ /*+ INDX(e IDX_EMP) */ → 오타! 힌트 무시됨
→ 실행 계획으로 힌트 적용 여부 확인 필수MySQL 힌트
-- 옵티마이저 힌트
SELECT /*+ NO_INDEX(e idx_dept) */ * FROM employees e WHERE dept_id = 10;
SELECT /*+ JOIN_ORDER(d, e) */ * FROM departments d JOIN employees e ...;
SELECT /*+ BNL(e) */ * FROM employees e ...; -- Block Nested Loop
-- 인덱스 힌트 (전통 방식)
SELECT * FROM employees USE INDEX (idx_dept) WHERE dept_id = 10;
SELECT * FROM employees FORCE INDEX (idx_dept) WHERE dept_id = 10;
SELECT * FROM employees IGNORE INDEX (idx_dept) WHERE dept_id = 10;인덱스 튜닝 전략
인덱스가 필요한 경우:
* WHERE 조건에 자주 사용되는 컬럼
* JOIN 조건 컬럼 (FK)
* ORDER BY, GROUP BY 컬럼
* 선택도(Selectivity)가 높은 컬럼 (고유값이 많은)
* 카디널리티가 높은 컬럼
인덱스가 불필요한 경우:
* 테이블 크기가 작은 경우 (수백 건)
* 선택도가 낮은 컬럼 (성별: M/F)
* DML이 매우 빈번한 테이블 (인덱스 유지 비용)
* 거의 사용되지 않는 쿼리 패턴-- 인덱스: (dept_id, hire_date, salary)
-- 좋은 활용: 선행 컬럼부터 순서대로 사용
WHERE dept_id = 10 -- ✓ dept_id 사용
WHERE dept_id = 10 AND hire_date > '2024-01-01' -- ✓ dept_id + hire_date
WHERE dept_id = 10 AND hire_date > '2024-01-01' AND salary > 5000 -- ✓ 전부
-- 나쁜 활용: 선행 컬럼 건너뜀
WHERE hire_date > '2024-01-01' -- ✗ dept_id 없음
WHERE salary > 5000 -- ✗ dept_id, hire_date 없음
-- 복합 인덱스 컬럼 순서 결정 원칙:
-- 1. 등치(=) 조건 컬럼을 앞에
-- 2. 범위 조건 컬럼을 뒤에
-- 3. 정렬(ORDER BY) 컬럼 고려-- 인덱스: (dept_id, name, salary)
-- 커버링 인덱스: 인덱스만으로 결과 반환 (테이블 접근 불필요)
SELECT name, salary FROM employees WHERE dept_id = 10;
-- → 인덱스에 name, salary가 모두 포함
-- → 실행 계획: INDEX RANGE SCAN (테이블 접근 없음)
-- → Oracle: "TABLE ACCESS" 없음, MySQL Extra: "Using index"대량 데이터 처리 전략
| 전략 | 설명 | 적용 시점 |
|---|---|---|
| 배치 처리 | 대량 INSERT/UPDATE를 배치 단위로 | 정기 데이터 적재 |
| 파티셔닝 | 테이블을 날짜/범위/해시로 분할 | 수억 건 이상 테이블 |
| Parallel Query | 여러 프로세스가 동시에 쿼리 실행 | 대량 분석 쿼리 |
| Materialized View | 집계 쿼리 결과를 미리 저장 | 자주 사용되는 복잡 집계 |
| 아카이빙 | 오래된 데이터를 별도 테이블로 이동 | 히스토리 데이터 |
CREATE TABLE orders (
id NUMBER,
user_id NUMBER,
order_date DATE,
total NUMBER
)
PARTITION BY RANGE (order_date) (
PARTITION p2023 VALUES LESS THAN (DATE '2024-01-01'),
PARTITION p2024 VALUES LESS THAN (DATE '2025-01-01'),
PARTITION p2025 VALUES LESS THAN (DATE '2026-01-01'),
PARTITION p_max VALUES LESS THAN (MAXVALUE)
);
-- Partition Pruning: 해당 파티션만 스캔
SELECT * FROM orders
WHERE order_date BETWEEN DATE '2024-01-01' AND DATE '2024-12-31';
-- → p2024 파티션만 읽음
-- 파티션 추가
ALTER TABLE orders ADD PARTITION p2026
VALUES LESS THAN (DATE '2027-01-01');
-- 오래된 파티션 삭제 (아카이빙)
ALTER TABLE orders DROP PARTITION p2023;
-- → DELETE보다 훨씬 빠름 (데이터 파일 자체를 삭제)CREATE TABLE orders (
id INT AUTO_INCREMENT,
user_id INT,
order_date DATE,
total DECIMAL(10,2),
PRIMARY KEY (id, order_date) -- 파티션 키 포함 필수!
)
PARTITION BY RANGE (YEAR(order_date)) (
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION p_max VALUES LESS THAN MAXVALUE
);통계 정보 관리
옵티마이저는 통계 정보를 기반으로 실행 계획을 결정합니다. 통계가 오래되면 비효율적인 계획을 선택할 수 있습니다.
-- 테이블 통계 수집
EXEC DBMS_STATS.GATHER_TABLE_STATS('SCHEMA', 'EMPLOYEES');
-- 스키마 전체 통계 수집
EXEC DBMS_STATS.GATHER_SCHEMA_STATS('SCHEMA');
-- 통계 확인
SELECT table_name, num_rows, last_analyzed
FROM dba_tables
WHERE owner = 'SCHEMA'
ORDER BY last_analyzed;
-- 히스토그램 생성 (데이터 분포가 편향된 컬럼)
EXEC DBMS_STATS.GATHER_TABLE_STATS(
'SCHEMA', 'ORDERS',
method_opt => 'FOR COLUMNS SIZE 254 status'
);ANALYZE TABLE employees;
-- 히스토그램 생성 (MySQL 8.0+)
ANALYZE TABLE orders UPDATE HISTOGRAM ON status WITH 100 BUCKETS;
-- 통계 확인
SELECT * FROM information_schema.STATISTICS
WHERE TABLE_NAME = 'employees';페이징 최적화
대량 데이터에서 페이징 처리 시 OFFSET이 커지면 심각한 성능 저하가 발생합니다.
-- OFFSET이 클수록 느려짐
SELECT * FROM orders ORDER BY id LIMIT 10 OFFSET 100000;
-- → 100,010행을 읽고 100,000행을 버림! (비효율)
-- OFFSET 0: 10행 읽기 → 빠름
-- OFFSET 1000: 1,010행 읽기 → 보통
-- OFFSET 100000: 100,010행 읽기 → 매우 느림-- 방법: 마지막으로 조회한 행의 키를 기준으로 다음 페이지 조회
-- 첫 페이지
SELECT * FROM orders ORDER BY id LIMIT 10;
-- → 마지막 행의 id = 1050
-- 다음 페이지 (OFFSET 대신 WHERE 조건)
SELECT * FROM orders WHERE id > 1050 ORDER BY id LIMIT 10;
-- → 인덱스 사용, 항상 10행만 읽음
-- → 페이지가 깊어져도 성능 동일!
-- 복합 정렬의 커서 페이징
SELECT * FROM orders
WHERE (created_at, id) > ('2024-01-15', 1050)
ORDER BY created_at, id
LIMIT 10;-- 큰 테이블에서 OFFSET 페이징이 필요할 때
-- 안티패턴: 모든 컬럼을 OFFSET으로 가져옴
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 50000;
-- 개선: PK만 먼저 가져오고, JOIN으로 상세 데이터 조회
SELECT o.* FROM orders o
JOIN (
SELECT id FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 50000
) sub ON o.id = sub.id;
-- → 서브쿼리에서 커버링 인덱스 사용 가능서브쿼리 최적화
-- 안티패턴: 상관 서브쿼리 (행마다 서브쿼리 실행)
SELECT e.name, e.salary,
(SELECT d.dept_name FROM departments d WHERE d.id = e.dept_id) AS dept_name
FROM employees e;
-- → employees 행 수만큼 서브쿼리 반복 실행
-- 개선: JOIN
SELECT e.name, e.salary, d.dept_name
FROM employees e
JOIN departments d ON e.dept_id = d.id;
-- → 단일 조인으로 해결-- IN (서브쿼리 결과를 모두 가져와서 비교)
SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE status = 'ACTIVE');
-- EXISTS (조건 만족하는 첫 행 발견 시 즉시 종료)
SELECT * FROM orders o
WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id AND u.status = 'ACTIVE');
-- 선택 기준:
-- 서브쿼리 결과가 작으면 → IN이 유리
-- 외부 쿼리 결과가 작으면 → EXISTS가 유리
-- MySQL 최신 버전은 옵티마이저가 자동 변환하는 경우 많음-- 안티패턴: OR 조건 → 각 인덱스를 활용 못할 수 있음
SELECT * FROM employees
WHERE dept_id = 10 OR hire_date > '2024-01-01';
-- 개선: UNION ALL로 분리 → 각각 인덱스 사용 가능
SELECT * FROM employees WHERE dept_id = 10
UNION ALL
SELECT * FROM employees WHERE hire_date > '2024-01-01'
AND dept_id <> 10; -- 중복 제거 조건쿼리 튜닝 체크리스트
□ 실행 계획을 확인했는가?
□ Full Table Scan이 의도적인가?
□ 적절한 인덱스가 존재하는가?
□ 복합 인덱스의 컬럼 순서가 쿼리에 맞는가?
□ WHERE 절에서 컬럼에 함수를 적용하지 않았는가?
□ 암묵적 형변환이 발생하지 않는가?
□ N+1 쿼리가 없는가?
□ 불필요한 SELECT *를 사용하지 않았는가?
□ 통계 정보가 최신인가?
□ 조인 순서와 방식이 적절한가?
□ 페이징 처리 시 OFFSET이 너무 크지 않은가?
□ 서브쿼리를 JOIN으로 변환할 수 있는가?
□ 커버링 인덱스를 활용할 수 있는가?
□ 불필요한 정렬(ORDER BY)이 있지 않은가?정리
쿼리 튜닝의 80%는 적절한 인덱스와 올바른 SQL 작성으로 해결됩니다. 나머지 20%는 실행 계획 분석, 힌트 사용, 구조 변경(파티셔닝, 반정규화)으로 대응합니다.
튜닝의 핵심 순서는 느린 쿼리 발견 → 실행 계획 분석 → 원인 파악(인덱스 부재, 비효율적 조인 등) → 개선 → 검증입니다. 안티패턴을 숙지하고, 항상 실행 계획으로 변경 효과를 확인하는 습관이 중요합니다.
시험에서는 인덱스를 무효화하는 패턴(함수 적용, 암묵적 형변환, 부정 조건), 복합 인덱스의 컬럼 순서 규칙, N+1 문제의 정의와 해결 방법, Oracle 힌트의 종류와 용도가 빈출됩니다.
다음 장에서는 관계형 모델을 넘어서 NoSQL과 분산 데이터베이스를 다루겠습니다.