EXISTS와 상관 서브쿼리
상관 서브쿼리가 바깥 행을 참조하는 방법을 이해하고 EXISTS·NOT EXISTS로 안전한 세미·안티 조인을 작성합니다.
목록의 값을 가져오는 대신 ‘관련 행이 하나라도 있는가’만 묻는 질문이 많습니다.
게시된 글이 있는 태그, 아직 게시글이 없는 회원, 답글이 없는 댓글처럼 결과에 오른쪽 열은 필요하지 않습니다.
EXISTS는 안쪽 행의 존재를 불리언 조건식으로 바꾸며 상관 조건이 관계 방향을 결정합니다.
ch5에서 회원과 게시글, 게시글 태그, 태그의 FK 경로를 만들었습니다.
이제 바깥의 후보 한 행마다 안쪽 테이블에 대응 행이 있는지 검사합니다.
댓글의 NULL 허용 parent_id는 NOT IN과 NULL이 만났을 때 생기는 대표적인 오류도 보여 줍니다.
EXISTS 예제 데이터
NULL이 결과를 바꾸는 조건을 같은 데이터로 재현하도록 댓글 네 개와 태그 네 개를 고정합니다.
댓글 3과 4는 답글의 부모가 아니며, 활성 태그 중 database에만 게시된 글이 연결되어 있습니다.
DROP TABLE IF EXISTS ch6_exists_post_tag_demo;
DROP TABLE IF EXISTS ch6_exists_post_demo;
DROP TABLE IF EXISTS ch6_exists_tag_demo;
DROP TABLE IF EXISTS ch6_exists_comment_demo;
CREATE TABLE ch6_exists_comment_demo (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
parent_id BIGINT UNSIGNED NULL,
content VARCHAR(200) NOT NULL,
CONSTRAINT fk_ch6_exists_comment_parent
FOREIGN KEY (parent_id) REFERENCES ch6_exists_comment_demo (id)
) ENGINE = InnoDB;
CREATE TABLE ch6_exists_tag_demo (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
code VARCHAR(40) NOT NULL UNIQUE,
name VARCHAR(80) NOT NULL,
is_active BOOLEAN NOT NULL
) ENGINE = InnoDB;
CREATE TABLE ch6_exists_post_demo (
id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
title VARCHAR(80) NOT NULL,
status VARCHAR(16) NOT NULL,
created_at DATETIME(6) NOT NULL
) ENGINE = InnoDB;
CREATE TABLE ch6_exists_post_tag_demo (
post_id BIGINT UNSIGNED NOT NULL,
tag_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (post_id, tag_id),
CONSTRAINT fk_ch6_exists_post_tag_post
FOREIGN KEY (post_id) REFERENCES ch6_exists_post_demo (id),
CONSTRAINT fk_ch6_exists_post_tag_tag
FOREIGN KEY (tag_id) REFERENCES ch6_exists_tag_demo (id)
) ENGINE = InnoDB;
INSERT INTO ch6_exists_comment_demo (id, parent_id, content)
VALUES
(1, NULL, '첫 댓글'),
(2, 1, '첫 댓글의 답글'),
(3, NULL, '답글 없는 댓글'),
(4, 2, '더 이상 답글 없는 댓글');
INSERT INTO ch6_exists_tag_demo (id, code, name, is_active)
VALUES
(1, 'database', '데이터베이스', TRUE),
(2, 'java', 'Java', TRUE),
(3, 'legacy', '이전 분류', FALSE),
(4, 'empty', '게시글 없음', TRUE);
INSERT INTO ch6_exists_post_demo (id, title, status, created_at)
VALUES
(1, 'EXISTS 기초', 'PUBLISHED', '2026-07-01 09:00:00'),
(2, 'Java 초안', 'DRAFT', '2026-07-10 09:00:00'),
(3, '과거 게시글', 'PUBLISHED', '2026-05-01 09:00:00');
INSERT INTO ch6_exists_post_tag_demo (post_id, tag_id)
VALUES
(1, 1),
(2, 2),
(3, 3);NULL이 포함된 NOT IN
답글의 부모로 참조되지 않는 댓글을 찾으려고 NULL 허용 부모 ID 목록에 NOT IN을 사용합니다.
목록 안의 NULL은 ‘어떤 값인지 모름’을 뜻하므로 후보 ID가 목록의 모든 값과 다르다고 확정할 수 없습니다.
문법 오류 없이 결과가 비어 더 발견하기 어렵습니다.
SELECT id, content
FROM ch6_exists_comment_demo
WHERE id NOT IN (
SELECT parent_id
FROM ch6_exists_comment_demo
)
ORDER BY id;parent_id가 NULL인 최상위 댓글 한 개만 있어도 대부분의 후보 비교가 알 수 없음이 되어 WHERE를 통과하지 못합니다.
expected comments without replies | 2 rows
actual result | 0 rows
warning | no SQL error; NULL changes predicate truthx NOT IN (1, NULL)은 x <> 1 AND x <> NULL처럼 해석할 수 있고 두 번째 비교가 알 수 없음입니다.
TRUE AND 알 수 없음은 알 수 없음이므로 WHERE에서 제거됩니다.
안쪽에 WHERE parent_id IS NOT NULL을 추가할 수 있지만, 관계 부재 질문 자체는 NOT EXISTS가 더 직접적이며 NULL 허용 값의 영향을 받지 않습니다.
EXISTS의 상관 조건
EXISTS 안쪽 SELECT는 한 행이라도 찾으면 TRUE이고 열 값은 사용하지 않습니다.
SELECT 1은 그 의도를 드러냅니다.
안쪽 WHERE child.parent_id = parent.id가 바깥 현재 행과 안쪽 후보를 연결하는 상관 조건입니다.
EXISTS는 오른쪽 행을 결과로 펼치지 않는 세미 조인, NOT EXISTS는 대응 행이 없는 왼쪽 후보만 남기는 안티 조인 의미로 읽을 수 있습니다.
EXISTS와 NOT EXISTS는 표준 SQL입니다.
‘상관 서브쿼리는 반드시 바깥 행 수만큼 느리게 실행된다’고 단정하지 않습니다.
MySQL 8.4 옵티마이저는 세미-조인 변환, 구체화, 인덱스 조회 등으로 물리 실행을 바꿀 수 있습니다.
SQL 의미와 물리 계획을 분리하고 EXPLAIN ANALYZE로 실제 반복 수와 읽은 행을 확인합니다.
존재와 부재의 표현
답글이 후보 댓글을 부모로 참조하는지 NOT EXISTS로 확인합니다.
게시된 글이 있는 활성 태그는 EXISTS로 확인해 오른쪽 연결 행 때문에 태그가 반복되지 않게 합니다.
-- 어떤 답글의 부모로도 참조되지 않는 댓글
SELECT candidate.id, candidate.content
FROM ch6_exists_comment_demo AS candidate
WHERE NOT EXISTS (
SELECT 1
FROM ch6_exists_comment_demo AS reply
WHERE reply.parent_id = candidate.id
)
ORDER BY candidate.id;
-- 게시된 글이 하나라도 연결된 활성 태그
SELECT t.id, t.code, t.name
FROM ch6_exists_tag_demo AS t
WHERE t.is_active = TRUE
AND EXISTS (
SELECT 1
FROM ch6_exists_post_tag_demo AS pt
JOIN ch6_exists_post_demo AS p ON p.id = pt.post_id
WHERE pt.tag_id = t.id
AND p.status = 'PUBLISHED'
)
ORDER BY t.code, t.id;NULL 허용 부모 값은 부재 판단을 오염시키지 않고, 태그는 게시글 수와 관계없이 한 행씩만 반환됩니다.
개선 실행 결과result | id | code/content
no reply | 3 | 답글 없는 댓글
no reply | 4 | 더 이상 답글 없는 댓글
published tag | 1 | databaseEXISTS 안에서 바깥 별칭을 빠뜨리면 모든 태그에 대해 같은 독립 결과를 재사용해 전부 나오거나 전부 빠질 수 있습니다.
상관 조건의 양쪽 키를 소리 내어 읽습니다.
오른쪽 열이 최종 SELECT에 필요하면 JOIN이 자연스럽고, 존재만 필요하면 EXISTS가 행 증가를 만들지 않아 의도가 명확합니다.
EXISTS와 JOIN 비교
같은 업무 질문을 EXISTS와 JOIN으로 작성해 ID 집합이 같은지 확인하고, 실행 계획에서 실제 전략은 별도로 관찰합니다.
WITH exists_result AS (
SELECT t.id AS tag_id
FROM ch6_exists_tag_demo AS t
WHERE EXISTS (
SELECT 1
FROM ch6_exists_post_tag_demo AS pt
JOIN ch6_exists_post_demo AS p ON p.id = pt.post_id
WHERE pt.tag_id = t.id
AND p.status = 'PUBLISHED'
)
),
join_result AS (
SELECT DISTINCT t.id AS tag_id
FROM ch6_exists_tag_demo AS t
JOIN ch6_exists_post_tag_demo AS pt ON pt.tag_id = t.id
JOIN ch6_exists_post_demo AS p ON p.id = pt.post_id
WHERE p.status = 'PUBLISHED'
)
SELECT
(SELECT COUNT(*) FROM exists_result) AS exists_count,
(SELECT COUNT(*) FROM join_result) AS join_count,
(SELECT COUNT(*) FROM exists_result e
LEFT JOIN join_result j USING (tag_id)
WHERE j.tag_id IS NULL) AS only_exists;
EXPLAIN ANALYZE
SELECT t.id
FROM ch6_exists_tag_demo AS t
WHERE EXISTS (
SELECT 1
FROM ch6_exists_post_tag_demo AS pt
JOIN ch6_exists_post_demo AS p ON p.id = pt.post_id
WHERE pt.tag_id = t.id
AND p.status = 'PUBLISHED'
);두 건수가 같고 only_exists가 0이어야 의미가 같습니다.
계획에서는 posts 접근 인덱스, 실제 행 수와 반복 횟수를 확인하되 특정 계획 이름을 영구 보장으로 기록하지 않습니다.
SELECT 절의 상관 스칼라 서브쿼리는 바깥 행마다 계산값을 붙일 수 있지만 같은 테이블을 여러 번 읽기 쉬워집니다.
회원별 최근 게시글 시각처럼 한 값이 필요하다면 집계 서브쿼리 JOIN, 윈도 함수, 상관 스칼라 중 결과와 비용을 비교합니다.
FROM의 파생 테이블은 먼저 결과 집합을 정의해 이름 붙일 수 있지만 MySQL이 항상 구체화하는 것은 아닙니다.
옵티마이저가 병합할 수 있으므로 논리적 단계와 물리적 임시 저장을 같은 뜻으로 보지 않습니다.
부모 ID가 NULL인 댓글을 넣고 NOT IN, NULL 제외 NOT IN, NOT EXISTS 세 쿼리의 결과를 비교하세요.
이어서 게시된 글이 0·1·3건 연결된 태그를 만들어 EXISTS 결과는 태그당 최대 한 행임을 확인합니다.
상관 조건을 일부러 제거했을 때 모든 후보가 같은 결과를 받는 실수도 재현합니다.
SELECT 1을 SELECT s.title으로 바꾸어도 EXISTS 결과가 같다는 점에서 열 값이 소비되지 않음을 확인합니다.
존재 검사는 권한 필터에서 자주 사용됩니다.
테넌트·소유자 조건을 안쪽 상관 쿼리에 빠뜨리면 다른 사용자의 관계 행 때문에 접근이 허용될 수 있습니다.
반대로 소프트-삭제나 유효 기간 조건이 빠지면 이미 종료된 관계도 존재한다고 판단합니다.
EXISTS 조건식에는 단순 FK뿐 아니라 현재 유효한 관계의 전체 정의를 포함하고, 복합 조건을 지원할 인덱스를 측정합니다.
보안 관련 NOT EXISTS는 NULL을 기본 허용으로 해석하지 않고 미통과-닫힌 결과를 별도 테스트합니다.
NOT EXISTS는 안쪽 결과가 0행일 때 TRUE입니다.
기준 날짜가 시간대에 따라 달라지는 ‘최근 30일’은 서버 CURRENT_DATE를 그대로 쓰기 전에 업무 시간대를 정합니다.
NULL 허용 상관 키에서 child.key = parent.key는 NULL끼리도 TRUE가 아니므로 NULL을 같은 그룹으로 볼지 별도 조건이 필요합니다.
존재하는 자식 중 하나의 열을 출력하려면 EXISTS와 무관한 임의 값을 선택하지 말고 원하는 행의 순위·집계 기준을 명시합니다.
이 문서의 세미·안티 조인 의미는 인덱스 장과 삭제 수명주기 장에서 다시 사용됩니다.
‘활성 태그 중 게시글 있음’은 태그의 활성 조건과 post_tags, posts의 관계 조건을 분리해 각 인덱스 후보를 설명합니다.
삭제 검증에서는 부모를 지우기 전에 NOT EXISTS로 참조 부재를 확인하더라도 동시 삽입과의 경쟁이 있으므로 FK가 최종 무결성을 지켜야 합니다.
SQL 조회 판단과 스키마 제약의 역할을 서로 대신시키지 않습니다.
통계 장에서는 NOT EXISTS로 누락 차원을 찾고, LEFT JOIN의 NULL 확장 결과와 같은 ID 집합인지 일치 여부를 확인합니다.
EXISTS·JOIN·IN 선택 기준
| 판단 축 | 확인할 질문 |
|---|---|
| 출력 | 오른쪽 열이 필요한가, 관계 존재만 필요한가? |
| 상관 | 안쪽 FK와 바깥 PK를 연결하는 조건이 명시됐는가? |
| NULL | NOT IN 목록의 NULL 허용 가능성을 제거하거나 NOT EXISTS를 썼는가? |
| 범위 | 상태·테넌트·유효 기간이 존재 정의에 포함됐는가? |
| 계획 | 실제 행 수·반복 횟수·인덱스를 대표 데이터로 측정했는가? |
자연어가 ‘하나라도 있는’ 또는 ‘하나도 없는’이면 EXISTS를 먼저 검토합니다.
오른쪽 속성을 표시하거나 행을 펼치는 요구가 생길 때 JOIN으로 바꾸고 행 증가를 다시 계산합니다.
연습 문제
활성 태그 중 기준 시각 2026-07-15 00:00:00까지 최근 30일 동안 게시된 글이 하나도 연결되지 않은 태그를 찾으세요.
태그 ID·코드·이름을 보여 주고 코드 순으로 정렬합니다.
오른쪽 게시글 열은 결과에 필요하지 않으며 NULL 가능한 NOT IN은 사용하지 마세요.
해설과 예시 답안
태그가 바깥 후보이고, 같은 tag_id·게시 상태·반열린 시간 범위를 만족하는 연결 행의 부재를 NOT EXISTS로 묻습니다.
실행 날짜에 따라 답이 바뀌지 않도록 기준 시각을 CTE에 고정합니다.
WITH params AS (
SELECT CAST('2026-07-15 00:00:00' AS DATETIME) AS as_of
)
SELECT t.id, t.code, t.name
FROM ch6_exists_tag_demo AS t
CROSS JOIN params
WHERE t.is_active = TRUE
AND NOT EXISTS (
SELECT 1
FROM ch6_exists_post_tag_demo AS pt
JOIN ch6_exists_post_demo AS p ON p.id = pt.post_id
WHERE pt.tag_id = t.id
AND p.status = 'PUBLISHED'
AND p.created_at >= params.as_of - INTERVAL 30 DAY
AND p.created_at < params.as_of + INTERVAL 1 DAY
)
ORDER BY t.code, t.id;고정 fixture에서는 java, empty 두 태그가 남아야 합니다.
운영 쿼리에서는 고정값 대신 애플리케이션이 정한 업무 시간대의 기준 시각을 매개변수로 전달합니다.
핵심 정리
- EXISTS는 안쪽 값이 아니라 상관 조건을 만족하는 행 존재를 판단합니다.
- NOT EXISTS는 NULL 허용 목록의 NOT IN보다 관계 부재를 직접 표현합니다.
- 존재만 필요하면 세미 조인 의미로 행 증가 없이 왼쪽 후보를 보존합니다.
- 상관 서브쿼리의 SQL 의미와 옵티마이저의 물리 실행 전략을 구분합니다.
다음 문서에서는 같은 열 모양의 결과 집합을 UNION으로 세로 결합합니다.