Django ORM 구조와 최적화 전략
1. ORM
ORM(Object Relational Mapping)은 객체와 관계형 데이터베이스를 연결(매핑)해주는 것을 말한다.
- 객체 지향 프로그래밍은 클래스를 사용하고, 관계형 데이터베이스는 테이블을 사용
- 객체 모델과 관계형 모델 간에 불일치가 존재
- ORM을 통해 SQL문을 자동으로 생성하여 불일치를 해결
장점
- SQL 언어를 사용하지 않고도 데이터베이스를 조작 가능
- DB를 바꾸더라도 ORM 코드는 그대로 이용이 가능하여 DBMS에 대한 종속성이 줄어든다.
- 유지보수의 편리성
단점
- ORM만으로 복잡한 SQL문을 생성하는 데에 어려움이 있다.
- DB에 직접 쿼리를 보내는 것이 아니기 때문에 상대적으로 성능 저하가 발생
- 잘못된 사용은 N+1 Problem의 비효율을 야기
2. QuerySet
Django ORM을 통해 생성된 자료형으로, 데이터베이스의 테이블로부터 가져온 객체의 목록이다.
2-1. QuerySet 특징
1) Lazy Loading
필요한 시점에만 SQL을 호출하는 Lazy한 특성이 있다. 이를 이해하기 위해 예시 모델을 하나 만들었다.
Post 테이블과 User 테이블은 다대일(N:1) 관계로 정의하고, ForeignKey 필드는 다대일 관계에서 '다' 쪽인 User 테이블에 포함시켜야 하므로 아래와 같이 작성하였다.
- Django에서 테이블 간 관계를 나타내는 필드: ForeignKey, ManyToManyField, OneToOneField
- 각각 N:1, M:N, 1:1 관계를 의미
- ForeignKey(외래키): 관계형 DB에서 한 테이블의 필드(열, 속성)에서 다른 테이블의 행을 식별할 수 있는 키
- 외래키가 포함된 테이블은 자식 테이블, 외래키 값을 제공하는 테이블은 부모 테이블
# sample_project/member/models.py
from django.db import models
class User(models.Model): # 부모 테이블
name = models.CharField(max_length=20)
class Post(models.Model): # 자식 테이블
""" ForeignKey 필드를 사용할 때, on_delete 옵션은 필수이며,
CASCADE은 ForeignKey 필드가 바라보는 레코드가 삭제되면 그 레코드와 연결된 자식 레코드도 삭제"""
user = models.ForeignKey(User, on_delete=models.CASCADE)
title = models.CharField(max_length=255)
위의 모델을 바탕으로 데이터를 생성했다는 가정하에 데이터베이스에 저장된 데이터를 조회해보자.
from member.models import User, Post
users = User.objects.all()
first_user = users[0]
user_list = list(users)
User.objects.all()은 선언된 순간 단순히 쿼리셋 객체다. 실제로 쿼리가 호출되서 수행하는 시점은 users[0] 혹은 list(users)으로 쿼리셋을 호출했을 때이다. 그러나 필요한 시점에만 SQL을 호출하는 lazy loading 때문에 비효율이 발생한다. first_user = users[0]으로 0번째 user를 얻어오고 싶어서 sql을 호출하면, LIMIT 1이 걸린 sql이 호출된다. 그리고 user_list = list(users)로 모든 user 목록을 얻기 위해 다시 sql을 호출하면, 앞의 쿼리를 재사용하지 않고, 불필요하게 SQL을 한번 더 호출되게 된다.
2) Caching: 쿼리를 재사용하는 방법
QuerySet에서 SQL을 호출하면 그 데이터 결과를 가지고 있다. 이것을 QuerySet에서 Result Cache라고 부른다.
user_list = list(users) 이 로직에 모든 user를 가져오는 sql이 호출되서 users 쿼리셋에는 모든 user 데이터가 캐싱되어 있다. 그래서 두번째로 first_user = users[0]을 호출하면 앞의 로직에서 캐싱된 값을 재사용하게 된다.
즉, 쿼리셋을 호출하는 순서를 바꾸는 것만으로도 쿼리셋 캐싱이 달라진다.
from member.models import User, Post
users = User.objects.all()
user_list = list(users)
first_user = user_list[0]
3) N+1 Problem
지연 로딩의 또 다른 문제는 바로 외래키 관계에 있는 데이터를 참조해서 호출할 때 발생한다. lazy-loading은 일단 쿼리가 날라갈 때 참조 모델(외래키, 다대다 관계)의 데이터는 당장 필요하지 않기 때문에 참조 모델의 데이터는 가져오지 않고 해당 모델이 갖고 있는 1개의 row 만을 가져온다. 그렇기 때문에 현재 모델에서 외래키 관계에 있는 모델을 호출할 때마다 다시 쿼리가 날아가게 된다.
전체 포스트 N개의 목록을 얻기 위한 쿼리 1회 + 해당 포스트를 작성한 유저 이름 N회 = N + 1
from member.models import User, Post
posts = Post.objects.all()
for post in posts:
# posts 쿼리셋 입장에서 post의 user정보가 필요한 시점은 바로 여기
# 따라서 user.name을 알기 위해 for문이 돌 때마다 SQL을 호출
post.user.name
4) Eager Loading
N+1 Problem을 해결하기 위해 select_related()와 prefetch_related() 메소드로 즉시 로딩을 할 수 있다.
(QuerySet 내부 구조: queryset은 1개의 쿼리와 0~N개의 추가 쿼리로 구성)
- select_related(): join을 통해 데이터를 즉시 로딩하는 방법
- 셀렉트할 객체가 역참조하는 single object(1:1, N:1)이거나, 정참조 foreign key일 때 사용
- 모델 필드(테이블의 열) = models.ForeignKey(null=False)이면 INNER JOIN
- 모델 필드 = models.ForeignKey(null=True)이면 OUTER JOIN
Model.object.filter(조건절).select_related('정방향 참조 필드')
------------------------------------------------------------
SELECT * FROM 'Model' m
INNER JOIN '정방향 참조 필드' r
ON m.r_id =r.id
WHERE 조건절
SELECT * FROM 'Model' m --> (left table)
# LEFT 테이블인 'Model' m의 정보 다 포함
LEFT OUTER JOIN '정방향 참조 필드' r --> (right table)
ON m.r_id =r.id
WHERE 조건절
- prefetch_related(): 추가 쿼리를 수행해서 데이터를 즉시 로딩하는 방법
- 구하려는 객체가 정참조 multiple objects(M:N, 1:N)이거나, 역참조 Foreign Key일 때 사용
Model.object.filter(조건절).prefetch_related('역방향 참조 필드')
-> 추가 쿼리 수행
-------------------------------------------------------------------------
SELECT * FROM '역방향 참조 필드' WHERE id IN ('첫 번째 쿼리 결과의 id 리스트)
ForeignKey의 역참조와 정참조
- FK를 가지지 않은 클래스(식당)에서 FK를 가진 클래스(주문)를 참조할 때는 역참조
- FK를 가진 클래스(Post)에서 FK를 가지지 않는 클래스(User)를 참조할 때는 정참조
참고
https://django-orm-cookbook-ko.readthedocs.io/en/latest/
https://www.youtube.com/watch?v=EZgLfDrUlrk