ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Django] prefetch_related 동작과 자동 쿼리 생성 조건 알아보기
    언어/파이썬 & 장고 2021. 5. 1. 20:44

    먼저 아래에서 사용하는 장고의 버전은 Django 1.11.5 으로 현재 최신 버전인 3.2와는 동작이 다를 수 있습니다.

    기본 조건

    예시 모델

    class Coupon(models.Model):
        coupon_no = models.BigAutoField(
            db_column='coupon_no', primary_key=True
        )
        coupon_name = models.CharField(
            db_column='coupon_name', max_length=200
        )
            is_deleted = models.CharField(
            db_column='is_deleted', max_length=1,
        )
            ...
    
    class CouponApplyBrand(models.Model):
        coupon_apply_brand_no = models.BigAutoField(
            db_column='coupon_apply_brand_no', primary_key=True
        )
        coupon_no = models.ForeignKey(
            Coupon,
            db_column='coupon_no', null=False, related_name='apply_brands'
        )
            is_deleted = models.CharField(
            db_column='is_deleted', max_length=1,
        )
            apply_type = models.SmallIntegerField(
            db_column='apply_type'
        )
            ...

    prefetch_related() 동작

    prefetch_related()의 동작은 다음과 같습니다.

    a = Coupon.objects.prefetch_related(
                'apply_brands'
            ).filter(coupon_name='쿠폰 테스트')

    위와 같이 선언하고 a 변수를 사용하지 않는다면 쿼리 호출을 콘솔에서 볼 수 없습니다. (=lazy loading)

    따라서 아래와 같이 ORM 작성 후, 해당 변수를 반복문을 쓰던지, 리스트 타입으로 변환을 하던지, 직접 특정 객체에 접근해야만 쿼리 호출을 콘솔에서 확인할 수 있습니다.

    a = Coupon.objects.prefetch_related(
                'apply_brands'
            ).filter(coupon_name='쿠폰 테스트')
    
    list(a)
    
    SELECT *
    FROM coupon
    WHERE coupon.coupon_name = '쿠폰 테스트';
    
    SELECT *
    FROM coupon_apply_brand
    WHERE coupon_apply_brand.coupon_no IN (17141, 10752, 11279, 5141, 16934, 5681, 6712, 6713, 825);
    
    a = Coupon.objects.prefetch_related(
                'apply_brands'
            ).filter(coupon_name='쿠폰 테스트')
    
    a[0].coupon_name
    
    SELECT *
    FROM coupon
    WHERE coupon.coupon_name = '쿠폰 테스트'
    LIMIT 1;
    
    SELECT *
    FROM coupon_apply_brand
    WHERE coupon_apply_brand.coupon_no IN (17141)

    조인 조건

    자식 객체를 (CouponApplyBrand) 주체로 조인을 걸 때, (select_related) 아래의 조건으로 조인이 걸립니다.

    CouponApplyBrand.objects.select_related('coupon_no')

    • field= model.ForeignKey( null = False) 이면 Inner Join
    • field= model.ForeignKey( null = True ) 이면 Left Outer Join

    그러나 위는 select_related()를 선언했을 때의 경우고 장고는 selected_related()나 prefetch_related()를 하지 않더라도 필요하다 싶으면 자체적으로 조인을 붙여줍니다.

    장고 자체적으로 조인 생성하는 규칙

    위와 같이 모델이 선언되었을 때, Coupon 객체를 기반으로 CouponApplyBrand 객체의 데이터를 아래와 같이 가져온다고 하면 조건에 따라 조인이 다르게 생성됩니다.

    1. inner join

    Coupon.objects.filter(apply_brands__is_deleted='F')
    
    SELECT *
    FROM coupon INNER JOIN coupon_apply_brand ON (coupon_no = coupon_no)
    WHERE (coupon_apply_brand.is_deleted = 'F' AND coupon.is_deleted = 'F');

    2. left outer join

    Coupon.objects.filter(
        Q(
            Q(apply_brands__is_deleted='F') |
            Q(is_deleted= 'F')
        )
    )
    
    SELECT *
    FROM coupon LEFT OUTER JOIN coupon_apply_brand ON (coupon_no = coupon_no)
    WHERE (coupon_apply_brand.is_deleted = 'F' or coupon.is_deleted = 'F');

    맨 처음 inner join과 left outer join의 조건을 위의 쿼리에 그대로 대입하면 왜 저런지 성립이 됩니다.

    첫 번째는 coupon_apply_brand 테이블의 is_deleted 컬럼의 값이 무조건 존재한다고 가정하기 때문에(null 허용하지 않음) inner join으로 장고가 자체적으로 조인을 추가합니다.

    두 번째는 coupon_apply_brand 테이블의 is_deleted 컬럼의 값이 or 조건으로 있을 수도 있고 없을 수도 있으므로(null 허용) left outer join을 건 모습을 볼 수 있습니다.

    알 수 없는 장고의 쿼리 생성

    아래와 같이 2개의 ORM이 있다고 하면 동작은 과연 동일할까요?

    a = Coupon.objects.filter(
        Q(
            Q(is_deleted='F') |
            Q(
                Q(apply_brands__is_deleted='F'),
                ~Q(apply_brands__apply_type=1)
            )
        )
    )
    
    b = Coupon.objects.filter(
        Q(
            Q(is_deleted='F') |
            Q(
                ~Q(apply_brands__apply_type=1),
                Q(apply_brands__is_deleted='F')
            )
        )
    )

    두 ORM의 차이는 ~Q()를 어디에 선언했냐 입니다. 결과부터 얘기하면 두 ORM의 쿼리는 서로 다릅니다. 위 a, b의 쿼리를 추출하면 다음과 같습니다.

    -- a 변수의 ORM 쿼리
    SELECT *
    FROM coupon LEFT OUTER JOIN coupon_apply_brand ON (coupon.coupon_no = coupon_apply_brand.coupon_no)
    WHERE (
        coupon.is_deleted = 'F' OR
        (
            coupon_apply_brand.is_deleted = 'F' AND
            NOT (
                coupon.coupon_no IN (
                    SELECT U1."coupon_no" AS Col1
                    FROM coupon_apply_brand U1
                    WHERE (
                        U1."apply_type" = 1 AND
                        U1."coupon_apply_brand_no" = (coupon_apply_brand.coupon_apply_brand_no)
                    )
                )
            )
        )
    );
    
    -- b 변수의 ORM 쿼리
    SELECT *
    FROM coupon LEFT OUTER JOIN coupon_apply_brand ON (coupon.coupon_no = coupon_apply_brand.coupon_no)
    
    WHERE (
        coupon.is_deleted = 'F' OR
        (
            NOT (
                coupon.coupon_no IN (
                    SELECT U1."coupon_no" AS Col1
                    FROM "orders"."t_coupon_apply_brand" U1
                    WHERE U1."apply_type" = 1
                )
            ) AND
            coupon_apply_brand.is_deleted = 'F'
        )
    );

    두 변수의 차이점은 ~Q()의 순서에 따라 WHERE절의 서브쿼리에서 U1."coupon_apply_brand_no" = (coupon_apply_brand.coupon_apply_brand_no) 유무입니다.

    NOT절로 ~Q(자식 테이블의 값)을 하면 장고에서는 서브쿼리를 사용하도록 하는 것으로 보이고 NOT 절 위에 자식 테이블의 값을 필터로 걸고 있다면 a 변수의 쿼리와 같이 join으로 걸린 테이블의 pk와 NOT절의 subquery pk가 같아야지만 된다 라는 로직이 있는 것 같습니다.

    위와 유사하게 NOT 절을 아래와 같이 사용한다면 inner join이 걸리지만 WHERE절의 서브쿼리에서 pk 비교하는 로직이 있는 것을 볼 수 있습니다.

    a = Coupon.objects.filter(
        Q(apply_brands__is_deleted='F'),
        ~Q(apply_brands__apply_type=1)
    )
    
    -- a 변수의 ORM 쿼리
    SELECT *
    FROM FROM coupon INNER JOIN coupon_apply_brand ON (coupon.coupon_no = coupon_apply_brand.coupon_no)
    WHERE (
        coupon_apply_brand.is_deleted = 'F' AND
        NOT (
            coupon.coupon_no IN (
                SELECT U1."coupon_no" AS Col1
                FROM coupon_apply_brand U1
                WHERE (
                    U1."apply_type" = 1 AND
                    U1."coupon_apply_brand_no" = (coupon_apply_brand.coupon_apply_brand_no)
                )
            )
        )
    );
    
    b = Coupon.objects.filter(
        ~Q(apply_brands__apply_type=1),
        Q(apply_brands__is_deleted='F')
    )
    
    -- b 변수의 ORM 쿼리
    SELECT *
    FROM FROM coupon INNER JOIN coupon_apply_brand ON (coupon.coupon_no = coupon_apply_brand.coupon_no)
    WHERE (
        NOT (
            coupon.coupon_no IN (
                SELECT U1."coupon_no" AS Col1
                FROM coupon_apply_brand U1
                WHERE U1."apply_type" = 1
            )
        ) AND
        coupon_apply_brand.is_deleted = 'F'
    );

    만약 NOT절을 사용하지 않았다면 subquery가 들어가지 않기 때문에 두 ORM의 쿼리는 동일해집니다.

    a = Coupon.objects.filter(
        Q(apply_brands__is_deleted='F'),
        Q(apply_brands__apply_type=1)
    )
    
    -- a 변수의 ORM 쿼리
    SELECT *
    FROM FROM coupon INNER JOIN coupon_apply_brand ON (coupon.coupon_no = coupon_apply_brand.coupon_no)
    WHERE (
        coupon_apply_brand.is_deleted = 'F' AND
        coupon_apply_brand.apply_type = 1
    );
    
    b = Coupon.objects.filter(
        Q(apply_brands__apply_type=1),
        Q(apply_brands__is_deleted='F')
    )
    
    -- b 변수의 ORM 쿼리
    SELECT *
    FROM FROM coupon INNER JOIN coupon_apply_brand ON (coupon.coupon_no = coupon_apply_brand.coupon_no)
    WHERE (
        coupon_apply_brand.apply_type = 1 AND
        coupon_apply_brand.is_deleted = 'F'
    );

    결론

    장고에서는 사용자의 의도와 상관없이 필요하다 싶으면 조인을 걸거나 입력하지 않은 코드를 추가할 수도 있습니다. 따라서 복잡한 ORM이 생성이 된다 싶으면 ORM 분리를 하던지 ORM의 쿼리를 계속 확인을 하면서 코딩을 해야 될 것 같습니다.

    개발자의 의도와는 다르게 프레임워크가 자동으로 쿼리를 생성해서 편할 수도 있지만 예기치 못한 버그가 날 수도 있으니 조심해서 개발해야 합니다.,

    댓글