안동민 개발노트 아이콘

안동민 개발노트

5장 : SQL 심화 — 조인과 서브쿼리

OUTER JOIN

INNER JOIN은 양쪽 테이블에서 조인 조건이 일치하는 행만 반환합니다. 하지만 실무에서는 주문이 없는 회원도 포함하여 보여달라, 담당자가 배정되지 않은 프로젝트도 목록에 나와야 한다라는 요구가 빈번합니다. 이처럼 일치하지 않는 행도 결과에 포함시키고 싶을 때, OUTER JOIN을 사용합니다.

OUTER JOIN의 핵심은 한쪽(또는 양쪽) 테이블의 모든 행을 보존하고, 매칭되지 않는 상대 테이블의 컬럼은 NULL로 채운다는 것입니다.


OUTER JOIN의 세 가지 종류

OUTER JOIN은 어느 쪽 테이블을 보존하느냐에 따라 세 가지로 나뉩니다.

  • LEFT OUTER JOIN: 왼쪽 테이블의 모든 행을 보존합니다
  • RIGHT OUTER JOIN: 오른쪽 테이블의 모든 행을 보존합니다
  • FULL OUTER JOIN: 양쪽 테이블의 모든 행을 보존합니다

SQL에서 OUTER 키워드는 생략할 수 있습니다. LEFT JOIN이라고 쓰면 LEFT OUTER JOIN과 동일합니다.

다만 DBMS 지원 범위는 조금 다릅니다. LEFT JOIN은 대부분의 DBMS에서 지원하지만, FULL OUTER JOIN은 MySQL처럼 직접 지원하지 않는 DBMS도 있어 UNION 조합으로 대체해야 합니다.


LEFT JOIN 상세

LEFT JOIN은 가장 자주 사용되는 OUTER JOIN입니다. FROM 절에 오는 왼쪽 테이블의 모든 행을 유지하고, 오른쪽 테이블에서 매칭되는 행이 없으면 NULL로 채웁니다.

예제 테이블
-- 회원 테이블
-- user_id | username
-- 1       | 김철수
-- 2       | 이영희
-- 3       | 박민수

-- 주문 테이블
-- order_id | user_id | status
-- 1        | 1       | DELIVERED
-- 2        | 1       | SHIPPED
-- 3        | 2       | PAID
LEFT JOIN 기본
SELECT u.username, o.order_id, o.status
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id;

박민수는 주문이 없지만 LEFT JOIN이므로 왼쪽 테이블(users)의 모든 행이 보존됩니다. 매칭되는 주문이 없기 때문에 order_id와 status가 NULL입니다.

주의할 점은 김철수처럼 매칭되는 행이 여러 개이면, 왼쪽 테이블의 같은 행이 여러 번 출력된다는 것입니다. 1:N 관계에서 LEFT JOIN을 하면 결과 행 수는 보존 테이블의 행 수 이상이 됩니다.


LEFT JOIN + IS NULL (Anti-Join 패턴)

매칭되지 않는 행만 찾기는 실무에서 매우 자주 쓰이는 패턴입니다.

주문이 없는 회원만 찾기
SELECT u.username
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
WHERE o.order_id IS NULL;

이 패턴을 Anti-Join이라 부릅니다. A에는 있지만 B에는 없는 것을 찾을 때 사용합니다.

WHERE 절에서 IS NULL을 체크할 때는 반드시 오른쪽 테이블의 기본키(PK) 또는 NOT NULL 컬럼을 사용해야 합니다. 원래 NULL이 가능한 컬럼으로 체크하면 원래 NULL인 행매칭되지 않아 NULL인 행을 구별할 수 없습니다.


Anti-Join 3가지 방법 비교

Anti-Join을 구현하는 방법은 세 가지가 있습니다.

방법 1: LEFT JOIN + IS NULL
SELECT u.username
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
WHERE o.order_id IS NULL;
방법 2: NOT EXISTS
SELECT u.username
FROM users u
WHERE NOT EXISTS (
    SELECT 1 FROM orders o
    WHERE o.user_id = u.user_id
);
방법 3: NOT IN
SELECT u.username
FROM users u
WHERE u.user_id NOT IN (
    SELECT user_id FROM orders
    WHERE user_id IS NOT NULL
);

NOT IN에서 서브쿼리 결과에 NULL이 포함되면, SQL의 3값 논리에 의해 모든 비교가 UNKNOWN이 되어 결과가 아무 행도 반환되지 않습니다. 따라서 NOT IN을 사용할 때는 반드시 WHERE user_id IS NOT NULL 조건을 추가하거나, NOT EXISTS를 사용하는 것이 안전합니다.

실무에서는 보통 NOT EXISTS를 가장 안전한 기본 선택지로 둡니다. LEFT JOIN + IS NULL도 좋은 패턴이지만, 오른쪽 테이블 컬럼에 추가 필터가 있으면 그 필터를 ON에 둘지 WHERE에 둘지에 따라 결과가 달라질 수 있습니다.

NOT IN의 NULL 함정
-- orders에 user_id = NULL인 행이 있으면
-- 이 쿼리는 아무 결과도 반환하지 않음!
SELECT u.username
FROM users u
WHERE u.user_id NOT IN (
    SELECT user_id FROM orders  -- NULL 포함 가능
);

많은 DBMS에서는 LEFT JOIN + IS NULL과 NOT EXISTS를 비슷한 실행 계획으로 최적화할 수 있습니다. 다만 인덱스, NULL 가능성, 통계 상태에 따라 달라질 수 있으므로 중요한 쿼리는 실행 계획을 확인하는 것이 좋습니다.


RIGHT JOIN

RIGHT JOIN은 오른쪽 테이블의 모든 행을 보존합니다. LEFT JOIN에서 테이블 순서를 뒤집은 것과 동일합니다.

RIGHT JOIN
SELECT u.username, o.order_id, o.status
FROM orders o
RIGHT JOIN users u ON u.user_id = o.user_id;

위 쿼리는 아래 LEFT JOIN과 완전히 동일한 결과를 반환합니다.

동일한 LEFT JOIN
SELECT u.username, o.order_id, o.status
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id;

실무에서는 RIGHT JOIN보다 LEFT JOIN을 훨씬 많이 사용합니다. 이유는 SQL을 왼쪽에서 오른쪽으로 읽기 때문에, 기준 테이블을 먼저 쓰고, 조인 대상을 뒤에 쓰는 LEFT JOIN이 더 자연스럽기 때문입니다. 3개 이상의 테이블을 조인할 때도 LEFT JOIN만으로 일관되게 작성하는 것이 가독성이 좋습니다.


FULL OUTER JOIN

FULL OUTER JOIN은 양쪽 테이블의 모든 행을 보존합니다. 왼쪽에만 있는 행, 오른쪽에만 있는 행, 양쪽 모두에 있는 행이 전부 결과에 포함됩니다.

FULL OUTER JOIN
SELECT e.name AS 직원명, d.dept_name AS 부서명
FROM employees e
FULL OUTER JOIN departments d ON e.dept_id = d.dept_id;

FULL OUTER JOIN의 결과를 집합 개념으로 표현하면 LEFT JOIN ∪ RIGHT JOIN에 가깝습니다. 다만 SQL 결과는 중복 행을 가질 수 있으므로 실제 구현에서는 중복 처리 방식을 명확히 해야 합니다.


MySQL에서 FULL OUTER JOIN 구현

MySQL은 FULL OUTER JOIN을 직접 지원하지 않습니다. UNION 또는 UNION ALL과 anti-filter를 조합해 같은 의미를 구현합니다.

MySQL에서 FULL OUTER JOIN 대체: 간단한 형태
-- LEFT JOIN 결과
SELECT e.name, d.dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id

UNION

-- RIGHT JOIN 결과 (LEFT에서 안 나온 행만 추가)
SELECT e.name, d.dept_name
FROM employees e
RIGHT JOIN departments d ON e.dept_id = d.dept_id;

UNION은 양쪽에서 매칭된 행이 두 번 나오는 것을 막아 주지만, 선택 컬럼이 같은 정상 중복까지 제거할 수 있습니다. 중복 보존이 중요하거나 키 기준으로 정확히 맞춰야 한다면 UNION ALL과 WHERE 조건을 조합하는 방식이 더 안전합니다.

UNION ALL을 사용하는 정확한 FULL OUTER JOIN
SELECT e.name, d.dept_name
FROM employees e
LEFT JOIN departments d ON e.dept_id = d.dept_id

UNION ALL

SELECT e.name, d.dept_name
FROM employees e
RIGHT JOIN departments d ON e.dept_id = d.dept_id
WHERE e.emp_id IS NULL;  -- LEFT JOIN에서 이미 나온 행 제외

이때 e.emp_id처럼 왼쪽 테이블의 NOT NULL 키 컬럼으로 anti-filter를 걸어야 합니다. NULL이 가능한 일반 컬럼을 기준으로 제외하면 실제 매칭된 행과 매칭되지 않은 행을 구분하지 못할 수 있습니다.


ON 절과 WHERE 절의 차이 (핵심!)

OUTER JOIN에서 가장 흔한 실수는 ON 절에 써야 할 조건을 WHERE 절에 쓰는 것입니다. 이 차이를 정확히 이해하는 것이 매우 중요합니다.

ON 절의 조건은 어떤 상대 행을 붙일지 결정하는 조인 과정에서 적용됩니다. 보존 테이블의 행은 ON 조건에 맞는 상대 행이 없어도 결과에 남고, 상대 테이블 컬럼만 NULL로 채워집니다. 반면 WHERE 절의 조건은 조인이 끝난 후 최종 결과 전체에 적용되므로, 보존된 행도 필터링 대상이 됩니다.

ON 절에 조건을 두는 경우
-- 배송 완료된 주문만 조인하되, 모든 회원은 보존
SELECT u.username, o.order_id, o.status
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
                   AND o.status = 'DELIVERED';

이영희는 주문이 있지만 status가 DELIVERED가 아니므로 ON 조건에 매칭되지 않습니다. 그러나 LEFT JOIN이므로 이영희 행은 보존되고, 오른쪽 컬럼이 NULL로 채워집니다.

WHERE 절에 같은 조건을 두는 경우
SELECT u.username, o.order_id, o.status
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
WHERE o.status = 'DELIVERED';

WHERE 절에 o.status = 'DELIVERED'를 두면, 조인 후 최종 결과에서 이 조건을 만족하지 않는 행이 제거됩니다. 이영희는 주문 상태가 DELIVERED가 아니어서 제거되고, 박민수는 o.status가 NULL이라 조건 결과가 UNKNOWN이 되어 제거됩니다. 결과적으로 LEFT JOIN이 INNER JOIN처럼 동작합니다.

이 규칙을 기억하면 OUTER JOIN에서 의도하지 않은 결과가 나오는 대부분의 문제를 해결할 수 있습니다.


여러 테이블의 OUTER JOIN

3개 이상의 테이블을 OUTER JOIN할 때는 조인 순서가 중요합니다.

3개 테이블 LEFT JOIN
-- 회원 → 주문 → 주문상세 순서로 조인
SELECT u.username, o.order_id, oi.product_name, oi.quantity
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
LEFT JOIN order_items oi ON o.order_id = oi.order_id;

주의할 점은 두 번째 LEFT JOIN에서 첫 번째 조인 결과의 NULL 행이 올바르게 전파된다는 것입니다. 중간에 INNER JOIN을 섞으면 이전 LEFT JOIN으로 보존한 NULL 행이 제거될 수 있습니다.

INNER JOIN을 섞으면 위험
-- 첫 번째 LEFT JOIN으로 보존한 박민수 행이
-- 두 번째 INNER JOIN에서 제거됨!
SELECT u.username, o.order_id, oi.product_name
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
INNER JOIN order_items oi ON o.order_id = oi.order_id;
-- 박민수: o.order_id = NULL → INNER JOIN 실패 → 행 제거

보존해야 하는 기준 행이 이어지는 구간에서는 LEFT JOIN 흐름을 유지하는 것이 안전합니다. 다만 반드시 매칭된 하위 데이터만 필요한 구간이라면 INNER JOIN을 의도적으로 섞을 수 있습니다.


OUTER JOIN과 집계 함수

OUTER JOIN과 집계 함수를 함께 사용할 때는 NULL 처리에 주의해야 합니다.

회원별 주문 수 (LEFT JOIN + COUNT)
-- 잘못된 방법
SELECT u.username, COUNT(*) AS order_count
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
GROUP BY u.user_id, u.username;

COUNT(*)는 NULL 행도 세므로, 주문이 없는 박민수도 1이 됩니다. 올바른 방법은 COUNT에 오른쪽 테이블의 컬럼을 지정하는 것입니다.

올바른 방법
SELECT u.username, COUNT(o.order_id) AS order_count
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
GROUP BY u.user_id, u.username;

SUM, AVG 등은 NULL을 무시하지만 COUNT(*)와는 동작이 다릅니다. 특히 매칭된 값이 전부 NULL이면 SUM, AVG 결과 자체가 NULL이 될 수 있으므로 함께 주의해야 합니다.

COALESCE로 NULL 대체
SELECT u.username,
       COALESCE(SUM(o.total_amount), 0) AS total_spent
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
GROUP BY u.user_id, u.username;

SUM은 NULL을 무시하지만, 모든 값이 NULL이면 SUM 결과 자체가 NULL이 됩니다. COALESCE를 사용하면 이런 경우 0으로 대체할 수 있습니다.


SELF OUTER JOIN

자기 자신과 OUTER JOIN하는 것도 가능합니다. 계층 구조 데이터에서 상위 노드가 없는 최상위 항목을 포함할 때 자주 사용됩니다.

상사가 없는 직원도 포함 (조직도)
SELECT e.name AS 직원명,
       m.name AS 상사명
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.emp_id;

상사가 없는 김대표도 LEFT JOIN 덕분에 결과에 포함됩니다. INNER JOIN이었다면 manager_id가 NULL인 김대표는 제외됩니다.


Oracle 전통 방식 (+)

Oracle에서는 ANSI 표준 JOIN 구문이 도입되기 전부터 (+) 표기법으로 OUTER JOIN을 지원했습니다.

Oracle (+) 문법
-- LEFT JOIN과 동일
SELECT u.username, o.order_id
FROM users u, orders o
WHERE u.user_id = o.user_id(+);

-- RIGHT JOIN과 동일
SELECT u.username, o.order_id
FROM users u, orders o
WHERE u.user_id(+) = o.user_id;

(+)는 NULL이 채워질 수 있는 쪽(비보존 테이블 쪽)에 붙입니다. LEFT JOIN이면 오른쪽에 (+), RIGHT JOIN이면 왼쪽에 (+)입니다.

이러한 제한 때문에 Oracle 공식 문서에서도 ANSI 표준 JOIN 구문을 권장합니다. 하지만 레거시 코드에서 (+)를 자주 볼 수 있으므로 의미를 이해하고 있어야 합니다.


CROSS JOIN

CROSS JOIN은 두 테이블의 모든 행 조합(카테시안 곱)을 생성합니다. 조인 조건이 없습니다.

CROSS JOIN 기본
-- 3명 × 6상품 = 18행
SELECT u.username, p.name
FROM users u
CROSS JOIN products p;

CROSS JOIN은 의도적으로 사용하는 경우가 있습니다.

CROSS JOIN 활용: 달력 생성
-- 모든 월 × 모든 부서 조합 생성 (보고서 기본 틀)
SELECT m.month_name, d.dept_name,
       COALESCE(s.total_sales, 0) AS total_sales
FROM months m
CROSS JOIN departments d
LEFT JOIN monthly_sales s ON m.month_id = s.month_id
                          AND d.dept_id = s.dept_id;

이 패턴은 데이터가 없는 조합도 0으로 표시해야 하는 보고서를 만들 때 유용합니다. CROSS JOIN으로 모든 조합의 빈 틀을 만들고, LEFT JOIN으로 실제 데이터를 채우는 방식입니다.

CROSS JOIN 활용: 자릿수 생성
-- 0~9999까지의 숫자 테이블 생성
WITH digits AS (SELECT 0 AS d UNION ALL SELECT 1 UNION ALL SELECT 2
  UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5
  UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8
  UNION ALL SELECT 9)
SELECT a.d * 1000 + b.d * 100 + c.d * 10 + d.d AS num
FROM digits a CROSS JOIN digits b
CROSS JOIN digits c CROSS JOIN digits d;

의도하지 않은 CROSS JOIN은 성능 재앙을 일으킵니다. 10,000행 × 10,000행 = 1억 행이 생성됩니다. JOIN 조건을 빠뜨리지 않도록 항상 주의해야 합니다. 특히 암묵적 조인(FROM a, b) 구문에서 WHERE 절의 조인 조건을 누락하면 의도치 않은 CROSS JOIN이 됩니다.


NATURAL JOIN과 USING

NATURAL JOIN과 USING 절은 조인 조건을 간결하게 표현하는 방법입니다.

NATURAL JOIN
-- 같은 이름의 컬럼으로 자동 조인
SELECT username, order_id
FROM users
NATURAL JOIN orders;
-- user_id라는 같은 이름의 컬럼으로 자동 매칭
USING 절
SELECT username, order_id
FROM users
JOIN orders USING (user_id);
-- 조인 컬럼 이름이 같을 때 간결하게 작성

NATURAL JOIN은 위험합니다. 나중에 테이블에 같은 이름의 컬럼이 추가되면 예상치 못한 조인 조건이 생겨 결과가 바뀝니다. 명시적인 ON 절을 사용하는 것이 안전합니다.

USING 절의 특이한 점은 조인 컬럼이 결과에 한 번만 나타난다는 것입니다. ON 절로 조인하면 양쪽 테이블의 user_id가 모두 나타나지만, USING으로 조인하면 user_id가 한 번만 나타납니다.

USING은 두 테이블의 조인 컬럼명이 같을 때만 사용할 수 있습니다. 결과 컬럼 이름이 합쳐지는 방식과 별칭 참조 가능 범위는 DBMS마다 세부 차이가 있으므로, 복잡한 쿼리에서는 명시적 ON 절이 디버깅하기 쉽습니다.


LATERAL JOIN / APPLY

일부 DBMS에서는 LATERAL JOIN이라는 고급 조인을 지원합니다. 왼쪽 테이블의 각 행에 대해 서브쿼리를 실행할 수 있습니다.

LATERAL JOIN (PostgreSQL)
-- 각 부서의 최근 주문 3건만 조인
SELECT d.dept_name, recent.order_id, recent.total
FROM departments d
LEFT JOIN LATERAL (
    SELECT o.order_id, o.total
    FROM orders o
    WHERE o.dept_id = d.dept_id
    ORDER BY o.order_date DESC
    LIMIT 3
) recent ON TRUE;
OUTER APPLY (SQL Server)
-- SQL Server에서 LEFT JOIN LATERAL에 가까운 기능
SELECT d.dept_name, recent.order_id, recent.total
FROM departments d
OUTER APPLY (
    SELECT TOP 3 o.order_id, o.total
    FROM orders o
    WHERE o.dept_id = d.dept_id
    ORDER BY o.order_date DESC
) recent;

LATERAL JOIN은 각 행마다 조건이 다른 서브쿼리를 조인할 때 유용합니다. 일반 조인으로는 표현하기 어려운 부서별 상위 N개같은 패턴을 깔끔하게 작성할 수 있습니다. SQL Server에서는 보존 행을 유지하는 형태가 OUTER APPLY이고, 매칭되는 행만 남기는 형태가 CROSS APPLY입니다.


OUTER JOIN 성능 고려

OUTER JOIN은 항상 INNER JOIN보다 느리다고 단정할 수는 없습니다. 다만 매칭되지 않는 행도 보존해야 하므로 조인 순서나 조건 이동 같은 옵티마이저 선택지가 줄어드는 경우가 있고, 이때 비용이 커질 수 있습니다.


흔한 실수 정리


OUTER JOIN 전체 정리

다음 절에서는 쿼리 안에 쿼리를 넣는 서브쿼리를 다루겠습니다.