Django에서 JOIN 다루기 - ORM부터 Raw SQL까지

Django에서 JOIN 다루기 - ORM부터 Raw SQL까지

Django ORM의 JOIN 메커니즘

모델 관계 정의

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# models.py
from django.db import models

class User(models.Model):
    name = models.CharField(max_length=50)
    email = models.EmailField()
    city = models.CharField(max_length=50)
    created_at = models.DateTimeField(auto_now_add=True)

class Category(models.Model):
    name = models.CharField(max_length=50)
    description = models.TextField(blank=True)

class Product(models.Model):
    name = models.CharField(max_length=100)
    category = models.ForeignKey(Category, on_delete=models.CASCADE)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    stock_quantity = models.IntegerField(default=0)

class Order(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    order_date = models.DateField(auto_now_add=True)
    status = models.CharField(max_length=20, default='pending')
    total_amount = models.DecimalField(max_digits=10, decimal_places=2)

class OrderItem(models.Model):
    order = models.ForeignKey(Order, on_delete=models.CASCADE)
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    quantity = models.IntegerField()
    unit_price = models.DecimalField(max_digits=10, decimal_places=2)

select_related는 SQL의 LEFT JOIN을 생성하여 한 번의 쿼리로 관련 객체를 함께 가져온다.

기본 사용

1
2
3
4
5
6
7
8
9
10
# N+1 문제 발생 코드
orders = Order.objects.all()
for order in orders:
    print(f"{order.user.name}: {order.total_amount}")  # 각 order마다 user 쿼리 실행

# 생성되는 SQL (N+1 문제)
# SELECT * FROM orders;                    -- 1번째 쿼리
# SELECT * FROM users WHERE id = 1;        -- 2번째 쿼리 (첫 번째 주문의 사용자)
# SELECT * FROM users WHERE id = 2;        -- 3번째 쿼리 (두 번째 주문의 사용자)
# ... 주문 개수만큼 반복
1
2
3
4
5
6
7
8
9
# select_related로 최적화
orders = Order.objects.select_related('user').all()
for order in orders:
    print(f"{order.user.name}: {order.total_amount}")  # 추가 쿼리 없음

# 생성되는 SQL (JOIN 사용)
# SELECT orders.*, users.*
# FROM orders
# LEFT OUTER JOIN users ON orders.user_id = users.id;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 여러 관계를 한 번에 JOIN
order_items = OrderItem.objects.select_related(
    'order__user',           # order → user
    'product__category'      # product → category
).all()

for item in order_items:
    print(f"고객: {item.order.user.name}")
    print(f"상품: {item.product.name}")
    print(f"카테고리: {item.product.category.name}")

# 생성되는 SQL
# SELECT orderitem.*, order.*, users.*, product.*, category.*
# FROM orderitem
# LEFT OUTER JOIN order ON orderitem.order_id = order.id
# LEFT OUTER JOIN users ON order.user_id = users.id
# LEFT OUTER JOIN product ON orderitem.product_id = product.id
# LEFT OUTER JOIN category ON product.category_id = category.id;
  • ForeignKey (N:1 관계)
  • OneToOneField (1:1 관계)
  • 역방향 OneToOneField
1
2
3
4
5
6
7
8
# 사용 가능한 경우
Order.objects.select_related('user')              # ForeignKey
User.objects.select_related('profile')            # OneToOneField
Profile.objects.select_related('user')            # 역방향 OneToOneField

# 사용 불가능한 경우
User.objects.select_related('order_set')          # 역방향 ForeignKey (1:N)
Order.objects.select_related('products')          # ManyToManyField (N:N)

prefetch_related는 JOIN을 사용하지 않고 별도의 쿼리를 실행한 후 Python에서 관계를 연결한다.

동작 원리

1
2
3
4
5
6
7
# prefetch_related 사용
users = User.objects.prefetch_related('order_set').all()

for user in users:
    print(f"{user.name}의 주문:")
    for order in user.order_set.all():  # 추가 쿼리 없음
        print(f"  {order.order_date}: {order.total_amount}")

내부 동작 과정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1단계: 사용자 조회
SELECT id, name, email, city FROM users;

# 2단계: 관련 주문들을 IN절로 한 번에 조회
SELECT id, user_id, order_date, total_amount 
FROM orders 
WHERE user_id IN (1, 2, 3, 4, 5);

# 3단계: Python에서 user_id 기준으로 관계 연결
Django가 내부적으로 수행:
{
  1: [Order(id=1), Order(id=3)],  # 김철수의 주문들
  2: [Order(id=2), Order(id=5)],  # 이영희의 주문들
  3: [],                          # 박민수는 주문 없음
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from django.db.models import Prefetch

# 조건부 prefetch
users = User.objects.prefetch_related(
    Prefetch(
        'order_set',
        queryset=Order.objects.filter(status='completed').order_by('-order_date'),
        to_attr='completed_orders'
    )
).all()

for user in users:
    for order in user.completed_orders:  # 완료된 주문만
        print(f"{order.order_date}: {order.total_amount}")
  • 역방향 ForeignKey (1:N 관계)
  • ManyToManyField (N:N 관계)
  • GenericForeignKey
1
2
3
User.objects.prefetch_related('order_set')           # 역방향 ForeignKey
Order.objects.prefetch_related('products')           # ManyToManyField
User.objects.prefetch_related('liked_products')      # ManyToManyField

N+1 문제와 해결방법

N+1 문제 발생 패턴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 패턴 1: ForeignKey 접근
orders = Order.objects.all()
for order in orders:
    print(order.user.name)  # N+1 문제!

# 해결: select_related 사용
orders = Order.objects.select_related('user').all()

# 패턴 2: 역방향 ForeignKey 접근
users = User.objects.all()
for user in users:
    print(user.order_set.count())  # N+1 문제!

# 해결: prefetch_related 사용
users = User.objects.prefetch_related('order_set').all()

# 패턴 3: 중첩 관계 접근
order_items = OrderItem.objects.all()
for item in order_items:
    print(item.order.user.name)        # N+1 문제!
    print(item.product.category.name)  # N+1 문제!

# 해결: 중첩 select_related 사용
order_items = OrderItem.objects.select_related(
    'order__user',
    'product__category'
).all()

Django ORM의 한계

지원하지 않는 JOIN 타입

1
2
3
4
5
6
7
# Django ORM이 지원하지 않는 기능들
# 1. RIGHT JOIN
# 2. FULL OUTER JOIN
# 3. CROSS JOIN
# 4. Self JOIN (제한적 지원)
# 5. 복잡한 조건의 JOIN
# 6. 윈도우 함수와 함께 사용되는 JOIN

Self Join의 한계

1
2
3
4
5
6
7
8
9
10
11
class Employee(models.Model):
    name = models.CharField(max_length=50)
    manager = models.ForeignKey('self', on_delete=models.SET_NULL, null=True)

# ORM으로는 제한적
employees = Employee.objects.select_related('manager').all()
for emp in employees:
    manager_name = emp.manager.name if emp.manager else '없음'
    print(f"{emp.name}의 관리자: {manager_name}")

# 더 복잡한 Self JOIN은 Raw SQL 필요
This post is licensed under CC BY 4.0 by the author.