SQL 서브쿼리 정리 (IN, EXISTS, NOT IN 주의점)
“평균보다 비싼 상품”, “한 번이라도 주문한 회원”, “카테고리별 가장 최근 글”처럼 조건 안에 또 다른 조회 결과가 필요할 때 쓰는 것이 서브쿼리다. 이 글에서는 SQL 서브쿼리 종류를 위치별로 나눠 정리하고, IN과 EXISTS의 차이, NOT IN의 NULL 함정, JOIN으로 바꾸는 방법까지 다룬다. 집계 함수는 SQL GROUP BY에, 테이블 합치기는 JOIN 정리에 먼저 정리해 두었다.

SQL 서브쿼리 위치별 종류
| 위치 | 이름 | 예 |
|---|---|---|
| WHERE | 조건 서브쿼리 | 평균보다 큰 금액 |
| SELECT | 스칼라 서브쿼리 | 행마다 값 하나 붙이기 |
| FROM | 파생 테이블 | 집계 결과를 다시 조회 |
SQL 서브쿼리 1: WHERE 절
-- 평균 주문 금액보다 큰 주문
SELECT * FROM orders
WHERE amount > (SELECT AVG(amount) FROM orders);
-- 한 번이라도 book 을 산 고객의 모든 주문
SELECT * FROM orders
WHERE customer IN (SELECT customer FROM orders WHERE category = 'book');
괄호 안의 서브쿼리가 먼저 값을 만들고, 바깥 쿼리가 그 값으로 조건을 건다. 비교 연산자(=, >)를 쓸 때는 서브쿼리가 값 하나만 돌려줘야 하고, 여러 값이면 IN을 쓴다. 예제 테이블은 GROUP BY 글의 orders 테이블이다.
EXISTS와 NOT EXISTS
-- 주문이 하나라도 있는 회원
SELECT * FROM members m
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer = m.name);
-- 주문이 없는 회원
SELECT * FROM members m
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer = m.name);
EXISTS는 조건에 맞는 행이 “있는지”만 보고, 하나라도 찾으면 더 보지 않는다. 바깥 행의 값(m.name)을 안에서 쓰기 때문에 상관 서브쿼리라고 부른다.
NOT IN의 NULL 함정
-- orders.customer 에 NULL 이 하나라도 있으면 결과가 0건이 된다
SELECT * FROM members
WHERE name NOT IN (SELECT customer FROM orders);
NOT IN 목록에 NULL이 섞이면 비교 결과가 “알 수 없음”이 되어 아무 행도 나오지 않는다. 오류도 나지 않아 찾기 어려운 버그가 된다. “없는 것 찾기”는 NOT EXISTS를 쓰거나, 서브쿼리에 WHERE customer IS NOT NULL을 붙이자. NULL 처리 규칙은 SQL WHERE 조건 정리에 있다.
SQL 서브쿼리 2: SELECT 절과 FROM 절
-- SELECT 절: 회원마다 주문 횟수 붙이기
SELECT m.name,
(SELECT COUNT(*) FROM orders o WHERE o.customer = m.name) AS order_cnt
FROM members m;
-- FROM 절: 고객별 합계를 구한 뒤 다시 거르기 (별칭 필수)
SELECT t.customer, t.total
FROM (SELECT customer, SUM(amount) AS total
FROM orders GROUP BY customer) AS t
WHERE t.total >= 20000;
FROM 절 서브쿼리에는 반드시 AS t 같은 별칭을 붙여야 한다. MySQL 8.0부터는 같은 내용을 WITH 절(CTE)로 더 읽기 쉽게 쓸 수 있다.
WITH t AS (
SELECT customer, SUM(amount) AS total FROM orders GROUP BY customer
)
SELECT * FROM t WHERE total >= 20000;
서브쿼리와 JOIN, 무엇을 쓸까
-- 서브쿼리
SELECT * FROM members
WHERE name IN (SELECT customer FROM orders);
-- JOIN 으로 바꾸면 (중복 제거 필요)
SELECT DISTINCT m.*
FROM members m
JOIN orders o ON o.customer = m.name;
요즘 MySQL은 IN 서브쿼리를 내부적으로 조인처럼 최적화하는 경우가 많아, 읽기 쉬운 쪽으로 쓰고 느릴 때 실행 계획을 보는 것이 현실적이다. SELECT 절의 상관 서브쿼리는 행마다 실행될 수 있어 데이터가 많으면 느려질 수 있으니 주의하자. 실행 계획 확인은 EXPLAIN 보는 법, 느린 쿼리 찾기는 MySQL 느린 쿼리 로그에 있다. 서브쿼리 규칙 전체는 MySQL 서브쿼리 공식 문서를 참고하자.
SQL 서브쿼리 정리
값 하나로 비교할 때는 WHERE 서브쿼리, 있는지 없는지는 EXISTS와 NOT EXISTS, 집계 결과를 다시 거를 때는 FROM 절이나 WITH를 쓴다. NOT IN과 NULL의 조합만 피하면 서브쿼리로 대부분의 복잡한 조회를 깔끔하게 풀 수 있다. 이것으로 이번 달 매일 글 연재를 마친다. 전날 글은 Spring Boot 배포이다.
첫 댓글을 남겨보세요