관계를 설계해야 하는 이유
게시판이 단순할 때는 Post 모델 하나만으로도 충분해 보입니다. 제목, 본문, 작성일 정도만 저장하면 목록과 상세 화면을 만들 수 있습니다. 하지만 실제 서비스에서는 곧 다른 데이터가 붙습니다.
게시글에는 댓글이 달립니다. 사용자는 프로필을 가질 수 있습니다. 게시글에는 여러 태그가 붙고, 하나의 태그는 여러 게시글에서 다시 사용됩니다. 이때 모든 정보를 Post 모델에 문자열로 몰아넣으면 처음에는 편해 보이지만, 검색·수정·통계가 어려워집니다.
Django는 이런 관계를 모델 필드로 표현합니다.
ForeignKey: 일대다 관계OneToOneField: 일대일 관계ManyToManyField: 다대다 관계
관계 설계는 단순히 테이블을 나누는 작업이 아닙니다. 나중에 데이터를 어떻게 조회할지까지 함께 정하는 일입니다.
AI 생성 이미지
댓글은 ForeignKey로 연결한다
댓글은 게시글 하나에 속합니다. 하나의 게시글에는 여러 댓글이 달릴 수 있습니다. 이것이 일대다 관계입니다. Django에서는 ForeignKey로 표현합니다.
# boards/models.py
from django.conf import settings
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=100)
content = models.TextField()
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="posts",
)
created_at = models.DateTimeField(auto_now_add=True)
view_count = models.PositiveIntegerField(default=0)
def __str__(self):
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,
related_name="comments",
)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created_at"]
def __str__(self):
return self.content[:20]Comment.post는 댓글이 어느 게시글에 속하는지 나타냅니다. on_delete=models.CASCADE는 연결된 게시글이 삭제되면 댓글도 함께 삭제하겠다는 뜻입니다. 게시글이 사라졌는데 댓글만 남아 있으면 의미가 없기 때문에 댓글에는 보통 이 설정을 사용합니다.
여기서 중요한 옵션이 related_name입니다.
post.comments.all()
user.posts.all()
user.comments.all()related_name="comments"를 지정했기 때문에 게시글에서 댓글 목록을 post.comments.all()로 가져올 수 있습니다. 만약 related_name을 지정하지 않으면 Django는 기본적으로 comment_set 같은 이름을 만듭니다. 동작은 하지만 코드의 의미가 덜 분명합니다.
모델을 추가하거나 변경했다면 마이그레이션을 만들어 적용해야 합니다.
python manage.py makemigrations
python manage.py migrate프로필은 OneToOneField로 연결한다
사용자와 프로필은 성격이 다릅니다. 로그인에 필요한 사용자 정보는 User 모델이 담당하고, 닉네임·소개글·프로필 이미지 같은 부가 정보는 별도 모델로 분리할 수 있습니다.
사용자 한 명은 프로필 하나만 가집니다. 프로필 하나도 사용자 한 명에게만 연결됩니다. 이것이 일대일 관계입니다.
# accounts/models.py
from django.conf import settings
from django.db import models
class Profile(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="profile",
)
nickname = models.CharField(max_length=30)
bio = models.TextField(blank=True)
image = models.ImageField(upload_to="profiles/", blank=True)
def __str__(self):
return self.nickname이제 사용자 객체에서 프로필을 다음처럼 접근할 수 있습니다.
request.user.profile.nickname단, 모든 사용자에게 프로필이 반드시 생성되어 있다고 가정하면 오류가 날 수 있습니다. 회원가입 시점에 프로필을 함께 만들거나, 프로필이 없을 때 생성하는 흐름을 별도로 준비해야 합니다.
태그는 ManyToManyField로 연결한다
태그는 다대다 관계의 대표적인 예입니다. 하나의 게시글에는 django, orm, 성능 같은 여러 태그가 붙을 수 있습니다. 동시에 django 태그는 여러 게시글에서 사용될 수 있습니다.
# boards/models.py
class Tag(models.Model):
name = models.CharField(max_length=30, unique=True)
def __str__(self):
return self.name
class Post(models.Model):
title = models.CharField(max_length=100)
content = models.TextField()
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="posts",
)
tags = models.ManyToManyField(
Tag,
related_name="posts",
blank=True,
)
created_at = models.DateTimeField(auto_now_add=True)
view_count = models.PositiveIntegerField(default=0)ManyToManyField를 사용하면 Django가 중간 테이블을 자동으로 만듭니다. 개발자가 직접 post_id, tag_id를 가진 테이블을 만들지 않아도 됩니다.
사용법은 다음과 같습니다.
post.tags.add(tag)
post.tags.remove(tag)
post.tags.all()
tag.posts.all()blank=True는 폼에서 태그를 선택하지 않아도 된다는 뜻입니다. 데이터베이스에 NULL을 저장한다는 의미가 아닙니다. 다대다 관계는 별도의 연결 테이블로 관리되므로, 선택된 태그가 없으면 연결 행이 없을 뿐입니다.
템플릿에서 관계 데이터 출력하기
상세 화면에서 게시글, 작성자, 태그, 댓글을 함께 보여 준다고 생각해 봅시다.
# boards/views.py
from django.shortcuts import get_object_or_404, render
from .models import Post
def post_detail(request, pk):
post = get_object_or_404(Post, pk=pk)
return render(request, "boards/post_detail.html", {"post": post})템플릿에서는 관계 이름을 따라가며 데이터를 출력할 수 있습니다.
<h2>{{ post.title }}</h2>
<p>작성자: {{ post.author.username }}</p>
<p>{{ post.content }}</p>
<ul>
{% for tag in post.tags.all %}
<li>#{{ tag.name }}</li>
{% empty %}
<li>태그가 없습니다.</li>
{% endfor %}
</ul>
<h3>댓글</h3>
{% for comment in post.comments.all %}
<p>{{ comment.author.username }}: {{ comment.content }}</p>
{% empty %}
<p>아직 댓글이 없습니다.</p>
{% endfor %}이 코드는 읽기 쉽습니다. 상세 화면처럼 게시글 하나만 다룰 때는 큰 문제가 되지 않을 수도 있습니다. 하지만 목록 화면에서 이런 접근을 반복하면 이야기가 달라집니다. 데이터가 적을 때는 티가 나지 않지만, 게시글이 수십 개만 되어도 쿼리가 급격히 늘어날 수 있습니다.
N+1 쿼리 문제 이해하기
다음 목록 View를 보겠습니다.
posts = Post.objects.order_by("-created_at")그리고 템플릿에서 작성자와 댓글 수를 출력한다고 해 봅시다.
{% for post in posts %}
<h2>{{ post.title }}</h2>
<p>{{ post.author.username }}</p>
<p>댓글 {{ post.comments.count }}개</p>
{% endfor %}겉으로는 단순합니다. 하지만 내부에서는 이런 일이 벌어질 수 있습니다.
게시글 목록을 가져오는 쿼리 1번
각 게시글의 작성자를 가져오는 쿼리 N번
각 게시글의 댓글 수를 세는 쿼리 N번
게시글이 30개라면 1번으로 끝날 일이 61번의 쿼리가 될 수 있습니다. 이런 상황을 흔히 N+1 쿼리 문제라고 부릅니다. 처음 1번의 조회 이후, 반복문 안에서 관련 객체를 가져오느라 N번의 추가 쿼리가 발생하는 문제입니다.
해결 방향은 간단합니다. 반복문에 들어가기 전에 필요한 관련 데이터를 미리 가져와야 합니다.
select_related로 ForeignKey를 함께 가져오기
select_related()는 ForeignKey나 OneToOneField처럼 객체 하나로 이어지는 관계에 사용합니다. SQL의 JOIN을 사용해 관련 객체를 한 번에 가져옵니다.
게시글 목록에서 작성자를 함께 보여 준다면 다음처럼 작성할 수 있습니다.
posts = (
Post.objects
.select_related("author")
.order_by("-created_at")
)이제 템플릿에서 post.author.username에 접근해도 게시글마다 작성자를 다시 조회하지 않습니다.
{% for post in posts %}
<h2>{{ post.title }}</h2>
<p>{{ post.author.username }}</p>
{% endfor %}select_related()는 한 객체가 다른 한 객체를 가리키는 관계에 적합합니다. 댓글에서 게시글을 가져오거나, 게시글에서 작성자를 가져오거나, 프로필에서 사용자를 가져오는 경우가 여기에 해당합니다.
comments = Comment.objects.select_related("post", "author")하지만 다대다 관계나 역방향 일대다 관계에는 적합하지 않습니다. 게시글 하나에 댓글이 여러 개 붙을 수 있기 때문입니다. 이런 관계는 다음에 볼 prefetch_related()를 사용합니다.
prefetch_related로 여러 객체 관계 가져오기
prefetch_related()는 ManyToManyField나 역방향 ForeignKey처럼 여러 객체가 연결되는 관계에 사용합니다. Django가 별도의 쿼리로 관련 객체들을 가져온 뒤, Python에서 알맞게 묶어 줍니다.
게시글 목록에서 태그와 댓글을 함께 보여 주려면 다음처럼 작성합니다.
posts = (
Post.objects
.select_related("author")
.prefetch_related("tags", "comments")
.order_by("-created_at")
)author는 게시글 하나당 작성자 하나이므로 select_related()를 사용했습니다. tags와 comments는 게시글 하나에 여러 개가 연결될 수 있으므로 prefetch_related()를 사용했습니다.
템플릿에서는 평소처럼 접근합니다.
{% for post in posts %}
<h2>{{ post.title }}</h2>
<p>{{ post.author.username }}</p>
{% for tag in post.tags.all %}
<span>#{{ tag.name }}</span>
{% endfor %}
<p>댓글 {{ post.comments.all|length }}개</p>
{% endfor %}이 경우 post.tags.all과 post.comments.all은 미리 가져온 결과를 활용할 수 있습니다. 다만 prefetch_related()를 사용했더라도 템플릿이나 View에서 다시 필터링하면 별도 쿼리가 발생할 수 있습니다.
post.comments.filter(author=request.user)이 코드는 미리 가져온 comments 전체 목록을 그대로 사용하는 것이 아니라, 새로운 조건의 쿼리를 다시 실행합니다. 목록 화면에 필요한 조건이 분명하다면 View에서 미리 QuerySet을 구성하는 편이 좋습니다.
annotate로 댓글 수를 함께 계산하기
댓글 수를 표시하기 위해 템플릿에서 post.comments.count를 반복해서 호출하면 게시글마다 count 쿼리가 발생할 수 있습니다. 이럴 때는 annotate()와 Count를 사용해 목록을 가져올 때 댓글 수를 함께 계산합니다.
from django.db.models import Count
posts = (
Post.objects
.select_related("author")
.annotate(comment_count=Count("comments"))
.order_by("-created_at")
)이제 각 post 객체에는 실제 모델 필드는 아니지만 comment_count라는 값이 붙습니다.
{% for post in posts %}
<h2>{{ post.title }}</h2>
<p>{{ post.author.username }}</p>
<p>댓글 {{ post.comment_count }}개</p>
{% endfor %}이 방식은 목록 화면에서 특히 유용합니다. 댓글 객체 전체가 필요한 것이 아니라 개수만 필요하다면 댓글을 모두 가져올 필요가 없습니다. 필요한 값만 계산해서 가져오면 됩니다.
태그 수나 작성한 글 수에도 같은 방식을 사용할 수 있습니다.
users = User.objects.annotate(post_count=Count("posts"))Post.author에 related_name="posts"를 지정했기 때문에 Count("posts")라고 쓸 수 있습니다. 관계 이름을 명확하게 정해 두면 조회 코드도 자연스럽게 읽힙니다.
aggregate로 전체 통계 구하기
annotate()가 각 객체마다 값을 붙이는 기능이라면, aggregate()는 QuerySet 전체를 하나의 통계 값으로 요약합니다.
from django.db.models import Avg, Count, Max, Sum
stats = Post.objects.aggregate(
total_posts=Count("id"),
total_views=Sum("view_count"),
max_views=Max("view_count"),
avg_views=Avg("view_count"),
)결과는 딕셔너리 형태입니다.
{
"total_posts": 120,
"total_views": 3480,
"max_views": 210,
"avg_views": 29.0,
}관리자 대시보드나 통계 페이지에서는 이런 방식이 유용합니다. 모든 게시글을 Python으로 가져온 뒤 직접 반복문으로 합계를 구하는 것보다, 데이터베이스가 잘하는 집계 작업을 데이터베이스에 맡기는 편이 효율적입니다.
QuerySet은 필요한 만큼만 평가된다
Django의 QuerySet은 만드는 순간 바로 데이터베이스에 접근하지 않습니다.
posts = Post.objects.filter(author=request.user)이 줄만으로는 아직 쿼리가 실행되지 않습니다. 반복하거나, 리스트로 변환하거나, 개수를 세거나, 템플릿에서 실제로 사용할 때 쿼리가 실행됩니다. 이를 지연 평가라고 합니다.
이 특성 덕분에 다음처럼 조건을 단계적으로 조립할 수 있습니다.
posts = Post.objects.all()
if keyword:
posts = posts.filter(title__icontains=keyword)
if tag_name:
posts = posts.filter(tags__name=tag_name)
posts = posts.select_related("author").order_by("-created_at")중요한 점은 QuerySet을 너무 일찍 평가하지 않는 것입니다. 예를 들어 중간에 list(posts)로 변환하면 그 순간 쿼리가 실행되고, 결과는 더 이상 QuerySet이 아니라 Python 리스트가 됩니다. 그 뒤에는 filter()나 select_related()를 이어 붙여 조회 조건을 조립할 수 없습니다. View에서는 가능하면 마지막까지 QuerySet 상태를 유지하고, 필요한 조건과 최적화를 붙인 뒤 템플릿에 넘기는 편이 좋습니다.
목록 View에 적용해 보기
지금까지의 내용을 게시글 목록 View에 모아 보겠습니다.
# boards/views.py
from django.db.models import Count
from django.views.generic import ListView
from .models import Post
class PostListView(ListView):
model = Post
template_name = "boards/post_list.html"
context_object_name = "posts"
paginate_by = 10
def get_queryset(self):
queryset = (
Post.objects
.select_related("author")
.prefetch_related("tags")
.annotate(comment_count=Count("comments"))
.order_by("-created_at")
)
keyword = self.request.GET.get("q")
if keyword:
queryset = queryset.filter(title__icontains=keyword)
return queryset이 View는 여러 가지를 한 번에 처리합니다.
작성자는
select_related()로 함께 가져옵니다.태그는
prefetch_related()로 미리 가져옵니다.댓글 수는
annotate()로 계산합니다.검색어가 있으면 제목 기준으로 필터링합니다.
정렬과 페이지네이션을 적용합니다.
템플릿은 복잡한 데이터 조회를 몰라도 됩니다.
{% for post in posts %}
<article>
<h2>{{ post.title }}</h2>
<p>{{ post.author.username }}</p>
<p>댓글 {{ post.comment_count }}개 · 조회 {{ post.view_count }}회</p>
{% for tag in post.tags.all %}
<span>#{{ tag.name }}</span>
{% endfor %}
</article>
{% empty %}
<p>게시글이 없습니다.</p>
{% endfor %}좋은 최적화는 코드를 어렵게 만드는 것이 아닙니다. 화면에 필요한 데이터를 View에서 명확히 준비하고, 템플릿은 그 데이터를 출력하는 데 집중하게 만드는 것입니다.
먼저 관계를 분명히, 그다음 쿼리를 줄인다
데이터베이스 최적화는 처음부터 복잡한 기술을 적용하는 일이 아닙니다. 먼저 데이터의 관계를 정확히 표현해야 합니다. 댓글은 게시글에 속하므로 ForeignKey, 프로필은 사용자와 하나씩 대응하므로 OneToOneField, 태그는 여러 게시글과 서로 공유되므로 ManyToManyField를 사용합니다.
그다음 실제 화면에서 어떤 데이터가 필요한지 봅니다. 한 객체로 이어지는 관계는 select_related(), 여러 객체로 이어지는 관계는 prefetch_related()를 사용합니다. 개수나 합계처럼 계산된 값은 annotate()와 aggregate()로 데이터베이스에서 처리합니다.
데이터가 적을 때는 비효율적인 코드도 문제없이 동작합니다. 하지만 데이터가 늘어나면 "동작한다"만으로는 부족합니다. 같은 화면을 만들더라도 쿼리를 적게 실행하는 코드가 더 오래 버팁니다. Django ORM을 잘 사용한다는 것은 SQL을 전혀 몰라도 된다는 뜻이 아니라, ORM이 어떤 조회를 만들지 의식하며 코드를 작성한다는 뜻입니다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.