SQL JOIN 완벽 가이드 – 테이블 연결의 모든 것 (INNER, LEFT, RIGHT, FULL)

데이터베이스에서 여러 테이블의 정보를 한 번에 조회해야 할 때가 많죠? 회원 정보와 주문 정보가 각각 다른 테이블에 있을 때, 둘을 연결해서 “누가 무엇을 주문했는지” 확인하려면 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 JOINLEFT 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 쿼리 작성 팁이 있다면 댓글로 공유해주세요!


참고 자료:

댓글 남기기