Django 기초 | Python 풀스택 웹 프레임워크 시작하기

이 글의 핵심

Django는 신문사 사내 도구에서 출발해 인증·ORM·Admin을 기본으로 갖춘 배터리 포함 프레임워크가 됐습니다. MTV라는 용어가 MVC와 어떻게 대응하는지, ORM이 SQL을 감추면서도 우회로를 열어 둔 이유, Admin을 사용자용 UI로 쓰면 안 되는 이유를 먼저 짚고 실제 구현으로 넘어갑니다.

들어가며

Django는 “배터리 포함(Batteries Included)” 철학의 Python 웹 프레임워크입니다. 인증, ORM, Admin, 폼 검증, 보안(CSRF, XSS, SQL Injection 방어) 등 웹 애플리케이션에 필요한 거의 모든 기능이 기본 제공됩니다.

배터리 포함의 대가는 정해진 방식입니다. 프로젝트 구조, ORM, 템플릿, 인증이 서로 맞물려 있어서 Django가 권하는 방식을 따르면 적은 코드로 많은 것을 얻지만, 일부를 다른 도구로 바꾸려 하면(예: ORM 대신 SQLAlchemy) 그만큼 Django의 장점이 사라집니다. 관리 화면과 인증이 있는 전통적인 웹 서비스에는 강하고, 작은 JSON API 하나만 필요하다면 Flask나 FastAPI가 더 가볍습니다.


Django의 역사와 설계 철학

신문사에서 탄생한 프레임워크

Django는 2003년 미국 캔자스주 로렌스의 Lawrence Journal-World 신문사에서 탄생했습니다. 웹 개발자 Adrian Holovaty와 Simon Willison이 신문 기사, 사진, 비디오를 빠르게 웹에 게시하는 시스템을 만들다가 공통 패턴을 프레임워크로 분리한 것이 Django의 시작입니다.

2005년 오픈소스로 공개된 Django는 다음 철학을 표방했습니다:

  1. Don’t Repeat Yourself (DRY): 중복을 제거하고 재사용 가능하게
  2. Loose coupling, tight cohesion: 각 컴포넌트는 독립적이되, 함께 잘 동작
  3. Explicit is better than implicit: 마법 같은 동작보다 명시적 선언
  4. Batteries included: 인증, Admin, ORM 등 풀스택 기능 제공

왜 “신문사” 배경이 중요할까? 신문사는 빠른 마감, 대량의 콘텐츠, 복잡한 워크플로를 다루는 곳입니다. Django는 처음부터 “빠르게 기능을 만들고, 안전하게 운영하며, 쉽게 확장할 수 있어야 한다”는 현실적 요구에 최적화되었습니다.

MTV vs MVC: Django만의 용어

웹 프레임워크는 보통 MVC(Model-View-Controller) 패턴을 따릅니다. Django도 유사하지만, 용어가 다릅니다:

전통적 MVCDjango MVT역할
ModelModel데이터베이스 스키마, 비즈니스 로직
ViewTemplate사용자에게 보여지는 HTML
ControllerViewHTTP 요청을 처리하는 함수/클래스

왜 헷갈리게 만들었을까? Django 개발자들은 “Controller”라는 용어가 프레임워크 내부(URL 라우팅)에 더 가깝으며, View는 “사용자가 보는 것”이 아니라 “로직”이라고 생각했습니다. Template이 정말 “사용자가 보는 View”에 가깝다는 주장입니다.

ORM의 철학: “SQL을 숨기되, 우회로는 열어두기”

Django ORM은 데이터베이스 독립성을 제공합니다. PostgreSQL, MySQL, SQLite, Oracle 등을 같은 Python 코드로 다룰 수 있습니다:

# Python ORM 코드
Post.objects.filter(published=True).order_by('-created_at')[:10]

# PostgreSQL SQL
SELECT * FROM blog_post WHERE published = TRUE ORDER BY created_at DESC LIMIT 10;

# MySQL SQL (동일)
SELECT * FROM blog_post WHERE published = 1 ORDER BY created_at DESC LIMIT 10;

하지만 성능이 중요한 경우, raw SQL을 직접 쓸 수 있는 “탈출구”도 제공합니다:

Post.objects.raw('SELECT * FROM blog_post WHERE ...')

이는 “추상화는 좋지만, 현실에서는 최적화가 필요할 때가 있다”는 실용주의를 반영합니다.

Django Admin은 내부 관리자를 위한 CRUD 인터페이스입니다. 많은 초보자가 “Admin을 커스터마이징해서 일반 사용자에게 제공”하려 하지만, Django 공식 문서는 이를 권장하지 않습니다:

공식 문서의 표현을 옮기면, Admin은 조직 내부의 관리 도구로 쓰는 것이 권장 용도이고 프런트엔드 전체를 그 위에 만들도록 의도된 것이 아닙니다.

왜? Admin에도 모델 단위 권한 시스템이 있지만, 기본 전제는 “신뢰할 수 있는 직원이 데이터를 직접 다룬다”는 것입니다. 화면 구성이 모델 구조를 그대로 드러내고, 목록 화면은 대량의 데이터나 복잡한 관계에서 쉽게 느려지며(list_display에 관계 필드를 넣으면 행마다 추가 쿼리가 나가는 N+1이 흔합니다), 일반 사용자의 흐름에 맞춘 검증과 안내를 넣기 어렵습니다. “편집자가 급하게 기사를 수정하는” 용도로는 훌륭하지만, 고객이 쓰는 화면으로는 맞지 않습니다. 운영 환경에서는 Admin 주소를 기본값인 /admin/이 아닌 경로로 바꾸고, 가능하면 사내 네트워크나 VPN에서만 접근하게 두는 것도 흔한 관행입니다.

마이그레이션의 역사: South → Django 1.7

초기 Django(~1.6)에는 마이그레이션 시스템이 없었습니다. 모델을 변경하면 직접 SQL을 작성해서 ALTER TABLE을 실행해야 했습니다. 이를 해결하기 위해 South라는 서드파티 라이브러리가 등장했습니다(2008).

Django 1.7(2014)에서 South의 핵심 개발자가 Django 코어 팀에 합류하며 공식 마이그레이션 시스템이 내장되었습니다. 이제 makemigrations와 migrate 명령으로 스키마 변경을 자동화할 수 있습니다.


설치, 프로젝트 생성, 폴더 구조

설치 및 프로젝트 생성

startproject는 웹 사이트 전체를 감쌀 큰 상자를 만들고, startapp은 그 안에 기능 단위(예: 블로그, 결제) 모듈을 하나씩 추가하는 과정입니다. 이후 models.py는 DB 테이블 모양을, views.py는 요청 처리 순서를, templates는 화면 HTML을 담당합니다.

# Django 설치
pip install django
# 프로젝트 생성
django-admin startproject myproject
cd myproject
# 앱 생성
python manage.py startapp blog
# 개발 서버 실행
python manage.py runserver

startapp으로 앱을 만든 뒤 가장 먼저 해야 할 일은 settings.py의 INSTALLED_APPS에 'blog'를 추가하는 것입니다. 이 단계를 빠뜨리면 makemigrations가 No changes detected만 출력하고 테이블이 만들어지지 않아, 페이지를 열면 django.db.utils.OperationalError: no such table: blog_post가 납니다. 템플릿을 찾지 못한다는 TemplateDoesNotExist 오류도 흔한데, 앱 안의 템플릿은 blog/templates/blog/post_list.html처럼 앱 이름 폴더를 한 번 더 두고 'blog/post_list.html'로 참조하는 것이 관례입니다. 폴더를 한 단계 줄이면 당장은 동작하지만, 다른 앱에 같은 이름의 템플릿이 생기는 순간 엉뚱한 파일이 선택됩니다.

프로젝트 구조

myproject/
├── myproject/
│   ├── __init__.py
│   ├── settings.py  # 설정
│   ├── urls.py      # URL 라우팅
│   └── wsgi.py
├── blog/
│   ├── models.py    # 모델 (데이터)
│   ├── views.py     # 뷰 (로직)
│   ├── urls.py
│   └── templates/   # 템플릿 (화면)
└── manage.py

모델 정의와 마이그레이션

모델 정의

# blog/models.py
from django.conf import settings
from django.db import models
class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    
    def __str__(self):
        return self.title
    
    class Meta:
        ordering = ['-created_at']

마이그레이션

# 마이그레이션 파일 생성
python manage.py makemigrations
# 데이터베이스에 적용
python manage.py migrate

author는 문자열이 아니라 사용자 모델을 가리키는 ForeignKey로 두었습니다. 작성자 이름을 문자열로 저장하면 사용자가 이름을 바꾸거나 탈퇴했을 때 연결이 끊기고, 뒤에서 다룰 select_related('author')도 FieldError: Non-relational field given in select_related: 'author'로 실패합니다. User를 직접 import하지 않고 settings.AUTH_USER_MODEL을 쓰는 것은, 나중에 커스텀 사용자 모델로 바꿔도 이 코드를 고치지 않기 위해서입니다. 커스텀 사용자 모델은 첫 migrate 전에 정해 두는 것이 좋습니다. 운영 중에 바꾸려면 기존 마이그레이션과 외래 키를 모두 손봐야 해서 매우 번거롭습니다.

마이그레이션 파일은 코드와 함께 커밋해야 하는 산출물입니다. 팀원마다 각자 makemigrations를 돌리면 같은 번호의 마이그레이션이 두 개 생겨 Conflicting migrations detected 오류가 나고, 운영 DB에 적용되는 순서도 어긋납니다. 또 열 추가처럼 간단해 보이는 변경도 큰 테이블에서는 잠금 때문에 오래 걸릴 수 있으므로, 적용 전에 python manage.py sqlmigrate blog 0002로 실제 SQL을 확인하는 습관이 도움이 됩니다.


함수 기반 뷰와 클래스 기반 뷰

함수 기반 뷰

# blog/views.py
from django.shortcuts import render, get_object_or_404
from .models import Post
def post_list(request):
    posts = Post.objects.all()
    return render(request, 'blog/post_list.html', {'posts': posts})
def post_detail(request, pk):
    post = get_object_or_404(Post, pk=pk)
    return render(request, 'blog/post_detail.html', {'post': post})

클래스 기반 뷰

from django.views.generic import ListView, DetailView
from .models import Post
class PostListView(ListView):
    model = Post
    template_name = 'blog/post_list.html'
    context_object_name = 'posts'
    paginate_by = 10
class PostDetailView(DetailView):
    model = Post
    template_name = 'blog/post_detail.html'

URL 라우팅

URL 패턴

# blog/urls.py
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
    path('', views.post_list, name='post_list'),
    path('post/<int:pk>/', views.post_detail, name='post_detail'),
]
# myproject/urls.py
# 필요한 모듈 import
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
    path('admin/', admin.site.urls),
    path('blog/', include('blog.urls')),
]

템플릿 작성

템플릿 작성

<!-- templates/blog/post_list.html -->
<!DOCTYPE html>
<html>
<head>
    <title>블로그</title>
</head>
<body>
    <h1>블로그 포스트</h1>
    
    {% for post in posts %}
        <article>
            <h2>
                <a href="{% url 'blog:post_detail' post.pk %}">
                    {{ post.title }}
                </a>
            </h2>
            <p>{{ post.content|truncatewords:30 }}</p>
            <small>{{ post.created_at|date:"Y-m-d H:i" }}</small>
        </article>
    {% empty %}
        <p>포스트가 없습니다.</p>
    {% endfor %}
</body>
</html>

Admin 등록과 슈퍼유저

Admin 등록

# blog/admin.py
from django.contrib import admin
from .models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ['title', 'author', 'created_at']
    list_filter = ['created_at', 'author']
    search_fields = ['title', 'content']
    date_hierarchy = 'created_at'

슈퍼유저 생성

python manage.py createsuperuser
# Username: admin
# Email: [email protected]
# Password: ****
# http://localhost:8000/admin/ 접속

간단한 블로그 만들기

# blog/views.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect
from django.contrib import messages
from .models import Post
@login_required
def create_post(request):
    if request.method == 'POST':
        title = request.POST.get('title')
        content = request.POST.get('content')

        Post.objects.create(
            title=title,
            content=content,
            author=request.user,  # 작성자는 폼 값이 아니라 로그인한 사용자
        )
        
        messages.success(request, '포스트가 생성되었습니다!')
        return redirect('blog:post_list')
    
    return render(request, 'blog/create_post.html')

이 뷰를 처음 실행하면 폼을 제출하는 순간 Forbidden (403) CSRF verification failed. Request aborted.를 만나기 쉽습니다. Django는 기본으로 CSRF 보호 미들웨어가 켜져 있어서, POST 폼 안에 {% csrf_token %}을 넣지 않으면 요청을 거부합니다. 이 오류를 없애려고 @csrf_exempt를 붙이는 것은 보호 장치를 끄는 것이므로, 템플릿에 토큰을 넣는 것이 올바른 해결입니다. 또 이 예제는 흐름을 보여 주려고 request.POST.get()으로 값을 직접 꺼냈지만, 빈 제목이나 너무 긴 입력을 검증하지 않습니다. 실무에서는 모델에서 ModelForm을 만들어 form.is_valid()로 검증하고, 실패하면 오류 메시지와 함께 폼을 다시 보여 주는 것이 Django의 표준 방식입니다. 처리 후 redirect로 다른 주소로 보내는 것(POST-Redirect-GET)은 새로고침 때 같은 글이 두 번 등록되는 것을 막기 위한 것입니다.


쿼리 최적화: N+1 문제

ORM에서 가장 흔한 성능 문제는 목록을 돌면서 관계를 참조할 때 생기는 N+1 쿼리입니다.

# ❌ 게시글 100개면 쿼리 101번: 목록 1번 + 작성자 조회 100번
for post in Post.objects.all():
    print(post.author.username)

# ✅ ForeignKey·OneToOne은 JOIN으로 한 번에
for post in Post.objects.select_related('author'):
    print(post.author.username)

# ✅ 역방향·ManyToMany는 별도 쿼리 1번으로 모아서 (Post에 tags = ManyToManyField(Tag)가 있다고 가정)
for post in Post.objects.prefetch_related('tags'):
    print([t.name for t in post.tags.all()])

쿼리셋은 지연 평가라서 반복문에 들어가기 전까지 SQL이 실행되지 않고, post.author에 접근하는 순간마다 추가 쿼리가 나갑니다. 템플릿에서 {{ post.author.name }}을 쓰는 경우도 똑같이 N+1이 되는데, 템플릿 안이라 코드 리뷰에서 눈에 잘 띄지 않습니다. 개발 중에는 django-debug-toolbar로 페이지당 쿼리 수를 보거나, 테스트에서 self.assertNumQueries(2)로 쿼리 수를 고정해 두면 누군가 관계 접근을 추가했을 때 바로 드러납니다. select_related는 JOIN이라 관계가 많을수록 결과 행이 커지므로, 다대다·역방향 관계에는 prefetch_related를 씁니다.

Django REST Framework로 API 만들기

JSON API가 필요하면 Django REST Framework(DRF)가 사실상 표준입니다. 모델에서 직렬화기(serializer)와 뷰셋을 만들고 라우터에 등록하면 목록·상세·생성·수정·삭제 엔드포인트가 한 번에 생깁니다.

# posts/serializers.py
from rest_framework import serializers
from .models import Post

class PostSerializer(serializers.ModelSerializer):
    class Meta:
        model = Post
        fields = ['id', 'title', 'content', 'created_at']
        read_only_fields = ['id', 'created_at']

# posts/views.py
from rest_framework import viewsets, permissions

class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.select_related('author')
    serializer_class = PostSerializer
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]

# posts/urls.py
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register(r'posts', PostViewSet)
urlpatterns = router.urls

주의할 점은 DRF의 기본 권한이 AllowAny 라는 것입니다. 설정(REST_FRAMEWORK['DEFAULT_PERMISSION_CLASSES'])이나 뷰에서 권한을 지정하지 않은 ModelViewSet은 로그인하지 않은 사용자도 수정·삭제까지 할 수 있는 API가 됩니다. 튜토리얼 코드를 그대로 배포했다가 데이터가 지워지는 전형적인 사고라서, 프로젝트를 시작할 때 전역 기본값을 IsAuthenticated로 두고 공개가 필요한 뷰만 열어 주는 편이 안전합니다. fields = '__all__'도 같은 이유로 피합니다. 나중에 모델에 비밀번호 해시나 내부 메모 같은 필드가 추가되면 API로 그대로 노출됩니다. 또 이 직렬화기는 author를 받지 않으므로, 그대로 POST하면 필수 외래 키가 비어 IntegrityError가 납니다. 뷰셋에 def perform_create(self, serializer): serializer.save(author=self.request.user)를 추가해 작성자를 서버에서 채워 넣어야 합니다.

배포 전 체크리스트

python manage.py check --deploy   # 보안 관련 설정 누락을 경고해 준다
python manage.py collectstatic --noinput
gunicorn myproject.wsgi:application --bind 0.0.0.0:8000 --workers 3
  • DEBUG = False: 켜 둔 채 배포하면 에러 페이지에 설정값과 스택 트레이스가 노출됩니다.
  • ALLOWED_HOSTS에 실제 도메인을 넣습니다. DEBUG = False인데 비어 있으면 모든 요청이 400(Bad Request)이 되어 “배포하자마자 사이트가 안 열린다”의 가장 흔한 원인입니다.
  • SECRET_KEY와 DB 비밀번호는 코드가 아니라 환경 변수에서 읽습니다.
  • runserver는 개발용입니다. 운영에서는 Gunicorn·uWSGI 같은 WSGI 서버(비동기가 필요하면 Uvicorn 등 ASGI 서버) 뒤에 Nginx를 두고, 정적 파일은 collectstatic으로 모아 Nginx나 WhiteNoise가 서빙하게 합니다. DEBUG = False에서는 Django가 정적 파일을 서빙하지 않으므로, 이 단계를 빼먹으면 CSS가 전부 깨진 화면이 나옵니다.

Django 기본 요약

  1. Django: 풀스택 웹 프레임워크
  2. MVT: Model, View, Template
  3. ORM: Python 코드로 DB 조작
  4. Admin: 자동 관리자 페이지
  5. 마이그레이션: 데이터베이스 스키마 관리

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Django와 FastAPI 중 무엇으로 시작해야 하나요?

A. 관리자 화면·인증·ORM·폼·마이그레이션을 한 번에 갖춘 서비스를 빨리 만들어야 하면 Django가 유리하고, 타입 힌트 기반의 가벼운 JSON API나 비동기 I/O가 중심이면 FastAPI가 간결합니다. Django도 3.1부터 비동기 뷰를 지원하고 4.1부터 ORM에 aget()·acreate() 같은 비동기 메서드가 생겼지만, 내부적으로는 여전히 동기 DB 드라이버를 스레드에서 실행하는 방식이 많아서, 비동기가 핵심인 서비스라면 그 점을 먼저 확인하는 것이 좋습니다.