Join
조인
두 개 이상의 테이블을 특정 조건으로 결합하여 데이터를 조회하는 SQL 연산으로, INNER JOIN, LEFT JOIN, RIGHT JOIN, FULL OUTER 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을 이해하는 것이 중요합니다.
-- 예제 테이블 구조
-- 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;
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()