[Djnago/DB] 댓글 기능 구현하기 (1) : Comment-Article (N:1) 관계 설정

문지은·2023년 5월 3일
0

Django + Database

목록 보기
1/12
post-thumbnail

댓글 기능 구현을 통해 관계형 데이터베이스에서 외래 키 속성을 사용해 모델간 N:1 관계를 설정하는 방법에 대해 알아보자.
이번 포스팅에서는 먼저 Comment 모델과 Article 모델간 관계를 설정해보자!


⭐️ 개요

N:1 관계

  • Many-to-one relationship
  • 한 테이블의 0개 이상의 레코드가 다른 테이블의 레코드 한개와 관련된 경우
  • 기준 테이블에 따라 (1:N, One-to-many relationships)이라고도 함

Foreign Key

  • 외래 키(외부 키)
  • 관계형 데이터베이스에서 다른 테이블의 행을 식별할 수 있는 키
  • 참조되는 테이블의 기본 키(Primary Key)를 가리킴
  • 참조하는 테이블의 행 1개의 값은 참조되는 측 테이블의 행 값에 대응됨
    • 이 때문에 참조되는 테이블의 행에는 참조되는 테이블에 나타나지 않는 값을 포함할 수 없음
  • 참조하는 테이블 행 여러 개가 참조되는 테이블의 동일한 행을 참조할 수 있음

특징

  • 키를 사용하여 부모 테이블의 유일한 값을 참조(by 참조 무결성)
  • 외래 키의 값이 반드시 부모 테이블의 기본 키일 필요는 없지만 유일한 값이어야 함

참조 무결성

  • 데이터베이스 관계 모델에서 관련된 2개의 테이블 간의 일관성을 말함
  • 외래 키가 선언된 테이블의 외래 키 속성(열)의 값은 그 테이블의 부모가 되는 테이블의 기본 키 값으로 존재해야 함

⭐️ N:1 (Comment-Article)

모델 관계 설정

  • 0개 이상의 댓글은 1개의 게시글에 작성 될 수 있음
  • N:1 관계에서 댓글을 담당할 Comment 모델은 N, Article 모델은 1이 될 것

Django Relationship fields

  1. OneToOneField() : A one-to-one relationship

  2. ForeignKey() : A many-to-one relationship

  3. ManyToManyField() : A many-to-many relationship

    여기서 우리가 사용할 것은 ForeignKey()

ForeignKey(to, on_delete, **options)

  • A many-to-one relationship을 담당하는 Django의 모델 필드 클래스
  • Django 모델에서 관계형 데이터베이스의 외래 키 속성을 담당
  • 2개의 필수 위치 인자가 필요
    1. 참조하는 model class
    2. on_delete 옵션

Comment Model 정의

  • 외래 키 필드는 ForeignKey 클래스를 작성하는 위치와 관계 없이 필드의 마지막에 작성됨
  • ForeignKey() 클래스의 인스턴스 이름은 참조하는 모델 클래스 이름의 단수형(소문자)으로 작성하는 것을 권장
# articles/models.py

class Comment(models.Model):
    article = models.ForeignKey(Article, on_delete=models.CASCADE)
    content = models.CharField(max_length=200)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.content

ForeignKey arguments - on_delete

  • 외래 키가 참조하는 객체가 사라졌을 때, 외래 키가 가진 객체를 어떻게 처리할 지를 정의
  • 데이터 무결성을 위해서 매우 중요한 설정
  • on_delete 옵션 값
    • CASCADE : 부모 객체(참조된 객체)가 삭제 됐을 때 이를 참조하는 객체도 삭제
    • PROTECT, SET_NULL, SET_DEFAULT … 등 여러 옵션 값이 존재

Migration 과정 진행

  1. models.py 에서 모델에 대한 수정사항이 발생했기 때문에 migration 과정 진행

    $ python manage.py makemigrations
    • migration 파일 0002_comment.py 생성 확인
  2. migrate 진행

$ python manage.py migrate
  • Comment 모델 클래스로 인해 생성된 테이블 확인
  • ForeginKey 모델 필드로 인해 작성된 컬럼의 이름이 article_id
  • 만약 ForeignKey 인스턴스를 article이 아닌 abcd로 생성했다면 abcd_id로 만들어짐

댓글 생성 연습하기

  • shell_plus 실행
$ python manage.py shell_plus
  • 댓글 생성
# Comment 클래스의 인스턴스 comment 생성
>>> comment = Comment()
    
# 인스턴스 변수 저장
>>> comment.content = 'first comment'
    
# DB에 댓글 저장
>>> comment.save()
    
# 에러 발생
IntegrityError: NOT NULL constraint failed: articles_comment.article_id
# articles_comment 테이블의 ForeignKeyField, article_id 값이 저장시 누락되었기 때문
# 게시글 생성 및 확인
>>> article = Article.objects.create(title='title', content='content')
>>> article
<Article: 2번째글 - title>
    
# 외래 키 데이터 입력
# 다음과 같이 article 객체 자체를 넣을 수 있음
>>> comment.article = article
# 또는 comment.article_id = article.pk 처럼 pk 값을 직접 외래 키 컬럼에 
# 넣어 줄 수도 있지만 권장하지 않음
    
# DB에 댓글 저장 및 확인 
>>> comment.save()
>>> comment
<Comment: first comment>
  • 댓글 속성 값 확인
>>> comment.pk
1
    
>>> comment.content
'first comment'
    
# 클래스 변수명인 article로 조회 시 해당 참조하는 게시물 객체를 조회할 수 있음
>>> comment.article
<Article: 2번째글 - title>
    
# article_pk는 존재하지 않는 필드이기 때문에 사용 불가
>>> comment.article_id
2
  • comment 인스턴스를 통한 article 값 접근하기
    # 1번 댓글이 작성된 게시물의 pk 조회
    In [13]: comment.article.pk
    Out[13]: 2
    
    # 1번 댓글이 작성된 게시물의 content 조회
    In [14]: comment.article.content
    Out[14]: 'content'
  • 두번 째 댓글 작성해보기
>>> comment = Comment(content='second comment', article=article)
    
>>> comment.save()
    
>>> comment.pk
2
    
>>> comment
<Comment: second comment>
    
>>> comment.article_id
 2
  • 작성된 댓글 확인

관계 모델 참조

  • Related manager는 N:1 혹은 M:N 관계에서 사용가능한 문맥(context)
  • Django는 모델 간 N:1 혹은 M:N 관계가 설정되면 역참조할 때에 사용할 수 있는 manager 생성
    • 우리가 이전에 모델 생성 시 objects라는 매니저를 통해 queryset api를 사용했던 것 처럼 related manager를 통해 queryset api를 사용할 수 있게 됨
  • 지금은 N:1 관계에서의 related manager 만을 학습할 것

역참조

  • 나를 참조하는 테이블(나를 외래 키로 지정한)을 참조하는 것
  • 즉, 본인을 외래 키로 참조 중인 다른 테이블에 접근하는 것
  • N:1 관계에서는 1이 N을 참조하는 상황
    • 외래 키를 가지지 않는 1이 외래 키를 가진 N을 참조

article.comment_set.method()

  • Article 모델이 Comment 모델을 참조(역참조)할 때 사용하는 매니저
  • article.comment 형식으로는 댓글 객체를 참조 할 수 없음
    • 실제로 Article 클래스에는 Comment와의 어떠한 관계도 작성되어 있지 않음
  • 대신 Django가 역참조 할 수 있는 comment_set manager를 자동으로 생성해 article.comment_set 형태로 댓글 객체를 참조할 수 있음
    • N:1 관계에서 생성되는 Related manager의 이름은 참조하는 모델명_set 이름 규칙으로 만들어짐
  • 반면 참조상황(Comment → Article)에서는 실제 ForeignKey 클래스로 작성한 인스턴스가 Comment 클래스의 클래스 변수이기 때문에 comment.article 형태로 작성 가능
  • shell_plus 실행
$ python manage.py shell_plus
  • 댓글이 저장된 게시글 조회하기
article = Article.objects.get(pk=2)
  • dir() 함수를 사용해 클래스 객체가 사용할 수 있는 메서드 확인하기
>>> dir(article)
[...
 'comment_set',
 'content',
 'created_at',
 'date_error_message',
 'delete',
 'from_db',
 'full_clean',
...
 ]
  • 게시글에 작성된 모든 댓글 조회하기 (역참조)
>>> article.comment_set.all()
<QuerySet [<Comment: first comment>, <Comment: second comment>]>
  • 게시글에 작성된 모든 댓글 출력하기
>>> comments = article.comment_set.all()

>>> for comment in comments:
>>>     print(comment.content)

first comment
second comment
# articles/models.py

class Comment(models.Model):
    article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name='comments')
    content = models.CharField(max_length=200)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.content
  • ForeignKey 클래스의 선택 옵션
  • 역참조 시 사용하는 매니저 이름(model_set manager)을 변경할 수 있음
  • 작성 후, migration 과정이 필요
  • 선택 옵션이지만 상황에 따라 반드시 작성해야 하는 경우가 생기기도 하는데 이는 추후에 알아보자
  • 작성 후 다시 원래 코드로 복구
    • 위와 같이 변경하면 기존 article.comment_Set은 더이상 사용할 수 없고, article.comments로 대체됨

admin site 등록

  • 새로 작성한 Comment 모델을 admin site에서도 관리하기 위해 등록하기
# articles/admin.py
    
from django.contrib import admin
from .models import Article, Comment
    
# Register your models here.
admin.site.register(Article)
admin.site.register(Comment)

⭐️ Comment 구현

CREATE

CommentForm 작성

  • 사용자로부터 댓글 데이터를 입력하기 위한 CommentForm 작성
# articles/forms.py
from django import forms
from .models import Article, Comment

class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = '__all__'
  • detail 페이지에서 CommentForm 출력
# articles/views.py

from .forms import ArticleForm, CommentForm

def detail(request, pk):
    article = Article.objects.get(pk=pk)
    comment_form = CommentForm()
    context = {
        'article': article,
        'comment_form' : comment_form,}
    return render(request, 'articles/detail.html', context)
<!-- articles/detail.html -->
...,
<h5>Comments</h5>
<hr>
<form action="#" method="POST">
  {% csrf_token %}
  {{ comment_form }}
  <input type="submit" value="작성">
</form>

다음과 같이 출력됨

  • 실 서비스에서는 댓글을 작성할 때 댓글을 어떤 게시글에 작성하는지 직접 게시글 번호를 선택하지 않음
  • 실제로는 해당 게시글에 댓글을 작성하면 자연스럽게 그 게시글에 댓글이 작성되어야 함
  • 다음과 같이 출력되는 이유는 Comment 클래스의 외래 키 필드 article 또한 데이터 입력이 필요하기 때문
  • 하지만, 외래 키 필드는 사용자의 입력으로 받는 것이 아니라 view 함수 내에서 받아 별도로 처리되어 저장되어야 함

외래 키 필드를 출력에서 제외 후 확인

# articles/forms.py

class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        exclude = ('article',)

  • 출력에서 제외된 외래 키 받아오기
    • detail 페이지의 url을 살펴보면 path('<int:pk>/', views.detail, name='detail') url에 해당 게시글의 pk 값이 사용되고 있음
    • 댓글의 외래 키 데이터에 필요한 정보가 바로 게시글의 pk 값
    • 이전에 학습했던 url을 통해 변수를 넘기는 variable routing 사용
# articles/urls.py

from django.urls import path
from . import views

app_name = 'articles'
urlpatterns = [
    ...,
    path('<int:pk>/comments/', views.comments_create, name='comments_create'),
]
<!-- articles/detail.html -->

<h5>Comments</h5>
<hr>
<form action="{% url 'articles:comments_create' article.pk %}" method="POST">
  {% csrf_token %}
  {{ comment_form }}
  <input type="submit" value="작성">
</form>

The save() method

  • save(commit=False)
    • 아직 데이터베이스에 저장되지 않은 인스턴스를 반환
    • 저장하기 전에 객체에 대한 사용자 지정 처리를 수행할 때 유용하게 사용
  • save 메서드의 commit 옵션을 통해 DB에 저장되기 전 article 객체 저장하기
 # articles/views.py

def comments_create(request, pk):
    article = Article.objects.get(pk=pk)
    comment_form = CommentForm(request.POST)
    if comment_form.is_valid():
        comment = comment_form.save(commit=False)
        comment.article = article
        comment.save()
    return redirect('articles:detail', article.pk)
  • 댓글 작성 후 테이블 확인

작성한 댓글 목록 출력하기

  • 특정 article에 있는 모든 댓글을 가져온 후 context에 추가
# articles/views.py

from .models import Article, Comment

def detail(request, pk):
    article = Article.objects.get(pk=pk)
    comment_form = CommentForm()
    comments = article.comment_set.all()
    context = {
        'article': article,
        'comment_form' : comment_form,
        'comments' : comments,
    }
    return render(request, 'articles/detail.html', context)
  • detail 템플릿에서 댓글 목록 출력하기
<!-- articles/detail.html -->

<h4>댓글 목록</h4>
<ul>
  {% for comment in comments %}
    <li>{{ comment.content }}</li>
  {% endfor %}
</ul>
  • 댓글 목록 출력 확인하기

DELETE

댓글 삭제 구현하기

# articles/urls.py

app_name = 'articles'
urlpatterns = [
    ...,
    path('<int:article_pk>/comments/<int:comment_pk>/delete/', views.comments_delete, name='comments_delete'),
]
# articles/views.py
from .models import Movie, Comment

def comments_delete(request, article_pk, comment_pk):
    comment = Comment.objects.get(pk=comment_pk)
    comment.delete()
    return redirect('articles:detail', article_pk)
  • 댓글을 삭제할 수 있는 버튼을 각각의 댓글 옆에 출력될 수 있도록 함
<!-- articles/detail.html -->

<h4>댓글 목록</h4>
<ul>
  {% for comment in comments %}
    <li>
      {{ comment.content }}
      <form action="{% url 'articles:comments_delete' article.pk comment.pk %}" method="POST">
        {% csrf_token %}
        <input type="submit" value="DELETE">
      </form>
    </li>
  {% endfor %}
</ul>
  • 댓글 삭제 버튼 출력 확인 및 삭제 시도해보기

댓글 수정을 지금 구현하지 않는 이유

  • 댓글 수정도 게시글 수정과 마찬가지로 구현 가능
    • 게시글 수정 페이지가 필요했던 것처럼 댓글 수정 페이지가 필요하게 됨
  • 하지만 일반적으로 댓글 수정은 수정 페이지로 이동 없이 현재 페이지가 유지된 상태로 댓글 작성 Form 부분만 변경되어 수정할 수 있도록 함
  • 페이지의 일부 내용만 업데이트 하는 것은 JavaScript의 영역

⭐️ Comment 추가 사항

댓글 개수 출력하기

  1. DTL filter - length 사용
{{ comments|length }}

{{ article.comment_set.all|length }}
  1. Queryset API - count() 사용
{{ comments.count }}

{{ article.comment_set.count }}
  • detail 템플릿에 작성하기
<!-- articles/detail.html -->

<h4>댓글 목록</h4>
{% if comments %}
<p><b>{{ comments|length }}개의 댓글이 있습니다.</b></p>
{% endif %}
  • 출력 확인

댓글이 없는 경우 대체 컨텐츠 출력하기

  • DTL for empty 활용
<!-- articles/detail.html -->

<h4>댓글 목록</h4>
{% if comments %}
<p><b>{{ comments|length }}개의 댓글이 있습니다.</b></p>
{% endif %}
<ul>
  {% for comment in comments %}
    <li>
      {{ comment.content }}
      <form action="{% url 'articles:comments_delete' article.pk comment.pk %}" method="POST">
        {% csrf_token %}
        <input type="submit" value="DELETE">
      </form>
    </li>
  {% empty %}
  <p>댓글이 없습니다.</p>
  {% endfor %}
</ul>
<hr>
  • 출력 확인하기
profile
코드로 꿈을 펼치는 개발자의 이야기, 노력과 열정이 가득한 곳 🌈

0개의 댓글