서브쿼리
하나의 SQL 안에 또 다른 SQL을 넣을 수 있습니다. 이것이 서브쿼리(Subquery)입니다. 평균 가격보다 비싼 상품처럼 다른 쿼리의 결과를 조건으로 사용하고 싶을 때 유용합니다.
서브쿼리는 SQL의 강력한 기능 중 하나로, 복잡한 비즈니스 로직을 하나의 SQL 문으로 표현할 수 있게 합니다. 서브쿼리 없이는 여러 번의 쿼리를 실행하고 결과를 프로그래밍 언어에서 조합해야 하지만, 서브쿼리를 사용하면 데이터베이스 엔진이 한 번에 처리합니다.
-- 서브쿼리 없이: 2번의 쿼리 필요
-- 1단계: 평균 가격 구하기
SELECT AVG(price) FROM products; -- 결과: 50000
-- 2단계: 평균보다 비싼 상품 조회
SELECT * FROM products WHERE price > 50000;
-- 서브쿼리 사용: 1번의 쿼리로 해결
SELECT * FROM products
WHERE price > (SELECT AVG(price) FROM products);서브쿼리의 가장 큰 장점은 동적 조건을 만들 수 있다는 점입니다. 평균값이 변하더라도 쿼리를 수정할 필요가 없습니다.
서브쿼리의 기본 분류
서브쿼리는 크게 두 가지 기준으로 분류할 수 있습니다.
반환 형태에 따른 분류
┌─────────────────────────────────────────────────────────┐
│ 스칼라 서브쿼리 (Scalar) │
│ 반환: 단일 값 (1행 1열) │
│ 사용: SELECT 절, WHERE 절의 비교 연산자 오른쪽 │
│ 예: (SELECT MAX(price) FROM products) │
├─────────────────────────────────────────────────────────┤
│ 행 서브쿼리 (Row) │
│ 반환: 단일 행 (1행 N열) │
│ 사용: WHERE 절에서 행 비교 │
│ 예: WHERE (dept, job) = (SELECT dept, job FROM ...) │
├─────────────────────────────────────────────────────────┤
│ 테이블 서브쿼리 (Table) │
│ 반환: 여러 행, 여러 열 (N행 M열) │
│ 사용: FROM 절 (인라인 뷰), IN/EXISTS 연산자 │
│ 예: FROM (SELECT ... GROUP BY ...) summary │
├─────────────────────────────────────────────────────────┤
│ 열 서브쿼리 (Column) │
│ 반환: 여러 행, 단일 열 (N행 1열) │
│ 사용: IN, ANY, ALL 연산자 │
│ 예: WHERE id IN (SELECT id FROM ...) │
└─────────────────────────────────────────────────────────┘외부 쿼리와의 관계에 따른 분류
* 비상관 서브쿼리 (Non-correlated)
외부 쿼리와 독립적. 서브쿼리가 먼저 한 번 실행되고 결과가 외부에 전달됨
예: WHERE price > (SELECT AVG(price) FROM products)
* 상관 서브쿼리 (Correlated)
외부 쿼리의 값을 참조. 외부의 각 행마다 서브쿼리가 반복 실행됨
예: WHERE price = (SELECT MAX(price) FROM products p2 WHERE p2.category = p.category)서브쿼리의 위치별 분류
| 위치 | 이름 | 반환 | 예시 |
|---|---|---|---|
| SELECT 절 | 스칼라 서브쿼리 | 단일 값 | (SELECT COUNT(*) ...) |
| FROM 절 | 인라인 뷰 | 테이블 | FROM (SELECT ...) t |
| WHERE 절 | 중첩 서브쿼리 | 값 또는 집합 | WHERE id IN (SELECT ...) |
| HAVING 절 | 중첩 서브쿼리 | 값 | HAVING COUNT(*) > (SELECT ...) |
스칼라 서브쿼리
스칼라 서브쿼리는 SELECT 절에서 사용되며, 반드시 단일 값(1행 1열)을 반환해야 합니다. 2개 이상의 행을 반환하면 오류가 발생합니다.
SELECT o.order_id,
o.order_date,
o.total_amount,
(SELECT u.username
FROM users u
WHERE u.user_id = o.user_id) AS customer_name
FROM orders o;스칼라 서브쿼리는 외부 쿼리의 각 행마다 실행되므로 상관 서브쿼리입니다. 행이 많으면 성능 문제가 발생할 수 있습니다.
-- 전체 평균과 각 상품의 가격 차이
SELECT p.name,
p.price,
(SELECT AVG(price) FROM products) AS avg_price,
p.price - (SELECT AVG(price) FROM products) AS diff
FROM products p;
-- 각 부서의 사원 수
SELECT d.dept_name,
(SELECT COUNT(*) FROM employees e WHERE e.dept_id = d.dept_id) AS emp_count
FROM departments d;스칼라 서브쿼리의 NULL 처리
서브쿼리 결과가 0행이면 NULL을 반환합니다. 이는 오류가 아니라 정상 동작입니다.
-- 주문이 없는 고객의 경우 last_order_date는 NULL
SELECT u.username,
(SELECT MAX(o.order_date)
FROM orders o
WHERE o.user_id = u.user_id) AS last_order_date
FROM users u;
-- COALESCE로 NULL 대체
SELECT u.username,
COALESCE(
(SELECT MAX(o.order_date)
FROM orders o
WHERE o.user_id = u.user_id),
'주문 없음'
) AS last_order_date
FROM users u;스칼라 서브쿼리 vs LEFT JOIN
동일한 결과를 LEFT JOIN으로도 얻을 수 있으며, 대부분의 경우 JOIN이 더 효율적입니다.
-- 방법 1: 스칼라 서브쿼리
SELECT o.order_id,
(SELECT u.username FROM users u WHERE u.user_id = o.user_id) AS name
FROM orders o;
-- 방법 2: LEFT JOIN (일반적으로 더 빠름)
SELECT o.order_id, u.username AS name
FROM orders o
LEFT JOIN users u ON u.user_id = o.user_id;인라인 뷰
FROM 절에 서브쿼리를 작성하면 그 결과가 임시 테이블처럼 동작합니다. 이를 인라인 뷰(Inline View)라 합니다. 반드시 별칭(alias)을 지정해야 합니다.
SELECT summary.category,
summary.total_value,
summary.product_count
FROM (
SELECT category,
SUM(price * stock) AS total_value,
COUNT(*) AS product_count
FROM products
GROUP BY category
) summary
WHERE summary.total_value > 1000000
ORDER BY summary.total_value DESC;인라인 뷰는 복잡한 쿼리를 단계적으로 작성할 때 유용합니다. 먼저 서브쿼리에서 데이터를 가공하고, 외부 쿼리에서 필터링하거나 정렬합니다.
-- 카테고리별 매출 상위 3개 카테고리
SELECT *
FROM (
SELECT category,
SUM(price * stock) AS total_value,
ROW_NUMBER() OVER (ORDER BY SUM(price * stock) DESC) AS rn
FROM products
GROUP BY category
) ranked
WHERE ranked.rn <= 3;인라인 뷰의 중첩
인라인 뷰 안에 또 다른 인라인 뷰를 넣을 수 있습니다. 하지만 깊은 중첩은 가독성을 떨어뜨리므로 CTE(WITH 절)로 대체하는 것이 좋습니다.
SELECT *
FROM (
SELECT category, avg_price
FROM (
SELECT category, AVG(price) AS avg_price
FROM products
GROUP BY category
) inner_view
WHERE avg_price > 50000
) outer_view
ORDER BY avg_price DESC;
-- CTE로 개선 — 같은 결과, 더 읽기 쉬움
WITH category_avg AS (
SELECT category, AVG(price) AS avg_price
FROM products
GROUP BY category
)
SELECT category, avg_price
FROM category_avg
WHERE avg_price > 50000
ORDER BY avg_price DESC;WHERE 절 서브쿼리
WHERE 절에서 서브쿼리를 사용하면 다른 테이블의 데이터를 조건으로 활용할 수 있습니다.
IN 연산자
서브쿼리가 반환하는 목록에 값이 포함되는지 확인합니다.
-- 주문한 적이 있는 고객
SELECT * FROM users
WHERE user_id IN (SELECT user_id FROM orders);
-- 주문한 적이 없는 고객
SELECT * FROM users
WHERE user_id NOT IN (SELECT user_id FROM orders);NOT IN과 NULL 함정
NOT IN에서 서브쿼리 결과에 NULL이 포함되면 전체 결과가 공집합이 됩니다. 이것은 매우 흔한 실수입니다.
-- orders 테이블에 user_id가 NULL인 행이 있다면
-- 아래 쿼리는 결과가 0행!
SELECT * FROM users
WHERE user_id NOT IN (SELECT user_id FROM orders);
-- 원인: NULL과의 비교는 항상 UNKNOWN
-- user_id NOT IN (1, 2, NULL) → user_id != 1 AND user_id != 2 AND user_id != NULL
-- user_id != NULL은 UNKNOWN이므로 전체가 UNKNOWN → 거짓
-- 해결 방법 1: NOT IN에서 NULL 제거
SELECT * FROM users
WHERE user_id NOT IN (
SELECT user_id FROM orders WHERE user_id IS NOT NULL
);
-- 해결 방법 2: NOT EXISTS 사용 (권장)
SELECT * FROM users u
WHERE NOT EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.user_id
);EXISTS 연산자
EXISTS는 서브쿼리 결과가 하나라도 존재하면 TRUE를 반환합니다. NOT EXISTS는 결과가 없으면 TRUE입니다.
-- 주문한 적이 있는 고객 (EXISTS)
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o WHERE o.user_id = u.user_id
);
-- 동일한 결과 (IN)
SELECT * FROM users
WHERE user_id IN (SELECT user_id FROM orders);EXISTS는 조건을 만족하는 행을 하나라도 찾으면 즉시 TRUE를 반환하고 서브쿼리 실행을 중단합니다(Short-circuit). 반면 IN은 서브쿼리의 전체 결과를 먼저 구합니다.
┌───────────────────────────────────────────────────────┐
│ 외부 테이블이 작고 내부 테이블이 클 때 → EXISTS 유리 │
│ EXISTS는 내부 테이블에서 일치하는 첫 행만 찾으면 중단│
│ │
│ 외부 테이블이 크고 내부 테이블이 작을 때 → IN 유리 │
│ IN은 서브쿼리 결과를 해시 테이블로 만들어 빠르게 비교│
│ │
│ NULL이 있을 수 있는 경우 → EXISTS 사용 (안전) │
│ NOT IN은 NULL 함정이 있지만 NOT EXISTS는 안전 │
└───────────────────────────────────────────────────────┘ALL / ANY(SOME) 연산자
ALL은 서브쿼리의 모든 값과 비교하여 참인 경우, ANY(또는 SOME)는 하나라도 만족하면 참입니다.
-- ALL: 모든 도서 가격보다 비싼 상품 (= 도서 중 최고가보다 비쌈)
SELECT * FROM products
WHERE price > ALL (SELECT price FROM products WHERE category = '도서');
-- 위와 동일 (MAX 사용)
SELECT * FROM products
WHERE price > (SELECT MAX(price) FROM products WHERE category = '도서');
-- ANY: 도서 중 하나라도보다 비싼 상품 (= 도서 중 최저가보다 비쌈)
SELECT * FROM products
WHERE price > ANY (SELECT price FROM products WHERE category = '도서');
-- 위와 동일 (MIN 사용)
SELECT * FROM products
WHERE price > (SELECT MIN(price) FROM products WHERE category = '도서');| 표현식 | 동등한 표현 |
| ----------------- | ------------------- |
| > ALL (서브쿼리) | > (SELECT MAX(...)) |
| < ALL (서브쿼리) | < (SELECT MIN(...)) |
| > ANY (서브쿼리) | > (SELECT MIN(...)) |
| < ANY (서브쿼리) | < (SELECT MAX(...)) |
| = ANY (서브쿼리) | IN (서브쿼리) |
| != ALL (서브쿼리) | NOT IN (서브쿼리) |HAVING 절 서브쿼리
GROUP BY와 HAVING을 함께 사용할 때도 서브쿼리를 넣을 수 있습니다.
-- 전체 평균 주문 수보다 많이 주문한 고객
SELECT user_id, COUNT(*) AS order_count
FROM orders
GROUP BY user_id
HAVING COUNT(*) > (
SELECT AVG(cnt) FROM (
SELECT COUNT(*) AS cnt FROM orders GROUP BY user_id
) t
);상관 서브쿼리 심화
상관 서브쿼리(Correlated Subquery)는 외부 쿼리의 현재 행 값을 참조하는 서브쿼리입니다. 외부 쿼리의 각 행마다 서브쿼리가 실행됩니다.
SELECT p.name, p.price, p.category
FROM products p
WHERE p.price = (
SELECT MAX(p2.price) FROM products p2
WHERE p2.category = p.category -- 외부의 p.category 참조
);실행 과정
외부 쿼리에서 1번째 행 가져옴 (category = '전자')
→ 서브쿼리 실행: SELECT MAX(price) WHERE category = '전자' → 500000
→ 1번째 행의 price = 500000? → TRUE → 결과 포함
외부 쿼리에서 2번째 행 가져옴 (category = '전자')
→ 서브쿼리 실행: SELECT MAX(price) WHERE category = '전자' → 500000
→ 2번째 행의 price = 300000? → FALSE → 결과 제외
외부 쿼리에서 3번째 행 가져옴 (category = '도서')
→ 서브쿼리 실행: SELECT MAX(price) WHERE category = '도서' → 30000
→ 3번째 행의 price = 30000? → TRUE → 결과 포함
... (모든 행에 대해 반복)행이 많을수록 서브쿼리 실행 횟수가 증가하므로, 대량 데이터에서는 심각한 성능 문제가 발생할 수 있습니다.
상관 서브쿼리를 JOIN으로 변환
대부분의 상관 서브쿼리는 JOIN이나 윈도우 함수로 변환할 수 있습니다.
-- 원본: 상관 서브쿼리 (느릴 수 있음)
SELECT p.name, p.price, p.category
FROM products p
WHERE p.price = (
SELECT MAX(p2.price) FROM products p2
WHERE p2.category = p.category
);
-- 변환 1: JOIN 사용
SELECT p.name, p.price, p.category
FROM products p
INNER JOIN (
SELECT category, MAX(price) AS max_price
FROM products
GROUP BY category
) m ON p.category = m.category AND p.price = m.max_price;
-- 변환 2: 윈도우 함수 사용 (가장 효율적)
SELECT name, price, category
FROM (
SELECT name, price, category,
RANK() OVER (PARTITION BY category ORDER BY price DESC) AS rk
FROM products
) ranked
WHERE rk = 1;다중 열 서브쿼리
서브쿼리에서 여러 열을 동시에 비교할 수 있습니다.
-- 각 카테고리에서 최고가인 상품 (다중 열 비교)
SELECT * FROM products
WHERE (category, price) IN (
SELECT category, MAX(price) FROM products GROUP BY category
);
-- Oracle에서 다중 열 비교
SELECT * FROM employees
WHERE (department_id, salary) IN (
SELECT department_id, MAX(salary) FROM employees GROUP BY department_id
);주의: MySQL의 오래된 버전에서는 다중 열 IN 서브쿼리의 최적화가 불완전할 수 있습니다.
서브쿼리 활용 패턴
패턴 1: 존재 여부 확인 (EXISTS)
SELECT p.name, p.price
FROM products p
WHERE EXISTS (
SELECT 1 FROM order_items oi WHERE oi.product_id = p.product_id
);패턴 2: 비율 계산
SELECT category,
COUNT(*) AS cnt,
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM products), 1) AS pct
FROM products
GROUP BY category
ORDER BY pct DESC;패턴 3: 이전/다음 값 참조
SELECT o.order_id,
o.order_date,
o.order_date - (
SELECT MAX(o2.order_date)
FROM orders o2
WHERE o2.user_id = o.user_id
AND o2.order_date < o.order_date
) AS days_since_last
FROM orders o
ORDER BY o.user_id, o.order_date;
-- 위 쿼리는 LAG 윈도우 함수로 더 효율적으로 작성 가능
SELECT order_id,
order_date,
order_date - LAG(order_date) OVER (
PARTITION BY user_id ORDER BY order_date
) AS days_since_last
FROM orders
ORDER BY user_id, order_date;패턴 4: 조건부 UPDATE
-- 3개월간 주문이 없는 고객을 휴면 처리
UPDATE users
SET status = '휴면'
WHERE user_id NOT IN (
SELECT DISTINCT user_id FROM orders
WHERE order_date >= CURRENT_DATE - INTERVAL '3 months'
);
-- DELETE에서도 사용 가능
DELETE FROM products
WHERE product_id NOT IN (
SELECT DISTINCT product_id FROM order_items
);패턴 5: INSERT ... SELECT
-- 월별 매출 요약을 집계 테이블에 삽입
INSERT INTO monthly_sales (month, total_amount, order_count)
SELECT DATE_TRUNC('month', order_date) AS month,
SUM(total_amount),
COUNT(*)
FROM orders
WHERE order_date >= '2024-01-01'
GROUP BY DATE_TRUNC('month', order_date);서브쿼리 vs 조인 비교
| 비교 항목 | 서브쿼리 | 조인 |
|---|---|---|
| 가독성 | 논리적 순서로 읽힘 | 테이블 관계를 한눈에 봄 |
| 성능 | 상관 서브쿼리는 느릴 수 있음 | 일반적으로 더 효율적 |
| 결과 중복 | 중복 발생하지 않음 | 1:N 관계에서 중복 가능 |
| NULL 안전성 | NOT IN에 주의 필요 | LEFT JOIN은 NULL 안전 |
| 사용 시점 | 단일 값 비교, EXISTS | 다중 컬럼 결합, 양쪽 데이터 표시 |
| SELECT 절 | 스칼라 서브쿼리 가능 | 불가 (다른 방법 필요) |
-- 방법 1: 서브쿼리 (IN)
SELECT * FROM users
WHERE user_id IN (SELECT user_id FROM orders WHERE total_amount > 100000);
-- 방법 2: 조인
SELECT DISTINCT u.*
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id
WHERE o.total_amount > 100000;
-- 방법 3: EXISTS
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM orders o
WHERE o.user_id = u.user_id AND o.total_amount > 100000
);옵티마이저가 서브쿼리를 자동으로 조인으로 변환하는 경우도 있습니다(Subquery Unnesting). 하지만 불가능한 경우도 있으므로, 대량 데이터에서는 EXPLAIN으로 실행 계획을 확인하는 것이 좋습니다.
서브쿼리 성능 최적화
실행 계획 확인
EXPLAIN ANALYZE
SELECT * FROM users
WHERE user_id IN (SELECT user_id FROM orders);
-- 실행 계획에서 확인할 점:
-- "Semi Join" → 옵티마이저가 서브쿼리를 조인으로 변환함 (좋음)
-- "Subplan" → 서브쿼리가 그대로 실행됨 (대량 데이터에서 주의)최적화 가이드
┌─────────────────────────────────────────────────────────┐
│ 1. NOT IN 대신 NOT EXISTS 사용 (NULL 안전, 성능 우수) │
├─────────────────────────────────────────────────────────┤
│ 2. 상관 서브쿼리 → JOIN 또는 윈도우 함수로 변환 │
│ (외부 행마다 반복 실행을 한 번의 결합으로 변경) │
├─────────────────────────────────────────────────────────┤
│ 3. SELECT 절 스칼라 서브쿼리 → LEFT JOIN 변환 │
│ (스칼라 서브쿼리는 행마다 실행되므로 비효율적) │
├─────────────────────────────────────────────────────────┤
│ 4. 서브쿼리 내 인덱스 활용 확인 │
│ (서브쿼리의 WHERE 절 컬럼에 인덱스가 있는지 확인) │
├─────────────────────────────────────────────────────────┤
│ 5. 중첩 인라인 뷰 → CTE (WITH 절)로 가독성 개선 │
│ (성능은 대부분 동일하지만 유지보수성 향상) │
├─────────────────────────────────────────────────────────┤
│ 6. 불필요한 SELECT * 대신 필요한 컬럼만 조회 │
│ (서브쿼리에서도 최소한의 컬럼만 반환) │
└─────────────────────────────────────────────────────────┘흔한 실수와 주의사항
실수 1: 스칼라 서브쿼리가 여러 행 반환
-- 오류! 서브쿼리가 여러 행을 반환
SELECT (SELECT username FROM users) FROM orders;
-- ERROR: more than one row returned by a subquery
-- 수정: 조건을 추가하여 1행만 반환
SELECT (SELECT username FROM users WHERE user_id = o.user_id)
FROM orders o;실수 2: SELECT 절의 컬럼 수 불일치
-- 오류! IN 서브쿼리는 단일 열만 반환해야 함
SELECT * FROM users
WHERE user_id IN (SELECT user_id, username FROM orders);
-- 수정: 비교할 열만 반환
SELECT * FROM users
WHERE user_id IN (SELECT user_id FROM orders);실수 3: 불필요한 서브쿼리
-- 불필요: 서브쿼리 안에 서브쿼리
SELECT * FROM products
WHERE category IN (
SELECT category FROM (
SELECT DISTINCT category FROM products
) t
);
-- 간결하게: 서브쿼리 없이
SELECT DISTINCT category FROM products;핵심 정리
┌────────────────────────────────────────────────────────┐
│ 서브쿼리 = SQL 안에 포함된 또 다른 SQL │
├────────────────────────────────────────────────────────┤
│ 위치별 분류 │
│ SELECT 절: 스칼라 서브쿼리 (단일 값) │
│ FROM 절: 인라인 뷰 (임시 테이블) │
│ WHERE 절: IN, EXISTS, ALL, ANY │
│ HAVING 절: 그룹 조건의 동적 비교 │
├────────────────────────────────────────────────────────┤
│ 관계별 분류 │
│ 비상관: 독립 실행, 결과 캐싱 가능 │
│ 상관: 외부 행마다 반복 실행, 성능 주의 │
├────────────────────────────────────────────────────────┤
│ 핵심 주의사항 │
│ NOT IN + NULL = 공집합 → NOT EXISTS 사용 │
│ 스칼라 서브쿼리 → LEFT JOIN 변환 고려 │
│ 상관 서브쿼리 → JOIN 또는 윈도우 함수 변환 │
│ EXPLAIN으로 실행 계획 확인 필수 │
└────────────────────────────────────────────────────────┘다음 절에서는 서브쿼리의 재사용 버전인 뷰와 CTE를 다루겠습니다.