SQL JOIN으로 관계형 테이블을 연결하는 방법
SQL JOIN의 동작 방식과 INNER, OUTER, CROSS, SELF JOIN의 차이, 다중 테이블 조회와 성능 최적화 방법을 정리한다.
2026-08-14 · 최초 발행 2025-08-10
분리된 테이블을 하나의 조회 결과로 묶는 방식
관계형 데이터베이스는 정규화를 거치면서 데이터를 여러 테이블에 나눠 저장한다. 조회 시에는 기본키와 외래키처럼 서로 연결되는 칼럼을 기준으로 행을 결합해야 하며, 이때 사용하는 연산이 JOIN이다.
다음처럼 직원 정보와 부서 정보가 각각 분리되어 있다고 가정한다.
[Employees 테이블]
emp_id | name | dept_id
-----------------------
1 | 홍길동 | 10
2 | 김철수 | 20
3 | 이영희 | 10
4 | 박민수 | 30
[Departments 테이블]
dept_id | dept_name
------------------
10 | 개발부
20 | 마케팅부
30 | 인사부
40 | 재무부
직원의 이름과 소속 부서를 같은 결과에서 보려면 두 테이블의 dept_id를 연결한다.
일치하는 행만 남기는 INNER JOIN
INNER JOIN은 조인 조건을 만족하는 행만 결과로 반환한다. 두 테이블 모두에 대응하는 값이 있을 때 적합하다.
SELECT e.name, d.dept_name
FROM Employees e
INNER JOIN Departments d ON e.dept_id = d.dept_id;
조회 결과에는 양쪽 테이블에서 연결된 직원과 부서만 포함된다.
name | dept_name
------------------
홍길동 | 개발부
김철수 | 마케팅부
이영희 | 개발부
박민수 | 인사부
어느 테이블의 행을 보존할지 결정하는 외부 조인
LEFT JOIN은 왼쪽 테이블의 모든 행을 유지한다. 오른쪽 테이블에서 조건에 맞는 행을 찾지 못하면 오른쪽 칼럼은 NULL이 된다. 예를 들어 Employees에 dept_id가 50인 직원이 있다면 해당 직원은 결과에 남고 dept_name은 NULL로 표시된다.
SELECT e.name, d.dept_name
FROM Employees e
LEFT JOIN Departments d ON e.dept_id = d.dept_id;
RIGHT JOIN은 반대로 오른쪽 테이블의 모든 행을 남긴다. Departments의 모든 부서를 조회해야 할 때 사용할 수 있으며, 직원이 없는 부서(예: 재무부)는 name이 NULL로 나타난다.
SELECT e.name, d.dept_name
FROM Employees e
RIGHT JOIN Departments d ON e.dept_id = d.dept_id;
FULL JOIN은 양쪽 테이블의 전체 행을 포함한다. 어느 한쪽에만 존재하는 행은 반대편 칼럼을 NULL로 채운다.
SELECT e.name, d.dept_name
FROM Employees e
FULL JOIN Departments d ON e.dept_id = d.dept_id;
모든 조합과 테이블 내부 관계
CROSS JOIN은 조인 조건 없이 두 테이블의 카테시안 곱(Cartesian product)을 만든다. Employees의 각 행은 Departments의 모든 행과 결합하므로, Employees의 행 수(4) × Departments의 행 수(4) = 16개의 행이 생성된다.
SELECT e.name, d.dept_name
FROM Employees e
CROSS JOIN Departments d;
SELF JOIN은 하나의 테이블을 자신과 다시 조인하는 방식이다. 계층 구조나 같은 테이블 안의 연관 관계를 확인할 때 쓴다. 직원과 관리자 관계는 다음처럼 조회할 수 있다.
SELECT e1.name AS Employee, e2.name AS Manager
FROM Employees e1
LEFT JOIN Employees e2 ON e1.manager_id = e2.emp_id;
조건식과 여러 테이블을 연결하는 쿼리
Non-Equi Join은 = 대신 범위나 대소 비교 연산자를 조인 조건으로 사용한다. 급여 구간에 따라 등급을 연결하는 경우가 이에 해당한다.
SELECT e.name, s.salary_grade
FROM Employees e
JOIN Salary_Grades s ON e.salary BETWEEN s.min_salary AND s.max_salary;
조회 대상이 세 개 이상의 테이블로 늘어나도 JOIN을 이어서 사용할 수 있다.
SELECT e.name, d.dept_name, p.project_name
FROM Employees e
JOIN Departments d ON e.dept_id = d.dept_id
JOIN Projects p ON e.emp_id = p.emp_id;
조인 성능은 조건에 쓰이는 칼럼의 인덱스, 조인 순서, 필터링 시점에 영향을 받는다. 작은 테이블을 먼저 조인해 중간 결과셋을 줄이고, 필요한 데이터만 연결하도록 WHERE 절을 함께 사용한다.
SELECT e.name, d.dept_name
FROM Employees e
JOIN Departments d ON e.dept_id = d.dept_id
WHERE e.hire_date > '2020-01-01';
대용량 테이블을 연결할 때는 성능 저하가 발생할 수 있다. 복잡한 다중 조인에서는 실행 계획을 확인하고, 조인 칼럼에 적절한 인덱스가 있는지 검토해야 한다. 외부 조인 결과의 NULL 처리와 조인 조건이 없는 CROSS JOIN으로 인한 데이터 증가도 함께 점검할 대상이다.
DBMS별 구문 차이
대부분의 현대 DBMS는 ANSI SQL 표준 JOIN 구문을 지원한다. 다만 Oracle에는 WHERE 절에 조인 조건을 적는 전통적 문법이 있으며, 다음과 같이 표현한다.
-- Oracle 구식 문법 (INNER JOIN)
SELECT e.name, d.dept_name
FROM Employees e, Departments d
WHERE e.dept_id = d.dept_id;
-- Oracle 구식 문법 (LEFT JOIN)
SELECT e.name, d.dept_name
FROM Employees e, Departments d
WHERE e.dept_id = d.dept_id(+);
MySQL은 RIGHT JOIN과 LEFT JOIN은 지원하지만, FULL JOIN은 직접 지원하지 않는다. 이 경우 UNION을 사용해 구현할 수 있다.
-- MySQL에서 FULL JOIN 구현
SELECT e.name, d.dept_name
FROM Employees e
LEFT JOIN Departments d ON e.dept_id = d.dept_id
UNION
SELECT e.name, d.dept_name
FROM Employees e
RIGHT JOIN Departments d ON e.dept_id = d.dept_id
WHERE e.emp_id IS NULL;
조회, 검증, 분석용 모델에서의 활용
JOIN은 고객, 주문, 상품처럼 분리된 데이터를 결합해 리포트를 만들 때 쓰인다.
SELECT c.customer_name, o.order_date, p.product_name, oi.quantity, p.price
FROM Customers c
JOIN Orders o ON c.customer_id = o.customer_id
JOIN Order_Items oi ON o.order_id = oi.order_id
JOIN Products p ON oi.product_id = p.product_id
WHERE o.order_date BETWEEN '2023-01-01' AND '2023-12-31';
외래키 제약이 없는 환경에서는 LEFT JOIN으로 연결 대상이 없는 데이터를 찾아 무결성을 확인할 수 있다.
-- 외래키 제약이 없는 환경에서 불일치 데이터 찾기
SELECT e.emp_id, e.name, e.dept_id
FROM Employees e
LEFT JOIN Departments d ON e.dept_id = d.dept_id
WHERE d.dept_id IS NULL AND e.dept_id IS NOT NULL;
데이터 웨어하우스에서는 사실 테이블과 차원 테이블을 연결해 다차원 분석용 데이터 마트를 구성한다.
CREATE VIEW Sales_Analysis AS
SELECT
d.year, d.month, d.day,
p.category, p.sub_category,
c.region, c.country,
SUM(f.sales_amount) as total_sales,
COUNT(f.order_id) as order_count
FROM FactSales f
JOIN DimDate d ON f.date_key = d.date_key
JOIN DimProduct p ON f.product_key = p.product_key
JOIN DimCustomer c ON f.customer_key = c.customer_key
GROUP BY d.year, d.month, d.day, p.category, p.sub_category, c.region, c.country;