서브쿼리
서브쿼리(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로 대체하는 것이 성능상 유리하다.
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
부서별 평균 급여
상관 서브쿼리 예제
"자신의 부서 평균 급여보다 많이 받는 직원"
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 — 행 증가 문제
주의할 점
상관 서브쿼리 성능 주의 — 외부 쿼리 행 수만큼 반복 실행된다. 대량 데이터에서는 JOIN 또는 CTE로 대체한다.
중첩 서브쿼리 남용 금지 — 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행을 보장한다.