설표의 장고




장고) 게시판 기능 구현하기(CRUD)





( 수정됨)


환경

OS : Windows 10
python : 3.10.11
Django : 5.0.7

주요 파일 경로

mysite/
|- manage.py
|- config/
|   |- urls.py
|   |- settings/
|       |- settings.py
|- board/
|   |- models.py
|   |- urls.py
|   |- views.py
|- templates/
    |- list.html
    |- detail.html

읽기 전에

이 글을 따라하는 것이 목적이라면 이전 글에서 진행한 대로 기초 설정을 동일하게 맞추고 따라해주세요.

app 생성하기

이전 글에서 사이트에서 어떤 것이 필요한지 알아보았으니, 이번에는 그것을 구현해볼 차례입니다.

게시글을 관리할 "board"라는 이름의 app을 생성합니다.
명령어는 "django-admin startapp board"입니다.

(django) C:\django\mysite>django-admin startapp board
(django) C:\django\mysite>

models.py 작성하기

app을 생성하면 mysite 폴더에 "board"라는 이름의 폴더가 생성됩니다.
"board" 폴더에서 "models.py"를 찾아 다음과 같이 작성해줍니다.

# board/models.py
# code by 설표의장고(django.seolpyo.com)

from django.conf import settings
from django.db import models
from django.shortcuts import resolve_url


# 게시글 model
class Article(models.Model):
    class Meta:
        ordering = ['-date_edit',] # 기본 정렬순서, date_edit 기준 내림차순 정렬
    author = models.ForeignKey( # 작성자 정보
        getattr(settings, 'AUTH_USER_MODEL'), # object가 settings의 AUTH_USER_MODEL에 해당하는 model에 속하도록 설정
        on_delete=models.SET_NULL, # 작성자 model 삭제되면 값을 null로 변경
        null=True, # null data를 허용
        editable=False, # 일반 사용자가 이 data를 건드릴 수 없도록 설정
    )
    title = models.CharField( # 제목 field
        verbose_name='제목', # 표시되는 명칭
        max_length=40, # 최대 40자
        blank=False, # 이 값은 반드시 입력되어야 함을 설정
    )
    content = models.TextField( # 내용 field
        verbose_name='내용',
        max_length=9999, # 최대 9999자
        blank=False, # 이 값은 반드시 입력되어야 함을 설정
    )
    date_post = models.DateTimeField( # 작성시간 field
        auto_now_add=True, # object가 최초 저장될 때만 시간을 갱신하도록 설정
    )
    date_edit = models.DateTimeField( # 수정시간 field
        auto_now=True, # object가 저장될 때마다 시간을 갱신하도록 설정
    )
    hits = models.IntegerField( # 조회수 field
        default=0, # 기본값을 0으로 설정
        editable=False, # 일반 사용자가 이 data를 건드릴 수 없도록 설정
    )

    def hit(self):
        "조회수 1 추가"
        self.hits = models.F('hits') + 1
        return self.save(update_fields=['hits'])
    # 절대경로(url) 설정
    def get_absolute_url(self): return resolve_url('board:detail', self.pk)


# 댓글 model
class Comment(models.Model):
    post = models.ForeignKey( # 게시글 정보
        Article, # object가 Article object에 속하도록 설정
        on_delete=models.SET_NULL,
        null=True,
        editable=False,
    )
    author = models.ForeignKey(getattr(settings, 'AUTH_USER_MODEL'), on_delete=models.SET_NULL, null=True, editable=False,)
    content = models.TextField(
        verbose_name='내용',
        max_length=199, # 최대 199자
        blank=False,
    )
    date_post = models.DateTimeField(auto_now_add=True,)
    date_edit = models.DateTimeField(auto_now=True,)

    def get_absolute_url(self): return resolve_url('board:detail', getattr(self, 'post_id')) + f'#comment{self.pk}'

app 등록하기

models.py를 수정했다면, settings.py의 INSTALLED_APPS에 방금 생성한 app의 이름인 "board"를 추가해줍니다.

# config/settings/settings.py

...
INSTALLED_APPS = [
    'board',
    ...
]
...

마이그레이션

"python manage.py makemigrations" 명령어와 "python manage.py migrate" 명령어를 순서대로 입력해줍니다.
그러면 다음과 같이 마이그레이션이 진행됩니다.

(django) C:\django\mysite>python manage.py makemigrations
Migrations for 'board':
  board\migrations\0001_initial.py
    - Create model Article
    - Create model Comment

(django) C:\django\mysite>python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, board, contenttypes, member, sessions
Running migrations:
  Applying board.0001_initial... OK

(django) C:\django\mysite>

views.py 작성하기

board app에서 수행할 작업을 views.py에 작성해줍니다.

views.py에서 구현하는 내용은 게시물 생성(Create), 읽기(Read), 수정(Update), 삭제(Delete)이며, 영어로는 각 단어의 앞 글자를 가져와 "CRUD operations" 라고 부릅니다.

# board/views.py
# code by 하얀설표(https://django.seolpyo.com/)

from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView

from .models import Article, Comment


class PostMixin:
    model = Article
    fields = '__all__' # form에 사용되는 field 목록
    template_name = 'post.html' # 작성 페이지에 사용하는 template
    def form_valid(self, form):
        self.object = form.save( # object 생성 function
            commit=False # object를 생성하지만 database에 저장하지는 않음
        )
        self.object.author = self.request.user # object에 작성자 정보 추가
        if self.kwargs.get('post_id'): setattr(self.object, 'post_id', self.kwargs['post_id']) # 댓글인 경우 object에 post_id 정보 추가
        self.object.save() # object를 database에 저장
        return redirect(self.get_success_url()) # self.get_success_url()으로 가져온 url로 redirect. 기본값은 self.object.get_absolute_url()을 사용한다.

class EditMixin:
    model = Article
    fields = '__all__' # form에 사용되는 field 목록
    template_name = 'post.html' # 작성 페이지에 사용하는 template
    def get_object(self, queryset=None):
        if not self.kwargs.get('post_id'): # 수정하는 object가 게시글인지 댓글인지 판별
            return get_object_or_404( # 다음 조건으로 object를 탐색하고, 없으면 http 404로 응답하는 function
                self.model, # 탐색에 사용되는 model
                pk=self.kwargs[self.pk_url_kwarg], # object pk
                author=self.request.user # 요청을 보낸 이용자가 object 작성자와 같아야 한다.
            )
        return get_object_or_404(
            self.model,
            pk=self.kwargs[self.pk_url_kwarg],
            post_id=self.kwargs['post_id'], # post_id가 object에 저장된 값과 일치해야 한다.
            author=self.request.user
        )

class DeleteMixin:
    model = Article
    def get_object(self, queryset=None):
        if self.request.user.is_staff: return super().get_object() # 관리자(staff)가 게시물을 삭제할 수 있도록 한다.
        if not self.kwargs.get('post_id'): # 삭제하는 object가 게시글인지 댓글인지 판별
            return get_object_or_404(self.model, pk=self.kwargs[self.pk_url_kwarg], author=self.request.user)
        return get_object_or_404(self.model, pk=self.kwargs[self.pk_url_kwarg], post_id=self.kwargs['post_id'], author=self.request.user)
    def get_success_url(self):
        if not self.kwargs.get('post_id'): return resolve_url('board:list') # 게시글 목록으로 redirect
        return resolve_url('board:detail', self.kwargs['post_id']) # 게시글 페이지로 redirect


class List(ListView):
    "글 목록 페이지 요청에 사용되는 작업"
    template_name = 'list.html' # 사용되는 template
    paginate_by = 2 # 한 페이지에 2개의 게시글을 노출하도록 설정
    queryset = ( # 게시글 목록에 사용되는 object
        Article.objects # 게시글 가져오기
            .annotate(num_comments=Count('comment')) # 댓글수를 num_comments라는 이름으로 부여
            .order_by( # 정렬하기
                *Article._meta.ordering # Article model의 Meta에 선언한 odering을 기준으로 정렬
            )
    )

class Post(PostMixin, CreateView):
    "글 작성 페이지 요청에 사용되는 작업"
    pass

class Edit(EditMixin, UpdateView):
    "글 수정 페이지 요청에 사용되는 작업"
    pass

class Delete(DeleteMixin, DeleteView):
    "글 삭제 요청에 사용되는 작업"
    pass

class Detail(DetailView):
    "개별 글 조회 페이지 요청에 사용되는 작업"
    model = Article
    template_name = 'detail.html'
    def get_object(self, queryset=None):
        obj: Article = super().get_object(queryset)
        obj.hit() # models.py에서 설정한 (조회수 + 1) 작업
        obj.refresh_from_db() # 변경된 data 가져오기
        return obj


class CmtPost(PostMixin, CreateView):
    "댓글 작성 페이지 요청에 사용되는 작업"
    model = Comment

class CmtEdit(EditMixin, UpdateView):
    "댓글 수정 페이지 요청에 사용되는 작업"
    model = Comment

class CmtDelete(DeleteMixin, DeleteView):
    "댓글 삭제 요청에 사용되는 작업"
    model = Comment

urls.py 작성하기

어느 url에서 어떤 작업을 수행할지 urls.py를 만들어 다음과 같이 작성합니다.

# board/urls.py
# code by 하얀설표(https://django.seolpyo.com/)

from django.urls import path
from django.contrib.auth.decorators import login_required

from . import views


app_name = 'board' # 이 app에 속한 url은 "board:{name}" 형식의 name을 갖게 된다.

urlpatterns = [
    path('', views.List.as_view(), name='list'),
    path('?page=<int:page>', views.List.as_view(), name='list'),
    path('<int:pk>/', views.Detail.as_view(), name='detail'),
    path(
        'post/', # 글 작성 url
        login_required( # 이용자가 로그인한 상태인지 확인하는 데코레이터
            views.Post.as_view(), # 로그인한 경우 수행하는 작업
            login_url='member:login' # 로그인한 경우 "member:login"에 해당하는 url로 redirect
        ),
        name='post' # url name, app_name과 합쳐 "board:post"로 명명된다.
    ),
    path('<int:pk>/edit/', login_required(views.Edit.as_view(), login_url='member:login'), name='edit'),
    path('<int:pk>/delete/', login_required(views.Delete.as_view(), login_url='member:login'), name='delete'),
    path('<int:post_id>/comment/', login_required(views.CmtPost.as_view(), login_url='member:login'), name='comment'),
    path('<int:post_id>/<int:pk>/edit/', login_required(views.CmtEdit.as_view(), login_url='member:login'), name='comment_edit'),
    path('<int:post_id>/<int:pk>/comment/', login_required(views.CmtDelete.as_view(), login_url='member:login'), name='comment_delete'),
]

url 할당하기

config 폴더의 urls.py에서 다음과 같이 board app의 urls.py를 할당합니다.
이제 홈페이지에 접속하면 이전에 만든 home function이 아닌 board app에 작성된 게시글 목록을 보여주게 됩니다.

"member/" url이 반드시 board app url보다 위에 있어야 한다는 것을 유의해주세요.

# config/urls.py
# code by 하얀설표(https://django.seolpyo.com/)

from django.contrib import admin
from django.urls import path, include

# from . import views # 더 이상 사용하지 않으므로 제거

urlpatterns = [
    path('admin/', admin.site.urls),
    path('member/', include('member.urls')), # member/ 디렉토리 접근시 member app의 urls.py를 사용
    path('', include('board.urls')), # 홈페이지 접근시 board app의 urls.py를 사용
    # path('', views.home), # 더 이상 사용하지 않으므로 제거
]

템플릿 작성하기

글 목록과 개별 글 조회 페이지에서 사용할 html 템플릿을 작성합니다.
글 작성과 수정 템플릿은 이전에 회원가입 페이지를 위해 작성한 post.html을 사용하기 때문에 작성하지 않습니다.

list.html

# templates/list.html
# code by 하얀설표(https://django.seolpyo.com/)

{% extends "base.html" %}


{% block main %}
<form action="{% url 'board:post' %}">
    <input type="submit" value="글 작성하기">
</form>
<table class="list">
    <thead>
        <tr>
            <th>번호</th>
            <th>제목</th>
            <th>작성자</th>
            <th>작성일</th>
            <th>조회수</th>
        </tr>
    </thead>
    <tbody>
        {# 게시글 list loop #}
        {% for obj in object_list %}
        <tr>
            <td>{{ obj.pk }}</td>
            <td>
                {# 게시글 절대경로 #}
                <a href="{{ obj.get_absolute_url }}">
                    {# 제목과 댓글 수 표시 #}
                    {{ obj.title }} {% if obj.num_comments %}({{ obj.num_comments  }}){% endif %}
                </a>
            </td>
            {# 작성자 #}
            <td>{{ obj.author }}</td>
            {# 작성시간 #}
            <td>{{ obj.date_post|date:'Y.m.d.' }}</td>
            {# 조회수 #}
            <td>{{ obj.hits }}</td>
            {% empty %}
            {# list가 빈 경우 #}
            <td colspan="5">게시글이 없습니다.</td>
        </tr>
        {# loop 종료 명령어 #}
        {% endfor %}
    </tbody>
</table>

{% if is_paginated %}
<div class="page" style="display: flex;">
    {% for i in paginator.page_range %}
        {# 페이지 버튼 간 간격 띄우기 #}
        {% if not forloop.first %}&nbsp;&nbsp;{% endif %}

        {% if i == page_obj.number %}
        <p><b>{{ i }}</b></p>
        {% elif page_obj.number|add:-5 <= i and i <= page_obj.number|add:5 %}
        <p><a href="{% url 'board:list' %}?page={{ i }}">{{ i }}</a></p>
        {% endif %}
    {% endfor %}
</div>
{% endif %}

{% endblock %}

detail.html

# templates/detail.html
# code by 하얀설표(https://django.seolpyo.com/)

{% extends "base.html" %}

{% block main %}
<script>
    function del() { return confirm('삭제하시겠습니까?') }
</script>

<form action="{% url 'board:list' %}" style="display: inline-block;">
    <input type="submit" value="글 목록">
</form>
{# 게시글 수정 버튼을 작성자에게만 보여주기 #}
{% if request.user == object.author %}
&nbsp;
<form action="{% url 'board:edit' object.pk %}" style="display: inline-block;">
    <input type="submit" value="글 수정하기">
</form>
{% endif %}
{# 게시글 삭제 버튼을 작성자 또는 관리자에게만 보여주기 #}
{% if request.user == object.author or request.user.is_staff %}
&nbsp;
<form method="post" action="{% url 'board:delete' object.pk %}" onsubmit="return del()" style="display: inline-block;">
    {% csrf_token %}
    <input type="submit" value="글 삭제하기">
</form>
{% endif %}
<div class="title">
    {# 게시글 제목 #}
    {{ object.title }}
</div>
<div class="info" style="display: flex;">
    {# 작성자 #}
    <div>{{ object.author }}</div>
    {# 작성시간과 수정시간 #}
    <div>{% if object.date_post == object.date_edit %}작성시간 {{ object.date_post|date:'Y.m.d. H:i' }}{% else %}수정시간 {{ object.date_edit|date:'Y.m.d. H:i' }}{% endif %}</div>
    {# 조회수 #}
    <div>조회 {{ object.hits }}</div>
</div>
<hr>
<div class="content">
    {# 본문 내용 #}
    {# "|safe" template filter를 적용하면 html을 escape하지 않는다. #}
    {{ object.content|safe }}
</div>
<hr>
<form action="{% url 'board:comment' object.pk %}">
    <input type="submit" value="댓글 작성하기">
</form>
<hr>
<div class="comments">
    {% if object.comment_set.all %}
    <div>댓글 {{ object.comment_set.all.count }}</div>
    {# 댓글 loop #}
    <div style="width: 95%; margin-left: auto; margin-right: auto;">
        {% for cmt in object.comment_set.all %}
            {% if not forloop.first %}<hr>{% endif %}
            <div id="comment{{ cmt.pk }}">
                <div style="display: flex;">
                    <div class="name"><b>{{ cmt.author }}</b></div>
                    {# 댓글 수정 버튼을 작성자에게만 보여주기 #}
                    {% if request.user == cmt.author %}
                    <form action="{% url 'board:comment_edit' object.pk cmt.pk %}" style="display: inline-block; margin-left: auto;">
                        <input type="submit" value="댓글 수정하기">
                    </form>
                    {% endif %}
                    {# 댓글 삭제 버튼을 작성자 또는 관리자에게만 보여주기 #}
                    {% if request.user == cmt.author or request.user.is_staff %}
                    &nbsp;&nbsp;
                    <form method="post" action="{% url 'board:comment_delete' object.pk cmt.pk %}" onsubmit="return del()" style="display: inline-block;">
                        {% csrf_token %}
                        <input type="submit" value="댓글 삭제하기">
                    </form>
                    {% endif %}
                </div>
                <div class="content"><pre>{{ cmt.content }}</pre></div>
                <div class="date">{% if cmt.date_post == cmt.date_edit %}작성시간 {{ cmt.date_post|date:'Y.m.d. H:i' }}{% else %}수정시간 {{ cmt.date_edit|date:'Y.m.d. H:i' }}{% endif %}</div>
            </div>
        {% endfor %}
    </div>
    {% else %}
    <div>댓글이 없습니다.</div>
    {% endif %}
</div>

{% endblock %}

글 작성해보기

글을 3개 이상 작성해봅시다.
글 목록 페이지에 최대 2개의 게시글을 노출하도록 설정했기 때문에 게시글이 3개 이상일 때부터 게시글 목록 하단에 페이지 리스트가 노출됩니다.

한 페이지당 노출되는 게시글 수를 조정하고 싶다면, views.py의 List class에 있는 paginate_by를 수정하면 됩니다.

장고 게시판 구현

html escape

샘플 이미지를 보면 p태그, h태그, img태그 등이 포함된 게시글 내용은 html로 구현되어 나오고, 댓글은 구현된 html이 아닌 텍스트로 표시되는 것을 확인할 수 있습니다.
이는 장고에서 기본적으로 모든 텍스트를 escape하여 페이지를 만들기 때문인데, html을 작성할 때 safe라는 이름의 template filter를 적용하면 텍스트를 escape하지 않도록 설정할 수 있습니다.

앞서 작성한 detail.html을 살펴보면 {{ object.content|safe }}으로 safe filter가 적용되어있는 것을 확인할 수 있습니다.



이 글의 댓글 기능은 일부러 막아놓았습니다. 궁금한 내용이 있다면 게시판을 이용해주세요!


공감 : 0