[Django] ORM의 동작원리와 특징

David Im·2022년 8월 14일
2
post-thumbnail

본 글은 야간모드에 최적화 되어있습니다. 우측 상단에서 해 혹은 달모양을 클릭시어 velog 설정을 야간모드로 해주시면 더욱 편안하게 읽으실 수 있습니다.

회사에서 업무를 지금까지 진행해오면서 상당한 기간의 유지보수를 진행했다.

어떻게보면 유지보수만 진행한다는 것이 개발스킬적으로 늘어난게 많이 없어보일 수도 있을 것이라 느껴질 수 있고, 나 역시 실제로도 그렇게 느꼈던적이 굉장히 많았다.

하지만 시니어분들이 없는 상태에서 유지보수를 진행하고 메꿔오다보니, 매번 내가 Django와 함께 했지만 아직도 공부해야할 부분들이 많다는 걸 깨닫는다. 특히 쿼리셋도 그렇고 유지보수를 진행하면서, 리팩토링을 진행하면서 과거의 내가 짠 코드를 보고있노라면 이불킥이 상당히 하고싶더라..ㅋㅋ

내가 1년반동안 이 프레임워크로 일해왔고 하면서 느꼈던 것을 복기해보고자 처음부터 기초를 다지는 내용과 더불어서, 내가 실제로 진행해왔던 쿼리개선이나 프로젝트 구조 개선등에 대한 것들도 함께 적어보고자 한다.

복기하는 내용으로 삼고자, 약간의 시리즈로 작성할 예정이다.

첫번째는 가장 기본적인 ORM 동작원리부터 적어보도록하겠다.


1. ORM이란?

ORM은 object relational mapping의 약자로 객체와 관계형 데이터베이스의 데이터를 자동으로 매핑(연결)해주는 것을 말한다.

객체 간의 관계를 바탕으로 SQL을 자동으로 생성해서 sql쿼리문 없이도 데이터베이스의 데이터를 다룰 수 있게 해준다.

대표적으로 내가 이곳에 작성할 Django에서는 Django ORM이, Spring으로 유명한 Java에서는 Hibernate가 있다.

일반적으로 우리는 DB에서 데이터를 찾기 위해서는 익히 알고 있는 SQL을 사용해서 작업을 하게된다.
하지만 실제로 개발을 하면서 일일히 쿼리를 사용해서 가져오는것도 한계가 있고 조금 더 쉽게 이용하고자 만들어진 통역사의 개념이 ORM이라고 보면된다.

예를 들어서 성이 김씨인 사용자들만 검색하고싶어서 SQL문을 사용했다고 가정하면 아래와 같은 SQL문이 나온다.

SELECT * FROM User u WHERE u.first_name='kim'

하지만 지금처럼 간단한 내용을 SQL문으로 작성해서 사용하려면 우리는 python이라는 언어와 SQL을 둘다 사용할 줄 알아야한다.

하지만 Django에서는 ORM을 통해 두가지 언어를 사용해야 하는 귀찮은 상황을 해결한다. 한마디로, Django ORM은 python과 SQL사이의 통역사인 것이다. 따라서 위의 SQL 예시을 다음과 같은 Django에서 사용되는 python의 형태로 표현할 수 있게 해준다.

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

이처럼 Django 내에서 조금더 우리가 SQL을 쉽고 이해하기 빠르게 보여주면서 쓸 수 있도록 해주는것이 ORM이다.

2. ORM의 특징

Django ORM에서는 3가지 특징을 가지고있다. 그 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번이다. 왜 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

언뜻보면 그냥 순서만 바뀌고 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이 두번 실행되는 경우

# 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(즉시로딩)

ORM 특징 중에서 개인적으로 가장 중요하고 놓치지 말아야할 부분이라고 생각되는 특징이다.

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

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

대표적으로 select_relatedprefetch-related가 있다.


N+1 Problem

우선 N+1 Problem에 대한 이해를 위해서 해당 내용이 어떤내용인지 잠깐 적겠다.

아래와 같이 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는 항상 추가쿼리가 발생한다는것을 알아두자.


3. ORM의 장점 및 단점

ORM의 장점

1. 정말 빠른 개발이 가능하고 생산성이 증가한다.

  • SQL을 알지 못하더라도 ORM에 대한 어느정도 이해만 갖추고 있다면 개발이 가능하다. 실제로 나도 그랬고, 기본적인 SQL만 알고 있던 상태에서 ORM을 접해 개발을 시작했기때문에 초심자라도 누구나 쉽게 접근해서 데이터 가공이 가능하다.

2. 부수적인 코드가 줄어들어 객체에 대한 코드를 별도로 작성하기에 가독성이 좋아진다.

  • SQL로 작성하는 것이 아니고, ORM 문법에 맞춰 딱 떨어지는 내용으로 알아보기가 쉽다.
  • 가독성이 좋아진다는 것과 별도로 ORM 문법에 대한 이해만 어느정도 있는 사람이라면 ORM 코드만 보고도 어떠한 내용을 요구하고자 하는지 유추가 가능하다.

3. 유지보수에 있어 유용하며 코드의 재사용이 가능해진다.

  • 2번의 장점에서 이어지는 내용인데, 가독성이 좋고 ORM에 대한 어느정도 이해만 있다면 일반적인 형태의 유지보수가 가능하고, 한번 사용해놓은 ORM에 대해서는 동일하게 필요한 내용이 있는 곳이나 튜닝해서 사용해야하는 곳에서 재활용이 가능하다.

ORM의 단점

1. 해당 프로그래밍 언어를 사용하지만 ORM 라이브러리를 따로 배워야한다.

  • ORM을 사용하는 어느곳이나 마찬가지인데, Django의 경우에는 python을 사용하지만 Django ORM의 문법을 따로 익혀야하며, Spring의 경우에는 java를 사용하지만 Hibernate의 어노테이션과 사용법을 익혀야한다. 즉, 언어는 같은것을 사용하지만 프로그래밍 언어의 문법과 ORM의 문법은 별개이다.

2. 규모가 크거나 복잡한 프로젝트의 경우 SQL문으로 작성하는것이 좋을 수 있다.

  • 내가 현재 겪고있는 문제이기도 한데, 서비스가 점점 커지면서 요구되어야하거나 조회해야하는 데이터의 세밀함이 올라가고 여러개의 테이블을 Join하고 섞어서 데이터를 가공해야하는 경우에는 오히려 역으로 SQL이 더 편해질 때가 있다.

  • ORM만 맹신하는 경우에, 성장하는 서비스일수록 무너지기 쉽다. ORM이 대체해서 작성하는 SQL이 오히려 내가 작성하는 SQL보다 비효율적일때도 있는가 하면, 굳이 반복해서 수행하지 않아도 되는 부분에 대해서 반복해서 수행하는 현상들이 종종있다.

3. 정확한 원리를 이해하지 않고도 사용할 수 있다 보니 문제 대처능력이 떨이질 수 있다.

  • 장점이 양날의 검으로 인해 단점으로 돌아온다. 정말 양날의 검중에서 가장 위험한 양날의 검이라고 생각한다.
    내가 초창기에 ORM의 개념만 어느정도 이해하고 그냥 사용을 했던 사람이었다. 당연히 SQL은 알고있었지만 지금처럼 깊이있는 수준까지는 알지 못했던 터였기때문에, 조회를 해와야하는 부분마다 .get().filter()를 남발해서 사용했었고 리팩토링을 시작하고 나서 되돌아본 나의 코드는 그야말로 쓰레기통에 던져버리고 싶을 정도의 코드였다.

  • SQL의 이해가 없다면 ORM으로 짜여있는 내용을 이해하기에도 어렵다. 기본적인 ORM의 개념으로는 큰 SQL 쿼리를 감당하거나 역으로 치환해보려 했을때, 정말어렵기도 하거나와 ORM으로 4~5줄씩 이어지는 코드를 이해도가 없는 상태에서 본다면 무슨 내용인지 이해할 수 없다.

결론은 ORM은 확실히 좋은 도구이고 개발 편의성을 높여주지만, 그 밑바탕이 되는 이해도와 지식이 없다면 정말 낮고 낮은 성능의 서비스를 제공하는 API를 만들게 되어 리팩토링 시 정말 뒤엎어야하는 가능성이 높다. (1년전의 나처럼)


4. 마무리

ORM을 사용한지 1년반이 되었는데, 아직까지도 ORM 관련해서 튜닝이라던가 성능개선을 하는 유지보수를 진행하다보면 구글링으로 찾는것도 많고 다른 사람의 코드를 보고 배우는 내용도 정말 많다.

시리즈형식으로 내가 지금까지 진행했던 성능개선 부분에 대해서 몇가지도 작성하면서, 공부한 내용들을 조금 더 첨가하여 작성해보려고한다.

다음 내용은 ORM의 filter에서 사용되는 기본 구문들에 대해서 작성해보겠다.



참고자료

profile
코더보다 개발자로, 결과와 과정의 시너지를 만들어 가고 싶은 주니어 개발자

0개의 댓글