콘텐츠로 이동

서브쿼리

서브쿼리(Subquery): SQL 문 안에 중첩된 또 다른 SELECT 문. 외부 쿼리(메인 쿼리)가 실행될 때 내부 쿼리의 결과를 값·집합·테이블로 활용한다.

왜 쓰는지

단일 쿼리로 표현하기 어려운 다단계 조회를 하나의 SQL로 처리하기 위해 사용한다.

-- "평균 주문 금액보다 많이 주문한 회원" 조회
-- → 먼저 평균을 구하고, 그 값으로 필터링 필요
SELECT name
FROM member
WHERE total_amount > (SELECT AVG(total_amount) FROM member);

서브쿼리 없이는 평균 조회 → 애플리케이션에서 값 저장 → 두 번째 쿼리 실행의 2단계가 필요하다.

장점 / 단점

내용
장점 복잡한 조건을 단계적으로 표현 가능, JOIN보다 가독성이 높은 경우 있음
장점 행 수가 증가하지 않아 집계·존재 여부 확인에 안전
단점 상관 서브쿼리는 행마다 반복 실행 → 대량 데이터에서 성능 저하
단점 깊이 중첩될수록 가독성과 최적화 둘 다 나빠짐

분류 기준

서브쿼리는 두 가지 기준으로 분류할 수 있다.

기준 종류
위치 SELECT절(스칼라), FROM절(테이블), WHERE·HAVING절
독립성 비상관(Non-correlated) vs 상관(Correlated)

위치별 종류

SELECT 절 — 스칼라 서브쿼리

언제: 각 행에 대해 다른 테이블의 단일 값을 컬럼처럼 붙이고 싶을 때

-- 회원별 주문 건수를 함께 조회
SELECT
    m.name,
    (SELECT COUNT(*) FROM orders o WHERE o.member_id = m.id) AS order_count
FROM member m;

특징

  • 반드시 단일 행, 단일 컬럼을 반환해야 한다. 여러 행 반환 시 오류.
  • 외부 쿼리의 행마다 실행되는 상관 서브쿼리 방식으로 동작한다.
  • 결과가 없으면 NULL을 반환한다.

성능 주의 — 행 수만큼 반복 실행된다. 100만 행이면 100만 번 실행. JOIN + GROUP BY로 대체하는 것이 성능상 유리하다.

-- ❌ 행마다 서브쿼리 실행
SELECT m.name, (SELECT COUNT(*) FROM orders WHERE member_id = m.id) AS cnt
FROM member m;

-- ✅ JOIN으로 대체
SELECT m.name, COUNT(o.id) AS cnt
FROM member m
LEFT JOIN orders o ON m.id = o.member_id
GROUP BY m.id, m.name;

FROM 절 — 테이블 서브쿼리 (인라인 뷰)

언제: 집계·가공한 결과를 다시 쿼리의 대상 테이블로 사용할 때

-- 회원별 총 주문 금액을 집계한 뒤, 상위 3명 조회
SELECT t.name, t.total
FROM (
    SELECT m.name, SUM(o.amount) AS total
    FROM member m
    JOIN orders o ON m.id = o.member_id
    GROUP BY m.id, m.name
) AS t
ORDER BY t.total DESC
LIMIT 3;

특징

  • 반드시 별칭(alias) 을 붙여야 한다 (AS t).
  • 외부 쿼리와 독립적으로 먼저 실행된다(비상관).
  • MySQL 8.0 이상에서는 CTE(WITH) 로 대체하면 가독성이 좋다.
-- CTE 버전 (권장)
WITH order_totals AS (
    SELECT m.name, SUM(o.amount) AS total
    FROM member m
    JOIN orders o ON m.id = o.member_id
    GROUP BY m.id, m.name
)
SELECT name, total FROM order_totals ORDER BY total DESC LIMIT 3;

WHERE / HAVING 절

단일 행 서브쿼리

언제: 비교 대상이 단일 값 하나일 때

=, !=, >, <, >=, <=비교 연산자와 함께 사용한다. 서브쿼리가 정확히 1행을 반환해야 한다.

-- 가장 최근 주문한 회원 조회
SELECT name
FROM member
WHERE id = (SELECT member_id FROM orders ORDER BY created_at DESC LIMIT 1);

-- 평균 급여보다 많이 받는 직원
SELECT name, salary
FROM employee
WHERE salary > (SELECT AVG(salary) FROM employee);

서브쿼리가 2행 이상을 반환하면 오류가 발생한다. LIMIT 1 또는 집계함수로 반드시 1행을 보장한다.

다중 행 서브쿼리

언제: 비교 대상이 여러 값의 집합일 때

IN, NOT IN, ANY, ALL, EXISTS 등과 함께 사용한다.

IN / NOT IN

-- 한 번이라도 주문한 회원
SELECT name FROM member
WHERE id IN (SELECT DISTINCT member_id FROM orders);

-- 한 번도 주문하지 않은 회원
SELECT name FROM member
WHERE id NOT IN (SELECT member_id FROM orders WHERE member_id IS NOT NULL);

ANY — 서브쿼리 결과 중 하나라도 조건을 만족하면 TRUE

-- 어떤 부서의 평균 급여보다도 많이 받는 직원 (가장 낮은 평균보다 높으면 됨)
SELECT name, salary FROM employee
WHERE salary > ANY (SELECT AVG(salary) FROM employee GROUP BY dept_id);
-- = salary > MIN(각 부서 평균)

ALL — 서브쿼리 결과 모두에 대해 조건을 만족해야 TRUE

-- 모든 부서의 평균 급여보다 많이 받는 직원 (가장 높은 평균보다 높아야 함)
SELECT name, salary FROM employee
WHERE salary > ALL (SELECT AVG(salary) FROM employee GROUP BY dept_id);
-- = salary > MAX(각 부서 평균)
연산자 의미 동등 표현
> ANY 하나라도 보다 크면 > MIN(...)
> ALL 모두보다 크면 > MAX(...)
= ANY 하나라도 같으면 IN (...)
!= ALL 모두와 다르면 NOT IN (...)

NOT IN + NULL 주의 — 서브쿼리 결과에 NULL이 하나라도 있으면 NOT IN은 항상 FALSE를 반환해 결과가 0행이 된다.

-- ❌ member_id에 NULL이 있으면 결과가 항상 0행
SELECT name FROM member WHERE id NOT IN (SELECT member_id FROM orders);

-- ✅ NULL 명시 제거
SELECT name FROM member
WHERE id NOT IN (SELECT member_id FROM orders WHERE member_id IS NOT NULL);

-- ✅ NOT EXISTS로 대체 (NULL 영향 없음)
SELECT name FROM member m
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.member_id = m.id);

다중 컬럼 서브쿼리

언제: 두 개 이상의 컬럼 조합으로 비교해야 할 때

-- 부서별로 최고 급여를 받는 직원 조회
SELECT name, dept_id, salary
FROM employee
WHERE (dept_id, salary) IN (
    SELECT dept_id, MAX(salary)
    FROM employee
    GROUP BY dept_id
);
  • (컬럼1, 컬럼2) IN (서브쿼리) 형태로 묶어서 비교한다.
  • MySQL에서 지원하며, 단일 컬럼 비교보다 JOIN 없이 간결하게 표현할 수 있다.

독립성 기준

비상관 서브쿼리 (Non-correlated)

외부 쿼리와 독립적으로 실행된다. 서브쿼리를 먼저 한 번 실행하고, 그 결과를 외부 쿼리에서 사용한다.

-- 서브쿼리가 외부 쿼리와 무관하게 독립 실행
SELECT name FROM member
WHERE id IN (SELECT member_id FROM orders WHERE amount > 10000);
-- 순서: (1) orders에서 amount > 10000인 member_id 목록 추출
--       (2) 해당 id를 가진 member 조회

특징: 한 번만 실행 → 성능 부담 적음.

상관 서브쿼리 (Correlated Subquery)

상관 서브쿼리: 외부 쿼리의 컬럼을 서브쿼리 내부에서 참조한다. 외부 쿼리의 행마다 서브쿼리가 새로 실행된다.

쉬운 이해: 샘플 데이터로 보기

먼저 데이터를 살펴보겠습니다.

employee 테이블

id | name  | dept_id | salary
---|-------|---------|--------
 1 | Alice |   10    | 50000
 2 | Bob   |   10    | 45000
 3 | Carol |   10    | 48000
 4 | Dave  |   20    | 60000
 5 | Eve   |   20    | 55000
 6 | Frank |   30    | 42000

부서별 평균 급여

dept_id | AVG(salary)
--------|------------
   10   | 47666.67
   20   | 57500
   30   | 42000

상관 서브쿼리 예제

"자신의 부서 평균 급여보다 많이 받는 직원"

SELECT e.name, e.salary, e.dept_id
FROM employee e
WHERE e.salary > (
    SELECT AVG(salary)
    FROM employee
    WHERE dept_id = e.dept_id  -- ← 외부 쿼리의 e.dept_id 참조!
);

실행 과정 (행마다 반복)

① 외부 쿼리: Alice 처리 (dept_id=10, salary=50000)
   서브쿼리: dept_id=10인 모든 직원의 평균 = 47666.67
   50000 > 47666.67? YES → Alice 선택 ✓

② 외부 쿼리: Bob 처리 (dept_id=10, salary=45000)
   서브쿼리: dept_id=10인 모든 직원의 평균 = 47666.67 (반복 계산!)
   45000 > 47666.67? NO → Bob 제외 ✗

③ 외부 쿼리: Carol 처리 (dept_id=10, salary=48000)
   서브쿼리: dept_id=10인 모든 직원의 평균 = 47666.67 (또 반복!)
   48000 > 47666.67? YES → Carol 선택 ✓

④ 외부 쿼리: Dave 처리 (dept_id=20, salary=60000)
   서브쿼리: dept_id=20인 모든 직원의 평균 = 57500 (새로 계산)
   60000 > 57500? YES → Dave 선택 ✓

⑤ 외부 쿼리: Eve 처리 (dept_id=20, salary=55000)
   서브쿼리: dept_id=20인 모든 직원의 평균 = 57500 (반복!)
   55000 > 57500? NO → Eve 제외 ✗

⑥ 외부 쿼리: Frank 처리 (dept_id=30, salary=42000)
   서브쿼리: dept_id=30인 모든 직원의 평균 = 42000 (새로 계산)
   42000 > 42000? NO → Frank 제외 ✗

결과

name  | salary | dept_id
------|--------|--------
Alice | 50000  |   10
Carol | 48000  |   10
Dave  | 60000  |   20

특징: 외부 행 6개마다 서브쿼리 실행 → 총 6번 실행.


비상관 vs 상관 서브쿼리 비교

비상관 서브쿼리 (서브쿼리가 1번만 실행)

-- 평균을 한 번만 계산 (47666.67, 57500, 42000)
SELECT e.name, e.salary, e.dept_id
FROM employee e
WHERE e.salary > (
    SELECT AVG(salary) FROM employee  -- 전체 평균만 한 번
);
-- 결과: 전체 평균(48388.89)보다 많은 직원

상관 서브쿼리 (서브쿼리가 행마다 실행)

-- 각 직원마다 부서별 평균을 다시 계산
SELECT e.name, e.salary, e.dept_id
FROM employee e
WHERE e.salary > (
    SELECT AVG(salary)
    FROM employee
    WHERE dept_id = e.dept_id  -- e.dept_id 값에 따라 필터링이 달라짐!
);
-- 결과: 자신의 부서 평균보다 많은 직원
특성 비상관 상관
외부 참조 ❌ 없음 ✓ e.dept_id 참조
독립 실행 ✓ 1번 ❌ 행마다 반복
데이터 6행 기준 서브쿼리 1번 서브쿼리 6번
1000만 행 기준 서브쿼리 1번 서브쿼리 1000만 번

성능 차이 실감하기

상관 서브쿼리 (느림) ❌

SELECT m.name, m.id
FROM member m
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.member_id = m.id  -- m의 각 행에 대해 반복 실행
);

-- member 테이블에 100만 행 있으면
-- orders 테이블을 100만 번 스캔 (성능 저하!)

JOIN으로 대체 (빠름) ✓

SELECT DISTINCT m.name, m.id
FROM member m
INNER JOIN orders o ON m.id = o.member_id;

-- 한 번의 JOIN으로 처리 (훨씬 빠름!)


EXISTS vs IN: 상관 서브쿼리에서의 차이

EXISTS (권장)

-- 조건 맞는 첫 번째 행을 찾으면 즉시 중단 (short-circuit)
SELECT m.name
FROM member m
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.member_id = m.id
);

-- m의 각 행에 대해:
-- "o.member_id = m.id"인 행이 1개라도 있으면 TRUE
-- 있으면 즉시 다음 m행으로 넘어감 (효율적!)

IN (주의)

-- 서브쿼리 결과를 모두 메모리에 올린 후 비교
SELECT m.name
FROM member m
WHERE m.id IN (
    SELECT o.member_id FROM orders o
);

-- 상관 서브쿼리가 아님 (각 member에서 전체 orders 스캔)
-- 결과는 같지만 EXISTS가 더 효율적

상관 서브쿼리 상황에서 EXISTS 활용

-- 각 직원의 부서에서 50000 이상의 급여를 받는 사람이 있는지 확인
SELECT e.name
FROM employee e
WHERE EXISTS (
    SELECT 1 FROM employee e2
    WHERE e2.dept_id = e.dept_id
    AND e2.salary >= 50000
);


EXISTS / NOT EXISTS

상관 서브쿼리의 대표적인 활용. 존재 여부만 확인하며, 조건에 맞는 행을 찾는 순간 즉시 중단(short-circuit) 한다.

-- 주문이 있는 회원
SELECT m.name FROM member m
WHERE EXISTS (
    SELECT 1 FROM orders o WHERE o.member_id = m.id
);

-- 주문이 없는 회원
SELECT m.name FROM member m
WHERE NOT EXISTS (
    SELECT 1 FROM orders o WHERE o.member_id = m.id
);
  • SELECT 1 — 존재 여부만 확인하므로 실제 컬럼 값은 중요하지 않다. 관례적으로 1을 쓴다.
  • NULL의 영향을 받지 않으므로 NOT IN보다 안전하다.

IN vs EXISTS 비교

IN EXISTS
동작 방식 서브쿼리 결과 전체를 메모리에 올린 뒤 비교 행마다 서브쿼리 실행, 조건 맞으면 즉시 중단
NULL 처리 NOT IN에서 NULL 포함 시 오작동 NULL 영향 없음
적합한 상황 서브쿼리 결과가 소량일 때 서브쿼리 테이블이 크고 인덱스가 있을 때
가독성 단순 목록 비교에 직관적 존재 여부 확인에 의미가 명확

언제 JOIN 대신 서브쿼리를 쓰는지

상황 이유
집계 결과로 필터링 JOIN 후 HAVING으로 해결 안 되는 다단계 집계 비교
존재 여부만 확인 EXISTS가 JOIN보다 간결하고 행 수 유지
결과 행 수를 유지해야 할 때 to-many JOIN은 행이 증가하지만 서브쿼리는 유지됨
독립적인 집계가 필요할 때 FROM절 서브쿼리로 먼저 집계 후 재사용

JOIN — 행 증가 문제

-- DISTINCT 없으면 중복 행 발생
SELECT DISTINCT m.name
FROM member m
JOIN orders o ON m.id = o.member_id;

EXISTS — 행 수 유지

-- 행 증가 없이 존재 여부만 확인
SELECT m.name
FROM member m
WHERE EXISTS (
    SELECT 1 FROM orders o
    WHERE o.member_id = m.id
);

주의할 점

상관 서브쿼리 성능 주의 — 외부 쿼리 행 수만큼 반복 실행된다. 대량 데이터에서는 JOIN 또는 CTE로 대체한다.

-- ❌ 100만 행이면 서브쿼리 100만 번 실행
SELECT name, (SELECT COUNT(*) FROM orders WHERE member_id = m.id) AS cnt
FROM member m;

-- ✅ JOIN + GROUP BY로 대체
SELECT m.name, COUNT(o.id) AS cnt
FROM member m
LEFT JOIN orders o ON m.id = o.member_id
GROUP BY m.id, m.name;

중첩 서브쿼리 남용 금지 — 3단계 이상 중첩하면 가독성과 성능 모두 나빠진다. CTE(WITH)로 단계를 분리한다.

-- ❌ 3단계 중첩
SELECT * FROM (
    SELECT * FROM (
        SELECT * FROM orders WHERE amount > 10000
    ) AS t1 WHERE ...
) AS t2 WHERE ...;

-- ✅ CTE로 분리
WITH filtered AS (
    SELECT * FROM orders WHERE amount > 10000
),
ranked AS (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY member_id ORDER BY amount DESC) AS rn
    FROM filtered
)
SELECT * FROM ranked WHERE rn = 1;

단일 행 보장 — WHERE절 단일 행 서브쿼리에서 결과가 2행 이상이면 런타임 오류가 발생한다. LIMIT 1 또는 집계 함수로 반드시 1행을 보장한다.

-- ❌ orders에 여러 행이 있으면 오류
WHERE id = (SELECT member_id FROM orders WHERE amount > 5000)

-- ✅ LIMIT 1 또는 집계
WHERE id = (SELECT member_id FROM orders WHERE amount > 5000 LIMIT 1)
WHERE salary = (SELECT MAX(salary) FROM employee)