ks.dgoon.lee log

dboard - 댓글 보여주기 최적화 + 댓글 페이징 전처리


2024/01/17 12:46:10 #django #dev #comment #bulletin board #database #dboard #query optimization

요 블로그에서 대강 만들어뒀던 댓글 코드를 dboard 에도 그대로 가져가서 붙여둔 상태였다. silk 로 쿼리 얼마나 많이 하나 보면서 유독 post 화면이 쿼리가 많았던 이유가 바로 댓글 때문이었다.

댓글-대댓글-대대댓글-대대대댓글 이렇게 depth 를 가질 수 있고, 설정으로 최대 깊이를 두긴 했지만 이론상 깊이 제한이 없다. 고개를 왼쪽으로 까딱 뉘여서 보면 학교에서 배우던 tree 구조라는 것을 알 수 있다. 그래서 아주 나이브하게 DFS 트리 순회를 하면서 댓글을 보여주도록 했는데, 그래서 리스트를 쭉 이터레이션하는게 아니라서 쿼리가 댓글 개수에 비례해서 늘어나는 것이다. 댓글 개수를 N 이라고 하면 O(N) 정도인 것 같다. 자세히 생각하진 않았지만 대충 그런 느낌...

https://www.sqlteam.com/articles/sql-for-threaded-discussion-forums

요걸 참고해서(이건 django-comments-xtd 소스코드 뒤지다가 걔들이 주석으로 붙여둔 링크 발견한 것) sort_order 라는걸 넣어 보았다. 여기의 sort_order 란 한 스레드 안에서 댓글이 보여지는 순서를 미리 계산해둔 것이다. precomputed field 라고 보면 된다. 한 스레드를 여러가지로 정의할 수 있는데, 보통은 포스팅을 rootnode 로 하는 스레드를 만드는 구조를 잡는게 자연스러울 것 같다... 지만 나는 한 글에 달린 1차 댓글들이 rootnode 가 되도록 했다. 이건 뭐 필요에 따라서 적당히 하면 된다. 최신댓글을 위에 보여줄거냐 밑에 보여줄거냐 등등은 sort_order 를 계산에 내 의도에 맞게 반영하면 된다.

views.py 에서는 comment.parent 세팅 이외의 다른 조작은 하지 않고, 나머지 thread, timestamps, depth_level, sort_order 등은 모두 signals.py 에서 계산하도록 해 두었다. 그리하여, @receiver(pre_save, sender=Comment) 시그널 받아 처리하는 함수(댓글 객체가 저장되기 전에 호출되는 hook) 아래에 아래 코드를 추가.

# sort_order를 계산해보자.
if instance.parent is None:
instance.sort_order = 0
else:
queryset = Comment.objects.filter(thread=instance.thread,
post_level__lte=instance.parent.post_level,
sort_order__gt=instance.parent.sort_order)
if queryset.exists():
min_sort_order = queryset.aggregate(Min('sort_order'))['sort_order__min']
print(f'min_sort_order: {min_sort_order}')
Comment.objects.filter(thread=instance.thread,
sort_order__gte=min_sort_order).update(sort_order=F('sort_order') + 1)
instance.sort_order = min_sort_order
else:
max_sort_order = Comment.objects.filter(thread=instance.thread) \
.aggregate(Max('sort_order'))['sort_order__max'] or 0
print(f'max_sort_order: {max_sort_order}')
instance.sort_order = max_sort_order + 1

역시 인덱스는 0부터니까 sort_order 도 0부터 시작하도록 했다. 아쉬운 것은, 위 코드를 만들고 나서 기존 commentsort_order 를 올바르게 넣는 마이그레이션을 하는 것은 너무나도 귀찮다는 것이었다. 이게 댓글이 하나씩 달릴 때마다 같은 thread에 있는 다른 댓글들을 업데이트하는것이라 ... 하자면 못할것도 없지만, dboard 는 아직 공개된것도 아니고 프로덕션에서 쓰는것도 아니니까 그냥 처음부터 있었다 치고 기존 테스트 데이터를(어차피 populate 커맨드로 넣은 더미데이터만 가득이니...) 버리고 갈아엎기로 했다. 프로덕션 서비스에서 이런 짓을 하면 진짜 토나올듯.
트리 순회하면서 댓글 출력하던 - 그래서 좀 복잡해서 따로 fragment 로 빼놓았었다 - 부분 아래에 그냥 order_by 로 정렬한 다음 쭉 출력해주는 걸 추가했다. 기존 댓글 출력과 sort_order 기반으로 보여주는걸 같이 보면서 여러 방식으로 댓글을 달아보며 기존과 완전히 동일하게 보이는 것 확인 완료. 일단 로직은 맞다 로직은... 아직 django 만져본지 한달 막 넘었을 뿐이라 아직 확인을 더 해봐야 하는게 있는데 그건 따로 뒤져봐야겠다.

signals.py 에서 시그널 받아 처리하는 부분이 atomicity 보장이 되는가? 한 글에 댓글을 여러 사람이 동시에 달고 있다면 안 꼬이는가? -> 나중에 확인

갈아엎고 나서 silk 에서 SQL 을 확인해본 바, 여전히 쿼리수가 좀 더 줄었으면 좋긴 하겠지만 O(1) 으로 바뀌긴 했다. 포스팅 화면을 띄울때 기존에는 댓글 개수에 따라서 30개에서 백개 이상 쿼리를 할 때도 있었는데, 16개로 고정되었다. 글 내용 밑에 다시 글 목록을 보여주는데 목록에서 쿼리 8개를 쓰니, 글 내용+댓글목록 보여주는데 8개의 쿼리를 하는 것이다. 귤 몇개 더 먹으면서 더 최적화할 수도 있겠지만 일단 이렇게 해두고 넘어간다. 쿼리 개수는 댓글 개수 N 대비 O(1) 이지만, 쿼리 자체의 수행시간은 DB 내부에서 좀 더 걸리긴 할거다. 그래도 뭐 훨씬 나아졌다. 댓글이 많은 경우 빨라진 것과 더불어 ... 이제 댓글 목록에 페이징을 할 수 있게 되었다. 휴, 힘들었다 힘들었어.

덧: sort_order 를 계산하면서 read 는 빨라졌지만, 댓글을 달 때 write 는 더 무거워졌다. 사용 패턴에 맞춰서 trade-off 를 하는 부분이다. 기존에는 DB에 엔트리 하나 넣는 걸로 끝이었지만, sort_order 업데이트를 할 때에는 최악의 경우 같은 thread 에 있는 다른 모든 댓글의 sort_order + 1 을 해야 한다. 그래서 나는 Postrootnode 로 세우지 않고, 그 안에 달린 1차 댓글들 각각 rootnode 로 세웠다. 다만, 이것도 trade-off 가 있는데 그렇게 함으로써 글 하나에 있는 댓글들을 다 가져와 정렬할때 sort_order 와 함께 다른필드를 묶어서 Coalesce 를 써야 했다. 엔지니어링 세상은 trade-off 로 가득하다. 

댓글 0개