Back-end/Forum with Django

20. 비동기 요청으로 댓글 생성

JUTABI 2025. 7. 2. 20:53

이제 댓글 생성을 비동기로 요청해 봅시다.

서버 측 작업

urls.py

기존의 SSR url을 그대로 사용합니다.

comment_views.py

from django.http import HttpResponseNotAllowed, JsonResponse
from django.shortcuts import render, get_object_or_404

from forum.forms import CommentForm
from forum.models import *


# 댓글 생성 후에 클라이언트에게 comments 쿼리셋을 넘겨 줄 예정인데 comment_list와 중복코드를 막기 위해
# 댓글 리스트를 불러오는 함수를 따로 작성하였습니다.
    # 댓글 생성 시 상태만 전송하고 성공했다면 클라이언트에서 comment list 요청을 보낼 수도 있지만
    # 그렇게 되면 서버에 결국 두번 요청하는 것이기 때문에 리소스를 낭비하게 되고 코드도 불필요한 증가가 생깁니다.
def get_comments(post_pk):
    comments = Comment.objects.filter(post=post_pk).values(
        'pk', 'author__pk', 'author__username', 'author__nickname', 'content', 'created_date')

    custom_field = []
    for comment in comments:
        custom_field.append({
            'pk': comment['pk'],
            'author_pk': comment['author__pk'],
            'username': comment['author__username'],
            'nickname': comment['author__nickname'],
            'content': comment['content'],
            'created_date': comment['created_date'],
        })
    return custom_field


def comment_create(request, post_pk):
    if request.method == 'POST':
        post = get_object_or_404(Post, pk=post_pk)
        form = CommentForm(request.POST)
        if form.is_valid():
            comment = form.save(commit=False)
            comment.author = request.user
            comment.post = post
            comment.save()
            # 기존의 코드와 다르게 redirect 하는 것이 아닌 status와 comments 쿼리셋을 전송합니다.
            # 클라이언트 측 코드에서 댓글 생성이 완료되면 다시 getComments()작업을 하는 것이 아닌
            # 생성이 정상 처리 되면 뷰에서 바로 쿼리셋을 넘기고 클라이언트는 renderComments()를 진행합니다.
            context = {
                'status': 'success',
                'comments': get_comments(post_pk),
            }
            return JsonResponse(context)
        # 만약 폼에 문제가 있다면 status와 에러 내용, 기존의 <form>내용을 사용자에게 다시 반환합니다.
        context = {
            'status': 'error',
            'errors': form.errors,
            # SSR에서는 오류 발생 시(글자제한을 넘었거나, 특정패턴 금지에 걸렸거나) 기존의 문자열을 사용자에게 
            # 다시 보내줘야 하지만 비동기 요청에서는 창이 새로고침 되거나 리다이렉트 되지 않기 때문에 입력 내용이
            # 살아있어 오류 메세지만 보내주고, <textarea>에 기존 입력 content를 재지정 해 줄 필요가 없습니다.
            # 'content': form.cleaned_data.get('content'),
        }
        return JsonResponse(context)
    return HttpResponseNotAllowed(['POST'])


def comment_list(request, post_pk):
    comments = get_comments(post_pk)

    return JsonResponse(list(comments), safe=False)

클라이언트 측 작업

post_detail.html

새로운 댓글 갱신 작업 시 (생성, 수정, 삭제) 기존 내용을 삭제하기 위해 <template>태그를 밖으로 빼주었습니다.

    <div class="comments">

    </div>
    <template id="comment-template">
        <div class="card mb-3">
            <div class="card-header"></div>
            <div class="card-body">
                <div class="card-text" style="white-space: pre-wrap"></div>
            </div>
            <div class="card-footer" style="font-size: small">
                <div class="comment-created-date"></div>
                <div class="comment-modified-date"></div>
            </div>
        </div>
    </template>

    <!--기존의 action 속성 값을 데이터셋-url로 변경합니다.-->
    <form id="comment-form" class="my-4" method="POST" data-url="{% url 'forum:comment_create' post.pk %}">
        {% csrf_token %}
        <!--form_errors 템플릿을 사용하지 않습니다.-->
        <!--{% include 'form_errors.html' %}-->
        <div class="my-2">
            ...
        </div>
        <div class="text-end">
            ...
        </div>
    </form>

post_detail.js

// 여러 함수에서 같은 api를 사용하는 비동기 요청이 있을 때는 요청 로직을 함수로 만들어 재사용 합니다.
// method와 body(ajax의 data)인자를 기본값을 설정하여 입력값이 없더라도 기본 get요청으로 동작하게 합니다.
async function apiFetch(url, method = 'get', body = null) {
    try {
        const options = {
            method: method,
        }
        if (body) options.body = body; //options['body'] = body;
        // body인자의 값이 존재한다면 => 아래 객체 접근 항목 참조
        // options = {
        //     method: method,
        //     body: body,
        // }

        const response = await fetch(url, options);
        if (!response.ok) throw new Error(response.statusText);

        return response.json();
    } catch (error) {
        console.error(`Fetch: Failed to fetch: ${error}`);
    }
}

async function getComments(post_pk) {
    // 프로미스가 아닌 JSON데이터를 받아오기 위해 await, 를 사용하기 위해 함수를 async로 선언
    const comments = await apiFetch(`/posts/${post_pk}/comments/`);
    renderComments(comments);
}

function formatISODate(date) {
    ...
}

function renderComments(comments) {
    const commentsDiv = document.querySelector('.comments');
    // 이미 렌더링 되어 있는 comments에서 다시 렌더링할 때를 위해서 innerHTML(하위 노드들)을 제거합니다.
    // <template>를 <div comments>의 하위에서 밖으로 빼내줍니다.
    commentsDiv.innerHTML = '';

    comments.forEach(comment => {
        ...
        // 커스텀 필드명을 적용했기 때문에 key의 이름을 변경했습니다.
        cardDiv.querySelector('.card-header').append(`${comment['nickname']} (${comment['username']})`);
        ...
    })
}

document.querySelector('#comment-form')
    // await를 사용하기 위해 이벤트 리스너 함수도 async로 선언합니다.
    .addEventListener('submit', async function (event) {
        // 기존 요소의 동작을 막아 주는 메서드 입니다.
        // a 태그 클릭 시 링크 이동, <form>의 submit 시 새로고침 하며 데이터 전송 등
        // 메서드를 작성 하지 않고 confirm('hello')를 입력해 봅시다. (confirm 후 페이지가 새로고침 됩니다.)
        event.preventDefault();

        // 아래 context.status === 'error'일때 생성한 경고창 div를 삭제합니다.
        // jQuery 사용 시 부모 노드에서 삭제하지 않고, 엘리먼트.remove()사용이 가능합니다.
        const commentFormAlert = document.querySelector('#comment-form-alert');
        if (commentFormAlert) {
            // 여기서 this는 document.querySelector('#comment-form') 입니다.
            this.removeChild(document.querySelector('#comment-form-alert'));
        }

        // JSON으로 content와 csrf토큰을 넘기는게 아닌 기존의 <form>태그의 작동과 동일한 FormData 객체로 전송합니다.
        // JSON으로 전송 시:
        // const body2 = {
        //     content: this.querySelector('textarea').value,
        //     csrfmiddlewaretoken: document.querySelector('input[name="csrfmiddlewaretoken"]').value,
        // }
        const body = new FormData();
        body.append('content', this.querySelector('textarea').value);
        body.append('csrfmiddlewaretoken', document.querySelector('input[name="csrfmiddlewaretoken"]').value)

        // await를 사용하지 않으면 Promise 객체를 반환합니다.
        // .then을 사용하지 않고 서버에서 전달받은 context를 바로 리턴받기 위해 await를 사용합니다.
        const context = await apiFetch(this.dataset.url, 'POST', body);
        if (context['status'] === 'success') {
            // 서버에서 comment 생성 성공과 쿼리셋(JSON)을 전달받았다면 렌더 함수를 진행합니다. (기존 내용 삭제)
            renderComments(context['comments']);
            // 비동기 처리이기 때문에 textarea에 작성했던 댓글의 내용이 지워지지 않고 살아있습니다. 지워줍시다.
            this.querySelector('textarea').value = '';
        }
        else if (context['status'] === 'error') {
            // alert(`${Object.keys(context.errors)}: ${context['errors']['content']}`)

            // this(comment create <form>)에 경고창을 띄워줍시다.
            // 기존의 form_errors.html 은 사용하지 않습니다.
            const div = document.createElement('div');
            // 또 한번의 요청(성공, 실패 둘 다)을 했을 때 기존의 오류 메세지를 제거하기 위해 id속성을 추가합니다.
            div.id = 'comment-form-alert';
            div.className = 'alert alert-danger';
            const errMsg = div.appendChild(document.createElement('strong'));
            // 장고 폼의 라벨은 템플릿 내에서만 적용됩니다.
            errMsg.textContent = "내용: ";
            div.append(context['errors']['content']);
            // <form>의 맨 위에 경고창을 추가해 줍니다.
            this.prepend(div);
        }
    })
결과 확인

자바스크립트의 객체 접근
// 점 표기법
object.pk
object.name
// 키 이름이 유효한 자바스크립트 식별자(문자, _, $ 등으로 시작하고 공백이나 특수문자가 없음)일 때 사용 가능합니다.

// 대괄호 표기법
object['pk']
object['author-name']
const keyName = 'name';
object[keyname]
// 키 이름이 변수를 사용하여 동적으로 결정될 때, 공백 하이픈 숫자 등으로 시작하는 키 이름일 때도 사용 가능합니다.

// 키는 문자열이고 전달받은 곳에 따라 유효한 식별자로 시작하지 않을 수 있기 때문에 두 방식을 모두 지원합니다.
// 저는 JSON 객체의 키 값이라는 것을 강조하고 싶어서 대괄호 표기법을 사용하였습니다.
자바스크립트 클릭 이벤트 리스너
// jQuery 사용 시:
$('#comment-form').submit(function (event) {

})

// 한번만 지정 가능 (핸들러 개수: 1개), 두번 이상 지정시 덮어쓰기 됨
document.querySelector('#comment-form').onsubmit = function (event) {

}

// 여러 기능 등록 가능 (핸들러 개수: 여러 개)
document.querySelector('#comment-form').addEventListener('submit', function (event) {

})