WeniVooks

검색

Django 베이스캠프

DB 모델 관계2

이전 장에서 우리는 Post 모델을 만들었습니다. 이번 장에서는 태그, 댓글 등의 기능들을 직접 추가해 보면서, 모델 관계를 직접 구현하고, 활용하는 방법을 배워보겠습니다.

1. 글쓴이(Author) 추가하기

게시물의 글쓴이를 나타낼 수 있게 author 필드를 추가하겠습니다. 글쓴이 한명은 여러 글을 쓸 수 있지만 글 하나가 여러 글쓴이를 가질 수는 없습니다. 그러니 1:n 관계에 사용하는 ForeingnKey를 이용해 models.py를 수정해봅시다.

#blog > models.py
from django.db import models
from django.contrib.auth.models import User
 
class Post(models.Model):
    title = models.CharField(max_length=100)
    content = models.TextField()
    head_image = models.ImageField(upload_to="blog/images/%Y/%m/%d/", blank=True)
    file_upload = models.FileField(upload_to="blog/files/%Y/%m/%d/", blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateField(auto_now=True)
    author = models.ForeignKey(User, on_delete=models.CASCADE)
 
    def __str__(self):
        return self.title
#blog > models.py
from django.db import models
from django.contrib.auth.models import User
 
class Post(models.Model):
    title = models.CharField(max_length=100)
    content = models.TextField()
    head_image = models.ImageField(upload_to="blog/images/%Y/%m/%d/", blank=True)
    file_upload = models.FileField(upload_to="blog/files/%Y/%m/%d/", blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateField(auto_now=True)
    author = models.ForeignKey(User, on_delete=models.CASCADE)
 
    def __str__(self):
        return self.title

이때 주의해야 할 점이 있습니다. 1 : N 관계에서 ForeignKey는 항상 N 쪽에 작성합니다. 예를 들어, 한 사용자가 여러 게시물을 작성할 수 있으므로, ForeignKey는 게시물 모델에 둡니다. 이렇게 하면 각 게시물은 자신의 작성자를 쉽게 참조할 수 있고, 한 사용자의 모든 게시물을 효율적으로 찾을 수 있습니다.

반대로 ForeignKey를 사용자 모델에 두면 여러 문제가 발생합니다. 예를 들어, 한 사용자가 100개의 게시물을 작성했다고 가정해 봅시다. 이 경우 사용자 모델에 100개의 게시물 참조를 저장해야 하며, 새 게시물을 작성할 때마다 사용자 정보를 수정해야 합니다. 또한 사용자가 작성할 수 있는 게시물 수에 제한이 생기고, 특정 게시물의 작성자를 찾으려면 모든 사용자 정보를 검색해야 하는 비효율이 발생합니다. 이런 이유로 ForeignKey는 항상 '다수(N)' 쪽인 게시물 모델에 두는 것이 바람직합니다.

모델을 수정했으니 makemigrationsmigrate를 해줍니다.

python manage.py makemigrations
python manage.py migrate
python manage.py makemigrations
python manage.py migrate

이때 makemigrations을 하면, 아래와 같은 알림 사항이 뜹니다.

post 모델에 author 필드를 새로 넣으려고 하는데, 이 author 필드는 반드시 값이 있어야 한다는 조건이 있습니다. 그런데 이미 있던 게시물들은 author 정보가 없기 때문에 생긴 문제입니다. 이때 Django는 이 문제를 해결할 두 가지 방법을 제안합니다.

  1. 지금 모든 기존 게시물에 대해 임시로 같은 값을 넣는 방법
  2. 일단 멈추고 코드에서 기본값을 정해주는 방법 입니다.

1번을 입력하면 새로운 필드에 대한 기본값을 입력하라는 프롬프트가 나타납니다. 이때 >>> 프롬프트에 1을 입력하면, 데이터베이스의 User 모델에서 ID가 1인 사용자가 모든 기존 게시물의 author로 설정됩니다. 아래 이미지 처럼 Author이 추가된 것을 볼 수 있습니다.

만약 추가해야 하는 값이 User가 아니라 날짜라면 프롬프트에서 import datetime을 입력하고, datetime.datetime.now()를 입력하면 됩니다. 이렇게 하면 모든 기존 게시물에 현재 시간이 들어갑니다. 또한 입력해야 하는 값이 숫자라면 숫자를, 텍스트라면 텍스트를 입력하면 됩니다.

해결하는 방법이 이것만 있는 것은 아닙니다. 2번을 선택해 models.py에서 author 필드에 null=True를 추가하면 됩니다. 이렇게 하면 author 필드에 값이 없어도 되기 때문에, 기존 게시물에도 author 필드를 추가할 수 있습니다.

1.1 CASCADE 실습

아래는 수정한 models.py 에서 추가된 코드입니다.

author = models.ForeignKey(User, on_delete=models.CASCADE)
author = models.ForeignKey(User, on_delete=models.CASCADE)

이 코드는 유저를 삭제했을 때 이 유저와 연관된 게시물을 함께 삭제하겠다는 의미입니다. 1:N 관계에서 1을 삭제하면 N도 함께 삭제되는 것을 의미합니다. 이것을 CASCADE라고 합니다. 만약 삭제가 되어도 남게 하고 싶다면 models.SET_NULL을 넣으면 됩니다. 이제 이 코드를 다른 유저 계정을 생성해서, 실행이 되는지 테스트 해봅시다.

python manage.py runserver
python manage.py runserver

우선 서버를 키고, 관리자페이지 > 사용자 추가를 클릭해서 새로운 유저를 만들어 줍니다. 임의로 leehojun2라는 유저를 생성했습니다. 사용자(들)을 클릭해보면, 유저가 한명 더 생성된 것을 확인할 수 있습니다.

게시물을 3개 더 작성해 줍니다. 이때 Author은 이미지와 같이 새로 만든 유저로 설정해 주세요.

게시물을 3개 더 추가했다면, 이제 총 게시물은 6개가 되었습니다. 이제 유저를 삭제해봅시다. 사용자(들)에서 삭제할 유저를 선택하고, 액션에서 삭제 선택, 실행을 누르면 유저가 삭제됩니다.

Post를 확인해보면, leejojun2으로 만든 게시물이 삭제된 것을 확인 할 수 있습니다.

blog_list.html파일을 수정해, blog 페이지에서 게시물이 잘 보이는지 확인해봅시다.

  • templates > blog > blog_list.html
<!-- templates > blog > blog_list.html -->
{% for i in posts %}
    <h1>{{ i.title }}</h1>
    <p>{{ i.content }}</p>
    <p>{{ i.author }}</p>
    <hr>
{% endfor %}
<!-- templates > blog > blog_list.html -->
{% for i in posts %}
    <h1>{{ i.title }}</h1>
    <p>{{ i.content }}</p>
    <p>{{ i.author }}</p>
    <hr>
{% endfor %}

유저가 삭제되었을 때, 게시글을 보존하고 싶다면, 글쓴이를 빈칸으로 만들 수 있습니다. 아래 코드 처럼 on_delete을 models.CASCADE대신 models.SET_NULL로 설정하면 author가 빈칸이 됩니다.

author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)

on_delete=models.SET_NULL을 사용하고 싶으면, null=True과 같이 이 필드에 null값이 들어 갈 수 있게 설정해야합니다.

2. 태그, 댓글 기능 추가하기

이번에는 태그댓글을 추가해 봅시다. 태그에 대해서 생각해보면, 한 게시물은 여러개의 태그를 가질 수 있고, 한 태그는 여러 게시물에 속할 수 있습니다. 그러니 게시물과 태그는 다대다(N:M)관계를 이룹니다. 댓글은 하나의 게시물에서 여러 댓글을 가질 수 있습니다. 하지만 댓글은 하나의 게시물에만 속합니다. 그러니 게시물과 댓글은 일대다(1:N)관계를 가집니다. 일대다 관계에는 ForeingnKey를 다대다 관계에는 ManyToManyField를 사용합니다. ForeingnKey1:N에서 N에 정의하고, ManyToManyField는 양쪽 모델 중 한 곳에 정의합니다.

2.1 models.py 수정하기

우선 models.py를 수정해 태그와 댓글 모델을 추가합니다.

  • models.py
#blog > models.py
from django.db import models
from django.contrib.auth.models import User
 
class Post(models.Model):
    title = models.CharField(max_length=100)
    content = models.TextField()
    head_image = models.ImageField(
        upload_to='blog/images/%Y/%m/%d/', blank=True)
    file_upload = models.FileField(
        upload_to='blog/files/%Y/%m/%d/', blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateField(auto_now=True)
    author = models.ForeignKey(
        User, on_delete=models.CASCADE
    )
    # tags필드를 추가합니다. 
    tags = models.ManyToManyField('Tag', blank=True)
 
    def __str__(self):
        return self.title
 
#새로운 Comment모델을 추가합니다.
class Comment(models.Model):
    message = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateField(auto_now=True)
    post = models.ForeignKey(
        Post, on_delete=models.CASCADE, related_name='comments'
    )
    author = models.ForeignKey(
        User, on_delete=models.CASCADE
    )
 
    def __str__(self):
        return self.message
    
class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)
 
    def __str__(self):
        return self.name
#blog > models.py
from django.db import models
from django.contrib.auth.models import User
 
class Post(models.Model):
    title = models.CharField(max_length=100)
    content = models.TextField()
    head_image = models.ImageField(
        upload_to='blog/images/%Y/%m/%d/', blank=True)
    file_upload = models.FileField(
        upload_to='blog/files/%Y/%m/%d/', blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateField(auto_now=True)
    author = models.ForeignKey(
        User, on_delete=models.CASCADE
    )
    # tags필드를 추가합니다. 
    tags = models.ManyToManyField('Tag', blank=True)
 
    def __str__(self):
        return self.title
 
#새로운 Comment모델을 추가합니다.
class Comment(models.Model):
    message = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateField(auto_now=True)
    post = models.ForeignKey(
        Post, on_delete=models.CASCADE, related_name='comments'
    )
    author = models.ForeignKey(
        User, on_delete=models.CASCADE
    )
 
    def __str__(self):
        return self.message
    
class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)
 
    def __str__(self):
        return self.name

Post 모델에 tags 필드를 추가하여 Tag 모델과 다대다 관계를 설정합니다. Comment 모델을 새로 생성하고, post 필드를 통해 Post 모델과 일대다 관계를 설정했습니다.

이때 Comment 모델에서 related_name='comments'를 사용하여 Post 모델에서 연결된 댓글들을 쉽게 접근할 수 있게 했습니다. 여기서 related_name은 Post에서 Comment를 부를 때 사용할 이름입니다. 템플릿 문법에서 아래와 같이 호출됩니다.

{% for comment in post.Comment.all %}
{% for comment in post.Comment.all %}

모델을 변경했으므로 이를 데이터베이스에 반영해야 합니다.

python manage.py makemigrations
python manage.py migrate
python manage.py makemigrations
python manage.py migrate
2.2 admin.py 수정하기

새로 만든 모델들을 관리자 페이지에서 관리할 수 있도록 admin.py 파일을 수정합니다.

  • admin.py
#blog > admin.py
from django.contrib import admin
from .models import Post, Comment, Tag
 
admin.site.register(Post)
admin.site.register(Comment)
admin.site.register(Tag)
#blog > admin.py
from django.contrib import admin
from .models import Post, Comment, Tag
 
admin.site.register(Post)
admin.site.register(Comment)
admin.site.register(Tag)

이제 서버를 키고, 관리자 페이지에서 변경사항을 확인해봅시다.

왼쪽 상단 위에 Comments와 Tags가 추가된 것을 볼 수 있습니다. 또 새 글을 작성할 때 태그를 선택할 수 있도록 추가되었습니다. 댓글과 태그을 달아보세요. 이제 관리자 페이지에서 작성한 댓글과 태그를 blog 사이트에서 볼수 있도록 만들어 봅시다.

2.3 템플릿 파일 수정하기

블로그 목록과 상세 페이지에서 태그와 댓글을 표시하도록 템플릿을 수정합니다.

  • blog_list.html
<!-- blog_list.html -->
{% for post in posts %}
    <h1>{{ post.title }}</h1>
    <p>{{ post.content }}</p>
    <p>{{ post.author }}</p>
    <p>{{ post.comments }}</p>
    <p>{{ post.tags }}</p>
    {% for comment in post.comments.all %}
        <p>{{ comment.message }}</p>
    {% endfor %}
    {% for tag in post.tags.all %}
        <p>{{ tag.name }}</p>
    {% endfor %}
    <hr>
{% endfor %}
<!-- blog_list.html -->
{% for post in posts %}
    <h1>{{ post.title }}</h1>
    <p>{{ post.content }}</p>
    <p>{{ post.author }}</p>
    <p>{{ post.comments }}</p>
    <p>{{ post.tags }}</p>
    {% for comment in post.comments.all %}
        <p>{{ comment.message }}</p>
    {% endfor %}
    {% for tag in post.tags.all %}
        <p>{{ tag.name }}</p>
    {% endfor %}
    <hr>
{% endfor %}

댓글이나 태그를 가져올때 다음과 같은 코드는 사용하지 않습니다.

<p>{{ post.comments }}</p>
<p>{{ post.tags }}</p>
<p>{{ post.comments }}</p>
<p>{{ post.tags }}</p>

대신 아래의 코드를 사용해야 합니다.

{% for comment in post.comments.all %}
    <p>{{ comment.message }}</p>
{% endfor %}
{% for tag in post.tags.all %}
    <p>{{ tag.name }}</p>
{% endfor %}
{% for comment in post.comments.all %}
    <p>{{ comment.message }}</p>
{% endfor %}
{% for tag in post.tags.all %}
    <p>{{ tag.name }}</p>
{% endfor %}

Django에서 post.commentspost.tags를 직접 사용하면 실제 데이터를 보여주지 않습니다. {% for comment in post.comments.all %}와 같이 모든 객체를 불러와야만 읽을 수 있습니다. post.comments는 해당 객체를 가리키기만 하는 것이며, 실제 데이터를 불러오지 않습니다.

또한 Post 모델에 comments와 tags 필드가 없는데 어떻게 Post에 .을 찍어 comments와 tags를 사용할 수 있을까요? 이는 Django의 역참조(Reverse relationship) 기능 때문입니다. Post 모델에 직접적으로 comments와 tags 필드를 정의하지 않았지만, Comment 모델과 Tag 모델에서 ForeignKey로 Post를 참조하고 있기 때문에 자동으로 역참조가 가능해집니다. 기본적으로는 N으로 연결된 모델명을 소문자로 바꾸고 복수형으로 만들어 사용하지만 아래와 같이 related_name을 사용하여 원하는 이름으로 바꿀 수 있습니다. 특별한 경우가 아니면 related_name을 사용하지 않아도 됩니다.

class Comment(models.Model):
    post = models.ForeignKey(
        Post, on_delete=models.CASCADE, related_name='comments'
    )
class Comment(models.Model):
    post = models.ForeignKey(
        Post, on_delete=models.CASCADE, related_name='comments'
    )

또한 Post 모델에 tags와 comments가 연결은 되어있지만 실제 DB 쿼리를 실행하지 않습니다. 이는 성능 최적화를 위한 방법입니다. 만약 실제 데이터를 불러오고 싶다면 .all()을 사용해야 합니다. 이 메서드를 사용하는 순간 데이터베이스 쿼리가 실행되어 실제 데이터를 불러옵니다. 다만 이렇게 할 것이라면 아래처럼 미리 로드해두는 것이 좋습니다. 이렇게 하면 템플릿에서 이 정보를 사용할 때 추가 쿼리를 실행하지 않아 성능이 향상됩니다. 이 수업에서는 성능 최적화에 대한 내용은 다루지 않습니다.

from django.shortcuts import render
from .models import Post
 
def post_list(request):
    posts = Post.objects.all().prefetch_related('comments', 'tags')
    return render(request, 'blog/post_list.html', {'posts': posts})
from django.shortcuts import render
from .models import Post
 
def post_list(request):
    posts = Post.objects.all().prefetch_related('comments', 'tags')
    return render(request, 'blog/post_list.html', {'posts': posts})

commentstags많은 쪽(N)의 관계를 나타내기 때문에 이런 방식을 사용합니다. 하나의 게시물에 여러 댓글이나 태그가 연결될 수 있기 때문에, 이런 경우에는 성능 최적화를 위해 Django는 직접 모든 댓글과 태그를 가져오지 않고 "여기 댓글들이 있어요"라고 알려주기만 합니다.

만약 세부 내용이 필요하다면, .all()을 사용해 실제 데이터를 불러옵니다. 이런 방식은 필요할 때만 데이터베이스 쿼리를 실행하여 효율성을 높힙니다. 반면에 하나 쪽 관계(예: 댓글에서 게시물로의 관계)는 직접 접근이 가능합니다.

추가적으로 {{post.comments}}, {{post.tags}}을 사용하면 아래와 같은 문구가 출력됩니다.

blog.Comment.None
blog.Tag.None
blog.Comment.None
blog.Tag.None
  • blog_detail.html
<!-- blog_detail.html -->
<h1>{{ post.title }}</h1>
<p>{{ post.author }}</p>
<p>{{ post.content }}</p>
 
{% for tag in post.tags.all %}
    <p>{{ tag.name }}</p>
{% endfor %}
 
{% for comment in post.comments.all %}
    <p>{{ comment.message }}</p>
{% endfor %}
<!-- blog_detail.html -->
<h1>{{ post.title }}</h1>
<p>{{ post.author }}</p>
<p>{{ post.content }}</p>
 
{% for tag in post.tags.all %}
    <p>{{ tag.name }}</p>
{% endfor %}
 
{% for comment in post.comments.all %}
    <p>{{ comment.message }}</p>
{% endfor %}

3. 태그 모아보기 기능 추가하기

태그를 클릭하면, 태그가 달린 게시물을 모아서 볼 수 있도록 만들어봅시다.

3.1 urls.py 수정하기

우선 tag별로 모아서 볼 수 있도록, url패턴을 추가해 줍니다.

from django.urls import path
from . import views
 
urlpatterns = [
    path("", views.blog_list, name="blog_list"),
    path("<int:pk>/", views.blog_detail, name="blog_detail"),
    path("tag/<str:tag>/", views.blog_tag, name="blog_tag"),
]
from django.urls import path
from . import views
 
urlpatterns = [
    path("", views.blog_list, name="blog_list"),
    path("<int:pk>/", views.blog_detail, name="blog_detail"),
    path("tag/<str:tag>/", views.blog_tag, name="blog_tag"),
]
3.2 views.py 수정하기

tag url에 들어가면 실행될 view함수를 정의합니다.

# views.py
from django.shortcuts import render
from .models import Post, Comment, Tag
 
def blog_list(request):
    posts = Post.objects.all()
    return render(request, "blog/blog_list.html", {"posts": posts})
 
def blog_detail(request, pk):
    post = Post.objects.get(pk=pk)
    if request.method == "POST":
        author = request.user
        message = request.POST["message"]
        c = Comment.objects.create(author=author, message=message, post=post)
        c.save()
    return render(request, "blog/blog_detail.html", {"post": post})
 
def blog_tag(request, tag):
    posts = Post.objects.filter(tags__name__iexact=tag)
    return render(request, "blog/blog_list.html", {"posts": posts})
# views.py
from django.shortcuts import render
from .models import Post, Comment, Tag
 
def blog_list(request):
    posts = Post.objects.all()
    return render(request, "blog/blog_list.html", {"posts": posts})
 
def blog_detail(request, pk):
    post = Post.objects.get(pk=pk)
    if request.method == "POST":
        author = request.user
        message = request.POST["message"]
        c = Comment.objects.create(author=author, message=message, post=post)
        c.save()
    return render(request, "blog/blog_detail.html", {"post": post})
 
def blog_tag(request, tag):
    posts = Post.objects.filter(tags__name__iexact=tag)
    return render(request, "blog/blog_list.html", {"posts": posts})

blog_tag 함수는 ORM 쿼리의 필터 기능을 이용해서, 클릭한 태그와 일치하는 태그를 가진 모든 게시물을 찾아서 보여줍니다.

3.3 템플릿 수정하기

태그를 클릭하면 해당 태그의 게시물 목록으로 이동할 수 있도록 템플릿을 수정합니다.

<!-- blog_detail.html -->
<h1>{{ post.title }}</h1>
<p>{{ post.content }}</p>
<p>{{ post.author }}</p>
 
{% for tag in post.tags.all %}
    <a href="/blog/tag/{{ tag.name }}">#{{ tag.name }}</a>
{% endfor %}
 
{% for comment in post.comments.all %}
    <p>{{ comment.message }}</p>
{% endfor %}
<!-- blog_detail.html -->
<h1>{{ post.title }}</h1>
<p>{{ post.content }}</p>
<p>{{ post.author }}</p>
 
{% for tag in post.tags.all %}
    <a href="/blog/tag/{{ tag.name }}">#{{ tag.name }}</a>
{% endfor %}
 
{% for comment in post.comments.all %}
    <p>{{ comment.message }}</p>
{% endfor %}
3.4 실행 및 테스트

서버를 열고 블로그 상세페이지에서 태그를 클릭해 잘 작동하는지 테스트해봅시다. 태그를 클릭하면, 그 태그가 포함된 게시물만 보여주는 것을 확인할 수 있습니다. 아래는 실제 화면 입니다.

블로그 상세 페이지 태그 페이지
3.4 DB 모델 관계14장 form과 인증