장고 기초 #5 Templates와 정적 파일
#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.htmlblog/templates/blog/post_list.html처럼 앱 이름을 한 번 더 넣는 이유는 다른 앱의 같은 이름 템플릿과 충돌을 피하기 위해서. 두 앱이 모두 post_list.html을 가지고 있으면 어느 게 선택될지 모르기 때문입니다.
settings.py의 TEMPLATES 설정
#
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로 앱별 자동 검색.
변수 출력 — {{ }}
#
<h1>{{ post.title }}</h1>
<p>작성자: {{ post.author.username }}</p>
<p>작성일: {{ post.created_at }}</p>
<div>{{ post.content }}</div>{{ var }}가 변수 출력. 점 표기는 다음 순서로 시도:
- dict의 키 (
var["title"]) - 객체의 속성 (
var.title) - 메소드 (
var.title()) — 인자 없이 호출 가능한 경우 - 리스트의 인덱스 (
var[0])
이 자동 시도가 템플릿이 단순한 표현식만 허용하는 이유입니다 — Python 코드를 직접 못 씁니다 (그게 Django 템플릿 철학).
자동 이스케이프 — XSS 기본 방어 #
Django 템플릿은 {{ }}로 출력되는 모든 값을 자동 HTML이스케이프 합니다. <script>alert(1)</script>가 변수에 들어 있으면 <script>...로 변환됩니다.
이걸 풀고 싶다면 (예: 미리 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.counter | 1부터 시작하는 인덱스 |
forloop.counter0 | 0부터 시작 |
forloop.first | 첫 반복이면 True |
forloop.last | 마지막 반복이면 True |
forloop.revcounter | 끝에서부터 1까지 |
{% for post in posts %}
<div class="{% if forloop.first %}featured{% endif %}">
{{ forloop.counter }}. {{ post.title }}
</div>
{% endfor %}{% 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 %}
#
웹 페이지의 공통 골격 (헤더, 푸터, 사이드바)을 한곳에 모아두고 페이지마다 변하는 부분만 채우는 패턴.
<!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>© 2026 My Blog</p>
</footer>
</body>
</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 }}로 부모 내용 + 자식 내용 합치기 가능
다단 상속 #
base.html (사이트 골격)
↑
blog/_layout.html (사이드바 추가, blog 공통)
↑
blog/post_list.html (실제 페이지)각 단계가 자기 위 템플릿을 extends 하면 됩니다. 깊이 쌓을수록 신중히.
정적 파일 — CSS, JS, 이미지 #
settings와 디렉터리 #
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 %}
#
{% 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 같은 웹 서버가 직접 서빙합니다.
uv run python manage.py collectstatic이 명령이 하는 일:
- 모든 앱의
static/+STATICFILES_DIRS의 파일들을STATIC_ROOT로 복사 - 같은 이름이 있으면 첫 번째 검색 경로의 파일이 우선
운영의 자세한 흐름 (WhiteNoise, CDN, manifest storage)은 중급 #6 Static/Media 운영에서.
Media 파일 — 사용자 업로드 #
STATIC은 개발자가 만든 파일 (CSS, JS), MEDIA는 사용자가 업로드한 파일 (프로필 이미지, 첨부 등). 둘은 다른 용도입니다.
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"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로 쓸 수 있지만 (TEMPLATES의 BACKEND 변경), Admin / Form / 기타 빌트인은 Django 템플릿 기준이라 섞여 동작합니다. 처음엔 Django 템플릿 그대로 쓰는 게 무난합니다.
사용자 정의 태그 / 필터 #
자주 쓰는 표현이 있으면 직접 만들 수 있습니다.
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>© {% 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, 에러 표시까지 한 번에 풀어내는 패턴을 다룹니다.