데이터베이스 성능은 애플리케이션 전체의 성능을 좌우한다. 잘못 작성된 쿼리 하나가 전체 시스템을 마비시킬 수 있고, 반대로 최적화된 쿼리는 수십 배의 성능 향상을 가져다준다.
실무에서 가장 많이 마주치는 문제는 느린 쿼리다. 사용자가 3초 이상 기다리면 이탈률이 급격히 증가하고, 비즈니스에 직접적인 손실을 가져온다. 오늘은 실무에서 바로 적용할 수 있는 SQL 성능 최적화 기법들을 알아보자.
인덱스는 데이터베이스의 책 목차와 같다. 전체 책을 읽지 않고도 원하는 내용을 빠르게 찾을 수 있게 해준다.
-- 인덱스 생성 전후 성능 비교
-- 인덱스 없이 100만 건 조회
SELECT * FROM users WHERE email = 'user@example.com';
-- 실행 시간: 2.3초
-- 인덱스 생성
CREATE INDEX idx_users_email ON users(email);
-- 인덱스 생성 후 동일 쿼리
SELECT * FROM users WHERE email = 'user@example.com';
-- 실행 시간: 0.01초 (230배 향상)
복합 인덱스는 여러 컬럼을 조합해서 만드는 인덱스다. 컬럼 순서가 성능에 큰 영향을 미친다.
-- 잘못된 복합 인덱스 설계
CREATE INDEX idx_bad ON orders(customer_id, status, created_at);
-- 이 쿼리는 인덱스를 제대로 활용하지 못함
SELECT * FROM orders WHERE status = 'pending' AND created_at > '2025-01-01';
-- 올바른 복합 인덱스 설계
CREATE INDEX idx_good ON orders(status, created_at, customer_id);
-- 이제 모든 쿼리가 인덱스를 활용할 수 있음
SELECT * FROM orders WHERE status = 'pending';
SELECT * FROM orders WHERE status = 'pending' AND created_at > '2025-01-01';
SELECT * FROM orders WHERE status = 'pending' AND created_at > '2025-01-01' AND customer_id = 123;
복합 인덱스 설계 원칙:
같은 결과를 얻는 여러 방법이 있지만, 성능은 천차만별이다.
-- EXISTS 사용 (가장 효율적)
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.id AND o.status = 'completed'
);
-- IN 사용 (중간 효율)
SELECT * FROM users
WHERE id IN (
SELECT user_id FROM orders
WHERE status = 'completed'
);
-- JOIN 사용 (가장 비효율적 - 중복 발생)
SELECT DISTINCT u.* FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.status = 'completed';
서브쿼리는 종종 JOIN으로 변환하면 성능이 크게 향상된다.
-- 비효율적인 서브쿼리
SELECT * FROM products p
WHERE p.category_id IN (
SELECT id FROM categories
WHERE name LIKE '%electronics%'
);
-- 최적화된 JOIN 쿼리
SELECT p.* FROM products p
JOIN categories c ON p.category_id = c.id
WHERE c.name LIKE '%electronics%';
대용량 데이터에서 페이지네이션을 구현할 때 주의해야 한다.
-- 비효율적인 OFFSET 사용
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 10 OFFSET 10000;
-- 10,000개 행을 건너뛰어야 하므로 매우 느림
-- 커서 기반 페이지네이션 (권장)
SELECT * FROM orders
WHERE created_at < '2025-09-15 10:30:00'
ORDER BY created_at DESC
LIMIT 10;
MySQL의 실행 계획을 분석하면 쿼리 성능을 정확히 파악할 수 있다.
EXPLAIN SELECT u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2025-01-01'
GROUP BY u.id, u.name
HAVING order_count > 5;
중요한 지표들:
MySQL이 잘못된 실행 계획을 선택할 때 힌트를 사용한다.
-- 특정 인덱스 강제 사용
SELECT * FROM orders USE INDEX(idx_created_at)
WHERE created_at > '2025-01-01';
-- 인덱스 사용 금지
SELECT * FROM orders IGNORE INDEX(idx_created_at)
WHERE created_at > '2025-01-01';
-- 인덱스 강제 사용 (다른 인덱스 무시)
SELECT * FROM orders FORCE INDEX(idx_created_at)
WHERE created_at > '2025-01-01';
데이터 타입은 저장 공간과 성능에 직접적인 영향을 미친다.
-- 비효율적인 데이터 타입
CREATE TABLE bad_design (
id INT(11) AUTO_INCREMENT PRIMARY KEY,
status VARCHAR(255), -- ENUM이 더 효율적
created_at DATETIME, -- TIMESTAMP가 더 효율적
amount DECIMAL(10,2), -- FLOAT이 더 효율적일 수 있음
description TEXT -- VARCHAR가 더 효율적일 수 있음
);
-- 최적화된 데이터 타입
CREATE TABLE good_design (
id INT AUTO_INCREMENT PRIMARY KEY,
status ENUM('pending', 'completed', 'cancelled'),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
amount FLOAT,
description VARCHAR(500)
);
NULL은 인덱스 효율성을 떨어뜨린다. 가능하면 NOT NULL을 사용하자.
-- NULL 허용 (비효율적)
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(100) NULL -- NULL 허용
);
-- NOT NULL 사용 (효율적)
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE
);
대용량 데이터를 한 번에 처리하면 메모리 부족이나 락 문제가 발생할 수 있다.
-- 비효율적인 대용량 업데이트
UPDATE orders SET status = 'processed' WHERE created_at < '2025-01-01';
-- 100만 건을 한 번에 업데이트하면 락이 오래 걸림
-- 배치 처리 (권장)
DELIMITER $$
CREATE PROCEDURE BatchUpdateOrders()
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE batch_size INT DEFAULT 1000;
DECLARE affected_rows INT;
REPEAT
UPDATE orders
SET status = 'processed'
WHERE created_at < '2025-01-01'
AND status != 'processed'
LIMIT batch_size;
SET affected_rows = ROW_COUNT();
-- 배치 간 잠시 대기 (시스템 부하 방지)
SELECT SLEEP(0.1);
UNTIL affected_rows = 0 END REPEAT;
END$$
DELIMITER ;
CALL BatchUpdateOrders();
대용량 테이블을 논리적으로 분할하면 성능이 크게 향상된다.
-- 날짜별 파티셔닝
CREATE TABLE orders (
id INT AUTO_INCREMENT,
user_id INT,
amount DECIMAL(10,2),
created_at TIMESTAMP,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (YEAR(created_at)) (
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
-- 파티션 프루닝으로 성능 향상
SELECT * FROM orders WHERE created_at >= '2025-01-01';
-- 2025년 파티션만 검색하므로 매우 빠름
자주 사용되는 쿼리 결과를 캐싱하면 성능을 크게 향상시킬 수 있다.
-- 쿼리 캐시 활성화 (MySQL 8.0 이전)
SET GLOBAL query_cache_size = 268435456; -- 256MB
SET GLOBAL query_cache_type = ON;
-- 자주 사용되는 통계 쿼리
SELECT
DATE(created_at) as date,
COUNT(*) as order_count,
SUM(amount) as total_amount
FROM orders
WHERE created_at >= CURDATE() - INTERVAL 30 DAY
GROUP BY DATE(created_at);
-- 이 쿼리는 캐시되어 반복 실행 시 매우 빠름
데이터베이스 외부에서 캐싱하는 방법들이다.
# Redis를 이용한 쿼리 결과 캐싱
import redis
import json
import hashlib
redis_client = redis.Redis(host='localhost', port=6379, db=0)
def get_cached_query_result(query, params):
# 쿼리와 파라미터를 해시화해서 캐시 키 생성
cache_key = f"query:{hashlib.md5(f'{query}{params}'.encode()).hexdigest()}"
# 캐시에서 결과 조회
cached_result = redis_client.get(cache_key)
if cached_result:
return json.loads(cached_result)
# 캐시에 없으면 DB에서 조회
result = execute_query(query, params)
# 결과를 캐시에 저장 (5분간 유지)
redis_client.setex(cache_key, 300, json.dumps(result))
return result
MySQL의 느린 쿼리 로그를 활성화해서 성능 문제를 찾자.
-- 느린 쿼리 로그 설정
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 1초 이상 걸리는 쿼리 로깅
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
-- 현재 설정 확인
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
MySQL 5.7+에서 제공하는 성능 스키마로 상세한 성능 정보를 얻을 수 있다.
-- 가장 많이 실행되는 쿼리 TOP 10
SELECT
DIGEST_TEXT as query,
COUNT_STAR as exec_count,
AVG_TIMER_WAIT/1000000000000 as avg_time_sec,
SUM_ROWS_EXAMINED as total_rows_examined
FROM performance_schema.events_statements_summary_by_digest
ORDER BY COUNT_STAR DESC
LIMIT 10;
-- 가장 느린 쿼리 TOP 10
SELECT
DIGEST_TEXT as query,
AVG_TIMER_WAIT/1000000000000 as avg_time_sec,
MAX_TIMER_WAIT/1000000000000 as max_time_sec
FROM performance_schema.events_statements_summary_by_digest
WHERE AVG_TIMER_WAIT > 1000000000 -- 1초 이상
ORDER BY AVG_TIMER_WAIT DESC
LIMIT 10;
인덱스 최적화:
쿼리 최적화:
데이터 타입:
모니터링:
SQL 성능 최적화는 한 번에 모든 걸 적용하려고 하지 말고, 점진적으로 개선해나가는 게 중요하다.
가장 먼저 해야 할 일은:
성능 최적화는 개발자의 핵심 역량이다. 꾸준히 공부하고 실무에 적용해보자.