안동민 개발노트 아이콘

안동민 개발노트

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

쿼리 튜닝 실전

실행 계획을 읽을 수 있게 되었으니, 실제로 느린 쿼리를 찾아 개선하는 방법을 다룹니다. 쿼리 튜닝은 체감 성능을 크게 바꿀 수 있지만, 감으로 고치는 작업이 아닙니다. 먼저 느린 SQL을 찾고, 실행 계획과 실제 실행 통계를 확인한 뒤, 가장 작은 변경으로 개선 효과를 검증해야 합니다.


느린 쿼리 찾기

Oracle — AWR, ASH, V$SQL

느린 쿼리 조회 (Oracle)
-- 최근 실행된 SQL 중 평균 경과 시간이 긴 것
SELECT sql_id, elapsed_time/1000000 AS elapsed_sec,
       executions, buffer_gets,
       ROUND((elapsed_time/NULLIF(executions,0))/1000000, 3) AS avg_elapsed_sec,
       ROUND(buffer_gets/NULLIF(executions,0)) AS gets_per_exec,
       SUBSTR(sql_text, 1, 100) AS sql_text
FROM v$sql
WHERE executions > 0
ORDER BY avg_elapsed_sec 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;

V$SQLELAPSED_TIME, BUFFER_GETS는 커서 단위 누적값입니다. 한 번 매우 느린 SQL과 자주 실행되어 누적 시간이 큰 SQL을 구분하려면 총합과 실행당 평균을 함께 봐야 합니다.

Oracle 진단 도구용도
AWR (Automatic Workload Repository)주기적 성능 스냅샷, Top SQL 분석
ASH (Active Session History)실시간 활성 세션 분석
V$SQL현재 캐시된 SQL 정보
V$SESSION현재 세션 상태
SQL Monitoring실행 중인 SQL 실시간 모니터링
SQL Tuning Advisor자동 튜닝 권고

AWR, ASH, SQL Monitoring, SQL Tuning Advisor는 환경에 따라 라이선스와 권한 조건이 붙을 수 있으므로 운영 환경에서는 사용 가능 범위를 먼저 확인합니다.

SQL Tuning Advisor (Oracle)
-- 튜닝 태스크 생성
DECLARE
  l_task_name VARCHAR2(100);
BEGIN
  l_task_name := DBMS_SQLTUNE.CREATE_TUNING_TASK(
    sql_id    => 'abc123def456',
    scope     => DBMS_SQLTUNE.SCOPE_COMPREHENSIVE,
    task_name => 'tune_orders_sql'
  );
  DBMS_SQLTUNE.EXECUTE_TUNING_TASK(task_name => l_task_name);
END;
/

-- 권고안 확인
SELECT DBMS_SQLTUNE.REPORT_TUNING_TASK('tune_orders_sql')
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초 초과
SET SESSION long_query_time = 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;

log_queries_not_using_indexes는 실행 시간이 짧은 쿼리도 많이 남길 수 있어 로그가 급격히 커질 수 있습니다. 수집 기간을 정하고, long_query_time, min_examined_row_limit, Performance Schema 집계와 함께 해석하는 편이 안전합니다.


자주 만나는 안티패턴

안티패턴문제개선
SELECT *불필요한 컬럼과 네트워크 전송량 증가필요한 컬럼만 명시
N+1 문제목록 조회 후 각 행마다 추가 쿼리JOIN, IN, batch fetch로 묶기
불필요한 서브쿼리반복 실행 또는 불필요한 중간 결과 가능성JOIN/EXISTS/집계 방식 비교
OR 조건 남용조건 결합 때문에 인덱스 선택이 어려울 수 있음UNION ALL 분리 또는 인덱스 재검토
LIKE '%검색%'선행 와일드카드로 일반 B-tree 탐색이 어려움전문 검색 인덱스, n-gram, 검색 엔진
함수 적용컬럼 변환 때문에 일반 인덱스 활용이 어려울 수 있음조건 변형 또는 함수 기반/표현식 인덱스
암묵적 형변환DBMS가 컬럼 쪽을 변환하면 인덱스 활용 저하바인드/리터럴 타입 일치
불필요한 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'; -- 날짜 범위 탐색 어려움
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';  -- 인덱스 사용 가능

MySQL 8.0.13 이상은 함수 기반 인덱스와 비슷하게 표현식 인덱스를 지원합니다. 다만 DBMS별 문법과 제약이 다르므로, 가능한 경우 먼저 조건식을 컬럼 원형에 맞게 바꾸는 편이 이식성과 유지보수에 유리합니다.

암묵적 형변환

암묵적 형변환 문제
-- phone_number는 VARCHAR 타입, 인덱스 있음

-- 안티패턴: 숫자로 비교 → 암묵적 형변환 위험
SELECT * FROM customers WHERE phone_number = 01012345678;
-- → DBMS와 타입 우선순위에 따라 컬럼 쪽 변환이 발생할 수 있음
-- → 컬럼이 변환되면 기존 인덱스 활용이 어려워짐

-- 개선: 타입을 일치시킴
SELECT * FROM customers WHERE phone_number = '01012345678';
-- → 인덱스 정상 사용

Oracle 힌트

힌트(Hint)는 옵티마이저에게 접근 경로, 조인 순서, 조인 방식 등을 유도하는 주석입니다. 힌트는 통계 갱신, 인덱스 설계, SQL 재작성으로 해결하기 어려울 때 마지막에 검토합니다.

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)병렬 처리 유도동시성, 리소스, 라이선스 확인
FIRST_ROWS(n)초기 응답 시간 우선페이징/온라인 조회에서 검토
ALL_ROWS전체 처리량 우선배치/분석 쿼리에서 검토

MySQL 힌트

MySQL 힌트
-- Index-level Optimizer Hint (MySQL 8.0.20+)
SELECT /*+ NO_INDEX(e idx_dept) */ * FROM employees e WHERE dept_id = 10;

-- Join-order Optimizer Hint (MySQL 8.0+)
SELECT /*+ JOIN_ORDER(d, e) */ * FROM departments d JOIN employees e ...;

-- 인덱스 힌트 (전통 방식)
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;

MySQL optimizer hint는 적용할 수 없거나 충돌하면 무시될 수 있습니다. 힌트 적용 여부는 EXPLAIN과 경고 메시지로 확인합니다.


인덱스 튜닝 전략

인덱스 생성 판단 기준
인덱스가 필요한 경우:
  * WHERE 조건에 자주 사용되는 컬럼
  * JOIN 조건 컬럼 (FK)
  * ORDER BY, GROUP BY 컬럼
  * 선택도(Selectivity)가 높은 컬럼
  * 여러 조건을 함께 만족시키는 복합 인덱스 후보
  * 커버링 인덱스로 테이블 접근을 줄일 수 있는 경우

인덱스가 불필요한 경우:
  * 테이블이 작아 전체 스캔 비용이 낮은 경우
  * 단독으로는 선택도가 낮고 다른 조건과도 잘 결합되지 않는 컬럼
  * INSERT/UPDATE/DELETE가 매우 빈번해 유지 비용이 큰 경우
  * 거의 사용되지 않는 쿼리 패턴만을 위한 경우
복합 인덱스 컬럼 순서
-- 인덱스: (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) 컬럼 고려
-- 4. 범위 조건 뒤 컬럼은 탐색 범위 축소보다 후속 필터/커버링에 쓰일 수 있음
커버링 인덱스
-- 인덱스: (dept_id, name, salary)

-- 커버링 인덱스: 인덱스만으로 결과 반환 (테이블 접근 불필요)
SELECT name, salary FROM employees WHERE dept_id = 10;
-- → 인덱스에 name, salary가 모두 포함
-- → 실행 계획: INDEX RANGE SCAN, TABLE ACCESS 없음
-- → 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 >= DATE '2024-01-01'
  AND order_date <  DATE '2025-01-01';
-- → 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)  -- 모든 UNIQUE KEY는 파티션 표현식 컬럼 포함 필요
)
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
);

파티셔닝은 “큰 테이블이면 무조건 빠름”이 아닙니다. 쿼리 조건이 파티션 키를 잘 제한해 partition pruning이 일어날 때 효과가 크고, MySQL은 파티션 테이블의 unique key 제약, 스토리지 엔진 제약, 외래 키 제약도 함께 확인해야 합니다.


통계 정보 관리

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

통계 수집 (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';

-- 히스토그램 확인
SELECT SCHEMA_NAME, TABLE_NAME, COLUMN_NAME, HISTOGRAM
FROM information_schema.COLUMN_STATISTICS
WHERE SCHEMA_NAME = DATABASE()
  AND TABLE_NAME = 'orders';

통계 갱신은 실행 계획을 바꿀 수 있으므로, 대량 적재나 파티션 교체 이후에는 수집 시점과 영향 범위를 정해두고 실행 계획 변화를 확인합니다.


페이징 최적화

대량 데이터에서 페이징 처리 시 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;
-- → 인덱스 범위 탐색으로 필요한 범위 근처만 읽음
-- → 페이지가 깊어져도 비용 증가가 작음

-- 복합 정렬의 커서 페이징
SELECT * FROM orders
WHERE (created_at, id) > ('2024-01-15', 1050)
ORDER BY created_at, id
LIMIT 10;

커서 기반 페이징은 정렬 순서가 유일하고 안정적이어야 합니다. created_at처럼 같은 값이 반복될 수 있는 컬럼은 id 같은 tie-breaker를 함께 정렬 조건과 인덱스에 넣어야 누락이나 중복을 줄일 수 있습니다.

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;
-- → DBMS가 조인으로 변환하지 못하면 반복 실행 비용이 커질 수 있음

-- 개선: 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');

-- 선택 기준:
-- 최신 DBMS는 IN/EXISTS를 semi join 등으로 변환하는 경우가 많음
-- NULL 처리, 중복, 실행 계획을 확인한 뒤 선택
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 OR dept_id IS NULL);  -- 중복 제거와 NULL 의미 보존

OR 조건을 항상 UNION ALL로 바꾸는 것은 아닙니다. 옵티마이저가 OR expansion, index merge, bitmap operation으로 이미 좋은 계획을 만들 수 있으므로, 분리 전후의 실행 계획과 중복 제거 조건을 함께 검증합니다.


쿼리 튜닝 체크리스트

튜닝 체크리스트
□ 실행 계획을 확인했는가?
□ Full Table Scan이 의도적인가?
□ 적절한 인덱스가 존재하는가?
□ 복합 인덱스의 컬럼 순서가 쿼리에 맞는가?
□ WHERE 절에서 컬럼에 함수를 적용하지 않았는가?
□ 암묵적 형변환이 발생하지 않는가?
□ N+1 쿼리가 없는가?
□ 불필요한 SELECT *를 사용하지 않았는가?
□ 통계 정보가 최신인가?
□ 조인 순서와 방식이 적절한가?
□ 페이징 처리 시 OFFSET이 너무 크지 않은가?
□ 서브쿼리를 JOIN으로 변환할 수 있는가?
□ 커버링 인덱스를 활용할 수 있는가?
□ 불필요한 정렬(ORDER BY)이 있지 않은가?

정리

쿼리 튜닝은 느린 SQL 식별, 실행 계획 분석, 원인 가설, 작은 변경, 효과 검증의 반복입니다. 인덱스와 SQL 작성 방식이 가장 자주 만지는 지점이고, 힌트나 구조 변경은 근거가 충분할 때 신중히 적용합니다.

튜닝의 핵심 순서는 느린 쿼리 발견 → 실행 계획 분석 → 원인 파악(인덱스 부재, 비효율적 조인 등) → 개선 → 검증입니다. 안티패턴을 숙지하되, 항상 실행 계획과 실제 실행 통계로 변경 효과를 확인하는 습관이 중요합니다.

시험에서는 인덱스 활용을 어렵게 만드는 패턴(함수 적용, 암묵적 형변환, 부정 조건), 복합 인덱스의 컬럼 순서 규칙, N+1 문제의 정의와 해결 방법, Oracle 힌트의 종류와 용도가 빈출됩니다.

다음 장에서는 관계형 모델을 넘어서 NoSQL과 분산 데이터베이스를 다루겠습니다.