숏컷 코드
SELECT u.id, u.name, p.title
FROM users AS u
LEFT JOIN posts AS p ON p.user_id = u.id;문법
LEFT JOIN은 "없는 관계도 의미 있는 데이터"임을 선언한다
INNER JOIN은 양쪽 테이블에 모두 매칭 행이 있을 때만 결과를 만듭니다. LEFT JOIN은 왼쪽 테이블의 모든 행을 유지하되, 오른쪽에 대응되는 행이 없으면 오른쪽 컬럼 전체를 NULL로 채웁니다. "아직 글을 쓰지 않은 사용자도 목록에 포함해야 한다"처럼 관계의 부재 자체가 비즈니스 의미를 가질 때 LEFT JOIN이 옳은 선택입니다. 실제 실행 계획은 데이터 양과 정렬 상태에 따라 Nested Loop, Hash Join, Merge Join 계열 중에서 결정됩니다.
-- 글을 한 번도 쓰지 않은 사용자 포함한 전체 목록
SELECT u.name, COUNT(p.id) AS post_count
FROM users AS u
LEFT JOIN posts AS p ON p.user_id = u.id
GROUP BY u.id, u.name
ORDER BY post_count DESC;LEFT JOIN은 "왼쪽은 유지하고 오른쪽이 없으면 NULL"로 읽으면 된다
입문 단계에서는 LEFT JOIN을 "왼쪽 목록을 기준으로 유지한다"라고 이해하면 됩니다. 오른쪽에 매칭이 없을 때 NULL이 나오는 건 오류가 아니라, 관계가 없다는 정보입니다.
SELECT u.id, u.name, p.title
FROM users AS u
LEFT JOIN posts AS p ON p.user_id = u.id;오른쪽 테이블의 NULL은 JOIN 실패가 아니라 관계 부재 신호다
LEFT JOIN 결과에서 오른쪽 컬럼이 NULL이면 쿼리가 틀린 게 아니라 "해당 사용자에게 연결된 행이 없다"는 정보입니다. 이 NULL을 활용하면 "연결 없는 행만 필터링"하는 안티조인 패턴을 표현할 수 있습니다. 이 패턴은 NOT EXISTS로도 표현 가능하며, 실제로 어느 쪽이 더 읽기 쉽고 실행 계획이 안정적인지는 데이터 분포를 두고 EXPLAIN ANALYZE로 확인하는 편이 안전합니다.
-- 한 번도 글을 쓰지 않은 사용자만 추출 (안티조인 패턴)
SELECT u.id, u.name
FROM users AS u
LEFT JOIN posts AS p ON p.user_id = u.id
WHERE p.id IS NULL;ON 조건과 WHERE 조건의 위치가 LEFT JOIN 의미를 바꾼다
LEFT JOIN 뒤에 오른쪽 테이블의 필터 조건을 WHERE 절에 두면 NULL 행이 걸러져 사실상 INNER JOIN처럼 동작합니다. "오른쪽 테이블의 특정 행 중 조건을 만족하는 것"과만 연결하려면 해당 조건을 ON 절 안에 넣어야 합니다. 이 차이는 논리 정확성 문제이므로 실행 계획보다 먼저 짚어야 합니다.
-- 잘못된 패턴: WHERE 조건이 LEFT JOIN을 INNER JOIN으로 만듦
SELECT u.name, p.title
FROM users AS u
LEFT JOIN posts AS p ON p.user_id = u.id
WHERE p.published = true; -- NULL 행이 제거됨
-- 올바른 패턴: 오른쪽 테이블 조건은 ON 절에
SELECT u.name, p.title
FROM users AS u
LEFT JOIN posts AS p
ON p.user_id = u.id AND p.published = true;관계 부재 확인과 실제 값 확인은 같은 NULL이라도 읽는 기준이 다르다
LEFT JOIN 결과에서 오른쪽 값이 NULL이라고 해서 항상 "관계가 없다"는 뜻은 아닙니다. 정말 관계 부재를 확인하려면 오른쪽 기본 키나 NOT NULL이 보장된 기준 열을 봐야 하고, 실제 데이터 컬럼의 NULL은 "연결은 됐지만 값이 비어 있다"일 수 있습니다.
SELECT u.id, p.id AS post_id, p.deleted_at
FROM users AS u
LEFT JOIN posts AS p ON p.user_id = u.id;여러 LEFT JOIN 은 인덱스 설계와 함께 봐야 한다
LEFT JOIN 의 오른쪽 테이블 조인 키(p.user_id)에 인덱스가 없으면 대용량 테이블에서 Sequential Scan 이 발생합니다. EXPLAIN ANALYZE 로 Nested Loop 안쪽에 Index Scan 이 보이는지 확인하고, 없다면 조인 컬럼에 인덱스를 추가해야 합니다. 여러 테이블을 연속으로 LEFT JOIN 할 때는 중간 결과 크기 순서도 계획에 영향을 줍니다.
-- 조인 키 인덱스 확보
CREATE INDEX idx_posts_user_id ON posts (user_id);
-- 실행 계획 확인
EXPLAIN ANALYZE
SELECT u.name, p.title
FROM users AS u
LEFT JOIN posts AS p ON p.user_id = u.id
WHERE u.active = true;체크포인트
| 상황 | 적합한 선택 |
|---|---|
| 양쪽 모두 존재하는 행만 필요할 때 | INNER JOIN |
| 왼쪽 행을 모두 유지해야 할 때 | LEFT JOIN |
| 오른쪽과 연결 없는 행만 추출할 때 | LEFT JOIN ... WHERE 오른쪽.col IS NULL |
| 오른쪽 테이블을 조건으로 필터링할 때 | 조건을 ON 절 안에 배치 |
| 조인 성능이 느릴 때 | 오른쪽 테이블 조인 컬럼에 인덱스 추가 |
주의할 점
LEFT JOIN 뒤에 오른쪽 테이블 조건을 WHERE 절에 두면 NULL 행이 제거되어 사실상 INNER JOIN처럼 동작합니다.
"없는 행도 유지한다"는 의도라면 오른쪽 테이블 필터는 반드시 ON 절 안에 넣어야 합니다.
또한 오른쪽 테이블의 조인 컬럼에 인덱스가 없으면 대규모 데이터에서 전체 스캔이 발생할 수 있습니다.
SELECT u.name
FROM users AS u
LEFT JOIN posts AS p ON p.user_id = u.id
WHERE p.title IS NULL;p.title IS NULL은 "글이 없음"과 "글은 있는데 제목이 NULL"을 구분하지 못합니다. 관계 부재를 보려면 보통 오른쪽 기본 키 같은 NOT NULL 기준 열을 검사하는 편이 맞습니다.
SELECT u.id, u.name
FROM users AS u
LEFT JOIN posts AS p
ON p.user_id = u.id
WHERE p.id IS NULL
AND u.active = true;안티조인 패턴은 "오른쪽에 행이 아예 없다"를 보는 용도입니다. 여기에 오른쪽 컬럼 조건을 더 얹고 싶다면, 그 조건도 대개 ON 절에서 먼저 좁혀 놓아야 의도와 실제 결과가 어긋나지 않습니다.
참고 링크
1 sources