장고 기초 #3 Models와 ORM 기초
#2 프로젝트 셋업에서 빈 blog 앱을 만들었습니다. 이번 글은 그 안의 models.py에 DB의 형태를 그리고, ORM으로 데이터를 처음 다루는 법을 살펴봅니다. SQL 한 줄도 직접 안 씁니다 — 그게 ORM의 약속입니다.
Model 한 줄 정의 #
Django의 Model은 DB 테이블에 매핑되는 Python 클래스 입니다. 클래스 하나 = 테이블 하나, 클래스 속성 = 컬럼.
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이 생성됩니다 (직접 안 써도 됩니다 — 마이그레이션이 만듭니다).
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입니다.
자주 쓰는 필드 타입 #
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— 인덱스
nullvsblank헷갈리는 포인트 —null은 DB,blank은 폼. 문자열 필드에는null=True를 잘 안 씁니다 (빈 문자열""로 두는 게 컨벤션).
choices — Enum 흉내
#
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각 단계의 일:
makemigrations— 모델과 현재 마이그레이션 상태를 비교, 차이를 마이그레이션 파일로 저장 (blog/migrations/0001_initial.py같은)migrate— 마이그레이션 파일을 실제 DB에 적용
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 #
블로그 도메인에 흔한 관계들:
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_NOTHING | DB에 맡김 (보통 사용 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으로 데이터를 다룹니다. QuerySet은 DB 쿼리를 표현하는 Python 객체 — lazy 하게 동작합니다 (실제 호출 전까지 SQL 안 나감).
uv run python manage.py shellCreate — 만들기 #
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 — __으로 조건 만들기
#
# 텍스트
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로:
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 — 쿼리는 언제 나가는가 #
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 — 모델 설정 #
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 흉내makemigrations→migrate두 단계, 마이그레이션 파일은 git 커밋- 관계 —
ForeignKey(N:1),ManyToManyField(N:N),OneToOneField(1:1) on_delete는 ForeignKey의 필수, 보통CASCADErelated_name으로 역참조 이름 명시settings.AUTH_USER_MODEL컨벤션- QuerySet은 lazy — 순회/슬라이싱/
exists()시 SQL 발사 - lookup
__icontains,__gt,__in,__year,__isnull, … Q객체로 OR / NOTobjects.create,save,update(),delete()Meta로 ordering / index / constraint
다음 글(#4 URL과 Views)에서는 이 모델로 만든 데이터를 URL과 view 함수로 노출하고, render로 첫 HTML 응답을 만들어봅니다. ORM의 더 깊은 이야기 (select_related, prefetch_related, annotate)는 중급 #2 ORM 중급에서 이어집니다.