완성된 블로그 프로젝트 만들기
지금까지 배운 Django 기능들을 통합하여 실제 운영 가능한 블로그를 만들어봅시다. 이 프로젝트는 FastAPI 개발자들이 Django의 전체적인 개발 플로우를 이해할 수 있도록 설계되었습니다.
1. 프로젝트 요구사항
기능 요구사항
- 사용자 인증 (회원가입, 로그인, 로그아웃)
- 포스트 CRUD (생성, 조회, 수정, 삭제)
- 카테고리 및 태그 시스템
- 댓글 시스템
- 검색 기능
- 페이지네이션
- 관리자 인터페이스
- 반응형 웹 디자인
기술 스택
- Django 5.0+
- Bootstrap 5
- SQLite (개발) / PostgreSQL (운영)
- Pillow (이미지 처리)
2. 프로젝트 구조 설계
myblog/
├── myblog/ # 프로젝트 설정
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── accounts/ # 사용자 관리
├── blog/ # 블로그 기능
├── static/ # 정적 파일
├── media/ # 업로드 파일
├── templates/ # 템플릿
└── requirements.txt
3. 프로젝트 생성 및 설정
프로젝트 생성
# 가상환경 생성 및 활성화
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Django 및 필요 패키지 설치
pip install django pillow
# 프로젝트 생성
python -m django startproject myblog
# django-admin startproject myblog
cd myblog
# 앱 생성
python manage.py startapp blog
python manage.py startapp accounts
settings.py 설정
# myblog/settings.py
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = 'your-secret-key-here'
DEBUG = True
ALLOWED_HOSTS = []
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Local apps
'blog',
'accounts',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'myblog.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / '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',
],
},
},
]
# Database
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Static files
STATIC_URL = '/static/'
STATICFILES_DIRS = [BASE_DIR / 'static']
STATIC_ROOT = BASE_DIR / 'staticfiles'
# Media files
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Authentication
LOGIN_URL = 'accounts:login'
LOGIN_REDIRECT_URL = 'blog:post_list'
LOGOUT_REDIRECT_URL = 'blog:post_list'
# Internationalization
LANGUAGE_CODE = 'ko-kr'
TIME_ZONE = 'Asia/Seoul'
USE_I18N = True
USE_TZ = True
4. 모델 설계
블로그 모델
# blog/models.py
from django.db import models
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils.text import slugify
class Category(models.Model):
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(unique=True, blank=True)
description = models.TextField(blank=True)
class Meta:
verbose_name_plural = "Categories"
ordering = ['name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class Tag(models.Model):
name = models.CharField(max_length=50, unique=True)
slug = models.SlugField(unique=True, blank=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class Post(models.Model):
STATUS_CHOICES = [
('draft', '초안'),
('published', '공개'),
]
title = models.CharField(max_length=200)
slug = models.SlugField(unique=True, blank=True)
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name='posts')
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, related_name='posts')
content = models.TextField()
excerpt = models.TextField(max_length=500, blank=True)
featured_image = models.ImageField(upload_to='posts/images/', blank=True, null=True)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='draft')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
view_count = models.PositiveIntegerField(default=0)
tags = models.ManyToManyField(Tag, blank=True, related_name='posts')
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['-created_at', 'status']),
]
def __str__(self):
return self.title
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.title)
# 요약 자동 생성
if not self.excerpt:
self.excerpt = self.content[:200] + '...' if len(self.content) > 200 else self.content
super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('blog:post_detail', kwargs={'slug': self.slug})
@property
def is_published(self):
return self.status == 'published'
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
author = models.ForeignKey(User, on_delete=models.CASCADE)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
is_approved = models.BooleanField(default=True)
class Meta:
ordering = ['created_at']
def __str__(self):
return f'{self.author.username}의 댓글: {self.content[:50]}'
5. 뷰 구현
블로그 뷰
# blog/views.py
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages
from django.db.models import Q, F
from django.urls import reverse_lazy
from .models import Post, Category, Tag, Comment
from .forms import PostForm, CommentForm
class PostListView(ListView):
model = Post
template_name = 'blog/post_list.html'
context_object_name = 'posts'
paginate_by = 6
def get_queryset(self):
queryset = Post.objects.filter(status='published').select_related('author', 'category')
# 검색 기능
search = self.request.GET.get('search')
if search:
queryset = queryset.filter(
Q(title__icontains=search) |
Q(content__icontains=search) |
Q(tags__name__icontains=search)
).distinct()
# 카테고리 필터
category_slug = self.request.GET.get('category')
if category_slug:
queryset = queryset.filter(category__slug=category_slug)
# 태그 필터
tag_slug = self.request.GET.get('tag')
if tag_slug:
queryset = queryset.filter(tags__slug=tag_slug)
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search'] = self.request.GET.get('search', '')
context['categories'] = Category.objects.all()
context['popular_posts'] = Post.objects.filter(
status='published'
).order_by('-view_count')[:5]
return context
class PostDetailView(DetailView):
model = Post
template_name = 'blog/post_detail.html'
context_object_name = 'post'
slug_field = 'slug'
slug_url_kwarg = 'slug'
def get_queryset(self):
return Post.objects.filter(status='published').select_related('author', 'category')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# 조회수 증가
Post.objects.filter(pk=self.object.pk).update(view_count=F('view_count') + 1)
# 댓글
context['comments'] = self.object.comments.filter(
is_approved=True
).select_related('author')
context['comment_form'] = CommentForm()
# 관련 포스트
context['related_posts'] = Post.objects.filter(
category=self.object.category,
status='published'
).exclude(pk=self.object.pk)[:3]
return context
class PostCreateView(LoginRequiredMixin, CreateView):
model = Post
form_class = PostForm
template_name = 'blog/post_form.html'
def form_valid(self, form):
form.instance.author = self.request.user
messages.success(self.request, '포스트가 작성되었습니다.')
return super().form_valid(form)
class PostUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
model = Post
form_class = PostForm
template_name = 'blog/post_form.html'
slug_field = 'slug'
slug_url_kwarg = 'slug'
def test_func(self):
post = self.get_object()
return self.request.user == post.author or self.request.user.is_staff
def form_valid(self, form):
messages.success(self.request, '포스트가 수정되었습니다.')
return super().form_valid(form)
class PostDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView):
model = Post
template_name = 'blog/post_confirm_delete.html'
success_url = reverse_lazy('blog:post_list')
slug_field = 'slug'
slug_url_kwarg = 'slug'
def test_func(self):
post = self.get_object()
return self.request.user == post.author or self.request.user.is_staff
def delete(self, request, *args, **kwargs):
messages.success(request, '포스트가 삭제되었습니다.')
return super().delete(request, *args, **kwargs)
@login_required
def add_comment(request, post_slug):
post = get_object_or_404(Post, slug=post_slug, status='published')
if request.method == 'POST':
form = CommentForm(request.POST)
if form.is_valid():
comment = form.save(commit=False)
comment.post = post
comment.author = request.user
comment.save()
messages.success(request, '댓글이 작성되었습니다.')
else:
messages.error(request, '댓글 작성에 실패했습니다.')
return redirect('blog:post_detail', slug=post_slug)
6. 폼 정의
# blog/forms.py
from django import forms
from .models import Post, Comment
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ['title', 'category', 'content', 'excerpt', 'featured_image', 'tags', 'status']
widgets = {
'title': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '제목을 입력하세요'
}),
'category': forms.Select(attrs={'class': 'form-select'}),
'content': forms.Textarea(attrs={
'class': 'form-control',
'rows': 15,
'placeholder': '내용을 입력하세요'
}),
'excerpt': forms.Textarea(attrs={
'class': 'form-control',
'rows': 3,
'placeholder': '요약을 입력하세요 (선택사항)'
}),
'featured_image': forms.FileInput(attrs={'class': 'form-control'}),
'tags': forms.SelectMultiple(attrs={'class': 'form-select'}),
'status': forms.Select(attrs={'class': 'form-select'}),
}
class CommentForm(forms.ModelForm):
class Meta:
model = Comment
fields = ['content']
widgets = {
'content': forms.Textarea(attrs={
'class': 'form-control',
'rows': 4,
'placeholder': '댓글을 입력하세요'
}),
}
7. URL 설정
# blog/urls.py
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
path('', views.PostListView.as_view(), name='post_list'),
path('post/<slug:slug>/', views.PostDetailView.as_view(), name='post_detail'),
path('post/new/', views.PostCreateView.as_view(), name='post_create'),
path('post/<slug:slug>/edit/', views.PostUpdateView.as_view(), name='post_edit'),
path('post/<slug:slug>/delete/', views.PostDeleteView.as_view(), name='post_delete'),
path('post/<slug:post_slug>/comment/', views.add_comment, name='add_comment'),
]
# myblog/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('blog.urls')),
path('accounts/', include('accounts.urls')),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
8. 템플릿 구현
기본 템플릿
<!-- templates/base.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}My Blog{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
{% load static %}
<link href="{% static 'css/style.css' %}" rel="stylesheet">
</head>
<body>
<!-- 네비게이션 -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{% url 'blog:post_list' %}">
<i class="fas fa-blog"></i> My Blog
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="{% url 'blog:post_list' %}">홈</a>
</li>
{% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{% url 'blog:post_create' %}">글쓰기</a>
</li>
{% endif %}
</ul>
<ul class="navbar-nav">
{% if user.is_authenticated %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown">
{{ user.username }}님
</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'accounts:profile' %}">프로필</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'accounts:logout' %}">로그아웃</a></li>
</ul>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'accounts:login' %}">로그인</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'accounts:signup' %}">회원가입</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- 메시지 -->
{% if messages %}
<div class="container mt-3">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
</div>
{% endif %}
<!-- 메인 콘텐츠 -->
<main class="container my-4">
{% block content %}
{% endblock %}
</main>
<!-- 푸터 -->
<footer class="bg-dark text-light py-4 mt-5">
<div class="container text-center">
<p>© 2024 My Blog. FastAPI 개발자를 위한 Django 과정.</p>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
포스트 목록 템플릿
<!-- templates/blog/post_list.html -->
{% extends 'base.html' %}
{% block content %}
<div class="row">
<!-- 메인 콘텐츠 -->
<div class="col-lg-8">
<!-- 검색 폼 -->
<div class="card mb-4">
<div class="card-body">
<form method="get" class="d-flex">
<input type="text" name="search" value="{{ search }}"
class="form-control me-2" placeholder="검색어를 입력하세요">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i>
</button>
</form>
</div>
</div>
<!-- 포스트 목록 -->
<div class="row">
{% for post in posts %}
<div class="col-md-6 mb-4">
<div class="card h-100">
{% if post.featured_image %}
<img src="{{ post.featured_image.url }}" class="card-img-top" style="height: 200px; object-fit: cover;">
{% endif %}
<div class="card-body d-flex flex-column">
<h5 class="card-title">
<a href="{{ post.get_absolute_url }}" class="text-decoration-none">
{{ post.title }}
</a>
</h5>
<p class="card-text">{{ post.excerpt }}</p>
<div class="mt-auto">
<small class="text-muted">
<i class="fas fa-user"></i> {{ post.author.username }}
<i class="fas fa-calendar ms-2"></i> {{ post.created_at|date:"Y-m-d" }}
<i class="fas fa-eye ms-2"></i> {{ post.view_count }}
</small>
</div>
</div>
</div>
</div>
{% empty %}
<div class="col-12">
<div class="text-center py-5">
<h4>포스트가 없습니다.</h4>
{% if user.is_authenticated %}
<a href="{% url 'blog:post_create' %}" class="btn btn-primary">첫 번째 포스트 작성하기</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- 페이지네이션 -->
{% if is_paginated %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% if search %}&search={{ search }}{% endif %}">처음</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search %}&search={{ search }}{% endif %}">이전</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">{{ page_obj.number }} / {{ page_obj.paginator.num_pages }}</span>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search %}&search={{ search }}{% endif %}">다음</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if search %}&search={{ search }}{% endif %}">마지막</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
<!-- 사이드바 -->
<div class="col-lg-4">
<!-- 카테고리 -->
<div class="card mb-4">
<div class="card-header">
<h5><i class="fas fa-folder"></i> 카테고리</h5>
</div>
<div class="list-group list-group-flush">
<a href="{% url 'blog:post_list' %}" class="list-group-item list-group-item-action">
전체 포스트
</a>
{% for category in categories %}
<a href="?category={{ category.slug }}" class="list-group-item list-group-item-action">
{{ category.name }}
</a>
{% endfor %}
</div>
</div>
<!-- 인기 포스트 -->
<div class="card">
<div class="card-header">
<h5><i class="fas fa-fire"></i> 인기 포스트</h5>
</div>
<div class="list-group list-group-flush">
{% for post in popular_posts %}
<a href="{{ post.get_absolute_url }}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h6 class="mb-1">{{ post.title|truncatechars:30 }}</h6>
<small>{{ post.view_count }}</small>
</div>
</a>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}
9. Admin 설정
# blog/admin.py
from django.contrib import admin
from .models import Category, Tag, Post, Comment
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'post_count']
prepopulated_fields = {'slug': ('name',)}
def post_count(self, obj):
return obj.posts.count()
post_count.short_description = '포스트 수'
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'post_count']
prepopulated_fields = {'slug': ('name',)}
def post_count(self, obj):
return obj.posts.count()
post_count.short_description = '포스트 수'
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ['title', 'author', 'category', 'status', 'created_at', 'view_count']
list_filter = ['status', 'created_at', 'category', 'tags']
search_fields = ['title', 'content']
prepopulated_fields = {'slug': ('title',)}
filter_horizontal = ['tags']
date_hierarchy = 'created_at'
fieldsets = [
('기본 정보', {
'fields': ['title', 'slug', 'author', 'category']
}),
('내용', {
'fields': ['content', 'excerpt', 'featured_image']
}),
('메타데이터', {
'fields': ['tags', 'status'],
}),
]
@admin.register(Comment)
class CommentAdmin(admin.ModelAdmin):
list_display = ['post', 'author', 'content_short', 'created_at', 'is_approved']
list_filter = ['is_approved', 'created_at']
search_fields = ['content', 'author__username', 'post__title']
actions = ['approve_comments', 'disapprove_comments']
def content_short(self, obj):
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
content_short.short_description = '내용'
def approve_comments(self, request, queryset):
queryset.update(is_approved=True)
approve_comments.short_description = '선택한 댓글 승인'
def disapprove_comments(self, request, queryset):
queryset.update(is_approved=False)
disapprove_comments.short_description = '선택한 댓글 비승인'
10. 배포 준비
requirements.txt
환경변수 설정 (.env)
SECRET_KEY=your-secret-key-here
DEBUG=True
DATABASE_URL=sqlite:///db.sqlite3
ALLOWED_HOSTS=localhost,127.0.0.1
운영 환경 설정
# settings/production.py
from .base import *
from decouple import config
DEBUG = False
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='').split(',')
# 보안 설정
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
# 정적 파일 (Whitenoise 사용)
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
정리
완성된 블로그 프로젝트 특징: - 실용적 기능: 실제 사용 가능한 완전한 블로그 - Django 통합: 모든 Django 기능을 실제로 적용 - 확장 가능: 추가 기능을 쉽게 구현할 수 있는 구조 - 관리 편의성: Admin을 통한 효율적인 콘텐츠 관리
FastAPI 개발자를 위한 학습 포인트: - Django의 MTV 패턴 실제 적용 - ORM을 통한 복잡한 데이터 관계 처리 - 템플릿 시스템으로 동적 UI 구현 - 내장 기능들의 실용적 활용
다음 장에서는 Django REST Framework를 통해 API 개발까지 다뤄보겠습니다!