ABOUT ME

Today
Yesterday
Total
  • [QuerySet] Django 의 DB 접근법 (2): Eager Loading (부제: DB 최적화 전략)
    Backend/Django 2025. 1. 5. 23:47

    작성 계기

    'Django 의 DB 접근법' 이란 제목의 글을 작성하게 된 핵심 계기는 바로 아래의 동영상 강의이다.
    Django ORM (QuerySet)구조와 원리 그리고 최적화전략 - 김성렬 - PyCon Korea 2020

    위 영상을 보고 Django 의 DB 접근 방식을 처음으로 접하게 되었고 새로 얻게 된 지식을 완벽히 흡수하고 싶었다. 이를 위해 영상을 다시 나만의 글로 작성하는 방식을 채택하게 되었다.


    본문

    Eager Loading 즉시 평가

    DB data 를 사용하는 code 실행과 동시에 SQL 문이 실행되어 원하는 객체를 바로 메모리에 로드하는 방식.

    Django 의 QuerySet 은 Eager Loading 을 지원하는 `select_related()` 와 `prefetch_related()` 메서드를 제공한다.

    위 두 메서드는 Eager Loading 방식으로 앞으로 필요할 것 같은 객체들을 메모리에 "미리" load 하여 DB 접근 횟수를 줄이는 것이 목적이다. (DB 접근 횟수가 줄어드는 이유는 Django 가 cache 를 이용하여 DB 대신 메모리의 data 를 사용하기 때문)


    select_related()

    QuerySet 으로 data 를 가져올 때 정방향 참조 관계의 table data 를 바로 로드 하기 위해 사용하는 함수.
    정방향 참조 관계인 테이블과 JOIN 하여 사용하는 SQL 문을 실행하여 원하는 모든 객체를 load 한다.
    1:1 참조 관계에서만 사용할 수 있는 함수이기 때문에 OneToOneField, ForeignKey 타입 field 에 대해서만 사용이 가능하다.

    사용법

    selecte_related(*fields)
    (이 때 *fields 위치에 오는 문자열들은 모두 다른 table 을 참조하는 필드명이다.)

    위 code 가 실행되면 실제 생성되는 SQL query 는 다음 정도로 추측할 수 있다.

    SELECT * FORM "메인 인스턴스의 Table" INNER JOIN "field 가 가리키는 Table" ON "메인"."field_id"="field_table"."id"

    이 때 JOIN 방식은 실제 model field 타입 선언 때 지정된 null 옵션 값에 따라 결정된다. null=True 이면 OUTER JOIN 이, False 이면 INNER JOIN 이 채택된다.
    또 QuerySet 의 조건절의 영향을 받기도 한다. (아직 정확히 어떤 작용인지는 모름.)

    select_related() 는 정방향 참조 관계인 모델에 대해서만 사용이 가능하다. 따라서 select_related() 의 parameter 로 오직 자신의 필드만 입력이 가능하다.

    그러나 OneToOneField 타입의 필드에 대해서는 참조 당한 모델에서 select_related() 를 이용하여 역으로 JOIN 이 가능하다. 참조 당한 모델을 다른 여러 모델들도 참조할 수 있는 ForeignKey 와 달리 OneToOneField 는 참조하는 모델과 참조 당한 모델만의 1:1 관계로 한정되어 있기 때문이다.

    class UserProfile(models.Model):
        user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile')
        bio = models.TextField()
    
    >>> User.objects.select_related('profile') ########### OneToOneField 에서는 이게 가능하다!

    만약 어떤 모델 A 의 참조 관계가 많은데, 이를 한꺼번에 부르고 싶거나 또는 모든 관계 파악이 어려운 경우 parameter 없이 select_relate() 를 사용하면 A 의 모든 관계를 바탕으로 한 모든 참조 관계 객체를 얻을 수 있다. 그러나 이는 불필요한 객체까지 로드하므로 성능적 저하가 발생할 수 있으니 사용을 적극 권장하지는 않는다.

    select_related() 로 로드된 모든 객체를 참조하는 field data 를 메모리에서 지우고 싶다면 select_related(None) 을 사용하면 된다.

    예시

    from django.db import models  
    
    
    class Topping(models.Model):  
        name = models.CharField(max_length=100)  
    
        def __str__(self):  
            return self.name  
    
    
    class Pizza(models.Model):  
        name = models.CharField(max_length=100)  
        toppings = models.ManyToManyField(to=Topping, through='Recipe', through_fields=('related_pizza', 'related_topping'))  
    
        def __str__(self):  
            return f"{self.name}({', '.join(t.name for t in self.toppings.all())})"  
    
    # Pizza 와 Topping 의 N:M 관계를 이어줄 중간 Table
    class Recipe(models.Model):  
        related_pizza = models.ForeignKey(Pizza, on_delete=models.CASCADE)  
        related_topping = models.ForeignKey(Topping, on_delete=models.CASCADE)  
    
        class Meta:  
            db_table = 'restaurant_recipe'
    >>> from django.db import connection
    >>> 
    >>> connection.queries_log.clear() 
    >>> 
    >>> Recipe.objects.select_related("related_pizza", "related_topping").filter("related_pizza__name"="Margherita")
    >>> for q in connection.queries_log:                                                                             
    ...     print(q['sql'])              
    ...
    SELECT "restaurant_recipe"."id", "restaurant_recipe"."related_pizza_id", "restaurant_recipe"."related_topping_id", "restaurant_pizza"."id", "restaurant_pizza"."name", "restaurant_topping"."id", "restaurant_topping"."name" FROM "restaurant_recipe" INNER JOIN "restaurant_pizza" ON ("restaurant_recipe"."related_pizza_id" = "restaurant_pizza"."id") INNER JOIN "restaurant_topping" ON ("restaurant_recipe"."related_topping_id" = "restaurant_topping"."id") WHERE "restaurant_pizza"."name" = 'Margherita' LIMIT 21
    



    prefetch_related()

    https://docs.djangoproject.com/en/5.1/ref/models/QuerySets/#prefetch-related
    [Django] select_related(), prefetch_related()란?(N+1문제 해결법) (tistory.com)

    select_related() 와 달리 N:M 관계에서도 사용할 수 있는 Eager Loading 방식의 함수이다.
    즉 Model field 가 ManyToMany, ManyToOne 를 포함한 다대다 관계형 타입일 때
    +
    ForeignKey 로 자신을 참조하는 모델에 대해 역방향 참조가 필요할 때 사용 가능하다.

    사용법

    prefetch_related(*lookups)

    lookup 이란 Django 에서 SQL query 의 WHERE 절을 쉽게 만들 수 있도록 제공하는 일종의 API 이다. (lookup 에 대한 공식문서)

    prefetch_related() 에는 1개 이상의 lookup 이 올 수 있으며, lookup 1개 당 하나의 SQL query 가 실행된다.

    prefetch_related() 호출 결과, 메인 QuerySet 과는 별도의 QuerySet 이 반환 되어 메모리에 저장된다. (caching)

    select_related() 와 다르게 정방향, 역방향 참조가 모두 사용 가능하다. 그러나 이왕이면 1:1 정방향 참조는 select_related() 를 사용할 것을 권장한다. -> 이 권장사항 때문에 prefetch_related() 는 역방향 참조만 가능할 것이라는 착각을 하기 쉬우니 주의!

    예시

    ModelClass.objects.prefetch_related('ref')

    위와 같은 code 가 실행될 때, 'ModelClass' DB Table 을 조회하는 SQL문 외에, 'ref' DB Table 을 조회하는 추가 쿼리를 발생하여 ModelClass 가 필요로 하는 모든 data 가 load 된다.

    위 예시 code 로 생성되는 SQL query 는 다음과 같이 유추할 수 있다.

    SELECT * FROM "ModelClass의 Table"
    
    SELECT * FROM "ref 의 Table" WHERE "ref의 Table"."id" in ("위 SQL문으로 조회된 ModelClass 의 id 목록")

    예시2

    prefetch_related() 사용 안 했을 때: 총 3 개의 query 실행 (N+1 Problem)

    # 이미 2 개의 Pizza 객체가 생성된 후
    >>> from django.db import connection
    >>> 
    >>> connection.queries_log.clear() 
    >>> for q in connection.queries:   
    ...     print(q['sql'])
    ...
    >>> pizzas = Pizza.objects.all()   
    >>> for q in connection.queries: 
    ...     print(q['sql'])            
    ...
    >>> for pz in pizzas:
    ...     print(pz)
    ...
    Margherita(tomato, cheese, basil)
    Melanzane(tomato, melanzane, cheese)
    >>> for q in connection.queries:   
    ...     print(q['sql'])          
    ...
    SELECT "restaurant_pizza"."id", "restaurant_pizza"."name" FROM "restaurant_pizza"
    SELECT "restaurant_topping"."id", "restaurant_topping"."name" FROM "restaurant_topping" INNER JOIN "restaurant_recipe" ON ("restaurant_topping"."id" = "restaurant_recipe"."related_topping_id") WHERE "restaurant_recipe"."related_pizza_id" = 1
    SELECT "restaurant_topping"."id", "restaurant_topping"."name" FROM "restaurant_topping" INNER JOIN "restaurant_recipe" ON ("restaurant_topping"."id" = "restaurant_recipe"."related_topping_id") WHERE "restaurant_recipe"."related_pizza_id" = 2
    >>> connection.queries_log.clear()

    사용했을 때: 총 2 개의 query 실행

    >>> pizzas = Pizza.objects.prefetch_related('toppings') 
    >>> for pz in pizzas:
    ...     print(pz)
    ...
    Margherita(tomato, cheese, basil)
    Melanzane(tomato, melanzane, cheese)  ################## prefetch_related() 사용 안했을 때와 결과는 동일
    >>> for q in connection.queries:
    ...     print(q['sql'])          
    ...
    SELECT "restaurant_pizza"."id", "restaurant_pizza"."name" FROM "restaurant_pizza"
    SELECT ("restaurant_recipe"."related_pizza_id") AS "_prefetch_related_val_related_pizza_id", "restaurant_topping"."id", "restaurant_topping"."name" FROM "restaurant_topping" INNER JOIN "restaurant_recipe" ON ("restaurant_topping"."id" = "restaurant_recipe"."related_topping_id") WHERE "restaurant_recipe"."related_pizza_id" IN (1, 2)
    ##################### 그러나 실제 실행된 query 가 다름.

    위 두 번째 예시에서 Pizzatoppings 필드를 가지므로 Pizza.objects.prefetch_related('toppings') 가 정방향 참조일 것이라고 혼동할 수 있다. 그러나 PizzaRecipe 을 거쳐 Topping 을 참조하고 있다. 중간 테이블을 거쳐 다대다 관계의 Table 을 참조하는 것은 역방향 참조로 간주되므로 Pizza.objects.prefetch_related('toppings') 역시 역방향 참조가 된다.

    Prefetch() 를 사용한 조건 추가

    prefetch_related() 의 parameter 로 주어지는 lookup 은 다대다관계형 티입인 필드명이나 자신을 참조하는 역방향 참조 관계의 모델을 의미하는 키워드가 포함된다. 이 때 특정 조건을 만족하는 객체들만 prefetch 하고 싶을 경우에는 lookup 대신 Prefetch() 객체를 전달하여 조건을 추가할 수 있다.

    prefetch(Prefetch(lookups, queryset=None, to_attr=None)

    Pizza.objects.prefetch_related(Prefetch('topping', queryset=Topping.objects.filter(name__len__gt=3, to_attr='used_long_toppings')))



    참고

    filter(*lookups) 의 lookup 으로 자신의 역방향 참조 모델을 가리키면 SQL query 에서는 INNER JOIN + WHERE 이 발생한다. (단 JOIN 조건은 무조건 나의 id = 역참조 모델의 id 인듯...)
    그러나 자신의 field 를 가리키면 filter 에 지정한 조건은 WHERE 절로만 들어간다.

    filter 와 select_related(), prefetch_related() 의 동작 차이: filter 는 메인 모델을 선별하는데 영향을 미친다.


    주의사항

    무분별한 prefetch 는 DB 접근 횟수만 늘려 오히려 성능 저하를 야기할 수 있다.

    댓글

Designed by Tistory.