IT/WEB

[Django] 일대다 관계 구축하기:ForeignKey (카테고리, 댓글, 대댓글 등)

개발자 두더지 2022. 7. 11. 23:52
728x90

일본의 한 블로그 글을 번역한 포스트입니다. 오역 및 직역, 의역이 있을 수 있으며 틀린 내용은 지적 부탁드립니다.

 

일대다 관계를 구축할 수 있으면, 토픽에 카테고리를 추가하거나 토픽에 댓글을 작성할 수 있게 된다. 이번 포스트에서는 이 방법에 대해 일대다 원리에서 부터 Django의 models.py 작성법까지 설명한다. 

 

 

일대다 구조


 일대다를 한 마디로 설명하자면 프로 야구 팀과 팀에 소속되어 있는 관계라고 할 수 있다. 프로 야구 침은 여러 명을 선수를 보유하고 있으며, 선수의 경우 어떠한 한 개의 팀에 소속되어 있다. 이러한 경우 야구 구단쪽은 일, 선수쪽은 다가 된다.

 DB 테이블로 표시하자면 다음과 같다.

 

 

일대다 관계의 장점


 직접 소속처를 문자열로 적는 것보다 일대다관계로 만드는 쪽이 장점이 많다. 

  • 반드시 하나만을 선택(복수 선택할 수 없음)
  • 표기 실수가 없어짐
  • 다쪽의 데이터가 없어져도 일쪽의 데이터를 계속해서 활용하는 등의 설정을 할 수 있음

 마지막 장점에 대해 조금 더 자세히 설명하자면, 예를 들어 구단이 없어져도 기존에 소속되어 있는 선수에 대해 어떻게 할지  다음과 같이 지정할 수 있다. 

  • 구단에 소속되어 있는 선수를 구단 미소속의 선수로 하여 데이터를 계속 저장할 수 있다.
  • 구단에 소속되어 있는 선수 데이터를 전부 말소할 수 있다.
  • 구단에 소속되어 있는 선수의 데이터를 다른 구단으로 이동할 수 있다.
  • 선수를 보유하고 있는 구단이 있다면 구단 삭제되지 않도록 할 수 있다.

 

 

일대다 관계의 예


카테고리 게시된 콘텐츠(게시판, 동영상등)
게시자 게시된 콘텐츠(게시판, 동영상등)
게시된 콘텐츠(게시판, 동영상등) 콘텐츠 댓글
콘텐츠의 댓글 그 댓글의 대댓글
부서/그룹 소속사원/멤버 등

 

 

일대다 예시 코드


 게시판 모델을 이용하게 간단하게 예를 들도록 하겠다. 먼저 models.py를 아래와 같이 작성한다.

class Category(models.Model):

    name    = models.CharField(verbose_name="카테고리명",max_length=20)

    def __str__(self):
        return self.name


class Topic(models.Model):

    category    = models.ForeignKey(Category,verbose_name="카테고리",on_delete=models.CASCADE)
    comment     = models.CharField(verbose_name="댓글",max_length=2000)

    def __str__(self):
        return self.comment

 Category 모델 클래스를 만든 뒤, Topic 모델 클래스에 ForeignKey 필드로 추가한다.

 ForeignKey 필드에는 필드 옵션으로 on_delete를 지정할 필요가 있다. 이번에 지정한 models.CASCADE는 토픽으로 짖어된 카테고리가 삭제됐을 때, 토픽도 세트로 삭제되도록 지정한 것이다. 물론 앞서 설명했듯, 삭제되지 않고 남게 하도록 설정할 수 있다.

 아래에는 on_delete로 지정할 수 있는 값의 목록이다.

결과
models.CASCADE 일쪽이 삭제됐을 때, 엮여있는 다도 모두 삭제된다.
models.PROTECT 일쪽이 삭제됐을 때, 엮여있는 다가 존재하고 있는 경우, 삭제되지 않는다.
models.SET_NULL
일쪽이 삭제됐을 때, 엮여있는 다에 NULL(Python의 None)이 입력된다(그러나 Null=True인 경우에만 한정된다).
models.SET_DEFAULT 일쪽이 삭제됐을 때, 엮어 있는 다에 default의 값이 입력된다(그러나 default값이 설정되어 있는 경우에만 한정된다).

 그리고 이것을 마이그레이션하면, 경고가 출력된다. null 금지인 category에는 필드 옵션의 default가 없다는 것이다. 이 모순에 대한 대책은 다음 세 가지가 있다.

  • [대책1] category 필드에 null=True 와 blank = True 필드 옵션을 지정하여 미분류도 허가하기.
category    = models.ForeignKey(Category,verbose_name="카테고리",on_delete=models.CASCADE, null=True, blank=True)
  • [대책2] migrations 디렉토리와 db.sqlite3을 삭제하고, 다시 처음부터 마이그레이션을 만들기.

참고로 수동으로 migrations 디렉토리를 삭제한 경우, 다시 마이그레이션 파일을 만들 때, 명시적으로 어플 명을 지정할 필요가 있다.

python3 manage.py makemigrations bbs
  • [대책3] migrations 디렉토리와 db.sqlite3을 삭제할 수 없는 경우, 한 번에 한정되는 디폴트값을 넣기.

 그러나 입력할 default값은 숫자여야한다. ForeignKey는 지정한 모델의 id 필드와 연결되기 때문이다. id 필드가 미지정인 경우 숫자형이 된다. 따라서 적당한 숫자를 입력한다. Django의 관리자(admin)화면에서 입력하면 된다.  

 

 

토픽에 댓글을 작성할 수 있도록 하기


계속해서 토픽에 댓글을 작성할 수 있도록 하자. 모델의 작성법은 거의 동일하다. models.py를 아래와 같이 변경한다.

from django.db import models

class Category(models.Model):

    name    = models.CharField(verbose_name="카테고리명",max_length=20)

    def __str__(self):
        return self.name

class Topic(models.Model):

    category    = models.ForeignKey(Category,verbose_name="카테고리",on_delete=models.CASCADE)
    comment     = models.CharField(verbose_name="댓글",max_length=2000)

    def __str__(self):
        return self.comment

class Reply(models.Model):

    target  = models.ForeignKey(Topic,verbose_name="댓글 대상의 토픽",on_delete=models.CASCADE)
    comment = models.CharField(verbose_name="댓글",max_length=2000)

    def __str__(self):
        return self.comment

 이전 코드와 비교하면 Reply 모델 클래스가 새롭게 만들어져 있다. 이 Reply 모델 클래스의 ForeignKey는 Topic과 연결되어 있다. 즉, 댓글 대상의 Topic의 id를 지정했다.

 물론, Reply는 유저에서 작성한다고 가정하므로 forms.py에 Reply 모델 클래스를 사용한 폼 클래스를 만들 필요가 있다.

from django import forms 
from .models import Topic,Reply

class TopicForm(forms.ModelForm):

    class Meta:
        model   = Topic
        fields  = [ "category","comment" ]

class ReplyForm(forms.ModelForm):

    class Meta:
        model   = Reply
        fields  = [ "target","comment" ]

 계속해서 뷰를 작성하자. 모델 클래스의 Reply, 폼 클래스의 ReplyForm을 각각 import한다.

from django.shortcuts import render,redirect

from django.views import View
from .models import Category,Topic,Reply
from .forms import TopicForm,ReplyForm

class IndexView(View):

    def get(self, request, *args, **kwargs):

        context = {}
        context["topics"]       = Topic.objects.all()
        context["categories"]   = Category.objects.all()

        return render(request,"bbs/index.html",context)

    def post(self, request, *args, **kwargs):

        form    = TopicForm(request.POST)

        if form.is_valid():
            form.save()
        else:
            print("검증NG")

        return redirect("bbs:index")

index   = IndexView.as_view()

class ReplyView(View):

    def get(self, request, pk, *args, **kwargs):

        context = {}
        context["topic"]    = Topic.objects.filter(id=pk).first()
        context["replies"]  = Reply.objects.filter(target=pk)

        return render(request,"bbs/reply.html",context)

    def post(self, request, pk, *args, **kwargs):

        copied              = request.POST.copy()
        copied["target"]    = pk

        form    = ReplyForm(copied)

        if form.is_valid():
            form.save()
        else:
            print("검증NG")

        return redirect("bbs:reply",pk)

reply   = ReplyView.as_view()

 새롭게 ReplyView를 작성했다. 여기서 댓글 포맷을 표시, 댓글 작성 처리를 한다. 인수로써는 pk를 받고 있다. 댓글 대상의 포틱 id(인수명pk)를 획득하고 싶으므로, 이것은 URL안에 포함되는 형식으로 했다. 즉 urls.py를 다음과 같이 작성했다.

from django.urls import path
from . import views

app_name    = "bbs"
urlpatterns = [ 
    path('', views.index, name="index"),
    path('reply/<int:pk>/', views.reply, name="reply"),
]

 reply/인수/ 라면 views.reply 즉 ReplyView가 호출된다. 이 때 인수로써 pk가 전달된다. 예를 들어, 다음과 같이 pk가 5인경우

reply/5/

 리퀘스트 송신됐다면 pk에는 정수형(int형) 5가 들어오고 뷰가 실행된다. GET 메소드라면 pk로 토픽의 특정과 렌더링, POST 메소드라면 pk에서 댓글 작성 대상 토픽의 ID를  키명 target에 값을 넣어, 검증한다. 이것으로 urls.py로 대상 id를 특정하여 표시하거나 댓글을 저장할 수 있다.

 나중에 답장할 때는 HTML의 form 태그의 action 속성에 action="reply/숫자/"가 되도록 템플릿을 만든다. templates/bbs/reply.html의 코드는 다음과 같다.

<!DOCTYPE html>
<html lang="kr">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>{{ topic.comment }}에 대한 댓글</title>

    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">

</head>
<body>

    <h1 class="bg-primary text-white text-center">댓글</h1>

    <main class="container">

        <a class="btn btn-primary" href="{% url 'bbs:index' %}">목록으로 돌아가기</a>

        <div class="border">
            <div>{{ topic.category.name }}</div>
            <div>{{ topic.comment }}</div>
        </div>

        <form action="{% url 'bbs:reply' topic.id %}" method="POST">
            {% csrf_token %}
            <textarea class="form-control" name="comment"></textarea>
            <input type="submit" value="송신">
        </form>

        <h2>댓글 목록</h2>

        <div>{{ replies|length }}건의 댓글</div>

        {% for reply in replies %}
        <div class="border">
            <div>{{ reply.id }}</div>
            <div>{{ reply.comment }}</div>
        </div>
        {% empty %}
        <div>댓글이 없습니다.</div>
        {% endfor %}

    </main>
</body>
</html>

 action 속성은 포맷의 송신처를 나타내고 있기 때문에 URL을 지정할 필요가 있다. 그렇지만, action="reply/{{ topic.id }}"등으로 작성하면 URL이 변경됐을 때 처리가 안된다.

 그러므로 form 태그의 action 속성에는 DTL의 url를 사용하고 있다. urls.py에서 지정한 name과 app_name를 사용하여 역순하고 있는 것이다.

 이 때, 인수로써는 topic.id를 지정한다. 이것으로 토픽 id에 따라 action 속성이 바뀌게 된다. 이참에 template/bbs/index.html에 송신 링크를 넣자.

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>간단한 게시판</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
</head>
<body>

    <main class="container">
        <form method="POST">
            {% csrf_token %}
            <select name="category">
                {% for category in categories %}
                <option value="{{ category.id }}">{{ category.name }}</option>
                {% endfor %}
            </select>
            <textarea class="form-control" name="comment"></textarea>
            <input type="submit" value="송식">
        </form>

        {% for topic in topics %}
        <div class="border">
            <div>{{ topic.category.name }}</div>
            <div>{{ topic.comment }}</div>

            <!--↓추가-->
            <a class="btn btn-primary" href="{% url 'bbs:reply' topic.id %}">송신</a>
        </div>
        {% endfor %}

    </main>
</body>
</html>

참고자료

https://noauto-nolife.com/post/django-models-foreignkey/

728x90