도라의 개발 블로그

소프트웨어 개발/데이터·통계

SQL JOIN 실수 패턴 | LEFT JOIN과 WHERE, 중복 행, NOT IN NULL, EXISTS

boradora 2026. 8. 3. 22:54

작업일2026. 07

JOIN LEFT JOIN NULL EXISTS NOT IN

JOIN 문법은 금방 익숙해지는데, 결과 행 수가 예상과 다르게 나오는 일은 계속 생깁니다. 오류가 안 나고 결과만 조용히 틀리기 때문에 찾기도 어렵습니다.

LEFT JOIN을 걸어놓고 WHERE로 조건을 하나 붙였는데, 왜 결과가 줄어들까요?
그 순간 LEFT JOININNER 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_idNULL입니다. 그런데 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을 안 들은 학생은 짝을 못 찾을 뿐 행은 남고, scoreNULL로 채워집니다.

ON 절에 넣으면
조인하면서 조건을 함께 본다
짝이 없으면 NULL 로 채워 남긴다
LEFT JOIN 이 그대로 유지됩니다
WHERE 절에 넣으면
조인을 먼저 끝낸다
NULL 인 행이 조건에 걸려 지워진다
결과가 INNER JOIN 과 같아집니다
일부러 INNER JOIN 처럼 쓰고 싶을 때는 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는 실제로 같은 금액의 주문이 여러 건일 경우 그것까지 하나로 합쳐 버릴 수 있기 때문입니다.

orders (1건)
주문 101 · 5만원
order_items (3건)
키보드
마우스
모니터
JOIN 결과 (3행)
5만원
5만원
5만원
여기서 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_idNULL이 한 건이라도 있으면 결과가 무조건 비어버립니다. 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이라고 합니다. EXISTSIN은 많은 DBMS에서 옵티마이저가 이런 형태로 최적화합니다. 이 경우 조건을 만족하는 행을 하나 찾으면 추가 탐색을 생략할 수 있어 불필요한 작업을 줄일 수 있습니다. 또한 결과 자체가 존재 여부만 판단하므로 DISTINCT를 따로 사용할 필요가 없습니다.

IN은 비교 대상이 명확하고 목록이 작을 때 읽기 쉽습니다. 반면 서브쿼리 결과가 크거나 바깥 쿼리의 값을 참조하는 상관 서브쿼리라면 EXISTS가 더 적합한 경우가 많습니다.

정리하면

  • LEFT JOIN + WHERE 우측 조건 = INNER JOIN. OUTER를 유지하려면 조건을 ON에 넣습니다
  • 짝이 없는 행만 찾을 때는 WHERE ... IS NULL이 맞습니다. 이건 Anti-Join 패턴입니다
  • ON을 빠뜨리면 CROSS JOIN입니다. 10만 × 10만이면 쿼리가 끝나지 않습니다
  • 1:N 조인 뒤의 집계는 부풀려집니다. 서브쿼리에서 먼저 집계하고 조인합니다
  • NOT INNULL 하나에 결과가 통째로 비어버립니다. NOT EXISTS를 기본으로 씁니다
한 줄 정리: ON조인할 대상을 고르는 조건이고, WHERE조인이 끝난 결과를 거르는 조건입니다. 이 순서만 기억해도 LEFT JOIN, NULL, 결과 행 수가 왜 달라지는지 대부분 이해할 수 있습니다.

참고