ABOUT ME

Today
Yesterday
Total
  • [QuerySet] Django 의 DB 접근법 (1): Lazy loading
    Backend/Django 2024. 11. 10. 23:19

     

    Django 공부를 위해 Youtube 를 뒤지다가 아래의 영상을 발견하게 되었다.
    Django ORM (QuerySet)구조와 원리 그리고 최적화전략 - 김성렬 - PyCon Korea 2020

    영상은 Django 의 ORM 은 성능 효율을 높이기 기본적으로 DB 접근 횟수를 줄이는 Lazy loading 을 사용한다로 시작한다. 아직 '성능' 이 중요한 줄은 알지만 향상시키는 방법은 덜 익숙한 나에게 흥미로운 도입부였다. 그렇게 해당 영상을 시작으로 QuerySet 의 동작 원리를 파고들게 되었다.

    Lazy Loading 지연 평가

    QuerySet 객체 생성(선언)을 했더라도 SQL 문(=Query)이 실제로 실행되는 시점은 해당 객체가 실사용되는 때(='평가' 라고도 한다.)이다.

    QuerySet 이 평가되는 시점

    출처: QuerySet API reference | Django documentation | Django (djangoproject.com)

    1. 반복(iteration): QuerySet을 반복할 때.
    2. Slicing with Step: QuerySet 의 step 을 사용한 slicing 한 결과는 일반적인 list 가 된다. (참고: step 을 사용하지 않은 slicing 결과는 원본과 마찬가지로 평가되지 않은(unevaluated) QuerySet이다.)
    3. Pickling/Caching: QuerySet 을 pickling 하면 그 결과는 DB 접근(SQL문 실행) 을 통해 얻어진다. (*pickle: 객체를 byte stream 으로 직렬화 하는 것. byte stream 을 객체로 변환하는 작업은 unpickle 이라 한다.)
    4. 리스트 변환(list()): QuerySet을 리스트로 변환할 때. 인덱싱 과정이 여기에 속함.
    5. len() 호출: QuerySet 의 길이를 구할 때.
    6. repr() 호출: QuerySet 의 문자열 표현을 구할 때.
    7. bool() 호출: QuerySet 을 불리언 컨텍스트에서 사용할 때.

    예시

    # Ex1)  
    users = User.objecs.all()  # 아직 SQL문이 호출되지 않음.  
    
    user\_list = list(users)    # 이 때 전체 user data 를 가져오는 SQL문이 호출됨.  
    
    
    # Ex2)  
    users = User.objects.all()  # 아직 SQL문이 호출되지 않음.  
    
    for user in users:          # 이 때 전체 user data 를 가져오는 SQL문이 호출됨.  
    print(f"{user.name}")   # name 은 users table 의 column   

    QuerySet 은 Caching 을 사용하기 때문에 필요한 data 를 생성 또는 호출하는 순서에 따라 SQL 문 호출 횟수가 달라질 수 있다.

    ########## QuerySet caching 을 사용하지 않을 때  
    
    users = Users.objects.all()  
    
    first\_user = users\[0\]  # 처음 1개의 entry 를 가져오는 SQL문 호출 -> SQL문 호출 횟수: +1  
    all\_user = list(users)  # 전체 entry 를 가져오는 SQL문 호출 -> SQL문 호출 횟수: +1  
    # 총 2회  
    
    
    ########## QuerySet caching 을 사용할 때  
    
    users = Users.objects.all()  
    
    all_user = list(users)  # 전체 entry 를 가져오는 SQL문 호출 -> SQL문 호출 횟수: +1  
    first_user = users\[0\]   # 이전 SQL문 호출 결과를 caching 한 곳에서 첫번째 data 를 가져옴. SQL문 호출 횟수: 0  
    # 총 1회  

    -> caching 을 사용하여 SQL문 호출 횟수를 줄이도록 작성하는 것이 좋다.

    부작용: N+1 Problem

    지나친 Lazy Loading 사용으로 인해 발생할 수 있는 비효율적인 상황.
    2 개의 Table A, B가 서로 1:1 Mapping 관계이고 B가 A를 참조 할 때, 반복문을 통해 N 개의 data 에 접근하면 실제 SQL문 호출 횟수(=DB Table 접근 횟수)는 N+1 이 되어버리는 상황을 일컫는 말.
    이는 Lazy loading 과 반복문이 만났을 때 발생할 수 있는 문제이다.

    모든 A instance 의 B.data 를 가져오기 위해 A instance 의 수 N 만큼 반복문을 돌린다고 가정해보자.

    for a in all_a 에서 모든 A instance 를 가져오기 위한 SQL문이 먼저 호출된다. 이 때 Lazy Loading 의 방식에 따라 반복문에서 사용되는 A Table 의 모든 data 는 load 되지만, 당장 사용하지 않는 B Table 의 data 는 load 되지 않는다.

    이후 반복문 안에서 B data 를 사용하는 Code 가 실행될 때마다 해당 data 를 가져오기 위한 N 번의 SQL문이 호출되는 것이다.

    따라서 SQL문이 호출된 전체 횟수는 모든 A data 를 가져올 때 '1번'과 매 loop 마다 B data 를 가져오기 위한 'N 번'의 총 합인 N+1 이 된다.

    all_a = A.objects.all()  
    
    for a in all_a:  # all_a 를 가져오기 위한 SQL문 호출 (+1)  
    print(a.b)  # b 는 B data. Table B 로부터 b 를 가져오기 위한 SQL문이 매 번 호출 (+N)  

    하지만 모든 A Table 의 data 를 load 할 때 A 가 참조하는 모든 B data 도 사전에 load 했다면 실제 실행 될 SQL문 은 총 2번으로 그쳤을 것이다.

    이처럼 실제 사용 시점이 아니라 어느 객체의 조회 시점에 그 객체와 관련된 모든 DB data 도 함께 load 하는 방식을 가리켜 Eager Loading 이라 한다. 즉, Eager Loading 은 N+1 과 같은 Lazy Loading 의 비효율적인 상황을 해결하기 위한 방식이라 볼 수 있다.

    댓글

Designed by Tistory.