ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Django]mypick_forest 복수 리턴 문제(Q 공부 등등)
    카테고리 없음 2023. 8. 1. 22:25

    문제상황

    아래와 같이, hashtag가 여러개이면 mypage/forest에 리턴이 그 수만큼 생기는 문제가 생겼다

    근데 또 view의 serializer에서 hashtag는 없어서 리턴되는 값에는 hashtag가 없지만 그냥 나머지 정보는 다 똑같은 결과가 여러개가 등장한다


    문제 원인 파악

    일단 forest 객체가 여러개가 생성되는 것은 아니다.

    그렇다는 것은 forest의 모델이나 create함수의 문제는 아니라는 것!

    문제는 mypick_forest의 selector와 view에 있다는 것!

    따라서 mypage/forest/selector의 selector 코드를 분석해보았다.

    class UserForestSelector:
        def __init__(self, user: User):
            self.user = user
    
        def list(self, search: str = '', category_filter: list = []):
            like_forest = self.user.liked_forests.all()
    
            q = Q()
            q.add(Q(title__icontains=search) |
                  Q(subtitle__icontains=search) |
                  Q(content__icontains=search) |
                  Q(hashtags__name__icontains=search), q.AND)
            
            if len(category_filter) > 0:
                query = None
                for element in category_filter:
                    if query is None:
                        query = Q(category=element)
                    else:
                        query = query | Q(category=element)
                q.add(query, q.AND)
    
            forests = like_forest.filter(q).annotate(
                name=F('hashtags__name'),
                writer_is_verified=F('writer__is_verified'),
            ).order_by('-created')
    
            return forests

    여기서 내가 생소한 Q개념이 사용되어서 일단 코드를 분석하며 공부하여보았다.

    • list 매서드는 search, category_filter의 내개변수를 갖는다

    Q()

    • q=Q() --> 이건 Q객체를 하나 만든거다. 이름이 q인. 쿼리라고 한다. 이건 필터링하는데 사용된다고 한다.
    • Django에서 Q는 쿼리를 구성하기 위해 사용되는 객체입니다. 이 객체는 SQL의 OR, AND, NOT 등과 같은 복잡한 쿼리 조건을 Python 코드로 표현할 수 있도록 도와줍니다.
    • 일반적으로 Q 객체는 Q(<조건>)의 형태로 사용됩니다. 여기서 <조건>은 필드 간의 비교, 논리 연산자, 함수 호출 등을 포함하는 Django의 쿼리 조건을 나타내는 객체입니다.
    • Q 객체는 & (AND), | (OR), ~ (NOT) 연산자를 지원합니다. 이러한 연산자를 사용하여 여러 개의 Q 객체를 조합하여 복잡한 쿼리를 구성할 수 있습니다.
    • Q 객체는 Django의 QuerySet API와 함께 주로 사용되며, 데이터베이스로부터 특정 조건에 맞는 데이터를 검색하고 필터링하는 데 유용합니다.
    • 예시를 보자
    from django.db.models import Q
    
    # 조건 1: title 필드가 "forest"를 포함하는 경우
    condition1 = Q(title__icontains='forest')
    
    # 조건 2: writer 필드가 "John"이거나 writer 필드가 인증된 경우
    condition2 = Q(writer__name='John') | Q(writer__is_verified=True)
    
    # 복합 조건: 조건 1과 조건 2를 AND 연산하여 결과를 구함
    combined_condition = condition1 & condition2
    
    # QuerySet에서 복합 조건을 사용하여 필터링
    filtered_queryset = MyModel.objects.filter(combined_condition)
    • icontains는 대소문자를 구분하지 않고 문자열을 포함하고 있냐는 것이다
    • 코드에서 아래 부분이 이해가 되지 않았다.
     if len(category_filter) > 0:
                query = None
                for element in category_filter:
                    if query is None:
                        query = Q(category=element)
                    else:
                        query = query | Q(category=element)
                q.add(query, q.AND)
    • 코드에서 category_filter는 리스트 형태이며, 여러 개의 카테고리가 담겨 있을 수 있습니다. category_filter 리스트에 요소가 있는 경우, 각 요소에 해당하는 카테고리를 OR 연산으로 조합하여 필터링할 조건을 생성하는 것이 목적입니다.
    • 반복문 for element in category_filter:category_filter 리스트의 요소들을 하나씩 순회합니다. 각 요소를 element 변수로 받아옵니다. 이 때, 요소에 해당하는 카테고리와 관련된 Q 객체를 생성하여 query 변수에 추가하는 동작이 수행됩니다.
    • 여기서 주목해야 할 부분은 query = query | Q(category=element)입니다. | 연산자는 OR 연산을 수행합니다. 이것은 이전에 생성된 query에 새로운 Q 객체를 OR 연산하여 기존 조건과 새로운 조건 중 어느 하나만 만족해도 되는 조건을 생성하는 것을 의미합니다.
    • 즉, 각 요소에 대해 카테고리를 조건으로 갖는 Q 객체를 생성하고, 첫 번째 요소에서는 query 변수에 그대로 대입하며, 두 번째 이후 요소들에 대해서는 query와 OR 연산하여 하나의 조건으로 합쳐지는 방식으로 query 변수가 갱신되어 최종적으로는 여러 개의 조건을 OR 연산으로 조합한 Q 객체가 생성됩니다. 이렇게 생성된 Q 객체는 해당하는 카테고리 중 하나라도 만족하는 숲들을 필터링하는데 사용됩니다.
    • q.add(query, q.AND)는 q 객체에 query 객체를 AND 연산자로 조합하여 두 조건이 동시에 충족되어야 하는 새로운 Q 객체를 생성하는 것을 의미합니다.
    • 검색 조건과 카테고리 필터링 조건을 모두 만족해야하기 때문에 q.AND가 붙은 것으로 보인다

    selector에서 annotate되어있던 name을 view 코드에 넣어놓고 돌려보니

    결과가 이따구로 나왔다...이러니까 문제가 생기지

     

    그렇다고 annotate안의 name을 주석처리한다고 한개만 리턴되는 것도 아니었다...얼척X

    생각한 방법: 아무래도 hashtags가 리스트로 반환이 안되는 것 같다(selector의 문제). dto로 해결을 한번 해보겠다


    시도한 방법1:dto 사용->실패?

    dto를 사용하여 정말 지랄 맞게 코드를 구현하였다

    dto를 사용하여 hashtag를 리스트로 포함시키는 것은 쉬웠다.

    forest_dtos = []
    
            for forest in forests:
                hashtags=[hashtag.name for hashtag in forest.hashtags.all()]
                forest_dto= MyPickForestDto(
                    id=forest.id,
                    title=forest.title,
                    forest_likes=forest.forest_likes,
                    # preview=forest.preview,
                    # forest_like=forest.forest_like,
                    rep_pic=forest.rep_pic,
                    writer=forest.writer,
                    writer_is_verified=forest.writer.is_verified,
                    hashtags=hashtags,
                )
                forest_dtos.append(forest_dto)
    
            return forest_dtos

    위와 같이 전에 작성한 코드를 사용하면 어렵지 않았다. 하지만 복병 발생.

    forest_like는 사실

    view 코드를 보면

    forest_like = serializers.SerializerMethodField()
    ...
    def get_forest_like(self, obj):
                re_user = self.context['request'].user
                likes = ForestSelector.likes(obj, user=re_user)
                return likes

    위와 같이 ForestSelctor에서 정의된 함수를 사용해서 뽑아오는 것이었다.

    이걸 dto로 구현하려니 코드가 훨씬훨씬 복잡해졌는데...

    또한 preview도 이런식으로 함수로 구현된 아이였다. 대공사의 시작이었다

    어찌저찌 dto도 고치고 함수에 Case문도 끌어들여오며 import도 오지게 시켜서 결국 돌렸더니 hashtag까지 똑같은 결과가 4개 리턴되는 그냥 또 여기다 for문 써서 중복 제거해야하는 레전드 귀찮음 대공사가 되었다.

    결과로 나왔던 postman 화면을 보여주고 싶은데 지금 또 문제가 생겨서 서버가 안 돌아간다. 암튼 코드는 아래와 같이 짰다.

    @dataclass
    class MyPickForestDto:
        id:int
        title:str
        user_likes:bool
        preview:str = None
        # forest_like:bool
        rep_pic: str
        writer:dict
        writer_is_verified:bool
        hashtags:list[str] = None
        
    forests = like_forest.filter(q).annotate(
                user_likes=Case(
                    When(Exists(Forest.likeuser_set.through.objects.filter(
                        forest_id=OuterRef('pk'),
                        user_id=self.user.pk
                    )),
                        then=Value(1)),
                    default=Value(0),
                ),
            ).select_related('writer').prefetch_related('hashtags').order_by('-created')
            
            forest_dtos = []
    
            for forest in forests:
                hashtags=[hashtag.name for hashtag in forest.hashtags.all()]
                forest_dto= MyPickForestDto(
                    id=forest.id,
                    title=forest.title,
                    user_likes=forest.user_likes,
                    preview=forest.preview,
                    # forest_like=forest.forest_like,
                    rep_pic=forest.rep_pic,
                    writer=forest.writer,
                    writer_is_verified=forest.writer.is_verified,
                    hashtags=hashtags,
                )
                forest_dtos.append(forest_dto)
    
            return forest_dtos

    이상함을 감지하고 생각보다 간단한 해답이 있을거라 찾은 부분은...다름아닌

    print(like_forest)는 결과가 1개고

    print(forest)는 결과가 3개가 리턴된다는 것에서부터였다.

    그 말 뜻은, 아무리 dto를 통해서 코드를 굴려도 결국 forests만 지나면 결과가 3개가 된다는 것이었다.

    name=forest__name하던 코드도 없는데 왜 그럴까....

    [prefetch/distinct사용하고 name지우지 않았을때]

    [2.name지우고 distinct만 쓸때]

    [3.name지우고 prefetch만 쓸때]

    세번의 실험결과...결국 prefetch_related와 distinct를 둘 다 써야함을 깨달았음

     

    +)prefetch_related하고 distinct안했을때 결과 아래와 같고 앞뒤로 print걸으니까  forests지나면서 무조건 여러개로 바뀌는듯

     

    --> 결론: 

    name=F('hashtags__name')을 사용하는건 잘못되었다. 사용할거면 prefetch_related()를 사용해라.

    하지만 그 또한 중복된 정보를 가져올 수 있다(이유는 ManyToMany여서인 것 같다).

    때문에 distinct로 중복제거를 해라!

    아래는 참고한 chatgpt의 설명!

    "ManyToMany 관계에서는 prefetch_related()를 사용할 때 중복된 결과가 발생할 수 있습니다. 이는 하나의 숲이 여러 개의 해시태그를 가지고 있을 때, 중복된 숲들이 반환되는 결과가 발생하는 것을 의미합니다."

     


    나의 의문: photos도 manytomany관계이다. 그런데 왜 photos가여러개일때는 아무렇지 않다가 hashtags에서만 결과의 수가 많아지는가?

    내가 나름 생각한 이유: filter(q)를 하면서 like_forest에 있는 hashtag를 가져와 검사해야한다. 그 이유가 아닐까? 그 과정에서각 포레스트마다 관련된 해시태그를 불러오기 위해 N+1 쿼리 문제가 발생할 수 있습니다. 

     

    내 생각이 맞았다.

    q에서 hashtag날리니까 결과가 아름답게 하나씩만 나옴

     

    이제야 속이 다 시원하네....

Designed by Tistory.