icon

안동민 개발노트

5장 : SQL 심화 — 조인과 서브쿼리

서브쿼리


하나의 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 ...)                   │
└─────────────────────────────────────────────────────────┘

외부 쿼리와의 관계에 따른 분류

비상관 vs 상관
* 비상관 서브쿼리 (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을 반환합니다. 이는 오류가 아니라 정상 동작입니다.

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;

인라인 뷰는 복잡한 쿼리를 단계적으로 작성할 때 유용합니다. 먼저 서브쿼리에서 데이터를 가공하고, 외부 쿼리에서 필터링하거나 정렬합니다.

인라인 뷰: TOP-N 패턴
-- 카테고리별 매출 상위 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 연산자

서브쿼리가 반환하는 목록에 값이 포함되는지 확인합니다.

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이 포함되면 전체 결과가 공집합이 됩니다. 이것은 매우 흔한 실수입니다.

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 vs IN
-- 주문한 적이 있는 고객 (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 vs IN 성능 차이
┌───────────────────────────────────────────────────────┐
│ 외부 테이블이 작고 내부 테이블이 클 때 → EXISTS 유리  │
│  EXISTS는 내부 테이블에서 일치하는 첫 행만 찾으면 중단│
│                                                       │
│ 외부 테이블이 크고 내부 테이블이 작을 때 → IN 유리    │
│  IN은 서브쿼리 결과를 해시 테이블로 만들어 빠르게 비교│
│                                                       │
│ NULL이 있을 수 있는 경우 → EXISTS 사용 (안전)         │
│  NOT IN은 NULL 함정이 있지만 NOT EXISTS는 안전        │
└───────────────────────────────────────────────────────┘

ALL / ANY(SOME) 연산자

ALL은 서브쿼리의 모든 값과 비교하여 참인 경우, ANY(또는 SOME)는 하나라도 만족하면 참입니다.

ALL, ANY 사용 예시
-- 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 / ANY 변환 규칙
| 표현식            | 동등한 표현         |
| ----------------- | ------------------- |
| > ALL (서브쿼리)  | > (SELECT MAX(...)) |
| < ALL (서브쿼리)  | < (SELECT MIN(...)) |
| > ANY (서브쿼리)  | > (SELECT MIN(...)) |
| < ANY (서브쿼리)  | < (SELECT MAX(...)) |
| = ANY (서브쿼리)  | IN (서브쿼리)       |
| != ALL (서브쿼리) | NOT IN (서브쿼리)   |

HAVING 절 서브쿼리

GROUP BY와 HAVING을 함께 사용할 때도 서브쿼리를 넣을 수 있습니다.

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이나 윈도우 함수로 변환할 수 있습니다.

상관 서브쿼리 → 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

서브쿼리를 사용한 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
-- 월별 매출 요약을 집계 테이블에 삽입
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으로 서브쿼리 최적화 확인
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를 다루겠습니다.

목차