def get_board_set_from_board_group(request, board_group_id):
...완료!!!
def home(request):
...이번장!!!
def board(request, board_url):
...
def post_detail(request, board_url, pk):
...
def reply_write(request, board_url, pk):
...
def rereply_write(request, board_url, pk):
...
def reply_delete(request, board_url, pk):
...
def rereply_delete(request, board_url, pk):
...
def like(request, board_url, pk):
...
get_board_set_from_board_group 리펙토링 정보 보기
Board 앱의 View 함수들을 살펴보니 총 9개의 코드가 존재합니다.
하나하나씩 불필요한 코드를 제거하거나 리팩토링해 보겠습니다
이번에는 home 입니다.
def home(request):
recent_post_set = Post.objects.active().order_by(
'-id'
)[:6]
liked_ordered_post_set = Post.objects.active().annotate(
reply_count=Count('replys', distinct=True) + Count('rereply', distinct=True),
like_count=Count('likes', distinct=True),
).order_by(
'-like_count',
'-reply_count',
'-id',
)[:6]
tag_set = Tag.objects.all()
announce_set = Announce.objects.order_by(
'-created_at'
)[:5]
lesson = Lesson.objects.last()
context = {
'recent_post_set': recent_post_set,
'liked_ordered_post_set': liked_ordered_post_set,
'tag_set': tag_set,
'announce_set': announce_set,
'lesson': lesson,
}
return render(request, 'board/home.html', context)
home view(Controller)가 하는 역할을 살펴보면, https://cwbeany.com/ 이 페이지에 들어왔을 때 보여주는 역할들을 하고 있습니다.
리팩토링 절차는 다음과 같습니다:
1. import 는 이상이 없기 때문에 Pass 합니다.
2. 불필요한 코드도 없어 보입니다. 우선 Pass 합시다.
3. 중복되는 코드가 있는지 확인해봤는데,
def home(request):
recent_post_set = Post.objects.active().order_by(
'-id'
)[:6]
liked_ordered_post_set = Post.objects.active().annotate(
reply_count=Count('replys', distinct=True) + Count('rereply', distinct=True),
like_count=Count('likes', distinct=True),
).order_by(
'-like_count',
'-reply_count',
'-id',
)[:6]
test_services.py 에 테스트케이스도 만듭니다.
class GetActivePostsTestCase(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username='test_user',
password='test_password',
)
self.board = Board.objects.create(
url='test_board',
name='test_board',
)
self.active_post = Post.objects.create(
title="Active Post",
board=self.board,
is_active=True,
author=self.user,
)
self.inactive_post = Post.objects.create(
title="Inactive Post",
board=self.board,
is_active=False,
author=self.user,
)
def test_get_active_posts(self):
# Given:
# When: Get active posts
active_posts = get_active_posts()
# Then: Active posts are returned
# And: Only active posts are returned
self.assertEqual(active_posts.count(), 1)
# And: Active post is returned
self.assertEqual(active_posts.first().id, self.active_post.id)
그래서 아래와 같이 View 가 바뀝니다.
def home(request):
post_qs = get_active_posts()
recent_post_qs = post_qs.order_by(
'-id'
)[:6]
liked_ordered_post_qs = post_qs.annotate(
reply_count=Count('replys', distinct=True) + Count('rereply', distinct=True),
like_count=Count('likes', distinct=True),
).order_by(
'-like_count',
'-reply_count',
'-id',
)[:6]
명칭도 qs 로 바꿨습니다.
나머지도 전부 services.py 에 Service Layer 로 함수를 만듭시다.
def home(request):
post_qs = get_active_posts()
recent_post_qs = post_qs.order_by(
'-id'
)[:6]
liked_ordered_post_qs = post_qs.annotate(
reply_count=Count('replys', distinct=True) + Count('rereply', distinct=True),
like_count=Count('likes', distinct=True),
).order_by(
'-like_count',
'-reply_count',
'-id',
)[:6]
context = {
'recent_post_set': recent_post_qs,
'liked_ordered_post_set': liked_ordered_post_qs,
'tag_set': get_tags(),
'announce_set': get_announces().order_by(
'-id'
)[:5],
'lesson': get_lessons().last(),
}
return render(request, 'board/home.html', context)
context라는 변수도 없애봅시다
def home(request):
post_qs = get_active_posts()
recent_post_qs = post_qs.order_by(
'-id'
)[:6]
liked_ordered_post_qs = post_qs.annotate(
reply_count=Count('replys', distinct=True) + Count('rereply', distinct=True),
like_count=Count('likes', distinct=True),
).order_by(
'-like_count',
'-reply_count',
'-id',
)[:6]
return render(
request,
'board/home.html',
{
'recent_post_set': recent_post_qs,
'liked_ordered_post_set': liked_ordered_post_qs,
'tag_set': get_tags(),
'announce_set': get_announces().order_by(
'-id'
)[:5],
'lesson': get_lessons().last(),
}
)
board/home.html 도 상수로 만듭시다.
이렇게 사용하면 같이 사용하는 게 있는 경우 수정하기 불편하기 때문입니다.
BOARD_HOME_PATH = 'board/home.html'
def home(request):
post_qs = get_active_posts()
recent_post_qs = post_qs.order_by(
'-id'
)[:6]
liked_ordered_post_qs = post_qs.annotate(
reply_count=Count('replys', distinct=True) + Count('rereply', distinct=True),
like_count=Count('likes', distinct=True),
).order_by(
'-like_count',
'-reply_count',
'-id',
)[:6]
return render(
request,
BOARD_HOME_PATH,
{
'recent_post_set': recent_post_qs,
'liked_ordered_post_set': liked_ordered_post_qs,
'tag_set': get_tags(),
'announce_set': get_announces().order_by(
'-id'
)[:5],
'lesson': get_lessons().last(),
}
)
이제 다른 주제로 넘어가보죠.
render 에 context 를 보면 qs 자체를 HTML에 보내버리면, View에서도 데이터를 관리해야 하고, HTML에 있는 template tag로도 데이터를 관리해야 합니다. 이렇게 되면 개발자가 직접 HTML까지 들어가서 데이터를 확인해야 하는 상황이 발생합니다.
예를 들면, 분명 저는 Tag queryset만 넘겼는데, Tag 옆에 데이터를 보면 숫자가 표시되는 것을 알 수 있습니다. 이게 뭘까요?
HTML 코드를 보면 아래처럼 이런 작업을 하고 있습니다.
<!-- Tags -->
<div class="py-2">
<h5>TAGS</h5>
<div class="d-flex flex-wrap">
{% for tags in tag_set %}
<a href="{% url 'board:board' '_'|add:tags.tag_name %}" class="badge bg-primary m-1 text-decoration-none text-white">{{tags.tag_name}} <span class="badge rounded-pill bg-light text-primary">{{tags.post_set.count}}</span></a>
{% endfor %}
</div>
</div>
지금 이렇게 보면 한번 for 문 처리할 때, N 회 count 쿼리가 일어납니다.
너무 비효율적이죠.
보기도 힘들도 비효율적이고...
이런 작업을 없애는 작업을 해봅시다.
일단 DTO 를 만듭니다. 예제는 이번에는 TagInfo 만 정리하겠습니다.
class TagInfo(BaseModel):
tag_name: str = Field(...)
post_count: int = Field(...)
class HomeResponse(BaseModel):
recent_post_set: List[dict]
liked_ordered_post_set: List[dict]
tag_infos: List[TagInfo] = Field(
default_factory=list,
description='태그 이름과 각 태그의 게시물 수 목록',
)
announce_set: List[dict]
lesson: List[dict]
service 함수 get_tags_active_post_count 를 만들고 Tag 를 주입합니다.
def get_tags_active_post_count(tag_ids: List[int]) -> Dict[int, int]:
if not tag_ids:
return {}
return dict(
Tag.objects.filter(
id__in=tag_ids,
).annotate(
post_count=Count('post', filter=Q(post__is_active=True)),
).values_list(
'id',
'post_count',
)
)
def home(request):
...
tags = get_tags()
post_count_by_tag_id = get_tags_active_post_count([tag.id for tag in tags])
return render(
request,
BOARD_HOME_PATH,
HomeResponse(
recent_post_set=recent_post_qs,
liked_ordered_post_set=liked_ordered_post_qs,
tag_infos=[
TagInfo(
tag_name=_tag.tag_name,
post_count=post_count_by_tag_id.get(_tag.id, 0),
) for _tag in tags
],
announce_set=get_announces().order_by(
'-id'
)[:5],
lesson=get_lessons().last(),
).model_dump_json(),
)
이렇게 하면 N번 호출했던 count() 를 1번만 호출해서 대응할 수 있습니다!
이렇게 수정했으니 html 코드도 수정해야합니다.
<!-- Tags -->
<div class="py-2">
<h5>TAGS</h5>
<div class="d-flex flex-wrap">
{% for tag_info in tag_infos %}
<a href="{% url 'board:board' '_'|add:tag_info.tag_name %}" class="badge bg-primary m-1 text-decoration-none text-white">{{tag_info.tag_name}} <span class="badge rounded-pill bg-light text-primary">{{tag_info.post_count}}</span></a>
{% endfor %}
</div>
</div>
위 처럼 모든 Query 를 DTO 로 대응하도록 만들겠습니다.
def home(request):
post_qs = get_active_posts()
recent_post_qs = post_qs.order_by(
'-id'
)[:6]
liked_ordered_post_qs = post_qs.annotate(
reply_count=Count('replys', distinct=True) + Count('rereply', distinct=True),
like_count=Count('likes', distinct=True),
).order_by(
'-like_count',
'-reply_count',
'-id',
)[:6]
tags = get_tags()
post_count_by_tag_id = get_tags_active_post_count([tag.id for tag in tags])
lesson = get_lessons().last()
return render(
request,
BOARD_HOME_PATH,
HomeResponse(
recent_posts=[
HomePost(
id=recent_post.id,
board_url=recent_post.board.url,
title=recent_post.title,
short_body=recent_post.short_body(),
image_url=recent_post.post_img.url if recent_post.post_img else static('logo.ico'),
created_at=recent_post.created_at.strftime('%Y-%m-%d'),
)
for recent_post in recent_post_qs
],
liked_ordered_posts=[
HomePost(
id=liked_ordered_post.id,
board_url=liked_ordered_post.board.url,
title=liked_ordered_post.title,
body=liked_ordered_post.body,
like_count=liked_ordered_post.like_count,
reply_count=liked_ordered_post.reply_count,
author_nickname=liked_ordered_post.author.nickname,
created_at=liked_ordered_post.created_at.strftime('%Y-%m-%d'),
)
for liked_ordered_post in liked_ordered_post_qs
],
tag_infos=[
TagInfo(
tag_name=_tag.tag_name,
post_count=post_count_by_tag_id.get(_tag.id, 0),
) for _tag in tags
],
announce_infos=[
AnnounceInfo(
title=announce.title,
body=announce.body,
created_at=announce.created_at.strftime('%Y-%m-%d'),
) for announce in get_announces().order_by('-id')[:5]
],
lesson=HomeLesson(
summary=lesson.summary,
body=lesson.body,
) if lesson else None,
).model_dump(),
)
이전과 비교하면 확실히 코드는 더 길어지긴 했습니다.
하지만 더 명확하게 무엇을 HTML 에 전달하고 쓰고 있는지 알 수 있습니다.
자 View 를 어느정도 DTO 와 함께 깨끗하게 만들었으니 이제 성능 개선을 해봅시다.
- 불필요하게 여러 번 호출되는 쿼리가 있다면 리팩토링합니다.
코드를 보면
recent_posts
HomeResponse(
recent_posts=[
HomePost(
id=recent_post.id,
board_url=recent_post.board.url, # <---- 여기 부분
title=recent_post.title,
short_body=recent_post.short_body(),
image_url=recent_post.post_img.url if recent_post.post_img else static('logo.ico'),
created_at=recent_post.created_at.strftime('%Y-%m-%d'),
)
for recent_post in recent_post_qs
],
liked_ordered_posts
liked_ordered_posts=[
HomePost(
id=liked_ordered_post.id,
board_url=liked_ordered_post.board.url, # <---- 여기 부분
title=liked_ordered_post.title,
body=liked_ordered_post.body,
like_count=liked_ordered_post.like_count,
reply_count=liked_ordered_post.reply_count,
author_nickname=liked_ordered_post.author.nickname, # <---- 여기 부분
created_at=liked_ordered_post.created_at.strftime('%Y-%m-%d'),
)
for liked_ordered_post in liked_ordered_post_qs
],
각각 board 그리고 author 를 통해서 데이터를 접근하고 있습니다.
이런 상황에서 Join 을 하지 않으면 N회 쿼리가 발생하여 성능상 이슈가 발생합니다.
이런 지표를 보기위해 django-debug-toolbar 패키지를 설치했습니다.
설치 방법
Installation — Django Debug Toolbar 4.3.0 documentation (django-debug-toolbar.readthedocs.io)
오는 것과 같이 중복 쿼리가 발생한다.
고쳐봅시다.
def home(request):
post_qs = get_active_posts().select_related(
'board',
'author',
)
...
간단합니다. 이렇게만 하면 됩니다.
중복 쿼리가 전부 없어졌습니다.
21개의 쿼리가 9개로 줄었으면 실행 속도도 18ms 에서 8ms 로 줄었습니다.
말하자면 50% 정도 쿼리 응답 속도 성능 개선을 한것입니다.
조금만 더 개선 해봅시다.
개선된 쿼리를 디테일하게 보면
최근 5개의 게시글을 가져오는 Query 인데 최신 글 데이터 중에 사용하는 정보는
id
board_url
title
short_body
image_url
created_at
위에 내용밖에 없는데 Django ORM은 모든 데이터를 다 가져옵니다.
비유하자면, 가방은 한정적인데 불필요한 물건을 더 가져가는 꼴입니다.
그렇다고 해서 그 정보를 또 사용하는가?
그것도 아닙니다.
이렇게 되면 메모리에 불필요한 데이터가 쌓여서 성능 이슈가 발생할 수 있습니다.
그럼 개선해봅시다.
def home(request):
recent_post_qs = get_active_posts().select_related(
'board',
).order_by(
'-id'
).only(
'id',
'board__url',
'title',
'body',
'post_img',
'created_at',
)[:6]
...
only 메서드를 이용해서 필요한 정보만 가져오도록 작업했습니다.
실행 속도도 이전 보다 조금 더 빨라졌습니다.
쿼리도 엄청 깔끔해졌습니다.
다른 쿼리도 개선하려고 했는데, annotate를 이용하면 only 메서드를 사용할 수 없습니다. 뿐만 아니라 지금은 괜찮겠지만, 만약 좋아요나 댓글이 많아진다면 annotate와 Count를 하는 행위는 진짜 쿼리 성능을 망치는 하나의 지름길입니다. 간단한 경우 사용하면 이슈는 없겠지만, 제 블로그는 개선을 위한 리팩토링을 하고 있으니 이 부분도 한번 해결해보려고 합니다.
이 내용은 다음 게시글에 추가로 정리해보겠습니다~!
완성 View
def home(request):
recent_post_qs = get_active_posts().select_related(
'board',
).order_by(
'-id'
).only(
'id',
'board__url',
'title',
'body',
'post_img',
'created_at',
)[:6]
liked_ordered_post_qs = get_active_posts().select_related(
'board',
'author',
).annotate(
reply_count=Count('replys', distinct=True) + Count('rereply', distinct=True),
like_count=Count('likes', distinct=True),
).order_by(
'-like_count',
'-reply_count',
'-id',
)[:6]
tags = get_tags()
post_count_by_tag_id = get_tags_active_post_count([tag.id for tag in tags])
lesson = get_lessons().last()
return render(
request,
BOARD_HOME_PATH,
HomeResponse(
recent_posts=[
HomePost(
id=recent_post.id,
board_url=recent_post.board.url,
title=recent_post.title,
short_body=recent_post.short_body(),
image_url=recent_post.post_img.url if recent_post.post_img else static('logo.ico'),
created_at=recent_post.created_at.strftime('%Y-%m-%d'),
)
for recent_post in recent_post_qs
],
liked_ordered_posts=[
HomePost(
id=liked_ordered_post.id,
board_url=liked_ordered_post.board.url,
title=liked_ordered_post.title,
body=liked_ordered_post.body,
like_count=liked_ordered_post.like_count,
reply_count=liked_ordered_post.reply_count,
author_nickname=liked_ordered_post.author.nickname,
created_at=liked_ordered_post.created_at.strftime('%Y-%m-%d'),
)
for liked_ordered_post in liked_ordered_post_qs
],
tag_infos=[
TagInfo(
tag_name=_tag.tag_name,
post_count=post_count_by_tag_id.get(_tag.id, 0),
) for _tag in tags
],
announce_infos=[
AnnounceInfo(
title=announce.title,
body=announce.body,
created_at=announce.created_at.strftime('%Y-%m-%d'),
) for announce in get_announces().order_by('-id')[:5]
],
lesson=HomeLesson(
summary=lesson.summary,
body=lesson.body,
) if lesson else None,
).model_dump(),
)
완성 HTML
{% extends 'base.html' %}
{% load static %}
{% block style %}
<style>
.card {
height: 100%;
}
.card_img{
background-repeat:no-repeat;
background-position-y:center;
background-size:contain;
}
.truncate-3 {
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.truncate-3-with-out-overflow {
font-size: 15px;
white-space: break-spaces;
}
.truncate-5 {
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
}
</style>
{% endblock %}
{% block title %}메인페이지{% endblock %}
{% block og_title %}메인페이지{% endblock %}
{% block description %}천방지축 Beany의 잡다한 IT 자료를 올리는 블로그{% endblock %}
{% block current_url %}https://cwbeany.com{{ request.get_full_path }}{% endblock %}
{% block img %}https://cwbeany.com/static/logo.ico{% endblock %}
{% block script %}
<script>
$(document).ready(function(){
$('#lesson_detail').on('click', function() {
if ($(this).parent().parent().find('p').hasClass('truncate-3')) {
$(this).parent().parent().find('p').removeClass('truncate-3');
$(this).parent().parent().find('p').addClass('truncate-3-with-out-overflow');
$(this).text('접기');
} else {
$(this).parent().parent().find('p').removeClass('truncate-3-with-out-overflow');
$(this).parent().parent().find('p').addClass('truncate-3');
$(this).text('자세히보기');
}
});
});
</script>
{% endblock %}
{% block lesson %}
{% if lesson %}
<h5 class="rounded-top border border-dark bg-light p-2">오늘의 교훈</h5>
<div class="col-12">
<div class="card">
<div class="row g-0">
<div class="col-12">
<div class="card-body">
<h5 class="card-title">{{ lesson.summary }}</h5>
<p class="card-text truncate-3">{{ lesson.body }}</p>
<div class="card-text text-center">
<span id="lesson_detail" style="cursor: pointer;">자세히보기</span>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block top %}
<!-- 최근 포스트 -->
<h5 class="rounded-top border border-dark bg-light p-2">최근 포스트</h5>
<div class="row g-2">
{% for post in recent_posts %}
<div class="col-12 col-sm-12 col-md-6 col-lg-6 col-xl-6">
<a class="text-decoration-none text-dark" href="{% url 'board:post' post.board_url post.id %}">
<div class="card">
<div class="row g-0">
<div class="col-lg-8">
<div class="card-body">
<h5 class="card-title text-truncate">{{post.title}}</h5>
<p class="card-text truncate-3">{{post.short_body|striptags}}</p>
<p class="card-text"><small class="text-muted"><i class="bi bi-calendar-week"></i> {{post.created_at}}</small></p>
</div>
</div>
<div class="col-lg-4 card_img d-none d-lg-block rounded" style="background-image:url({{post.image_url}});"></div>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
{% endblock %}
{% block middle_left %}
<!-- 좋아요 순 포스트 -->
<h5 class="rounded-top border border-dark bg-light p-2">좋아요 많은 게시글</h5>
{% for post in liked_ordered_posts %}
<a class="text-decoration-none text-dark" href="{% url 'board:post' post.board_url post.id %}">
<div class="p-3 mb-3 border rounded">
<h5>{{post.title}}</h5>
<hr style="margin:5px;">
<div class="text-break truncate-5 mb-2 overflow-hidden">
{{post.body|safe}}
</div>
<div class="text-secondary mb-2">
<i class="bi bi-person-circle"></i> {{post.author_nickname}} <i class="bi bi-calendar-week"></i> {{post.created_at}}
</div>
<div>
<i class="bi bi-heart text-danger"></i></i> {{post.like_count}} <i class="bi bi-chat-right-text"></i> {{post.reply_count}}
</div>
</div>
</a>
{% endfor %}
{% endblock %}
{% block middle_right %}
<!-- 검색 하기 -->
<form class="d-flex mb-3" style="justify-content: flex-end;" method="get" action="{% url 'board:board' 'search' %}">
<input class="me-2" type="text" value="{{ request.GET.search }}" name="search" placeholder="검색어를 입력하세요." />
<button class="btn btn-sm btn-primary" type="submit">검색</button>
</form>
<!-- 소개 -->
<div class="p-4 bg-light rounded my-2">
<h5>About Beany's Blog</h5>
<div>Beany의 개인 블로그 입니다~! 개인 일상/프로젝트/자료 등 잡다한 내용이 담길 블로그 입니다! 잘 부탁드립니다~!</div>
</div>
<!-- Announce -->
<div>
{% for announce_info in announce_infos %}
<div class="toast shadow-sm show mb-2" style="width: 100%;" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header" style="justify-content: space-between;">
<div class="text-truncate">
<strong class="me-auto fs-xs"><i class="bi bi-exclamation-diamond-fill text-warning"></i> {{announce_info.title}}</strong>
</div>
<small style="flex-shrink: 0;" class="text-muted">{{announce_info.created_at|date:'Y-m-d'}}</small>
</div>
<div class="toast-body fs-xs">
{{announce_info.body}}
</div>
</div>
{% endfor %}
</div>
<!-- Tags -->
<div class="py-2">
<h5>TAGS</h5>
<div class="d-flex flex-wrap">
{% for tag_info in tag_infos %}
<a href="{% url 'board:board' '_'|add:tag_info.tag_name %}" class="badge bg-primary m-1 text-decoration-none text-white">{{tag_info.tag_name}} <span class="badge rounded-pill bg-light text-primary">{{tag_info.post_count}}</span></a>
{% endfor %}
</div>
</div>
{% endblock %}