icon

안동민 개발노트

13장 : 쿼리 최적화와 실행 계획

쿼리 튜닝 실전


실행 계획을 읽을 수 있게 되었으니, 실제로 느린 쿼리를 찾아 개선하는 방법을 다룹니다. 쿼리 튜닝은 DB 성능 개선에서 가장 효과적인 수단입니다.


느린 쿼리 찾기

Oracle — AWR, ASH, V$SQL

느린 쿼리 조회 (Oracle)
-- 최근 실행된 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자동 튜닝 권고
SQL Tuning Advisor (Oracle)
-- 튜닝 태스크 생성
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

느린 쿼리 로그 설정 (MySQL)
-- 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 문제 상세
-- 안티패턴: 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)는 옵티마이저에게 실행 방법을 제안하는 주석입니다. 옵티마이저가 최적이 아닌 계획을 선택할 때 사용합니다.

Oracle 힌트 사용
-- 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 힌트

MySQL 힌트 (MySQL 8.0+)
-- 옵티마이저 힌트
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집계 쿼리 결과를 미리 저장자주 사용되는 복잡 집계
아카이빙오래된 데이터를 별도 테이블로 이동히스토리 데이터
파티셔닝 (Oracle)
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보다 훨씬 빠름 (데이터 파일 자체를 삭제)
MySQL 파티셔닝
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
);

통계 정보 관리

옵티마이저는 통계 정보를 기반으로 실행 계획을 결정합니다. 통계가 오래되면 비효율적인 계획을 선택할 수 있습니다.

통계 수집 (Oracle)
-- 테이블 통계 수집
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'
);
통계 수집 (MySQL)
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 페이징의 문제
-- 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행 읽기 → 매우 느림
커서 기반 페이징 (Keyset Pagination)
-- 방법: 마지막으로 조회한 행의 키를 기준으로 다음 페이지 조회
-- 첫 페이지
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;
Deferred Join (지연 조인)
-- 큰 테이블에서 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;
-- → 서브쿼리에서 커버링 인덱스 사용 가능

서브쿼리 최적화

상관 서브쿼리 → JOIN 변환
-- 안티패턴: 상관 서브쿼리 (행마다 서브쿼리 실행)
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;
-- → 단일 조인으로 해결
EXISTS vs IN
-- 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 조건 → UNION ALL 변환
-- 안티패턴: 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과 분산 데이터베이스를 다루겠습니다.

목차