서브쿼리 최적화 — 서브쿼리가 느린 이유와 해결법
서브쿼리를 쓰면 가독성은 좋아지는데, 왜 "서브쿼리 대신 JOIN을 쓰라"는 말을 자주 들을까요?
서브쿼리는 SQL을 직관적으로 작성할 수 있게 해주지만, 잘못 사용하면 심각한 성능 문제를 일으킵니다.
개념 정의
서브쿼리는 다른 쿼리 안에 포함된 쿼리입니다. 위치에 따라 세 가지로 분류합니다.
-- 1. SELECT 절: 스칼라 서브쿼리 (한 행, 한 컬럼 반환)
SELECT name,
(SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS order_count
FROM users u;
-- 2. FROM 절: 파생 테이블 (인라인 뷰)
SELECT sub.dept, sub.cnt
FROM (SELECT department AS dept, COUNT(*) AS cnt
FROM employees GROUP BY department) sub;
-- 3. WHERE 절: 조건 서브쿼리
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);
상관 서브쿼리 vs 비상관 서브쿼리
비상관 서브쿼리 (Non-correlated)
외부 쿼리와 독립적으로 실행됩니다. 한 번만 실행 하면 됩니다.
-- 서브쿼리가 외부 쿼리의 값을 참조하지 않음
SELECT * FROM users
WHERE department_id IN (
SELECT id FROM departments WHERE active = 1 -- 독립 실행
);
상관 서브쿼리 (Correlated)
외부 쿼리의 값을 참조하므로 외부 행마다 반복 실행 됩니다.
-- u.id를 참조하므로 users의 각 행마다 서브쿼리 실행
SELECT u.name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt
FROM users u;
-- users가 10,000행이면 서브쿼리가 10,000번 실행
이것이 서브쿼리가 느린 가장 큰 이유입니다. N+1 문제 와 본질적으로 같습니다.
서브쿼리가 느린 구체적인 이유
1. 반복 실행 문제
-- 느린 쿼리: 매 행마다 서브쿼리 실행
SELECT e.name, e.salary,
(SELECT AVG(salary) FROM employees WHERE department = e.department) AS dept_avg
FROM employees e;
employees가 10,000행, 부서가 50개라면 서브쿼리가 10,000번 실행됩니다.
-- 빠른 대안: JOIN 사용
SELECT e.name, e.salary, d.dept_avg
FROM employees e
JOIN (SELECT department, AVG(salary) AS dept_avg
FROM employees GROUP BY department) d
ON e.department = d.department;
-- 서브쿼리는 1번만 실행되고, JOIN으로 매칭
2. 임시 테이블 실체화
FROM 절의 서브쿼리(파생 테이블)는 결과를 임시 테이블 로 저장할 수 있습니다.
-- 파생 테이블이 실체화(Materialization)되면 임시 테이블 생성
SELECT * FROM (
SELECT user_id, SUM(amount) AS total
FROM orders
GROUP BY user_id
) sub
WHERE sub.total > 10000;
실체화의 문제:
- 임시 테이블에는 인덱스가 없을 수 있습니다
- 데이터가 크면 디스크 기반 임시 테이블이 됩니다
- 외부 쿼리의 WHERE 조건이 서브쿼리 안으로 밀려들어가지 않습니다
3. 최적화 장벽
서브쿼리는 옵티마이저가 전체 쿼리를 하나로 보고 최적화하는 것을 방해할 수 있습니다.
-- 옵티마이저가 최적화하기 어려운 형태
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);
-- MySQL 5.5 이전에는 이 쿼리가 다음처럼 실행되었습니다:
-- for each row in users:
-- SELECT user_id FROM orders WHERE amount > 1000 AND user_id = users.id
-- 상관 서브쿼리로 변환되어 매 행마다 실행!
MySQL의 서브쿼리 최적화 전략
MySQL 5.6부터 옵티마이저가 서브쿼리를 크게 개선했습니다.
Semi-Join 최적화 (5.6+)
IN이나 EXISTS 서브쿼리를 내부적으로 JOIN 으로 변환합니다.
-- 원본 쿼리
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);
-- 옵티마이저가 내부적으로 Semi-Join으로 변환
-- (결과는 동일하지만 실행 방식이 다름)
SELECT DISTINCT users.*
FROM users
SEMI JOIN orders ON users.id = orders.user_id
WHERE orders.amount > 1000;
Semi-Join의 세부 전략:
| 전략 | 설명 |
|---|---|
| Table Pullout | 서브쿼리 테이블을 외부로 끌어올림 |
| Duplicate Weedout | 중복을 임시 테이블로 제거 |
| First Match | 첫 매칭만 찾고 다음 행으로 이동 |
| Loose Scan | 인덱스의 그룹별 첫 값만 스캔 |
| Materialization | 서브쿼리 결과를 임시 테이블로 실체화 |
-- EXPLAIN에서 Semi-Join 전략 확인
EXPLAIN SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);
-- Extra: Using where; LooseScan 등
파생 테이블 머지 (5.7+)
FROM 절의 서브쿼리를 외부 쿼리와 합쳐서 임시 테이블 생성을 피합니다.
-- 원본: 파생 테이블 사용
SELECT * FROM (
SELECT id, name, age FROM users WHERE age > 20
) sub
WHERE sub.name LIKE '김%';
-- 머지 후: 하나의 쿼리로 합쳐짐
SELECT id, name, age FROM users
WHERE age > 20 AND name LIKE '김%';
머지가 불가능한 경우:
GROUP BY,DISTINCT,LIMIT이 포함된 서브쿼리- 집계 함수가 포함된 경우
UNION이 포함된 경우
-- 머지 불가: GROUP BY가 있어서 실체화됨
SELECT * FROM (
SELECT department, COUNT(*) AS cnt
FROM employees
GROUP BY department
) sub
WHERE sub.cnt > 5;
Subquery Materialization
IN 서브쿼리의 결과를 임시 테이블에 실체화 하고, 해당 테이블에 인덱스를 생성하여 조회합니다.
SELECT * FROM users
WHERE department_id IN (
SELECT id FROM departments WHERE region = 'Asia'
);
-- 옵티마이저: departments의 결과를 임시 테이블에 저장하고 인덱스 생성
-- users의 각 행에 대해 임시 테이블을 인덱스로 조회
EXISTS vs IN
전통적으로 EXISTS와 IN의 성능 차이가 자주 논의되었지만, MySQL 8.0에서는 대부분 같은 실행 계획으로 처리됩니다.
-- IN 사용
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);
-- EXISTS 사용
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 1000);
-- MySQL 8.0: 두 쿼리의 EXPLAIN 결과가 대부분 동일합니다
그래도 구분이 필요한 경우:
- **서브쿼리 결과가 적을 때 **: IN이 직관적
- ** 외부 테이블이 작고 서브쿼리가 클 때 **: EXISTS가 유리할 수 있음 (first match)
- **NULL 처리 **: NOT IN은 NULL에 주의, NOT EXISTS가 안전
-- NOT IN의 NULL 함정
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders);
-- orders.user_id에 NULL이 있으면 결과가 빈 집합이 됩니다!
-- NOT EXISTS는 NULL에 안전
SELECT * FROM users u
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
서브쿼리를 JOIN으로 변환하는 패턴
스칼라 서브쿼리 → LEFT JOIN
-- Before: 스칼라 서브쿼리 (매 행마다 실행)
SELECT u.name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS cnt
FROM users u;
-- After: LEFT JOIN + GROUP BY
SELECT u.name, COUNT(o.id) AS cnt
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;
IN 서브쿼리 → JOIN
-- Before: IN 서브쿼리
SELECT * FROM products
WHERE category_id IN (SELECT id FROM categories WHERE active = 1);
-- After: JOIN
SELECT p.*
FROM products p
JOIN categories c ON p.category_id = c.id
WHERE c.active = 1;
NOT IN → LEFT JOIN + IS NULL
-- Before: NOT IN
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM orders);
-- After: LEFT JOIN + IS NULL (NULL 안전)
SELECT u.*
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.user_id IS NULL;
서브쿼리가 더 나은 경우
항상 JOIN이 좋은 것은 아닙니다. 서브쿼리가 더 적합한 경우도 있습니다.
-- 1. 집계 결과와 비교
SELECT * FROM employees
WHERE salary > (SELECT AVG(salary) FROM employees);
-- 2. EXISTS로 존재 여부만 확인
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);
-- 3. 가독성이 중요한 복잡한 조건
SELECT * FROM products
WHERE price > (SELECT AVG(price) FROM products WHERE category_id = 1)
AND stock > (SELECT MIN(stock) FROM products WHERE featured = 1);
실행 계획으로 확인하기
-- 서브쿼리 최적화 여부 확인
EXPLAIN FORMAT=TREE
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE amount > 1000);
-- optimizer_switch로 최적화 전략 확인
SHOW VARIABLES LIKE 'optimizer_switch';
-- semijoin=on, materialization=on, subquery_materialization_cost_based=on ...
-- 특정 최적화 비활성화 (디버깅용)
SET optimizer_switch='semijoin=off';
주의할 점
NOT IN에 NULL이 포함되면 결과가 빈 집합이 된다
SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM orders);
-- orders.user_id에 NULL이 하나라도 있으면 결과가 항상 빈 집합!
이유는 SQL의 NULL 비교 규칙 때문입니다. id NOT IN (1, 2, NULL)은 id != 1 AND id != 2 AND id != NULL로 변환되고, id != NULL은 항상 UNKNOWN이므로 전체가 UNKNOWN이 됩니다. NOT EXISTS나 LEFT JOIN + IS NULL로 대체해야 합니다.
MySQL 5.5 이하에서 IN 서브쿼리는 상관 서브쿼리로 변환되었다
구버전 MySQL에서 WHERE id IN (SELECT ...) 형태의 쿼리가 극도로 느렸던 이유입니다. MySQL 5.6부터 Semi-Join 최적화가 도입되어 이 문제가 해결되었지만, 레거시 코드에서는 여전히 JOIN으로 작성된 패턴을 볼 수 있습니다.
스칼라 서브쿼리의 N+1 문제를 간과하기 쉽다
SELECT 절의 서브쿼리는 외부 행마다 실행됩니다. 결과 행이 적을 때는 문제 없어 보이지만, 데이터가 늘어나면 선형적으로 느려집니다. LEFT JOIN으로 변환하면 한 번의 실행으로 끝납니다.
정리
| 항목 | 설명 |
|---|---|
| 상관 서브쿼리 | 외부 행마다 반복 실행, N+1과 동일한 문제 |
| Semi-Join (5.6+) | IN/EXISTS를 효율적인 JOIN으로 변환 |
| 파생 테이블 머지 (5.7+) | FROM 절 서브쿼리를 임시 테이블 없이 외부와 합침 |
| NOT IN vs NOT EXISTS | NOT IN은 NULL 문제, NOT EXISTS가 안전 |
| IN vs EXISTS (8.0) | 옵티마이저가 대부분 같은 실행 계획으로 처리 |
| 스칼라 서브쿼리 | LEFT JOIN으로 변환하면 성능 개선 |