Django의 ORM

솜솜이·2023년 5월 28일
0

ORM이란?

ORM(Object-Relation Mapping)란, 객체(Object)와 관계형 데이터베이스(Relational)을 연결(Mapping)해 주는 것을 의미한다. 간단하게 설명하면 데이터베이스의 테이블을 객체(Object)와 연결하여 테이블에 CRUD를 할 때, SQL 쿼리를 사용하지 않고도, 가능하게 하는 것을 말한다.

ORM의 장점

  • SQL query가 아닌 직관적인 코드로 (메소드) 데이터를 조작할 수 있어 개발자가 객체모델로 프로그래밍하는 데 집중할 수 있다.
  • 각 객체에 대한 코드를 별도로 작성하기 때문에 코드 가독성이 좋다.
  • SQL의 절차적이고 순차적인 접근이 아닌 객체 지향적인 접근으로 생산성이 좋다.

ORM의 단점

  • 프로젝트 복잡성이 커질 경우 난이도가 높아진다.
  • 완벽한 ORM으로만 서비스를 구현하기가 어렵다
  • 잘못 구현하게 되면 속도가 저하되고 일관성이 없어질 수 있다.
  • sql 쿼리문으로 다루지 않다 보니 정확한 원리를 이해하는데 어려움이 발생할 수 있다.

ORM의 특징

Django ORM에서는 3가지 특징

1. Lazy-loading(지연로딩)

Django ORM은 기본적으로 Lazy-loading이다. 무슨말이냐면, 우리가 ORM을 사용해서 입력한다고 해서 바로 SQL로 치환하여 동작하지 않고 실제로 데이터를 가져와야하는 부분에서 필요한 데이터를 가져오도록 동작한다는 이야기이다.

users = User.objects.filter(first_name='kim')

for user in users:
	print(user.full_name)

위 코드에서 users에 ORM을 선언했다. 하지만 ORM은 저 구간에서 데이터를 아직 가져오지 않는다.

그리고 바로 아래에서 동작하는 for문이 시작하는 부분에서 users의 ORM이 도작하여 데이터를 가져오게된다.
정말로 필요한 시점에서 ORM이 동작하는 것을 어떻게보면 굉장히 효율적이라고 보여질 수 있는 부분인데, 그 효율이 의외로 반대의 효과를 낼 때도 있다.

users = User.objects.filter(first_name='kim')

first_user = users[0].full_name

user_list = list(users)

이 코드에서 ORM은 몇번 동작할까?
정답은 2번이다.

위에 설명했 듯 ORM은 실제 데이터를 필요한 곳에서 불러오는 Lazy-loading이 특징이라고 하였다.

즉, users에서는 실제로 사용되지 않아 임시적으로 가지고 있지만 아래의 first_user에서 users[0].full_name으로 users를 호출한다. 여기서 1번 호출이되지만 바로 아래 user_list에서 list()를 통해 user를 list화 하는 것을 위해 또 한 번 호출하게 된다.

Lazy-loading의 특성 때문에, 데이터를 하나만 가져오기 위한 첫번째 부분, 그리고 전체를 리스트화 하기위한 두번째 부분을 위해서 ORM이 불필요하게 두번 동작한것이다.

그럼 이 문제를 해결하려면 어떻게 해야하는가? 아래와 같이 코드를 변경해주면 된다.

users = User.objects.filter(first_name='kim')

user_list = list(users)

first_user = user_list[0].full_name

순서만 바꿨을 뿐인데 1번만 동작한 것이다
언뜻보면 그냥 순서만 바뀌고 list화 한 users 데이터의 첫번째를 가져오는거라 ORM이 한번 더 동작할 수 있다고 생각할 수 있는데, 이것이 가능한 이유를 바로 아래에서 찾을 수 있다.

2. Caching

Lazy-loading의 마지막에서 사용된 코드가 ORM을 1번만 호출하여 사용이 가능한 것은 ORM 특징의 2번째인 Caching 때문이다. Django ORM은 특정 데이터를 한번 불러온 경우 캐싱을 활용한다. 데이터에 추가적인 변화를 일으키지 않는 쿼리 데이터를 재활용하는 경우에는 이미 ORM으로 호출하여 불러온 데이터를 캐싱해두고 있다가 다시 사용한다는 것이다.

users = User.objects.filter(first_name='kim')

user_list = list(users)

first_user = user_list[0].full_name

이 코드를 다시 보면

우리는 users를 list화 하기위해서 ORM을 요청했다. 그리고 user_list에는 ORM이 가져온 데이터가 담겨있고 그 데이터는 캐싱된 상태로 저장되어있다.

그리고 마지막으로 user_list에서 첫번째 유저를 가져온다면? 캐싱된 users를 다시 활용하여 정보를 보내주게 된다.

이처럼 캐싱을 잘 활용하면 불필요한 SQL 쿼리 요청을 줄이고 성능과 비용적으로도 효율적이게 비즈니스 로직을 설계할 수 있게된다.

그렇다면 어떤 차이가 있길래 아래의 내용은 Caching이 적용되고, 위의 코드는 되지 않는 것일까?

Caching의 데이터에 변화가 없어야한다라고 위에 설명을 적어두었다.
즉, 가져온 데이터에 대해서 추가적인 필터나 다른 결과를 도출하지 않는 내용이어야 캐시된 데이터를 활용해서 사용한다는 의미이다.

# ORM이 두번 실행되는 경우
users = User.objects.filter(first_name='kim')

# ORM 결과에서 첫번째 내용만 필요함(실질적인 SQL 실행이 달라짐)
first_user = users[0] # like SQL -> SELECT * FROM User u WHERE first_name='kim' LIMIT 1

# 위에서 실행한 ORM의 캐시내용은 첫번째 내용밖에 담고 있지 않음 -> 전체를 리스트화하려면 다시 ORM을 요청해야야함
user_list = list(users) # like SQL -> SELECT * FROM User u WHERE first_name='kim'

위의 코드에서는 users[0]이라는 것을 통해본다면 실질적으로 동작하는 쿼리는 추가적으로 LIMIT 1을 의미하게 되기때문에 하나의 데이터에 대해서만 가져와서 저장한다.
하지만 바로 아래의 user_list는 users에 있는 내용처럼 전체를 가져와서 리스트화를 실행하는 결과이기때문에 first_user와 user_list의 세부 실행이 달라져 동일한 ORM이어도 두 번의 데이터가 각각 필요하기 때문에 두번 호출하게 되는것이다.

밑에 후술할 내용에서도 적겠지만 ORM의 성능과도 직관되고 맹신하지 말아야하는 가장 무서운 점이 바로 이부분에서 드러난다.

ORM이 같은 내용으로 보여도 실제로 동작하는 SQL을 출력해보면 정말 제각각으로 상황에 맞게 SQL실행을 해버리기때문에, 같은 users를 호출한다고해서 동일하게 캐싱을 적용해서 사용하는게 아니기 때문.
꼭 Caching의 의미를 이해할 때 이 점을 숙지하고 있어야한다고 본다.

3. Eager Loading(즉시로딩)

Lazy-loading이 지금 필요한 데이터를 한정하여 가져왔다면, Eager-loading은 반대의 개념으로 지금 당장 사용하지 않을 데이터도 포함하여 Query문을 실행하기 때문에 Lazy-loading의 N+1문제의 해결책으로 많이 사용하게 된다.

Lazy-loading의 문제점으로 꼽히는 이 N+1 Problem을 해결하기 위해서, 이 특징을 도입한 Django ORM 문법을 사용한다.
(N+1 Problem을 제거하기 위해서라고도 했지만, 상황에 따라 일부러 유도해서 사용하는 경우도 있다.)

대표적으로 select_related와 prefetch-related가 있다.

그렇다면 N+1문제는 무엇인가?

N+1 문제란?
아래와 같이 User모델과 User모델의 PK를 FK로 가지는 UserProfile 모델이 있다고 가정하자.

class User(AbstractBaseUser, PermissionsMixin, models.Model):

    # Data fields
    username = models.CharField(max_length=200, null=False, blank=True, unique=True)
    first_name = models.CharField(max_length=200, null=False, blank=True)
    last_name = models.CharField(max_length=200, null=False, blank=True)
    
	class Meta:
    	db_table = 'user'
        
class UserProfile(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='user_profile')
    phone_number = models.CharField(max_length=50, default='')
    zip_code = models.CharField(max_length=20, default='')
    home_address = models.CharField(max_length=200,default='')
    
	class Meta:
    	db_table = 'user'   

여기서 만약 우리가 성이 김씨인 사용자들의 UserProfile의 주소를 가져오고자 한다.
그럼 아래와 같은 코드를 짤 수 있다.

kims_users = User.objects.filter(first_name='kim')

for user in kims_users:
	user_address = user.user_profile.home_address
	print(f'user address :: {user_address}')

위 코드를 실행 시켜 ORM을 추적해보면 kims_users를 가져와서 호출하는 과정에서 ORM이 한번돌고, user_address를 부르는 부분마다 ORM을 계속해서 실행시킨다.

user.user_profile.home_address로 표기되어 마치 연결된것처럼 보이고, 한번에 가져와서 보여주는 듯 하지만 home_address 가져오기 위해 호출하는 user.user_profile.home_address 부분에는 우리가 지정했던 kims_users라는 ORM이 가져온 쿼리데이터 안에는 없다.

그렇기때문에 외래키로 연결되어있는 user_profile의 home_address의 정보를 가져오려면 for문이 실행되어 한명한명의 주소를 가져올때마다 추가로 user_profile을 가져오는 ORM을 실행하는 것이다.

# 반복문 실행시 ORM이 진행하는 쿼리의 예시

# kims_users를 불러오기위한 ORM 실행
SELECT * FROM user u WHERE first_name='kim'

# for문 진입하여 user_profile의 address를 가져와야함 
# -> 위 ORM에 데이터가 없기때문에 해당하는 user마다 user_profile을 호출하는 추가 쿼리가 반복적으로 실행

SELECT up.home_address FROM user_profile up WHERE user_id=u.id=1
SELECT up.home_address FROM user_profile up WHERE user_id=u.id=2
SELECT up.home_address FROM user_profile up WHERE user_id=u.id=3

이처럼 불필요하게 동일한 내용의 쿼리가 계속 실행이되는것이 유저수가 100,1000명일때는 상관없다고 하더라도 만약 100만명, 1000만명으로 늘어나게 된다면 쿼리수는 N+1으로 급격하게 늘어나게되고, DB의 부담도 커지며 성능적으로도 매우 좋지 않은 함수가 된다.

select_related

위의 코드를 select_related를 통해 가져오는 코드로 변경을 해보자.

kims_users = User.objects.select_related('user_profile').filter(first_name='kim')

for user in kims_users:
	user_address = user.user_profile.home_address
	print(f'user address :: {user_address}')

for문에는 변화가 없지만, 위에있는 구문에서 select_related를 추가하여 user_profile 부분도 같이 가져오도록 지정했다. 그렇기 때문에 for문이 실행되는 구간에서 SQL을 다시 해오지 않고, 위에서 캐싱된 데이터를 통해서 가져오기때문에 SQL이 한번만 수행된다.

이와 같이 select_related를 사용하여 필요한 데이터를 한번에 join하여 미리 가져오고, 캐싱해둔 상태로 사용하여 N+1 Problem을 줄일 수 있다.

이는 객체가 역참조하는 단일 객체(one-to-one or many-to-one)이거나, 또는 정참조하는 관계일 때 사용 가능하다.

select_related로 명시하지 않아도 발생하는 join 형태
추가적으로 위에서 설명한 select_related를 적용한 구문을 조금 변형해서 user_profile의 특정조건을 가진 사람의 데이터만 가져온다고 할 경우 아래와 같은 구문으로도 사실 사용이 가능하다.

kims_users = User.objects.filter(first_name='kim', user_profile__home_address='seoul')

for user in kims_users:
	user_address = user.user_profile.home_address
	print(f'user address :: {user_address}')

이는 별도로 select_related를 사용하지 않아도 ORM에서 join이 필요하다고 판단되는 경우에는 저렇게 지정해주면 자동으로 join을 실행하여 결과를 가져오는데, 이런 경우라도 명시적으로 select_related를 사용해주는 것이 좋다고 한다.

제3자가 코드를 보고 이 구문에서는 join이 발생했구나 라는것을 이해하게 할 수 있도록 하기 위해서라고 한다. 코드라는것이 개인프로젝트가 아니라면 비단 나만 보는게 아니라, 다른사람과 함께 작업하는 내용이기때문에 확실히 그 방법이 좋은 듯하다.

prefetch-related
Django에서 N+1 Problem을 해결하기위해서 select_related와 같은 맥락으로 사용되는 메소드이다.

데이터베이스에서 외래키(Foreign Key)가 있다면 양방향으로 JOIN이 가능하지만 select_related는 이를 지원하지 않는다.
따라서 one-to-many, many-to-many 모델의 경우는 또 다른 방법인 prefetch_related를 사용한다.

Django Document에서는 prefetch_related를 python 내에서 "joining"한다고 설명한다.

select_related와 달리 데이터베이스 내에서 JOIN이 발생하지 않고, 2개의 테이블을 각각 불러들여 파이썬이 ORM을 처리하는 단계에서 결합하는 방식을 사용한다는 뜻이다.

이번에도 위의 코드를 prefetch-related를 통해 가져오는 코드로 변경을 해보자.

kims_users = User.objects.prefetch_related('user_profile').filter(first_name='kim')

for user in kims_users:
	user_address = user.user_profile.home_address
	print(f'user address :: {user_address}')

위와같이 prefetch_related를 사용해서 가져오게되면 기본적인 쿼리에 추가적인 쿼리가 한번 더 발생한다. select_related보다는 비효율적으로 보일 수 있지만, 특정한 상황들에서는 prefetch_related가 조금 더 효율적인 경우도 있다고 하니, 잘 고려해서 사용

기억해야할 사항은 prefetch_related는 항상 추가쿼리가 발생한다는것

참고
https://velog.io/@emrrbs9090/Django-ORM%EC%9D%98-%EB%8F%99%EC%9E%91%EC%9B%90%EB%A6%AC%EC%99%80-%ED%8A%B9%EC%A7%95
https://techblog.yogiyo.co.kr/django-queryset-1-14b0cc715eb7
https://velog.io/@wjjin/Django-ORM-%EA%B5%AC%EC%A1%B0%EC%99%80-%EC%9B%90%EB%A6%AC-2

0개의 댓글