django framework 시작하기

이번시간에는 django라는 full-stack framework에 대해 간단하게 알아보겠습니다. 본 article은 reader의 컴퓨터에 python과 mysql이 정상적으로 연동되어 운영되고 있는 환경의 가정하고 씌여졌습니다.

Install

우선 django를 쓰려면 설치가 우선적으로 되어야하겠죠?

pip install Django

설치가 되었으면 버젼을 확인합니다. command not found에러가 나면 /usr/local/bin에 pip이 없는것이니 ln -s /usr/local/bin/pip3 /usr/local/bin/pip을 실행하여 링크를 만들어준다.

$ python -m django --version
3.2.15

이제 django-adminstartproject옵션을 통해서 새로운 프로젝트를 하나 생성해볼게요. django-admin은 django framework을 손쉽게 관리할수 있도록 제공된 툴입니다.

startproject

django-admin startproject tutorial

위의 명령어를 실행하면 tutorial라는 폴더가 생성되고 그 안에 아래와 같은 폴더 구조가 생겼을 거에요.

혹시 위의 명령을 실행하는데 command not found에러가 났다면, /usr/local/bin폴더에 django-admin이 없는 것이니, ln -s /Library/Frameworks/Python.framework/Versions/3.7/bin/django-admin /usr/local/bin/django-admin를 실행하여 심볼릭 링크를 만들어 준 후에 다시 실행해보세요.

tutorial/
    manage.py
    print/
        __init__.py
        settings.py
        urls.py
        asgi.py
        wsgi.py

root폴더명은 맘대로 바꾸셔도 되요.

manage.py는 django-admin랑 basically같은 건데요, django-admin가 system path가 추가되어있지 않은경우에 직접 호출해서 사용할수 있습니다. 아래의 3개 명령은 모두 같은 명령입니다.

$ django-admin <command> [options]
$ manage.py <command> [options]
$ python -m django <command> [options]

__init__.py는 해당 폴더를 package에 추가하기위해서 필수적으로 있어야하는 파일입니다. 이건 django 뿐만 아니라 python에서 정한 규칙이라서 해당 폴더안의 파일을 다른 파일에서 불러 쓰려면 꼭 있어야해요.

settings.py은 각종 설정값들을 저장하는 용도이고, urls.py은 서비스의 모든 route를 저장하고 있는 파일입니다. 그리고 asgi.py는 ASGI를 사용하기위한 entry-point이고, wsgi.py는 WSGI를 사용하기 위한 entry-point입니다. 얘네들은 나중에 접속자가 많아졌을때 성능을 좋게 해줄수 있는 기능을 제공하는 서비스들인데 설정이 조금 복잡하기도 하고해서 지금은 일단 넘어가고 나중에 깊이있게 다루도록 하겠습니다.

이제 서비스를 한번 띄워볼까요?

$ python manage.py runserver
Watching for file changes with StatReloader
Performing system checks...

System check identified no issues (0 silenced).

You have 18 unapplied migration(s). Your project may not work properly until you apply the migrations for app(s): admin, auth, contenttypes, sessions.
Run 'python manage.py migrate' to apply them.
January 10, 2022 - 03:37:06
Django version 3.2.11, using settings 'print.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

위의 명령을 실행하면 서비스가 뜨고, 프로그램에서 남기는 로그들을 보여줍니다. 서비스는 Ctrl+C를 쳐서 끝낼수 있습니다.

서비스가 잘 떴는지 http://127.0.0.1:8000/를 브라우저에 치고 들어가서 확인해볼까요? 성공적으로 실행이 되면 아래와 같은 페이지가 뜹니다.

아직 프로젝트 폴더에 디자인등 보여줄게 아무것도 없어도 django framework자체에서 이렇게 예쁜 화면을 보여준답니다.

startapp

이렇게 만들어진 framework에 투표를 할수 있는 poll 서비스를 만들어 붙여볼게요.

$ cd tutorial
$ python manage.py startapp polls

위의 명령어를 실행하면 아래와 같은 폴더와 파일들이 생성됩니다.

polls/
    __init__.py
    admin.py
    apps.py
    migrations/
        __init__.py
    models.py
    tests.py
    views.py

views.py를 열어서 아래와 같이 화면에 보여줄 메세지를 넣어주세요. 이 파일은 손님들이 poll에 접속했을때 보여줄 화면을 정하는 곳이랍니다. poll이라는 app에 접속하면 index는 보통 폴더path를 쳤을때 기본적으로 보여주는 파일명이자나요. 그런 의미에서 index라고 함수명을 적어주고 거기에서 “Hello, world. You’re at the polls index.”를 출력해서 보여줄게요. django에서 제공하는 HttpResponse라는 함수를 이용해서 rendering을 할거에요. HttpResponse를 아래와 같이 import한 후에 함수를 호출해주세요.

from django.http import HttpResponse

def index(request):
    return HttpResponse("Hello, world. You're at the polls index.")

view파일에 index함수를 만들었다고 django가 자동으로 folder path를 쳤을때 위의 메세지를 보여주는건 아니구요. 아까 project를 생성할때 설명드렸던 route를 관리하는 urls.py파일 기억하시죠? 그 파일에 poll폴더의 path를 등록해야 해당 route가 서비스로서 기능을 해요. urls.py를 열어서 원래 생성되었던 admin이라는 route위에 polls라는 route를 추가할게요.

from django.contrib import admin
from django.urls import usls, path

urlpatterns = [
    path('polls/', include('polls.urls')),
    path('admin/', admin.site.urls),
]

여기서 제가 include라는 함수를 썼는데요. polls라는 app안에 route를 관리하는 또다른 urls.py를 두고 싶을때 그걸 여기에서 불러다가 추가시키는 거에요. 여기서 사용한 polls.urls는 사실 폴더를 package구조로 생각해서 변형한거라서 실은 polls/urls.py파일을 include시킨거랍니다. urls.py를 root에서 죄다 관리하는게 아니라 app별로 쪼개서 관리하면 훨씬 심플하고 읽기도 편리하겠죠? 근데 여기서 왜 admin.site.urls은 include함수 없이 그냥 갖다 쓴거죠? 하고 궁금하실텐데요. 그건 admin.site.urls는 django에서 얘만 유일하게 예외적으로 그냥 갖다 쓸수 있게 만들었어요. 특별대우인데 왜 특별대우를 받는지는 앞으로 차차 설명해드릴게요. 혹시 원래 코드와 차이점을 보셨는지 모르겠는데 include함수를 쓰기 위해서 from django.urls import include가 추가되었습니다. 다시 말씀드리지만 어떤 함수든 Python기본 함수가 아니면 패키지에서 import를 해주어야합니다.

polls/urls.py를 include시키려면 해당 파일을 만들어서 정한 곳에 갖다놔야겠죠? polls폴더 안에 urls.py를 생성하고 아래와 같이 urlpatterns이라는 이름의 배열을 하나 정의해주세요. 그리고 그 안에 views.index를 보여주도록 합니다.

from django.urls import path

from . import views

urlpatterns = [
    path('', views.index, name='index'),
]

urlpatterns이라는 이름은 route를 나열해놓는 배열의 이름인데 이건 django에서 정한 예약어입니다. urls.py에 이 urlpatterns라는 배열이 정의되어있지 않으면 나중에 실행할때 형식에 맞지 않는다고 에러가나요. 이게 바로 framework을 사용하는 이유겠죠? 모든 개발자가 같은 변수명과 같은 규칙을 따르게 해서 나중에 어떤 개발자가 코드를 읽더라도 손쉽게 이해할수 있게 말이죠.

일단 route에 polls를 추가했으니까 view에서 정한대로 메세지가 나오는지 한번 서비스를 실행해볼까요?

python manage.py runserver

서비스가 문제없이 떴다면 http://localhost:8000/polls/를 열어보세요.

site domain에 접속하면 우선 root folder의 urls.py를 찾게 되고 거기서 include한 polls/urls.py을 호출하게 됩니다. polls/urls.py에서는 urlpatterns라는 배열에 path라는 함수를 이용해서 하나의 route을 추가했어요.

polls/urls.py에서 사용한 path라는 함수는 3개의 인자를 받습니다. 그 첫번째가 route인데요, 여기서는 empty string이죠? 그 말은 해당 폴더의 root를 의미합니다. 그리고 두번째 인자는 화면에 보여줄 view인데 값이 views.index라고 되어있는데, views.py파일안의 index함수를 호출하라는 의미입니다. 마지막으로 name이라는 인자가 있는데 이는 해당 route에 별칭을 두어 django framework어디에서나 해당 route를 이 별칭으로 불러다 쓸수 있는 유용한 기능이에요. route에 할당한 이름은 template에서 링크를 걸때 매우 편리하게 쓸수 있어요.

자 이제 Database를 연동해볼게요. 프로젝트생성시 만들어졌던 settings.py를 열어보세요. 그 안에 보시면 DATABASE라는 변수가 선언되어 있는데요.

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}

django는 기본적으로 SQLite를 database로 사용합니다. 다른 database를 사용하고자 할때는 ENGINE의 값을 바꿔주시는데요. 현재는 ‘django.db.backends.sqlite3’로 되어있지만 ‘django.db.backends.postgresql’, ‘django.db.backends.mysql’이나 ‘django.db.backends.oracle’등 다른 엔진도 지원합니다. 그밖에 다른 engin은 여기서 확인하세요. 그리고 NAME에는 database의 이름을 적으면 되는데요. SQLite은 파일의 형태로 저장되기때문에 파일의 절대경로와 확장자를 포함한 파일명을 적어주었어요. MySQL을 사용한다면 DB명 이외에 접속에 필요한 USER나 PASSWORD등을 정의해주어야해요.

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'tutorial',
        'USER': 'tutorial',
        'PASSWORD': 'PassWord123!!!',
        'HOST': '127.0.0.1',
        'PORT': '3306',
    }
}

ENGINE에 대한 자세한 사항은 여기를 참고해주세요.

기왕에 settings.py를 열었으니까 잠시 서비스 timezone을 맞추고 갈게요. 현재는 UTC로 설정이 되어있는데 여러분이 서비스할 지역의 시간으로 해야 읽기 편리하겠죠. 한국은 KST로 하면 될거 같아요. Korea Standard Time. 저는 뉴욕시간으로 정보를 저장하도록 설정하겠습니다.

TIME_ZONE = 'EST'

그리고 settings.py파일 맨 위에 보시면 INSTALLED_APPS라는 변수가 있어요. 이게 현재 프로젝트에 사용될 django의 application들을 나열해 놓은건데요. 다른 application을 추가할수도 있고 사용하지 않는건 제거하셔도됩니다. 하지만 기본적으로 추가된 application들이 유용하게 쓰일수 있으니까 일단 이대로 갈게요.

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

위의 유틸중에 어떤 애들은 database table사용해서 관리하는 경우가 있어요. 그래서 위의 유틸들을 정의한 뒤에 database migration을 반드시 해주어야합니다. 그래야 정의된 유틸에 따라 필요한 table을 database에 생성을 하거든요.

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying sessions.0001_initial... OK

위의 migrate명령어를 실행하면 다음과 같이 유틸사용에 필요한 table들을 자동으로 생성합니다.

다시 말씀드리지만 해당 table들은 INSTALLED_APPS에 나열된 유틸기반으로 생성되기때문에 추가 table을 원하지 않으시는 경우에는 유틸사용을 하지 않으시면 됩니다.

migrate명령어를 실행했을때 table들이 잘 생성되었다면 데이타베이스 접속은 원활하게 잘되고 있다는 뜻입니다. 이제 데이타베이스에 데이타를 연동할 model을 만들어 볼게요. polls/model.py를 열어서 아래와 같이 코딩해주세요.

from django.db import models

class Question(models.Model):
    question_text = models.CharField(max_length=200)
    pub_date = models.DateTimeField('date published')

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

위의 코드를 설명하자면요. 일단 django.db에서 제공하는 models를 import합니다. 왜냐면 각 테이블에 매칭되는 데이타들을 models.Model을 기반으로 만들거거든요. 무슨말이냐면 테이블의 칼럼들은 테이블마다 다양하지만 models.Model클래스를 엄마 클래스로 사용하여 models.Model가 제공하는 모든 기능을 사용하겠다는 거에요.

위의 코드에서 정의한 Question클래스를 보세요 옆에 괄호안에 models.Model를 넣어서 엄마클래스로 지정했지요. 그리고 추가로 2개의 클래스변수를 선언했습니다. 하나는 Char로 하나는 Datetime으로 정의했네요.

그리고 또 하나의 클래스 Choice도 보시면 마찬가지로 models.Model를 확장해서 만들겠다고 선언했어요. 그리고 3개의 멤버변수를 추가했습니다. Question과의 관계를 정의하는 ForeignKey하나와, 답변문장을 저장할 Char필드, 그리고 몇명이 선택했는지 저장할 votes라는 변수를 Integer로 선언했네요.

이제 이렇게 선언한 데이타베이스정보를 실제 데이타베이스에 적용할 차례에요. 방금전에 INSTALLED_APPS에 나열된 앱들을 사용하기 위해 migrate을 했는데 테이블들이 자동으로 생성되었자나요. 지금 우리가 만드는 polls라는 앱도 똑같이 취급하면 됩니다. INSTALLED_APPS에 앱을 등록하고 migrate을 실행하면 우리가 정의한 테이블이 자동으로 생성이 되는거에요. 그런데 여기서 우리가 만든 polls라는 앱을 application으로 인식하게 해주는 것이 바로 apps.py안에 정의된 PollsConfig클래스 입니다. 이 파일은 아까 우리가 startapp을 실행할때 자동으로 생성된건데 안에 보면 해당 앱을 django에서 applicatioin인식하도록 코딩이 이미 되어있어요.

from django.apps import AppConfig


class PollsConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'polls'

이렇게 정의된 application을 INSTALLED_APPS에 아래와 같이 등록해주시면 됩니다. settings.py를 여시고 INSTALLED_APPS맨위에 PollsConfig를 full path와 함께 넣어줍니다.

INSTALLED_APPS = [
    'polls.apps.PollsConfig',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

이제 django가 polls라는 앱이 서비스에 추가되었다는 걸 알게 되었어요. 이제 테이블 migration을 해야겠죠? table을 생성하기에 앞서 model을 만들거에요. django가 작업하기 쉬운 형태로 table을 정의하는거죠.

$ python manage.py makemigrations polls
Migrations for 'polls':
  polls/migrations/0001_initial.py
    - Create model Question
    - Create model Choice

위와 같이 명령어를 실행하면 polls/migrations/0001_initial.py파일이 생성되고 그 안에 CreateModel로 두개의 model이 정의되어 있습니다. 실제 database에 migration을 하기에 앞서 위에 생성된 0001파일을 이용해서 Create table 스크립트를 만들어서 한번 볼게요.

$ python manage.py sqlmigrate polls 0001
BEGIN;
--
-- Create model Question
--
CREATE TABLE "polls_question" (
    "id" serial NOT NULL PRIMARY KEY,
    "question_text" varchar(200) NOT NULL,
    "pub_date" timestamp with time zone NOT NULL
);
--
-- Create model Choice
--
CREATE TABLE "polls_choice" (
    "id" serial NOT NULL PRIMARY KEY,
    "choice_text" varchar(200) NOT NULL,
    "votes" integer NOT NULL,
    "question_id" integer NOT NULL
);
ALTER TABLE "polls_choice"
  ADD CONSTRAINT "polls_choice_question_id_c5b4b260_fk_polls_question_id"
    FOREIGN KEY ("question_id")
    REFERENCES "polls_question" ("id")
    DEFERRABLE INITIALLY DEFERRED;
CREATE INDEX "polls_choice_question_id_c5b4b260" ON "polls_choice" ("question_id");

COMMIT;

위와 같이 sqlmigrate을 이용하면 실제 table을 생성하지 않은 상태에서 create스크립트를 볼수 있어요. Create table스크립트를 확인하셨고 문제가 발견되지 않았다면 실제 database에 테이블을 생성할 차례입니다.

$ python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, polls, sessions
Running migrations:
  Applying polls.0001_initial... OK

database에 들어가서 확인해보니 polls_choice와 polls_question이라는 테이블이 만들어 졌어요. migrate명령어는 실행할때마다 그동안 정의한 모든 스크립트를 다시 실행하는게 아니고요. 새로 만들어서 아직 실행되지 않은 파일만 실행해줍니다. 그러니까 테이블을 수정해야할 일이 있으면 그 전에 만들었던 Create table파일을 수정하시면 안되고요. 새롭게 Alter table스크립트를 만들어서 적용하셔야해요. 물론 Alter table스크립트도 model변경시 자동으로 만들어 지기때문에 편리하게 테이블을 관리할수 있어요.

이쯤에서 우리가 만든 model을 django API를 이용해서 살펴볼까요? django에서 제공하는 shell을 아래 명령어를 실행하여 열면 코드를 입력할수 있는 프롬프트가 뜹니다.

API shell

$ python manage.py shell
Python 3.7.4 (v3.7.4:e09359112e, Jul  8 2019, 14:54:52)
[Clang 6.0 (clang-600.0.57)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
(InteractiveConsole)
>>> 

여기에 우리가 방금 만든 model들을 import해볼게요

>>> from polls.models import Choice, Question

그리고 Question에 있는 모든 데이타를 가져옵니다

>>> Question.objects.all()
<QuerySet []>

아직 테이블 안에 아무것도 없으니까 비어있겠죠. 데이타를 넣어봅시다. 우리가 아까 선언한 Question model을 잠시 다시 살펴보면 pub_date라는 칼럼이 있어요. 여기에 시간데이타를 넣으려면 우리가 시간을 알아야겠죠. 현재 시간을 알아올수 있는 함수는 timezone에서 제공합니다. 일단 timezone을 import할게요.

>>> from django.utils import timezone

이 다음에는 Questions model클래스를 객체화해서 정의한 변수에 실제 값들을 넣습니다. 질문내용과 출간시간을 아래와 같이 넣을게요.

>>> q = Question(question_text="What's new?", pub_date=timezone.now())

그리고 나서 이 model을 실제 database에 적용합니다.

>>> q.save()

적용하고 나면 model에 원래는 없었던 id가 생깁니다. database에서 q를 저장한뒤 자체적으로 생성한 id를 q.id에 저장해줌으로써 저장결과도 알려주고 필요한데다 갖다 쓰라는거죠.

>>> q.id
1

우리가 넣은 데이타가 잘 들어갔는지 살펴볼까요?

>>> q.question_text
"What's new?"
>>> q.pub_date
datetime.datetime(2022, 1, 10, 13, 27, 23, 742673, tzinfo=<UTC>)

아주 잘 들어갔네요. 이번에는 내용을 수정해볼까요? 해당객체의 question_text변수의 값을 바꾸고 저장을 하면 이번에는 q.id에 값이 들어가 있기때문에 새로운 record를 생성하는게 아니라 기존에 record를 수정하게 됩니다.

>>> q.question_text = "What's up?"
>>> q.save()

question에 저장된 데이타를 전부다 꺼내볼까요?

>>> Question.objects.all()
<QuerySet [<Question: Question object (1)>]>

처음에는 비어있던 테이블이 이제는 Question이라는 model이 배열방에 들어가있네요. 근데 우리가 입력한 데이타는 안보여주고 Question: Question object (1)라고 보여주네요. 이렇게 부르면 우리가 입력했던 질문을 보여주면 좋겠죠? model.py를 열어서 Question과 Choice클래스에 __str__이라는 함수를 아래와 같이 추가해주세요. 기존에 변수는 그대로 놔주고 밑에 함수만 추가하는거에요.

from django.db import models

class Question(models.Model):
...
    def __str__(self):
        return self.question_text

class Choice(models.Model):
...
    def __str__(self):
        return self.choice_text

이렇게 하면 Object를 보여줄때 내용을 보여주게 되거든요. Shell을 다시 실행하셔야 변경된 내용이 적용되어 보여집니다.

$ python manage.py shell
>>> from polls.models import Choice, Question
>>> Question.objects.all()
<QuerySet [<Question: What's up?>]>

이번에는 Question클래스에 해당 Poll이 최근에 만들어 진건지 알아보는 함수를 추가해볼게요.

import datetime

from django.db import models
from django.utils import timezone


class Question(models.Model):
...
    def was_published_recently(self):
        return self.pub_date >= timezone.now() - datetime.timedelta(days=1)

파일을 저장하고 쉘을 다시 실행해볼게요.

python manage.py shell

우선 만든 model들을 import하고요

>>> from polls.models import Choice, Question

저장된 Question을 모두 가져옵니다.

>>> Question.objects.all()
<QuerySet [<Question: What's up?>]>

이번에는 filter 함수를 이용해서 검색을 해볼까요? 고유번호가 1인것을 가져옵니다.

>>> Question.objects.filter(id=1)
<QuerySet [<Question: What's up?>]>

고유번호가 2인걸로 검색을 해볼까요? 현재 우리가 넣은 데이타가 1개이기때문에 2번 데이타는 없죠. 하지만 에러는 나지 않고 빈 배열을 넘겨줍니다.

>>> Question.objects.filter(id=2)
<QuerySet []>

이번에는 마찬가지로 filter함수를 이용해서 질문으로 검색해볼게요. 질문이 What으로 시작하는 Question을 검색해볼까요?

>>> Question.objects.filter(question_text__startswith='What')
<QuerySet [<Question: What's up?>]>

이번에는 get함수를 이용애서 올해 만들어진 질문을 가져와볼게요

>>> from django.utils import timezone
>>> current_year = timezone.now().year
>>> Question.objects.get(pub_date__year=current_year)
<Question: What's up?>

이번에는 마찬가지로 get함수를 이용해서 id로 질문을 가져와볼까요?

>>> Question.objects.get(id=1)
<Question: What's up?>

없는 데이타를 가져오려고 하면 어떻게 될까요?

>>> Question.objects.get(id=2)
Traceback (most recent call last):
...
polls.models.Question.DoesNotExist: Question matching query does not exist.

get함수는 없는 데이타를 가져오려고 시도하면 에러가 납니다.

pk로도 가져올 수 있어요.

>>> Question.objects.get(pk=1)
<Question: What's up?>

이번에는 가져온 데이타를 객체에 담아서 해당객체의 함수를 호출해볼게요

>>> q = Question.objects.get(pk=1)
>>> q.was_published_recently()
True

이번에는 질문에 연계된 선택지를 불러와볼까요. 1번 질문을 가져와서 해당 질문의 선택지 전체를 보도록합니다.

>>> q = Question.objects.get(pk=1)
>>> q.choice_set.all()
<QuerySet []>

결과 set은 당연히 비어있겠죠. 우리가 아무것도 할당하지 않았잖아요. 그럼 이제 질문객체의 choice_set에 선택지를 하나 생성해볼게요.

>>> q.choice_set.create(choice_text='Not much', votes=0)
<Choice: Not much>

생성후에는 결과로 객체를 보여줍니다. 아까 우리가 __str__에 정의한대로 선택지를 문자열로 보여주네요.

선택지가 하나만 있으면 안되니까 몇개 더 만들어 볼까요? 만들면서 생성된 Choice객체를 받아서 한번 살펴볼게요.

>>> q.choice_set.create(choice_text='The sky', votes=0)
<Choice: The sky>
>>> c = q.choice_set.create(choice_text='Just hacking again', votes=0)

Choice model에 question이라는 멤버변수는 Question model을 ForeignKey로 가지고 있습니다.

>>> c.question
<Question: What's up?>

자, 그러면 지금까지 생성한 Choice들이 잘 들어갔는지 1번 Question객체의 choice_set을 한번 들여다볼까요?

>>> q.choice_set.all()
<QuerySet [<Choice: Not much>, <Choice: The sky>, <Choice: Just hacking again>]>

Choice도 마찬가지로 filter함수를 이용하여 검색이 가능합니다. 아래는 올해 출간한 Choice들입니다.

>>> Choice.objects.filter(question__pub_date__year=current_year)
<QuerySet [<Choice: Not much>, <Choice: The sky>, <Choice: Just hacking again>]>

이번에는 Choice 문자열로 검색을 해볼까요?

>>> c = q.choice_set.filter(choice_text__startswith='Just hacking')
>>> <QuerySet [<Choice: Just hacking again>]>

가져온 Choice를 삭제해볼게요

>>> c.delete()
(1, {'polls.Choice': 1})
>>> q.choice_set.all()
<QuerySet [<Choice: Not much>, <Choice: The sky>]>

삭제한 뒤에 Question객체의 choice_set을 보니까 두개밖에 남지 않았습니다.

createsuperuser

django shell을 exit()로 종료하시고, 이번에는 어드민계정을 생성해볼게요.

$ python manage.py createsuperuser
Username (leave blank to use 'slim'): admin
Email address: myemail@gmail.com
Password:
Password (again):
Superuser created successfully.

계정을 생성했으면 서버를 띄워볼게요.

$ python manage.py runserver

브라우저를 열고 http://127.0.0.1:8000/admin/를 열어보세요. 로그인 화면이 나오면 방금 입력한 id와 비번을 치고 들어가세요.

Django admin login screen

로그인 하시면 아래와 같은 관리자페이지가 보이실거에요. 안전하게 로그인 할수 있는 사용자를 등록할수 있고, 사용자에게 특정권한을 부여할수도 있습니다.

Django admin index page

이제 지금까지 우리가 만든 Poll서비스를 여기 관리자페이지에서 관리할수 있도록 만들거에요. 그동안 Shell로 등록해야해서 불편했는데 GUI로 관리할수 있으니 너무 좋겠어요. 우선 admin에 서비스를 등록하려면 polls/admin.py파일을 생성해야해요. 파일에 django가 제공하는 admin클래스를 import해서 site에 Question을 아래와 같이 등록해주면 됩니다.

from django.contrib import admin

from .models import Question

admin.site.register(Question)

이 상태에서 http://127.0.0.1:8000/admin을 열어보세요.

Questions을 클릭하시면 아래와 같이 질문목록이 보이고 여기서 새 질문을 생성하기도하고 삭제도 하고 하면서 편리하게 관리하실수 있어요.

일단 여기서는 간단하게 admin 페이지에서 기본적인 관리가 된다는것만 보고 실제 view를 만들어 보도록할게요. MVC는 뭔지 다 아시죠? 간단하게 설명하자면 Model View Controller의 줄임말인데요. Model은 데이타베이스를 반영한 클래스이고 클래스안에 데이타 관련한 함수가 들어가기도 해요. 대부분 Model은 데이타베이스의 테이블과 똑같은 항목을 가지는데요 그게 항상 그렇지도 않아요. 운영하다가 DB 테이블은 안바꾸고 Model만 바꿔서 쓰기도 하거든요. 하지만 새로 구성하는 서비스라면 Model이랑 테이블이 같으면 덜 헷갈리고 좋죠. Model이 순수하게 저장되는 데이타를 표현한 것이라면, View는 화면에 보여주는 역할을 담당해요. 보통 HTML템플릿을 별도에 파일에 만들어서 View클래스에서 불러다가 가져온 데이타를 rendering하는 거죠. 그리고 얘네둘 중간에 Controller가 있습니다. Controller는 Model을 가져다가 데이타를 불러 담고 비지니스 로직에 맞춰 데이타를 지지고 볶고 하는 애에요. Framework에 따라서 View를 불러주기도 해요.

현재 우리가 만드는 poll서비스의 view파일에는 index함수 하나만있어요. urls.py에 정의된 대로 /polls/가 호출되었을때 보여주는 화면이죠. View파일에 아래 3개의 함수를 추가로 넣어볼게요.

def detail(request, question_id):
    return HttpResponse("You're looking at question %s." % question_id)

def results(request, question_id):
    response = "You're looking at the results of question %s."
    return HttpResponse(response % question_id)

def vote(request, question_id):
    return HttpResponse("You're voting on question %s." % question_id)

View가 추가되면 해당 뷰를 호출해줄 route를 polls/urls.py에 명시해주어야합니다.

from django.urls import path

from . import views

urlpatterns = [
    path('', views.index, name='index'),
    path('<int:question_id>/', views.detail, name='detail'),
    path('<int:question_id>/results/', views.results, name='results'),
    path('<int:question_id>/vote/', views.vote, name='vote'),
]

코드위에 주석에서 보시다시피 브라우저에서 /polls/1을 호출하면 1번 Question을 화면에 보여주어야겠죠? 현재는 View에 정의된 대로 Question번호를 보여주고 있습니다. 이제 poll서비스를 열면 데이타를 가져와서 Question목록을 보여주고 그중 하나를 치고 들어가면 상세하게 보여주도록 한번 해볼게요.

우선 index에 들어간 메세지대신 질문 목록을 보여주도록 하겠습니다. 목록을 보여주기 위해서는 일단 목록디자인을 정의한 템플릿파일이 있어야되요. /polls/templates/polls/index.html이라는 파일을 생성해서 그 안에 템플릿을 아래와 같이 저장해 주세요.

{% if latest_question_list %}
    <ul>
    {% for question in latest_question_list %}
        <li><a href="/polls/{{ question.id }}/">{{ question.question_text }}</a></li>
    {% endfor %}
    </ul>
{% else %}
    <p>No polls are available.</p>
{% endif %}

위의 파일은 latest_question_list라는 배열변수를 받아서 목록으로 보여주고, 해당 아이템은 상세페이지로 가는 링크를 걸어줍니다. 만약 latest_question_list라는 배열이 비었다면 No polls are available.이라는 메세지를 보여주는 템플릿입니다.

그리고 위의 템플릿에서 사용할 변수값, latest_question_list을 views.py에서 정의해주어야합니다. polls/views.py에 있는 index함수를 아래와 같이 수정해주세요. 변경에 따른 라이브러리 import도 다 수정해주셔야합니다.

from django.http import HttpResponse
from django.template import loader

from .models import Question


def index(request):
    latest_question_list = Question.objects.order_by('-pub_date')[:5]
    template = loader.get_template('polls/index.html')
    context = {
        'latest_question_list': latest_question_list,
    }
    return HttpResponse(template.render(context, request))

이 파일에서는 polls의 index를 열었을때 Question데이타를 pub_date순으로 가져와서 우리가 위에서 생성한 템플릿 파일, index.html을 가져와서 rendering을 합니다. rendering을 할때 context라는 dict를 넘겨주게 되는데 여기서 key/value로 저장되는 값들이 템플릿에서는 변수명과 그 값으로 변환됩니다.

파일들을 저장 하셨다면 이제 브라우저를 열어서 http://127.0.0.1:8000/polls/를 확인해보세요.

우리가 view의 index에 명시한대로 모든 Question을 가져와서 템플릿에 디자인한 대로 ul/li태그에 보여줍니다.

404 Error

위의 화면에서 Question의 link를 클릭하면 상세화면으로 가는데요. 이때 해당 Question이 없다면 우리는 404 Error를 보여주어야 하겠습니다. 아래와 같이 views.py파일의 detail함수를 수정해 주세요.

from django.http import Http404, HttpResponse
from django.shortcuts import render
from django.template import loader

from .models import Question
...
def detail(request, question_id):
    try:
        question = Question.objects.get(pk=question_id)
    except Question.DoesNotExist:
        raise Http404("Question does not exist")
    return render(request, 'polls/detail.html', {'question': question})

주어진 question_id로 Question을 조회해와서 detail.html라는 템플릿을 통해서 화면에 출력하고 있습니다. 특히 여기서는 render함수에 템플릿 경로와 사용될 데이타를 넘겨줌으로써 간편하게 rendering을 하고 있습니다. 이제 그러면 detail.html도 정의를 해야겠네요. /polls/templates/polls/detail.html이라는 파일을 생성하고 심플하게 그 안에서 해당 Question을 출력해줘볼게요.

{{ question }}

http://127.0.0.1:8000/polls/1/을 열어보면 이제 아래와 같이 Question의 text를 보여줍니다.

그리고 없는 Question을 열어보면, http://127.0.0.1:8000/polls/4/ 아래와 같이 404에러를 발생시킵니다.

위의 화면에서 보듯이 project root의 settings.py라는 파일안에 DEBUG변수의 설정값이 True로 되어있어서 개발자 모드로 에러에 대해 더욱 자세히 보여주고 있습니다.

get_object_or_404()

django에서는 404를 더욱 간단하게 보여주도록 제공하는 함수가 또 있습니다. views.py의 detail함수를 아래와 같이 수정해 주세요.

from django.shortcuts import get_object_or_404, render

from .models import Question
...
def detail(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    return render(request, 'polls/detail.html', {'question': question})

get_object_or_404를 이용하면 try/except코드를 사용하지 않아도 해당 데이타가 없는 경우 자동으로 404페이지를 보여줍니다.

Use the template system

이번에는 detail페이지를 열었을때 해당 질문의 선택사항을 불러다가 보여주도록 하겠습니다. 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>

Question객체의 question_text를 h1크기로 보여주고 Question객체의 choice_set배열방을 돌면서 ul/li로 선택지를 목록으로 보여줍니다.

Removing hardcoded URLs in templates

polls/templates/polls/index.html파일에 보면 각 Question들의 링크의 href이 /polls/{{ question.id }}/로 경로가 hardcoding되어있습니다. 이 코드는 {% url 'detail' question.id %}로 표현될수 있습니다. 이렇게 바꾸면 링크의 경로가 바뀌어도 자동으로 반영이 되어 보다 편리하게 이용하실수 있습니다.

위의 경로가 자동으로 반영되는지 테스트삼아 polls/urls.py에서 urlpatterns에 기존에 path('/', views.detail, name='detail'),라고 되어있던 부분을 path('specifics/<int:question_id>/', views.detail, name='detail'),로 잠시 변경해 볼게요. 그러면 detail페이지의 링크가 http://127.0.0.1:8000/polls/specifics/1/로 바뀌게 되는데 http://127.0.0.1:8000/polls/의 링크가 자동으로 변경되어 있는것을 보실수 있으실 겁니다.

Namespacing URL names

index.html에서 {% url 'detail' question.id %}부분을 보면 url이 detail이라고 되어 있는데, 하나의 프로젝트안에 여러개의 앱이 존재하는 경우 detail이라는 url은 여러개 존재할수 있습니다. 이때 각 app별로 영역을 구분짓는것이 바로 namespacing입니다. polls/urls.py파일에 app_name = 'polls'을 아래와 같이 추가해주세요.

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>/results/', views.results, name='results'),
    path('<int:question_id>/vote/', views.vote, name='vote'),
]

이렇게 urls.py에 app이름을 명시함으로써 템플릿에서 아래와 같이 polls:detail앱이름을 붙여서 경로를 명시할수 있습니다.

<li><a href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a></li>

Write a minimal form

이제 실제 설문조사에 사용자가 투표를 할수 있도록 detail페이지에 form을 만들어볼까요? polls/templates/polls/detail.html을 아래와 같이 수정해주세요.

<form action="{% url 'polls:vote' question.id %}" method="post">
{% csrf_token %}
<fieldset>
    <legend><h1>{{ question.question_text }}</h1></legend>
    {% 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 %}
</fieldset>
<input type="submit" value="Vote">
</form>

위의 템플릿에서는 각 선택지별로 라이오상자를 만들어 선택할수 있게 하고 submit버튼을 이용하여 form의 정보를 polls:vote로 전송하는 기능을 합니다.

polls:vote의 위치는 polls/urls.py에 아래와 같이 정의되어있습니다.

path('<int:question_id>/vote/', views.vote, name='vote'),

polls/views.py을 열어서 임시로 만들어뒀던 vote함수를 아래와 같이 수정해주세요.

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 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': "You didn't select a choice.",
        })
    else:
        selected_choice.votes += 1
        selected_choice.save()
        return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))

이 함수에서는 해당 Question을 get_object_or_404함수로 가져와서 관련된 choice들을 get으로 가져와서 선택한 보기가 존재하는지 확인하고 만약 선택지가 없는 경우 에러메세지를 보여줍니다. 투표를 하고자하는 Question과 선택지가 존재한다면 기존 투표수에 1을 더해서 저장한뒤 “polls:results”화면으로 redirect시킵니다.

여기서 자세히 보시면 request.POST[‘choice’]라는 코드가 있는데 짐작하시겠지만 django는 request의 POST에 form정보를 저장해서 vote함수를 호출해줍니다. 마찬가지로 form이나 URL에 GET으로 받은 데이타는 request.GET에 저장이 됩니다.

그리고 HttpResponseRedirect라는 함수를 가져다 썼는데요, 이 함수는 특정경로로 redirection시켜주는 기능을 합니다. HttpResponseRedirect안에 보면 reverse라는 함수를 함께 사용했는데요, 이 함수는 논리적 경로를 실제 경로(/polls/3/results/)로 변경해주는 역할을 합니다. 위에서도 말씀드렸다시피 실제 경로를 사용하게 되면 나중에 경로가 변경되었을때 경로를 참조한 모든 곳을 수정해주어야하기 때문에 가급적 하드코딩대신 이렇게 namespace와 view의 함수명으로 명시해줌으로써 좀더 flexible한 코딩이되는거에요.

그러면 이제 redirect시키는 polls:results로 이동해볼까요? 이 경로는 urls에 아래와 같이 정의되어있습니다.

path('<int:question_id>/results/', views.results, name='results'),

view파일에서 results함수를 아래와 같이 수정해주세요.

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

get_object_or_404함수를 이용해서 해당 id의 Question정보를 가져오고, 가져온 객체를 results.html템플릿에 전달해서 질문과 선택지 그리고 각 선택지에 저장된 투표개수를 화면에 출력합니다. 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>

이제 결과를 확인해볼까요? http://127.0.0.1:8000/polls/에는 설문지 목록이 보입니다. 그중 하나를 클릭하고 들어가면 http://127.0.0.1:8000/polls/1/ 선택할수 있는 폼이 보입니다. 선택을 하고 Vote버튼을 누르면 http://127.0.0.1:8000/polls/1/results/ 결과가 보입니다. Vote again?링크를 눌러서 다시 시도해봅니다. 이번에는 아무것도 선택하지 않고 Vote버튼을 눌러볼까요? You didn't select a choice.라는 메세지가 잘 뜨네요.

그런데 여기서 한가지 문제가 있습니다. 사용자가 투표를 하면 기존 투표수를 가져와서 +1을 하는 방식은 자칫 잘못된 결과를 초래할수 있습니다. 두명의 사용자가 동시에 투표를 한경우 기존 투표수가 동일해서 둘다 +1을 눌렀지만 기존 투표수 + 1은 같은 값을 가지게되는거죠. 이런 상황을 race condition이라고 하는데 이런 상황을 처리하는 방법을 여기를 눌러서 확인해주세요.

Use generic views: Less code is better

코드는 언제나 짧으면 짧을수록 좋습니다. 우리는 지금까지 polls앱에 대한 기능을 장황하게 코딩을 했습니다. 그런데 사실 지금 우리가 만든 기능은 거의 대부분의 앱에서 필요로 하는 기본적인 기능들이었습니다. Model에서 각 Model들간의 정보와 그들간의 관계만 정확하게 설정해준다면 이하 모든 기능들은 자동으로 처리해주는 django의 generic views를 살펴보도록하시죠.

자동으로 처리해주는 generic view를 사용하기 위해서는 우선 urls.py를 아래와 같이 수정합니다. index와 detail 그리고 results를 generic view로 하겠다고 변경하는 겁니다. 여기서 주의하실점은 기존에 상세페이지로 넘겨줄때 사용했던 변수 question_id는 pk로 변경해 주셔야합니다.

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'),
]

그리고 위의 urls.py에서 불러주었던 views.py에 정의했던 3개의 함수를 Django에서 제공하는 View Class로 변경하여 선언합니다. 우선 generic모듈을 import하고, generic.ListView를 확장하여 urls.py에 명시한대로 클래스를 아래와 같이 선언합니다. 선언한 뒤에 model에 Question이라고 값을 주고, template_name에 각 템플릿 경로를 저장합니다.

from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.template import loader
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):
...
// 이하 코드는 원래 있던대로 놔주세요

만든 코드 Test하기

polls/tests.py파일을 아래와 같이 수정합니다.

import datetime

from django.test import TestCase
from django.utils import timezone

from .models import Question


class QuestionModelTests(TestCase):

    def test_was_published_recently_with_future_question(self):
        """
        was_published_recently() returns False for questions whose pub_date
        is in the future.
        """
        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertIs(future_question.was_published_recently(), False)

위의 테스트파일은 TestCase를 확장하여 정의한 테스트클래스에 정의된 함수는 질문을 생성할때 만든날짜를 미래의 날짜로 생성하려고 할때 어떻게 되는지 보는 함수입니다.

정의한 테스트클래스를 아래의 명령어를 통해서 실행합니다.

python manage.py test polls

Sources: