SQL JOIN 실수 패턴 | LEFT JOIN과 WHERE, 중복 행, NOT IN NULL, EXISTS
작업일2026. 07

JOIN 문법은 금방 익숙해지는데, 결과 행 수가 예상과 다르게 나오는 일은 계속 생깁니다. 오류가 안 나고 결과만 조용히 틀리기 때문에 찾기도 어렵습니다.
LEFT JOIN을 걸어놓고 WHERE로 조건을 하나 붙였는데, 왜 결과가 줄어들까요?그 순간
LEFT JOIN이 INNER JOIN으로 바뀌었기 때문입니다.문법이 틀린 것이 아니라 같은 문법이 다른 결과를 내는 자리가 몇 군데 있습니다. 전부 실행은 잘 되고 오류도 없어서, 알고 있지 않으면 그냥 지나갈 수 밖에 없죠.
ON 절과 WHERE 절
모든 학생을 보여주되 CS101 성적만 함께 보고 싶다고 해보겠습니다. 수강하지 않은 학생도 목록에는 있어야 합니다.
-- 의도와 다른 결과
SELECT s.name, e.score
FROM students s
LEFT JOIN enrollments e ON e.student_id = s.id
WHERE e.course_id = 1; -- 수강 안 한 학생이 사라집니다
수강하지 않은 학생은 e.course_id가 NULL입니다. 그런데 WHERE는 조인이 끝난 뒤에 행을 거르므로, NULL = 1이 참이 아니라서 그 학생들이 통째로 빠집니다. LEFT JOIN으로 남겨 둔 행이 WHERE 절에서 다시 제거된 것입니다.
-- 의도대로
SELECT s.name, e.score
FROM students s
LEFT JOIN enrollments e
ON e.student_id = s.id
AND e.course_id = 1; -- 조인 조건에 넣습니다
ON에 넣으면 조인하는 과정에서 조건이 적용됩니다. CS101을 안 들은 학생은 짝을 못 찾을 뿐 행은 남고, score만 NULL로 채워집니다.
WHERE 가 맞습니다. OUTER JOIN 결과를 유지하면서 조건을 걸고 싶을 때만 ON 에 넣습니다.예외가 하나 있습니다. WHERE e.id IS NULL 처럼 짝이 없는 행만 찾는 경우는 WHERE에 두는 것이 맞습니다. 수강한 적 없는 학생만 뽑는 패턴이고, 이걸 Anti-Join 이라고 부릅니다.
결과 행 수 예측
JOIN을 걸기 전에 몇 행이 나올지 먼저 세어보는 습관이 실수를 많이 막아줄 수 있습니다. A가 5행, B가 3행이고 그중 2건이 매칭될 때 종류별로 이렇게 나오게 됩니다.
| JOIN 종류 | 결과 행 수 | NULL이 들어가는 칸 |
|---|---|---|
INNER JOIN |
2행 (매칭된 것만) | 없음 |
LEFT JOIN |
5행 (A 전체) | B 컬럼 (3행이 NULL) |
RIGHT JOIN |
3행 (B 전체) | A 컬럼 |
FULL OUTER JOIN |
6행 (5 + 3 − 2) | 양쪽 비매칭 컬럼 |
CROSS JOIN |
15행 (5 × 3) | 없음 |
Anti-Join (NOT EXISTS) |
3행 (5 − 2) | 없음 |
ON 조건을 빠뜨리면 CROSS JOIN이 됩니다. 5행과 3행이면 15행이라 눈에 띄지만, 10만 행과 10만 행이면 100억 행이 됩니다. JOIN 조건이 빠지면 결과 행 수가 기하급수적으로 늘어나 실행 시간이 크게 증가합니다.
1:N 조인의 중복
고치는 방법은 두 가지입니다. 서브쿼리에서 먼저 집계한 뒤 조인하거나, 값이 하나뿐이라면 DISTINCT로 거릅니다. 합계를 계산하는 상황이라면 앞쪽이 맞습니다. DISTINCT는 실제로 같은 금액의 주문이 여러 건일 경우 그것까지 하나로 합쳐 버릴 수 있기 때문입니다.
SUM(o.amount) 를 하면 5만원이 아니라 15만원이 나옵니다. 주문 금액이 상품 개수만큼 복사됐기 때문입니다.고치는 방법은 두 가지입니다. 서브쿼리에서 먼저 집계한 뒤 조인하거나, 값이 하나뿐이라면 DISTINCT로 거릅니다. 합계를 내는 상황이라면 앞쪽이 맞습니다. DISTINCT는 같은 금액이 진짜로 여러 건일 때 그것까지 지워버립니다.
NOT IN의 NULL 함정
이건 결과가 조금 어긋나는 정도가 아니라 아예 빈 집합이 나옵니다.
-- 서브쿼리 결과에 NULL 이 하나라도 있으면 항상 0건
SELECT name FROM students
WHERE id NOT IN (SELECT student_id FROM enrollments);
enrollments.student_id에 NULL이 한 건이라도 있으면 결과가 무조건 비어버립니다. NOT IN은 내부적으로 id <> 1 AND id <> 2 AND id <> NULL 처럼 풀리는데, NULL과의 비교는 참도 거짓도 아닌 알 수 없음이 되고, AND로 묶이면 전체가 참이 될 수 없기 때문입니다.
-- NOT EXISTS 로 바꾸면 NULL 이 있어도 정상 동작
SELECT s.name
FROM students s
WHERE NOT EXISTS (
SELECT 1 FROM enrollments e WHERE e.student_id = s.id
);
NOT EXISTS를 기본으로 사용하는 습관이 안전합니다. NOT IN은 비교 대상에 NULL이 하나라도 포함되면 예상과 다른 결과가 나올 수 있습니다. 비교 대상이 NOT NULL임을 확실히 알 때만 NOT IN을 사용하는 것이 좋습니다.EXISTS와 Semi-Join
"90점 이상 받은 과목이 하나라도 있는 학생"을 찾는다고 해보겠습니다. JOIN으로 짜면 중복이 생깁니다.
-- 90점 이상 과목 수만큼 학생 이름이 반복됩니다
SELECT DISTINCT s.name
FROM students s
JOIN enrollments e ON e.student_id = s.id
WHERE e.score >= 90;
-- EXISTS: 하나라도 있으면 참. 중복이 생기지 않습니다
SELECT s.name
FROM students s
WHERE EXISTS (
SELECT 1 FROM enrollments e
WHERE e.student_id = s.id AND e.score >= 90
);
오른쪽 테이블의 존재 여부만 확인하고 값은 가져오지 않는 조인을 Semi-Join이라고 합니다. EXISTS와 IN은 많은 DBMS에서 옵티마이저가 이런 형태로 최적화합니다. 이 경우 조건을 만족하는 행을 하나 찾으면 추가 탐색을 생략할 수 있어 불필요한 작업을 줄일 수 있습니다. 또한 결과 자체가 존재 여부만 판단하므로 DISTINCT를 따로 사용할 필요가 없습니다.
IN은 비교 대상이 명확하고 목록이 작을 때 읽기 쉽습니다. 반면 서브쿼리 결과가 크거나 바깥 쿼리의 값을 참조하는 상관 서브쿼리라면 EXISTS가 더 적합한 경우가 많습니다.
정리하면
LEFT JOIN+WHERE우측 조건 =INNER JOIN. OUTER를 유지하려면 조건을ON에 넣습니다- 짝이 없는 행만 찾을 때는
WHERE ... IS NULL이 맞습니다. 이건 Anti-Join 패턴입니다 ON을 빠뜨리면CROSS JOIN입니다. 10만 × 10만이면 쿼리가 끝나지 않습니다- 1:N 조인 뒤의 집계는 부풀려집니다. 서브쿼리에서 먼저 집계하고 조인합니다
NOT IN은NULL하나에 결과가 통째로 비어버립니다.NOT EXISTS를 기본으로 씁니다
ON은 조인할 대상을 고르는 조건이고,
WHERE는 조인이 끝난 결과를 거르는 조건입니다.
이 순서만 기억해도 LEFT JOIN, NULL, 결과 행 수가 왜 달라지는지 대부분 이해할 수 있습니다.