회원 목록과 주문 목록이 각각 다른 표에 흩어져 있다고 해보자. "이번 달에 주문한 사람의 이름을 뽑아줘"라는 부탁을 받는 순간, 우리는 두 표를 어떤 식으로든 이어 붙여야 한다. 이 '이어 붙이기'가 바로 JOIN이다. 개념은 단순한데, 막상 실무에서 결과 행이 이상하게 부풀거나 빠지면 그때부터 머리가 아파진다. 대부분은 JOIN의 종류를 정확히 구분하지 못해서 생기는 일이다.

이 글에서는 네 가지 JOIN — INNER, LEFT, RIGHT, FULL OUTER — 를 회원과 주문이라는 익숙한 예제로 하나씩 뜯어본다. MySQL 8.x를 기준으로 하되, 개념 자체는 어느 데이터베이스에서나 똑같이 통한다.

먼저, 예제 표부터 그려두자

이야기를 끝까지 따라오려면 머릿속에 표 두 개를 고정해두는 게 좋다. 회원 표 members와 주문 표 orders다.

-- members (회원)
-- id | name
--  1 | 김하늘
--  2 | 이바다
--  3 | 박도윤   <- 아직 주문이 없는 회원

-- orders (주문)
-- id | member_id | amount
--  1 |     1     | 25000
--  2 |     1     | 12000
--  3 |     2     |  9000
--  4 |     9     |  5000   <- 탈퇴했는지, member_id 9는 members에 없음

핵심은 두 가지 '어긋남'이다. 회원 3번(박도윤)은 주문이 하나도 없고, 주문 4번은 존재하지 않는 회원(9번)을 가리킨다. 이 어긋남을 각 JOIN이 어떻게 다루는지가 이 글의 전부라고 해도 과언이 아니다.

JOIN의 종류를 가른다는 건 결국 "짝이 없는 행을 살릴 것이냐, 버릴 것이냐"를 정하는 일이다.

INNER JOIN — 양쪽에 다 있는 것만

가장 기본이자 가장 많이 쓰는 JOIN이다. 두 표를 이었을 때 양쪽 모두에 짝이 있는 행만 남긴다.

SELECT m.name, o.amount
FROM members m
INNER JOIN orders o ON m.id = o.member_id;

결과는 김하늘(2건)과 이바다(1건)의 주문, 총 세 줄이다. 주문이 없는 박도윤은 빠지고, 회원이 없는 주문 4번도 빠진다. 짝이 맞아떨어지는 교집합만 나오는 셈이다.

실무에서 "주문한 회원의 이름과 금액"처럼 양쪽에 데이터가 다 있어야 의미가 생기는 조회는 대부분 INNER JOIN으로 해결된다. 참고로 INNER 키워드는 생략하고 그냥 JOIN이라고 써도 동일하게 동작한다.

LEFT JOIN — 왼쪽 표는 무조건 다 남긴다

여기서부터가 진짜 갈림길이다. LEFT JOIN은 왼쪽(FROM에 먼저 쓴) 표의 행을 하나도 버리지 않는다. 오른쪽에 짝이 없으면 그 자리를 NULL로 채운다.

SELECT m.name, o.amount
FROM members m
LEFT JOIN orders o ON m.id = o.member_id;

이번엔 박도윤도 결과에 나온다. 다만 주문이 없으니 amount 칸이 NULL로 찍힌다. "전체 회원을 보여주되, 주문이 있으면 금액도 같이" 같은 요구에 딱 맞는다. 반대로 주문 4번(회원 없음)은 여전히 사라진다. 왼쪽 표인 members에 그 회원이 없기 때문이다.

LEFT JOIN이 빛나는 대표적 장면은 '없는 것 찾기'다. 예를 들어 주문을 한 번도 안 한 회원만 골라내고 싶다면 이렇게 쓴다.

SELECT m.name
FROM members m
LEFT JOIN orders o ON m.id = o.member_id
WHERE o.id IS NULL;

LEFT JOIN으로 짝 없는 행을 NULL로 남긴 뒤, WHERE o.id IS NULL로 그 NULL만 걸러내는 패턴이다. 실무에서 정말 자주 나오는 관용구이니 통째로 외워두면 좋다.

RIGHT JOIN — 방향만 반대인 쌍둥이

RIGHT JOIN은 LEFT JOIN의 거울상이다. 이번엔 오른쪽 표를 전부 남기고 왼쪽에 짝이 없으면 NULL을 채운다.

SELECT m.name, o.amount
FROM members m
RIGHT JOIN orders o ON m.id = o.member_id;

앞선 세 건에 더해, 회원이 없는 주문 4번(member_id 9)이 등장한다. 대신 name이 NULL로 나온다. "주문은 다 보여주되, 회원 정보는 있으면 붙여라"는 뜻이다.

실무에서는 RIGHT JOIN을 쓰는 일이 의외로 드물다. 사람은 보통 문장을 왼쪽에서 오른쪽으로 읽기 때문에, 남기고 싶은 표를 왼쪽에 두고 LEFT JOIN으로 쓰는 편이 훨씬 직관적이기 때문이다. RIGHT JOIN이 필요해지면, 두 표의 순서를 바꿔 LEFT JOIN으로 다시 쓰는 습관을 들이면 헷갈릴 일이 준다.

FULL OUTER JOIN — 양쪽 어느 쪽이든 살린다

FULL OUTER JOIN은 양쪽 표의 모든 행을 다 남긴다. 짝이 없는 자리는 어느 쪽이든 NULL로 채운다. 즉, 박도윤(주문 없음)도 나오고 주문 4번(회원 없음)도 나온다.

그런데 여기서 MySQL 사용자라면 주의할 점이 하나 있다. MySQL은 FULL OUTER JOIN 문법을 지원하지 않는다. PostgreSQL이나 오라클에서는 그냥 쓰면 되지만, MySQL에서는 LEFT JOIN과 RIGHT JOIN의 결과를 UNION으로 합쳐 흉내 낸다.

SELECT m.name, o.amount
FROM members m
LEFT JOIN orders o ON m.id = o.member_id
UNION
SELECT m.name, o.amount
FROM members m
RIGHT JOIN orders o ON m.id = o.member_id;

UNION은 중복 행을 자동으로 제거해주므로, 위아래에서 겹치는 교집합 부분이 두 번 나오지 않는다. 자주 쓰이진 않지만 "양쪽의 불일치를 한눈에 점검"할 때 유용하다.

자주 밟는 지뢰 세 가지

첫째, JOIN 뒤 ON 조건을 빠뜨리는 것. 조건 없이 두 표를 이으면 모든 행이 서로 짝지어지는 CROSS JOIN이 되어, 100행 × 100행이 1만 행으로 폭발한다. 결과가 이상하게 많다면 ON부터 의심하자.

둘째, LEFT JOIN 후 오른쪽 표 조건을 WHERE에 잘못 두는 것. 예를 들어 WHERE o.amount > 10000을 붙이면, NULL은 이 조건에서 탈락하므로 애써 살려둔 '주문 없는 회원'이 도로 사라진다. 왼쪽 행을 유지하면서 걸러야 한다면 조건을 ON 절 안으로 옮겨야 한다.

셋째, 집계 함수와 섞을 때의 행 부풀림이다. 한 회원이 주문 3건이면 JOIN 결과도 3행이 된다. 이 상태에서 회원 수를 COUNT하면 실제보다 부풀려진다. COUNT(DISTINCT m.id)를 쓰거나, 주문을 먼저 집계한 뒤 JOIN하는 식으로 순서를 정리해야 한다.

정리하며

JOIN은 결국 "짝이 없는 행을 어떻게 대할 것인가"라는 하나의 질문으로 수렴한다. 교집합만 필요하면 INNER, 왼쪽을 다 살리려면 LEFT, 그 반대가 RIGHT, 양쪽 다면 FULL OUTER — 이 한 줄만 손에 쥐고 있어도 대부분의 상황은 정리된다. 특히 실무에서는 LEFT JOIN과 IS NULL을 엮어 '없는 것'을 찾는 패턴을 가장 많이 만나게 될 것이다.

처음엔 결과 행이 늘었다 줄었다 하는 게 당황스럽겠지만, 오늘 그린 회원·주문 표 두 개만 머릿속에 붙여두면 어떤 JOIN을 만나도 "아, 이건 이쪽을 살리는구나" 하고 그림이 그려질 것이다. 오늘 배운 네 가지를, 다음에 표 두 개를 마주쳤을 때 천천히 하나씩 떠올려 보시길.