3-11 추천
* `[완성 소스]` : [github.com/pahkey/jump2django/tree/3-11](https://github.com/pahkey/jump2django/tree/…
wikidocs.net
1. 추천 수 응답 (서버 측 작업)
forum/models.py
from django.contrib.auth.models import User
from django.db import models
from django.utils import timezone
from member.models import ForumUser
class Post(models.Model):
author = models.ForeignKey(ForumUser, on_delete=models.CASCADE)
title = models.CharField(max_length=100)
content = models.TextField()
created_date = models.DateTimeField(default=timezone.now)
updated_date = models.DateTimeField(null=True, blank=True)
voted_users = models.ManyToManyField(ForumUser)
class Comment(models.Model):
author = models.ForeignKey(ForumUser, on_delete=models.CASCADE)
post = models.ForeignKey(Post, on_delete=models.CASCADE)
content = models.TextField()
created_date = models.DateTimeField(default=timezone.now)
updated_date = models.DateTimeField(null=True, blank=True)
voted_users = models.ManyToManyField(ForumUser)
한 유저는 여러 게시물(댓글)에 추천할 수 있고 / 한 게시물(댓글)에 여러 유저가 추천할 수 있습니다.
그렇기 때문에 다대다 관계 필드를 사용하여 '추천인들' 필드를 Post, Comment 필드의 속성에 추가합니다.
그리고 마이그레이션을 진행해 봅시다.
(.venv) C:\Users\***\PycharmProjects\forum-with-django>python manage.py makemigrations
SystemCheckError: System check identified some issues:
ERRORS:
forum.Comment.author: (fields.E304) Reverse accessor 'ForumUser.comment_set' for 'forum.Comment.author' clashes with reverse accessor for 'forum.Comment.voted_users'.
HINT: Add or change a related_name argument to the definition for 'forum.Comment.author' or 'forum.Comment.voted_users'.
forum.Comment.voted_users: (fields.E304) Reverse accessor 'ForumUser.comment_set' for 'forum.Comment.voted_users' clashes with reverse accessor for 'forum.Comment.author'.
HINT: Add or change a related_name argument to the definition for 'forum.Comment.voted_users' or 'forum.Comment.author'.
forum.Post.author: (fields.E304) Reverse accessor 'ForumUser.post_set' for 'forum.Post.author' clashes with reverse accessor for 'forum.Post.voted_users'.
HINT: Add or change a related_name argument to the definition for 'forum.Post.author' or 'forum.Post.voted_users'.
forum.Post.voted_users: (fields.E304) Reverse accessor 'ForumUser.post_set' for 'forum.Post.voted_users' clashes with reverse accessor for 'forum.Post.author'.
HINT: Add or change a related_name argument to the definition for 'forum.Post.voted_users' or 'forum.Post.author'.
마이그레이션 생성 오류를 확인하실 수 있는데요.
02 차시에서 모델 생성 시 Post와 1:N관계를 가지는 author속성을 이용해 우리가 뷰나 템플릿 변수에서 'User.post_set.all()'메서드를 사용하여 해당 유저가 작성한 Posts를 불러오는 기능을 사용할 수 있음을 확인했습니다.
그러나 이제는 voted_users속성이 추가되면서 User의 post_set을 사용하면 장고가 유저가 작성한 Posts를 보여줘야 하는지 유저가 추천한 Posts를 보여줘야 하는지 알 수 없게 됩니다.
그래서 우리는 related_name 매개변수를 사용하여 서로를 구분할 수 있는 메서드명 스트링 값을 전달해 주어야 합니다.
...
class Post(models.Model):
author = models.ForeignKey(ForumUser, on_delete=models.CASCADE, related_name='posts')
...
voted_users = models.ManyToManyField(ForumUser, related_name='voted_posts')
def __str__(self):
return self.title
class Comment(models.Model):
author = models.ForeignKey(ForumUser, on_delete=models.CASCADE, related_name='comments')
...
voted_users = models.ManyToManyField(ForumUser, related_name='voted_comments')
related_name에 원하는 메서드 명을 전달할 수 있습니다. 기존과 같이 User.voted_comments.all() 메서드 형태로 접근 가능합니다.
위처럼 author 속성에도 related_name에 인자를 전달 해 주면 User.posts.all()을 이용해 접근할 수 있고,
만약 전달하지 않았다면 기존처럼 User.post_set.all()을 이용해 작성한 게시물을 불러오고, User.voted_posts.all()을 이용해 추천한 게시물을 불러올 수 있습니다.
이제 마이그레이션을 진행해 줍시다.
(.venv) C:\Users\***\PycharmProjects\forum-with-django>python manage.py makemigrations
Migrations for 'forum':
forum\migrations\0005_comment_voted_users_post_voted_users_and_more.py
+ Add field voted_users to comment
+ Add field voted_users to post
~ Alter field author on comment
~ Alter field author on post
(.venv) C:\Users\***\PycharmProjects\forum-with-django>python manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, forum, member, sessions
Running migrations:
Applying forum.0005_comment_voted_users_post_voted_users_and_more... OK
결과 확인
장고 쉘에서 해당 메서드가 정상 작동하는지 확인해 봅시다.
(.venv) C:\Users\***\PycharmProjects\forum-with-django>python manage.py shell
8 objects imported automatically (use -v 2 for details).
Python 3.13.1 (tags/v3.13.1:0671451, Dec 3 2024, 19:06:28) [MSC v.1942 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> from member.models import ForumUser
>>> user1 = ForumUser.objects.get(pk=1)
>>> user1.posts.all()
<QuerySet [<Post: 1234>, <Post: 2345>, <Post: 3456>]>
>>> user1.voted_posts.all()
<QuerySet []>
>>>
forum/comment_views.py
post_views는 템플릿 변수를 사용하기 때문에 템플릿 내에서 .count()메서드를 사용하여 추천 수를 출력할 수 있습니다.
그러나 comment_views는 JSON형식으로 응답해주기 때문에 응답 내용에 추천 수를 동봉해 봅시다.
- Post의 경우 템플릿 내부에서 {{ post.voted_users.count }}와 같이 접근 가능합니다
def get_comments(post_pk, page):
comments = Comment.objects.filter(post=post_pk).values(
'pk', 'author__pk', 'author__username', 'author__nickname', 'content', 'created_date', 'updated_date')
paginator = Paginator(comments, 10)
if not page:
page = paginator.num_pages
page_obj = paginator.get_page(page)
custom_field = []
for comment in page_obj: #page_obj의 크기 만큼 반복문이 돔
# 쿼리셋 내부 comment 각각의 pk속성 값을 통해 DB에 해당 객체의 내용을 쿼리하고
temp_cmt = get_object_or_404(Comment, pk=comment['pk'])
# 다시 voted_users의 집계(count)연산을 DB에 요청 쿼리를 보냅니다.
voted_users_count = temp_cmt.voted_users.count()
custom_field.append({
'pk': comment['pk'],
'author_pk': comment['author__pk'],
'username': comment['author__username'],
'nickname': comment['author__nickname'],
'content': comment['content'],
'voted_users.count': voted_users_count,
'created_date': comment['created_date'],
'updated_date': comment['updated_date'],
})
context = {
'comments': custom_field,
'last_page': paginator.num_pages,
}
return context
.위와 같은 방법을 사용하면 count같은 연산도 쿼리셋이나 리스트에 포함시킬 수 있습니다.
다만 위의 방법처럼 반복문 내에서 Comment 모델 객체 인스턴스를 생성하면 문제점이 몇가지 존재하는데요.
- 불필요한 모델 인스턴스를 만들지 않고 DB만으로 count할 수 있습니다.
- 집계(count, sum, avg ...)를 할때 모델 인스턴스 생성 없이 annotate()를 통해 DB에서 처리시킬 수 있습니다.
- DB는 집계 연산에 특화되어 있습니다. 대량 데이터일 수록 반복문 내에서 인스턴스 생성은 속도차이가 커집니다.
- 반복적인 모델 인스턴스 생성으로 서버의 리소스를 낭비합니다.
- 2N+1 추가 쿼리 발생
- 반복문 내에서 각각의 comment를 상대로 두번의 쿼리를 추가로 보내게 됩니다. (2N+1 추가 쿼리)
- 첫 쿼리(values)[1] + 반복문 내 각각의 comment 쿼리[N] + 반복문 내 각각의 comment의 count연산[N]
- 단일 쿼리로 요청할 수 있는 작업 2N+1 이라는 비효율적인 방법을 사용하게 됩니다.
- 반복문 내에서 각각의 comment를 상대로 두번의 쿼리를 추가로 보내게 됩니다. (2N+1 추가 쿼리)
성능적으로 보았을 때 데이터량이 많아질수록 value()와 annotate()를 이용하여 원하는 필드와 값을 DB선에서 처리해 가져오고
정말 필요한 경우에만 반복문 내에서 인스턴스를 생성하여 필요한 정보를 포함하는 것이 좋습니다.
annotate()를 이용한 뷰 함수
from django.db.models import Count
def get_comments(post_pk, page):
comments = (Comment.objects.filter(post=post_pk)
# annotate를 사용하여 voted_users의 카운트를 DB에서 집계하고, 추가 필드를 생성합니다.
.annotate(voted_users_count=Count('voted_users'))
.values('pk', 'author__pk', 'author__username', 'author__nickname',
# 추가한 필드를 key값으로 하여 이 값도 values 결과에 포함시켜 쿼리셋에 저장합니다.
'content', 'voted_users_count', 'created_date', 'updated_date'))
paginator = Paginator(comments, 10)
if not page:
page = paginator.num_pages
page_obj = paginator.get_page(page)
custom_field = []
for comment in page_obj:
custom_field.append({
'pk': comment['pk'],
'author_pk': comment['author__pk'],
'username': comment['author__username'],
'nickname': comment['author__nickname'],
'content': comment['content'],
'voted_users_count': comment['voted_users_count'],
'created_date': comment['created_date'],
'updated_date': comment['updated_date'],
})
context = {
'comments': custom_field,
'last_page': paginator.num_pages,
}
return context
+ N+1 문제
특정 문제를 해결하기 위해 반복문을 사용 시 발생하는 문제 (특히 조회의 경우 추가적인 데이터 접근)
- 발생 예제
posts = Post.objects.all() # 초기 데이터 조회 (1 쿼리)
for post in posts: # 반복문 내 추가 쿼리 (N 쿼리)
print(post.author.username)
# N개의 게시물에 대해 N번의 추가 데이터베이스 쿼리
# 외래키인 author속성에 대해서 ORM은 외래키인 author의 pk값 만을 요청합니다. (lazy loading)
# author의 내부 필드에 접근하면 그때서야 해당 author에 접근하기 위해 추가 쿼리를 날림
# 그래서 author 객체의 하위 속성인 username에 접근하기 위해서는
# 해당 pk값을 가지는 author객체를 쿼리하여 생성 후 해당 데이터에 접근하게 됩니다.
- 해결 방법
# 1. select_related 사용하기 (1:1, N:1)
# ORM이 SQL쿼리 시 JOIN을 이용하여 연관 객체를 모두 불러옵니다.
# Post의 author 처럼 단일 객체를 ForeignKey or OneToOne로 찾는 경우에 사용합니다.
posts = Post.objects.select_related('author').all() # `SELECT...JOIN...`을 사용하여 한문장의 쿼리로 요청
for post in posts:
print(post.author.username) # 각각의 post의 author에 대한 정보를 같이 받아왔음으로 추가쿼리 발생 X
# 2. prefetch_related 사용하기 (1:N, N:N)
# ORM이 JOIN을 사용하여 한번의 쿼리 요청만 보내는 것이 아니라
# 별도 쿼리로 연관 데이터들을 한번에 불러와 파이썬 내에서 매칭합니다. (2번 요청)
# 접근하려는 하위 속성이 N인 경우에 사용합니다. (여러 객체를 찾는 경우)
# ex. posts[N]:comments[N] SQL JOIN 시 row가 폭증하여 비효율 적입니다.
# 첫 번째 쿼리에서 Posts 객체 목록을 가져오고
# 두 번째 쿼리에서 해당 Post들과 연결된 Comments 한번에 가져와 줍니다.
# 그리고 파이썬에서 각 Post에 해당하는 Comments를 매핑해 줍니다.
# 2.1 1:N 관계 (1+1 문제)
author = ForumUser.objects.get(username='asdf1234') # 한개의 author에 대해 쿼리 (1)
for post in author.posts.all(): # 그 author의 posts를 한번에 요청 쿼리 (1)
print(post)
author = ForumUser.objects.prefetch_related('posts').get(username='asdf1234')
for post in author.posts.all():
print(post)
# 이런 1:N 관계의 경우 prefetch의 여부가 실질적 성능차이를 내진 않습니다.
# 사용하지 않아도 .all()을 통해 한번의 추가 쿼리만 발생하고, prefetch를 사용해도 한번의 쿼리가 발생합니다.
# 2.2 N:N 관계 (N+1 문제)
authors = ForumUser.objects.all() # 여러 명의 authors에 대해 쿼리 (1)
for author in authors:
for post in author.posts.all(): # 받아온 authors에서 각각의 author의 posts 요청 쿼리 (N)
print(post)
# or
print(author.posts.all()) # 2중 반복문이 중요한게 아니고 각 author마다 posts를 쿼리한다는 것이 중요
authors = ForumUser.objects.prefetch_related('posts').all()
for author in authors:
for post in author.posts.all():
print(post)
# 3. annotate, values
# annotate: 반복문을 통한 집계가 필요한 경우 DB에서 집계 연산을 하도록 합니다.
# values: 필요한 필드를 명시하여 요청합니다.
우리의 프로젝트에도 N+1 문제가 나타나는 곳이 존재합니다. 위의 예제처럼 post_list가 해당하는데요.
def post_list(request):
posts = Post.objects.order_by('-created_date')
request_page = request.GET.get('page', 1)
paginator = Paginator(posts, 10)
page_obj = paginator.get_page(request_page)
context = {'posts': page_obj}
return render(request, 'forum/post_list.html', context)
{% for post in posts %}
<tr>
<td class="text-center">{{ post.pk }}</td>
<td class="text-center">
<a href="{% url 'forum:post_detail' post.pk %}">{{ post.title }}</a> [{{ post.comment_set.count }}]
</td>
<td class="text-center">{{ post.author.username }}</td>
<td class="text-center">{{ post.created_date|date:"Y/m/d A h:i" }}</td>
</tr>
{% endfor %}
템플릿에서 반복문을 통해 posts를 탐색하고 그 내부에서 외래키 관계인 author의 username(혹은 nickname)하위속성에 접근하게 됩니다.
우리는 페이징을 통해 한페이지에 10개의 게시물만 전달하기 때문에 실제 체감되는 성능차는 미미할 수 있습니다.
다만 미래의 확장성(수가 늘거나, 다양한 정보를 참조)을 고려하거나 N+1 문제를 방지하기 위한 습관을 생각해서 post_list 뷰함수에 select_related를 사용해 봅시다.
def post_list(request):
posts = Post.objects.select_related('author').order_by('-created_date')
...
1. 추천 수 응답 (클라이언트 측 작업)
post_detail.html
{% extends 'base.html' %}
{% block title %}
Forum: {{ post.title }}
{% endblock title %}
{% block body %}
<div class="container">
<h2 class="border-bottom py-2">{{ post.title }}</h2>
<div class="card border-secondary">
...
</div>
<div class="row">
<div class="col">
<a href="{% url 'forum:post_list' %}"
class="btn btn-secondary mt-2">글 목록</a>
</div>
<!-- 글 목록 버튼과 수정,삭제 버튼 사이에 추천 버튼을 위치시킵니다. -->
<div class="col text-center">
<button class="btn btn-primary mt-2 vote-button" data-url="">추천
<span class="badge text-bg-secondary">{{ post.voted_users.count }}</span>
</button>
</div>
<div class="col text-end">
<!-- 수정과 삭제 버튼 -->
</div>
</div>
...
<template id="comment-template">
<div class="card mb-3">
<div class="card-header">
<!-- 카드 헤더를 row col 클래스를 사용하여 반으로 쪼개고 -->
<div class="row">
<div class="col author"></div>
<div class="col vote text-end">
<!-- 카드 헤더의 오른쪽 상단에 추천 버튼을 위치시킵니다. -->
<button class="btn btn-primary btn-sm vote-button">추천
<span class="badge text-bg-secondary"></span>
</button>
</div>
</div>
</div>
...
</div>
</template>
...
</div>
...
post_detail.js
function renderComments(comments, requestPage, lastPage) {
const commentsDiv = document.querySelector('.comments');
commentsDiv.innerHTML = '';
comments.forEach(comment => {
const cardDiv = document.querySelector('#comment-template').content.cloneNode(true);
cardDiv.firstElementChild.id = `comment-${comment['pk']}`;
cardDiv.querySelector('.card-header .author').append(`${comment['nickname']} (${comment['username']})`);
cardDiv.querySelector('.card-header .vote-button span').textContent = comment['voted_users_count']
...
결과 확인

2. 추천 기능 (서버 측 작업)
urls.py
urlpatterns = [
...
# 장고에서 추천 기능을 하나의 리소스로 본다면 새 모델을 생성하고 /vote/로 url 라우팅 구조를 빼주는 것이 좋겠지만
# 추천 종류, 날짜, 기록 같은 세부 기능은 구현하지 않기 때문에 Post와 Comment의 한 기능으로써 구현해 봅시다.
...
path('<int:post_pk>/vote/', post_views.post_vote, name='post_vote'),
...
path('<int:post_pk>/comments/<int:comment_pk>/vote/', comment_views.comment_vote, name='comment_vote'),
추천 기능 로직
# post_views의 추천 기능
def post_vote(request, post_pk):
post = get_object_or_404(Post, pk=post_pk)
if not post.voted_users.filter(username=request.user.username).exists():
post.voted_users.add(request.user)
context = {
'status': 'added',
'voted_users_count': post.voted_users.count(),
}
else:
post.voted_users.remove(request.user)
context = {
'status': 'removed',
'voted_users_count': post.voted_users.count(),
}
return JsonResponse(context)
# comment_views의 추천 기능
def comment_vote(request, post_pk, comment_pk):
comment = get_object_or_404(Comment, pk=comment_pk)
if not comment.voted_users.filter(username=request.user.username).exists():
comment.voted_users.add(request.user)
context = {
'status': 'added',
'voted_users_count': comment.voted_users.count(),
}
else:
comment.voted_users.remove(request.user)
context = {
'status': 'removed',
'voted_users_count': comment.voted_users.count(),
}
return JsonResponse(context)
두 함수는 voted_users속성을 가지는 특정 모델 인스턴스를 수정하는데, 코드가 중복됩니다. (IDE에서도 경고해 줍니다.)
해당 뷰함수의 공통 로직을 분리하여 재사용(DRY Don't Repeat Yourself)해 봅시다.
base_views.py
def vote_toggle(request, obj):
if not obj.voted_users.filter(username=request.user.username).exists():
obj.voted_users.add(request.user)
context = {
'status': 'added',
'voted_users_count': obj.voted_users.count(),
}
else:
obj.voted_users.remove(request.user)
context = {
'status': 'added',
'voted_users_count': obj.voted_users.count(),
}
return context
특정 인스턴스를 매개변수로 받아 voted_users 속성에 대한 수정작업을 하는 공통 로직을 작성했습니다.
vote에 대한 views를 따로 생성하지 않고 기존에 post_list를 담당하고 있던 base_views에 작성하였습니다.
post_views.py
@require_POST
@login_required
def post_vote(request, post_pk):
post = get_object_or_404(Post, pk=post_pk)
from forum.views.base_views import vote_toggle
context = vote_toggle(request, post)
return JsonResponse(context)
comment_views.py
@require_POST
@login_required
def comment_vote(request, post_pk, comment_pk):
comment = get_object_or_404(Comment, pk=comment_pk)
from forum.views.base_views import vote_toggle
context = vote_toggle(request, comment)
return JsonResponse(context)
해당 뷰 함수에서 Post, Comment에 따라 모델 인스턴스만 생성 후 base_views의 추천 함수에 전달해 주었습니다.
해당 함수는 오로지 vote에서만 사용함으로 로컬 임포트를 하여 진행했습니다.
2. 추천 기능 (클라이언트 측 작업)
이제 게시물과 댓글의 추천 요청을 비동기로 보내봅시다.
post_detail.html
<!-- 게시물의 추천 버튼에 데이터셋을 이용하여 추천 기능 url을 삽입합니다. (댓글은 JS에서 렌더링 시 삽입) -->
<button class="btn btn-primary mt-2 vote-button"
data-url="{% url 'forum:post_vote' post.pk %}">추천
<span class="badge text-bg-secondary">{{ post.voted_users.count }}</span>
</button>
post_detail.js
import {makePagination} from '/static/js/paging.js';
const csrfTokenValue = document.querySelector('input[name="csrfmiddlewaretoken"]').value;
function bindDeleteListeners() {
...
}
function bindUpdateListeners() {
...
}
// vote 기능 바인더
function bindVoteListeners() {
const voteButtons = document.querySelectorAll('.vote-button');
voteButtons.forEach(function (element) {
element.addEventListener('click', async function () {
const body = new FormData();
body.append('csrfmiddlewaretoken', csrfTokenValue);
const context = await apiFetch(this.dataset.url, 'POST', body)
if (context.status === 'added' || context.status === 'deleted') {
this.querySelector('span').textContent = context['voted_users_count'];
}
})
})
}
function renderComments(comments, requestPage, lastPage) {
const commentsDiv = document.querySelector('.comments');
commentsDiv.innerHTML = '';
comments.forEach(comment => {
...
cardDiv.querySelector('.card-header .vote-button').dataset.url = `comments/${comment['pk']}/vote/`
cardDiv.querySelector('.card-header .vote-button span').textContent = comment['voted_users_count']
...
})
bindDeleteListeners();
bindUpdateListeners();
bindVoteListeners();
...
}
결과 확인

base_views.py
게시물 리스트에도 추천 수를 표시해 봅시다.
def post_list(request):
#메서드 체이닝을 위해 소괄호로 감싸기
posts = (
Post.objects
# author.username을 출력하기 위해
.select_related('author')
# 추천 수를 출력하기 위해
.annotate(voted_users_count=Count('voted_users'))
.order_by('-created_date')
)
request_page = request.GET.get('page', 1)
paginator = Paginator(posts, 10)
page_obj = paginator.get_page(request_page)
context = {'posts': page_obj}
return render(request, 'forum/post_list.html', context)
post_list.html
<thead>
<tr class="table-secondary text-center">
<th style="width: 6%">글번호</th>
<th style="width: 50%">제목</th>
<th style="width: 10%">작성자</th>
<th style="width: 15%">작성일</th>
<th style="width: 4%">추천</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% if posts %}
{% for post in posts %}
<tr>
<td class="text-center">{{ post.pk }}</td>
<td class="text-center">
<a href="{% url 'forum:post_detail' post.pk %}">{{ post.title }}</a> [{{ post.comment_set.count }}]
</td>
<td class="text-center">{{ post.author.nickname }}</td>
<td class="text-center">{{ post.created_date|date:"Y/m/d A h:i" }}</td>
<td class="text-center">{{ post.voted_users_count }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
결과 확인

'Back-end > Forum with Django' 카테고리의 다른 글
| 27. 비밀번호 초기화 (2) | 2025.07.29 |
|---|---|
| 26. 게시물 검색 (3) | 2025.07.29 |
| 24. 댓글 페이징 (3) | 2025.07.21 |
| 23. 비동기 로그인 & 게시물 임시저장 (1) | 2025.07.15 |
| 22. 비동기 요청으로 댓글 삭제 (2) | 2025.07.09 |