블로그 글을 작성할 때, 실수로 뒤로 가기 버튼을 누르거나 브라우저를 닫거나, 중간에 컴퓨터가 꺼질 수 있다는 불안감이 항상 있습니다.
그래서 아래 이미지처럼, 특정 부분까지 작성했다고 생각되면 게시글을 공개하지 않은 상태로 DB에 데이터를 저장하기 위해 매번 저장 버튼을 클릭합니다.
매번 이런 작업을 하다 보니 신경을 계속 써야 하고, 너무 힘든 점이 많습니다.
이러한 불편함을 개선하기 위해 일정한 주기로 자동으로 데이터를 임시 저장하는 기능을 구현하려고 합니다.
위 이미지처럼, 게시글 작성 페이지에 본문을 수정하면 디바운스 처리 후, 임시 저장을 수행하는 JavaScript 코드를 작성하여 요청하려고 합니다.
그리고 현재 생각 중인 것은 오직 게시글 본문(body)만 작업하려고 합니다. 가장 중요한 부분이 게시글 본문이기 때문입니다.
임시 저장은 게시글당 최대 5개까지만 저장할 수 있는 구조로 만들 계획입니다.
5개를 초과하면 FIFO 형태의 큐(Queue)로, 가장 먼저 저장된 것이 가장 먼저 삭제되도록 개발하려고 합니다.
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 역할을 할 수 있도록 작업했습니다.
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분 동안 임시저장 합니다.
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)
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',
)
마지막으로, 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 %}
짠~ 완성입니다~!!!
이제 드디어 매번 힘들게 수동으로 저장하지 않아도, 임시 저장 기능 덕분에 그 부담을 덜 수 있을 것 같습니다.