숏컷 코드
SELECT name
FROM users AS u
WHERE EXISTS (
SELECT 1
FROM posts AS p
WHERE p.user_id = u.id
);문법
EXISTS 는 첫 번째 매칭 행을 찾는 즉시 멈춰 전체 스캔을 피한다
EXISTS (subquery) 는 서브쿼리가 한 행이라도 반환하면 TRUE 입니다. 중요한 특성은 조기 종료(short-circuit) 입니다. 조건을 만족하는 첫 행을 찾는 순간 내부 쿼리 실행을 멈추므로, 매칭 행이 많은 테이블에서 IN (subquery) 보다 효율적인 경우가 많습니다. SELECT 1 처럼 반환 값 자체는 의미 없으며, 존재 여부만 판단합니다. PostgreSQL 옵티마이저는 EXISTS 를 Anti Join 또는 Semi Join 으로 변환해 실행합니다.
-- EXISTS: 글이 하나라도 있는 사용자만 조회
SELECT u.id, u.name
FROM users AS u
WHERE EXISTS (
SELECT 1
FROM posts AS p
WHERE p.user_id = u.id
AND p.published = true
);서브쿼리는 먼저 EXISTS, IN, 스칼라 서브쿼리로 나눠서 보면 된다
존재 여부를 묻는지(EXISTS), 목록 포함 여부를 보는지(IN), 값 하나를 계산하는지(스칼라 서브쿼리)로 먼저 나누면 서브쿼리 선택이 쉬워집니다. 겉보기 문법보다 "무엇을 묻는 쿼리인가"를 먼저 보는 편이 좋습니다.
-- 존재 여부
WHERE EXISTS (SELECT 1 ...)
-- 목록 포함 여부
WHERE id IN (SELECT ...)
-- 값 하나 계산
SELECT (SELECT COUNT(*) FROM posts p WHERE p.user_id = u.id)IN (subquery) 는 NULL 이 포함되면 NOT IN 결과가 완전히 깨진다
IN (subquery) 는 서브쿼리 결과 목록에 포함되는지 비교합니다. 목록이 크지 않다면 편리하지만, 서브쿼리 결과에 NULL 이 하나라도 포함되면 NOT IN (subquery) 는 3값 논리에 의해 항상 빈 결과를 반환합니다. NULL = 값 은 UNKNOWN 이고, NOT (UNKNOWN) 도 UNKNOWN 이기 때문입니다. "연결 없는 행"을 찾을 때는 NOT IN 대신 NOT EXISTS 또는 LEFT JOIN ... IS NULL 패턴이 안전합니다.
-- 위험한 패턴: manager_id 에 NULL 이 있으면 결과 없음
SELECT id, name FROM employees
WHERE id NOT IN (
SELECT manager_id FROM departments -- manager_id 가 NULL 인 행이 있으면 전체 결과 없어짐
);
-- 안전한 패턴: NOT EXISTS
SELECT id, name FROM employees AS e
WHERE NOT EXISTS (
SELECT 1 FROM departments AS d
WHERE d.manager_id = e.id
);
-- 안전한 패턴: LEFT JOIN + IS NULL
SELECT e.id, e.name
FROM employees AS e
LEFT JOIN departments AS d ON d.manager_id = e.id
WHERE d.manager_id IS NULL;상관 서브쿼리는 바깥 쿼리 행마다 반복 실행되므로 인덱스가 필수다
바깥 쿼리의 컬럼을 안쪽 서브쿼리에서 참조하는 것을 상관 서브쿼리(correlated subquery)라고 합니다. EXISTS 에서 WHERE p.user_id = u.id 처럼 바깥 u.id 를 참조하는 것이 대표적인 예입니다. 이 형태는 바깥 쿼리의 각 행마다 서브쿼리가 한 번씩 실행될 수 있습니다. PostgreSQL 은 이를 Semi Join 이나 Anti Join 으로 최적화하지만, 안쪽 서브쿼리의 참조 컬럼(p.user_id)에 인덱스가 없으면 최적화 효과가 제한됩니다.
-- 상관 서브쿼리 성능을 위한 인덱스
CREATE INDEX idx_posts_user_id ON posts (user_id);
CREATE INDEX idx_posts_user_published ON posts (user_id, published);
-- 실행 계획에서 Semi Join 또는 Index Scan 확인
EXPLAIN ANALYZE
SELECT u.id, u.name
FROM users AS u
WHERE EXISTS (
SELECT 1 FROM posts AS p
WHERE p.user_id = u.id AND p.published = true
);스칼라 서브쿼리는 SELECT 목록에서 단일 값을 계산할 때 쓰이지만 남용하면 N+1 이 된다
SELECT 목록 안에 단일 값을 반환하는 서브쿼리를 스칼라 서브쿼리라고 합니다. 편리하지만 바깥 쿼리의 각 행마다 실행되므로 대용량 데이터에서는 N+1 쿼리와 동일한 문제가 생깁니다. 가능하면 LEFT JOIN 이나 윈도 함수로 대체하는 것이 성능상 유리합니다.
-- 스칼라 서브쿼리 (행마다 서브쿼리 실행)
SELECT
u.id,
u.name,
(SELECT COUNT(*) FROM posts p WHERE p.user_id = u.id) AS post_count
FROM users u;
-- LEFT JOIN 으로 대체 (단일 패스)
SELECT
u.id,
u.name,
COUNT(p.id) AS post_count
FROM users u
LEFT JOIN posts p ON p.user_id = u.id
GROUP BY u.id, u.name;EXISTS 와 JOIN 은 질문 자체가 다르다
EXISTS는 "연결된 행이 있느냐"만 묻고, JOIN은 실제로 오른쪽 데이터를 결과에 붙입니다. 존재 여부만 필요한데 JOIN으로 바꾸면 중복 행 때문에 DISTINCT를 다시 붙이게 되는 경우가 많습니다. 필요한 게 "존재"인지 "결합된 데이터"인지 먼저 정하는 편이 안전합니다.
-- 존재 여부만 필요
SELECT u.id, u.name
FROM users AS u
WHERE EXISTS (
SELECT 1
FROM posts AS p
WHERE p.user_id = u.id
);
-- 실제 제목까지 필요
SELECT u.id, u.name, p.title
FROM users AS u
JOIN posts AS p ON p.user_id = u.id;선택 기준
| 상황 | 적합한 선택 |
|---|---|
| 존재 여부만 확인할 때 | EXISTS (조기 종료, Semi Join 최적화) |
| 소규모 정적 목록과 비교할 때 | IN (값1, 값2, ...) |
| 연결 없는 행을 제외할 때 | NOT EXISTS 또는 LEFT JOIN ... IS NULL |
NOT IN + NULL 이 포함된 서브쿼리 | NOT EXISTS 로 대체 (NULL 안전) |
| SELECT 목록에서 집계값 하나 계산 | 스칼라 서브쿼리 (소규모) 또는 LEFT JOIN + GROUP BY |
주의할 점
NOT IN (subquery) 는 서브쿼리 결과에 NULL 이 하나라도 포함되면 항상 빈 결과를 반환합니다.
이는 3값 논리 때문에 발생하는 조용한 버그로, 운영 중에 데이터가 쌓이면서 갑자기 나타날 수 있습니다.
"연결 없는 행 제외" 패턴은 NOT EXISTS 또는 LEFT JOIN ... IS NULL 을 사용해야 안전합니다.
SELECT name
FROM users
WHERE id IN (
SELECT user_id FROM posts
);이 패턴은 단순하고 읽기 쉽지만, "존재만 확인하면 되는가"가 핵심인 경우에는 EXISTS가 의도를 더 잘 드러내고 조기 종료 최적화도 기대할 수 있습니다.
SELECT DISTINCT u.id, u.name
FROM users AS u
JOIN posts AS p ON p.user_id = u.id;존재 여부만 확인하려고 JOIN 뒤에 DISTINCT를 붙이는 패턴은 흔하지만, 처음부터 EXISTS로 쓰는 편이 의도도 더 직접적이고 중복 제거 비용도 피할 수 있습니다.
참고 링크
1 sources