8. 투표 상세보기와 투표하고 결과보기 및 제너릭 뷰로 수정하기

등록일 : 2020.12.15. 19:29



Admin에서 Question과 Choice 구조 되짚어보기

이전에 우리는 투표를 위한 Question을 추가했다. 그리고 이와 매핑되는 Choice도 만들었다. Choice를 admin에서 만들 때 우리가 정의한 모델 구조에 의해 어떤 Question을 꼭 선택해야만 등록할 수 있었다.

좀 더 자세히 알아보자. Question과 Choice 모델은 서로 연관되어 있다. 질문 하나에는 여러 선택지가 올 수 있는 1 : N 구조로 되어있다. 하나의 Question에 많은 Choice가 올 수 있으며, 반대로 Choice는 하나의 Question만을 바라보고 있는 구조이다. 이는 하나의 질문에는 여러 선택지가 포함될 수 있다는 뜻이 되어 선택한 Question에 대한 Choice를 모두 보여주면 이것이 하나의 개별 투표를 의미한다고 보면 되겠다.

위에서 우리는 Question 목록을 장고 템플릿을 사용하여 html로 만들었다. 이와 유사하게 특정 Question의 상세보기 페이지를 진입했을 때에도 동일하게 템플릿을 사용해 표현하면 편리하다. index 함수에서 최신 question 목록을 출력하듯, 앞으로 만들 detail 함수에서 question과 연관된 choice들을 모두 가져와 장고 템플릿과 반복문을 활용해 하나씩 출력한다면 간결하고 쉽게 표현할 수 있을 것이다.

투표 상세보기 페이지를 장고 템플릿을 사용해 만들어 보기

투표 상세보기 페이지를 만들자. polls/templates/polls/ 경록에 detail.html을 만들자.

polls/templates/polls/detail.html 수정

<h1>{{ question.question_text }}</h1>

{% if error_message %}<p><strong>{{ error_message }}</strong></p>{% endif %}

<form action="{% url 'polls:vote' question.id %}" method="post">
{% csrf_token %}
{% for choice in question.choice_set.all %}
    <input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}">
    <label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br>
{% endfor %}
<input type="submit" value="Vote">
</form>

첫 번째 줄 <h1>{{ question.question_text }}</h1>은 투표 질문 제목을 표시하는 부분이다. 실제로 해당하는 question의 question_text를 출력해 준다.

<h1> 태그는 각 페이지의 대표 제목으로 사용되는 header 태그로 한 화면당 보통 한 번만 쓰는 것을 권장한다. 왜냐하면 추후에 여러분이 google, naver등 검색사이트에 내가 만든 웹을 추가하고 싶어질 수 있다. 이때 이런 검색사이트들은 자체적인 많은 규칙에 의해 페이지의 내용을 판단하는데 그중 하나로 헤더 태그를 참조하기도 한다. <h1> 태그가 여러 개일 경우 이런 검색사이트에서 해당 문서를 읽어 분석하는 데 어려움을 느낄 수 있다. 물론 두 번 사용한다고 사용자가 보는 데 문제도 없고 오류가 발생하는 것은 아니지만 한번 사용을 권장한다. depth에 따라 일반적으로 <h1> ~ <h6> 로 단계를 나누어 사용한다. <h2> 부터는 여러 번 사용해도 무관하다.

다음 문장을 보자.

{% if error_message %}
<p><strong>{{ error_message }}</strong></p>
{% endif %}

한 줄로 표시된 문장이지만 여기서는 설명을 위해 여러 줄로 변경했다. if문은 이전에 공부해서 어려움이 없을 것이다. 투표의 경우 어떤 하나의 선택을 하고 투표를 해야 하는데 선택하지 않고 투표하는 경우를 에러로 보고 이 경우 에러 메시지를 보여주기 위함이다. <strong></strong> 태그는 내용을 강조하고 싶을 때 태그이다. 이 태그로 감싸게 되면 텍스트를 굵게 표시된다. 텍스트를 굵게 만드는 방법으로 유사한 <b></b> 태그가 있다.

다음은 <form> 태그로 사용자가 입력한 요소들을 서버로 보낼 수 있도록 하는 역할을 한다. 장고 admin 로그인 화면 기억하는가? 이 부분도 form으로 만들어졌다. form 태그 자체는 눈에 보이지 않지만, id와 패스워드를 입력하는 입력 창과 로그인 버튼이 있고 이 모두를 감싸고 있다고 생각하면 된다. 아이디와 비밀번호를 입력하고 로그인 버튼을 누르면 사용자가 입력한 아이디와 패스워드를 서버로 전달할 것이다. 우리가 만들고 있는 투표 앱도 이 form 태그를 활용해 사용자가 선택한 선택지를 서버에 전달하게 될 것이다.

다음은 <form action="{% url 'polls:vote' question.id %}" method="post"> 문장을 살펴보자. action 속성의 경우 폼을 전송할 곳을 지정한다. 여기서도 이전 시간에 배웠던 탬플릿과 url 태그가 사용되었다. 투표 버튼을 클릭하면 폼 데이터를 이곳으로 전달하게 된다. /polls/vote/1/ 등의 모양으로 전달될 것이다. 이에 대한 처리는 vote() 함수가 하게 될 것이다.

여러분들이 웹 사이트에서 http://127.0.0:8000/poll/1 을 접속하면 이 부분은 자원을 요청하는 부분으로 method를 따로 지정하지 않아도 기본적으로 GET으로 동작한다. 그러나 사용자가 클릭한 값을 서버에 전달하고 이를 DB에 적용해야 투표 결과를 저장할 수 있다. 이렇게 서버에 사용자 선택 데이터를 넘겨주어 변경이 되는 경우 POST method를 사용하면 된다.

{% csrf_token %}에 대해 알아보자. CSRF는 Cross-site request forgery의 약자이다. 정상적인 사용자가 아닌 비정상 사용자의 공격을 심어 서버로 보내려는 다양한 시도를 할 수 있다. 이를 막기 위해 Django에서는 기본으로 csrf_token을 활용해 잘못된 요청을 받아들이지 않도록 해준다. 따라서 POST를 사용하는 곳에는 {% csrf_token %}만 넣으면 장고가 알아서 처리해주므로 크게 신경 쓸 필요는 없다. <form></form>태그 안에 사용하여 보안을 강화하도록 하자.

csrf_token 다음 문장을 살펴보자.

<!-- ...중략... -->
{% for choice in question.choice_set.all %}
    <input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}">
    <label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br>
{% endfor %}

detail 함수에서 우리는 사용자가 입력한 url을 통해 그에 해당하는 question object를 넘겨받았다. 투표를 하기 위해서는 해당 투표에 선택지를 모두 보여주고 골라야 한다. 어떻게 접근하면 좋을까? 맨 처음 구조를 만들 때 models.py에서 우리는 choice가 Question을 참조하도록 설계하였다. 이렇게 설계한 덕분에 question을 통해 이와 연결 되어 있는 choice들에게 접근할 수 있다. question과 연결된 choice들을 가져올 수 있는 매니저를 (소문자)모델이름_set로 제공해 주기 때문이다. 이 매니저를 통해 모든 데이터를 가지고 오고 싶은 경우 .all를 사용하면 된다. 이를 하나의 문장으로 합쳐 표현하여 question.choice_set.all 같은 모양이 되었다. 이를 통해 question에 해당하는 모든 Choice의 오브젝트를 가져오게 된다.

for문 안의 문장을 보자. form 안에 <input type="radio"> 태그가 사용되었다. input은 사용자 입력을 받을 수 있는 영역을 만들어 준다. 사용자 입력은 다양하다. 아이디처럼 일반 글자를 입력받을 수도 있고, 체크박스처럼 여러 개를 선택할 수도 있다. 한 번에 하나의 선택만 가능한 투표를 만든다면 input typeradio 타입으로 사용하면 된다. 라디오 타입은 한 번에 하나씩만 선택하도록 도와줄 것이다. 그 외에도 여러 가지 type 속성이 존재한다.

라디오 타입이라는 말은 잘 와닿지 않을 것이다. 옛날 라디오 수신기의 경우 버튼이 여러 개가 있고 한 번에 하나만 누를 수 있었다. 두 버튼을 동시에 누르면 둘 다 눌러지지 않거나 고장이 날 것이다. 제조사(제공자)의 의도에 따라 한번에 하나씩만 눌러질 수 있는 버튼이 웹에서 라디오 버튼이 되었다. 우리가 흔히 사용되는 동그라미에 클릭 시 선택된 것처럼 보이는 이런 입력이 radio type으로 이름이 정의되었음을 참고하자. IT 기술은 굉장히 빨리 바뀌지만 과거에 만들어진 이런 이름들은 과거를 알아야 이해가 쉬운 경우가 종종 있다.

<input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}"> 문장을 다시 살펴보자. type에도 name, id, value 속성이 같이 사용되었다.

  • name : 그룹핑 역할을 해준다. 같은 이름으로 여러개를 만들면 클릭 했을 때 이중 하나만 선택되게 하는 역할이다.
  • id : 각 개별 항목을 구분하는 역할이다.
  • value : 선택한 값을 전달했을 때 전송할 값을 알려준다.

forloop.counter는 for 태그가 몇 번 반복되었는지 알려준다. 카운터는 1부터 시작하고 숫자가 1씩 증가한다. 자연스레 각 radio 입력 항목이 1 → 2 → 3 → ... 순으로 될 것이다. 번호를 매길 때 유용하게 쓰일 수 있다. forloop.counter로 쓰인 부분은 단지 표시되는 부분의 구분을 위한 것이며 실제 DB와는 관련이 없다. 하지만 value 부분은 {{ choice.id }}를 통해 실제 DB에 있는 Choice 모델의 id 값을 가져왔다. 실제 투표 버튼을 눌렀을 때 선택된 input의 value 값이 전달될 것이고, 이 부분을 통해 투표 결과를 DB에 반영해 줄 수 있는 vote() 함수를 조금 후 만들 것이다.

마지막 <input type="submit" value="Vote">를 통해 하단에 투표 제출 버튼을 만들 수 있다. 버튼을 클릭하면 전체 감싸져 있는 form 태그의 action 부분으로 전달한다.

위에 detail.html 소스를 변경 반영 후 http://127.0.0.1:8000/polls/1/ 페이지에 접속해보자.

그리고 하나씩 클릭해보자. 하나를 선택하는 경우 다른 하나는 선택할 수 없게 된다.

크롬 브라우저에서 우클릭 후 페이지 소스 보기 메뉴를 선택해보자. 우리가 사용한 장고 템플릿을 통해 실제 html이 어떻게 만들어졌는지 알 수 있다.

http://127.0.0.1:8000/polls/1/를 소스 보기 한 화면

<h1>선호하는 떡볶이 맵기 강도는?</h1>

<form action="/polls/1/vote/" method="post">
<input type="hidden" name="csrfmiddlewaretoken" value="...생략...">

    <input type="radio" name="choice" id="choice1" value="1">
    <label for="choice1">순한맛</label><br>

    <input type="radio" name="choice" id="choice2" value="2">
    <label for="choice2">보통맛</label><br>

    <input type="radio" name="choice" id="choice3" value="3">
    <label for="choice3">매운맛</label><br>

    <input type="radio" name="choice" id="choice4" value="4">
    <label for="choice4">아주 매운맛</label><br>

<input type="submit" value="Vote">
</form>

소스 보기 기능은 유용하므로 잘 활용하자. 그리고 선호하는 값을 선택하고 투표도 해보자.

이전 장에서 실제 투표가 아닌 기본 틀만 만들었기 때문에 다음과 같은 화면이 보일 것이다.

이제 뒤에서 이 부분도 완성해보자.

투표 결과를 DB에 저장하고 투표 결과를 불러와 보여주기

이제 투표 결과를 DB에 반영하고 투표 결과를 보여주는 페이지도 만들어 보자.

특정 question의 상세보기 화면으로 진입해 Vote 버튼을 눌러 투표를 하면 우리는 해당 polls/question_id/vote/ 경로에서 처리할 것이다. 이 부분은 form의 action 필드에서 정의 했다. 실제 동작은 이 경로는 urls.py에 경로로 연결해 주었고 실제 기능은 views.py의 vote 함수에서 처리하기로 정의했다.

이제 투표를 했을 때 이를 처리해 줄 vote 함수를 수정하자.

from django.http import HttpResponse, HttpResponseRedirect # HttpResponse뒤에 , HttpResponseRedirect  추가됨
from django.shortcuts import get_object_or_404, render
from django.urls import reverse  # 추가됨

from .models import Choice, Question # 앞에 Choice, 추가됨

# ... 생략...
def vote(request, question_id): # vote 함수 수정
    question = get_object_or_404(Question, pk=question_id)
    try:
        selected_choice = question.choice_set.get(pk=request.POST['choice'])
    except (KeyError, Choice.DoesNotExist):
        # Redisplay the question voting form.
        return render(request, 'polls/detail.html', {
            'question': question,
            'error_message': "You didn't select a choice.",
        })
    else:
        selected_choice.votes += 1
        selected_choice.save()
        # Always return an HttpResponseRedirect after successfully dealing
        # with POST data. This prevents data from being posted twice if a
        # user hits the Back button.
        return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))

기존 보다 좀 복잡해진 코드가 나타났다. 한줄 한줄 알아가자.

def vote(request, question_id):
    question = get_object_or_404(Question, pk=question_id)

이 부분은 detail 함수와 동일하여 크게 어려울 것이 없다. 다음 문장으로 넘어가자.

selected_choice = question.choice_set.get(pk=request.POST['choice'])

우리는 투표 상세보기를 위한 detail.html에서 question.choice_set.all을 사용해 연관 Choice들을 모두 가져왔었다. 투표 결과를 반영하기 위해서는 모든 Choice들을 가져올 필요가 없다. 오로지 사용자가 선택한 Choice만 있으면 된다. 사용자가 투표를 하면 선택한 값을 포함하여 form의 POST method로 값을 전달할 것이다. 요청은 사용자가, 응답은 서버가 받는다. 그 요청을 받는 함수는 views.py의 vote()로 우리가 정의 했다. 전달받은 값은 값 중 'choice' 항목에 대한 실제 값을 원한다. 여기서는 request.POST['choice'] 형태로 접근하여 그 값을 알아낼 수 있다. 이는 파이썬의 딕셔너리 구조와 유사한데 이에 대해 학습하지 않았으므로 이해가 가지 않을 수 있다.

파이썬의 딕셔너리 구조에 대해 잠깐 배우고 가자. 딕셔너리는 key-value 쌍을 가지는 사전식 구조를 구성할 때 쓰인다. key는 중복되지 않고, 사전의 수많은 단어처럼 여러 key를 추가 할 수 있다. 다음의 예제를 보자. 터미널을 새로 띄워 이번에는 파이썬 콘솔로 진입해보자.

$ python
Python 3.7.7 (default, Apr 29 2020, 09:50:19) 
[Clang 11.0.0 (clang-1100.0.33.12)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> request_POST =  { 'choice': 1, }
>>> request_POST['choice']
1

투표 후 POST 요청으로 넘겨받은 choice의 value를 1이라고 하자. 이를 딕셔너리 자료형으로 비슷하게 만들어 보겠다. 여기서는 request_POST = { 'choice': 1, } 이런 모양으로 딕셔너리를 만들었다. 딕셔너리는 { }로 감싸져 있고 그안에 key : value 형식으로 여러 쌍을 선언할 수 있다. 여기서는 'choice' : 1로 쌍을 만들었다. 이 딕셔너리에서 key로 value를 접근하고 싶으면 request_POST['choice'] 이렇게 사용하면 된다. 이것은 예시이고 실제 위의 코드에서는 input name="choice"에서 정의 하였고 사용자의 선택 값은 value로 지정했다. request.POST['choice']로 딕셔너리 형태로 key(choice):value(사용자 전송 값)에 접근할 수 있는 것이다.

하지만 여기서 문제가 발생한다. 아무것도 선택하지 않고 투표 버튼을 누를 수 있는 예상치 못한 상황이 발생한다. 이 부분을 파이썬 콘솔에서 유사하게 만들어 확인해보자.

>>> request_POST = { }
>>> request_POST['choice']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'choice'
>>>

딕셔너리 구조에서 존재하지 않은 키를 요청하면 다음과 같이 KeyError가 발생한다. 이런 경우가 사용자 환경에서 발생한다면 우리는 적절한 조치를 취해 오류메시지 대신 메시지를 보여주거나 처리 할 수 있다.

try except else 사용법 알아보기

html 파일 수정할 때 if else문을 사용한 적이 있다. try except else 문장이 발생했다. try except는 위에서처럼 사용자가 예외적인 동작을 하거나 프로그램이 구동 중 예외적인 동작을 하게 되었을 때 이를 처리해 줄 수 있는 문법이다. 이 경우는 오류 메세지 대신 사용자에게 더 친화적인 화면을 보여주는 것이 좋겠다. '아무 선택도 하지 않았습니다. 선택 후 다시 투표해 주세요' 등의 메시지가 될것이다. 이런 오류는 사용자가 정보를 주지 않았기 때문에 당연하다고 생각될 수 있지만, 이런 것들을 잘 처리해주는 것이 '좋은 소프트웨어'라 볼 수 있다.

예를 들어 아이디를 입력하지 않은 경우 '아이디를 입력해주세요' 라는 경고 문구가 뜨지 않는다면 어떤 이유 때문에 로그인이 되지 않는지 한참을 모를 수 있다. 또한 오류메시지가 사용자에게 아무런 가이드 없이 보여진다면, 사용자는 여러분의 서비스가 오류 투성이라고 생각할 수도 있고 사용을 안 하게 될 중요한 계기를 제공하게 된다.

사용자의 실수를 방지하게 하고, 실수가 있었다면 해결 방법을 제시하는 것이 별거 아닌 것 같지만, 매우 중요한 요소이다.

따라서 try except else 구문으로 다음과 같이 처리할 수 있다.

try: 다음 동작을 시도하라.
except Error이름: Error이름 에러가 발생했다면 어떻게 처리 하라.
else: 오류가 발생하지 않았다면 다음과 같이 처리 하라.

except 구문은 에러의 내용을 기술하고 동일한 에러가 발생한 경우 어떻게 행동할지를 기술한다. 여기서는 만약 선택하지 않고 vote 버튼을 눌렀다면 아무것도 선택하지 않았다는 메시지와 함께 다시 선택할 수 있도록 detail.html 부분을 출력하도록 해볼 예정이다.

views.py vote() 보기

# ...생략...
  except (KeyError, Choice.DoesNotExist):
        # Redisplay the question voting form.
        return render(request, 'polls/detail.html', {
            'question': question,
            'error_message': "You didn't select a choice.",
        })
# ...생략...

except (KeyError, Choice.DoesNotExist):문장부터 살펴보자. 우리가 위에서 확인한 KeyError 외에도 한가지 에러를 더 추가했다. except KeyError: 로 하나를 사용할 수도 있으며 except ( A , B )형식으로 여러 에러 처리를 묶을 수 있다. Choice.DoesNotExist의 경우는 글자 그대로 question에 매핑된 Choice object에 존재하지 않는 object를 요청하는 경우 발생하는 오류이다.

두번째 #문장은 주석문으로 알고 있을 것이다. 하단 문장들이 question에 대한 투표 폼을 다시 화면에 표시한다는 설명이 되어있다. detail.html 로 넘겨주고 이번에는 question뿐만 아니라 error_message를 함께 넘겨줬다.

detail.html에 if문이 있던것이 기억나는가? 에러메시지가 있다면 에러메시지를 출력하고자 하는 조건문이 있었다. 이제 아무것도 선택하지 않고 투표를 한다면 오류메시지가 출력될 것이다.

detail.html 확인하기

<!-- ...중략... -->
{% if error_message %}<p><strong>{{ error_message }}</strong></p>{% endif %}
<!-- ...중략... -->

error_message를 넘겨받아 있는 경우만 출력해준다. error_message는 views.py의 vote 함수에서 사용자 입력 form이 아무것도 선택하지 않고 넘겨받은 경우 try except 구문에 의해 해당 에러 메시지와 question 정보를 detail.html로 넘겨주어 발생한다. 한번 아무것도 고르지 않고 시도해보자.

선택하지 않은 경우 오류메시지와 함께 다시 투표가 가능하다. 이런 처리는 사용자를 편리하게 만들어 준다. 그럼 정상적으로 선택한 경우는 어떻게 처리할까? 투표 결과를 저장하는 것이 맞겠다. 중요한 데이터와 다시 사용할 데이터는 특별한 일이 없다면 DB에 저장하는 것이 맞다. try except 에서 오류가 발생하지 않았다면, else 문장에서 정상적인 흐름에 대한 처리를 해주게 된다.

그 전에 model.py를 다시 한번 복습해보자.

model.py 보기

# ...중략...
class Choice(models.Model):
    question = models.ForeignKey(Question, on_delete=models.CASCADE)
    choice_text = models.CharField(max_length=200)
    votes = models.IntegerField(default=0)
# ...중략...

Choice 모델에 votes 필드를 IntegerField로 정의했었다. 기존에 path 부분에도 쓰였던 int의 개념과 같다. 숫자를 다루는 필드이며 기본값은 0으로 정의했다. 이는 admin에서 Choice 상세 페이지에 접근해서도 확인할 수 있었다. 투표가 정상적으로 진행된다면 우리는 이 투표수를 votes에 + 1 한 값을 반영해야 한다. 그 부분을 우리는 vote 함수에서 DB에 기록하게 요청할 수 있다.

views.py의 vote 함수

# ...중략...
    else:
        selected_choice.votes += 1
        selected_choice.save()
        # Always return an HttpResponseRedirect after successfully dealing
        # with POST data. This prevents data from being posted twice if a
        # user hits the Back button.
        return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))
# ...중략...

try 구문 안에 있는 selected_choice = question.choice_set.get(pk=request.POST['choice'])문을 통해 사용자가 선택한 답변에 해당하는 Choice 오브젝트를 selected_choice라는 이름으로 정의했다. 이것으로 해당 오브젝트의 votes 필드에 접근할 수 있다. selected_choice.votes 로 접근하면 된다. selected_choice는 사용자가 어떤 답변을 선택하느냐에 따라 달라질 것이고 그에 해당하는 votes 필드에 대응된다. 이 숫자를 하나 증가 위해서는 어떻게 할까? 간단하다. 우리가 아는 방법으로 숫자 + 1을 해주면 된다. vote 필드에 + 1 연산을 하게 되면 숫자가 1 증가한다. selected_choice.votes + 1 증가한 값을 selected_choice.votes 로 다시 정의하면 된다. 파이썬 문법으로는 selected_choice.votes = selected_choice.votes + 1과 같은 모양이 될 것이고, 이를 줄여서 다음과 같이 보통 작성한다.

selected_choice.votes += 1 # selected_choice.votes = selected_choice.votes + 1 와 같다

해당 값에 1을 증가한 값을 가리키는 의미이다. 하지만 이는 +1에 대한 값만 계산된 것이지 실제로 DB에 저장되지는 않았다. 계산기로 여러분이 보통 계산하고 계산기를 껐다 켜보자. 저장된 값이 남아있지 않을 것이다. 위의 +1 연산도 같은 개념이다. 이를 다음에도 또 계산된 결과 값을 유지 하기 위해서는 메모장에 저장해두거나 다른 곳에 저장해야 한다. 우리는 DB를 이용하니 여기 저장하면 된다. 실제 데이터베이스에 저장하는 명령어는 .save() 로 수행 가능하다. 여기서는 +1 값을 정의하고 이후 DB에 기록하면 되므로 더하기 해준 다음 문장에 selected_choice.save()를 수행하면 된다.

투표 후 투표 결과 화면 보여주기

DB에 투표 결과까지 반영했다면 이제 투표 완료 화면으로 이동시켜야 한다. 이는 return HttpResponseRedirect(reverse('polls:results', args=(question.id,))) 마지막 문장에서 수행한다.

기존에 우리는 views.py의 index 함수에서 HttpResponse로 최신 Question리스트를 통째로 넘겨 준 경험이 있었다. 이번엔 그와 비슷하지만 HttpResponseRedirect를 사용한다.

Redirect(리디렉션)은 요청한 응답을 다른 url로 넘겨주는 역할을 한다. 투표가 정상적이었다면, 이제 그 결과를 results 화면으로 넘겨줄 필요가 있다. 이는 사용자 입력과 상관없이 서버에서 자동으로 처리되는 방식이 필요 할 때 사용된다. 투표가 올바르게 되었고 이제 결과화면으로 보내는 것은 서버에서 처리할 내용이므로 HttpResponseRedirect 을 통해 results로 연결할 것이다.

reverse 알아보기

여기서는 추가적으로 reverse가 쓰인다. from django.urls import reverse를 통해 사용된다. 위에서 result로 보내기 위해 우리는 urlpatterns에 정의된 path로 이동시키게 해준다. reverse를 통해 요청을 다시 url로 넘여준다 정도로 생각하자. 투표 결과 화면은 어떤 투표를 했는지 알아야 하므로 question.id 를 함께 전달했다.

투표 하는 동작을 처리하는 vote() 함수까지 만들었다.

투표 결과 화면 만들기

마지막으로 결과를 보여 줄 수 있는 result() 함수를 완성해보자.

views.py 수정

from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse

from .models import Choice, Question

# ...중략...

def results(request, question_id): # result 함수 수정
    question = get_object_or_404(Question, pk=question_id)
    return render(request, 'polls/results.html', {'question': question})

# ...중략...

detail 함수와 거의 같으므로 특별한 설명이 필요하지 않다.

이제 결과 화면을 구성하는 작업이 필요하다. views.py 의 vote 함수에서 투표 결과를 처리 후 실제 결과를 보여 줄 화면이 필요하다. 여기서는 polls/templates/polls/results.html 파일로 만들기로 하였다.

polls/templates/polls/results.html 만들기

<h1>{{ question.question_text }}</h1>

<ul>
{% for choice in question.choice_set.all %}
    <li>{{ choice.choice_text }} -- {{ choice.votes }} vote{{ choice.votes|pluralize }}</li>
{% endfor %}
</ul>

<a href="{% url 'polls:detail' question.id %}">Vote again?</a>

results.html는 기존 detail.html 과 유사하다. 이번에는 {{ choice.votes }} vote{{ choice.votes|pluralize }} 가 새롭게 등장했다. {{ choice.votes }}는 해당 선택지의 vote 필드의 값을 가리킨다. 투표가 한 번도 되지 않았다면 0을 보여줄 것이고, 1회 이상 되었다면 해당 숫자가 보여질 것이다. 다양하게 제공되는 장고 필터 중 하나로, pluralize는 단수와 복수를 나타내주는 기능을 한다. 숫자가 1인 경우 1 vote, 2 이상인 경우 votes 라고 표시해주는 역할을 한다. 참고로 영어권에서는 1을 제외한 0도 복수로 0 votes 표시한다고 한다. 마지막 줄에는 투표 화면으로 돌아가는 링크가 표시된다. 이 링크를 누르면 해당 투표를 다시 진행할 수 있는 화면으로 이동하게 된다.

이제 실제 투표를 해보고 결과가 잘 반영되었는지 확인한다.

이제 투표가 제법 완성되었다.

generic view 사용하기

views.py 의 detail 뷰와 results 뷰는 각각의 뷰는 중복적인 부분이 존재한다. 투표 리스트를 보여주는 index 뷰 또한 비슷하다. 이런 뷰들이 공통적인 부분을 알아보자.

URL에 전달된 매개 변수에 따라 데이터베이스에서 데이터를 검색하고 정해진 템플릿을 로드하고 렌더링 된 템플릿을 반환한다. 따라서 이런 공통적인 부분을 단축어(shortcut)로 제공하는 시스템이 존재한다. 장고에서는 제너릭 뷰(generic views) 시스템이라고 부른다.

다음과 같은 과정으로 변환한다.

1. URLconf 변경하기
2. 불필요한 views 제거
3. 제너릭 뷰로 만들기

polls/urls.py 수정

from django.urls import path
from . import views

app_name = 'polls'
urlpatterns = [
    path('', views.IndexView.as_view(), name='index'),
    path('<int:pk>/', views.DetailView.as_view(), name='detail'),
    path('<int:pk>/results/', views.ResultsView.as_view(), name='results'),
    path('<int:question_id>/vote/', views.vote, name='vote'),
]

views.index 는 views.IndexView.as_view()로 변경했다. 변경시 대소문자에 주의하자. 다음 문장의 DetailsView 와 ResultView도 마찬가지다. 이 두 뷰는 <int:question_id>/ 패스가 <int:pk>/ 로 변경되었다. 제너릭 뷰에서는 pk로 변경하여 사용된다. vote뷰의 경우는 제너릭 뷰가 아닌 기존 뷰 그대로 유지할 것이므로 변화가 없다. vote를 제외한 나머지 3개의 path의 뷰가 제너릭 뷰로 변경될 것이다.

views.py 수정

from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.views import generic

from .models import Choice, Question


class IndexView(generic.ListView):
    template_name = 'polls/index.html'
    context_object_name = 'latest_question_list'

    def get_queryset(self):
        """Return the last five published questions."""
        return Question.objects.order_by('-pub_date')[:5]


class DetailView(generic.DetailView):
    model = Question
    template_name = 'polls/detail.html'


class ResultsView(generic.DetailView):
    model = Question
    template_name = 'polls/results.html'


def vote(request, question_id):
    ... # same as above, no changes needed.

from django.views import generic 부분은 장고 뷰에서 기본적으로 제공하는 generic 뷰를 사용하기 위해 가져왔다.

class IndexView(generic.ListView): 부분에서는 IndexView를 함수가 아닌 클래스로 만들고 generic.ListView를 가져왔다. IndexView는 투표 목록을 보여주는 경우이므로 ListView는 리스트를 보여주는 경우 사용하기 적절하다.

각각의 뷰들은 어떤 모델을 사용할지 알아야 한다. 제너릭 뷰에서는 이런 속성이 model로 정의되어 있어 해당하는 모델을 model = Question 이런 식으로 연결해줘야 한다.

하지만 IndexView에서는 정의하지 않았다. model을 사용하면 해당하는 모든 값을 가져오게 되는데 전체가 아닌 일부만 필요하다면 그 부분만 가져올 수 있다.

Question.objects.order_by('-pub_date')[:5]를 기억하는가? Question의 내용 중 최신순으로 정렬하여 5개만 가져와 달라는 것이다. 이것을 DB로 요청하는 문장이 곧 DB와 소통 가능한 쿼리문이 된다. DB에서 쿼리문을 통해 소통이 가능하다고 배웠었다. 따라서 이 동작을 하는 함수가 get_queryset 이라는 이름으로 정의된 것이다. 쿼리 셋만 필요하다면 model을 정의할 필요 없다.

다시 정리하면, model 속성에 필요한 models.py에서 모델 이름을 명시하거나 모델의 일부 데이터만 필요한 경우 get_queryset 함수를 통해 일부만 가져온다. 그리고 template_name에 연결하고자 하는 템플릿 html의 위치와 파일명을 지정해준다.

리스트 뷰에서는 해당 list를 사용할 오브젝트 이름이 index.html의 for 문에 사용되었었다. 따라서 해당 이름을 알려줘야한다. context_object_name = 'latest_question_list' 로 정의하여 알려준다.

DetailView에서는 Question정보와 pk로 직접 정보를 추출할 수 있기 때문에 이마저도 필요 없다. 코드가 달랑 두 줄로 해결되었다. detail.html 에서는 {{ question.question_text }}으로 question에 접근했다. question은 명시하지 않았지만 Question 모델을 사용하게 되어 question이라는 이름을 자동으로 제공해준다.

코드 수정 후 동작해보자. 동일하게 동작해야 정상이다.

더 이상의 자세한 설명은 여기서 더 이상 깊게 들어가긴 힘들 것 같다. 좀 더 전문가가 되기를 원하는 분은 https://docs.djangoproject.com/en/3.1/ref/class-based-views/generic-display/ 를 보길 권한다.

제너릭 뷰 부분은 만들어진 기능들이 많이 함축되어 있어서 배경 지식들이 없으면 다소 어려울 수 있다. 이를 잘 사용하면 개발 속도와 생산성은 엄청나게 빨라질 것이다. 다만 처음 배우는 데 있어서 기본적인 부분들을 잘 이해하고 적용하길 바란다. 위에 있는 설명조차도 많이 함축되어 있는 부분으로 이 강의에서는 기본 설명만 다룬다는 점을 참고하자.