장고 기초 #5 Templates와 정적 파일

7 분 소요

#4 URL과 Views에서 view가 render(request, "blog/post_list.html", {"posts": posts}) 같은 호출로 HTML을 반환했습니다. 이번 글은 그 HTML의 정체 — Django 템플릿 문법과, 템플릿이 참조하는 정적 파일 (CSS, JS, 이미지)의 처리를 봅니다.

템플릿 디렉터리 구조 #

Django의 템플릿 검색 경로는 두 군데 — **각 앱의 templates/**와 프로젝트 공통 templates/.

권장 구조
myblog/
├── config/
│   └── settings.py
├── templates/                       # 프로젝트 공통 (base.html 등)
│   └── base.html
└── blog/
    ├── views.py
    └── templates/
        └── blog/                    # 한 단계 더 들어가는 게 컨벤션
            ├── post_list.html
            └── post_detail.html

blog/templates/blog/post_list.html처럼 앱 이름을 한 번 더 넣는 이유는 다른 앱의 같은 이름 템플릿과 충돌을 피하기 위해서. 두 앱이 모두 post_list.html을 가지고 있으면 어느 게 선택될지 모르기 때문입니다.

settings.py의 TEMPLATES 설정 #

config/settings.py
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],     # 공통 템플릿 디렉터리
        "APP_DIRS": True,                      # 각 앱의 templates/ 자동 검색
        "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",
            ],
        },
    },
]

DIRS에 공통 디렉터리, APP_DIRS=True로 앱별 자동 검색.

변수 출력 — {{ }} #

blog/templates/blog/post_detail.html
<h1>{{ post.title }}</h1>
<p>작성자: {{ post.author.username }}</p>
<p>작성일: {{ post.created_at }}</p>
<div>{{ post.content }}</div>

{{ var }}가 변수 출력. 점 표기는 다음 순서로 시도:

  1. dict의 키 (var["title"])
  2. 객체의 속성 (var.title)
  3. 메소드 (var.title()) — 인자 없이 호출 가능한 경우
  4. 리스트의 인덱스 (var[0])

이 자동 시도가 템플릿이 단순한 표현식만 허용하는 이유입니다 — Python 코드를 직접 못 씁니다 (그게 Django 템플릿 철학).

자동 이스케이프 — XSS 기본 방어 #

Django 템플릿은 {{ }}로 출력되는 모든 값을 자동 HTML이스케이프 합니다. <script>alert(1)</script>가 변수에 들어 있으면 &lt;script&gt;...로 변환됩니다.

이걸 풀고 싶다면 (예: 미리 sanitize 한 HTML) |safe 필터:

이스케이프 풀기
{{ post.content_html|safe }}

|safe는 정말 신뢰할 수 있는 데이터에만. 사용자 입력에 그냥 붙이면 XSS 취약점입니다.

태그 — {% %} #

태그가 로직을 담당합니다. 자주 쓰는 것들.

{% if %} #

조건문
{% if post.is_published %}
  <span class="badge">발행됨</span>
{% elif post.status == "draft" %}
  <span class="badge">초안</span>
{% else %}
  <span class="badge">기타</span>
{% endif %}

{% if posts %}
  <p>총 {{ posts|length }}개</p>
{% else %}
  <p>아직 글이 없습니다.</p>
{% endif %}

비교 연산자 — ==, !=, <, >, <=, >=, in, not in, is, is not. 논리 연산자 — and, or, not.

{% for %} #

반복문
<ul>
{% for post in posts %}
  <li>
    <a href="{% url 'blog:post_detail' post.id %}">{{ post.title }}</a>
    <small>by {{ post.author.username }}</small>
  </li>
{% empty %}
  <li>등록된 글이 없습니다.</li>
{% endfor %}
</ul>

{% empty %}가 빈 컬렉션일 때 분기. 아주 자주 씁니다.

루프 안에서 쓸 수 있는 변수:

변수의미
forloop.counter1부터 시작하는 인덱스
forloop.counter00부터 시작
forloop.first첫 반복이면 True
forloop.last마지막 반복이면 True
forloop.revcounter끝에서부터 1까지
forloop 활용
{% for post in posts %}
  <div class="{% if forloop.first %}featured{% endif %}">
    {{ forloop.counter }}. {{ post.title }}
  </div>
{% endfor %}

{% url %} #

URL 생성
<a href="{% url 'blog:post_list' %}">목록</a>
<a href="{% url 'blog:post_detail' post.id %}">{{ post.title }}</a>
<a href="{% url 'blog:post_detail' post_id=post.id %}">{{ post.title }}</a>

#4에서 본 named URL의 템플릿 버전.

{% include %} #

다른 템플릿 끼워넣기
{% include "blog/_post_card.html" %}
{% include "blog/_post_card.html" with post=post show_author=True %}

부분 템플릿을 재사용. 파일명을 _로 시작하는 게 partial의 컨벤션.

{% csrf_token %} #

폼에는 무조건 들어가야 하는 태그.

<form method="post">
  {% csrf_token %}
  <input name="title" />
  <button>저장</button>
</form>

빠뜨리면 403 Forbidden으로 막힙니다. #6에서 자세히.

필터 — | #

필터는 변수를 변환 합니다. {{ value|filter }} 또는 {{ value|filter:"arg" }}.

자주 쓰는 필터
{{ post.title|upper }}                    {# 대문자 #}
{{ post.title|lower }}
{{ post.title|truncatechars:50 }}         {# 50자에서 자르고 ... #}
{{ post.title|truncatewords:10 }}
{{ post.created_at|date:"Y-m-d H:i" }}    {# 날짜 포맷 #}
{{ post.created_at|timesince }}            {# "3일 전" #}
{{ post.content|linebreaks }}              {# 줄바꿈 → <p>, <br> #}
{{ post.content|striptags }}               {# HTML 태그 제거 #}
{{ posts|length }}
{{ post.content|default:"내용 없음" }}
{{ value|yesno:"네,아니오,모름" }}
{{ price|floatformat:2 }}                  {# 1234.567 → 1234.57 #}
{{ name|capfirst }}                        {# 첫 글자만 대문자 #}
{{ posts|first }}
{{ posts|last }}
{{ posts|slice:":5" }}

필터는 체이닝 가능 — {{ post.title|truncatechars:50|upper }}.

템플릿 상속 — {% extends %} / {% block %} #

웹 페이지의 공통 골격 (헤더, 푸터, 사이드바)을 한곳에 모아두고 페이지마다 변하는 부분만 채우는 패턴.

templates/base.html — 공통 골격
<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>{% block title %}My Blog{% endblock %}</title>
  {% load static %}
  <link rel="stylesheet" href="{% static 'css/main.css' %}">
</head>
<body>
  <header>
    <a href="{% url 'blog:post_list' %}"></a>
    {% if user.is_authenticated %}
      <span>{{ user.username }}</span>
      <a href="{% url 'logout' %}"> 로그아웃</a>
    {% else %}
      <a href="{% url 'login' %}"> 로그인</a>
    {% endif %}
  </header>

  <main>
    {% block content %}{% endblock %}
  </main>

  <footer>
    <p>&copy; 2026 My Blog</p>
  </footer>
</body>
</html>
blog/templates/blog/post_list.html — 자식
{% extends "base.html" %}

{% block title %}글 목록 - My Blog{% endblock %}

{% block content %}
  <h1>글 목록</h1>
  <ul>
    {% for post in posts %}
      <li>
        <a href="{% url 'blog:post_detail' post.id %}">{{ post.title }}</a>
        <small>{{ post.created_at|date:"Y-m-d" }}</small>
      </li>
    {% empty %}
      <li>아직 글이 없습니다.</li>
    {% endfor %}
  </ul>
{% endblock %}

규칙:

  • 자식 템플릿 첫 줄{% extends "..." %}
  • 부모의 {% block name %}...{% endblock %} 자리에는 자식의 같은 이름 block이 들어감
  • 자식이 비워두면 부모의 기본값이 그대로 노출
  • block 안에서 {{ block.super }}로 부모 내용 + 자식 내용 합치기 가능

다단 상속 #

3단 상속 예
base.html               (사이트 골격)
blog/_layout.html       (사이드바 추가, blog 공통)
blog/post_list.html     (실제 페이지)

각 단계가 자기 위 템플릿을 extends 하면 됩니다. 깊이 쌓을수록 신중히.

정적 파일 — CSS, JS, 이미지 #

settings와 디렉터리 #

config/settings.py
STATIC_URL = "static/"

# 개발 단계에서 추가 검색 경로
STATICFILES_DIRS = [
    BASE_DIR / "static",
]

# 운영 배포 시 collectstatic의 출력 위치
STATIC_ROOT = BASE_DIR / "staticfiles"

각 앱의 static/ 디렉터리도 자동으로 검색됩니다 (django.contrib.staticfiles가 활성화되어 있을 때 — 기본).

정적 파일 위치
myblog/
├── static/                        # 프로젝트 공통
│   └── css/
│       └── main.css
└── blog/
    └── static/
        └── blog/                  # 앱 이름 한 번 더 (템플릿과 같은 컨벤션)
            ├── css/
            │   └── post.css
            └── images/
                └── logo.png

템플릿에서 {% static %} #

static 사용
{% load static %}

<link rel="stylesheet" href="{% static 'css/main.css' %}">
<link rel="stylesheet" href="{% static 'blog/css/post.css' %}">
<img src="{% static 'blog/images/logo.png' %}" alt="logo">
<script src="{% static 'blog/js/post.js' %}"></script>

{% load static %}을 템플릿 위에 한 번 선언해야 합니다. 보통 base.html<head> 위쪽에.

운영 배포 — collectstatic #

runserver (개발 서버)는 정적 파일을 자동으로 서빙합니다. 운영에서는 **collectstatic**으로 모든 정적 파일을 한 디렉터리 (STATIC_ROOT)로 모은 뒤, Nginx 같은 웹 서버가 직접 서빙합니다.

collectstatic
uv run python manage.py collectstatic

이 명령이 하는 일:

  • 모든 앱의 static/ + STATICFILES_DIRS의 파일들을 STATIC_ROOT로 복사
  • 같은 이름이 있으면 첫 번째 검색 경로의 파일이 우선

운영의 자세한 흐름 (WhiteNoise, CDN, manifest storage)은 중급 #6 Static/Media 운영에서.

Media 파일 — 사용자 업로드 #

STATIC개발자가 만든 파일 (CSS, JS), MEDIA사용자가 업로드한 파일 (프로필 이미지, 첨부 등). 둘은 다른 용도입니다.

config/settings.py
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
config/urls.py — 개발 단계만
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    # ...
]

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

운영에서 사용자 업로드를 직접 서빙하면 안 됩니다 — S3 같은 외부 스토리지로. 이것도 중급 #6에서.

Django 템플릿 vs Jinja2 — 한 줄 비교 #

Django 템플릿은 의도적으로 제한된 언어 입니다. Python 코드를 직접 못 쓰고, 임의 함수 호출도 어렵죠 ({% load %} 한 태그/필터만). 그게 보안과 디자이너 친화를 지키는 방식입니다.

Jinja2는 더 강력 — 템플릿 안에서 거의 모든 Python 표현식이 가능합니다. 그만큼 비즈니스 로직이 템플릿으로 뒤섞이기 쉽다는 뜻이기도 합니다.

Django에서도 Jinja2를 backend로 쓸 수 있지만 (TEMPLATESBACKEND 변경), Admin / Form / 기타 빌트인은 Django 템플릿 기준이라 섞여 동작합니다. 처음엔 Django 템플릿 그대로 쓰는 게 무난합니다.

사용자 정의 태그 / 필터 #

자주 쓰는 표현이 있으면 직접 만들 수 있습니다.

blog/templatetags/blog_extras.py
from django import template

register = template.Library()


@register.filter
def markdown_to_html(value: str) -> str:
    import markdown
    return markdown.markdown(value)


@register.simple_tag
def current_year():
    from datetime import datetime
    return datetime.now().year
템플릿
{% load blog_extras %}

<div>{{ post.content|markdown_to_html|safe }}</div>
<footer>&copy; {% current_year %}</footer>

templatetags/ 디렉터리, __init__.py 필요. 이 규칙을 어기면 Django가 못 찾습니다.

정리 #

이번 글에서 잡은 것:

  • 템플릿 위치 — 앱별 templates/<app>/... + 공통 templates/
  • TEMPLATES 설정의 DIRS / APP_DIRS
  • 변수 — {{ }}, 자동 HTML이스케이프 (XSS 방어)
  • 태그 — {% if %}, {% for %} (with {% empty %}), {% url %}, {% include %}, {% csrf_token %}
  • 필터 — |date, |truncatechars, |linebreaks, |safe, …
  • 템플릿 상속 — {% extends %}, {% block %}, {{ block.super }}
  • 정적 파일 — STATIC_URL, STATICFILES_DIRS, {% load static %}, {% static %}
  • collectstatic은 운영용
  • Media 파일은 별도 (MEDIA_URL / MEDIA_ROOT)
  • Django 템플릿은 의도적으로 제한된 언어 — 보안/단순성
  • 사용자 정의 태그/필터 — templatetags/

다음 글(#6 Forms와 ModelForm)에서는 이 템플릿 안에서 폼을 처리하는 표준 방법 — Form / ModelForm으로 검증, CSRF, 에러 표시까지 한 번에 풀어내는 패턴을 다룹니다.

X