백엔드 Back-end/장고 Django

Q. 장고Django ORM에서 n+1 이슈란? n+1 이슈 해결 방법은?

Tap to restart 2023. 1. 28. 19:00

A. n+1 이슈란 목록을 조회할 때 주로 발생하며, 연관된 데이터까지 가져올 때 데이터 개수(n으로 표현)만큼 추가로 데이터베이스를 조회하는 이슈를 뜻한다. 해결 방법은 쿼리셋을 가져올 때 select_related 또는 prefetch_related를 사용하는 것이다.

 

n+1 이슈 예

아주 간단히 카페 메뉴 정보를 저장하는 API 서버를 만드는 경우를 생각해보자. (예제 코드: DRF - CRUD)
카테고리 테이블과 음료 테이블 두 개가 있다.
카테고리 테이블에는 커피, 티를 입력했고, 음료에는 카테고리를 커피로 선택해서 아메리카노, 카페 라떼, 에스프레소, 카푸치노를 입력했다. 카테고리 테이블과 음료 테이블 사이의 관계는 카테고리 1개에 여러 음료가 연결될 수 있으니 1 : n(일 대 다) 관계다.

아래처럼 BeverageSerializer를 선언하고 음료 목록을 조회하면 아래와 같은 결과가 나타난다.

class BeverageSerializer(serializers.ModelSerializer):

    class Meta:
        model = Beverage
        fields = ("id", "name", "category", "price", "is_available")
{
    "count": 4,
    "next": null,
    "previous": null,
    "results": [
        {
            "id": 1,
            "name": "Americano",
            "category": 1,
            "price": 2000,
            "is_available": true
        },
        {
            "id": 2,
            "name": "Cafe Latte",
            "category": 1,
            "price": 4000,
            "is_available": true
        },
        {
            "id": 3,
            "name": "Espresso",
            "category": 1,
            "price": 4000,
            "is_available": true
        },
        {
            "id": 4,
            "name": "Cappucino",
            "category": 1,
            "price": 4500,
            "is_available": true
        }
    ]
}

SQL 쿼리를 로그로 살펴보면 아래처럼 한 줄이다.

(0.000) SELECT "app_beverage"."id", "app_beverage"."name", "app_beverage"."category_id", "app_beverage"."price", "app_beverage"."is_available", "app_beverage"."created_at", "app_beverage"."updated_at" FROM "app_beverage" LIMIT 4; args=()


위처럼 응답Response을 반환해주면 프런트 개발자가 카테고리 category 1이 어떤 카테고리인지 한번 더 따로 조회해야 한다. 그래서 카테고리 정보까지 한꺼번에 주기도 한다.

아래처럼 BeverageListSerializer를 새로 만들고 category = CategorySerializer()로 하자. category 정보를 단순히 id뿐만 아니라 name까지 얻고 싶다.

class CategorySerializer(serializers.ModelSerializer):
    class Meta:
        model = Category
        fields = ("id", "name")

class BeverageListSerializer(serializers.ModelSerializer):
    category = CategorySerializer(required=False)

    class Meta:
        model = Beverage
        fields = ("id", "name", "category", "price", "is_available")

아래처럼 목록에 대해서만 BeverageListSerializer가 적용되도록 해주자.

class BeverageViewSet(viewsets.ModelViewSet):
    queryset = Beverage.objects.all()
    serializer_class = BeverageSerializer
    http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head']
    permission_classes = [permissions.IsAuthenticated]

    def get_serializer_class(self):
        if self.action == "list":
            return BeverageListSerializer
        return self.serializer_class

GET /beverages/ 로 호출해보면 아래처럼 카테고리 정보도 함께 나온다.

{
    "count": 4,
    "next": null,
    "previous": null,
    "results": [
        {
            "id": 1,
            "name": "Americano",
            "category": {
                "id": 1,
                "name": "coffee"
            },
            "price": 2000,
            "is_available": true
        },
        {
            "id": 2,
            "name": "Cafe Latte",
            "category": {
                "id": 1,
                "name": "coffee"
            },
            "price": 4000,
            "is_available": true
        },
        {
            "id": 3,
            "name": "Espresso",
            "category": {
                "id": 1,
                "name": "coffee"
            },
            "price": 4000,
            "is_available": true
        },
        {
            "id": 4,
            "name": "Cappucino",
            "category": {
                "id": 1,
                "name": "coffee"
            },
            "price": 4500,
            "is_available": true
        }
    ]
}

이때 SQL 쿼리를 로그로 살펴보면 n + 1 이슈가 발생해서 커피 종류가 4개(n)니까 n + 1 = 4 + 1 = 5번 쿼리가 발생한다.

(0.000) SELECT "app_beverage"."id", "app_beverage"."name", "app_beverage"."category_id", "app_beverage"."price", "app_beverage"."is_available", "app_beverage"."created_at", "app_beverage"."updated_at" FROM "app_beverage" LIMIT 4; args=()
(0.000) SELECT "app_category"."id", "app_category"."name", "app_category"."created_at", "app_category"."updated_at" FROM "app_category" WHERE "app_category"."id" = 1 LIMIT 21; args=(1,)
(0.000) SELECT "app_category"."id", "app_category"."name", "app_category"."created_at", "app_category"."updated_at" FROM "app_category" WHERE "app_category"."id" = 1 LIMIT 21; args=(1,)
(0.000) SELECT "app_category"."id", "app_category"."name", "app_category"."created_at", "app_category"."updated_at" FROM "app_category" WHERE "app_category"."id" = 1 LIMIT 21; args=(1,)
(0.000) SELECT "app_category"."id", "app_category"."name", "app_category"."created_at", "app_category"."updated_at" FROM "app_category" WHERE "app_category"."id" = 1 LIMIT 21; args=(1,)

 

어차피 동일한 카테고리 정보를 매번 목록 개수만큼 쿼리로 요청하게 된다. 쓸데없이 데이터베이스에 부하를 일으키는 것이다!

해결 방법

쿼리셋에 select_related 또는 prefetch_related를 적용하면 된다.

 

select_related

n+1 이슈를 해결하기 위해서는 쿼리셋을 바꿔줘야 한다.
아래처럼 select_related를 적용해보자.

class BeverageViewSet(viewsets.ModelViewSet):
    queryset = Beverage.objects.select_related('category')
    ...

다시 요청해보면 SQL 쿼리가 아래처럼 한번만 실행되는 것을 볼 수 있다.

(0.000) SELECT "app_beverage"."id", "app_beverage"."name", "app_beverage"."category_id", "app_beverage"."price", "app_beverage"."is_available", "app_beverage"."created_at", "app_beverage"."updated_at", "app_category"."id", "app_category"."name", "app_category"."created_at", "app_category"."updated_at" FROM "app_beverage" INNER JOIN "app_category" ON ("app_beverage"."category_id" = "app_category"."id") LIMIT 4; args=()

select_related를 하면 INNER JOIN으로 실행되는 것을 알 수 있다.

prefetch_related

이번에는 prefetch_related를 적용해보자.

class BeverageViewSet(viewsets.ModelViewSet):
    queryset = Beverage.objects.prefetch_related('category')
    ...

prefetch_related를 적용한 경우 INNER JOIN이 발생하지 않고, 테이블 별로 따로 쿼리를 실행해서 아래처럼 두 번 쿼리가 실행되는 것을 볼 수 있다.

(0.000) SELECT "app_beverage"."id", "app_beverage"."name", "app_beverage"."category_id", "app_beverage"."price", "app_beverage"."is_available", "app_beverage"."created_at", "app_beverage"."updated_at" FROM "app_beverage" LIMIT 4; args=()
(0.000) SELECT "app_category"."id", "app_category"."name", "app_category"."created_at", "app_category"."updated_at" FROM "app_category" WHERE "app_category"."id" IN (1); args=(1,)


티 카테고리로 녹차를 추가해서도 해보자.
아래처럼 IN을 활용해서 SQL 쿼리는 여전히 두 번 실행되는 것을 확인할 수 있다.

{
    "count": 5,
    "next": null,
    "previous": null,
    "results": [
        {
            "id": 1,
            "name": "Americano",
            "category": {
                "id": 1,
                "name": "coffee"
            },
            "price": 2000,
            "is_available": true
        },
        {
            "id": 2,
            "name": "Cafe Latte",
            "category": {
                "id": 1,
                "name": "coffee"
            },
            "price": 4000,
            "is_available": true
        },
        {
            "id": 3,
            "name": "Espresso",
            "category": {
                "id": 1,
                "name": "coffee"
            },
            "price": 4000,
            "is_available": true
        },
        {
            "id": 4,
            "name": "Cappucino",
            "category": {
                "id": 1,
                "name": "coffee"
            },
            "price": 4500,
            "is_available": true
        },
        {
            "id": 5,
            "name": "Green Tea",
            "category": {
                "id": 2,
                "name": "tea"
            },
            "price": 4000,
            "is_available": false
        }
    ]
}
(0.000) SELECT "app_beverage"."id", "app_beverage"."name", "app_beverage"."category_id", "app_beverage"."price", "app_beverage"."is_available", "app_beverage"."created_at", "app_beverage"."updated_at" FROM "app_beverage" LIMIT 5; args=()
(0.000) SELECT "app_category"."id", "app_category"."name", "app_category"."created_at", "app_category"."updated_at" FROM "app_category" WHERE "app_category"."id" IN (1, 2); args=(1, 2)


다시 select_related로 변경해서 녹차가 있는 상태로 실행해보자. 아래처럼 select_related의 경우는 변화가 없는 것을 확인할 수 있다.

(0.000) SELECT "app_beverage"."id", "app_beverage"."name", "app_beverage"."category_id", "app_beverage"."price", "app_beverage"."is_available", "app_beverage"."created_at", "app_beverage"."updated_at", "app_category"."id", "app_category"."name", "app_category"."created_at", "app_category"."updated_at" FROM "app_beverage" INNER JOIN "app_category" ON ("app_beverage"."category_id" = "app_category"."id") LIMIT 5; args=()


카테고리 목록 조회시 해당 카테고리로 지정된 음료의 목록을 얻고 싶다면 어떻게 할까?
아래처럼 CategoryListSerializer를 만든다.

class CategoryListSerializer(serializers.ModelSerializer):
    beverages = BeverageSerializer(source='beverage_set', many=True)

    class Meta:
        model = Category
        fields = ("id", "name", "beverages")


CategoryViewset을 아래처럼 변경한다.

class CategoryViewSet(viewsets.ModelViewSet):
    queryset = Category.objects.prefetch_related('beverage_set')
    serializer_class = CategorySerializer
    http_method_names = ['get', 'post', 'put', 'patch', 'delete', 'head']
    permission_classes = [permissions.IsAuthenticated]

    def get_serializer_class(self):
        if self.action == "list":
            return CategoryListSerializer
        return self.serializer_class

아래처럼 카테고리 정보에 해당 카테고리 속한 음료 정보들을 얻을 수 있다.

{
    "count": 2,
    "next": null,
    "previous": null,
    "results": [
        {
            "id": 1,
            "name": "coffee",
            "beverages": [
                {
                    "id": 1,
                    "name": "Americano",
                    "category": 1,
                    "price": 2500,
                    "is_available": true
                },
                {
                    "id": 2,
                    "name": "Cafe Latte",
                    "category": 1,
                    "price": 4000,
                    "is_available": true
                },
                {
                    "id": 3,
                    "name": "Espresso",
                    "category": 1,
                    "price": 4000,
                    "is_available": true
                },
                {
                    "id": 4,
                    "name": "Cappucino",
                    "category": 1,
                    "price": 4500,
                    "is_available": true
                }
            ]
        },
        {
            "id": 2,
            "name": "tea",
            "beverages": [
                {
                    "id": 5,
                    "name": "Green Tea",
                    "category": 2,
                    "price": 4000,
                    "is_available": true
                }
            ]
        }
    ]
}

이 때 select_related()로 하면 에러가 발생한다. 음료 경우 Beverage 모델에 category가 foreign key로 등록되어 있어서 가능했지만, 카테고리 경우에는 사용할 수 없다. select_related는 inner join을 사용하는데 카테고리 목록을 조회하면서 음료 목록까지 한꺼번에 얻을 수는 없기 때문이다.

SQL 쿼리는 아래와 같다.

(0.000) SELECT "app_category"."id", "app_category"."name", "app_category"."created_at", "app_category"."updated_at" FROM "app_category" LIMIT 2; args=()
(0.000) SELECT "app_beverage"."id", "app_beverage"."name", "app_beverage"."category_id", "app_beverage"."price", "app_beverage"."is_available", "app_beverage"."created_at", "app_beverage"."updated_at" FROM "app_beverage" WHERE "app_beverage"."category_id" IN (1, 2); args=(1, 2)

 

참고

prefetch_related()
Returns a QuerySet that will automatically retrieve, in a single batch, related objects for each of the specified lookups.

This has a similar purpose to select_related, in that both are designed to stop the deluge of database queries that is caused by accessing related objects, but the strategy is quite different.

select_related works by creating an SQL join and including the fields of the related object in the SELECT statement. For this reason, select_related gets the related objects in the same database query. However, to avoid the much larger result set that would result from joining across a ‘many’ relationship, select_related is limited to single-valued relationships - foreign key and one-to-one.

prefetch_related, on the other hand, does a separate lookup for each relationship, and does the ‘joining’ in Python. This allows it to prefetch many-to-many and many-to-one objects, which cannot be done using select_related, in addition to the foreign key and one-to-one relationships that are supported by select_related. It also supports prefetching of GenericRelation and GenericForeignKey, however, it must be restricted to a homogeneous set of results. For example, prefetching objects referenced by a GenericForeignKey is only supported if the query is restricted to one ContentType.
출처: Django v3.2 Documentation QuerySet API reference


fetch는 가지고 오다란 뜻을 갖고 있다. 따라서 prefetch는 미리 가져온다는 뜻이다.
 

관련 글

Q. 장고Django에서 외래키ForeignKey 필드의 id를 얻을 때, field.id를 쓰지 말고 field_id를 써야 하는 이유는?
Q. 장고Django 쿼리셋Queryset에서 select_related보다 prefetch_related 사용을 권장하는 이유는?