데이터베이스에서 여러 테이블의 정보를 한 번에 조회해야 할 때가 많죠? 회원 정보와 주문 정보가 각각 다른 테이블에 있을 때, 둘을 연결해서 “누가 무엇을 주문했는지” 확인하려면 JOIN이 필수입니다. 오늘은 SQL의 핵심 기능인 JOIN을 종류별로 완벽하게 정리하고, 실전에서 바로 사용할 수 있는 예제를 공유합니다.
JOIN이란? 왜 필요할까요?
JOIN은 두 개의 테이블을 서로 묶어서 하나의 결과를 만들어내는 것입니다. 관계형 데이터베이스에서는 정보를 여러 테이블로 분산 저장하기 때문에, 필요한 데이터를 조회하려면 테이블을 연결해야 합니다.
JOIN이 필요한 실무 상황:
- 회원 테이블과 주문 테이블을 연결해 회원별 구매 내역 조회
- 상품 테이블과 재고 테이블을 연결해 판매 가능한 상품 확인
- 직원 테이블과 부서 테이블을 연결해 부서별 인원 현황 파악
- 게시글 테이블과 댓글 테이블을 연결해 게시글별 댓글 수 집계
JOIN의 종류 – 4가지 핵심 타입
SQL JOIN은 크게 INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL JOIN으로 구분됩니다. 각각의 특징과 사용 상황을 명확히 이해하면 복잡한 쿼리도 쉽게 작성할 수 있습니다.
1. INNER JOIN (내부 조인) – 교집합
INNER JOIN은 두 테이블을 조인할 때, 두 테이블에 모두 지정한 열의 데이터가 있어야 조회됩니다. 가장 많이 사용되는 JOIN 방식이죠.
특징:
- 양쪽 테이블에 모두 존재하는 데이터만 조회
- 매칭되지 않는 데이터는 결과에서 제외
- 가장 빠른 성능
기본 문법:
SELECT 컬럼목록
FROM 테이블1 AS A
INNER JOIN 테이블2 AS B ON A.공통컬럼 = B.공통컬럼
실전 예제 1: 주문한 회원만 조회
-- 회원 테이블
CREATE TABLE members (
user_id INT PRIMARY KEY,
user_name VARCHAR(50),
email VARCHAR(100)
);
-- 주문 테이블
CREATE TABLE orders (
order_id INT PRIMARY KEY,
user_id INT,
product_name VARCHAR(100),
order_date DATE
);
-- INNER JOIN 쿼리
SELECT
m.user_id AS `회원번호`,
m.user_name AS `회원명`,
o.order_id AS `주문번호`,
o.product_name AS `상품명`,
o.order_date AS `주문일자`
FROM members AS m
INNER JOIN orders AS o ON m.user_id = o.user_id
WHERE o.order_date >= '2025-01-01'
ORDER BY o.order_date DESC;
결과 특징:
- 주문을 한 번이라도 한 회원만 조회됨
- 주문 이력이 없는 회원은 결과에 나타나지 않음
- 한 회원이 여러 번 주문했다면 그 수만큼 행이 중복됨
2. LEFT OUTER JOIN (왼쪽 외부 조인) – 왼쪽 테이블 기준
LEFT OUTER JOIN은 왼쪽 테이블의 모든 행을 반환하고, 오른쪽 테이블에서 매칭되는 행을 가져옵니다. 매칭되지 않으면 NULL을 반환합니다.
특징:
- 왼쪽(기준) 테이블의 모든 데이터 조회
- 오른쪽 테이블에 매칭 데이터가 없어도 왼쪽 데이터는 표시
- 매칭 안 되면 오른쪽 컬럼은 NULL
기본 문법:
SELECT 컬럼목록
FROM 테이블1 AS A
LEFT OUTER JOIN 테이블2 AS B ON A.공통컬럼 = B.공통컬럼
-- 또는
LEFT JOIN 테이블2 AS B ON A.공통컬럼 = B.공통컬럼
참고: LEFT JOIN과 LEFT OUTER JOIN은 완전히 동일합니다.
실전 예제 2: 모든 회원과 주문 내역 조회
SELECT
m.user_id AS `회원번호`,
m.user_name AS `회원명`,
m.email AS `이메일`,
o.order_id AS `주문번호`,
o.product_name AS `상품명`,
o.order_date AS `주문일자`
FROM members AS m
LEFT JOIN orders AS o ON m.user_id = o.user_id
ORDER BY m.user_id;
결과 특징:
- 모든 회원이 조회됨
- 주문 이력이 없는 회원은 주문 관련 컬럼이 NULL로 표시
- 신규 가입 회원이나 휴면 회원 파악에 유용
실전 예제 3: 주문하지 않은 회원만 찾기
SELECT
m.user_id AS `회원번호`,
m.user_name AS `회원명`,
m.email AS `이메일`,
m.reg_date AS `가입일자`
FROM members AS m
LEFT JOIN orders AS o ON m.user_id = o.user_id
WHERE o.order_id IS NULL
ORDER BY m.reg_date DESC;
활용 팁: LEFT JOIN + WHERE IS NULL 패턴은 “A에는 있지만 B에는 없는” 데이터를 찾을 때 매우 유용합니다.
3. RIGHT OUTER JOIN (오른쪽 외부 조인) – 오른쪽 테이블 기준
RIGHT JOIN은 오른쪽 테이블의 모든 행을 반환하며, 왼쪽 테이블에 매칭되는 데이터가 없어도 표시됩니다.
특징:
- 오른쪽(기준) 테이블의 모든 데이터 조회
- 왼쪽 테이블에 매칭 데이터가 없어도 오른쪽 데이터는 표시
- 매칭 안 되면 왼쪽 컬럼은 NULL
- LEFT JOIN을 거꾸로 쓴 것과 동일
기본 문법:
SELECT 컬럼목록
FROM 테이블1 AS A
RIGHT OUTER JOIN 테이블2 AS B ON A.공통컬럼 = B.공통컬럼
-- 또는
RIGHT JOIN 테이블2 AS B ON A.공통컬럼 = B.공통컬럼
실전 예제 4: 모든 주문과 회원 정보 조회
SELECT
o.order_id AS `주문번호`,
o.product_name AS `상품명`,
o.order_date AS `주문일자`,
m.user_name AS `회원명`,
m.email AS `이메일`
FROM members AS m
RIGHT JOIN orders AS o ON m.user_id = o.user_id
ORDER BY o.order_date DESC;
결과 특징:
- 모든 주문이 조회됨
- 회원 정보가 삭제된 주문은 회원 컬럼이 NULL로 표시
- 탈퇴 회원의 주문 내역 파악에 유용
실무 팁: 대부분의 경우 RIGHT JOIN보다 LEFT JOIN이 더 직관적입니다. 테이블 순서만 바꾸면 동일한 결과를 얻을 수 있기 때문입니다.
-- 이 두 쿼리는 동일한 결과
SELECT * FROM A RIGHT JOIN B ON A.id = B.id;
SELECT * FROM B LEFT JOIN A ON B.id = A.id;
4. FULL OUTER JOIN (완전 외부 조인) – 합집합
FULL OUTER JOIN은 왼쪽 테이블 또는 오른쪽 테이블에 매칭되는 레코드가 있을 때 모든 레코드를 반환합니다.
특징:
- 양쪽 테이블의 모든 데이터 조회
- 매칭되는 데이터는 결합하여 표시
- 매칭되지 않는 데이터도 모두 표시 (NULL로 채움)
- LEFT JOIN + RIGHT JOIN을 합친 결과
기본 문법:
SELECT 컬럼목록
FROM 테이블1 AS A
FULL OUTER JOIN 테이블2 AS B ON A.공통컬럼 = B.공통컬럼
-- 또는
FULL JOIN 테이블2 AS B ON A.공통컬럼 = B.공통컬럼
주의: MySQL/MariaDB는 FULL OUTER JOIN을 지원하지 않습니다. UNION을 사용해야 합니다.
실전 예제 5: 모든 회원과 모든 주문 조회
-- PostgreSQL, SQL Server, Oracle
SELECT
m.user_id AS `회원번호`,
m.user_name AS `회원명`,
o.order_id AS `주문번호`,
o.product_name AS `상품명`
FROM members AS m
FULL OUTER JOIN orders AS o ON m.user_id = o.user_id;
-- MySQL에서는 UNION 사용
SELECT
m.user_id AS `회원번호`,
m.user_name AS `회원명`,
o.order_id AS `주문번호`,
o.product_name AS `상품명`
FROM members AS m
LEFT JOIN orders AS o ON m.user_id = o.user_id
UNION
SELECT
m.user_id AS `회원번호`,
m.user_name AS `회원명`,
o.order_id AS `주문번호`,
o.product_name AS `상품명`
FROM members AS m
RIGHT JOIN orders AS o ON m.user_id = o.user_id;
결과 특징:
- 주문하지 않은 회원도 표시 (주문 컬럼 NULL)
- 회원 정보가 없는 주문도 표시 (회원 컬럼 NULL)
- 데이터 정합성 검사에 유용
JOIN 실전 테크닉
1. 다중 테이블 JOIN
세 개 이상의 테이블을 연결할 때는 순차적으로 JOIN을 추가합니다.
SELECT
m.user_name AS `회원명`,
o.order_id AS `주문번호`,
o.order_date AS `주문일자`,
p.product_name AS `상품명`,
p.price AS `가격`,
c.category_name AS `카테고리`
FROM members AS m
INNER JOIN orders AS o ON m.user_id = o.user_id
INNER JOIN products AS p ON o.product_id = p.product_id
INNER JOIN categories AS c ON p.category_id = c.category_id
WHERE o.order_date >= '2025-01-01'
ORDER BY o.order_date DESC;
2. SELF JOIN (자기 자신과 조인)
같은 테이블을 두 번 사용해 계층 구조를 표현할 때 유용합니다.
-- 직원과 상사 정보 조회
SELECT
e1.emp_name AS `직원명`,
e1.position AS `직급`,
e2.emp_name AS `상사명`,
e2.position AS `상사직급`
FROM employees AS e1
LEFT JOIN employees AS e2 ON e1.manager_id = e2.emp_id
ORDER BY e1.emp_id;
3. CROSS JOIN (교차 조인)
모든 조합을 생성할 때 사용합니다. ON 조건이 없는 JOIN입니다.
-- 모든 색상과 사이즈 조합 생성
SELECT
c.color_name AS `색상`,
s.size_name AS `사이즈`
FROM colors AS c
CROSS JOIN sizes AS s
ORDER BY c.color_name, s.size_name;
결과:
| 색상 | 사이즈 |
|---|---|
| 검정 | S |
| 검정 | M |
| 검정 | L |
| 흰색 | S |
| 흰색 | M |
| 흰색 | L |
4. 복잡한 JOIN 조건
단순 동등 조건 외에도 다양한 조건을 사용할 수 있습니다.
-- 여러 조건을 결합한 JOIN
SELECT
m.user_name AS `회원명`,
o.order_id AS `주문번호`,
o.total_amount AS `주문금액`
FROM members AS m
INNER JOIN orders AS o
ON m.user_id = o.user_id
AND o.order_date >= m.reg_date
AND o.status = 'completed'
WHERE m.status = 'active'
AND o.total_amount >= 50000;
5. 집계 함수와 JOIN
JOIN 후 그룹화하여 통계를 낸 수 있습니다.
SELECT
m.user_id AS `회원번호`,
m.user_name AS `회원명`,
COUNT(o.order_id) AS `주문횟수`,
SUM(o.total_amount) AS `총구매금액`,
AVG(o.total_amount) AS `평균구매금액`,
MAX(o.order_date) AS `최근구매일`
FROM members AS m
LEFT JOIN orders AS o ON m.user_id = o.user_id
GROUP BY m.user_id, m.user_name
HAVING COUNT(o.order_id) >= 5
ORDER BY `총구매금액` DESC;
JOIN 성능 최적화 팁
1. 인덱스 활용
JOIN 조건에 사용되는 컬럼에는 반드시 인덱스를 생성하세요.
-- 외래키 컬럼에 인덱스 생성
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_products_category_id ON products(category_id);
2. 필요한 컬럼만 조회
SELECT *는 피하고 필요한 컬럼만 명시하세요.
-- 나쁜 예
SELECT *
FROM members AS m
INNER JOIN orders AS o ON m.user_id = o.user_id;
-- 좋은 예
SELECT
m.user_name,
o.order_id,
o.order_date
FROM members AS m
INNER JOIN orders AS o ON m.user_id = o.user_id;
3. WHERE 조건 먼저 필터링
JOIN 전에 데이터를 최대한 줄이면 성능이 향상됩니다.
SELECT
m.user_name,
o.order_id
FROM members AS m
INNER JOIN orders AS o
ON m.user_id = o.user_id
AND o.order_date >= '2025-01-01' -- JOIN 조건에 포함
WHERE m.status = 'active'; -- WHERE로 추가 필터링
4. 서브쿼리보다 JOIN 우선
대부분의 경우 서브쿼리보다 JOIN이 더 빠릅니다.
-- 서브쿼리 (느림)
SELECT user_name
FROM members
WHERE user_id IN (
SELECT user_id
FROM orders
WHERE order_date >= '2025-01-01'
);
-- JOIN (빠름)
SELECT DISTINCT m.user_name
FROM members AS m
INNER JOIN orders AS o ON m.user_id = o.user_id
WHERE o.order_date >= '2025-01-01';
JOIN 사용 시 주의사항
1. 중복 데이터 문제
일대다 관계에서 JOIN하면 행이 중복됩니다.
-- 한 회원이 여러 주문을 하면 회원 정보가 중복됨
SELECT
m.user_name,
COUNT(*) AS `행개수`
FROM members AS m
INNER JOIN orders AS o ON m.user_id = o.user_id
GROUP BY m.user_id, m.user_name;
해결방법: DISTINCT 사용 또는 적절한 GROUP BY
2. NULL 처리
OUTER JOIN 사용 시 NULL 값 처리에 주의하세요.
SELECT
m.user_name AS `회원명`,
COALESCE(COUNT(o.order_id), 0) AS `주문횟수`,
COALESCE(SUM(o.total_amount), 0) AS `총구매금액`
FROM members AS m
LEFT JOIN orders AS o ON m.user_id = o.user_id
GROUP BY m.user_id, m.user_name;
3. 데카르트 곱 주의
ON 조건을 빠뜨리면 모든 조합이 생성됩니다.
-- 잘못된 예 (데카르트 곱 발생)
SELECT *
FROM members, orders; -- 100명 * 1000건 = 100,000행!
-- 올바른 예
SELECT *
FROM members AS m
INNER JOIN orders AS o ON m.user_id = o.user_id;
JOIN 종류 선택 가이드
언제 어떤 JOIN을 사용할까?
| 상황 | 추천 JOIN | 이유 |
|---|---|---|
| 양쪽 테이블에 모두 있는 데이터만 | INNER JOIN | 가장 빠르고 깔끔 |
| 기준 테이블의 모든 데이터 + 매칭 | LEFT JOIN | 누락 없이 전체 파악 |
| 매칭 안 된 데이터만 찾기 | LEFT JOIN + IS NULL | 차집합 구현 |
| 양쪽 테이블의 모든 데이터 | FULL OUTER JOIN | 완전한 합집합 |
| 모든 조합 생성 | CROSS JOIN | 경우의 수 생성 |
실무 예제 – 전자상거래 주문 분석
-- 카테고리별 회원 구매 현황
SELECT
c.category_name AS `카테고리`,
COUNT(DISTINCT m.user_id) AS `구매회원수`,
COUNT(o.order_id) AS `주문건수`,
SUM(oi.quantity * p.price) AS `총매출`,
AVG(oi.quantity * p.price) AS `평균주문금액`
FROM categories AS c
INNER JOIN products AS p ON c.category_id = p.category_id
INNER JOIN order_items AS oi ON p.product_id = oi.product_id
INNER JOIN orders AS o ON oi.order_id = o.order_id
INNER JOIN members AS m ON o.user_id = m.user_id
WHERE o.order_date >= DATE_SUB(CURDATE(), INTERVAL 1 MONTH)
AND o.status = 'completed'
GROUP BY c.category_id, c.category_name
HAVING `총매출` >= 1000000
ORDER BY `총매출` DESC;
마무리 – JOIN 마스터하기
JOIN은 SQL의 핵심이자 관계형 데이터베이스의 진정한 힘입니다. 처음에는 복잡해 보이지만, 각 JOIN의 특징을 이해하고 실전에서 반복 연습하면 자연스럽게 습득할 수 있습니다.
핵심 요약:
- INNER JOIN: 양쪽 테이블에 모두 있는 데이터만 (교집합)
- LEFT JOIN: 왼쪽 테이블 전체 + 오른쪽 매칭 데이터
- RIGHT JOIN: 오른쪽 테이블 전체 + 왼쪽 매칭 데이터
- FULL JOIN: 양쪽 테이블의 모든 데이터 (합집합)
- JOIN 조건에는 반드시 인덱스 생성
- 필요한 컬럼만 조회해 성능 최적화
여러분은 실무에서 어떤 JOIN을 가장 많이 사용하시나요? 복잡한 JOIN 쿼리 작성 팁이 있다면 댓글로 공유해주세요!
참고 자료: