Quick Reference
SELECT name
FROM users AS u
WHERE EXISTS (
SELECT 1
FROM posts AS p
WHERE p.user_id = u.id
);존재 여부만 묻는 조회에는 EXISTS, 값 목록과 비교할 때는 IN, 결과 값 하나가 필요할 때는 스칼라 서브쿼리를 씁니다. 연결되지 않은 행을 찾을 때는 오른쪽 결과에 NULL이 들어올 수 있으므로 NOT IN보다 NOT EXISTS가 안전합니다.
문법
EXISTS는 존재 여부만 판단하고, 결과 행을 늘리지 않는다
EXISTS (subquery)는 서브쿼리가 한 행이라도 반환하면 TRUE입니다. 보통은 첫 행이 확인되면 더 읽지 않아도 되지만, 실제 실행 방법과 IN 대비 성능은 옵티마이저와 데이터 분포가 결정합니다. SELECT 1의 값 자체는 의미 없고, 존재만 판단한다는 의도를 드러내는 관례입니다. EXISTS는 매칭 행이 여러 개여도 바깥 행을 한 번만 반환하므로, 존재 여부만 필요할 때 JOIN 뒤에 DISTINCT를 붙이는 것보다 의미가 직접적입니다.
-- 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)NOT IN은 오른쪽 결과의 NULL 때문에 UNKNOWN이 될 수 있다
IN (subquery)는 서브쿼리 결과 목록에 포함되는지 비교합니다. 그러나 비교할 왼쪽 값이 NULL이거나, 일치하는 값이 없는데 오른쪽 결과에 NULL이 하나라도 있으면 IN과 NOT IN의 결과는 참·거짓이 아니라 UNKNOWN이 될 수 있습니다. WHERE는 UNKNOWN을 통과시키지 않으므로, "연결 없는 행"을 찾을 때는 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의 p.user_id = u.id가 대표적입니다. PostgreSQL은 이를 Semi Join이나 Anti Join으로 바꿀 수 있지만, 항상 바깥 행마다 같은 방식으로 반복 실행된다고 가정하면 안 됩니다. 안쪽 테이블이 크고 user_id, published 조건으로 소수 행을 빠르게 찾아야 한다면 해당 조건 순서에 맞는 인덱스를 검토하고, 결과는 EXPLAIN (ANALYZE, BUFFERS)로 확인합니다.
-- 상관 서브쿼리 성능을 위한 인덱스
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 목록 안에 단일 값을 반환하는 서브쿼리를 스칼라 서브쿼리라고 합니다. 결과가 없으면 NULL이 되고, 두 행 이상을 반환하면 오류가 납니다. 바깥 행과 상관된 스칼라 서브쿼리는 비용이 커질 수 있으므로, 집계 결과를 여러 행에 붙이는 목적이라면 LEFT JOIN과 GROUP BY 또는 윈도 함수가 더 읽기 쉽거나 효율적인지 실행 계획으로 비교합니다.
-- 스칼라 서브쿼리 (행마다 서브쿼리 실행)
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 (바깥 행을 중복시키지 않음) |
| 소규모 정적 목록과 비교할 때 | IN (값1, 값2, ...) |
| 연결 없는 행을 제외할 때 | NOT EXISTS 또는 LEFT JOIN ... IS NULL |
NOT IN + NULL 이 포함된 서브쿼리 | NOT EXISTS 로 대체 (NULL 안전) |
| SELECT 목록에서 집계값 하나 계산 | 스칼라 서브쿼리 (소규모) 또는 LEFT JOIN + GROUP BY |
주의할 점
NOT IN (subquery) 는 일치하지 않는 값만 비교하더라도 서브쿼리 결과에 NULL 이 있으면 UNKNOWN 이 될 수 있습니다.
이는 3값 논리 때문에 발생하는 조용한 버그로, 운영 중 데이터에 NULL 이 추가된 뒤에 드러날 수 있습니다.
"연결 없는 행 제외" 패턴은 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