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 사용을 권장하는 이유는?