🗄️ 데이터베이스

조인

JOIN

두 개 이상의 테이블을 연결하여 관련 데이터를 결합하는 SQL 연산입니다. INNER, LEFT, RIGHT, FULL OUTER JOIN 등 다양한 유형으로 테이블 간 관계를 표현하고 필요한 데이터를 추출합니다.

📖 상세 설명

INNER JOIN은 가장 많이 사용되는 조인으로, 두 테이블에서 조인 조건을 만족하는 행만 반환합니다. 예를 들어 주문 테이블과 고객 테이블을 INNER JOIN하면 주문이 있는 고객만 결과에 포함됩니다. 양쪽 모두에 데이터가 있는 경우만 결과로 나옵니다.

LEFT JOIN (LEFT OUTER JOIN)은 왼쪽 테이블의 모든 행을 반환하고, 오른쪽 테이블에서 일치하는 행이 없으면 NULL로 채웁니다. "모든 고객과 그들의 주문(주문이 없어도)"을 조회할 때 사용합니다. RIGHT JOIN은 반대로 오른쪽 테이블 기준입니다.

FULL OUTER JOIN은 양쪽 테이블의 모든 행을 반환합니다. 일치하지 않는 행은 NULL로 채워집니다. MySQL은 FULL OUTER JOIN을 직접 지원하지 않아 LEFT JOIN과 RIGHT JOIN의 UNION으로 구현합니다.

CROSS JOIN은 두 테이블의 카르테시안 곱(모든 조합)을 반환합니다. 조인 조건이 없으며, 10행 x 10행 = 100행이 됩니다. 테스트 데이터 생성이나 특수한 경우에 사용되지만, 대용량 테이블에서는 위험합니다.

SELF JOIN은 테이블이 자기 자신과 조인하는 것입니다. 조직도에서 직원-관리자 관계, 게시글의 답글 구조 등 계층적 데이터를 표현할 때 사용합니다. 별칭(Alias)을 사용해 같은 테이블을 구분합니다.

💻 코드 예제

-- SQL 조인 종류별 예제

-- 샘플 테이블 구조
-- customers: id, name, email
-- orders: id, customer_id, total_amount, created_at
-- products: id, name, price, category_id
-- order_items: order_id, product_id, quantity


-- ============================================
-- 1. INNER JOIN - 일치하는 행만 반환
-- ============================================
-- 주문이 있는 고객만 조회
SELECT
    c.id AS customer_id,
    c.name AS customer_name,
    o.id AS order_id,
    o.total_amount
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id;

-- 여러 테이블 INNER JOIN
SELECT
    c.name AS customer_name,
    o.id AS order_id,
    p.name AS product_name,
    oi.quantity
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
INNER JOIN order_items oi ON o.id = oi.order_id
INNER JOIN products p ON oi.product_id = p.id
WHERE o.created_at >= '2024-01-01';


-- ============================================
-- 2. LEFT JOIN - 왼쪽 테이블 전체 + 일치하는 오른쪽
-- ============================================
-- 모든 고객과 그들의 주문 (주문 없어도 포함)
SELECT
    c.id,
    c.name,
    COUNT(o.id) AS order_count,
    COALESCE(SUM(o.total_amount), 0) AS total_spent
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name;

-- 주문이 없는 고객만 찾기
SELECT c.id, c.name, c.email
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
WHERE o.id IS NULL;


-- ============================================
-- 3. RIGHT JOIN - 오른쪽 테이블 전체 + 일치하는 왼쪽
-- ============================================
-- 모든 주문과 고객 정보 (고객 정보 없어도 포함)
SELECT
    o.id AS order_id,
    o.total_amount,
    c.name AS customer_name
FROM customers c
RIGHT JOIN orders o ON c.id = o.customer_id;


-- ============================================
-- 4. FULL OUTER JOIN - 양쪽 모두 포함
-- ============================================
-- PostgreSQL
SELECT
    c.name AS customer_name,
    o.id AS order_id
FROM customers c
FULL OUTER JOIN orders o ON c.id = o.customer_id;

-- MySQL (FULL OUTER JOIN 미지원 - UNION 사용)
SELECT c.name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
UNION
SELECT c.name, o.id AS order_id
FROM customers c
RIGHT JOIN orders o ON c.id = o.customer_id;


-- ============================================
-- 5. CROSS JOIN - 모든 조합 (카르테시안 곱)
-- ============================================
-- 모든 제품-카테고리 조합 생성
SELECT
    p.name AS product_name,
    cat.name AS category_name
FROM products p
CROSS JOIN categories cat;

-- 날짜 범위 생성 (PostgreSQL)
SELECT d::date
FROM generate_series('2024-01-01', '2024-12-31', '1 day'::interval) d;


-- ============================================
-- 6. SELF JOIN - 자기 자신과 조인
-- ============================================
-- 직원과 관리자 관계
SELECT
    e.name AS employee_name,
    m.name AS manager_name
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;

-- 같은 카테고리의 다른 제품 찾기
SELECT
    p1.name AS product,
    p2.name AS related_product
FROM products p1
INNER JOIN products p2 ON p1.category_id = p2.category_id
    AND p1.id != p2.id;


-- ============================================
-- 7. 조인 성능 최적화
-- ============================================
-- 인덱스 생성 (조인 컬럼에 필수)
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);

-- EXPLAIN으로 실행 계획 확인
EXPLAIN ANALYZE
SELECT c.name, COUNT(o.id)
FROM customers c
LEFT JOIN orders o ON c.id = o.customer_id
GROUP BY c.name;

-- 서브쿼리 대신 조인 사용 (성능 향상)
-- 나쁜 예: 서브쿼리
SELECT name FROM customers
WHERE id IN (SELECT customer_id FROM orders WHERE total_amount > 10000);

-- 좋은 예: JOIN
SELECT DISTINCT c.name
FROM customers c
INNER JOIN orders o ON c.id = o.customer_id
WHERE o.total_amount > 10000;


-- ============================================
-- 8. 복합 조인 조건
-- ============================================
-- 여러 조건으로 조인
SELECT *
FROM price_history ph
INNER JOIN products p ON ph.product_id = p.id
    AND ph.effective_date <= CURRENT_DATE
    AND (ph.end_date IS NULL OR ph.end_date > CURRENT_DATE);

-- USING 절 (컬럼명이 같을 때)
SELECT c.name, o.total_amount
FROM customers c
INNER JOIN orders o USING (customer_id);  -- ON c.customer_id = o.customer_id 대신
# SQLAlchemy ORM 조인 예제 - Python
from sqlalchemy import create_engine, Column, Integer, String, ForeignKey, Numeric, DateTime, func
from sqlalchemy.orm import declarative_base, relationship, sessionmaker, joinedload
from datetime import datetime

Base = declarative_base()


# 모델 정의
class Customer(Base):
    __tablename__ = 'customers'

    id = Column(Integer, primary_key=True)
    name = Column(String(100), nullable=False)
    email = Column(String(200))

    orders = relationship('Order', back_populates='customer')


class Order(Base):
    __tablename__ = 'orders'

    id = Column(Integer, primary_key=True)
    customer_id = Column(Integer, ForeignKey('customers.id'))
    total_amount = Column(Numeric(12, 2))
    created_at = Column(DateTime, default=datetime.utcnow)

    customer = relationship('Customer', back_populates='orders')
    items = relationship('OrderItem', back_populates='order')


class Product(Base):
    __tablename__ = 'products'

    id = Column(Integer, primary_key=True)
    name = Column(String(200))
    price = Column(Numeric(10, 2))
    category_id = Column(Integer)


class OrderItem(Base):
    __tablename__ = 'order_items'

    id = Column(Integer, primary_key=True)
    order_id = Column(Integer, ForeignKey('orders.id'))
    product_id = Column(Integer, ForeignKey('products.id'))
    quantity = Column(Integer)

    order = relationship('Order', back_populates='items')
    product = relationship('Product')


# 세션 설정
engine = create_engine('postgresql://localhost/shop')
Session = sessionmaker(bind=engine)
session = Session()


# ============================================
# 1. INNER JOIN - join() 또는 filter()
# ============================================
# 주문이 있는 고객 조회
customers_with_orders = (
    session.query(Customer, Order)
    .join(Order)  # INNER JOIN 기본
    .filter(Order.total_amount > 10000)
    .all()
)

for customer, order in customers_with_orders:
    print(f"{customer.name}: 주문 #{order.id} - {order.total_amount}원")


# 명시적 조인 조건
result = (
    session.query(Customer, Order)
    .join(Order, Customer.id == Order.customer_id)
    .all()
)


# ============================================
# 2. LEFT OUTER JOIN - outerjoin()
# ============================================
# 모든 고객과 주문 수
from sqlalchemy import func, case

customer_stats = (
    session.query(
        Customer.name,
        func.count(Order.id).label('order_count'),
        func.coalesce(func.sum(Order.total_amount), 0).label('total_spent')
    )
    .outerjoin(Order)  # LEFT OUTER JOIN
    .group_by(Customer.id, Customer.name)
    .all()
)

for name, count, total in customer_stats:
    print(f"{name}: {count}건, {total}원")


# 주문이 없는 고객 찾기
inactive_customers = (
    session.query(Customer)
    .outerjoin(Order)
    .filter(Order.id == None)  # IS NULL
    .all()
)


# ============================================
# 3. 다중 테이블 조인
# ============================================
# 고객 - 주문 - 주문상품 - 제품 조인
order_details = (
    session.query(
        Customer.name.label('customer'),
        Order.id.label('order_id'),
        Product.name.label('product'),
        OrderItem.quantity
    )
    .join(Order, Customer.id == Order.customer_id)
    .join(OrderItem, Order.id == OrderItem.order_id)
    .join(Product, OrderItem.product_id == Product.id)
    .filter(Order.created_at >= datetime(2024, 1, 1))
    .all()
)


# ============================================
# 4. Eager Loading (N+1 문제 해결)
# ============================================
# 나쁜 예: N+1 쿼리 문제
customers = session.query(Customer).all()
for c in customers:
    print(c.orders)  # 각 고객마다 추가 쿼리 발생!

# 좋은 예: joinedload로 한 번에 로드
customers = (
    session.query(Customer)
    .options(joinedload(Customer.orders))  # JOIN으로 미리 로드
    .all()
)

for c in customers:
    print(c.orders)  # 추가 쿼리 없음!


# 중첩 Eager Loading
customers = (
    session.query(Customer)
    .options(
        joinedload(Customer.orders)
        .joinedload(Order.items)
        .joinedload(OrderItem.product)
    )
    .all()
)


# ============================================
# 5. 서브쿼리와 조인
# ============================================
from sqlalchemy.orm import aliased

# 최신 주문만 조인
latest_order_subq = (
    session.query(
        Order.customer_id,
        func.max(Order.created_at).label('latest_date')
    )
    .group_by(Order.customer_id)
    .subquery()
)

# 서브쿼리와 조인
latest_orders = (
    session.query(Customer, Order)
    .join(Order)
    .join(
        latest_order_subq,
        (Order.customer_id == latest_order_subq.c.customer_id) &
        (Order.created_at == latest_order_subq.c.latest_date)
    )
    .all()
)


# ============================================
# 6. Self Join
# ============================================
class Employee(Base):
    __tablename__ = 'employees'
    id = Column(Integer, primary_key=True)
    name = Column(String(100))
    manager_id = Column(Integer, ForeignKey('employees.id'))

# Self Join으로 관리자 조회
Manager = aliased(Employee, name='manager')

employees_with_managers = (
    session.query(
        Employee.name.label('employee'),
        Manager.name.label('manager')
    )
    .outerjoin(Manager, Employee.manager_id == Manager.id)
    .all()
)

for emp, mgr in employees_with_managers:
    print(f"{emp} -> 관리자: {mgr or '없음'}")


# ============================================
# 7. Raw SQL 조인 (복잡한 쿼리)
# ============================================
from sqlalchemy import text

result = session.execute(text("""
    SELECT c.name, COUNT(o.id) as order_count
    FROM customers c
    LEFT JOIN orders o ON c.id = o.customer_id
    GROUP BY c.id, c.name
    HAVING COUNT(o.id) > 5
"""))

for row in result:
    print(f"{row.name}: {row.order_count}건")
// Knex.js 조인 예제 - Node.js
const knex = require('knex')({
    client: 'pg',
    connection: 'postgresql://localhost/shop'
});

// ============================================
// 1. INNER JOIN
// ============================================
// 기본 INNER JOIN
async function getOrdersWithCustomers() {
    const orders = await knex('orders')
        .join('customers', 'orders.customer_id', 'customers.id')
        .select(
            'customers.name as customer_name',
            'orders.id as order_id',
            'orders.total_amount'
        )
        .where('orders.total_amount', '>', 10000);

    return orders;
}

// 여러 테이블 조인
async function getOrderDetails() {
    const details = await knex('orders')
        .join('customers', 'orders.customer_id', 'customers.id')
        .join('order_items', 'orders.id', 'order_items.order_id')
        .join('products', 'order_items.product_id', 'products.id')
        .select(
            'customers.name as customer',
            'orders.id as order_id',
            'products.name as product',
            'order_items.quantity',
            'order_items.quantity * products.price as line_total'
        );

    return details;
}


// ============================================
// 2. LEFT JOIN
// ============================================
// 모든 고객과 주문 통계
async function getCustomerStats() {
    const stats = await knex('customers')
        .leftJoin('orders', 'customers.id', 'orders.customer_id')
        .select('customers.name')
        .count('orders.id as order_count')
        .sum({ total_spent: 'orders.total_amount' })
        .groupBy('customers.id', 'customers.name');

    return stats;
}

// 주문 없는 고객 찾기
async function getInactiveCustomers() {
    const customers = await knex('customers')
        .leftJoin('orders', 'customers.id', 'orders.customer_id')
        .whereNull('orders.id')
        .select('customers.*');

    return customers;
}


// ============================================
// 3. RIGHT JOIN
// ============================================
async function getOrdersWithOptionalCustomer() {
    const orders = await knex('customers')
        .rightJoin('orders', 'customers.id', 'orders.customer_id')
        .select(
            'orders.id',
            'orders.total_amount',
            'customers.name'
        );

    return orders;
}


// ============================================
// 4. FULL OUTER JOIN (PostgreSQL)
// ============================================
async function getFullJoinExample() {
    const result = await knex('customers')
        .fullOuterJoin('orders', 'customers.id', 'orders.customer_id')
        .select('customers.name', 'orders.id');

    return result;
}


// ============================================
// 5. 복합 조인 조건
// ============================================
// ON 절에 여러 조건
async function getValidPrices() {
    const prices = await knex('products')
        .join('price_history', function() {
            this.on('products.id', '=', 'price_history.product_id')
                .andOn('price_history.effective_date', '<=', knex.raw('CURRENT_DATE'))
                .andOn(function() {
                    this.onNull('price_history.end_date')
                        .orOn('price_history.end_date', '>', knex.raw('CURRENT_DATE'));
                });
        })
        .select('products.name', 'price_history.price');

    return prices;
}


// ============================================
// 6. 서브쿼리와 조인
// ============================================
// 최신 주문 조인
async function getLatestOrders() {
    // 서브쿼리: 각 고객의 최신 주문 날짜
    const latestOrdersSubquery = knex('orders')
        .select('customer_id')
        .max('created_at as latest_date')
        .groupBy('customer_id')
        .as('latest');

    const result = await knex('customers')
        .join('orders', 'customers.id', 'orders.customer_id')
        .join(latestOrdersSubquery, function() {
            this.on('orders.customer_id', '=', 'latest.customer_id')
                .andOn('orders.created_at', '=', 'latest.latest_date');
        })
        .select(
            'customers.name',
            'orders.id as latest_order_id',
            'orders.total_amount'
        );

    return result;
}


// ============================================
// 7. Self Join
// ============================================
// 직원-관리자 관계
async function getEmployeeHierarchy() {
    const employees = await knex('employees as e')
        .leftJoin('employees as m', 'e.manager_id', 'm.id')
        .select(
            'e.name as employee',
            'm.name as manager'
        );

    return employees;
}


// ============================================
// 8. 조인 + 집계
// ============================================
// 카테고리별 매출
async function getSalesByCategory() {
    const sales = await knex('order_items')
        .join('products', 'order_items.product_id', 'products.id')
        .join('categories', 'products.category_id', 'categories.id')
        .select('categories.name as category')
        .sum({ total_quantity: 'order_items.quantity' })
        .sum({
            total_revenue: knex.raw('order_items.quantity * products.price')
        })
        .groupBy('categories.id', 'categories.name')
        .orderBy('total_revenue', 'desc');

    return sales;
}


// ============================================
// 9. Raw SQL 조인 (복잡한 쿼리)
// ============================================
async function complexJoinQuery() {
    const result = await knex.raw(`
        SELECT
            c.name,
            COUNT(DISTINCT o.id) as order_count,
            COUNT(oi.id) as item_count,
            SUM(oi.quantity * p.price) as total_revenue
        FROM customers c
        LEFT JOIN orders o ON c.id = o.customer_id
        LEFT JOIN order_items oi ON o.id = oi.order_id
        LEFT JOIN products p ON oi.product_id = p.id
        GROUP BY c.id, c.name
        HAVING COUNT(o.id) > 0
        ORDER BY total_revenue DESC
    `);

    return result.rows;
}


// ============================================
// 10. 페이지네이션과 조인
// ============================================
async function getOrdersWithPagination(page = 1, perPage = 20) {
    const offset = (page - 1) * perPage;

    const orders = await knex('orders')
        .join('customers', 'orders.customer_id', 'customers.id')
        .select(
            'orders.*',
            'customers.name as customer_name'
        )
        .orderBy('orders.created_at', 'desc')
        .limit(perPage)
        .offset(offset);

    // 총 개수 (페이지네이션용)
    const [{ count }] = await knex('orders').count('* as count');

    return {
        data: orders,
        total: parseInt(count),
        page,
        perPage,
        totalPages: Math.ceil(count / perPage)
    };
}


// 사용 예시
async function main() {
    try {
        const stats = await getCustomerStats();
        console.log('Customer Stats:', stats);

        const inactive = await getInactiveCustomers();
        console.log('Inactive Customers:', inactive.length);

    } catch (error) {
        console.error('Error:', error);
    } finally {
        await knex.destroy();
    }
}

main();

🗣️ 실무에서 이렇게 말하세요

💬 쿼리 최적화 논의에서
"이 쿼리가 느린 이유는 조인 컬럼에 인덱스가 없어서입니다. customer_id에 인덱스를 추가하면 Full Table Scan에서 Index Scan으로 바뀌어 훨씬 빨라질 겁니다. EXPLAIN으로 확인해보면 Nested Loop이 Hash Join으로 바뀔 거예요."
💬 코드 리뷰에서
"여기서 LEFT JOIN 대신 INNER JOIN을 쓰면 주문이 없는 신규 고객이 누락됩니다. 고객 목록에서 모든 고객을 보여주려면 LEFT JOIN이 맞아요. 단, 집계 시 COALESCE로 NULL 처리도 해주세요."
💬 성능 이슈 대응에서
"N+1 문제가 발생하고 있네요. 100명의 고객을 조회하면서 각 고객의 주문을 별도로 가져오니 101번의 쿼리가 실행됩니다. ORM의 eager loading이나 JOIN으로 한 번에 가져오도록 수정하면 됩니다."

⚠️ 주의사항 & 베스트 프랙티스

조인 컬럼 인덱스 누락

조인에 사용되는 외래 키 컬럼에 인덱스가 없으면 Full Table Scan이 발생합니다. FK 컬럼에는 반드시 인덱스를 생성하세요.

Cartesian Product 주의

ON 조건 없이 조인하거나 조건이 잘못되면 행 수가 폭발적으로 증가합니다. 대용량 테이블의 CROSS JOIN은 서버를 다운시킬 수 있습니다.

SELECT *와 조인

여러 테이블을 조인할 때 SELECT *는 중복 컬럼과 불필요한 데이터를 가져옵니다. 필요한 컬럼만 명시적으로 선택하세요.

조인 베스트 프랙티스

조인 컬럼 인덱스 필수, 작은 테이블을 먼저 조인, 서브쿼리보다 조인 선호, EXPLAIN으로 실행 계획 확인, ORM eager loading 활용.

🔗 관련 용어

📚 더 배우기