장고 기초 #3 Models와 ORM 기초

6 분 소요

#2 프로젝트 셋업에서 빈 blog 앱을 만들었습니다. 이번 글은 그 안의 models.pyDB의 형태를 그리고, ORM으로 데이터를 처음 다루는 법을 살펴봅니다. SQL 한 줄도 직접 안 씁니다 — 그게 ORM의 약속입니다.

Model 한 줄 정의 #

Django의 ModelDB 테이블에 매핑되는 Python 클래스 입니다. 클래스 하나 = 테이블 하나, 클래스 속성 = 컬럼.

blog/models.py
from django.db import models


class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    is_published = models.BooleanField(default=False)

    def __str__(self) -> str:
        return self.title

이 클래스 하나로 다음 SQL이 생성됩니다 (직접 안 써도 됩니다 — 마이그레이션이 만듭니다).

자동 생성되는 SQL (참고)
CREATE TABLE "blog_post" (
    "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
    "title" varchar(200) NOT NULL,
    "content" text NOT NULL,
    "created_at" datetime NOT NULL,
    "updated_at" datetime NOT NULL,
    "is_published" bool NOT NULL
);

테이블 이름은 <app>_<모델> (소문자) — blog_post. id는 자동 생성되는 PK입니다.

자주 쓰는 필드 타입 #

blog/models.py — 필드 카탈로그
from django.db import models


class FieldDemo(models.Model):
    # 문자열
    name = models.CharField(max_length=100)              # VARCHAR
    description = models.TextField()                     # TEXT (길이 제한 없음)
    slug = models.SlugField(unique=True)                 # URL 친화 문자열

    # 숫자
    views = models.IntegerField(default=0)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    rating = models.FloatField()

    # 불리언
    is_active = models.BooleanField(default=True)

    # 날짜/시간
    published_at = models.DateField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)  # 생성 시 자동
    updated_at = models.DateTimeField(auto_now=True)      # 저장 시마다 자동

    # 기타
    email = models.EmailField()
    website = models.URLField(blank=True)
    image = models.ImageField(upload_to="images/", blank=True)
    file = models.FileField(upload_to="files/", blank=True)
    uuid = models.UUIDField()

자주 보이는 옵션:

  • null=True — DB 레벨에서 NULL 허용
  • blank=True — 폼 검증에서 빈 값 허용
  • default=... — 기본값
  • unique=True — 유일 제약
  • choices=[...] — 정해진 값들만 허용 (Enum 비슷)
  • db_index=True — 인덱스

null vs blank 헷갈리는 포인트 — nullDB, blank. 문자열 필드에는 null=True를 잘 안 씁니다 (빈 문자열 ""로 두는 게 컨벤션).

choices — Enum 흉내 #

choices 패턴
class Post(models.Model):
    class Status(models.TextChoices):
        DRAFT = "draft", "초안"
        PUBLISHED = "published", "발행됨"
        ARCHIVED = "archived", "보관됨"

    title = models.CharField(max_length=200)
    status = models.CharField(
        max_length=20,
        choices=Status.choices,
        default=Status.DRAFT,
    )

TextChoices (또는 IntegerChoices)가 Python 3의 Enum 비슷하게 동작하면서 DB 값 + 사람이 읽는 라벨을 한 번에 정의합니다. 폼 / Admin에서 자동으로 select 옵션이 됩니다.

Migration — 모델 변경을 SQL로 #

모델을 만들었거나 바꿨다면 두 단계:

마이그레이션 흐름
uv run python manage.py makemigrations blog
uv run python manage.py migrate

각 단계의 일:

  1. makemigrations — 모델과 현재 마이그레이션 상태를 비교, 차이를 마이그레이션 파일로 저장 (blog/migrations/0001_initial.py 같은)
  2. migrate — 마이그레이션 파일을 실제 DB에 적용
blog/migrations/0001_initial.py (자동 생성)
class Migration(migrations.Migration):
    initial = True
    dependencies = []
    operations = [
        migrations.CreateModel(
            name="Post",
            fields=[
                ("id", models.AutoField(primary_key=True)),
                ("title", models.CharField(max_length=200)),
                ("content", models.TextField()),
                ("created_at", models.DateTimeField(auto_now_add=True)),
                ("updated_at", models.DateTimeField(auto_now=True)),
                ("is_published", models.BooleanField(default=False)),
            ],
        ),
    ]

마이그레이션 파일은 반드시 git에 커밋 합니다. 팀원이 같은 스키마를 재현할 수 있어야 합니다.

자주 쓰는 마이그레이션 명령 #

자주 쓰는 명령들
# 적용 상태 확인
uv run python manage.py showmigrations

# 특정 마이그레이션의 SQL 미리보기
uv run python manage.py sqlmigrate blog 0001

# 되돌리기 (예: 0001 이전으로)
uv run python manage.py migrate blog zero

관계 — ForeignKey, ManyToMany, OneToOne #

블로그 도메인에 흔한 관계들:

blog/models.py — 관계
from django.conf import settings
from django.db import models


class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)
    slug = models.SlugField(unique=True)

    def __str__(self) -> str:
        return self.name


class Post(models.Model):
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,                # 사용자 모델
        on_delete=models.CASCADE,
        related_name="posts",
    )
    tags = models.ManyToManyField(Tag, related_name="posts", blank=True)
    title = models.CharField(max_length=200)
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self) -> str:
        return self.title


class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    body = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)


class Profile(models.Model):
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="profile",
    )
    bio = models.TextField(blank=True)

세 가지 관계:

  • ForeignKey — N:1. Post -> User (한 사용자가 여러 글)
  • ManyToManyField — N:N. Post <-> Tag (한 글이 여러 태그, 한 태그가 여러 글)
  • OneToOneField — 1:1. Profile <-> User

on_delete 옵션 #

ForeignKey는 부모가 삭제될 때 자식을 어떻게 할지를 반드시 지정해야 합니다.

옵션동작
CASCADE부모 삭제 시 자식도 삭제 (가장 흔함)
PROTECT자식이 있으면 부모 삭제 차단
SET_NULL자식의 FK를 NULL로 (null=True 필요)
SET_DEFAULT기본값으로 (default= 필요)
DO_NOTHINGDB에 맡김 (보통 사용 X)

related_name — 역참조의 이름 #

posts = user.posts.all()         # related_name="posts"
comments = post.comments.all()   # related_name="comments"

related_name을 안 주면 post_set, comment_set 같은 기본 이름이 만들어집니다. 명시적으로 주는 게 가독성에 좋습니다.

settings.AUTH_USER_MODEL을 쓰는 이유 #

from django.contrib.auth.models import User를 직접 import 해도 동작은 하지만, 나중에 커스텀 User 모델로 바꿀 때 모든 import를 고쳐야 합니다. settings.AUTH_USER_MODEL로 두면 한 번에 갈아끼울 수 있습니다. 컨벤션 입니다.

QuerySet — Django ORM의 핵심 #

모델이 만들어졌으면 이제 ORM으로 데이터를 다룹니다. QuerySetDB 쿼리를 표현하는 Python 객체 — lazy 하게 동작합니다 (실제 호출 전까지 SQL 안 나감).

shell 진입
uv run python manage.py shell

Create — 만들기 #

객체 생성
from blog.models import Post, Tag
from django.contrib.auth import get_user_model

User = get_user_model()
author = User.objects.create_user(username="curtis", password="secret123")

# 방법 1: 인스턴스 → save
post = Post(author=author, title="첫 글", content="안녕!")
post.save()

# 방법 2: objects.create (한 번에)
post = Post.objects.create(author=author, title="두 번째 글", content="...")

# Tag M2M 추가
tag1 = Tag.objects.create(name="django", slug="django")
tag2 = Tag.objects.create(name="python", slug="python")
post.tags.add(tag1, tag2)

Read — 조회 #

기본 조회
# 전체
Post.objects.all()                        # <QuerySet [<Post: 첫 글>, ...]>

# PK로 한 건 (없으면 DoesNotExist)
Post.objects.get(pk=1)
Post.objects.get(id=1)

# 조건
Post.objects.filter(is_published=True)
Post.objects.exclude(is_published=False)

# 첫 / 마지막 / 카운트
Post.objects.first()
Post.objects.last()
Post.objects.count()

# 정렬
Post.objects.order_by("-created_at")      # 최신 순
Post.objects.order_by("title")            # 가나다 순

# 슬라이싱 (LIMIT/OFFSET)
Post.objects.all()[:10]                   # 처음 10개
Post.objects.all()[10:20]                 # 다음 10개

# 존재 여부 (가장 빠름)
Post.objects.filter(author=author).exists()

Lookup — __으로 조건 만들기 #

자주 쓰는 lookup
# 텍스트
Post.objects.filter(title__icontains="django")    # 대소문자 무시 부분 일치
Post.objects.filter(title__startswith="안녕")
Post.objects.filter(title__endswith="!")

# 비교
Post.objects.filter(views__gt=100)                 # > 100
Post.objects.filter(views__gte=100)                # >= 100
Post.objects.filter(views__lt=10)                  # < 10
Post.objects.filter(views__lte=10)                 # <= 10

# 포함
Post.objects.filter(id__in=[1, 2, 3])
Post.objects.filter(id__range=(1, 100))

# NULL
Post.objects.filter(published_at__isnull=True)

# 날짜
Post.objects.filter(created_at__year=2026)
Post.objects.filter(created_at__date="2026-04-14")
Post.objects.filter(created_at__gte="2026-01-01")

# 관계 따라가기 (FK 점선)
Post.objects.filter(author__username="curtis")
Post.objects.filter(tags__name="django")

__ (이중 언더스코어)가 Django ORM의 공통 문법 입니다. 필드 + lookup, 또는 관계 + 필드 + lookup 형식.

Q 객체 — OR / 복잡한 조건 #

filter(...)의 키워드 인자는 모두 AND입니다. OR 또는 복잡한 조건은 Q로:

Q 객체
from django.db.models import Q

# OR
Post.objects.filter(Q(title__icontains="django") | Q(content__icontains="django"))

# AND + OR 혼합
Post.objects.filter(
    Q(is_published=True) & (Q(title__icontains="django") | Q(tags__name="django"))
)

# NOT
Post.objects.filter(~Q(author__username="curtis"))

Update / Delete #

수정 / 삭제
# 단건 수정
post = Post.objects.get(pk=1)
post.title = "수정된 제목"
post.save()

# 일괄 수정 (SQL UPDATE 한 번)
Post.objects.filter(is_published=False).update(is_published=True)

# 단건 삭제
post.delete()

# 일괄 삭제
Post.objects.filter(created_at__lt="2020-01-01").delete()

update(...)save()를 거치지 않습니다auto_now, signals 같은 게 동작하지 않습니다. 트레이드오프를 알고 쓰세요. 자세한 건 중급 #2.

Lazy QuerySet — 쿼리는 언제 나가는가 #

lazy 동작
qs = Post.objects.filter(is_published=True)   # 아직 SQL 안 나감
qs = qs.order_by("-created_at")               # 아직 안 나감
qs = qs[:10]                                   # 아직 안 나감

for post in qs:                                # ← 여기서 SQL 한 번 나감
    print(post.title)

QuerySet은 순회, 슬라이싱, len(), bool(), list(), count()이 호출될 때 비로소 SQL을 발사합니다. 그래서 메소드 체이닝이 자유롭습니다. 이건 ORM 최적화의 출발점입니다 — 고급 #3에서 자세히.

Meta — 모델 설정 #

Meta 클래스
class Post(models.Model):
    title = models.CharField(max_length=200)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]               # 기본 정렬
        verbose_name = "글"                       # Admin 표시 이름
        verbose_name_plural = "글들"
        indexes = [
            models.Index(fields=["-created_at"]),
        ]
        constraints = [
            models.UniqueConstraint(
                fields=["author", "title"],
                name="unique_author_title",
            ),
        ]

Meta가 모델의 DB / 표시 관련 설정 들을 모읍니다. ordering을 한 번 잡아두면 .all()에 자동으로 적용됩니다.

정리 #

이번 글에서 잡은 것:

  • Model = Python 클래스 = DB 테이블, 속성 = 컬럼
  • 필드 — CharField, TextField, DateTimeField, BooleanField, SlugField, …
  • null (DB)와 blank (폼)은 다름
  • TextChoices로 Enum 흉내
  • makemigrationsmigrate 두 단계, 마이그레이션 파일은 git 커밋
  • 관계 — ForeignKey (N:1), ManyToManyField (N:N), OneToOneField (1:1)
  • on_delete는 ForeignKey의 필수, 보통 CASCADE
  • related_name으로 역참조 이름 명시
  • settings.AUTH_USER_MODEL 컨벤션
  • QuerySet은 lazy — 순회/슬라이싱/exists() 시 SQL 발사
  • lookup __icontains, __gt, __in, __year, __isnull, …
  • Q 객체로 OR / NOT
  • objects.create, save, update(), delete()
  • Meta로 ordering / index / constraint

다음 글(#4 URL과 Views)에서는 이 모델로 만든 데이터를 URL과 view 함수로 노출하고, render로 첫 HTML 응답을 만들어봅니다. ORM의 더 깊은 이야기 (select_related, prefetch_related, annotate)는 중급 #2 ORM 중급에서 이어집니다.

X