장고 중급 #5 메시지 / 세션 / 쿠키
HTTP는 stateless입니다. 한 요청과 다음 요청이 서로를 모르는 게 기본. 그 위에 “같은 사용자” 라는 개념을 얹기 위해 세 가지 도구가 있습니다.
- 메시지 — 한 요청에서 다음 요청으로 잠깐 전달 (flash)
- 세션 — 한 사용자의 여러 요청에 걸친 상태
- 쿠키 — 위 두 가지를 가능하게 하는 가장 밑단의 매체
이 글은 위에서 아래로 — 메시지부터 시작해 쿠키 보안까지 한 호흡에 봅니다.
#3에서 본 MessageMiddleware, SessionMiddleware가 동작의 근간입니다.
메시지 — flash 패턴 #
**“저장되었습니다”**같이 다음 페이지로 넘어가서 한 번만 보여주는 알림. 이걸 직접 만들면 세션을 만지는 코드를 매번 적어야 하는데, 장고는 messages 프레임워크로 풀어줍니다.
셋업 (보통 자동) #
startproject로 만든 프로젝트는 이미 들어 있습니다.
INSTALLED_APPS = [
...
"django.contrib.messages",
]
MIDDLEWARE = [
...
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
]
TEMPLATES = [{
...
"OPTIONS": {
"context_processors": [
...
"django.contrib.messages.context_processors.messages",
],
},
}]뷰에서 메시지 추가 #
from django.contrib import messages
from django.shortcuts import redirect
def post_create(request):
if request.method == "POST":
form = PostForm(request.POST)
if form.is_valid():
post = form.save()
messages.success(request, "글이 저장되었습니다.")
return redirect("post_detail", pk=post.pk)
messages.error(request, "입력값을 다시 확인해주세요.")
else:
form = PostForm()
return render(request, "blog/post_form.html", {"form": form})레벨별 헬퍼:
| 함수 | 레벨 | 쓰임 |
|---|---|---|
messages.debug | DEBUG | 개발 디버깅 |
messages.info | INFO | 단순 안내 |
messages.success | SUCCESS | 성공 알림 |
messages.warning | WARNING | 주의 |
messages.error | ERROR | 실패 |
DEBUG는 기본적으로 표시되지 않습니다. MESSAGE_LEVEL = messages.DEBUG로 켤 수 있습니다.
CBV에서 — SuccessMessageMixin
#
from django.contrib.messages.views import SuccessMessageMixin
from django.views.generic.edit import CreateView
class PostCreateView(SuccessMessageMixin, CreateView):
model = Post
form_class = PostForm
success_message = "%(title)s 글이 저장되었습니다."%(field)s 형태로 모델 필드를 메시지에 끼워 넣습니다. (#1 CBV의 Mixin 패턴.)
템플릿에서 렌더 #
{% if messages %}
<ul class="messages">
{% for message in messages %}
<li class="message message--{{ message.tags }}">
{{ message }}
</li>
{% endfor %}
</ul>
{% endif %}message.tags—success,error등 (CSS 클래스로 활용)- 한 번 렌더되면 자동으로 사라집니다 — 다음 요청엔 다시 보이지 않음
메시지 백엔드 #
기본 백엔드는 FallbackStorage — 세션을 시도하고 안 되면 쿠키로 폴백합니다.
MESSAGE_STORAGE = "django.contrib.messages.storage.session.SessionStorage"
# 또는 "...cookie.CookieStorage"쿠키 백엔드는 헤더가 커질 수 있고, 세션 백엔드는 미들웨어 순서가 중요. 기본 FallbackStorage가 보통 답 입니다.
Session — 여러 요청에 걸친 상태 #
세션은 사용자별로 서버 측에 보관하는 dict 같은 저장소입니다. 클라이언트는 세션 ID만 쿠키로 가지고, 실제 데이터는 서버에 있습니다 (기본 백엔드의 경우).
기본 사용 #
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk)
recent = request.session.get("recent_posts", [])
if pk in recent:
recent.remove(pk)
recent.insert(0, pk)
recent = recent[:10]
request.session["recent_posts"] = recent
return render(request, "blog/post_detail.html", {"post": post})API는 보통의 dict와 비슷합니다.
request.session["key"] = "value" # 저장
request.session.get("key") # 조회 (없으면 None)
request.session.get("key", default) # 기본값
del request.session["key"] # 삭제
"key" in request.session # 존재 검사
request.session.flush() # 세션 전체 비우고 새 세션 키 생성
request.session.cycle_key() # 키만 새로 (데이터 유지) — 권한 변경 후 권장세션 만료 — set_expiry
#
request.session.set_expiry(60 * 60) # 1시간 (초)
request.session.set_expiry(0) # 브라우저 닫으면 만료
request.session.set_expiry(None) # 기본값 (settings.SESSION_COOKIE_AGE)기본 만료는 SESSION_COOKIE_AGE = 1209600 (2주). “로그인 유지” 체크박스 같은 경우엔 set_expiry(0) 또는 긴 값으로 분기.
세션 백엔드 — 어디에 저장하나 #
SESSION_ENGINE = "django.contrib.sessions.backends.db" # 기본| 백엔드 | 저장 위치 | 쓰임 |
|---|---|---|
db (기본) | DB (django_session 테이블) | 거의 모든 경우 — 기본값으로 잘 동작 |
cache | 캐시 (Redis 등) | 매우 빠름. 캐시 비우면 사라짐 (주의) |
cached_db | 캐시 → DB 폴백 | DB의 영속성 + 캐시의 속도 |
file | 파일 시스템 | 단일 서버, 작은 트래픽 |
signed_cookies | 쿠키 자체에 (서명) | 서버 stateless. 데이터 양 제한, 매 요청마다 전송 |
운영에서 보통 답: db 또는 cached_db. Redis가 이미 있다면 cache 또는 cached_db로 가속.
signed_cookies는 서버를 완전히 stateless 하게 만들 수 있지만, 모든 요청에 세션 데이터가 전송되니 데이터를 작게 유지해야 합니다. 비밀 정보를 넣으면 안 됩니다 (서명만 되어 있을 뿐 암호화 X).
만료된 세션 청소 #
db 백엔드는 만료된 행이 자동으로 사라지지 않습니다. 주기적으로 정리:
python manage.py clearsessions쿠키 — 가장 밑단 #
세션 ID, CSRF 토큰, 로케일 같은 작은 값은 쿠키로 직접 다루기도 합니다.
쿠키 읽기/쓰기 #
def view(request):
visited = request.COOKIES.get("visited", "0")
visited = str(int(visited) + 1)
response = render(request, "page.html", {"visited": visited})
response.set_cookie(
"visited",
visited,
max_age=60 * 60 * 24 * 30, # 30일
httponly=True,
secure=True,
samesite="Lax",
)
return response
def logout_view(request):
response = redirect("home")
response.delete_cookie("visited")
return responserequest.COOKIES는 dict 형태로 모든 쿠키가 들어 있는 읽기 전용 객체입니다. 쓰기는 응답 객체에.
쿠키 보안 — HttpOnly / Secure / SameSite #
쿠키의 보안 속성 셋은 반드시 알아야 하는 기본 입니다.
HttpOnly
#
Set-Cookie: sessionid=abc; HttpOnlyJavaScript의 document.cookie로 읽을 수 없도록 막습니다. XSS가 일어나도 세션 쿠키를 도난당하지 않습니다.
세션 쿠키, 인증 쿠키는 반드시 HttpOnly. 장고의 SESSION_COOKIE_HTTPONLY와 CSRF_COOKIE_HTTPONLY의 기본값:
| 기본 | 의미 | |
|---|---|---|
SESSION_COOKIE_HTTPONLY | True | 세션 쿠키는 JS 접근 차단 |
CSRF_COOKIE_HTTPONLY | False | JS가 토큰을 헤더에 넣어 보내야 하므로 읽을 수 있어야 함 |
CSRF 쿠키만 예외인 이유 — fetch 요청에 X-CSRFToken 헤더로 토큰을 넣어 보내야 하기 때문입니다.
Secure
#
Set-Cookie: sessionid=abc; SecureHTTPS 연결에서만 전송됩니다. HTTP로는 쿠키가 안 갑니다. 운영에선 무조건 켜야 합니다.
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True # HTTP → HTTPS 자동 리다이렉트
SECURE_HSTS_SECONDS = 31536000 # 1년 HSTS
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = TrueSecurityMiddleware (#3)가 이 설정들을 강제합니다. 자세한 운영 보안은 고급 #7.
SameSite
#
쿠키가 다른 도메인에서 시작된 요청에 같이 전송될지를 제어합니다. CSRF의 가장 강한 방어선.
| 값 | 의미 |
|---|---|
Strict | 같은 사이트의 요청에만 (외부 링크 클릭 시도 안 보냄) |
Lax | 기본 권장. GET 같은 안전한 탑레벨 네비게이션엔 보냄, POST 등엔 안 보냄 |
None | 모든 요청에 (Secure도 같이 켜야 함) |
SESSION_COOKIE_SAMESITE = "Lax" # 기본
CSRF_COOKIE_SAMESITE = "Lax"Strict는 외부 링크로 사이트에 들어왔을 때 로그인이 풀려 보일 수 있어 UX가 어색합니다. Lax가 보통 답입니다.
CSRF와 쿠키의 관계 — 짧게 #
CSRF (Cross-Site Request Forgery) — 다른 사이트가 사용자의 인증 쿠키를 편승해서 요청을 위조하는 공격.
장고의 방어:
- 폼에
{% csrf_token %}→ 숨김 input으로 토큰 삽입 - 쿠키에 같은 토큰 저장 (CSRF 쿠키)
- POST/PUT/DELETE 요청에서 두 값을 비교 (
CsrfViewMiddleware) - 쿠키와 폼 값이 다르면 403 반환
JavaScript fetch 라면 X-CSRFToken 헤더로 토큰을 같이 보냅니다.
function getCookie(name) {
const m = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)"));
return m ? m[2] : null;
}
await fetch("/api/posts/", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRFToken": getCookie("csrftoken"),
},
body: JSON.stringify(data),
});SameSite=Lax가 켜져 있으면 단순 POST 위조는 이미 막힙니다. CSRF 토큰은 이중 방어 수단입니다.
작은 실전 — 비로그인 장바구니 #
세션을 쓰는 흔한 경우 — 로그인 안 한 사용자의 장바구니를 세션에 임시로 보관하는 패턴입니다.
def add_to_cart(request, product_id):
cart = request.session.get("cart", {})
cart[str(product_id)] = cart.get(str(product_id), 0) + 1
request.session["cart"] = cart
request.session.modified = True # dict 내부 변경은 자동 감지 안 됨!
return redirect("cart_detail")
def cart_detail(request):
cart = request.session.get("cart", {})
products = Product.objects.filter(pk__in=cart.keys())
items = [(p, cart[str(p.pk)]) for p in products]
return render(request, "cart/detail.html", {"items": items})함정 한 줄: dict 내부의 값을 바꿨을 때 장고가 자동으로 감지하지 못합니다. request.session.modified = True로 명시해야 저장됩니다. 또는 위 코드처럼 cart 자체를 다시 request.session["cart"] = cart로 대입하는 방법도 있습니다.
정리 #
이번 글에서 잡은 것:
- 메시지 —
messages.success/info/warning/error/debug, 템플릿{% for message in messages %} - CBV는
SuccessMessageMixin - 메시지 백엔드 — 기본
FallbackStorage가 보통 답 - 세션 —
request.session["key"],get,flush,cycle_key,set_expiry - 세션 백엔드:
db(기본),cache,cached_db,file,signed_cookies - 만료된 세션 청소:
python manage.py clearsessions - 쿠키 —
request.COOKIES,response.set_cookie,response.delete_cookie - 보안 속성:
HttpOnly— JS 접근 차단 (세션 쿠키엔 필수)Secure— HTTPS에서만 전송 (운영 필수)SameSite=Lax— CSRF 강한 방어선
- 운영 설정:
SESSION_COOKIE_SECURE,CSRF_COOKIE_SECURE,SECURE_SSL_REDIRECT,HSTS - CSRF 토큰 + 쿠키 비교, fetch는
X-CSRFToken헤더 - 세션의 dict 내부 변경은
request.session.modified = True
다음 글(#6 Static/Media 운영)에서는 기초 #5에서 첫인사한 정적 파일을, 운영 관점으로 다시 봅니다 — collectstatic, MEDIA_*, S3 / WhiteNoise / Storage 백엔드까지 다룹니다.