🗄️ 데이터베이스

Join

조인

두 개 이상의 테이블을 특정 조건으로 결합하여 데이터를 조회하는 SQL 연산으로, INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL OUTER JOIN 등 다양한 유형이 있습니다.

📖 상세 설명

Join(조인)은 관계형 데이터베이스에서 정규화된 여러 테이블의 데이터를 결합하는 핵심 연산입니다. 테이블 간의 관계(주로 외래 키)를 기반으로 연관된 데이터를 하나의 결과 집합으로 만들어, 데이터 중복 없이 복잡한 정보를 효율적으로 조회할 수 있게 합니다.

가장 많이 사용되는 INNER JOIN은 두 테이블에서 조인 조건을 만족하는 행만 반환합니다. LEFT JOIN(LEFT OUTER JOIN)은 왼쪽 테이블의 모든 행과 매칭되는 오른쪽 테이블의 행을 반환하며, 매칭이 없으면 NULL로 채웁니다. RIGHT JOIN은 그 반대이고, FULL OUTER JOIN은 양쪽 테이블의 모든 행을 반환합니다.

조인 성능 최적화는 데이터베이스 튜닝의 핵심입니다. 조인 컬럼에 인덱스를 생성하고, 필요한 컬럼만 SELECT하며, WHERE 절로 먼저 데이터를 필터링하면 성능이 향상됩니다. 쿼리 실행 계획(EXPLAIN)을 분석하여 Nested Loop, Hash Join, Merge Join 중 어떤 알고리즘이 사용되는지 확인하고 최적화할 수 있습니다.

현대 ORM(Object-Relational Mapping)에서는 조인을 추상화하여 제공합니다. SQLAlchemy의 join(), Django ORM의 select_related(), JPA의 @JoinColumn 등이 있습니다. 하지만 N+1 문제를 피하고 효율적인 쿼리를 작성하려면 실제 생성되는 SQL을 이해하는 것이 중요합니다.

💻 코드 예제

SQL Join 유형별 예제

-- 예제 테이블 구조
-- users: id, name, email
-- orders: id, user_id, product_name, amount, created_at

-- ============================================
-- INNER JOIN: 두 테이블에 모두 있는 데이터만
-- ============================================
SELECT
    u.name,
    u.email,
    o.product_name,
    o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id
WHERE o.created_at >= '2024-01-01';
-- 결과: 주문이 있는 사용자만 조회됨

-- ============================================
-- LEFT JOIN: 왼쪽 테이블(users)의 모든 행 포함
-- ============================================
SELECT
    u.name,
    u.email,
    COALESCE(o.product_name, '주문 없음') AS product,
    COALESCE(o.amount, 0) AS amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id;
-- 결과: 주문이 없는 사용자도 NULL 값과 함께 조회됨

-- ============================================
-- 다중 테이블 JOIN
-- ============================================
-- products: id, name, category_id, price
-- categories: id, name

SELECT
    u.name AS customer_name,
    o.created_at AS order_date,
    p.name AS product_name,
    c.name AS category_name,
    o.amount
FROM users u
INNER JOIN orders o ON u.id = o.user_id
INNER JOIN products p ON o.product_id = p.id
INNER JOIN categories c ON p.category_id = c.id
WHERE o.amount > 100
ORDER BY o.created_at DESC;

-- ============================================
-- SELF JOIN: 같은 테이블을 자기 자신과 조인
-- ============================================
-- employees: id, name, manager_id

SELECT
    e.name AS employee_name,
    m.name AS manager_name
FROM employees e
LEFT JOIN employees m ON e.manager_id = m.id;
-- 결과: 직원과 그 직원의 매니저 이름을 함께 조회

-- ============================================
-- 집계와 함께 사용하는 JOIN
-- ============================================
SELECT
    u.name,
    COUNT(o.id) AS order_count,
    SUM(o.amount) AS total_spent,
    AVG(o.amount) AS avg_order_amount
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name
HAVING COUNT(o.id) >= 5
ORDER BY total_spent DESC;

Python SQLAlchemy에서 Join 사용

from sqlalchemy import create_engine, Column, Integer, String, ForeignKey, func
from sqlalchemy.orm import declarative_base, relationship, Session, joinedload
from sqlalchemy import select

Base = declarative_base()

# 모델 정의
class User(Base):
    __tablename__ = 'users'
    id = Column(Integer, primary_key=True)
    name = Column(String(100))
    email = Column(String(255))
    orders = relationship("Order", back_populates="user")

class Order(Base):
    __tablename__ = 'orders'
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey('users.id'))
    product_name = Column(String(255))
    amount = Column(Integer)
    user = relationship("User", back_populates="orders")


# 데이터베이스 연결
engine = create_engine("postgresql://user:pass@localhost/mydb")

with Session(engine) as session:
    # ================================
    # 1. 기본 INNER JOIN
    # ================================
    stmt = (
        select(User.name, Order.product_name, Order.amount)
        .join(Order, User.id == Order.user_id)
        .where(Order.amount > 100)
    )
    results = session.execute(stmt).all()
    for name, product, amount in results:
        print(f"{name}: {product} - {amount}원")

    # ================================
    # 2. LEFT OUTER JOIN
    # ================================
    stmt = (
        select(User.name, Order.product_name)
        .outerjoin(Order, User.id == Order.user_id)  # LEFT JOIN
    )
    results = session.execute(stmt).all()
    for name, product in results:
        product = product or "주문 없음"
        print(f"{name}: {product}")

    # ================================
    # 3. Eager Loading으로 N+1 문제 해결
    # ================================
    # joinedload: 한 번의 JOIN 쿼리로 관련 데이터를 가져옴
    stmt = (
        select(User)
        .options(joinedload(User.orders))
        .where(User.name.like('김%'))
    )
    users = session.scalars(stmt).unique().all()
    for user in users:
        print(f"{user.name}의 주문:")
        for order in user.orders:  # 추가 쿼리 발생하지 않음
            print(f"  - {order.product_name}")

    # ================================
    # 4. 집계 함수와 GROUP BY
    # ================================
    stmt = (
        select(
            User.name,
            func.count(Order.id).label('order_count'),
            func.sum(Order.amount).label('total_spent')
        )
        .outerjoin(Order)
        .group_by(User.id, User.name)
        .having(func.count(Order.id) >= 3)
        .order_by(func.sum(Order.amount).desc())
    )
    results = session.execute(stmt).all()
    for name, count, total in results:
        print(f"{name}: {count}건, 총 {total or 0:,}원")

    # ================================
    # 5. 서브쿼리를 활용한 복잡한 JOIN
    # ================================
    # 최근 주문이 있는 사용자만 조회
    subquery = (
        select(Order.user_id)
        .where(Order.created_at >= '2024-01-01')
        .distinct()
        .subquery()
    )

    stmt = (
        select(User)
        .join(subquery, User.id == subquery.c.user_id)
    )
    active_users = session.scalars(stmt).all()

🗣️ 실무에서 이렇게 말해요

백엔드 개발자 "주문 내역 API가 느려요. 로그 보니까 N+1 문제가 있네요. 주문마다 사용자 정보를 따로 쿼리하고 있어요."
시니어 개발자 "select_related나 joinedload 써서 JOIN으로 한 번에 가져오세요. EXPLAIN으로 실행 계획 확인하고, 조인 컬럼에 인덱스 있는지도 체크하세요."
DBA "대용량 테이블 조인이면 Hash Join이 더 효율적일 수 있어요. work_mem 설정도 확인해보세요."
면접관 "INNER JOIN과 LEFT JOIN의 차이점과 각각 언제 사용하는지 설명해주세요."
지원자 "INNER JOIN은 두 테이블 모두에 매칭되는 데이터만 반환하고, LEFT JOIN은 왼쪽 테이블의 모든 행을 포함하며 매칭 없으면 NULL을 반환합니다. 예를 들어 '주문한 고객 목록'은 INNER JOIN, '모든 고객과 그들의 주문 현황(없으면 없음 표시)'은 LEFT JOIN을 사용합니다."
리뷰어 "이 쿼리에서 CROSS JOIN이 발생할 수 있어요. ON 절 없이 여러 테이블을 FROM에 나열하면 카테시안 곱이 됩니다."
작성자 "맞아요, WHERE에서 필터링하긴 하지만 명시적으로 INNER JOIN ON으로 수정하겠습니다. 가독성도 좋아지고 실수 방지도 되니까요."

⚠️ 주의사항

  • 인덱스 확인: 조인 조건에 사용되는 컬럼(외래 키, 기본 키)에 인덱스가 없으면 Full Table Scan이 발생하여 성능이 급격히 저하됩니다. 특히 대용량 테이블에서는 필수입니다.
  • N+1 문제: ORM에서 관계된 데이터를 지연 로딩(Lazy Loading)으로 가져오면 루프마다 쿼리가 발생합니다. Eager Loading(joinedload, select_related)을 사용하세요.
  • 조인 순서: 여러 테이블을 조인할 때 작은 테이블을 먼저 조인하거나, 옵티마이저가 최적 순서를 선택하도록 통계 정보를 최신으로 유지하세요.

📚 더 배우기