My Profile Photo

DongChanS's blog


수학과 학생의 개발일지


쟝고(Django) 3편 - 여러 앱 관리하기

이전 포스트에서 Diango Model에 대해서 알아보기 위해서

articles 테이블을 두고, 웹상으로 CRUD명령을 수행하였는데,

오늘은 더 발전시켜서 진짜 게시판같이 각 포스트마다 댓글창을 만들 것이고, 그러기 위해서는 여러 테이블들을 동시에 관리하는 작업이 필요하다.

우선적으로 그에 맞춰서 여러 테이블을 관리하기 위한 환경설정을 해보자.

(1) 준비사항

1-1. 환경설정

새로운 가상환경을 만들어서 diango를 설치하는데,

(bd-venv) start666:~/workspace/BOARD $ pip install django ipython django django_extensions

추가적으로 ipython과 diango_extensions를 설치하자.

(pip에서 여러 라이브러리를 설치하고 싶으면 그냥 띄어쓰기만 해주면 된다.)

ipython은 주피터노트북과 같은 대화형 파이썬 구조를 위한 라이브러리이고,

django_extensions는 이름과 같이 django에 대한 extension들을 관리하는 프로그램이다.

물론 새로운 것을 설치했으니 settings.py에도 인식을 시켜줘야한다.

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'django_extensions', #diango_extensions를 넣어주자.
    'articles'
]

그렇게 하면 shell 스크립트를 실행시킬 때 약간 다른 점을 확인할 수 있다.

(bd-venv) start666:~/workspace/BOARD $ python manage.py shell
Python 3.6.7 (default, Feb 13 2019, 02:17:15) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.3.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: from articles.models import Article                                                      
In [2]:  

이런식으로 django_extensions가 ipython을 인식해서 파이썬 shell을 대화형으로 바꿔주는 것이다.

만약, 여러 테이블을 다루게 되면 일일이 클래스를 임포트해줘야하는데, 이게 매우 귀찮기 때문에 shell 대신에 shell_plus라는 스크립트를 실행하면 자동으로 migrations안에 있는 테이블들을 임포트해준다.

(bd-venv) start666:~/workspace/BOARD $ python manage.py shell_plus
# Shell Plus Model Imports
from articles.models import Article
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import Group, Permission, User
from django.contrib.contenttypes.models import ContentType
from django.contrib.sessions.models import Session
# Shell Plus Django Imports
from django.core.cache import cache
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Avg, Case, Count, F, Max, Min, Prefetch, Q, Sum, When, Exists, OuterRef, Subquery
from django.utils import timezone
from django.urls import reverse


1-2. CRUD표준

웹에서 데이터베이스의 CRUD연산을 이용할 때는, 다음과 같은 표준형식이 있다.

CRUD URL Function
READ /articles/ list
  /articles/1 detail
CREATE /articles/new new
  /articles/create create
UPDATE /articles/1/edit edit
  /articles/update update
DELETE /articles/1/delete delete

이걸 RESTFul이라고 부르는데, 예전에는 이런 표준이 없으면서 url이 뒤죽박죽이었지만 이런 표준이 생기면서 다루기 쉽다고 합니다. (예를 들어서 edit/1이라고 하면 좋지않음.)

그리고, 조금 다른 이야기인데 만약 이런식으로 action url을 닫지 않는다면 장고에서는 문제가 발생한다. 꼭 /articles/create/ 이런식으로 url을 닫는 것을 권장한다.

<h1>게시글 작성</h1>
<form action="/articles/create" method="POST">
    제목 : <input type="text" name="title"/>
    내용 : <input type="text" name="content"/>
    <input type="submit" value="Submit"/>
</form>

101

2) 더 나은 사이트를 위해서

2-1. 여러 앱의 url을 관리하기

만약 어플리케이션이 너무 많아져버리면 project에 있는 urls.py의 urlpattern들이 매우 많아지면서 뒤죽박죽이 될 것이 뻔하다.

그런 상황을 방지하기 위해서 쟝고에서는 각 app마다 url dispatcher를 둠으로써 앱마다 url을 효율적으로 관리할 수 있는데,

위에서 보았듯이 articles라는 앱에서는 articles라는 주소가 반복되기 때문에 이러한 특징을 활용하면 좋을 것이다.

  1. articles 앱을 관리하는 url dispatcher를 만든다.

    형식은 프로젝트의 그것과 비슷하지만 articles가 생략되었다.

    # /articles/urls.py
    from django.urls import path
    import views
       
    urlpatterns = [
        path('',views.index, name="index"),
        path('new/',views.new, name="new"),
        path('create/',views.create, name="create"),
        path('<int:article_id>/edit/',views.edit, name="edit"),
        path('<int:article_id>/delete/',views.delete, name="delete"),
        path('update/',views.update, name="update"),
        path('<int:article_id>/',views.detail, name="detail"),
    ]
    

    그리고 예전과는 다르게 name이라는 파라미터를 통해서 각각의 url에 별칭을 부여하였는데,

    이렇게 별명을 만들어주면 Django는 별명을 통해서 url을 생성해서 줄 수 있다.

    ex)

       
    <h1>게시판입니다!</h1>
    <a href="{% url 'new' %}">새 글 만들기</a>
       
    

    이런식으로 url에 별칭을 매겨주면 하드코딩을 하지 않을 수 있다.

    ex2) variable routing이 필요하다면 이런식으로 할 수 있다.

       
    <td><a href="{% url 'detail' article.id %}">{{article.title}}</a></td>
       
    

    variable routing 순서대로 파이썬 변수값이 들어가기 때문에 이 점을 잘 유의해야한다.

    ex3) views.py의 redirect에도 위와 같은 별칭을 사용할 수 있다.

    def update(request):
        article_id = request.GET.get('id')
        title = request.GET.get('title')
        content = request.GET.get('content')
        article = Article.objects.filter(id=article_id).first()
        article.title = title
        article.content = content
        article.save()
        return redirect('detail', article.id)
    

    정말 신기하게도 detail이라고만 하면 이걸 detail별칭 url으로 인식한다고 한다.

    (아마도 /가 있냐 없느냐로 구분하는 것 같다.)

    detail 페이지에서는 variable routing을 해야 하므로, article.id를 파라미터로 더 줄 수 있다.

  2. 이렇게 sub-dispatcher를 만들었으면

    프로젝트단의 urls.py에게 “articles/라는 명령어를 보게 된다면 바로 /articles/urls.py에 찾아가세요!” 라는 묘사를 하면 된다.

    # urls.py
    from django.contrib import admin
    from django.urls import path, include
       
    urlpatterns = [
        path('admin/', admin.site.urls),
        path('articles/',include('articles.urls')),
    ]
    

    (views의 어떤 함수를 실행하는게 아니라 articles라는 url을 포함시키라는 의미이기 때문에 include라는 함수를 사용한다. 물론, include를 임포트해야한다.)

    그러면 /articles 라는 url을 넣어주면

    /articles까지는 대장문지기가 해석해주고, 그다음주소들만 따로 서브문지기가 해석하기때문에 분리하기가 매우 편리하다.

2-2. POST 방식을 사용하기

이전 포스트에서는 GET방식으로 form을 통해 데이터를 보냈는데, 이렇게 get방식을 이용하면 parameter들이 url을 통해서 전달되기 때문에 직접 웹사이트에 접속하지 않고도 url을 통해서 명령을 보낼 수 있다.

예시)

import requests
import time

def hacker(title,content):
    
    count = 0
    while count < 20:
        url = f'https://djangopractice-start666.c9users.io/articles/create/?title={title}&content={content}'
        requests.get(url)
        time.sleep(5)
        count += 1
        
if __name__ == "__main__":
    hacker("님아","제곧내")

이렇게 되면 5초마다 원격으로 글을 작성하는 명령을 내릴 수 있다.

이런 부득이한 상황을 타파하기 위해서 데이터를 받아야하는 명령은 무조건 POST방식을 사용해야하는데, POST방식을 사용하면 url에 파라미터가 남지 않기 때문에 조금 더 안전하다고 볼 수 있다.

물론 POST로 작성한다고 해도 원격으로 조정이 불가능한것은 아니고..

105

웹사이트의 Form Data라는 곳에 보낸 데이터들이 저장되기 때문에 이걸 requests 세션을 잘 이용하면 가져와서 원격으로 조정할 수 있다고 한다.


그래서 이런 방식 역시 보안적으로 매우 취약하기 때문에

form 데이터를 탈취해서 원격으로 사이트에 명령을 내릴 수 있게 된다.

쟝고에서는 이런 단순한 공격을 방지하기위한 장치가 있는데.. 그것을 살펴보도록 하자.

  1. 먼저 이런식으로 post방식의 form을 만들어보자

       
    <h1>게시글 작성</h1>
    <form action="{% url 'create' %}" method="POST">
        제목 : <input type="text" name="title"/>
        내용 : <input type="text" name="content"/>
        <input type="submit" value="Submit"/>
    </form>
       
    

    그러면 다음과 같은 에러가 발생한다.

    102

    CSRF검증에 실패하였기 때문에 요청을 중단했다는 뜻인데,

    이것은 CSRF공격 에 대비하기위하여 쟝고가 자체적으로 CSRF 검증을 실시했음을 뜻한다.

    CSRF공격은 위의 나무위키 링크에 잘 설명되어 있는데, 단순히 설명하면 웹사이트에 접속하지 않고도 원격으로 웹에 영향을 주는 행위들을 말한다.

  2. 쟝고에서는 POST방식으로 데이터를 주고받을 때 CSRF검증을 실시하는데,

    • 글 작성 : Django가 CSRF토큰(임의난수)을 만들어서 클라이언트에게 넘겨줌
    • 글 작성 이후에 요청을 보낸 클라이언트가 CSRF토큰을 가지고 있지 않다면, 글 작성요청을 거부한다.

    그렇기 때문에 우리가 POST방식을 사용하기 위해서는 CSRF토큰을 같이 넣어줘야한다.

    103

    (쟝고의 오류메시지에는 매우 자세한 설명이 기록되어있으니 따라하면 된다.)

       
    <h1>게시글 작성</h1>
    <form action="/articles/create/" method="POST">
        제목 : <input type="text" name="title"/>
        내용 : <input type="text" name="content"/>
        <input type="submit" value="Submit"/>
        {% csrf_token %}
    </form>
       
    
  3. 그러면 웹사이트에 다음과 같은 CSRF토큰이 발급된 것을 확인할 수 있다.

    104

    물론 이러한 CSRF토큰을 파이썬 내에서도 가져오는 방법이 있다고는 하는데.. 일단 이걸로는 단순한 CSRF공격을 막을 수 있다.

  4. POST로 첨부된 데이터를 얻는 것은 플라스크에서와 비슷하다.

    def update(request):
        article_id = request.POST.get('id')
        title = request.POST.get('title')
        content = request.POST.get('content')
        article = Article.objects.filter(id=article_id).first()
        article.title = title
        article.content = content
        article.save()
        return redirect('detail', article.id)
    

    GET대신에 POST라고만 바꾸면 된다.

2-3 Django가 경로를 찾는 방식

두개의 앱 articles와 pages가 있다고 생각해보자.

articles 앱은 게시판과 관련된 기능을 담고있고,

pages 앱은 어플리케이션과는 상관 없는 기타 페이지들을 담고있다. (ex) 이용약관, 개인정보 처리방침)

트리구조로는 이렇다.

.
├── articles # 앱1
...
│   ├── templates
│   │   ├── articles
│   │   │   ├── detail.html
│   │   │   ├── edit.html
│   │   │   ├── index.html
│   │   │   └── new.html
│   │   └── home.html # home.html은 동등한 위치에 있다.
...
├── board
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── db.sqlite3
├── manage.py
└── pages # 앱2
...
    ├── templates
    │   └── home.html
...

이런 상태에서 pages 앱에 있는 views.home 함수를 실행시킨다면,

# pages/view.html
from django.shortcuts import render

# Create your views here.
def home(request):
    return render(request,'home.html')

뜬금없이 articles앱에 있는 home.html을 참고하게 된다.

이렇게 되는 이유는 쟝고의 파일 검색방식이 문제가 되기 때문인데,

  1. Django는 프로젝트의 공통된 위치(templates) 폴더에서 html파일을 검색한다.

  2. 만약 같은 이름의 파일이 여러개 발견되었을 경우에는 installed_app의 순서대로 읽어서 렌더한다.

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

    위의 예시에는 articles가 먼저 입력되어있으므로 articles의 home.html이 입력되는 것이다.

그렇기 때문에 이러한 불상사를 막기 위해서는 articles앱과 pages앱의 html파일 경로를 구분시켜줄 필요가 있다.

따라서, 위의 articles앱에서처럼 template/articles 폴더를 만들어서 그 안에 html파일을 넣는게 관례이다.

2-4. 공통된 html파일을 상속하고싶을때

일반적으로 html파일을 상속할 때, 상속할 기본틀(base.html)은 앱과 상관없이 공통된 형식을 가지고 있는 경우가 많다. (부트스트랩같은건 전부 필요하니까… 그리고 앱에 관련된 파일이 필요하다면 extends 대신에 include 명령어를 쓰면 가능하다. )

그렇기 때문에 base.html 은 앱의 templates폴더가 아닌 프로젝트의 templates폴더에서 넣고싶다.

예를 들면 이런식이다. (board 프로젝트 기준)

├── board
│   ├── __init__.py
│   ├── __pycache__
│   │   ├── __init__.cpython-36.pyc
│   │   ├── settings.cpython-36.pyc
│   │   ├── urls.cpython-36.pyc
│   │   └── wsgi.cpython-36.pyc
│   ├── settings.py
│   ├── templates
│   │   └── base.html # 프로젝트 내에서 templates파일을 만듦.
│   ├── urls.py
│   └── wsgi.py
├── db.sqlite3
├── manage.py
└── pages # 앱2
...
    ├── templates
    │   └── home.html
...

문제는 이렇게 프로젝트내에서 templates/base.html을 만들면 이 templates는 기본적으로 앱의 templates 폴더가 아니기 때문에 쟝고가 검색하지않는다. 따라서 추가를 시켜줘야한다.

#BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR,'board','templates')], # 경로추가
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

BASE_DIR에 board폴더의 template폴더를 검색하도록 요청하는 것이다.

(BASE_DIR : 가상환경이 있는 절대경로)

여기서 사용된 os.path.join이라는 함수는 단순히 파라미터들을 /로 이어주는 역할인데 예시는 이렇다.

In [1]: import os                                                                    
In [2]: os.path.join('/','diango','join','test')                                 

Out[2]: '/diango/join/test'

하지만, 이 os.path.join 함수가 좋은 이유는 os라이브러리가 운영체제를 자동으로 고려하기 때문이다.

(우분투 쉘에서는 /이 표준이지만 윈도우에서는 \가 표준이기 때문에 이런 것들을 구분해준다)

아무튼 그렇게 base.html파일을 쟝고가 인식하게 하면, 그다음부터는 이전과 동일하다.


보너스 : ORM이 강력한 이유

ORM을 통해서 파이썬으로 데이터베이스를 관리하면 다음과 같은 장점이 있다.

  1. 파이썬 객체의 변화를 ORM이 그런 변화를 SQL문으로 자동으로 변환해줌

    EX) Article 테이블의 content 라는 컬럼을 context라는 이름으로 바꾸고싶을 때,

    (bd-venv) start666:~/workspace/BOARD $ python manage.py makemigrations
    Did you rename article.content to article.context (a TextField)? [y/N] y
    Migrations for 'articles':
      articles/migrations/0003_auto_20190220_1433.py
        - Rename field content on article to context
    

    클래스 멤버변수를 바꾸고 makemigrations 스크립트를 실행하면

    똑똑한 쟝고가 알아서 인식을 한다.

    그러면 이제 sqlmigrate 스크립트를 통해서 쟝고가 내부에서 실행한 sql문을 볼 수 있는데,

    (bd-venv) start666:~/workspace/BOARD $ python manage.py sqlmigrate articles 0003
    BEGIN;
    --
    -- Rename field content on article to context
    --
    ALTER TABLE "articles_article" RENAME TO "articles_article__old";
    CREATE TABLE "articles_article" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "context" text NOT NULL, "title" text NOT NULL);
    INSERT INTO "articles_article" ("id", "title", "context") SELECT "id", "title", "content" FROM "articles_article__old";
    DROP TABLE "articles_article__old";
    COMMIT;
       
    

    매우 복잡하다.

    단순히 테이블 내의 컬럼이름을 바꾸고 싶을 뿐인데,

    1. 기존의 데이터가 저장된 테이블을 _old 를 붙여서 임시로 이름을 바꾼다.
    2. context 컬럼을 가진 articles_article 라는 테이블을 생성한다.
    3. _old 테이블의 값을 전부 입력해준다.
    4. _old 테이블을 삭제한다.

    데이터를 보존하기 위해서 이런 복잡한 과정들을 내부에서 실행해준다.

  2. 데이터베이스 종류에 따라서 다른 SQL문을 구사해준다.

    사실 위의 SQL문이 매우 복잡한 이유는 우리가 sqlite라는 작은 데이터베이스를 쓰기 때문인데,

    이런 sqlite 데이터베이스는 alter table문이 매우 약하기 때문에 컬럼이름을 직접적으로 변경하면 문제가 발생하기때문이라고 한다.

    만약 postgresql을 사용한다면 파이썬 객체로는 같은 작업을 함에도 불구하고, 내부에서 쟝고가 차이점을 인식해서 alter table문을 직접적으로 사용할것이다.


comments powered by Disqus