본문 바로가기
Research/Django

[Django] 점프 투 장고 튜토리얼 - 3-15. 검색과 정렬

by RIEM 2021. 11. 26.

개요

이 문서는 점프 투 장고 사이트의 장고 튜토리얼 학습 내용을 정리한 내용입니다.

레퍼런스

점프 투 장고 https://wikidocs.net/72242

 

3-15. 검색과 정렬

검색기능과 정렬기능을 추가해보자.

 

검색

파이보에 질문과 답변 데이터가 쌓여갈 것이기 때문에 검색 기능은 필수다. 검색 대상은 제목, 질문 내용, 질문/답변 작성자 정도로 하면 되겠다. 예를 들어 ‘파이썬’키워드로 검색을 할 경우 ‘파이썬’문자열이 제목, 내용, 질문/답변 작성자에 포함하는지 조회하고 그 결과를 화면에 표시하면 되겠다.

 

이를 위해 질문목록 화면을 아래와 같이 수정해야 한다. 

 

아직 수정은 하지말고 내용만 확인해보자.

from django.db.models import Q

kw = request.GET.get('kw', '')  # 검색어

if kw:
    question_list = question_list.filter(
        Q(subject__icontains=kw) |  # 제목검색
        Q(content__icontains=kw) |  # 내용검색
        Q(author__username__icontains=kw) |  # 질문 글쓴이검색
        Q(answer__author__username__icontains=kw)  # 답글 글쓴이검색
    ).distinct()

 

코드위 Q함수는 OR 조건으로 데이터 조회를 가능하는 함수다. 제목, 내용 그리고 글쓴이를 OR 조건으로 검색하기 위함이다. 

Filter 함수 뒤의 district는 조회 결과 중복있을 경우 중복 제거하여 리턴하는 함수다. 하나의 질문에 여러 개의 답변이 있을 수 잇는데, 여러 개의 답변 중 답변자가 같을 경우 동일한 질문이 중복으로 조회된다. 이러한 중복 결과값을 제거해야한다.

 

GET

POST가 아닌 GET방식을 사용하는 이유에 대해 알아보자.

 

위 코드에서 kw는 화면으로부터 전달받은 검색어다. Kw는 keywords의 관례적인 약자다. 

 

kw = request.GET.get('kw', '')  # 검색어

Kw 값은 POST가 아닌 GET 방식으로 읽는다. 이는 GET 방식으로 전달되어야 아래와 같이 index 함수에서 읽을 수 있기 때문이다.

http://localhost:8000/?kw=파이썬&page=1

 

Kw값은 POST로 전달하든지 GET으로 전달하든지 둘 중 하나를 선택해야 한다. 저자는 POST방식으로 kw 전달을 권유하지 않는데, POST방식으로 전달할 경우 page 파라미터 또한 POST 방식으로 전달해야 하기 때문이라 한다. 

GET이 아닌 POST 방식으로 검색과 페이징 처리 시 웹 브라우저에서 ‘새로고침’이나 ‘뒤로가기’를 눌렀을 때 ‘만료된 페이지입니다’라는 오류가 자주 뜬다. POST 방식은 동일한 POST 요청이 발생했을 시 중복 요청을 방지하기 위해 이러한 오류를 발생시킨다. 이러한 이유로 여러 파라미터를 조합하여 게시물 목록 조회 시 GET 방식을 추천한다.

 

검색창

Kw와 page 동시에 GET방식으로 호출하기 위해 먼저 Question_list.html 템플릿에 검색어 입력할 수 있는 텍스트 창을 추가해주자.

> ../templates/pybo/question_list.html

 
{% extends 'pybo/base.html' %}
{% load pybo_filter %}
{% block content %}
<div class="container my-3">
    <div class="row justify-content-end my-3">
        <div class="col-4 input-group">
            <input type="text" class="form-control kw" value="{{ kw|default_if_none:'' }}">
            <div class="input-group-append">
                <button class="btn btn-outline-secondary" type="button" id="btn_search">찾기</button>
            </div>
        </div>
    </div>
    <table class="table">
    (... 생략 ...)

 

<table> 태그 상단에 검색창을 생성했다. 자바 스크립트에서 해당 텍스트창에 입력된 값을 로드하기 위해 텍스트창 class엣 kw값를 추가해주었다.

class="form-control kw"

 

검색 폼

Page와 kw 동시에 GET으로 요청할 수 있는 searchForm을 아래와 같이 추가해주자.

>../mysite/templates/pybo/question_list.html

 
(... 생략 ...)
    <!-- 페이징처리 끝 -->
    <a href="{% url 'pybo:question_create' %}" class="btn btn-primary">질문 등록하기</a>
</div>
<form id="searchForm" method="get" action="{% url 'index' %}">
    <input type="hidden" id="kw" name="kw" value="{{ kw|default_if_none:'' }}">
    <input type="hidden" id="page" name="page" value="{{ page }}">
</form>
{% endblock %}

 

GET 방식으로 요청하기 위해 method 속성에 ‘get’을 대입했다. Kw와 page는 이전에 요청했던 값을 기억해야하기 때문에 value에 값을 대입한다. 이전에 요청했던 kw, page값은 index 함수로부터 전달받은 값이다. Action 속성은 ‘폼이 전송되는 URL’이므로 질문 목록 URL인 {% url ‘index’ %}를 지정했다. Index 함수에서 kw, page 값을 전달하는 부분은 조금후에 진행한다.

 

페이징

기존 페이징 처리부분도 ‘?page=1’과 같이 직접 파라미터 코딩하는 방식에서 값을 읽고 폼에 설정할 수 있는 방식으로 수정해주자.

> ../mysite/templates/pybo/question_list.html

(... 생략 ...)

<!-- 페이징처리 시작 -->

<ul class="pagination justify-content-center">

    <!-- 이전페이지 -->

    {% if question_list.has_previous %}

    <li class="page-item">

        <a class="page-link" data-page="{{ question_list.previous_page_number }}" href="#">이전</a>

    </li>

    {% else %}

    <li class="page-item disabled">

        <a class="page-link" tabindex="-1" aria-disabled="true" href="#">이전</a>

    </li>

    {% endif %}

    <!-- 페이지리스트 -->

    {% for page_number in question_list.paginator.page_range %}

    {% if question_list.number|add:-5 <= page_number <= question_list.number|add:5 %}

        {% if page_number == question_list.number %}

        <li class="page-item active" aria-current="page">

            <a class="page-link" data-page="{{ page_number }}" href="#">{{ page_number }}</a>

        </li>

        {% else %}

        <li class="page-item">

            <a class="page-link" data-page="{{ page_number }}" href="#">{{ page_number }}</a>

        </li>

        {% endif %}

    {% endif %}

    {% endfor %}

    <!-- 다음페이지 -->

    {% if question_list.has_next %}

    <li class="page-item">

        <a class="page-link" data-page="{{ question_list.next_page_number }}" href="#">다음</a>

    </li>

    {% else %}

    <li class="page-item disabled">

        <a class="page-link" tabindex="-1" aria-disabled="true" href="#">다음</a>

    </li>

    {% endif %}

</ul>

<!-- 페이징처리 끝 -->

(... 생략 ...)



페이지들의 링크를 href 속성에 입력하지 않고 data-page속성으로 값을 읽을 수 있도록 변경했다.

 

기존안

<a class="page-link" href="?page={{ question_list.previous_page_number }}">이전</a>

 

수정안

<a class="page-link" data-page="{{ question_list.previous_page_number }}" href="#">이전</a>



검색 스크립트

Page, kw 파라미터 동시 요청할 수 있는 자바스크립트를 추가해주자.

> ../mysite/templates/pybo/question_list.html

 
(... 생략 ...)
{% endblock %}
{% block script %}
<script type='text/javascript'>
$(document).ready(function(){
    $(".page-link").on('click', function() {
        $("#page").val($(this).data("page"));
        $("#searchForm").submit();
    });

    $("#btn_search").on('click', function() {
        $("#kw").val($(".kw").val());
        $("#page").val(1);  // 검색버튼을 클릭할 경우 1페이지부터 조회한다.
        $("#searchForm").submit();
    });
});
</script>
{% endblock %}

 

위 추가한 자바스크립트 코드를 깊게 보자.

다음과 같이 class 속성으로 ‘page-link’라는 값을 가지고 있는 링크를 클릭한다고 생각해보자.

<a class="page-link" data-page="{{ question_list.previous_page_number }}" href="#">이전</a>



이 링크의 data-page 속성값을 읽고 searchForm의 page필드에 설정하여 searchFrom을 요청하도록 다음과 같은 스크립트를 추가했다. 

$(".page-link").on('click', function() {
    $("#page").val($(this).data("page"));
    $("#searchForm").submit();
});



그리고 검색버튼을 클릭하면 검색어 텍스트창에 입력된 값을 searchForm의 kw필드에 설정하여 searchForm을 요청하도록 다음과 같은 스크립트를 추가했다.

$("#btn_search").on('click', function() {
    $("#kw").val($(".kw").val());
    $("#page").val(1);  // 검색버튼을 클릭할 경우 1페이지부터 조회한다.
    $("#searchForm").submit();
});

 

검색버튼을 클릭하는 경우는 새로운 검색에 해당되므로 page에 항상 1을 설정하여 요청하도록 했다.

 

검색 views.py

화면에서 요청한 검색어를 질문 목록에서 조회하도록 하기 위해 base_views.py의 index 함수를 다음과 같이 수정해주자.

> ../pybo/views/base_views.py

(... 생략 ...)

from django.db.models import Q

(... 생략 ...)

 

def index(request):

    """

    pybo 목록출력

    """

    # 입력 파라미터

    page = request.GET.get('page', '1'# 페이지

    kw = request.GET.get('kw', ''# 검색어

 

    # 조회

    question_list = Question.objects.order_by('-create_date')

    if kw:

        question_list = question_list.filter(

            Q(subject__icontains=kw) |  # 제목검색

            Q(content__icontains=kw) |  # 내용검색

            Q(author__username__icontains=kw) |  # 질문 글쓴이검색

            Q(answer__author__username__icontains=kw)  # 답변 글쓴이검색

        ).distinct()

 

    # 페이징처리

    paginator = Paginator(question_list, 10# 페이지당 10개씩 보여주기

    page_obj = paginator.get_page(page)

 

    context = {'question_list': page_obj, 'page': page, 'kw': kw}

    return render(request, 'pybo/question_list.html', context)

(... 생략 ...)



Q함수 내 ‘subject__icontains=kw’는 제목에 kw문자열이 포함되었는 지를 확인한다. ‘Answer__author__username__icontains’는 답변 작성한 사람의 이름에 포함되는 지를 의미한다. ‘subject__contains=kw’대신 ‘subject__icontains=kw’을 사용하면 대소문자를 가리지 않고 찾아준다는 차이점이 있다. Filter함수에서 모델 속성에 접근하기 위해선 ‘__’를 이용해 하위 속성에 접근해야 한다. Context 딕셔너리에 page와 kw를 추가했는데, 이는 템플릿에 해당 데이터들을 함께 전달하기 위함이다.

 

검색 확인

 

검색창이 잘 작동되는 것을 확인할 수 있다.

 

정렬

질문 목록을 정렬할 수 있는 기능을 추가하자. 정렬 기준은 최신순/추천순/인기순(달린 답변 수 기준) 총 3가지로 구현한다. 

해당 정렬기준의 파라미터 역시 page와 kw파라미터와 함께 GET 방식으로 요청되어야 한다. 그렇지 않으면 페이징, 검색, 정렬 기능이 동작하지 않는다. 그리고 정렬 후 페이지 이동 하더라도 정렬은 유지되어야 하기 때문에 정렬, 페이징 그리고 검색은 항상 함께 동작해야 한다.

 

정렬 조건

정렬 조건을 추가해주자.

> ../mysite/templates/pybo/question_list.html

 
{% extends 'pybo/base.html' %}
{% load pybo_filter %}
{% block content %}
<div class="container my-3">
    <div class="row justify-content-between my-3">  <!-- 양쪽정렬 justify-content-between로 변경 -->
        <div class="col-2">
            <select class="form-control so">
                <option value="recent" {% if so == 'recent' %}selected{% endif %}>최신순</option>
                <option value="recommend" {% if so == 'recommend' %}selected{% endif %}>추천순</option>
                <option value="popular" {% if so == 'popular' %}selected{% endif %}>인기순</option>
            </select>
        </div>
        <div class="col-4 input-group">
            <input type="text" class="form-control kw" value="{{ kw|default_if_none:"" }}">
            <div class="input-group-append">
                <button class="btn btn-outline-secondary" type="button" id="btn_search">찾기</button>
            </div>
        </div>
    </div>
    <table class="table">
    (... 생략 ...)

 

우측정렬이던 justify-content-end를 양쪽 정렬인 justify-content-between로 바꾸고 좌측에는 정렬기준을 추가하고 우측에는 검색조건을 추가했다. 현재 선택된 정렬기준 값을 자바스크립트 통해 알기 위해 select태그의 class에 so를 추가했다.

 

정렬 폼

searchForm에 정렬기준 의미하는 input 엘리먼트를 추가해주자.

>../mysite/templates/pybo/question_list.html

 
(... 생략 ...)
<form id="searchForm" method="get" action="{% url 'index' %}">
    <input type="hidden" id="kw" name="kw" value="{{ kw|default_if_none:"" }}">
    <input type="hidden" id="page" name="page" value="{{ page }}">
    <input type="hidden" id="so" name="so" value="{{ so }}">
</form>
(... 생략 ...)

 

정렬 스크립트

정렬 기준(select)을 변경할 때 searchForm 요청이 발생하도록 다음과 같은 자바스크립트를 추가해주자.

>../templates/pybo/question_list.html

 
(... 생략 ...)
{% block script %}
<script type='text/javascript'>
$(document).ready(function(){
    (... 생략 ...)
    $(".so").on('change', function() {
        $("#so").val($(this).val());
        $("#page").val(1);
        $("#searchForm").submit();
    });
});
</script>
{% endblock %}

 

Class가 so인 엘리먼트인 경우, 즉 정렬 조건에 해당하는 select의 값이 변경되면 그 값을 searchForm의 so 필드에 저장하여 searchForm을 요청하도록 했다.

 

정렬 views.py

전달한 so 파라미터를 통해서 질문 목록을 정렬시키기 위해 index함수를 아래와 같이 수정하자.

> ../mysite/pybo/views/base_views.py

(... 생략 ...)

from django.db.models import Q, Count

(... 생략 ...)

def index(request):

    """

    pybo 목록출력

    """

    # 입력 파라미터

    page = request.GET.get('page', '1'# 페이지

    kw = request.GET.get('kw', ''# 검색어

    so = request.GET.get('so', 'recent'# 정렬기준

 

    # 정렬

    if so == 'recommend':

        question_list = Question.objects.annotate(num_voter=Count('voter')).order_by('-num_voter', '-create_date')

    elif so == 'popular':

        question_list = Question.objects.annotate(num_answer=Count('answer')).order_by('-num_answer', '-create_date')

    else# recent

        question_list = Question.objects.order_by('-create_date')

 

    # 검색

    question_list = Question.objects.order_by('-create_date')

    if kw:

        question_list = question_list.filter(

            Q(subject__icontains=kw) |  # 제목검색

            Q(content__icontains=kw) |  # 내용검색

            Q(author__username__icontains=kw) |  # 질문 글쓴이검색

            Q(answer__author__username__icontains=kw)  # 답글 글쓴이검색

        ).distinct()

 

    # 페이징처리

    paginator = Paginator(question_list, 10# 페이지당 10개씩 보여주기

    page_obj = paginator.get_page(page)

 

    context = {'question_list': page_obj, 'page': page, 'kw': kw, 'so': so}

    return render(request, 'pybo/question_list.html', context)

(... 생략 ...)   



추천순(recommend) 정렬기준의 경우, 추천수가 높은 것부터 정렬해야 하기 때문에 order_by에 추천수를 의미하는 -num_voter가 추가되었다. 추천수는 장고의 annotate 이용해 Count함수를 사용하여 구한다.

 

Question.objects.annotate(num_vote=Count(‘voter’)) 는 Question의 기존 속성인 author, subject, content, create_date, modify_date, voter에 num_voter라는 속성을 하나 더 추가한다고 생각하면 된다. Annotate로 num_voter를 지정하는 이유는 무엇일까? 이는 filter나 order_by에서 num_voter를 사용할 수 있기 때문이다. 여기서 질문의 추천수인 num_voter는 Count(‘voter’)처럼 Count 함수를 사용하여 얻을 수 있다. Count(‘voter’)는 이 질문의 추천 수를 의미한다.

 

order_by(‘-num_voter’, ‘-create_date’)처럼 order_by 함수에 1개 이상의 파라미터가 전달될 경우, 앞 항목이 우선순위를 가진다. 이 경우 추천수가 먼저 정렬되고 추천수가 같은 경우 최신순으로 정렬하게 된다.

 

템플릿에 전달하기 위해 Page, kw와 함께 so값도 context에 so를 추가했다.

 

놀랍다!

 

3-16 그 외 추가할 만한 기능들

저자가 추후 추가하면 좋을 것으로 생각한 기능들은 아래와 같다.

 

답변 페이징과 정렬

질문 뿐만 아니라 답변도 수 없이 달릴 수 있다. 답변들에 대한 페이징이 필요해 보인다.

답변 보여줄 때 최신순, 추천순 등으로 정렬하여 보여주는 기능도 추가할 수 있겠다. Stackoverflow.com, reddit.com과 같이 추천수가 높은 답변 위주로 보여주는 것을 예시로 들 수 있겠다.

 

카테고리

‘질문답변’ 외 다른 유형의 카테고리 게시판을 추가할 수 있다.

 

비밀번호 찾기와 변경

사용자가 비밀번호를 분실했을 때 조치할 수 있는 방법이 없다. 비밀번호 분실 시 임시비밀번호를 가입 시 등록한 이메일로 발송해주는 기능이 있다면 로그인 문제를 해결해줄 수 있을 것이다.

 

비밀번호 변경 프로그램도 필요하다. 로그인 후 비밀번호와 새 비밀번호 입력받아 비밀번호 수정할 수 있는 프로그램을 만들어보자.

 

프로필

로그인한 사용자의 프로필 화면 기능을 구현할 수 있다. 사용자에 대한 기본 정보와 작성한 질문, 답변, 댓글 등을 열람하는 기능을 구현할 수 있을 것이다.

 

최근 답변과 최근 댓글

최근 작성 답변 또는 댓글을 보여주는 기능도 만들 수 있다.

 

조회 수

답변 수, 추천 수 뿐만 아니라 조회 수 표시도 가능하다.

 

소셜 로그인

구글, 페이스북, 트위터 등을 통한 소셜 로그인 기능 구현도 가능하다.

 

마크다운 에디터

마크다운 문법을 쉽게 입력할 수 있는 마크다운 에디터를 적용해보자. 인터넷을 찾아보면 다양한 에디터가 많다. 저자는 simpleMDE을 추천한다.

 

댓글