본문 바로가기

EXISTS와 상관 서브쿼리

바깥 행을 참조하는 상관 서브쿼리와 EXISTS, NOT EXISTS로 관계가 있는지와 없는지를 묻습니다. NULL이 섞인 목록에 NOT IN을 쓰면 결과가 비는 이유, 행을 늘리지 않는 세미 조인과 안티 조인, 상관 조건을 빠뜨렸을 때의 오류와 권한 필터에서 주의할 점을 다룹니다.

목록의 값을 가져오는 대신 "관련된 행이 하나라도 있는가"만 묻는 질문이 많습니다. 공개 글이 있는 태그, 아직 답글이 없는 댓글을 찾을 때는 결과에 오른쪽 테이블의 열이 필요하지 않습니다. EXISTS는 안쪽 행이 있는지를 참과 거짓의 조건으로 바꾸며, 상관 조건이 그 관계의 방향을 정합니다.

5장 2편에서는 회원과 게시글, 연결 테이블, 태그의 외래 키 경로를 JOIN으로 이었습니다. 이 글에서는 바깥 후보의 행마다 안쪽 테이블에 대응하는 행이 있는지를 검사하고, NULL을 허용하는 댓글의 parent_id로 NOT IN과 NULL이 만났을 때 생기는 대표적인 오류도 확인합니다.

EXISTS 예제 데이터

NULL이 결과를 바꾸는 상황을 같은 데이터로 재현하도록 댓글 네 개와 태그 네 개를 고정합니다. 댓글 3과 4는 어떤 답글의 부모도 아니고, 활성 태그 가운데 공개 글이 연결된 것은 database뿐입니다.

ch6-2 EXISTS fixture
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을 써 봅니다. 아래 쿼리는 같은 댓글 테이블에서 parent_id를 모두 모아 목록을 만들고, 그 목록에 자기 id가 없는 댓글을 고르려 합니다. 최상위 댓글은 parent_id가 NULL이므로, 이 목록에는 NULL도 섞여 들어갑니다.

nullable 목록을 사용한 NOT IN
SELECT id, content
FROM ch6_exists_comment_demo
WHERE id NOT IN (
  SELECT parent_id
  FROM ch6_exists_comment_demo
)
ORDER BY id;

답은 댓글 3과 4의 두 행이어야 하지만, 결과는 빈 집합입니다. 안쪽 목록은 NULL, 1, NULL, 2인데, 목록 안의 NULL은 "어떤 값인지 모름"을 뜻하므로 3이 목록의 모든 값과 다르다고 확정할 수 없습니다. 3장 4편에서 본 것처럼 3 NOT IN (1, NULL, 2)는 (3 <> 1) AND (3 <> NULL) AND (3 <> 2)이고, 가운데의 알 수 없음 때문에 전체가 알 수 없음이 되어 WHERE를 통과하지 못합니다.

문법 오류가 없이 결과만 비어 있어서 더 알아채기 어려운 오류입니다. 안쪽에 WHERE parent_id IS NOT NULL을 더해도 되지만, "관계가 없다"는 질문 자체는 NOT EXISTS가 더 직접적이고 NULL을 허용하는 값의 영향도 받지 않습니다.

NULL · NOT IN · NOT EXISTS

답글 없는 댓글: NOT IN과 NOT EXISTS의 차이

같은 네 댓글에서 NULL이 섞인 값 목록은 0행, 상관 조건으로 확인한 관계 부재는 댓글 3·4를 반환합니다.

댓글의 실제 부모 참조와 들어오는 답글댓글2에서 부모1로, 댓글4에서 부모2로 화살표가 향한다. 댓글3에는 연결이 없다. 들어오는 답글 참조가 없는3과4를 NOT EXISTS가 남긴다. 댓글4에게 부모2가 있다는 것은 제외 조건이 아니다.댓글 1parent: NULL자식 2 있음댓글 2parent: 1자식 4 있음댓글 4parent: 2자식 없음 → 반환댓글 3parent: NULL자식 없음 → 반환부모 참조부모 참조
좁은 화면의 같은 두 부모 참조데스크톱과 같은4댓글과2참조이다. 댓글2의 참조는1로, 댓글4의 참조는2로 향한다. 댓글3과4로 들어오는 자식 참조가 없어서 결과에 남는다. 세로 나열은 작성 순서가 아니다.댓글 1parent: NULL자식 2 있음댓글 2parent: 1자식 4 있음댓글 4parent: 2자식 없음 → 반환댓글 3parent: NULL자식 없음 → 반환부모 1부모 2

화살표는 답글에서 참조하는 부모로 향합니다. 질문은 부모가 없는 댓글이 아니라, 자신을 부모로 참조하는 답글이 없는 댓글입니다.

같은 데이터, 같은 질문: 내 댓글에 답글이 없는가?

id → parent_id: 1 → NULL · 2 → 1 · 3 → NULL · 4 → 2

2번은 1번에, 4번은 2번에 달린 답글입니다. 답글이 없는 댓글은 3·4번이며, 후보 id는 모두 NOT NULL입니다.

NOT IN · 목록의 모든 값과 다른가?

candidate.id NOT IN (NULL, 1, NULL, 2)

3번은 1·2와 다르지만 NULL과의 비교는 UNKNOWN입니다. 모든 비교를 AND로 묶으면 TRUE AND UNKNOWN → UNKNOWN이 됩니다.

반환 id: 없음 (0행)

NOT EXISTS · 대응하는 답글이 0행인가?

안쪽 조건: reply.parent_id = candidate.id

3번을 참조하는 답글은 0행이므로 TRUE입니다. NULL 비교는 안쪽 WHERE를 통과하지 않아 대응 행으로 세지 않습니다.

반환 id: 3, 4 (2행)

후보별 결과 · WHERE는 TRUE만 남깁니다

고정된 네 댓글에서 NOT IN과 NOT EXISTS의 진리값 및 반환 여부
후보 댓글NOT INNOT EXISTS
1·2번 · 답글 있음FALSE → 제외FALSE → 제외
3·4번 · 답글 없음UNKNOWN → 제외TRUE → 반환
1·2번 · 답글 있음

NOT IN: FALSE → 제외

NOT EXISTS: FALSE → 제외

3·4번 · 답글 없음

NOT IN: UNKNOWN → 제외

NOT EXISTS: TRUE → 반환

NOT IN도 안쪽에서 parent_id IS NOT NULL로 NULL을 제거하면 이 데이터에서는 3·4번을 반환합니다. “대응 행이 없다”는 질문은 NOT EXISTS의 상관 조건으로 직접 표현할 수 있습니다.

EXISTS와 상관 조건

EXISTS의 안쪽 SELECT는 행을 하나라도 찾으면 참이 되고, 그 행의 열 값은 쓰지 않습니다. 그래서 SELECT 1로 적어 그 의도를 드러냅니다. 안쪽의 WHERE reply.parent_id = candidate.id처럼 바깥의 현재 행과 안쪽의 후보를 잇는 조건을 상관 조건(correlation condition)이라 하고, 이런 서브쿼리를 상관 서브쿼리(correlated subquery)라 합니다.

EXISTS는 오른쪽 행을 결과로 펼치지 않고 왼쪽 후보만 남기는 세미 조인으로, NOT EXISTS는 대응하는 행이 없는 왼쪽 후보만 남기는 안티 조인으로 읽을 수 있습니다. EXISTS와 NOT EXISTS는 표준 SQL입니다.

"상관 서브쿼리는 언제나 바깥 행의 수만큼 반복되어 느리다"고 단정하지는 않습니다. MySQL 8.4의 옵티마이저는 세미 조인으로의 변환, 구체화, 인덱스 조회 같은 방식으로 실제 실행을 바꿀 수 있습니다. SQL의 뜻과 물리적인 계획을 나누어 보고, 실제 반복 횟수와 읽은 행은 EXPLAIN ANALYZE로 확인합니다.

더 깊이: NOT EXISTS와 NOT IN이 안티 조인이 되는 조건

MySQL 8.0.17부터 옵티마이저는 NOT EXISTS (SELECT ...)와 NOT IN (SELECT ...), 그리고 IN (SELECT ...) IS NOT TRUE나 EXISTS (SELECT ...) IS FALSE 같은 형태를 안티 조인(antijoin)으로 바꿉니다. 짝이 없는 바깥 행만 돌려주는 조인 연산으로 바꾸어 두면, 세미 조인처럼 조인의 순서와 실행 전략을 고를 수 있게 됩니다. 이 변환도 optimizer_switch의 semijoin 플래그를 따르며, 해시 조인으로 실행되면 EXPLAIN FORMAT=TREE에 Hash antijoin으로 나타납니다.

다만 MySQL 8.4 매뉴얼은 비교하는 식이 NULL일 수 있으면 대부분의 경우 안티 조인으로 바꿀 수 없다고 적습니다. 앞 절에서 본 것처럼 NULL이 섞이면 NOT IN과 NOT EXISTS의 결과가 달라지므로, 두 형태를 같은 연산으로 다룰 수 없기 때문입니다. 그래서 상관 조건과 NOT IN에 쓰는 열을 NOT NULL로 선언해 두면, 결과의 뜻이 분명해질 뿐 아니라 옵티마이저가 고를 수 있는 실행 방식도 넓어집니다. 옵티마이저가 쿼리를 어떻게 바꾸었는지는 EXPLAIN을 실행한 직후 SHOW WARNINGS로 변환된 쿼리를 보면 알 수 있습니다.

존재와 부재를 나타낸다

답글이 후보 댓글을 부모로 가리키는지 NOT EXISTS로 확인하고, 공개 글이 있는 활성 태그는 EXISTS로 확인해 연결 행 때문에 태그가 되풀이되지 않게 합니다. 첫 쿼리는 앞 절의 상관 조건 reply.parent_id = candidate.id를 그대로 써서, 후보 댓글마다 그 댓글을 부모로 가리키는 답글이 하나라도 있는지 묻습니다. 둘째 쿼리의 EXISTS는 안쪽에서 연결 테이블과 게시글을 JOIN해 공개 글만 남기지만, 바깥으로는 태그의 열만 돌려줍니다.

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;
id | content
3  | 답글 없는 댓글
4  | 더 이상 답글 없는 댓글

id | code     | name
1  | database | 데이터베이스

NOT EXISTS는 NULL을 허용하는 부모 값에 흔들리지 않고 댓글 3과 4를 찾았습니다. java 태그에는 초안만, empty 태그에는 아무 글도 연결되지 않았으므로 EXISTS를 통과한 활성 태그는 database 하나이며, 연결된 글이 여러 개였더라도 태그는 한 행만 나옵니다.

EXISTS 안에서 바깥 별칭을 빠뜨리면 안쪽 쿼리가 바깥 행과 상관없는 독립된 질문이 되어, 모든 후보가 같은 결과를 받습니다. 그래서 상관 조건의 양쪽 키를 소리 내어 읽어 봅니다. 오른쪽의 열이 최종 SELECT에 필요하면 JOIN이 자연스럽고, 관계가 있는지만 필요하면 행을 늘리지 않는 EXISTS가 의도를 분명히 합니다.

EXISTS와 JOIN을 비교한다

같은 질문을 EXISTS와 JOIN으로 써서 id의 집합이 같은지 확인하고, 실제의 실행 방식은 따로 살펴봅니다. 아래에서 exists_result와 join_result는 같은 질문의 두 표현이고, JOIN 쪽은 연결 행만큼 태그가 되풀이될 수 있어 DISTINCT를 붙였습니다. 마지막 SELECT는 두 집합의 건수와, EXISTS 쪽에만 있는 id의 수(only_exists)를 한 행으로 보여 줍니다.

존재 결과 집합과 실행 계획 확인
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'
);
exists_count | join_count | only_exists
2            | 2          | 0

두 건수가 같고 only_exists가 0이므로 두 쿼리의 뜻이 같습니다. 이 비교에는 is_active = TRUE가 없어서, 공개 글이 연결된 태그 1과 비활성인 태그 3을 모두 셉니다. 바로 앞의 활성 태그 조회가 한 행이었던 것과 모순되지 않으며, 두 쿼리는 활성 조건이 있는지가 다릅니다.

EXPLAIN ANALYZE의 출력은 MySQL의 버전과 통계, 데이터에 따라 달라지므로 교재에 옮기지 않습니다. 계획에서는 게시글에 접근하는 인덱스, 실제 행의 수와 반복 횟수를 보되, 특정한 계획의 이름을 영구적으로 보장되는 것처럼 기록하지 않습니다.

SELECT 목록에 둔 상관 스칼라 서브쿼리는 바깥 행마다 계산한 값을 붙일 수 있지만, 같은 테이블을 여러 번 읽기 쉬워집니다. 회원별 최근 게시 시각처럼 값 하나가 필요하다면, 집계한 서브쿼리와의 JOIN이나 윈도 함수, 상관 스칼라 서브쿼리의 결과와 비용을 비교합니다. FROM의 파생 테이블도 결과 집합에 이름을 붙여 단계를 나누지만, MySQL이 언제나 따로 저장해 두는 것은 아니며 바깥 쿼리와 합칠 수도 있습니다.

존재 검사에서 따질 것

존재 검사는 권한 필터에 자주 쓰입니다. 테넌트나 소유자의 조건을 안쪽의 상관 쿼리에 빠뜨리면 다른 사용자의 관계 행 때문에 접근이 허용될 수 있고, 소프트 삭제나 유효 기간의 조건이 빠지면 이미 끝난 관계도 있다고 판단합니다. EXISTS의 조건에는 단순한 외래 키뿐 아니라 지금 유효한 관계의 정의 전체를 넣고, 그 복합 조건을 돕는 인덱스를 측정합니다. 보안과 관련된 NOT EXISTS는 확인하지 못한 경우를 허용으로 해석하지 않는지 따로 시험합니다.

NOT EXISTS는 안쪽 결과가 0행일 때 참입니다. "최근 30일" 같은 조건은 서버의 CURRENT_DATE를 그대로 쓰기 전에 업무의 시간대를 정합니다. NULL을 허용하는 키를 상관 조건으로 쓰면 child.key = parent.key는 NULL끼리도 참이 아니므로, NULL을 같은 그룹으로 볼지 정하고 그에 맞는 조건을 따로 씁니다.

있는 자식 가운데 한 행의 열을 보여 주어야 한다면, EXISTS와 관계없이 아무 값이나 고르지 말고 원하는 행의 순위나 집계 기준을 분명히 적습니다. 부모를 지우기 전에 NOT EXISTS로 참조가 없음을 확인하더라도, 그사이 다른 연결이 자식을 넣을 수 있으므로 최종적인 무결성은 외래 키가 지켜야 합니다. 조회로 하는 판단과 스키마의 제약이 서로를 대신하게 하지 않습니다.

따져 볼 것질문
출력오른쪽의 열이 필요한가, 관계가 있는지만 필요한가
상관안쪽 외래 키와 바깥 기본 키를 잇는 조건을 적었는가
NULLNOT IN 목록에 NULL이 들어올 수 없게 했거나 NOT EXISTS를 썼는가
범위상태, 테넌트, 유효 기간이 존재의 정의에 들어갔는가
계획실제 행 수와 반복 횟수, 인덱스를 대표 데이터로 측정했는가

요구 사항이 "하나라도 있는"이나 "하나도 없는"이라면 EXISTS부터 검토합니다. 오른쪽의 속성을 보여 주거나 행을 펼쳐야 하는 요구가 생기면 JOIN으로 바꾸고, 행이 늘어나는지 다시 계산합니다.

정리

  • EXISTS는 안쪽의 값이 아니라, 상관 조건을 만족하는 행이 있는지를 판단합니다.
  • NULL이 섞인 목록에 NOT IN을 쓰면 결과가 빌 수 있으므로, 관계가 없음은 NOT EXISTS로 나타냅니다.
  • 존재만 필요하면 EXISTS가 세미 조인처럼 행을 늘리지 않고 왼쪽 후보를 남깁니다.
  • 상관 서브쿼리의 뜻과 옵티마이저가 고르는 실제 실행 방식을 구별합니다.

연습 문제

1. 최근 30일 동안 공개 글이 없는 활성 태그

활성 태그 가운데, 기준 시각 2026-07-15 00:00:00 직전 30일 동안 작성되었고 지금 공개 상태인 글이 하나도 연결되지 않은 태그를 찾아 보세요. 태그의 id와 코드, 이름을 보여 주고 코드의 순서로 정렬합니다. 오른쪽 게시글의 열은 필요 없으며, NULL이 들어올 수 있는 NOT IN은 쓰지 않습니다.

해설 보기

태그가 바깥의 후보이고, 같은 tag_id를 가지며 공개 상태와 반열린 시간 범위를 만족하는 연결 행이 없는지를 NOT EXISTS로 묻습니다. 실행하는 날짜에 따라 답이 바뀌지 않도록 기준 시각을 CTE에 고정하며, 범위는 2026-06-15 00:00:00 이상 2026-07-15 00:00:00 미만입니다.

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
  )
ORDER BY t.code, t.id;
id | code  | name
4  | empty | 게시글 없음
2  | java  | Java

database는 7월 1일의 공개 글이 범위 안에 있어 빠졌고, java는 초안만 있어서, empty는 연결된 글이 없어서 남았습니다. 운영에서는 고정값 대신, 애플리케이션이 업무의 시간대로 정한 기준 시각을 파라미터로 넘깁니다.

2. 부재를 묻는 세 쿼리

답글이 없는 댓글을 (가) 본문의 NOT IN, (나) 안쪽에 WHERE parent_id IS NOT NULL을 더한 NOT IN, (다) NOT EXISTS로 각각 찾으면 결과는 어떻게 다른가요?

해설 보기

(가)는 목록에 NULL이 섞여 있어 빈 집합입니다. (나)는 목록이 1과 2만 남으므로 댓글 3과 4를 찾고, (다)도 같은 두 행을 찾습니다. (나)와 (다)는 지금 결과가 같지만, (나)는 안쪽 열에 NULL이 들어올 수 있다는 것을 기억하고 조건을 더해야 맞고 (다)는 그런 조건 없이도 맞습니다. 관계가 없음을 묻는 질문에서 NOT EXISTS를 기본으로 삼는 이유입니다.

3. 상관 조건을 지우면

본문의 활성 태그 조회에서 pt.tag_id = t.id만 지웠다고 해 봅시다. 공개 상태와 활성 상태의 조건은 그대로 남아 있습니다. 결과에 어떤 태그가 나올지 예측해 보세요.

해설 보기

활성 태그 세 개인 database, empty, java가 모두 나옵니다. 상관 조건이 없으면 안쪽 쿼리는 "어딘가에 연결된 공개 글이 있는가"라는 바깥과 무관한 질문이 되고, 공개 글 1과 3이 연결 행을 가지므로 태그가 무엇이든 참이 됩니다. "공개 글이 있다"와 "지금 이 태그에 연결된 공개 글이 있다"는 서로 다른 질문이며, 그 연결을 pt.tag_id = t.id가 만듭니다. 안쪽의 SELECT 1을 SELECT p.title로 바꾸어도 결과가 같다는 것으로, EXISTS가 열의 값을 쓰지 않는다는 것도 확인할 수 있습니다.

다음 글 UNION과 집합 결합에서는 열의 모양이 같은 결과 집합을 UNION으로 세로로 합치는 방법을 다룹니다.