3주차 화요일, 12일차 Today I Learned
장고(Django) 프레임웍을 사용한 API 서버 만들기 (2)
: 뷰 (View) 와 템플릿 (Template), 폼 (Forms)과 커스터마이징 (Customizing)
✏️ 학습 내용
1. 뷰 (Views)와 템플릿 (Templates)
모델에서 데이터를 정의하고 조작하면, 뷰에서는 이 모델들을 활용하는 역할을 한다. 그동안 모델을 장고 쉘에서 사용했다면, 이번에는 뷰에서 활용해보겠다.
order_by()은 쿼리셋을 정렬하기 위한 메서드이다. 이를 통해 특정한 필드를 기준으로 쿼리셋을 오름차순 혹은 내림차순의 형태로 정렬하고, 뒤에 슬라이싱을 추가하여 반환되는 쿼리셋의 범위를 지정해줄 수도 있다.
render()는 Django의 내장 함수 중 하나로, HTTP 요청을 받아 해당 요청에 대해 원하는 템플릿 파일을 렌더링하여 응답하는 기능이다. 이 함수는 보통 뷰(view)에서 사용되며, 첫 번째 인자로 요청 (Request) 객체, 두 번째 인자로 템플릿 파일의 경로, 세 번째 인자로 Context 변수를 입력 받는다.
# polls/views.py
from .models import *
from django.shortcuts import render
def index(request):
latest_question_list = Question.objects.order_by('-pub_date')[:5]
context = {'first_question': latest_question_list[0]}
return render(request, 'polls/index.html', context)
polls/index.html 템플릿 파일을 렌더링하고, 해당 템플릿에서 context 변수를 사용할 수 있도록 전달하여 그 결과를 HTTP 요청에 대한 응답으로 반환하는 코드이다.
from polls.models import *
Question.objects.order_by('-pub_date')[:5]
print(Question.objects.order_by('-pub_date')[:5].query)
>>> SELECT "polls_question"."id", "polls_question"."question_text", "polls_question"."pub_date" FROM "polls_question" ORDER BY "polls_question"."pub_date" DESC LIMIT 5
홈페이지 본문 내용이 줄글이 아니라 장고 어드민에서처럼 보이도록 하려면 html을 활용해야 하는데, 이를 가능하게 하는 것이 템플릿이다. 아래 폴더 안에 파일을 새로 만든다.
# polls/templates/polls/index.html
<ul>
<li>{{first question}}</li>
<ul>
2. 템플릿에서 제어문 사용하기
# polls/templates/polls/index.html
{% if questions %}
<ul>
{% for question in questions %}
<li>{{question}}</li>
{% endfor %}
</ul>
{% else %}
<p>no questions</p>
{% endif %}
# polls/views.py
from .models import *
from django.shortcuts import render
def index(request):
latest_question_list = Question.objects.order_by('-pub_date')[:5]
context = {'questions': latest_question_list}
#context = {'questions': []}
return render(request, 'polls/index.html', context)
뷰에서 1개만 받는 것이 아니라 전체를 다 받고, 템플릿에서 조건문으로 출력하는 방법이다. questions 변수가 비어있지 않은 경우에는 이 변수에 저장된 오브젝트들을 출력하고, 비어있다면 no questions 문구를 출력하도록 한다.
3. 상세 페이지 만들기
question의 목록이 아니라, 상세 내용을 받아오겠다. detail은 특정 Question의 세부 정보를 표시하는 뷰이다. detail 뷰에서는 특정 Question 오브젝트를 가져오기 위한 과정이 필요하다. 이를 위해 Question 모델 클래스에서 id 필드의 값이 detail 뷰의 인자로 전달된 question_id와 일치하는 객체를 get() 메서드로 가져와서 question 변수에 저장한다. 이렇게 저장된 question 변수는 해당 Question에 대한 정보를 렌더링하는 템플릿에 전달된다.
# polls/views.py
def detail(request, question_id):
question = Question.objects.get(pk=question_id)
return render(request, 'polls/detail.html', {'question': question})
웹 브라우저 상에서 사용자의 요청에 따라 detail 뷰의 내용이 실행될 수 있도록 url 경로를 설정해야 한다. detail 뷰는 request와 question_id 두 가지 입력값을 전달 받는다. http://127.0.0.1:8000/polls/ 뒤에 정수를 입력하면, 그 값을 question_id 변수에 전달하고 detail 뷰를 호출하도록 path를 지정해야 한다. 이 URL 패턴에 대한 이름은 'detail'로 지정한다.
# polls/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('', views.index, name='index'),
path('some_url', views.some_url),
path('<int:question_id>/', views.detail, name='detail'),
]
뷰에서 입력 받은 내용을 템플릿에서 표시해야 한다. detail 템플릿은 특정 Question에 대한 상세 정보(해당 질문과 연결되어 있는 선택지들)를 보여주는 역할을 한다. 먼저 해당 질문의 question_text를 제목으로 출력한다. 그리고 해당 질문과 연결된 모든 선택지들을 가져오고, 반복문을 통해 각 선택지의 choice_text를 출력한다. 아래의 question은 detail 뷰에서 전달 받은 question 오브젝트를 나타낸다.
# polls/templates/polls/detail.html
<h1>{{ question.question_text }}</h1>
<ul>
{% for choice in question.choice_set.all %}
<li>{{ choice.choice_text }}</li>
{% endfor %}
</ul>
템플릿 안에서는 ~all() 처럼 괄호를 쓰면 안 된다. 위 내용에 의해 question과 그 안의 choice가 노출된다.
4. 상세 페이지로 링크 추가하기
# polls/urls.py
from django.urls import path
from . import views
app_name = 'polls'
urlpatterns = [
path('', views.index, name='index'),
path('some_url', views.some_url),
path('<int:question_id>/', views.detail, name='detail'),
]
이전에 만든 질문 목록에 질문이 하나 이상 존재하는 경우에만 해당 목록에서 질문을 하나씩 출력하는 Index 템플릿 코드에 url을 이용해서 <a> 태그를 추가했다. 각 질문을 클릭하면 해당 질문의 상세 페이지로 이동하도록 링크를 추가하고자 했다. 해당 질문의 question.id를 입력받고, 그 값을 questions:question_detail에 반영하여 고유한 URL을 생성하면 된다. 이 때 app_name으로 지정한 문자열과 path에서 지정한 name 문자열을 이용해야 한다. (questions:question_detail) -> (polls:detail)
# polls/templates/polls/index.html
{% if questions %}
<ul>
{% for question in questions %}
<li><a href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a></li>
{% endfor %}
<ul>
{% else %}
<p>no questions</p>
{% endif %}
5. 404 에러 처리하기
HTTP 404 에러는 일반적으로 웹 요청에서 찾을 수 없는 페이지나 리소스를 요청했을 때 발생한다. get_object_or_404 메서드를 활용하여 Question 모델에서 pk = question_id 조건을 만족하는 오브젝트를 가져오고, 만약 해당 객체가 없다면 Http404 예외를 발생시키는 코드를 작성했다.
# polls/views.py
from models.py import *
from django.http import HttpResponse
#from django.http import Http404
from django.shortcuts import render , get_object_or_404
...
def detail(request, question_id):
"""
이 내용은 아래 question = get_object_or_404(Question, pk=question_id) 과 같은 내용이다.
get_object_or_404 를 사용하면 아래의 4줄을 1줄로 간단하게 표현할 수 있다.
from django.http import Http404도 입력할 필요가 없어진다.
try:
question = Question.objects.get(pk=question_id)
except Question.DoesNotExist:
raise Http404("Question does not exist")
"""
question = get_object_or_404(Question, pk=question_id)
return render(request, 'polls/detail.html', {'question': question})
6. 폼 (Forms)과 커스터마이징 (Customizing)
장고에서 폼(Forms)은 웹 애플리케이션에서 사용자로부터 데이터를 입력받고 처리(데이터 표시)하기 위한 도구이다.
1. 사용자 입력 수집: 폼을 사용하여 웹 애플리케이션 사용자로부터 데이터를 수집합니다. 사용자가 텍스트, 숫자, 날짜, 파일 등 다양한 유형의 데이터를 입력할 수 있습니다.
2. 데이터 유효성 검사: 폼은 사용자로부터 입력받은 데이터를 검증하고 유효성을 확인하는데 사용됩니다. 이를 통해 잘못된 데이터를 방지하고 애플리케이션의 데이터 무결성을 유지할 수 있습니다.
3. 데이터 표시: 폼은 데이터를 템플릿에 표시하는 데 사용됩니다. 이를 통해 웹 페이지에서 데이터를 출력하거나 수정할 수 있습니다.
4. CRUD 작업 지원: Create, Read, Update, Delete (CRUD) 작업을 수행하기 위해 폼을 사용할 수 있습니다. 사용자로부터 데이터를 입력받아 새로운 레코드를 생성하거나, 기존 레코드를 업데이트하고 삭제할 수 있습니다.
5. 보안: 폼은 웹 애플리케이션에서 사용자 입력을 안전하게 다룰 수 있도록 도와줍니다. 크로스 사이트 스크립팅(Cross-Site Scripting, XSS) 및 SQL 인잭션과 같은 공격으로부터 보호할 수 있습니다.
Django에서는 폼 클래스를 사용하여 폼을 정의하고 처리한다. 폼 클래스를 작성하면 폼 필드, 유효성 규칙, 초기 데이터, 레이아웃 등을 정의할 수 있다. 이를 통해 재사용 가능한 폼을 만들고, 폼 처리 및 유효성 검사를 자동화할 수 있다. Django의 폼 클래스는 HTML 폼을 생성하고 클라이언트와 서버 간의 데이터 교환을 간단하게 만들어준다.
# polls/views.py
...
def vote(request, question_id):
question = get_object_or_404(Question, pk=question_id)
try:
selected_choice = question.choice_set.get(pk=request.POST['choice'])
except (KeyError, Choice.DoesNotExist):
return render(request, 'polls/detail.html', {'question': question, 'error_message': '선택이 없습니다.'})
else:
selected_choice.votes += 1
selected_choice.save()
return HttpResponseRedirect(reverse('polls:index'))
위 내용은 vote에서 발생하는 에러를 처리한 것이다.
1. vote에서 아무 것도 처리 하지 않았을 때의 KeyError를 방지하기 위해 try~except로 처리해주었다.
2. vote에서 이미 삭제된 선택지를 선택했을 때의 DoesNotExist를 방지하기 위해 try~except로 처리해주었다. 사용자가 접속한 후에 선택지가 삭제되었고, 그 내용이 데이터베이스에 반영되기 전에 사용자가 선택을 먼저 했다면 발생할 수 있는 케이스이다.
# polls/urls.py
...
urlpatterns = [
path('', views.index, name='index'),
path('<int:question_id>/', views.detail, name='detail'),
path('<int:question_id>/vote/', views.vote, name='vote'), # 이 부분 추가
]
# polls/templates/polls/detail.html
<form action="{% url 'polls:vote' question.id %}" method="post">
{% csrf_token %}
<h1>{{ question.question_text }}</h1>
{% if error_message %}
<p><strong>{{ error_message }}</strong></p>
{% endif %}
{% 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>
7. 에러 방어하기 1
항상 브라우저가 정상동작 될 것이라고 생각해서는 안 된다. 무언가 오류로 서버와 정합성이 맞지 않는 데이터가 있을 수도 있다.
웹 페이지에서 선택지를 골라 Vote 버튼을 눌렀을 때, 발생 가능한 에러들을 상상해보고 그에 대해 방어할 수 있도록 코드를 추가했다. 위에서 DoesNotExist 방지를 위해 처리를 해주었는데, 그 이유는 앞서 말했듯이 브라우저를 로딩해서 선택을 하는 사이에 데이터가 삭제가 될 수 있기 때문이고, 또 다른 이유는 브라우저에서 올라간 데이터가 정합성이 맞지 않을 수도 있기 때문이다.
1) Vote를 누르는 사이에 테이블에서 Choice의 값이 사라지는 경우를 대비
2) 에러가 발생하여 존재하지 않는 엉뚱한 choice_id 값이 전달되는 경우를 대비 -> 추가로 잘못 전달된 choice_id의 값을 표시하기 위해서 아래와 같이 내용을 추가하였다.
# polls/views.py
def vote(request, question_id):
question = get_object_or_404(Question, pk=question_id)
try:
selected_choice = question.choice_set.get(pk=request.POST['choice'])
except (KeyError, Choice.DoesNotExist):
return render(request, 'polls/detail.html', {'question': question, 'error_message': f"선택이 없습니다. id={request.POST['choice']}"}) # 이 부분 추가!
else:
selected_choice.votes += 1
selected_choice.save()
return HttpResponseRedirect(reverse('polls:results', args=(question.id,))) # 이 부분 추가!
8. 에러 방어하기 2
실제로 사용하는 서버에서는 하나의 데이터베이스에 접속하는 여러 가지 서버를 운영한다. 따라서 여러 사람이 서로 다른 서버에서 동시에 하나의 Choice를 투표하는 상황도 발생할 수 있다. A라는 서버와 B라는 서버에서 동시에 값을 1증가시키라는 명령을 전달하는 경우, votes(총 득표 수)의 값은 원래대로라면 들어가야할 투표의 합계인 2가 아니라 1이 들어가는 에러가 발생할 수 있다. 이를 방지하기 위해 값을 1 증가시키는 연산을 서버가 아닌 데이터베이스에서 수행하도록 해야 한다.
A와 B가 모두 동시에 접속하여 vote를 했을 때, A서버와 B서버에서 각각 votes는 +1이 된다. 그럼 최종적으로 2가 되어야 하는데 1이 된다. 문제가 되는 부분은 `selected_choice.vote += 1` 이다. 메모리로 값을 읽어온 다음 증가되는 연산을 `selected_choice.vote += 1` 처럼 서버에서 하는 것이 아니라, `selected_choice.votes = F('votes') + 1` 처럼 데이터베이스에서 해야 한다. 그래야 각각의 서버에서 접속하여 동시에 요청이 들어오는 상황에 문제 없이 처리할 수 있다. F는 데이터베이스에서 읽어온 값을 사용하라는 것이다.
# polls/views.py
from django.urls import reverse
from django.db.models import F
def vote(request, question_id):
question = get_object_or_404(Question, pk=question_id)
try:
selected_choice = question.choice_set.get(pk=request.POST['choice'])
except (KeyError, Choice.DoesNotExist):
return render(request, 'polls/detail.html', {'question': question, 'error_message': f"선택이 없습니다. id={request.POST['choice']}"})
else:
# A서버에서도 Votes = 1
# B서버에서도 Votes = 1
selected_choice.votes = F('votes') + 1
selected_choice.save()
return HttpResponseRedirect(reverse('polls:index'))
9. 결과 조회 페이지
투표한 결과를 조회하는 화면을 만들도록 하겠다. 투표 결과를 조회하는 페이지를 만들기 위한 result 뷰와 result 템플릿의 코드는 아래와 같다. 그리고 추가적으로 필요한 코드의 내용은 vote 뷰에서 투표 결과를 데이터베이스에 저장하고, 이후 바로 결과 페이지로 연결될 수 있도록 리다이렉션을 설정해주는 과정이다. 이 때 args를 활용하여 question_id를 받아와서, 어떤 질문에 대한 결과인지를 표시해주도록 했다.
polls:result는 question.id를 주어야 하기 때문에 `return HttpResponseRedirect(reverse('polls:result', args=(question.id,)))` 처럼 작성했다. (polls:index는 아무런 인자도 주지 않아도 된다.)
# polls/views.py
from django.shortcuts import get_object_or_404, render
...
def vote(request, question_id):
...
else:
selected_choice.votes = F('votes') + 1
selected_choice.save()
return HttpResponseRedirect(reverse('polls:result', args=(question.id,))) # 수정!
def result(request, question_id):
question = get_object_or_404(Question, pk=question_id)
return render(request, 'polls/result.html', {'question': question})
# polls/templates/polls/result.html
<h1>{{ question.question_text }}</h1><br>
{% for choice in question.choice_set.all %}
<label> {{ choice.choice_text }} -- {{ choice.votes }} </label>
<br>
{% endfor %}
# polls/urls.py
from django.urls import path
from . import views
app_name = 'polls'
urlpatterns = [
path('', views.index, name='index'),
path('<int:question_id>/', views.detail, name='detail'),
path('<int:question_id>/vote/', views.vote, name='vote'),
path('<int:question_id>/result/', views.result, name='result'),
]
`*args`는 Python 함수에서 사용되는 파라미터(parameter)로, 함수가 호출되고 여러 개의 인자(argument)를 입력받는 상황에서 유연성을 높여주는 기능을 제공한다. 기존의 방식대로 함수에 여러가지 인자들을 전달하려면, 함수를 선언하고 괄호안에 전달되는 인자의 개수에 맞춰서 여러가지 파라미터들을 작성해주어야 했다. 하지만 *args를 사용하면 보다 간편하고 유연하게 함수에 여러가지 인자가 전달되는 상황을 처리할 수 있다.
`**kwargs`는 Python 함수에서 사용되는 파라미터(parameter)의 하나로, 함수가 호출될 때 여러 개의 키워드 인자(keyword argument)를 받을 수 있도록 기능을 제공한다. 여기서 키워드 인자란 인자의 값이 key값과 value값으로 구분되어 있는 경우를 의미한다. 이 때, key값에 해당하는 것이 변수명과 같은 역할을 담당하며, value는 그 변수의 값에 해당한다.
10. 장고 어드민의 편집 페이지 커스터마이징
폼은 Create, Read, Update, Delete (CRUD) 작업을 수행할 수 있게 지원한다. 장고 어드민 페이지에서 Question이나 Choice의 내용을 편집할 때, 그 기능을 보완하기 위해서 몇 가지 클래스들을 추가하여 커스터마이징할 수 있다.
현재는 Question과 Choice가 따로 페이지가 있는데, 이 둘을 한 번에 볼 수 있도록 작업을 했다. class ChoiceInline을 QuestionAdmin에서 작동하도록 `inlines = [ChoiceInline]`을 추가해주었다. 이 클래스를 활용하여 Question 모델을 편집하면서 Choice 모델도 함께 편집할 수 있다.
TabularInline은 테이블 형태로 데이터를 나란히 표시하며, 관련 모델의 데이터를 추가, 편집 및 삭제할 수 있는 방법을 제공한다.
extra 변수는 각 Question마다 보여줄 Choice의 수를 지정한다.
collapse 클래스가 적용된 생성일(pub_date)은 필드의 내용을 감추거나 보일 수 있는 옵션이 존재한다.
# polls/admin.py
from django.contrib import admin
from .models import Choice, Question
admin.site.register(Choice)
class ChoiceInline(admin.TabularInline):
model = Choice
extra = 3 # 항상 3개의 빈 공간
class QuestionAdmin(admin.ModelAdmin):
fieldsets = [
('질문 섹션', {'fields': ['question_text']}),
('생성일', {'fields': ['pub_date'], 'classes': ['collapse']}), # 'classes': ['collapse'] 입력하면 해당 필드가 숨김처리 된다.
]
readonly_fields = ['pub_date'] # 편집 불가능, 오직 읽기만 가능
inlines = [ChoiceInline]
admin.site.register(Question, QuestionAdmin)
11. 장고 어드민의 목록 페이지 커스터마이징
Question에서 한 컬럼에 제목과 날짜가 한 줄로 나오는 것이 아니라, 컬럼마다 제목과 날짜가 분리되어 나오도록 할 수 있다.
@admin.display 데코레이터는 모델 필드가 표시되는 방식을 사용자가 지정할 수 있도록 하는 기능을 제공한다. @admin.display 데코레이터를 사용하여, was_published_recently 메서드를 장고 어드민 페이지에서 사용할 수 있도록 models.py을 수정했다. 여기에서 boolean을 True로 설정하여 해당 필드가 boolean 값으로 표시되도록 지정했고, description='최근생성(하루기준)' 옵션은 메서드의 레이블을 지정한다.
# polls/models.py
import datetime
from django.db import models
from django.utils import timezone
from django.contrib import admin
class Question(models.Model):
question_text = models.CharField(max_length=200, verbose_name='질문') # verbose? verbose_name?
pub_date = models.DateTimeField(auto_now_add=True, verbose='생성일')
@admin.display(boolean=True, description='최근생성(하루기준)')
def was_published_recently(self):
return self.pub_date >= timezone.now() - datetime.timedelta(days=1)
def __str__(self):
return f'제목: {self.question_text}, 날짜: {self.pub_date}'
# polls/admin.py
from django.contrib import admin
from .models import Choice, Question
#admin.site.register(Choice)
class ChoiceInline(admin.TabularInline):
model = Choice
extra = 3
class QuestionAdmin(admin.ModelAdmin):
fieldsets = [
('질문 섹션', {'fields': ['question_text']}),
('생성일', {'fields': ['pub_date'], 'classes': ['collapse']}),
]
list_display = ('question_text', 'pub_date', 'was_published_recently')
readonly_fields = ['pub_date']
inlines = [ChoiceInline]
list_filter = ['pub_date']
search_fields = ['question_text', 'choice__choice_text']
admin.site.register(Question, QuestionAdmin)
💡 배운 점
Django의 뷰와 템플릿, 폼에 대해서 배웠다. 그리고 이를 커스터마이징 하는 것에 대해 배웠다.
🔖 잘한 것과 잘못한 것
- 코드를 따로 작성하여 설명을 적으니 이해가 더 쉬워졌다.
- 하지만 실습 준비가 안 되어 강의만 보고 실습을 직접 하지 못하니 아쉽다.
📝 남아있는 의문과 개선점
- 실제로 실습을 하면서 잘 작동되는지 확인해야겠다.
- [11번 (26강)] models.py의 Question 클래스에서 verbose랑 verbose_name이랑 같은 것인지?
☁️ 소감
강의를 하나 하나 보면서 이제 무엇을 하고자 하는 지에 대해서는 이해를 했지만, 코드를 완벽히 이해하지는 못 했다. 아직은 그저 따라하는 수준인 것 같다. 실습을 하면서 직접 작동되는 것을 보면 더 빨리 습득할 것 같은데 아직 실습 준비가 안 되어 아쉽다. 한 번 복습은 필요할 것 같다.