icon

안동민 개발노트

5장 : SQL 심화 — 조인과 서브쿼리

INNER JOIN


현실의 데이터는 하나의 테이블에 다 넣을 수 없습니다. 회원 정보는 users 테이블에, 주문 정보는 orders 테이블에 있습니다. 김철수가 주문한 상품을 알려면, 두 테이블을 연결(Join)해야 합니다. 조인은 SQL에서 가장 중요하고 자주 사용되는 연산이며, 관계형 데이터베이스가 데이터를 정규화하여 여러 테이블로 분산할 수 있는 것도 조인이 있기 때문입니다.


조인이 필요한 이유

정규화된 데이터베이스에서는 하나의 개체가 여러 테이블에 걸쳐 저장됩니다. 김철수의 주문 내역을 알려면 users 테이블에서 김철수를 찾고, orders 테이블에서 해당 회원의 주문을 찾아 두 결과를 합쳐야 합니다.

정규화된 테이블 구조
users 테이블
┌────┬────────┐
│ id │  name  │
├────┼────────┤
│ 1  │ 김철수 │
│ 2  │ 이영희 │
│ 3  │ 박민수 │
└────┴────────┘

orders 테이블
┌─────┬─────────┬────────┐
│ id  │ user_id │ status │
├─────┼─────────┼────────┤
│  1  │    1    │  배송  │
│  2  │    1    │  결제  │
│  3  │    2    │  배송  │
└─────┴─────────┴────────┘

orders.user_id → users.id

조인하지 않으면?
  SELECT * FROM users;    → 이름만 나옴, 주문 정보 없음
  SELECT * FROM orders;   → 주문만 나옴, 누군지 모름

조인하면!
  김철수 | 주문1 | 배송
  김철수 | 주문2 | 결제
  이영희 | 주문3 | 배송

만약 조인 없이 하나의 테이블에 모든 데이터를 넣으면 어떻게 될까요?

비정규화 시 문제
┌────┬────────┬──────────┬─────────┬────────┐
│ id │  name  │ email    │ order_id│ status │
├────┼────────┼──────────┼─────────┼────────┤
│ 1  │ 김철수 │ k@m.com  │    1    │  배송  │
│ 1  │ 김철수 │ k@m.com  │    2    │  결제  │  ← 이름, email 중복!
│ 2  │ 이영희 │ l@m.com  │    3    │  배송  │
└────┴────────┴──────────┴─────────┴────────┘

문제:
1. 데이터 중복 → 저장 공간 낭비
2. 이상 현상 → 김철수 이메일 변경 시 2행을 모두 수정해야 함
3. 삭제 이상 → 이영희 주문 삭제 시 이영희 정보도 사라짐

조인이 있기에 데이터를 정규화하여 중복 없이 저장하고, 필요할 때 연결하여 결합된 정보를 얻을 수 있습니다.


INNER JOIN 기본 구문

INNER JOIN은 두 테이블에서 조인 조건이 일치하는 행만 반환합니다. 일치하지 않는 행은 결과에 포함되지 않습니다.

명시적 JOIN (표준, 권장)
SELECT u.username, o.order_id, o.status
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id;
암시적 JOIN (Oracle 전통 방식)
SELECT u.username, o.order_id, o.status
FROM users u, orders o
WHERE u.user_id = o.user_id;

두 방식의 결과는 동일하지만, 명시적 JOIN이 의도가 더 명확하고 실수를 줄여줍니다. 암시적 JOIN에서 WHERE를 빼먹으면 카테시안 곱(모든 행의 조합)이 되어 결과가 폭발합니다.

INNER JOIN 동작 과정
1. users 테이블의 각 행에 대해
2. orders 테이블에서 user_id가 같은 행을 찾고
3. 일치하는 행끼리 결합하여 결과 행을 생성
4. 일치하지 않는 행(박민수)은 제외

users             orders              결과
id=1, 김철수  ↔   user_id=1, 주문1  →  김철수 | 주문1 | 배송
              ↔   user_id=1, 주문2  →  김철수 | 주문2 | 결제
id=2, 이영희  ↔   user_id=2, 주문3  →  이영희 | 주문3 | 배송
id=3, 박민수  ✗   (일치 없음)        →  결과에 없음

INNER 키워드는 생략할 수 있습니다. JOIN만 쓰면 INNER JOIN과 동일합니다.


테이블 별칭 (Alias)

조인할 때는 테이블에 별칭(alias)을 부여하는 것이 관례입니다. 코드가 간결해지고, 어느 테이블의 컬럼인지 명확해집니다.

별칭 사용법
-- 별칭 없이 (verbose)
SELECT users.username, orders.order_id
FROM users
INNER JOIN orders ON users.user_id = orders.user_id;

-- 별칭 사용 (간결)
SELECT u.username, o.order_id
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id;

-- AS 키워드를 사용해도 됨 (선택)
SELECT u.username, o.order_id
FROM users AS u
INNER JOIN orders AS o ON u.user_id = o.user_id;

두 테이블에 같은 이름의 컬럼이 있으면, 반드시 테이블명.컬럼명 또는 별칭.컬럼명으로 구분해야 합니다. 그렇지 않으면 모호성(ambiguity) 오류가 발생합니다.

별칭은 해당 쿼리 내에서만 유효합니다. FROM 절에서 별칭을 정의하면 SELECT, WHERE, ON, GROUP BY, HAVING, ORDER BY 절 어디에서든 사용할 수 있습니다. 그러나 원래 테이블명 대신 별칭을 사용하면, 그 쿼리 안에서는 원래 테이블명으로 참조할 수 없는 DBMS(예: Oracle)도 있으므로 주의합니다.

별칭 사용 시 주의 (Oracle)
-- Oracle에서는 별칭을 정의하면 원래 이름 사용 불가
SELECT users.username  -- 오류! 별칭 u를 정의했으므로
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id;
-- ORA-00904: "USERS"."USERNAME": invalid identifier
모호성 오류
-- ✗ 오류: user_id가 양쪽 테이블에 모두 존재
SELECT user_id, username, order_id
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id;
-- ERROR: Column 'user_id' in field list is ambiguous

-- ✓ 수정: 별칭으로 명확히 지정
SELECT u.user_id, u.username, o.order_id
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id;

ON 조건 vs WHERE 조건

ON 절에는 조인 조건을, WHERE 절에는 결과 필터 조건을 작성합니다. INNER JOIN에서는 두 곳에 동일한 조건을 써도 결과가 같지만, 의미를 명확히 분리하는 것이 좋습니다.

ON과 WHERE 분리
-- ON: 어떤 행을 결합할지 (조인 조건)
-- WHERE: 결합된 결과에서 어떤 행을 선택할지 (필터 조건)

SELECT u.username, o.order_id, o.status
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id   -- 조인 조건
WHERE o.status = 'DELIVERED';                    -- 필터 조건
ON vs WHERE 실행 순서
FROM users u                           1. 테이블 결정
INNER JOIN orders o                    2. 조인 대상 결정
ON u.user_id = o.user_id              3. 조인 조건으로 결합
WHERE o.status = 'DELIVERED'           4. 결합 결과 필터링

INNER JOIN에서는 ON에 쓰든 WHERE에 쓰든 결과가 같을 수 있지만, OUTER JOIN에서는 완전히 다른 결과를 냅니다. 따라서 조인 조건은 반드시 ON에, 필터 조건은 WHERE에 쓰는 습관을 들이는 것이 중요합니다.


동등 조인과 비동등 조인

동등 조인 (Equi-Join)

가장 일반적인 형태로, ON 절에 등호(=)를 사용합니다.

동등 조인
SELECT e.name, d.dept_name
FROM employees e
INNER JOIN departments d ON e.dept_id = d.dept_id;

비동등 조인 (Non-Equi Join)

등호가 아닌 비교 연산자(<, >, <=, >=, BETWEEN)를 사용하는 조인입니다. 범위 기반 매칭에 사용됩니다.

비동등 조인 — 급여 등급 매칭
-- 급여 등급 테이블
-- grade | low_sal | high_sal
-- 1     | 0       | 2000
-- 2     | 2001    | 4000
-- 3     | 4001    | 6000

SELECT e.name, e.salary, g.grade
FROM employees e
INNER JOIN salary_grades g
    ON e.salary BETWEEN g.low_sal AND g.high_sal;
결과
name   | salary | grade
-------|--------|------
최사원  | 1800   | 1
박대리  | 3200   | 2
김과장  | 4500   | 3

비동등 조인에서는 인덱스 활용이 어려워 대용량 데이터에서 성능이 떨어질 수 있습니다.


자연 조인 (Natural Join)

같은 이름의 컬럼을 자동으로 조인 조건에 사용합니다. ON 절이 필요 없어 간결하지만, 예상치 못한 동작의 원인이 될 수 있습니다.

자연 조인
-- 두 테이블에 동일 이름 컬럼(user_id)으로 자동 조인
SELECT username, order_id
FROM users
NATURAL JOIN orders;

나중에 테이블에 같은 이름의 컬럼(예: created_at)이 추가되면, 의도하지 않게 추가 조인 조건이 생겨 결과가 바뀝니다. 따라서 명시적 ON 절을 사용하는 것을 권장합니다.


다중 테이블 조인

3개 이상의 테이블을 연결할 수 있습니다. 테이블 간의 관계를 따라 순서대로 JOIN을 추가합니다.

3개 테이블 조인
-- "누가, 무엇을, 얼마에 주문했나?"
SELECT u.username,
       p.name AS product,
       oi.quantity,
       oi.unit_price,
       oi.quantity * oi.unit_price AS total
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id
INNER JOIN order_items oi ON o.order_id = oi.order_id
INNER JOIN products p ON oi.product_id = p.product_id;
조인 순서 시각화
users ──→ orders ──→ order_items ──→ products
  u.user_id   o.order_id   oi.product_id
  = o.user_id = oi.order_id = p.product_id

4개 테이블을 조인한다고 해서 쿼리가 느려지는 것은 아닙니다. 적절한 인덱스가 있으면 옵티마이저가 효율적인 실행 계획을 세웁니다. 하지만 5개 이상의 테이블을 조인하는 쿼리가 자주 필요하다면, 테이블 설계를 재검토해 볼 필요가 있습니다.

4개 테이블 조인 예시
-- 주문 상세 리포트: 회원명, 주문일, 상품명, 카테고리명, 수량
SELECT u.username, o.order_date,
       p.name AS product_name,
       c.category_name,
       oi.quantity
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id
INNER JOIN order_items oi ON o.order_id = oi.order_id
INNER JOIN products p ON oi.product_id = p.product_id
INNER JOIN categories c ON p.category_id = c.category_id
WHERE o.order_date >= '2024-01-01'
ORDER BY o.order_date DESC;

자기 조인 (Self Join)

같은 테이블을 두 번 참조하여 조인하는 것을 자기 조인이라 합니다. 계층 구조(조직도, 카테고리 트리)를 표현할 때 자주 사용됩니다.

자기 조인 — 조직도
-- employees 테이블
-- emp_id | name   | manager_id
-- 1      | 김대표  | NULL
-- 2      | 이부장  | 1
-- 3      | 박과장  | 2
-- 4      | 최사원  | 3

SELECT e.name AS 직원명,
       m.name AS 상사명
FROM employees e
INNER JOIN employees m ON e.manager_id = m.emp_id;
결과
직원명  | 상사명
--------|--------
이부장  | 김대표
박과장  | 이부장
최사원  | 박과장

주의할 점: 김대표는 manager_id가 NULL이므로 INNER JOIN에서 제외됩니다. 최상위 노드를 포함하려면 LEFT JOIN을 사용합니다.

자기 조인 — 같은 부서 동료 찾기
SELECT a.name AS 직원1, b.name AS 직원2
FROM employees a
INNER JOIN employees b ON a.dept_id = b.dept_id
WHERE a.emp_id < b.emp_id;  -- 중복 쌍 방지

조인과 인덱스

조인 성능은 조인 컬럼의 인덱스 유무에 크게 좌우됩니다. 외래키 컬럼에는 반드시 인덱스를 생성해야 합니다.

조인 알고리즘
1. Nested Loop Join
   바깥 테이블의 각 행에 대해 안쪽 테이블을 탐색
   안쪽 테이블에 인덱스가 있으면 매우 효율적
   소규모 결과에 적합

2. Hash Join
   작은 테이블로 해시 테이블을 만들고
   큰 테이블을 스캔하며 해시 테이블에서 매칭
   동등 조인에만 사용 가능
   대용량 테이블에 적합

3. Sort-Merge Join
   양쪽 테이블을 조인 컬럼으로 정렬한 뒤
   동시에 스캔하며 매칭
   이미 정렬된 데이터에 효율적
인덱스와 조인 성능
-- orders.user_id에 인덱스가 없으면
-- 매 users 행마다 orders 전체를 스캔 → O(n × m)
SELECT u.username, o.order_id
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id;

-- 인덱스 생성 후
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- → Nested Loop + Index Lookup → O(n × log m)
실행 계획 확인
-- MySQL
EXPLAIN SELECT u.username, o.order_id
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id;

-- PostgreSQL
EXPLAIN ANALYZE SELECT u.username, o.order_id
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id;

실행 계획에서 type: ALL(Full Table Scan)이 보이면 인덱스가 없거나 사용되지 않는 것입니다. 대용량 테이블에서 이는 심각한 성능 문제를 일으킬 수 있습니다.


조인 시 주의사항

JOIN 관련 흔한 실수
1. 조인 조건 누락
   FROM users, orders     ← WHERE 없으면 카테시안 곱
   → INNER JOIN + ON 사용으로 예방

2. 모호한 컬럼 참조
   SELECT user_id         ← 양쪽 테이블에 모두 존재
   → 별칭.컬럼 형식으로 명시

3. 불필요한 컬럼 조회
   SELECT * FROM users u INNER JOIN orders o ...
   → 필요한 컬럼만 명시 (SELECT u.name, o.order_id)

4. N+1 쿼리 (ORM 사용 시)
   첫 쿼리로 users를 가져온 후
   각 user마다 orders를 별도 쿼리 → N+1번 실행
   → JOIN으로 한 번에 조회 또는 Eager Loading 사용

5. 과도한 조인
   5개 이상의 테이블을 자주 조인
   → 테이블 설계 재검토 또는 뷰(VIEW) 활용

조인과 집합 연산의 관계

조인은 관계 대수의 카테시안 곱(×)에 선택(σ) 연산을 결합한 것으로 볼 수 있습니다. 이 관계를 이해하면 조인의 원리를 더 깊이 파악할 수 있습니다.

조인 = 카테시안 곱 + 선택
INNER JOIN
  users ⋈ (user_id = user_id) orders
= σ(user_id = user_id) (users × orders)

1. users × orders → 모든 행의 조합 생성 (3 × 3 = 9행)
2. σ(조건) → user_id가 같은 행만 필터링 (3행)

실제 DBMS는 카테시안 곱을 먼저 만들지 않습니다. 옵티마이저가 인덱스나 해시를 사용하여 일치하는 행만 효율적으로 찾습니다. 하지만 논리적으로는 동일한 결과입니다.


조인과 뷰 (VIEW)

자주 사용하는 조인 쿼리를 뷰로 저장하면 코드 중복을 줄이고 가독성을 높일 수 있습니다.

조인 뷰 생성
CREATE VIEW order_details AS
SELECT u.username,
       u.email,
       o.order_id,
       o.order_date,
       o.status,
       p.name AS product_name,
       oi.quantity,
       oi.unit_price
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id
INNER JOIN order_items oi ON o.order_id = oi.order_id
INNER JOIN products p ON oi.product_id = p.product_id;
뷰 사용
-- 복잡한 조인을 매번 작성하지 않아도 됨
SELECT username, product_name, quantity
FROM order_details
WHERE status = 'DELIVERED'
ORDER BY order_date DESC;

뷰는 쿼리를 저장할 뿐, 데이터를 별도로 저장하지는 않습니다. 뷰를 조회할 때마다 내부의 조인이 실행됩니다. 성능이 중요하면 물리화된 뷰(Materialized View)를 사용하여 결과를 캐시할 수 있습니다.


실무 조인 패턴

최신 주문만 조인

각 회원의 최신 주문만 조인
SELECT u.username, o.order_id, o.order_date, o.status
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id
WHERE o.order_date = (
    SELECT MAX(o2.order_date)
    FROM orders o2
    WHERE o2.user_id = u.user_id
);

조인과 GROUP BY 결합

회원별 주문 통계
SELECT u.username,
       COUNT(o.order_id) AS order_count,
       SUM(o.total_amount) AS total_spent,
       AVG(o.total_amount) AS avg_order
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id
GROUP BY u.username
HAVING COUNT(o.order_id) >= 2
ORDER BY total_spent DESC;

다대다(M:N) 관계 조인

다대다 관계는 중간 테이블(연결 테이블)을 통해 조인합니다.

학생-수강과목 다대다 조인
-- students ←→ enrollments ←→ courses
SELECT s.student_name, c.course_name, e.grade
FROM students s
INNER JOIN enrollments e ON s.student_id = e.student_id
INNER JOIN courses c ON e.course_id = c.course_id
WHERE e.semester = '2024-1';
다대다 관계 구조
students
┌──────┐
│ s_id │
│ name │
└──┬───┘
   │ 1:N

enrollments
┌──────────┐
│ s_id     │
│ c_id     │
│ grade    │
└──┬───────┘
   │ N:1

courses
┌──────┐
│ c_id │
│ name │
└──────┘

INNER JOIN 정리

INNER JOIN 핵심 요약
┌────────────────────────────────────────────────────────┐
│ INNER JOIN: 양쪽 테이블에서 조건이 일치하는 행만 반환  │
├────────────────────────────────────────────────────────┤
│ 구문: FROM A INNER JOIN B ON A.col = B.col             │
│ INNER 생략 가능: JOIN B ON ... = INNER JOIN            │
│ 별칭: FROM users u JOIN orders o (간결, 가독성)        │
├────────────────────────────────────────────────────────┤
│ 동등 조인: ON A.id = B.id (가장 일반적)                │
│ 비동등 조인: ON A.sal BETWEEN B.low AND B.high         │
│ 자연 조인: NATURAL JOIN (비권장)                       │
│ 자기 조인: FROM A a1 JOIN A a2 ON a1.id = a2.parent_id │
├────────────────────────────────────────────────────────┤
│ 성능: 조인 컬럼에 인덱스 필수                          │
│ 알고리즘: Nested Loop, Hash, Sort-Merge                │
│ ON: 조인 조건 / WHERE: 필터 조건                       │
│ 뷰: 반복 조인을 VIEW로 저장하여 재사용                 │
└────────────────────────────────────────────────────────┘

다음 절에서는 일치하지 않는 행도 포함하는 OUTER JOIN을 다루겠습니다.

목차