안동민 개발노트 아이콘

안동민 개발노트

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

INNER JOIN

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


조인이 필요한 이유

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

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

조인이 있기에 데이터를 정규화하여 중복을 줄이고 일관성을 관리하며, 필요할 때 연결하여 결합된 정보를 얻을 수 있습니다.


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 (레거시 방식)
SELECT u.username, o.order_id, o.status
FROM users u, orders o
WHERE u.user_id = o.user_id;

두 방식의 결과는 동일하지만, 명시적 JOIN이 의도가 더 명확하고 실수를 줄여줍니다. 암시적 JOIN은 오래된 SQL에서 흔히 보이지만 새로 작성하는 쿼리에는 권장하지 않습니다. 암시적 JOIN에서 WHERE를 빼먹으면 카테시안 곱(모든 행의 조합)이 되어 결과가 폭발합니다.

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

조인 조건에 사용하는 컬럼이 NULL이면 = 비교에서 일치하지 않습니다. 예를 들어 orders.user_id가 NULL인 행은 u.user_id = o.user_id 조건을 만족하지 않으므로 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;

-- PostgreSQL/MySQL/SQL Server 등: 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은 테이블 별칭 앞의 AS를 허용하지 않으므로 FROM users u처럼 씁니다. 또한 별칭을 정의한 뒤에는 그 쿼리 안에서 원래 테이블명으로 참조할 수 없는 DBMS도 있으므로 별칭을 일관되게 사용하는 것이 안전합니다.

별칭 사용 시 주의
-- Oracle에서는 테이블 별칭에 AS를 쓰지 않음
SELECT u.username
FROM users u;

-- 별칭을 정의한 뒤 원래 테이블명을 다시 쓰면 오류가 나는 DBMS가 있음
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개 테이블을 조인한다고 해서 곧바로 느려지는 것은 아닙니다. 적절한 인덱스와 선택도 높은 조건이 있으면 옵티마이저가 효율적인 실행 계획을 세울 수 있습니다. 다만 많은 테이블을 조인하는 쿼리가 자주 느리거나 읽기 어렵다면, 쿼리 목적과 테이블 설계를 함께 점검해야 합니다.

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 >= 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;  -- 중복 쌍 방지

조인과 인덱스

조인 성능은 조인 컬럼의 인덱스 유무에 크게 좌우됩니다. 외래키 컬럼은 조회·조인·삭제 검증에 자주 쓰이므로 인덱스 후보로 우선 검토해야 합니다. MySQL은 외래키 제약에 필요한 인덱스를 요구하거나 자동 생성하지만, PostgreSQL과 SQL Server는 참조하는 쪽 외래키 인덱스를 자동으로 만들어주지 않으므로 별도 설계가 필요합니다.

조인 알고리즘
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;

MySQL 실행 계획에서 type: ALL(Full Table Scan)이 보이면 해당 테이블을 전체 스캔한다는 뜻입니다. 작은 테이블에서는 괜찮을 수 있지만, 대용량 테이블에서는 조인 조건과 인덱스 사용 여부를 확인해야 합니다.


조인 시 주의사항


조인과 집합 연산의 관계

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

실제 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
);

이 패턴은 같은 회원의 주문이 같은 날짜에 여러 건 있으면 여러 행을 반환할 수 있습니다. 실무에서는 order_dateorder_id를 함께 사용하거나, 다음 장에서 다룰 윈도우 함수 ROW_NUMBER()로 회원별 1건만 고르는 방식이 더 안전합니다.

조인과 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.user_id, 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';

INNER JOIN 정리

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