인프런 영문 브랜드 로고
인프런 영문 브랜드 로고

인프런 커뮤니티 질문&답변

pplkjh2님의 프로필 이미지
pplkjh2

작성한 질문수

파이썬/장고 웹서비스 개발 완벽 가이드 with 리액트 (장고 4.2 기준)

11장 팔로잉 기능 구현 관련

해결된 질문

작성

·

78

·

수정됨

0

11장 팔로잉 기능 구현에서는

  • 팔로잉 하는 목록 페이지

  • 팔로우 하고 있지 않는 목록 페이지

두 가지를 구현하였습니다.

이를 기반으로 인스타그램처럼 프로필을 카드기반으로 꾸미고 10-12 에서 구현한 무한 스크롤을 적용하였습니다. 프로필 페이지에서 구현한 프로필 카드를 목록에서 재활용하여 반복을 줄이고자 했습니다.

대부분의 기능은 구현하였습니다만, 강의의 내용만으로는 스스로 해결하기 힘든 점들이 있어 질문글을 파란색으로 남깁니다.

 

우선, 팔로잉 목록과 언팔로인 목록을 나눈 경우 언팔로잉 목록에서 내가 팔로잉 한지 모르고 검색한 경우 유저 목록이 검색되지 않는 불편함이 존재할 수 있습니다.

물론 인스타 그램의 경우 강사님께서 구현한 것처럼 탭을 나누어서 구현하였으니 이는 문제가 아닐 수 있습니다.

그러나 하기의 사진은 정확한 예시는 아닐 수 있으나 내 팔로워들 중 내 팔로잉 여부를 확인할 수 있는 페이지를 구현하고 싶습니다.

  • 인스타 팔로워 페이지

  • 인스타 팔로잉 페이지

 

따라서 팔로우하지 않는 목록 페이지를 유저 목록들이 모두 나열되면서 눈으로 팔로우 여부를 확인할 수 있는 페이지로 구현하고자 합니다.

 


 

하기는 현재 구현한 목록 페이지의 일부 입니다.

개인 프로필 페이지에서는 팔로잉 버튼 대신 edit 버튼이 활성화 됩니다.

해당 페이지를 구현하기 위해 11장에서 구현한 두 함수를 generic ListView를 상속받은 class 기반 뷰로 구현하였습니다.

# views.py
@method_decorator(login_required_hx, name="dispatch")
class DiscoverListView(ListView):
    model = User
    paginate_by = 12
    context_object_name="user_list"
    
    def get_queryset(self):
        qs = User.objects.all()
        user: User = self.request.user
        follow_relation = self.kwargs.get("follow_relation", False) # url로 부터 받는 인자
        match follow_relation:
            case "following":
                qs = user.following_user_set.all()
            case "followed":
                qs = user.followed_user_set.all()

        # qs = User.objects.exclude(id__in=user.following_user_set.all())
        qs = qs.exclude(id__in=[user.pk]).select_related("profile").prefetch_related("follower_user_set", "following_user_set")

        query = self.request.GET.get("query", "").strip()
        if query:
            qs = qs.filter( 
                Q(username__icontains=query) | 
                Q(full_name__icontains=query) | 
                Q(email__icontains=query) 
                )
        return qs.order_by("username")
    
    def get_template_names(self) -> list[str]:
        # htmx로 요청이 들어오면 검색바 등 불필요한 위 아래 내용 제거 후 내용만 업데이트
        if self.request.htmx: 
            template_name = "accounts/_user_list.html"
        else:
            template_name = "accounts/user_list.html"
        return [template_name]
    
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # is_follwer 는 html에서 follow 버튼을 구분하기 위한 인자
        # context['is_follower'] = True if self.kwargs.get("follow_relation", False)=="following" else False
        qs = self.get_queryset()
        follow_dict = { q.username:self.request.user.is_follower(q) for q in qs }
        context['follow_dict'] = follow_dict
        query = self.request.GET.get("query", "").strip()
        context['query']=query
        return context

여기서 .prefetch_related("follower_user_set", "following_user_set") 을 통해 모델에서 구현한 팔로워와 팔로잉 유저 수를 불러오는 property를 templates에서 호출할 때 N+1 문제를 해결하고자 하였습니다.

<!-- _user_profile_card.html -->
{% load static %}
<div class="card" style="border-radius: 15px;">
  <div class="card-body p-4">
    <div class="d-flex">

      <div class="flex-shrink-0">
        {% if user.profile.avatar %}
        <img src="{{user.profile.avatar.url}}" 
        alt="{{user}}'s avatar"
        class="img-fluid"
        style="width: 180px; border-radius: 10px;">
        {% else %}
        <img src="{% static 'images/profile-icon-design-free-vector.jpg' %}" 
        alt="{{user}}'s avatar"
        class="img-fluid"
        style="width: 180px; border-radius: 10px;">
        {% endif %}
      </div>

      <div class="flex-grow-1 ms-3">
        <h5 class="mb-1" style="display:inline">{{ user.full_name }} </h5>
        {% if user.profile.team %} <h6 class="text-muted" style="display:inline">{{ user.profile.team }}</h6> {% endif %}
        <p class="mb-1 pb-1">{{ user.email }}</p>

        <div class="d-flex justify-content-start rounded-3 p-2 mb-2 bg-body-tertiary">
          <div>
            <p class="small text-muted mb-1">Data</p>
            <p class="mb-0">TDA</p>
          </div>
          <div class="px-3">
            <p class="small text-muted mb-1">Followers</p>
            <p class="mb-0">{{user.follower_count}}</p>
          </div>
          <div>
            <p class="small text-muted mb-1">Following</p>
            <p class="mb-0">{{user.following_count}}</p>
          </div>
        </div>
        <div class="d-flex pt-1">
          {% if request.user == user %}
          <a href="{% url 'accounts:profile_edit' %}" class="btn btn-outline-primary btn-sm">
            <i class="bi bi-pencil-square"></i>
            프로필 수정
          </a>
          {% else %}
          {% comment %} {% include "accounts/_user_follow.html" with username=user.username%} {% endcomment %}

          {% include "accounts/_user_follow.html" with is_follower=follow_dict.user.username username=user.username%}

          {% endif %}
        </div>

      </div>
    </div>
  </div>
</div>
  1. 프로필 카드 내에서 팔로워와 팔로잉 수를 보여주기 위해 접근한 제 방식이 맞는지가 궁금합니다. models.py에서 구현한 follower_count 또는 following_count를 template에서 호출하는 방식이 일반적인 접근 방식인가요?

그 다음 제가 위에서 구현하고자 한 유저별 팔로우 여부를 get_context_data에서 follow_dict 라는 변수명으로 저장하여 하기와 같이 template에 넘겨주었습니다.

<!--user_follow.html-->
{% include "core/_messages_as_event.html" %}
{% load keyvalue %}
{% if follow_dict|keyvalue:username %}
    <a href="#"
        hx-post="{% url 'accounts:user_unfollow' username %}"
        hx-swap="outerHTML"
        class="btn btn-sm btn-primary">
        <i class="bi bi-person-check-fill"></i>
        팔로잉 중
    </a>
{% else %}
    <a href="#"
        hx-post="{% url 'accounts:user_follow' username %}"
        hx-swap="outerHTML"
        class="btn btn-sm btn-secondary">
        <i class="bi bi-person-add"></i>
        팔로잉 하기
    </a>
{% endif %}

dictionary 키 값으로 변수를 입력하는 것이 django template에서 지원되지 않기 때문에 하기의 함수를 등록하여 활용하였습니다.

register = template.Library()
@register.filter
def keyvalue(dict, key):
    return dict[key]

결과물은 구현한 페이지처럼 잘 보여지지만, 두 가지 문제가 존재합니다.

  1. 확실치 않지만 하기의 코드에서 N+1 오류가 발생하는 것으로 보입니다.

    follow_dict = { q.username:self.request.user.is_follower(q) for q in qs }
  2. 팔로잉 혹은 언팔로잉 요청 시 500 서버 에러가 발생합니다. 아마도 follow_dict가 요청 시에 업데이트 되지 않아 발생하는 오류로 보입니다.
    이러한 점 때문에 인스타그램에서도 따로 탭을 두어서 관리하는가 싶기도 합니다만 이를 해결할 방법이 있을까요?
    어렵다면 팔로워 목록에서 내가 팔로잉 하는지 여부를 표기하는 인스타그램 페이지는 어떻게 구현해야 할까요?

답변 2

0

이진석님의 프로필 이미지
이진석
지식공유자

안녕하세요.

User 모델의 is_follower 메서드가 지정 user id에 대해서 EXISTS 쿼리를 수행하므로, 특정 user에 대해서 1회성으로 팔로잉 여부를 확인하는 데에는 적합하지만, 다수 유저에 대한 팔로잉 여부를 확인하실 때 쓰시면, 각 유저마다 조회 쿼리가 발생하므로 N+1 이슈가 발생합니다. (N+1 Problem은 오류는 아닙니다. :-) )

User 모델에 아래와 같이 is_follower_multiple 메서드를 추가하시어, 인자로 받은 쿼리셋의 모든 유저들에 대해서 한 번에 팔로잉 여부를 조회하실 수 있습니다.

image.png

그리고, Tip으로 이와 같은 쿼리셋을 한 번에 생각하기는 어려우니, ChatGPT, Claude, Cursor 서비스의 도움을 받으시면, 보다 수월하게 코드를 작성하실 수 있습니다.

살펴보시고, 댓글 남겨주세요.

화이팅입니다. :-)

pplkjh2님의 프로필 이미지
pplkjh2
질문자

아래 AI 댓글과 조합하여 모든 문제가 해결되었습니다. annotate를 view에서 사용하여 template에서 is_follower bool값을 불러서 활용하니까 편리하네요

0

안녕하세요, 인프런 AI 인턴입니다. 유형주 님.

  1. 프로필 카드 내에서 팔로워와 팔로잉 수를 보여주기 위해 접근한 방식

{% if user.follower_count %}{% if user.following_count %}와 같이 템플릿에서 직접 모델의 계산된 프로퍼티를 호출하는 방식은 매우 일반적이고 합리적인 접근 방식입니다. 이 경우, qs.prefetch_related("follower_user_set", "following_user_set")를 통해 N+1 문제를 미리 대비한 점도 좋은 접근입니다. 다만, 프로덕션 환경에서 실제로 문제가 없는지 확인하는 것이 중요합니다.

  1. 팔로우 여부를 담는 dictionary를 context 데이터로 넘기는 방식

    • N+1 문제: follow_dict = {q.username: self.request.user.is_follower(q) for q in qs} 코드가 호출될 때 각 사용자마다 is_follower 체크를 위해 데이터베이스 쿼리가 추가로 실행될 가능성이 높습니다. 이 경우, 데이터베이스 최적화가 필요할 수 있습니다.

    • 팔로우/언팔로우 기능의 업데이트 문제: 팔로우/언팔로우 버튼 클릭 시 500 서버 에러가 발생하는 이유는 팔로우/언팔로우 후에 현재 페이지에서 버튼 상태를 다시 렌더링 할 때, context 데이터를 갱신하지 않아서 발생할 가능성이 큽니다.

이 두 문제를 해결하기 위해 다음과 같은 방법을 고려할 수 있습니다.

N+1 문제를 해결하는 접근

  • 중복된 쿼리 제거: 현재 로그인한 사용자와 다른 사용자의 팔로우 관계를 미리 한 번에 가져오기 위해서 annotateexists와 같은 ORM 기능을 사용해보세요.
from django.db.models import Q

def get_queryset(self):
    qs = User.objects.all().exclude(id=self.request.user.pk)
    qs = qs.select_related("profile").prefetch_related("follower_user_set", "following_user_set")

    query = self.request.GET.get("query", "").strip()
    if query:
        qs = qs.filter(
            Q(username__icontains=query) |
            Q(full_name__icontains=query) |
            Q(email__icontains=query)
        )
    qs = qs.annotate(is_follower=Q(following_user_set__id=self.request.user.pk))
    return qs.order_by("username")

이렇게 하면 사용자가 어떤 팔로우 관계를 갖고 있는지 미리 계산하여 가져올 수 있습니다. 이후 템플릿에서 user.is_follower로 접근하면 됩니다.

상태 업데이트 문제 해결하기

  • 팔로우/언팔로우 시 상태 업데이트: 팔로우/언팔로우 요청 후에는 화면 전체를 다시 로드하지 않고 일부 영역만 업데이트 하도록 부분 렌더링을 사용할 수 있습니다. 이렇게 하면 서버에서는 user.is_follower 값을 다시 계산하지 않아도 됩니다.
<!-- user_follow.html -->
{% include "core/_messages_as_event.html" %}
{% load keyvalue %}
<div hx-target="this" hx-swap="outerHTML">
    {% if user.is_follower %}
    <a href="#"
       hx-post="{% url 'accounts:user_unfollow' username %}"
       hx-swap="outerHTML"
       class="btn btn-sm btn-primary">
        <i class="bi bi-person-check-fill"></i>
        팔로잉 중
    </a>
    {% else %}
    <a href="#"
       hx-post="{% url 'accounts:user_follow' username %}"
       hx-swap="outerHTML"
       class="btn btn-sm btn-secondary">
        <i class="bi bi-person-add"></i>
        팔로잉 하기
    </a>
    {% endif %}
</div>

이렇게 하면, 팔로우/언팔로우 후에 해당 부분만 업데이트되므로 성능 문제도 방지할 수 있습니다.

추가 참고 게시물:

저는 질문자님의 이해를 돕고자 지식공유자님의 기존 답변을 참고하여 유사한 답변 링크를 추천해드리고 있습니다. 현재 베타 기능으로 답변이 만족스럽지 않을 수 있는 점 양해 부탁드립니다. 🙏 추가적으로 궁금한 점이 있으시면, 이어서 질문해 주세요. 곧 지식공유자께서 답변해 주실 것입니다.

pplkjh2님의 프로필 이미지
pplkjh2

작성한 질문수

질문하기