서브쿼리를 쓰면 가독성은 좋아지는데, 왜 "서브쿼리 대신 JOIN을 쓰라"는 말을 자주 들을까요?

서브쿼리는 SQL을 직관적으로 작성할 수 있게 해주지만, 잘못 사용하면 심각한 성능 문제를 일으킵니다.

개념 정의

서브쿼리는 다른 쿼리 안에 포함된 쿼리입니다. 위치에 따라 세 가지로 분류합니다.

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)

외부 쿼리와 독립적으로 실행됩니다. 한 번만 실행 하면 됩니다.

SQL
-- 서브쿼리가 외부 쿼리의 값을 참조하지 않음
SELECT * FROM users
WHERE department_id IN (
    SELECT id FROM departments WHERE active = 1  -- 독립 실행
);

상관 서브쿼리 (Correlated)

외부 쿼리의 값을 참조하므로 외부 행마다 반복 실행 됩니다.

SQL
-- 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. 반복 실행 문제

SQL
-- 느린 쿼리: 매 행마다 서브쿼리 실행
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번 실행됩니다.

SQL
-- 빠른 대안: 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 절의 서브쿼리(파생 테이블)는 결과를 임시 테이블 로 저장할 수 있습니다.

SQL
-- 파생 테이블이 실체화(Materialization)되면 임시 테이블 생성
SELECT * FROM (
    SELECT user_id, SUM(amount) AS total
    FROM orders
    GROUP BY user_id
) sub
WHERE sub.total > 10000;

실체화의 문제:

  • 임시 테이블에는 인덱스가 없을 수 있습니다
  • 데이터가 크면 디스크 기반 임시 테이블이 됩니다
  • 외부 쿼리의 WHERE 조건이 서브쿼리 안으로 밀려들어가지 않습니다

3. 최적화 장벽

서브쿼리는 옵티마이저가 전체 쿼리를 하나로 보고 최적화하는 것을 방해할 수 있습니다.

SQL
-- 옵티마이저가 최적화하기 어려운 형태
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 으로 변환합니다.

SQL
-- 원본 쿼리
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서브쿼리 결과를 임시 테이블로 실체화
SQL
-- 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 절의 서브쿼리를 외부 쿼리와 합쳐서 임시 테이블 생성을 피합니다.

SQL
-- 원본: 파생 테이블 사용
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이 포함된 경우
SQL
-- 머지 불가: GROUP BY가 있어서 실체화됨
SELECT * FROM (
    SELECT department, COUNT(*) AS cnt
    FROM employees
    GROUP BY department
) sub
WHERE sub.cnt > 5;

Subquery Materialization

IN 서브쿼리의 결과를 임시 테이블에 실체화 하고, 해당 테이블에 인덱스를 생성하여 조회합니다.

SQL
SELECT * FROM users
WHERE department_id IN (
    SELECT id FROM departments WHERE region = 'Asia'
);

-- 옵티마이저: departments의 결과를 임시 테이블에 저장하고 인덱스 생성
-- users의 각 행에 대해 임시 테이블을 인덱스로 조회

EXISTS vs IN

전통적으로 EXISTS와 IN의 성능 차이가 자주 논의되었지만, MySQL 8.0에서는 대부분 같은 실행 계획으로 처리됩니다.

SQL
-- 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가 안전
SQL
-- 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

SQL
-- 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

SQL
-- 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

SQL
-- 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이 좋은 것은 아닙니다. 서브쿼리가 더 적합한 경우도 있습니다.

SQL
-- 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);

실행 계획으로 확인하기

SQL
-- 서브쿼리 최적화 여부 확인
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이 포함되면 결과가 빈 집합이 된다

SQL
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 EXISTSNOT IN은 NULL 문제, NOT EXISTS가 안전
IN vs EXISTS (8.0)옵티마이저가 대부분 같은 실행 계획으로 처리
스칼라 서브쿼리LEFT JOIN으로 변환하면 성능 개선
댓글 로딩 중...