회원가입

[설계 & 개발] 블로그 글 작성 시 임시 저장 기능 개발

Beany 2024-10-13

ChatGPT 요약

텍스트 작성 중에 중간에 브라우저를 닫거나 실수로 뒤로 가기 버튼을 누르면 데이터가 사라질 수 있어서 매번 저장 버튼을 누르는 번거로움이 있습니다. 이를 해결하기 위해 일정 주기로 자동으로 데이터를 임시 저장하는 기능을 구현하고, 게시글의 본문을 주로 처리하는 것을 목표로 합니다. 임시 저장은 최대 5개까지 저장 가능하며, FIFO 형태의 큐로 구현하여 가장 오래된 데이터가 삭제되도록 합니다. Redis를 이용하여 임시 저장을 위한 Queue를 정의하고, 게시글 본문 수정 시 디바운스 처리 후 자동 저장하는 JavaScript 코드를 작성합니다. 또한, Django Admin 페이지를 커스텀하여 임시 저장된 데이터를 확인하고 관리할 수 있도록 구축합니다.

블로그 글을 작성할 때, 실수로 뒤로 가기 버튼을 누르거나 브라우저를 닫거나, 중간에 컴퓨터가 꺼질 수 있다는 불안감이 항상 있습니다.

그래서 아래 이미지처럼, 특정 부분까지 작성했다고 생각되면 게시글을 공개하지 않은 상태로 DB에 데이터를 저장하기 위해 매번 저장 버튼을 클릭합니다.

 

 

매번 이런 작업을 하다 보니 신경을 계속 써야 하고, 너무 힘든 점이 많습니다.

이러한 불편함을 개선하기 위해 일정한 주기로 자동으로 데이터를 임시 저장하는 기능을 구현하려고 합니다.

 

 

 

임시 저장 프로세스


위 이미지처럼, 게시글 작성 페이지에 본문을 수정하면 디바운스 처리 후, 임시 저장을 수행하는 JavaScript 코드를 작성하여 요청하려고 합니다.

그리고 현재 생각 중인 것은 오직 게시글 본문(body)만 작업하려고 합니다. 가장 중요한 부분이 게시글 본문이기 때문입니다.

임시 저장은 게시글당 최대 5개까지만 저장할 수 있는 구조로 만들 계획입니다.

5개를 초과하면 FIFO 형태의 큐(Queue)로, 가장 먼저 저장된 것이 가장 먼저 삭제되도록 개발하려고 합니다.

 

 

필요한 작업


  1. RedisQueue 정의
  2. 임시 저장 생성 API 구현
  3. 임시 저장 조회 API 구현
  4. Admin 페이지에 임시 저장 생성 API 디바운스로 호출
  5. Admin 페이지에 임시 저장 조회 API 호출 후, 정보 가져올 수 있도록 설정

 

개발


1. RedisQueue 정의

Redis 세팅을 위해 Redis 설정을 정의합니다.

REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 0

 

common/common_utils/redis_utils.py

import redis
from django.conf import settings
from typing import Optional

redis_client = redis.StrictRedis(
    host=settings.REDIS_HOST,
    port=settings.REDIS_PORT,
    db=settings.REDIS_DB,
    decode_responses=True,
)


class RedisQueue:
    def __init__(self, queue_name: str, ttl: int = None, max_size: int = None):
        self.queue_name = queue_name
        self.ttl = ttl
        self.max_size = max_size

    def enqueue(self, value: str) -> int:
        """
        큐에 데이터를 추가하는 메서드. 만약 키가 없으면 자동으로 생성된다.
        """
        total_count = redis_client.lpush(
            self.queue_name,
            value,
        )
        if self.max_size and total_count > self.max_size:
            self.dequeue()
        if self.ttl:
            redis_client.expire(self.queue_name, self.ttl)
        return total_count

    def dequeue(self) -> str:
        """
        큐에서 데이터를 꺼내는 메서드.
        만약 데이터가 없으면 None을 반환.
        """
        return redis_client.rpop(self.queue_name)

    def size(self) -> int:
        """
        큐의 사이즈를 반환하는 메서드
        """
        return redis_client.llen(self.queue_name)

    def get_all(self) -> list:
        """
        큐의 모든 데이터를 반환하는 메서드
        """
        return redis_client.lrange(self.queue_name, 0, -1)

    def get_last(self) -> Optional[str]:
        """
        큐의 마지막 데이터를 반환하는 메서드
        """
        return redis_client.lindex(self.queue_name, 0)

RedisQueue 라는 클래스를 정의해 이것으로 Queue 역할을 할 수 있도록 작업했습니다.

 

 

 

2. 임시 저장 생성 API 구현

board.urls.py

urlpatterns = [
    ...
    path('post/temporary-save', post_temporary_save, name='post_temporary_save'),
]

위와 같은 엔드포인트(endpoint)를 기준으로 작업할 계획입니다.

PK를 정의하지 않은 이유는, 처음 저장하지 않으면 게시글의 PK가 정의되지 않아 PK를 알 수 없기 때문입니다.

따라서 두 가지 경우를 고려하려고 합니다. 첫 번째는 게시글 작성 시 PK가 없는 경우이고, 두 번째는 게시글 작성 후 PK가 정의되는 경우입니다.

만약 PK가 없다면, 사용자의 admin_account_id를 기준으로 Queue Name Key 를 정의하려고 합니다.

만약 PK가 있다면, 게시글의 PK 와 함께 조합한 기준으로 Queue Name Key 를 정의하려고 합니다.

 

board.views.py

@login_required(login_url='/')
def post_temporary_save(request):
    request_data = json.loads(request.body)
    queue_name = request_data.get('queue_name')
    value = request_data.get('value')

    if not queue_name or not value:
        return JsonResponse({'message': '"queue_name", "value" is required'}, status=400)

    redis_queue = RedisQueue(queue_name, ttl=60 * 30, max_size=5)
    if redis_queue.get_last() != value:
        redis_queue.enqueue(value)
    return JsonResponse({'message': 'success'}, status=200)

큐(Queue)의 이름과 값이 없으면 Redis에 임시 저장하지 않습니다.

또한, 값이 동일한 경우에도 Redis에 임시 저장하지 않습니다.

Queue 에 저장되면 30분 동안 임시저장 합니다.

 

 

 

3. 임시 저장 조회 API 구현

board.urls.py

urlpatterns = [
    ...
    path('post/get-temporary-save', get_temporary_save, name='get_temporary_save'),
]

이제 임시 저장 조회를 위한 endpoint 를 정의합시다
(REST 하지 않지만 이대로 합시다..)

 

board.views.py

@login_required(login_url='/')
def get_temporary_save(request):
    queue_name = request.GET.get('queue_name')
    if not queue_name:
        return JsonResponse({'message': '"queue_name" is required'}, status=400)

    redis_queue = RedisQueue(queue_name)
    return JsonResponse({'data': redis_queue.get_all()}, status=200)

 

 

 

4. Admin 페이지에 임시 저장 생성 API 디바운스로 호출

Django Admin 페이지 커스텀이 필요합니다. 원하는 JavaScript를 디테일 페이지에 정의하여 추가할 예정입니다.

django_admin/temp_save.js

document.addEventListener('DOMContentLoaded', function () {
    function sendTemporarySave(queue_name, value) {
        const data = {
            queue_name: queue_name,
            value: value
        };

        fetch('/post/temporary-save', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRFToken': getCookie('csrftoken')
            },
            body: JSON.stringify(data)
        })
            .then(response => {
                if (!response.ok) {
                    throw new Error('Network response was not ok');
                }
                return response.json();
            })
            .then(data => {
                console.log('Success:', data);
            })
            .catch((error) => {
                console.error('Error:', error);
            });
    }

    function getCookie(name) {
        let cookieValue = null;
        if (document.cookie && document.cookie !== '') {
            const cookies = document.cookie.split(';');
            for (let i = 0; i < cookies.length; i++) {
                const cookie = cookies[i].trim();
                if (cookie.substring(0, name.length + 1) === (name + '=')) {
                    cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
                    break;
                }
            }
        }
        return cookieValue;
    }

    function getCurrentUrl() {
        return window.location.href;
    }

    const url = getCurrentUrl();
    const urlParts = url.split('/');
    const postId = urlParts[urlParts.length - 3];
    let queueName;
    if (postId === 'post') {
        queueName = document.querySelector('#user-tools strong').innerHTML;
    } else {
        queueName = `post_${postId}`;
    }

    if (typeof CKEDITOR !== 'undefined') {
        CKEDITOR.on('instanceReady', function (event) {
            const editor = event.editor;
            let debounceTimer;

            function debounce(func, delay) {
                return function(...args) {
                    if (debounceTimer) {
                        clearTimeout(debounceTimer);
                    }
                    debounceTimer = setTimeout(() => {
                        func(...args);
                    }, delay);
                };
            }

            editor.on('change', function () {
                const value = editor.getData();
                debounce(sendTemporarySave, 500)(queueName, value);
            });
        });
    } else {
        console.error('CKEditor is not defined');
    }
});

만약 PK가 없다면, 사용자의 주황색 데이터를 기준으로 Queue Name Key를 정의합니다.

PK가 있다면, 게시글의 post_빨간색 데이터를 함께 조합하여 Queue Name Key를 정의합니다.

 

Django Admin의 클래스 Media 를 정의합니다.

board.admin.py

@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    inlines = [
        UrlImportantInline,
        ReplyInline,
        RereplyInline,
        LikeInline,
    ]
    list_filter = (
        'board__name',
    )
    search_fields = (
        'title',
        'body',
        'author__username',
        'author__nickname',
        'author__email',
    )
    list_display = (
        'id',
        'author',
        'board_name',
        'title',
        '_tag_set',
        '_comment_count',
        '_like_count',
    )

    class Media:
        js = (
            'django_admin/temp_save.js',
        )

 

 

 

5. Admin 페이지에 임시 저장 조회 API 호출 후, 정보 가져올 수 있도록 설정


마지막으로, Django Admin HTML을 커스터마이징하여 임시 저장된 데이터를 표시할 수 있도록 만들어보겠습니다.

이를 위해 Django Admin 템플릿을 오버라이딩할 계획입니다.

templates/admin/board/post/change_form.html

{% extends "admin/change_form.html" %}

{% block content %}
    <div id="custom-api-section" style="margin-bottom: 20px;">
        <h3>임시저장 데이터 가져오기</h3>
        <button class="default" id="fetch-api-data" type="button" style="
            background-color: #4CAF50;
            color: white;
            padding: 12px 20px;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            font-size: 16px;
            transition: background-color 0.3s ease;
            margin-bottom: 20px;
        ">새로 가져오기
        </button>
        <div id="api-result"></div>
    </div>

    {{ block.super }}  <!-- 기본 Admin 콘텐츠는 아래에 출력 -->

    <script type="text/javascript">
        document.addEventListener('DOMContentLoaded', function () {
            const fetchButton = document.getElementById('fetch-api-data');
            const resultDiv = document.getElementById('api-result');

            // Post ID나 queue_name을 가져오는 함수
            function getQueueName() {
                const urlParts = window.location.href.split('/');
                const postId = urlParts[urlParts.length - 3];
                if (postId === 'post') {
                    return document.querySelector('#user-tools strong').innerHTML;
                } else {
                    return `post_${postId}`;
                }
            }

            // API 요청을 보내는 함수
            function fetchTemporarySaveData(queueName) {
                const url = `/post/get-temporary-save?queue_name=${queueName}`;
                resultDiv.innerHTML = '<p>데이터를 가져오는 중입니다...</p>';

                fetch(url)
                    .then(response => {
                        if (!response.ok) {
                            throw new Error(`HTTP error! status: ${response.status}`);
                        }
                        return response.json();
                    })
                    .then(data => displayTemporarySaveData(data))
                    .catch(error => {
                        resultDiv.innerHTML = `<p>오류 발생: ${error.message}</p>`;
                    });
            }

            // 데이터를 화면에 표시하는 함수
            function displayTemporarySaveData(data) {
                resultDiv.innerHTML = ''; // 기존 내용을 지움

                if (!data.data || data.data.length === 0) {
                    resultDiv.innerHTML = '<p>임시 저장 데이터가 없습니다.</p>';
                    return;
                }

                data.data.forEach((item, index) => {
                    const outerDiv = document.createElement('div');
                    outerDiv.style.marginBottom = '20px';

                    const innerDiv = document.createElement('div');
                    innerDiv.innerHTML = `<p><strong>임시 저장 데이터 ${index + 1}</strong></p>`;

                    const textarea = document.createElement('textarea');
                    textarea.style.width = '100%';
                    textarea.style.height = '200px';
                    textarea.value = item;
                    textarea.name = `temp-save-result-${index}`;

                    outerDiv.appendChild(innerDiv);
                    outerDiv.appendChild(textarea);
                    resultDiv.appendChild(outerDiv);
                });
            }

            // 버튼 클릭 시 API 호출
            fetchButton.addEventListener('click', function () {
                const queueName = getQueueName();
                fetchTemporarySaveData(queueName);
            });
        });
    </script>
{% endblock %}

짠~ 완성입니다~!!!

이제 드디어 매번 힘들게 수동으로 저장하지 않아도, 임시 저장 기능 덕분에 그 부담을 덜 수 있을 것 같습니다.

0 0
블로그 일기
제 블로그의 고도화 과정을 설명합니다. 이는 코드 리팩토링과 추가된 기능들에 대해 기록하기 위한 게시판입니다. 어떤 기능이 추가되었는지, 무엇이 개선되었는지 등 고도화되는 과정을 자세히 다룰 예정입니다.
Yesterday: 456
Today: 99