[Django] (점프투장고) 페이징 반영된 앵커 완성하기

azzurri21·2022년 1월 23일
0

Django

목록 보기
6/7

점프투장고 3-16 파이보 추가 기능답변 페이징과 정렬 기능을 완성하고 발생한 앵커 관련 문제를 해결하는 과정이다.

[완성 소스] : https://github.com/jseop-lim/pybo/tree/29cd9f0848b03a3d03aef4bb5b1ebe6271b8a98e


문제 상황

점프투장고 3-13 앵커에서는 답변등록, 답변수정, 댓글등록, 댓글수정 시 앵커 태그를 이용하여 원하는 위치로 이동할 수 있도록 구현했다.

원래 해당 뷰들은 DB 접근 이후에 "질문 상세" URL을 호출하여 사용자가 등록 혹은 수정한 답변과 댓글에 해당하는 질문으로 이동한다. 이때 예를 들어 "질문 상세" 템플릿에 <a id="answer_{{ answer.id }}"></a>와 같은 앵커 태그를 포함시키고, 답변 등록 혹은 수정 후에 호출하는 "질문 상세" URL 뒤에 문자열 f'#answer_{answer.id}'을 추가하여, 해당 페이지에서 지정한 앵커로 스크롤을 이동시킨 것이다. 즉, 답변 등록 혹은 수정 이후에 리다이렉트 되는 URL은 (질문 상세 url)/#answer_11같은 형식이 된다.

이후 답변 페이징과 정렬 기능을 완성하고 나니 문제가 발생했다. 등록, 수정한 답변과 댓글에 해당하는 앵커를 현재 URL의 HTML에서 찾을 수 없게 된 것이다. 또한 기존의 답변 정렬기준이 무조건 추천순으로 초기화되는 버그가 생겼다.

"질문 상세" 뷰는 답변 페이지(page)와 정렬기준(so)을 GET 방식으로 전달받는다. 그리하여 이 정보들이 URL에 ?page=4&so=recommend처럼 추가되어 나타난다. 한편, 기존 질문 상세 URL에는 이러한 query string이 포함되지 않으므로 기본 설정된 '추천순 1페이지'로 이동하게 된다. 따라서 답변등록, 답변수정, 댓글등록, 댓글수정 시에는 반드시 추천순 1페이지로 리다이렉트 되고, 그 목록에 포함되는 답변과 댓글의 앵커만 HTML 상에 존재한다. 예를 들어 2페이지 이후의 답변/댓글을 수정한다면 의도와 다른 위치로 스크롤이 이동할 것이고, 최신순 정렬 상태에서 수정한다면 추천순으로 초기화된다.

해결 과정

질문상세 템플릿에서 답변등록, 답변수정, 댓글등록, 댓글수정 뷰에 페이지(page)와 정렬기준(so)를 전달해주고, 뷰에서는 리다이렉트하는 URL에 page와 so를 전달해주면 간단히 끝날거라 예상했으나... 생각보다 복잡한 문제였다.

답변 페이지(page) 계산

우선 사용자는 현재 질문 상세 화면에 표시된 답변만 수정가능하다. 또한 그러한 답변에만 댓글을 추가하거나 수정할 수 있다.

즉, 답변수정, 댓글등록/수정 시에는 이전 URL의 page와 so를 이후에도 그대로 쓰면 된다.

문제는 답변추가이다. 정렬기준(so)는 유지하더라도, 새로 추가된 답변의 페이지는 사용자가 '답변추가' 버튼을 누르기 전 페이지와 다르기 때문이다. 참고로 (추천순 정렬 시) 답변 추천 경우에도 페이지가 달라진다. 이때는 답변과 연결된 질문 인스턴스의 answer_set에서 해당 답변이 주어진 정렬기준에 대해 몇 번째 순서인지 직접 계산해야 한다. 이러한 기능을 Answer 모델의 메서드로 구현했다.

[mysite\pybo\models.py]

class Answer(models.Model):
    (... 생략 ...)
	
    @staticmethod
    def order_by_so(answer_list, so):
        # 정렬
        if so == 'recommend':
            # todo num_voter 필드 추가
            answer_list = answer_list.annotate(num_voter=Count('voter')).order_by('-num_voter', '-create_date')
        else:  # so == 'recent':
            answer_list = answer_list.order_by('-create_date')

        return answer_list
    
    def get_page(self, so='recommend'):
        # todo MySQL 연동 후에 raw SQL로 대체
        # https://stackoverflow.com/questions/1042596/get-the-index-of-an-element-in-a-queryset
        answer_list = Answer.order_by_so(self.question.answer_set.all(), so)

        index = 0
        for _answer in answer_list:
            index += 1
            if self == _answer:
                break

        return (index - 1)//5 + 1

현재는 반복문으로 구했지만, DBMS를 연동한 뒤에는 행번호(ROW_NUMBER)를 이용하여 성능을 향상시킬 것이다. 주석 링크에 관련 코드가 있다.

order_by_so()는 입력받은 Queryset에 정렬기준(so)에 따라 다른 쿼리를 적용하여 반환하는 함수이다. 같은 조건문이 여러 군데 중복으로 작성되는 것을 피하고자 Answer 클래스 내에 정의했다. 다만, Answer 모델은 기획자가 정한 정렬기준에 독립적이어야 하므로 정적(static) 메서드로 지정했다.

답변 목록의 기본 정렬기준은 추천순(recommend)이다. get_page 함수의 매개변수 디폴트 값에서 확인가능하다.

페이지와 정렬기준 전달

이제 정말 (1) 뷰에서 리다이렉트하는 URL(2) 템플릿에서 답변과 댓글 생성/수정으로 연결되는 링크만 일일이 수정하면 된다.

다만 이는 너무 비효율적인 작업이어서 다른 방법을 모색해보았다.

[실패] get_absolute_url() 메서드

모델 클래스에 get_absolute_url() 메서드를 구현하여 뷰에서 사용하는 redirect([이동할 URL]) 함수에 간단히 모델 인스턴스를 전달하여 복잡한 URL 문자열을 대체할 수 있다. 그리하여 아래와 같이 작성했다.

[mysite\pybo\models.py]

(... 생략 ...)

class Answer(models.Model):
    (... 생략 ...)

    def get_absolute_url(self):
        return reverse('pybo:detail', args=[self.question.id]) + f'?page={self.get_page()}#answer_{self.id}'


class Comment(models.Model):
	(... 생략 ...)
    
    def get_absolute_url(self):
        if self.question:
            return reverse('pybo:detail', args=[self.question.id]) + '#comment_question_start'
        else:  # if self.answer:
            return reverse('pybo:detail', args=[self.answer.question.id]) + \
                   f'?page={self.answer.get_page()}#comment_{self.id}'

뷰에서 so를 전달받지 못하면 자동으로 recommend(추천순)을 할당하므로 일부러 so는 url query로 전달하지 않았다.

상기했듯, 이러한 방식의 구현에는 커다란 문제가 있다. 바로 정렬기준이 기본 설정인 추천순으로 고정된다는 것이다. 만약 사용자가 최신순으로 답변을 보다가 댓글을 달았는데 정렬 기준이 제멋대로 추천순으로 바뀐다면 불편함을 겪을 것이다.

또 다른 문제가 있다. 사실 답변수정/댓글생성/댓글수정 시에는 페이지가 변하지 않는다. 그러므로 사용자가 이전에 보던 페이지와 정렬기준을 수정 없이 그대로 반환해도 충분하다. 그때도 get_absolute_url()을 호출하여 get_page() 메서드 내의 반복문을 실행하는 것은 성능 저하를 가져온다.

get_absolute_url에 url query를 전달하는 방법을 찾지 못해서 결국 도입에 실패했다. 하지만 이후 프로필 기능 추가에서 사용자 작성 답변/댓글 목록의 요소를 클릭하면 해당 답변/댓글로 이동할 때, 이 함수가 사용되므로 지우지 않았다. (그 경우에는 사용자가 기존에 설정해놓은 정렬기준이 없으므로 기본 정렬기준을 사용한다.)

질문 상세 Template 폼과 jQuery 이용

답변 및 댓글 모델 접근과 관련된 링크는 "질문 상세" 템플릿에서 <a>태그의 href 속성에 할당되어 있다. 예를 들어 답변 수정 링크의 태그는 아래와 같다.

<a href="{% url 'pybo:answer_modify' answer.id %}" (... 생략 ...)>수정</a>

여기서 href="{% url 'pybo:answer_modify' answer.id %}?page={{ page }}&so={{ so }}"처럼 답변 수정 URL 뒤에 query string을 추가하면 된다. 문제는 답변수정, 댓글추가, 댓글수정 세 부분 모두 수정해야 한다는 것이다. 그래서 다른 방법을 모색했다.

[mysite\templates\pybo\question_detail.html]

(... 생략 ...)
<!-- 추가함 -->
<a href="#" data-uri="{% url 'pybo:answer_modify' answer.id %}" class="next btn btn-sm btn-outline-secondary">수정</a>

<a href="#" class="delete btn btn-sm btn-outline-secondary" data-uri="{% url 'pybo:answer_delete' answer.id %}">삭제</a>

<!-- 추가함 -->
<a href="#" data-uri="{% url 'pybo:comment_create_answer' answer.id %}" class="small next"><small>댓글 추가 ..</small></a>

(... 생략 ...)
<form id="searchForm" method="get" action="{{ question.get_absolute_url }}#answer_start">
    <input type="hidden" id="page" name="page" value="{{ page }}">
    <input type="hidden" id="so" name="so" value="{{ so }}">
</form>
{% endblock %}

{% block script %}
<script type='text/javascript'>
    $(document).ready(function(){
        $(".delete").on('click', function() {
            if(confirm("정말 삭제하시겠습니까?")) {
                location.href = $(this).data('uri');
            }
        });
        $(".recommend").on('click', function() {
            if(confirm("정말 추천하시겠습니까?")) {
                location.href = $(this).data('uri');
            }
        });

        $(".page-link").on('click', function() {
            $("#page").val($(this).data("page"));
            $("#searchForm").submit();
        });
        $(".so").on('change', function() {
            $("#so").val($(this).val());
            $("#page").val(1);  // 새로운 기준으로 정렬할 경우 1페이지부터 조회한다.
            $("#searchForm").submit();
        });
        
        // 추가함 - 자바스크립트로 searchForm의 action 속성을 data('uri')로 변경
        $(".next").on('click', function() {  // query string을 다음 url에 포함시킨다.
            $("#searchForm").attr('action', $(this).data('uri'))
            $("#searchForm").submit();
        });
    });
</script>
{% endblock %}

searchForm은 답변 정렬기준이나 페이지 변경 시에 javascript 코드에 의해 submit된다. 폼 내부에서는 현재 페이지와 정렬기준을 입력받는다. 이러한 방식을 답변수정, 댓글추가/변경 시에도 이용하고자 한다. 다만, 폼의 action 속성이 사용자의 동작에 따라 달려져야 한다. 이는 원래 삭제 기능에 구현되어 있었다. 질문이나 답변 삭제 버튼을 클릭하면 변수 uri에 이동할 URL을 저장해놓고, 알림창에서 '확인'을 누르면 저장했던 링크로 이동하는 방식이다.

링크 클릭 시 작동 과정은 아래와 같다. (이전에 우선 답변수정, 댓글추가, 댓글변경 링크에 각각 next class를 추가했다.)

  1. 해당 클래스의 링크가 눌리면 변수 uri에 각 동작에 대한 URL이 저장된다.
  2. javascript 코드에서는 변수 uri의 값을 searchForm의 action 속성에 할당하여 이동할 경로를 변경하고, page와 so 변수를 GET 방식으로 submit 한다.

javascript와 jQuery에 대해서는 전혀 모르는 상태이므로 stackflow 답변을 다수 참고했다.

한편, POST 방식으로 요청되는 답변 생성 링크는 아래와 같이 page와 so 변수를 직접 url 뒤에 추가 했다.

<form action="{% url 'pybo:answer_create' question.id %}?so={{ so }}" method="post" class="my-3">

View 함수 반환값 수정

[mysite\pybo\views\answer_views.py]

from urllib.parse import urlparse, parse_qs
(... 생략 ...)


@login_required(login_url='common:login')
def answer_create(request, question_id):
    """
    pybo 답변등록
    """
    question = get_object_or_404(Question, pk=question_id)
    if request.method == "POST":
        form = AnswerForm(request.POST)

        if form.is_valid():
            answer = form.save(commit=False)
            answer.author = request.user  # author 속성에 로그인 계정 저장
            answer.create_date = timezone.now()
            answer.question = question
            answer.save()
			# 추가부분
            path = request.get_full_path()
            query_dict = parse_qs(urlparse(path).query)
            so = query_dict['so'][0]
            page = answer.get_page(so)
            return redirect(resolve_url(question)+f'?page={page}&so={so}#answer_{answer.id}')
    else:
        form = AnswerForm()
    context = {'question': question, 'form': form}
    return render(request, 'pybo/question_detail.html', context)


@login_required(login_url='common:login')
def answer_modify(request, answer_id):
    """
    pybo 답변수정
    """
    answer = get_object_or_404(Answer, pk=answer_id)
    if request.user != answer.author:
        messages.error(request, '수정 권한이 없습니다.')
        return redirect(answer.question)

    if request.method == "POST":
        form = AnswerForm(request.POST, instance=answer)
        if form.is_valid():
            answer = form.save(commit=False)
            answer.modify_date = timezone.now()
            answer.save()
            # 추가부분
            path = request.get_full_path()
            return redirect(resolve_url(answer.question)+f'?{urlparse(path).query}#answer_{answer.id}')
    else:
        form = AnswerForm(instance=answer)
    context = {'answer': answer, 'form': form}
    return render(request, 'pybo/answer_form.html', context)

(... 생략 ...)

질문 상세 화면에서 버튼을 누르면 GET 요청이 이루어지고, 빈 폼이 나타난다. 사용자가 폼의 내용을 채우고 완료 버튼을 누르면 POST 요청이 이루어진다. POST 요청 시 request.GET을 이용해 page와 so 값을 받을 수 없으므로, URL을 직접 파싱하여 값을 변수에 저장한다.

  • 답변 생성의 경우, so는 URL에서 바로 가져오고 page는 계산하므로 query string에서 so를 추출하는 과정이 추가되었다.

  • 답변 수정 및 댓글의 경우, so와 page 모두 이전 페이지의 url과 같으므로 query string 전체(urlparse(path).query)를 그대로 다음 url에 전달한다.

URL 가공 함수들

  • urlparse(): 문자열 형태 URL을 입력받아 6개 원소의 namedtuple을 반환한다. 각 원소는 문자열이며, 위 코드에서는 query 문자열만 따로 추출했다.
  • parse_qs(): 문자열 형태 URL query를 입력받아 딕셔너리로 반환. 딕셔너리의 value는 리스트 형태이다.

이외에 Django resolve() 함수나 QueryDict 클래스를 이용할 수도 있다.

배운 점

  • Django 모델 클래스의 get_absolute_url() 메서드
  • jQuery로 HTML Form의 속성 변경하기
  • URL 가공하기 (POST 요청 시 URL Query 이용 방법)
profile
파이썬 백엔드 개발자

0개의 댓글