테이블을 쪼개서 저장하는 이유는 중복을 줄이기 위해서다. 유저 정보를 주문마다 반복 저장하는 대신, 유저 테이블과 주문 테이블로 나눠 user_id로 연결한다. 하지만 조회할 땐 다시 합쳐야 한다. 그게 JOIN이다.
JOIN 종류마다 "어떤 행을 살리고 어떤 행을 버리는가"의 기준이 다르다. 이 글에서는 하나의 데이터를 고정해두고 JOIN마다 결과가 어떻게 달라지는지 직접 확인한다.
글 전체에서 아래 두 테이블을 사용한다.
users
| id | name |
|---|---|
| 1 | 철수 |
| 2 | 영희 |
| 3 | 민수 |
orders
| id | user_id | status |
|---|---|---|
| 1 | 1 | paid |
| 2 | 1 | cancel |
| 3 | 3 | paid |
철수는 주문이 2개(paid, cancel), 민수는 1개(paid), 영희는 주문이 없다.
양쪽 테이블 모두에 매칭되는 행만 결과에 포함한다. 한쪽이라도 없으면 버린다.
SELECT u.name, o.id AS order_id, o.status
FROM users u
INNER JOIN orders o ON u.id = o.user_id;
| name | order_id | status |
|---|---|---|
| 철수 | 1 | paid |
| 철수 | 2 | cancel |
| 민수 | 3 | paid |
영희는 주문이 없어서 결과에서 사라졌다. 오른쪽 테이블(orders)에 매칭되는 행이 없으면 왼쪽 행도 함께 버려지기 때문이다.
철수가 2행으로 나온다는 점도 주목할 만하다. 왼쪽 테이블의 1개 행이 오른쪽 테이블의 여러 행과 매칭되면, 매칭 수만큼 행이 늘어난다.
왼쪽 테이블의 모든 행을 유지하고, 오른쪽에서 매칭되는 행을 붙인다. 매칭이 없으면 오른쪽 컬럼을 NULL로 채운다. 왼쪽 행은 절대 사라지지 않는다.
SELECT u.name, o.id AS order_id, o.status
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
| name | order_id | status |
|---|---|---|
| 철수 | 1 | paid |
| 철수 | 2 | cancel |
| 영희 | NULL | NULL |
| 민수 | 3 | paid |
영희는 주문이 없지만 users 테이블 기준이기 때문에 결과에 남아있다. orders 쪽 컬럼만 NULL로 채워진다.
LEFT JOIN의 방향을 반대로 뒤집은 것이다. 오른쪽 테이블의 모든 행을 유지하고, 왼쪽에서 매칭되는 행을 붙인다.
SELECT u.name, o.id AS order_id, o.status
FROM users u
RIGHT JOIN orders o ON u.id = o.user_id;
| name | order_id | status |
|---|---|---|
| 철수 | 1 | paid |
| 철수 | 2 | cancel |
| 민수 | 3 | paid |
현재 예시에서는 orders의 모든 행이 users와 매칭되기 때문에 NULL이 나오지 않는다. 만약 user_id가 존재하지 않는 주문이 있었다면 그 행의 name 컬럼이 NULL로 나왔을 것이다.
실무에서는 테이블 순서를 바꿔서 LEFT JOIN으로 표현하는 경우가 많아, RIGHT JOIN은 상대적으로 덜 쓰인다.
LEFT JOIN 결과와 RIGHT JOIN 결과를 합친 것이다. 양쪽 테이블의 모든 행을 유지하고, 매칭이 없는 쪽은 NULL로 채운다.
SELECT u.name, o.id AS order_id, o.status
FROM users u
FULL OUTER JOIN orders o ON u.id = o.user_id;
| name | order_id | status |
|---|---|---|
| 철수 | 1 | paid |
| 철수 | 2 | cancel |
| 영희 | NULL | NULL |
| 민수 | 3 | paid |
현재 예시에서는 LEFT JOIN 결과와 동일하다. orders에 매칭 안 되는 행이 없기 때문이다. 양쪽 모두에 매칭 안 되는 행이 생기는 상황이라면 두 결과가 달라진다.
ON 조건 없이 두 테이블의 모든 행을 조합한다. 왼쪽 N개, 오른쪽 M개면 결과는 N × M행이다.
SELECT u.name, o.id AS order_id, o.status
FROM users u
CROSS JOIN orders o;
| name | order_id | status |
|---|---|---|
| 철수 | 1 | paid |
| 철수 | 2 | cancel |
| 철수 | 3 | paid |
| 영희 | 1 | paid |
| 영희 | 2 | cancel |
| 영희 | 3 | paid |
| 민수 | 1 | paid |
| 민수 | 2 | cancel |
| 민수 | 3 | paid |
users 3행 × orders 3행 = 9행. 매칭 조건 없이 가능한 모든 조합이 만들어진다.
실무에서 실수로 ON 조건을 빠뜨리면 CROSS JOIN처럼 동작해서 데이터가 폭발적으로 늘어날 수 있다. 의도적으로 쓰는 경우는 테스트 데이터 대량 생성이나 경우의 수 전체를 뽑아야 할 때 정도다.
SELF JOIN이라는 키워드가 따로 있는 게 아니다. 같은 테이블을 두 번 참조해서 JOIN하는 패턴에 붙인 이름이다.
계층 구조를 가진 테이블에서 자주 쓰인다. 예를 들어 직원 테이블에 manager_id가 있고, 이 값이 같은 테이블의 다른 직원 id를 가리키는 경우다.
employees
| id | name | manager_id |
|---|---|---|
| 1 | 철수 | NULL |
| 2 | 영희 | 1 |
| 3 | 민수 | 1 |
철수는 최상위 관리자라 manager_id가 NULL이고, 영희와 민수는 철수 밑이다.
각 직원과 그 상사 이름을 함께 출력하려면 employees 테이블을 두 번 참조해야 한다. 별칭을 다르게 줘서 마치 두 개의 다른 테이블처럼 JOIN한다.
-- e: 직원 역할, m: 상사 역할 (같은 테이블)
SELECT e.name AS 직원, m.name AS 상사
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
| 직원 | 상사 |
|---|---|
| 철수 | NULL |
| 영희 | 철수 |
| 민수 | 철수 |
JOIN을 쓸 때 조건을 ON에 걸지, WHERE에 걸지에 따라 결과가 달라진다. 특히 LEFT JOIN에서 이 차이가 두드러진다.
ON은 "오른쪽 테이블을 어떻게 붙일지"를 결정한다. ON 조건에 맞지 않는 행이 있어도 LEFT JOIN은 왼쪽 행을 버리지 않고, 오른쪽 컬럼만 NULL로 채운다.
WHERE는 JOIN이 완료된 결과에서 행을 제거한다. 조건에 맞지 않는 행은 NULL 여부와 상관없이 사라진다.
아래 두 쿼리는 얼핏 비슷해 보이지만 결과가 다르다.
-- 쿼리 1: ON에 조건
SELECT u.name, o.id AS order_id, o.status
FROM users u
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid';
| name | order_id | status |
|---|---|---|
| 철수 | 1 | paid |
| 영희 | NULL | NULL |
| 민수 | 3 | paid |
철수의 cancel 주문은 ON 조건에서 걸렸지만 철수 행은 유지된다. LEFT JOIN이기 때문이다.
-- 쿼리 2: WHERE에 조건
SELECT u.name, o.id AS order_id, o.status
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'paid';
| name | order_id | status |
|---|---|---|
| 철수 | 1 | paid |
| 민수 | 3 | paid |
영희가 사라졌다. NULL = 'paid'는 false로 평가되어 WHERE 조건을 통과하지 못하기 때문이다. LEFT JOIN을 썼지만 결과는 INNER JOIN처럼 동작하게 된다.
"모든 유저를 보되 paid 주문만 연결하고 싶다"면 ON에 조건을 넣어야 한다. "paid 주문이 있는 유저만 보고 싶다"면 WHERE가 맞다. 의도에 따라 위치가 달라진다.