JOIN
JOIN은 두 개 이상의 테이블을 연결해서 데이터를 조회하는 방법입니다. 현실에서는 데이터를 여러 테이블로 나누어 저장하기 때문에, 완전한 정보를 얻으려면 JOIN이 필수입니다.
왜 JOIN이 필요할까?
데이터베이스 설계 원칙:
- 중복 제거: 같은 정보를 여러 곳에 저장하지 않기
- 데이터 정규화: 관련있는 정보끼리 묶어서 별도 테이블로 분리
- 효율적인 관리: 변경사항이 있을 때 한 곳만 수정하면 됨
실생활 예시로 이해하기
온라인 쇼핑몰 데이터:
-- 주문 테이블 (Orders)
주문번호 | 고객ID | 주문일자 | 총금액
1001 | C001 | 2024-01-15 | 50000
1002 | C002 | 2024-01-16 | 30000
1003 | C001 | 2024-01-17 | 80000
-- 고객 테이블 (Customers)
고객ID | 고객명 | 전화번호 | 주소
C001 | 김철수 | 010-1111-2222 | 서울
C002 | 이영희 | 010-3333-4444 | 부산
C003 | 박민수 | 010-5555-6666 | 대구
문제: "주문 정보를 볼 때 고객ID만 보이면 누가 주문했는지 알기 어렵다" 해결: JOIN을 사용해서 주문정보 + 고객정보를 함께 조회
테스트 데이터 준비
실습을 위한 테이블을 만들어봅시다.
-- 고객 테이블
CREATE TABLE Customers (
customer_id VARCHAR(10) PRIMARY KEY,
customer_name NVARCHAR(50) NOT NULL,
phone VARCHAR(20),
city NVARCHAR(20),
registration_date DATE
);
-- 주문 테이블
CREATE TABLE Orders (
order_id INT IDENTITY(1001,1) PRIMARY KEY,
customer_id VARCHAR(10),
order_date DATE DEFAULT GETDATE(),
total_amount DECIMAL(10,2),
status VARCHAR(20) DEFAULT 'PENDING'
);
-- 테스트 데이터 삽입
INSERT INTO Customers VALUES
('C001', N'김철수', '010-1111-2222', N'서울', '2023-01-15'),
('C002', N'이영희', '010-3333-4444', N'부산', '2023-02-20'),
('C003', N'박민수', '010-5555-6666', N'대구', '2023-03-10'),
('C004', N'최은정', '010-7777-8888', N'인천', '2023-04-05'),
('C005', N'정우진', '010-9999-0000', N'광주', '2023-05-12');
INSERT INTO Orders (customer_id, order_date, total_amount, status) VALUES
('C001', '2024-01-15', 50000, 'COMPLETED'),
('C001', '2024-01-20', 75000, 'COMPLETED'),
('C002', '2024-01-16', 30000, 'COMPLETED'),
('C002', '2024-01-25', 45000, 'SHIPPED'),
('C003', '2024-01-17', 80000, 'PENDING'),
('C999', '2024-01-18', 25000, 'COMPLETED'); -- 존재하지 않는 고객
JOIN의 종류와 동작 원리
1. INNER JOIN - 교집합 (양쪽 모두에 있는 것만)
언제 사용하나?
- 양쪽 테이블에 모두 존재하는 데이터만 필요할 때
- 가장 일반적으로 사용하는 JOIN
- "확실한 관계"만 보고 싶을 때
동작 원리:
고객테이블: C001, C002, C003, C004, C005
주문테이블: C001, C001, C002, C002, C003, C999
INNER JOIN 결과: C001, C001, C002, C002, C003
(C004는 주문이 없어서 제외, C999는 고객이 없어서 제외)
실제 쿼리:
-- INNER JOIN: 주문이 있는 고객들만 조회
SELECT
c.customer_name AS '고객명',
c.city AS '거주지',
o.order_id AS '주문번호',
o.order_date AS '주문일',
o.total_amount AS '주문금액',
o.status AS '주문상태'
FROM Customers c
INNER JOIN Orders o ON c.customer_id = o.customer_id
ORDER BY c.customer_name, o.order_date;
결과 해석:
- 김철수, 이영희, 박민수의 주문 정보만 나타남
- 최은정, 정우진은 주문이 없어서 결과에 없음
- C999 주문은 고객정보가 없어서 결과에 없음
2. LEFT JOIN - 왼쪽 기준 (왼쪽은 다 보여주고, 오른쪽은 있으면 보여주기)
언제 사용하나?
- 기준 테이블의 모든 데이터를 보고 싶을 때
- "A 목록을 다 보면서, 관련된 B 정보도 있으면 함께 보고 싶을 때"
- 통계나 리포트에서 자주 사용
동작 원리:
왼쪽(Customers): C001, C002, C003, C004, C005
오른쪽(Orders): C001, C001, C002, C002, C003, C999
LEFT JOIN 결과:
C001 + 주문정보, C001 + 주문정보,
C002 + 주문정보, C002 + 주문정보,
C003 + 주문정보,
C004 + NULL, C005 + NULL
(모든 고객을 다 보여주되, 주문이 없으면 NULL)
실제 쿼리:
-- LEFT JOIN: 모든 고객을 보면서, 주문 정보도 함께 표시
SELECT
c.customer_name AS '고객명',
c.city AS '거주지',
c.registration_date AS '가입일',
ISNULL(o.order_id, 0) AS '주문번호',
o.order_date AS '주문일',
ISNULL(o.total_amount, 0) AS '주문금액',
CASE
WHEN o.order_id IS NULL THEN '주문없음'
ELSE o.status
END AS '주문상태'
FROM Customers c
LEFT JOIN Orders o ON c.customer_id = o.customer_id
ORDER BY c.customer_name, o.order_date;
결과 해석:
- 모든 고객(5명)이 결과에 나타남
- 주문이 있는 고객: 주문 정보와 함께 표시
- 주문이 없는 고객: 주문 관련 컬럼은 NULL로 표시
- 최은정, 정우진도 결과에 포함됨 (주문정보는 NULL)
3. RIGHT JOIN - 오른쪽 기준 (오른쪽은 다 보여주고, 왼쪽은 있으면 보여주기)
언제 사용하나?
- LEFT JOIN의 반대 개념
- 실무에서는 LEFT JOIN을 더 많이 사용 (테이블 순서를 바꾸면 되니까)
- 특별한 상황에서만 사용
동작 원리:
왼쪽(Customers): C001, C002, C003, C004, C005
오른쪽(Orders): C001, C001, C002, C002, C003, C999
RIGHT JOIN 결과:
C001 + 주문정보, C001 + 주문정보,
C002 + 주문정보, C002 + 주문정보,
C003 + 주문정보,
NULL + C999주문정보
(모든 주문을 다 보여주되, 고객이 없으면 NULL)
실제 쿼리:
-- RIGHT JOIN: 모든 주문을 보면서, 고객 정보도 함께 표시
SELECT
ISNULL(c.customer_name, '고객정보없음') AS '고객명',
c.city AS '거주지',
o.order_id AS '주문번호',
o.order_date AS '주문일',
o.total_amount AS '주문금액',
o.status AS '주문상태'
FROM Customers c
RIGHT JOIN Orders o ON c.customer_id = o.customer_id
ORDER BY o.order_date;
결과 해석:
- 모든 주문이 결과에 나타남
- C999 주문도 포함됨 (고객정보는 NULL)
- 주문이 없는 고객(최은정, 정우진)은 결과에 없음
4. FULL OUTER JOIN - 합집합 (양쪽 모든 것)
언제 사용하나?
- 양쪽 테이블의 모든 데이터를 다 보고 싶을 때
- 데이터 정합성 체크할 때
- 완전한 전체 현황을 파악할 때
동작 원리:
왼쪽(Customers): C001, C002, C003, C004, C005
오른쪽(Orders): C001, C001, C002, C002, C003, C999
FULL OUTER JOIN 결과:
매칭되는 것: C001, C001, C002, C002, C003
왼쪽만: C004, C005
오른쪽만: C999
실제 쿼리:
-- FULL OUTER JOIN: 모든 고객과 모든 주문을 다 보여주기
SELECT
ISNULL(c.customer_name, '고객정보없음') AS '고객명',
c.city AS '거주지',
ISNULL(o.order_id, 0) AS '주문번호',
o.order_date AS '주문일',
ISNULL(o.total_amount, 0) AS '주문금액',
CASE
WHEN c.customer_id IS NULL THEN '고객없는주문'
WHEN o.order_id IS NULL THEN '주문없는고객'
ELSE '정상'
END AS '상태분류'
FROM Customers c
FULL OUTER JOIN Orders o ON c.customer_id = o.customer_id
ORDER BY c.customer_name, o.order_date;
실무에서 자주 사용하는 JOIN 패턴
1. 고객별 주문 통계 (LEFT JOIN + 집계)
목적: 모든 고객의 주문 통계를 보고 싶다 (주문이 없는 고객도 포함)
SELECT
c.customer_name AS '고객명',
c.city AS '거주지',
c.registration_date AS '가입일',
COUNT(o.order_id) AS '총주문수', -- COUNT는 NULL 제외하고 센다
ISNULL(SUM(o.total_amount), 0) AS '총주문금액',
ISNULL(AVG(o.total_amount), 0) AS '평균주문금액',
MAX(o.order_date) AS '최근주문일',
CASE
WHEN COUNT(o.order_id) = 0 THEN '미주문고객'
WHEN COUNT(o.order_id) >= 2 THEN 'VIP고객'
ELSE '일반고객'
END AS '고객등급'
FROM Customers c
LEFT JOIN Orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name, c.city, c.registration_date
ORDER BY '총주문금액' DESC;
왜 LEFT JOIN인가?
- 주문이 없는 고객도 "0건, 0원"으로 표시하고 싶기 때문
- INNER JOIN을 쓰면 주문 없는 고객은 통계에서 빠짐
2. 월별 주문 현황 (데이터가 없는 달도 0으로 표시)
-- 월별 테이블 생성 (2024년 1월~12월)
WITH MonthList AS (
SELECT 1 AS month_num, N'1월' AS month_name
UNION ALL SELECT 2, N'2월'
UNION ALL SELECT 3, N'3월'
UNION ALL SELECT 4, N'4월'
UNION ALL SELECT 5, N'5월'
UNION ALL SELECT 6, N'6월'
UNION ALL SELECT 7, N'7월'
UNION ALL SELECT 8, N'8월'
UNION ALL SELECT 9, N'9월'
UNION ALL SELECT 10, N'10월'
UNION ALL SELECT 11, N'11월'
UNION ALL SELECT 12, N'12월'
)
SELECT
m.month_name AS '월',
COUNT(o.order_id) AS '주문건수',
ISNULL(SUM(o.total_amount), 0) AS '매출액',
COUNT(DISTINCT o.customer_id) AS '주문고객수'
FROM MonthList m
LEFT JOIN Orders o ON m.month_num = MONTH(o.order_date)
AND YEAR(o.order_date) = 2024
GROUP BY m.month_num, m.month_name
ORDER BY m.month_num;
3. 주문 상태별 분석
-- 고객별 주문 상태 분석
SELECT
c.customer_name AS '고객명',
COUNT(CASE WHEN o.status = 'COMPLETED' THEN 1 END) AS '완료주문',
COUNT(CASE WHEN o.status = 'SHIPPED' THEN 1 END) AS '배송중주문',
COUNT(CASE WHEN o.status = 'PENDING' THEN 1 END) AS '대기주문',
COUNT(o.order_id) AS '총주문수',
CASE
WHEN COUNT(CASE WHEN o.status = 'PENDING' THEN 1 END) > 0 THEN '처리필요'
WHEN COUNT(o.order_id) = 0 THEN '주문없음'
ELSE '정상'
END AS '상태'
FROM Customers c
LEFT JOIN Orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name
ORDER BY '처리필요' DESC, '총주문수' DESC;
JOIN 시 자주 하는 실수와 해결방법
1. 1:N 관계에서 중복 데이터 문제
❌ 문제 상황:
-- 고객 기본정보와 주문을 JOIN했는데, 고객정보가 중복으로 나온다
SELECT
c.customer_name,
c.phone,
o.order_date,
o.total_amount
FROM Customers c
INNER JOIN Orders o ON c.customer_id = o.customer_id;
-- 결과: 김철수가 2번 나옴 (주문이 2건이라서)
✅ 해결방법 1: 목적에 맞게 GROUP BY
-- 고객별 주문 요약 정보만 보고 싶다면
SELECT
c.customer_name,
c.phone,
COUNT(o.order_id) AS '주문건수',
SUM(o.total_amount) AS '총주문금액'
FROM Customers c
INNER JOIN Orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name, c.phone;
✅ 해결방법 2: 중복을 그대로 보되 이해하고 사용
-- 각 주문별 상세정보를 다 보고 싶다면 (중복이 정상)
SELECT
c.customer_name + ' (' + CAST(ROW_NUMBER() OVER(PARTITION BY c.customer_id ORDER BY o.order_date) AS VARCHAR) + '번째 주문)' AS '고객_주문순번',
c.phone,
o.order_date,
o.total_amount
FROM Customers c
INNER JOIN Orders o ON c.customer_id = o.customer_id
ORDER BY c.customer_name, o.order_date;
2. NULL 값 처리 실수
❌ 문제 상황:
-- LEFT JOIN 후 집계할 때 NULL 처리 안 함
SELECT
c.customer_name,
AVG(o.total_amount) AS '평균주문금액' -- 주문없는 고객은 NULL이 됨
FROM Customers c
LEFT JOIN Orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name;
✅ 해결방법:
SELECT
c.customer_name,
CASE
WHEN COUNT(o.order_id) = 0 THEN 0
ELSE AVG(o.total_amount)
END AS '평균주문금액',
ISNULL(SUM(o.total_amount), 0) AS '총주문금액'
FROM Customers c
LEFT JOIN Orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.customer_name;
3. WHERE vs ON 절 혼동
❌ 잘못된 사용:
-- WHERE에 조건을 두면 LEFT JOIN의 의미가 사라짐
SELECT c.customer_name, o.order_id
FROM Customers c
LEFT JOIN Orders o ON c.customer_id = o.customer_id
WHERE o.status = 'COMPLETED'; -- 이렇게 하면 INNER JOIN과 같아짐
✅ 올바른 사용:
-- 방법 1: JOIN 조건에 포함 (LEFT JOIN 의미 유지)
SELECT c.customer_name, o.order_id, o.status
FROM Customers c
LEFT JOIN Orders o ON c.customer_id = o.customer_id
AND o.status = 'COMPLETED';
-- 방법 2: WHERE에서 NULL도 고려
SELECT c.customer_name, o.order_id, o.status
FROM Customers c
LEFT JOIN Orders o ON c.customer_id = o.customer_id
WHERE o.status = 'COMPLETED' OR o.status IS NULL;
JOIN 성능 최적화 팁
1. 적절한 인덱스 생성
-- 조인에 사용되는 컬럼에 인덱스 생성
CREATE INDEX IX_Orders_CustomerID ON Orders(customer_id);
CREATE INDEX IX_Orders_CustomerID_OrderDate ON Orders(customer_id, order_date);
-- 자주 함께 조회되는 컬럼들을 포함하는 커버링 인덱스
CREATE INDEX IX_Orders_Covering
ON Orders(customer_id)
INCLUDE (order_date, total_amount, status);
2. JOIN 순서 고려
-- ✅ 작은 테이블을 먼저 필터링
SELECT c.customer_name, o.total_amount
FROM Customers c
INNER JOIN (
SELECT customer_id, total_amount
FROM Orders
WHERE order_date >= '2024-01-01' -- 먼저 필터링
) o ON c.customer_id = o.customer_id
WHERE c.city = N'서울'; -- 추가 필터링
언제 어떤 JOIN을 사용할까? - 의사결정 가이드
🎯 JOIN 선택 기준
1. INNER JOIN을 사용하는 경우:
- ✅ "연관된 데이터만 보고 싶을 때"
- ✅ "양쪽에 모두 존재하는 확실한 관계만 필요할 때"
- 예: 실제 주문한 고객들의 주문 상세내역
2. LEFT JOIN을 사용하는 경우:
- ✅ "기준 테이블의 모든 데이터를 보고 싶을 때"
- ✅ "없는 것도 0 또는 NULL로 표시하고 싶을 때"
- ✅ "통계나 리포트를 만들 때"
- 예: 모든 고객의 주문 통계 (주문 없는 고객도 포함)
3. RIGHT JOIN을 사용하는 경우:
- ✅ LEFT JOIN으로 쓸 수 있지만 테이블 순서가 고정된 경우
- 예: 모든 주문의 고객 정보 (고객 정보 없는 주문도 포함)
4. FULL OUTER JOIN을 사용하는 경우:
- ✅ "양쪽의 모든 데이터를 다 보고 싶을 때"
- ✅ "데이터 정합성을 체크할 때"
- 예: 고객-주문 데이터의 불일치 찾기
📊 실무 사용 빈도
- LEFT JOIN (60%) - 가장 많이 사용
- INNER JOIN (35%) - 두 번째로 많이 사용
- RIGHT JOIN (4%) - 가끔 사용
- FULL OUTER JOIN (1%) - 특별한 경우만 사용
마무리
JOIN은 SQL의 핵심 기능입니다. 각 JOIN의 특성을 정확히 이해하고, 목적에 맞게 사용하는 것이 중요합니다.
🔑 핵심 포인트
- INNER JOIN: 양쪽 모두 있는 것만 - "확실한 관계"
- LEFT JOIN: 왼쪽 기준으로 전부 - "전체 현황 파악"
- RIGHT JOIN: 오른쪽 기준으로 전부 - "LEFT JOIN의 반대"
- FULL OUTER JOIN: 양쪽 전부 - "완전한 전체 보기"
실무에서는 LEFT JOIN을 가장 많이 사용하니, LEFT JOIN부터 확실히 익히시기 바랍니다!