Django ORM 성능 튜닝

Django 뿐만 아니라 어떤 ORM을 사용하던지 성능 이슈는 발생할 수 밖에 없습니다.

ORM은 데이터베이스의 테이블을 객체지향 프로그래밍에서 흔히 사용하는 객체(Class)처럼 사용할 수 있도록 해주는 기술입니다.

기존 쿼리문을 작성하여 데이터베이스를 조작하는 것을 넘어서서 더 효율적이고 가독성 및 유지 보수에 적합한 코드를 만들기 위해 나오게 되었습니다.

무엇보다도 데이터베이스 종류에 따라 백엔드 로직이 수시로 바뀌는 문제를 해결할 수 있습니다.

즉, MySQL을 사용하다가 검토에 의해 PostgreSQL로 바꾸게 된다고 하더라고 ORM에 정의되어 있는 Model들을 가지고 몇 분이 채 되지 않아 PostgreSQL에 테이블들을 생성할 수 있습니다.

물론, 비즈니스 로직은 거의 수정하지 않은 채 말이죠.

하지만 ORM이 제공해주는 장점이 있으면 단점도 존재하기 마련입니다.

ORM을 사용했을 때 흔하게 겪게 되는 성능 이슈에 대해 파헤쳐 봅니다.


성능저하의 원인

N+1 Problem

Django REST Framework(이하 DRF)를 사용하면 REST API를 손쉽게 만들 수 있습니다.

하지만 개발자가 감안해야 할 부분이 있는데 바로 성능입니다.

많은 것을 제공해준다는 것은 내부적으로 처리가 많다는 뜻이고 이는 때때로 성능저하를 일으킬 수 있다는 것을 의미합니다.

ORM에서 성능 이슈가 발생하면 가장 흔한 원인으로 N+1 Problem이 언급됩니다.

N+1 Problem은 쿼리 1번으로 N건의 데이터를 가져왔는데 원하는 데이터를 얻기 위해 이 N건의 데이터를 데이터 수 만큼 반복해서 2차적으로 쿼리를 수행하는 문제입니다.

예시를 통해 좀 더 쉽게 문제에 접근해 봅시다.

$papers = query_rows("SELECT * FROM papers");
foreach($papers as $paper){
$professor_name = query_one("SELECT name FROM professors WHERE id = ?", $paper['author_id']);

}
  • 전체 논문 N건의 목록을 얻기 위한 쿼리 1회
  • 각 논문을 쓴 교수의 이름을 얻기 위한 쿼리 N회


위와 같은 로직이 N+1 문제를 야기합니다. 성능 측면에서 지극히 비효율적이지만 로직을 이해하기 쉽다는 장점이 있습니다.

Django REST Framework에서는 무엇이 문제인가?

DRF에서 Serializer는 핵심적인 역할을 합니다.

데이터베이스에서 가져온 데이터 Model들을 사용자가 원하는 형식으로 가공해서 Response로 보낼 수 있게 해주죠.

거기에 Filtering, Field-level validation, Object-level validation등 편리하고 다양한 기능들을 제공합니다.

하지만 이 Serializer가 중첩된 경우 성능 이슈가 발생하기 쉽습니다.

Foreign Key 제약이 걸려있는 데이터를 가져오기 위해서 Serializer안에 또 다른 Serializer를 선언하는 이른바 Nested Serializer를 사용하는 것이 성능저하의 원인이라고 볼 수 있습니다.

그럼 이 Nested Serializer를 사용했을 때 성능저하가 일어나는 이유는 무엇일까요?

Django ORM도 다른 ORM과 마찬가지로 Lazy-Loading 방식을 사용합니다.

Lazy-Loading 방식을 사용하면 ORM에서 명령을 실행할 때마다 데이터베이스에서 데이터를 가져오는 것이 아니라 모든 명령 처리가 끝나고 실제로 데이터를 불러와야 할 시점이 왔을 때 데이터베이스에 쿼리를 실행하는 방식을 의미합니다.

queryset = User.objects.all()
queryset = queryset.filter(user_status='normal') # 데이터베이스에서 데이터를 아직 가져오지 않음
queryset = queryset.order_by('datetime_signup') # 여전히 데이터베이스에 쿼리가 실행되지 않음
queryset = self.paginate_queryset(queryset) # 이제 데이터를 가져와야 하기 때문에 데이터베이스에 쿼리를 날림

이 방식의 장점은 매 단계마다 쿼리를 실행하지 않기 때문에 쿼리 요청을 최소화 할 수 있다는 것입니다.

하지만 이 방식이 성능에 악영향을 미칠 수도 있습니다.

아래의 예시를 보면서 무엇이 문제인지 살펴봅시다.

#serializers.py
class UserSerializer(serializer.ModelSerializer):
estimates = EstimateSerializer(many=True, read_only=True) # 성능저하의 원인!

위의 UserSerializer에 EstimateSerializer가 중첩된 것을 확인할 수 있습니다.

이 경우 UserSerializer는 내부적으로 다음과 같이 동작합니다.

  • 모든 User 정보를 가져옵니다.
  • 첫번째 User에 대해 모든 견적을 불러옵니다. 이때 새롭게 쿼리가 수행됩니다.
  • 두번째 User에 대해 위의 작업을 반복합니다.
  • 세번째 User에 대해 ㅜ이의 작업을 반복합니다.
  • .....


이는 User의 수가 많아지면 많아질 수록 성능에 엄청난 악영향을 미칩니다.

요청되는 쿼리수가 너무 많아집니다. 이는 매우 비효율적입니다.

또한, 위의 예시에서는 Nested Serializer가 EstimateSerializer 하나지만 때때로 하나의 Serializer안에 여러개의 Nested Serializer가 들어갈 수 있습니다. 이 경우 성능은 최악으로 치닫습니다.



성능저하의 해결책

Eager Loading

Eager Loading은 Lazy Loading의 반대 개념으로 사전에 쓸 데이터를 포함하여 쿼리를 날리기 때문에 비효율적으로 늘어나는 쿼리 요청을 사전에 방지할 수 있습니다.

Lazy-Loading에서는 쿼리 요청을 할 때 어떤 데이터를 필요로하는지 모릅니다. 그렇기 때문에 하나 하나 쿼리 요청을 보내는 것입니다.

Eager Loading은 아래와 같은 방식으로 수행됩니다.

foreach(User:with('estimate')->get() as $user)
{
$user->estimate->es_name;
}

위는 User에서 Foreign Key 제약이 걸려있는 estimate의 정보를 사용하겠다고 미리 알려줌으로써 쿼리를 최소화합니다.

SELECT * FROM User;
SELECT * FROM estimates WHERE user_id IN (1, 2, 3, ,,,);

이 때문에 Eager Loading은 N+1 문제의 해결책으로 잘 알려져 있습니다.

Django ORM에서는 Eager Loading을 어떻게 적용할 수 있을까요?

queryset = queryset.prefetch_related('estimate')

위의 prefetch_related 메서드를 통해 Eager Loading 방식으로 데이터를 불러올 수 있습니다.

한번에 User의 정보와 Estimate 정보를 가져오기 때문에 Lazy Loading 방식처럼 계속해서 쿼리 요청을 보낼 필요가 없습니다.

즉, User 정보와 Estimate 정보가 Join을 통해 합쳐져서 나오게 됩니다.

이미 한번에 불러왔기 때문에 그저 결과값에서 가져오기만(fetch)하면 됩니다.

위와 같은 방식을 사용하면 Django ORM에서는 필요한 데이터를 “미리” 가지고 옵니다.

그리고 Lazy Loading 방식처럼 필요한 데이터를 얻기 위해 계속 쿼리 요청을 하는 것이 아니라 local data cache에 가져온 데이터를 보관합니다.

그래서 필요한 데이터를 즉각적으로 제공할 수 있습니다.

이는 쿼리 요청을 데이터베이스 보내는 것 보다 당연히 빠릅니다. 이 때문에 성능 향상이 이루어질 수 있는 것이죠.


Django ORM에서 Eager Loading을 구현할 수 있는 메서드가 대표적으로 2가지 있는데 이에 대해서 살펴보겠습니다.

  • prefetch_related : 복잡한 관계를 가지고 있는 테이블간에 사용할 수 있는 메서드입니다. 하나의 row에 대해 관계가 이루어진 여러개의 row들이 존재할 때 (many=True 옵션이 적용되는 경우) 사용될 수 있습니다. 이 메서드가 쿼리로 치환될 때 WHERE ... IN 구문으로 치환됩니다.
  • select_related : 가장 간단하게 Eager Loading을 구현할 수 있는 메서드입니다. 이 메서드를 사용하면 쿼리상으로는 SQL Join을 수행하게 됩니다.


위의 방법을 적절하게 사용하면 Django ORM을 사용하면서 겪을 수 있는 성능 이슈를 만족스럽게 해결할 수 있을 것이라 생각합니다.

댓글()