장고 기초 #4 URL과 Views (FBV)

6 분 소요

#3 Models와 ORM 기초에서 Post 모델과 데이터를 갖췄습니다. 이번 글은 그 데이터를 URL로 노출 하고 view 함수가 어떻게 응답을 만드는지 봅니다. **함수 기반 뷰 (FBV)**부터 다룹니다. 클래스 기반 뷰 (CBV)는 중급 #1에서 다루겠습니다.

URLconf — URL과 view의 매핑 #

Django의 URL 라우팅은 **urls.py**가 담당합니다. 한 file의 urlpatterns 리스트가 패턴을 모은 것입니다.

config/urls.py — 최상위
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("blog/", include("blog.urls")),
]
blog/urls.py — 앱별
from django.urls import path

from . import views

app_name = "blog"

urlpatterns = [
    path("", views.post_list, name="post_list"),
    path("<int:post_id>/", views.post_detail, name="post_detail"),
    path("new/", views.post_new, name="post_new"),
    path("<int:post_id>/edit/", views.post_edit, name="post_edit"),
]

세 가지 핵심 함수:

  • path(route, view, name=...) — URL 패턴 한 줄
  • include("blog.urls") — 다른 모듈의 urlpatterns를 끼워넣기
  • app_name = "blog" — 네임스페이스. 다른 앱과 URL 이름이 겹치는 걸 방지

URL 매개변수 — <int:...>, <slug:...> #

path()의 첫 인자에 <타입:변수명>을 쓰면 view 함수 인자로 전달됩니다.

path converters
urlpatterns = [
    path("<int:post_id>/", views.post_detail, name="post_detail"),
    path("category/<slug:slug>/", views.by_category, name="by_category"),
    path("year/<int:year>/month/<int:month>/", views.archive, name="archive"),
    path("uuid/<uuid:token>/", views.by_token, name="by_token"),
]

기본 제공 converter:

타입매칭
str슬래시 빼고 모든 문자열 (기본)
int양의 정수
slug[-a-zA-Z0-9_]+ (URL 친화 문자열)
uuidUUID 형식
path슬래시 포함 모든 문자열

이 매개변수들이 view 함수의 키워드 인자로 들어옵니다.

blog/views.py
def post_detail(request, post_id):
    ...

def archive(request, year, month):
    ...

이름이 정확히 일치해야 합니다 — <int:post_id> 라면 함수 인자도 post_id.

함수 기반 뷰 (FBV) #

Django의 view는 요청을 받아 응답을 반환하는 함수 입니다. 가장 단순한 형태:

blog/views.py — 가장 작은 view
from django.http import HttpResponse


def index(request):
    return HttpResponse("Hello, blog!")

규칙은 두 개 — 첫 인자가 request, 반환값이 HttpResponse (또는 그 하위 클래스).

request 객체 #

request (정확히는 HttpRequest 인스턴스)가 들고 있는 것:

request의 자주 쓰는 속성
def example(request):
    request.method           # "GET", "POST", "PUT", "DELETE", ...
    request.GET              # ?key=value 쿼리 파라미터 (QueryDict)
    request.POST             # POST 본문 (폼 데이터)
    request.FILES            # 업로드된 파일들
    request.COOKIES          # 쿠키 dict
    request.session          # 세션 dict (#7)
    request.user             # 로그인된 사용자 (#7)
    request.headers          # 요청 헤더
    request.path             # "/blog/1/" 같은 경로
    ...

HttpResponse — 가장 기본 응답 #

HttpResponse
from django.http import HttpResponse


def hello(request):
    return HttpResponse("Hello!", content_type="text/plain")


def html(request):
    return HttpResponse("<h1>안녕</h1>")  # 기본은 text/html


def with_status(request):
    return HttpResponse("Forbidden", status=403)

상태 코드나 헤더를 직접 다룰 때 사용합니다.

JsonResponse — JSON 응답 #

JsonResponse
from django.http import JsonResponse


def post_list_json(request):
    posts = [
        {"id": 1, "title": "첫 글"},
        {"id": 2, "title": "두 번째 글"},
    ]
    return JsonResponse({"items": posts})

dict를 자동으로 JSON으로 변환해줍니다. safe=False를 주면 list도 직접 응답 가능. 하지만 본격적인 JSON API 라면 DRF가 정답입니다.

render — 템플릿 + 컨텍스트로 HTML #

가장 자주 쓰는 응답 헬퍼.

render
from django.shortcuts import render

from .models import Post


def post_list(request):
    posts = Post.objects.filter(is_published=True).order_by("-created_at")
    return render(request, "blog/post_list.html", {"posts": posts})

세 인자:

  1. request
  2. 템플릿 경로 (앱의 templates/ 디렉터리 기준)
  3. 컨텍스트 dict (템플릿에서 {{ posts }}로 접근)

템플릿 파일을 만드는 건 #5에서 자세히. 이번 글에서는 view의 구조에 집중합니다.

get_object_or_404 — 없으면 404 #

Post.objects.get(pk=1)은 객체가 없으면 Post.DoesNotExist 예외를 던집니다. 이걸 매번 try/except로 감싸지 말고 **get_object_or_404**를 쓰세요.

get_object_or_404
from django.shortcuts import get_object_or_404, render

from .models import Post


def post_detail(request, post_id):
    post = get_object_or_404(Post, pk=post_id, is_published=True)
    return render(request, "blog/post_detail.html", {"post": post})

객체가 없으면 자동으로 HTTP 404 응답을 만들어줍니다. 추가 조건 (is_published=True 같은)도 같이 줄 수 있습니다.

QuerySet에서도 비슷하게 get_list_or_404가 있습니다 — 빈 리스트면 404.

request.method 분기 — GET / POST #

폼이 있는 view에서 자주 쓰는 패턴입니다.

GET / POST 분기
from django.http import HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse

from .models import Post


def post_new(request):
    if request.method == "POST":
        title = request.POST.get("title", "").strip()
        content = request.POST.get("content", "").strip()
        if not title:
            return render(request, "blog/post_form.html", {"error": "제목을 입력하세요"})
        post = Post.objects.create(title=title, content=content, author=request.user)
        return HttpResponseRedirect(reverse("blog:post_detail", args=[post.id]))

    # GET — 빈 폼
    return render(request, "blog/post_form.html")

이 패턴은 매번 손으로 짜기엔 번거롭습니다 — Django의 Form이 이걸 추상화합니다 (#6).

Named URL — name=...이 있는 이유 #

URL을 hardcode 하는 건 나쁜 습관입니다. /blog/1/ 같은 경로가 바뀌면 코드 / 템플릿 모두 일일이 고쳐야 합니다. 이름으로 참조하세요.

reverse
from django.urls import reverse

reverse("blog:post_list")                          # "/blog/"
reverse("blog:post_detail", args=[42])             # "/blog/42/"
reverse("blog:post_detail", kwargs={"post_id": 42}) # "/blog/42/"

<app_name>:<url_name> 형식. urls.pyapp_name = "blog" + path(..., name="post_detail")"blog:post_detail"를 만듭니다.

템플릿에서는 {% url %} #

템플릿에서
<a href="{% url 'blog:post_detail' post.id %}">{{ post.title }}</a>

redirect — reverse의 줄임 버전 #

redirect
from django.shortcuts import redirect

def post_new(request):
    if request.method == "POST":
        post = Post.objects.create(...)
        return redirect("blog:post_detail", post_id=post.id)
    return render(request, "blog/post_form.html")

redirect는 내부적으로 reverse + HttpResponseRedirect를 한 번에 해줍니다. URL 이름, 경로 문자열, 모델 인스턴스 (모델에 get_absolute_url 정의 시) 모두 받습니다.

get_absolute_url — 모델 자기 자신의 URL #

모델에 자주 추가하는 메소드입니다.

blog/models.py
from django.db import models
from django.urls import reverse


class Post(models.Model):
    title = models.CharField(max_length=200)
    # ...

    def get_absolute_url(self) -> str:
        return reverse("blog:post_detail", args=[self.pk])

이 한 줄이 있으면:

  • redirect(post)가 자동으로 동작
  • Admin의 “사이트에서 보기” 링크가 살아남
  • 템플릿에서 <a href="{{ post.get_absolute_url }}">로 쓸 수 있음

POST와 CSRF #

Django는 모든 POST 요청에 CSRF 토큰을 요구합니다 (보안 기본값). 폼 템플릿 안에 {% csrf_token %}을 넣어야 합니다 — 자세한 건 #6.

API처럼 CSRF가 필요 없는 경우는 데코레이터로 제외:

csrf_exempt (조심해서)
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def webhook(request):
    ...

csrf_exempt외부 webhook처럼 진짜 필요한 경우에만. 사용자 폼에는 쓰면 안 됩니다.

HTTP 메소드 제한 — require_http_methods #

view가 받을 HTTP 메소드를 명시적으로 제한합니다.

메소드 제한
from django.views.decorators.http import require_GET, require_POST, require_http_methods


@require_GET
def post_list(request):
    ...


@require_POST
def post_delete(request, post_id):
    ...


@require_http_methods(["GET", "POST"])
def post_new(request):
    ...

허용되지 않은 메소드면 405 Method Not Allowed 자동 응답.

작은 종합 예시 #

블로그 view 4개를 한 흐름에:

blog/views.py — 종합
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_http_methods

from .models import Post


def post_list(request):
    qs = Post.objects.filter(is_published=True).order_by("-created_at")
    q = request.GET.get("q")
    if q:
        qs = qs.filter(title__icontains=q)
    return render(request, "blog/post_list.html", {"posts": qs, "q": q or ""})


def post_detail(request, post_id):
    post = get_object_or_404(Post, pk=post_id, is_published=True)
    return render(request, "blog/post_detail.html", {"post": post})


@login_required
@require_http_methods(["GET", "POST"])
def post_new(request):
    if request.method == "POST":
        title = request.POST.get("title", "").strip()
        content = request.POST.get("content", "").strip()
        if title and content:
            post = Post.objects.create(
                title=title,
                content=content,
                author=request.user,
            )
            return redirect("blog:post_detail", post_id=post.id)
    return render(request, "blog/post_form.html")


@login_required
@require_POST
def post_delete(request, post_id):
    post = get_object_or_404(Post, pk=post_id, author=request.user)
    post.delete()
    return redirect("blog:post_list")

@login_required는 로그인 안 된 사용자를 로그인 페이지로 리디렉트 — #7에서.

FBV vs CBV — 미리 보기 #

Django는 **클래스 기반 뷰 (CBV)**도 제공합니다.

🚫 아직 안 다룸 — CBV 미리 보기
from django.views.generic import ListView, DetailView


class PostListView(ListView):
    model = Post
    template_name = "blog/post_list.html"
    context_object_name = "posts"


class PostDetailView(DetailView):
    model = Post

CBV는 반복 패턴을 클래스로 줄여주는 도구 인데, 처음에는 FBV가 직관적입니다. 작은 프로젝트는 FBV만으로도 충분합니다. CBV의 깊이는 중급 #1에서 따로 다룹니다.

정리 #

이번 글에서 잡은 것:

  • URLconf — path(), include(), app_name으로 모듈화
  • URL 매개변수 — <int:>, <slug:>, <uuid:>, <path:>
  • FBV — def view(request, ...) -> HttpResponse
  • request.method, request.GET, request.POST, request.user
  • HttpResponse, JsonResponse, render
  • get_object_or_404 — 없으면 자동 404
  • Named URL — app:name + reverse / {% url %} / redirect
  • get_absolute_url 컨벤션
  • CSRF는 기본값, 폼에 {% csrf_token %} 필수
  • require_GET / POST / http_methods로 메소드 제한
  • CBV는 중급 #1에서

다음 글(#5 Templates와 정적 파일)에서는 view가 반환한 render(...)가 실제 HTML이 되는 흐름 — 템플릿 문법, 상속, 정적 파일 (CSS, JS, 이미지) 까지 다룹니다.

X