Skip to content

완성된 블로그 프로젝트 만들기

지금까지 배운 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>&copy; 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

Django>=5.0.0
Pillow>=10.0.0
gunicorn>=20.1.0  # 운영 서버용
python-decouple>=3.8  # 환경변수 관리

환경변수 설정 (.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 개발까지 다뤄보겠습니다!

Comments