AJAX 댓글 시스템¶
페이지 새로고침 없이 댓글을 작성, 수정, 삭제할 수 있는 AJAX 댓글 시스템 구현 방법을 설명합니다.
📋 기본 구조¶
AJAX 댓글 시스템은 다음 구성요소로 이루어집니다:
- 댓글 표시 영역
- 댓글 작성 폼
- JavaScript 이벤트 처리
- 서버 응답 처리
🔧 댓글 템플릿 구현¶
1. 댓글 목록 영역 (view.html)¶
<!-- 댓글 영역 -->
<div id="comment_area">
<div class="comment-header">
<h3>댓글 <span id="comment_count">{$oDocument->getCommentCount()}</span></h3>
</div>
<!-- 댓글 목록 -->
<div id="comment_list">
<!--@foreach($comment_list as $comment)-->
{@$comment_depth = $comment->get('depth')}
<div class="comment-item" data-comment-srl="{$comment->comment_srl}" data-depth="{$comment_depth}">
<div class="comment-content" style="margin-left:{$comment_depth*20}px">
<!-- 프로필 이미지 -->
<div class="comment-avatar">
<!--@if($comment->getProfileImage())-->
<img src="{$comment->getProfileImage()}" alt="프로필" class="avatar">
<!--@else-->
<img src="/common/img/default-avatar.png" alt="기본 프로필" class="avatar">
<!--@end-->
</div>
<div class="comment-main">
<!-- 댓글 헤더 -->
<div class="comment-info">
<strong class="comment-author">{$comment->getNickName()}</strong>
<span class="comment-date">{zdate($comment->get('regdate'), 'Y.m.d H:i')}</span>
<!-- 댓글 작성자 표시 -->
<!--@if($comment->getMemberSrl() == $oDocument->getMemberSrl())-->
<span class="author-badge">작성자</span>
<!--@end-->
</div>
<!-- 댓글 내용 -->
<div class="comment-text" id="comment_text_{$comment->comment_srl}">
{$comment->getContent()}
</div>
<!-- 댓글 액션 -->
<div class="comment-actions">
<button type="button" class="btn-reply" data-comment-srl="{$comment->comment_srl}">답글</button>
<!--@if($comment->isGranted())-->
<button type="button" class="btn-edit" data-comment-srl="{$comment->comment_srl}">수정</button>
<button type="button" class="btn-delete" data-comment-srl="{$comment->comment_srl}">삭제</button>
<!--@end-->
<!--@if($logged_info->is_admin == 'Y')-->
<button type="button" class="btn-admin-delete" data-comment-srl="{$comment->comment_srl}">관리자삭제</button>
<!--@end-->
</div>
<!-- 답글 작성 폼 (숨김) -->
<div class="reply-form" id="reply_form_{$comment->comment_srl}" style="display:none;">
<form class="comment-form" data-parent="{$comment->comment_srl}">
<input type="hidden" name="document_srl" value="{$oDocument->document_srl}">
<input type="hidden" name="parent_srl" value="{$comment->comment_srl}">
<div class="form-group">
<textarea name="content" class="form-control" placeholder="답글을 입력하세요..." rows="3"></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">답글 등록</button>
<button type="button" class="btn btn-cancel">취소</button>
</div>
</form>
</div>
<!-- 수정 폼 (숨김) -->
<div class="edit-form" id="edit_form_{$comment->comment_srl}" style="display:none;">
<form class="comment-edit-form" data-comment-srl="{$comment->comment_srl}">
<div class="form-group">
<textarea name="content" class="form-control" rows="3">{$comment->getContent(false)}</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">수정 완료</button>
<button type="button" class="btn btn-cancel">취소</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!--@end-->
</div>
<!-- 댓글 작성 폼 -->
<!--@if($grant->write_comment)-->
<div class="comment-write-area">
<form id="comment_form" class="comment-form">
<input type="hidden" name="mid" value="{$mid}">
<input type="hidden" name="document_srl" value="{$oDocument->document_srl}">
<input type="hidden" name="parent_srl" value="">
<div class="form-group">
<textarea name="content" id="comment_content" class="form-control" placeholder="댓글을 입력하세요..." rows="4"></textarea>
</div>
<div class="form-actions">
<div class="comment-options">
<label>
<input type="checkbox" name="notify_message" value="Y"> 답글 알림 받기
</label>
</div>
<button type="submit" class="btn btn-primary">댓글 등록</button>
</div>
</form>
</div>
<!--@else-->
<div class="comment-login-required">
<a href="{getUrl('act','dispMemberLoginForm')}" class="btn btn-login">로그인 후 댓글 작성 가능합니다</a>
</div>
<!--@end-->
</div>
2. JavaScript 이벤트 처리¶
// 댓글 AJAX 처리 스크립트
(function() {
'use strict';
// 댓글 관련 이벤트 바인딩
function initCommentEvents() {
// 댓글 작성
document.getElementById('comment_form').addEventListener('submit', handleCommentSubmit);
// 답글 버튼
document.querySelectorAll('.btn-reply').forEach(function(btn) {
btn.addEventListener('click', showReplyForm);
});
// 수정 버튼
document.querySelectorAll('.btn-edit').forEach(function(btn) {
btn.addEventListener('click', showEditForm);
});
// 삭제 버튼
document.querySelectorAll('.btn-delete').forEach(function(btn) {
btn.addEventListener('click', deleteComment);
});
// 취소 버튼
document.addEventListener('click', function(e) {
if(e.target.classList.contains('btn-cancel')) {
hideAllForms();
}
});
}
// 댓글 작성 처리
function handleCommentSubmit(e) {
e.preventDefault();
var form = e.target;
var formData = new FormData(form);
formData.append('act', 'procBoardInsertComment');
// 내용 검증
var content = formData.get('content').trim();
if(!content) {
alert('댓글 내용을 입력해주세요.');
return;
}
// 로딩 표시
var submitBtn = form.querySelector('button[type="submit"]');
var originalText = submitBtn.textContent;
submitBtn.textContent = '등록 중...';
submitBtn.disabled = true;
// AJAX 요청
fetch('/', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if(data.error == 0) {
// 성공 처리
form.reset();
refreshCommentList();
showMessage('댓글이 등록되었습니다.');
} else {
// 오류 처리
alert(data.message || '댓글 등록에 실패했습니다.');
}
})
.catch(error => {
console.error('Error:', error);
alert('네트워크 오류가 발생했습니다.');
})
.finally(() => {
// 로딩 해제
submitBtn.textContent = originalText;
submitBtn.disabled = false;
});
}
// 답글 폼 표시
function showReplyForm(e) {
hideAllForms();
var commentSrl = e.target.dataset.commentSrl;
var replyForm = document.getElementById('reply_form_' + commentSrl);
if(replyForm) {
replyForm.style.display = 'block';
replyForm.querySelector('textarea').focus();
// 답글 폼 이벤트 바인딩
var form = replyForm.querySelector('form');
form.addEventListener('submit', handleReplySubmit);
}
}
// 답글 작성 처리
function handleReplySubmit(e) {
e.preventDefault();
var form = e.target;
var formData = new FormData(form);
formData.append('act', 'procBoardInsertComment');
var content = formData.get('content').trim();
if(!content) {
alert('답글 내용을 입력해주세요.');
return;
}
fetch('/', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if(data.error == 0) {
hideAllForms();
refreshCommentList();
showMessage('답글이 등록되었습니다.');
} else {
alert(data.message || '답글 등록에 실패했습니다.');
}
})
.catch(error => {
console.error('Error:', error);
alert('네트워크 오류가 발생했습니다.');
});
}
// 수정 폼 표시
function showEditForm(e) {
hideAllForms();
var commentSrl = e.target.dataset.commentSrl;
var editForm = document.getElementById('edit_form_' + commentSrl);
var commentText = document.getElementById('comment_text_' + commentSrl);
if(editForm && commentText) {
commentText.style.display = 'none';
editForm.style.display = 'block';
editForm.querySelector('textarea').focus();
// 수정 폼 이벤트 바인딩
var form = editForm.querySelector('form');
form.addEventListener('submit', handleEditSubmit);
}
}
// 댓글 수정 처리
function handleEditSubmit(e) {
e.preventDefault();
var form = e.target;
var commentSrl = form.dataset.commentSrl;
var formData = new FormData();
formData.append('act', 'procBoardUpdateComment');
formData.append('comment_srl', commentSrl);
formData.append('content', form.querySelector('textarea').value);
fetch('/', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if(data.error == 0) {
refreshCommentList();
showMessage('댓글이 수정되었습니다.');
} else {
alert(data.message || '댓글 수정에 실패했습니다.');
}
})
.catch(error => {
console.error('Error:', error);
alert('네트워크 오류가 발생했습니다.');
});
}
// 댓글 삭제
function deleteComment(e) {
if(!confirm('댓글을 삭제하시겠습니까?')) {
return;
}
var commentSrl = e.target.dataset.commentSrl;
var formData = new FormData();
formData.append('act', 'procBoardDeleteComment');
formData.append('comment_srl', commentSrl);
fetch('/', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if(data.error == 0) {
refreshCommentList();
showMessage('댓글이 삭제되었습니다.');
} else {
alert(data.message || '댓글 삭제에 실패했습니다.');
}
})
.catch(error => {
console.error('Error:', error);
alert('네트워크 오류가 발생했습니다.');
});
}
// 모든 폼 숨기기
function hideAllForms() {
document.querySelectorAll('.reply-form, .edit-form').forEach(function(form) {
form.style.display = 'none';
});
document.querySelectorAll('.comment-text').forEach(function(text) {
text.style.display = 'block';
});
}
// 댓글 목록 새로고침
function refreshCommentList() {
var documentSrl = document.querySelector('input[name="document_srl"]').value;
fetch('/?act=getCommentList&document_srl=' + documentSrl)
.then(response => response.text())
.then(html => {
var parser = new DOMParser();
var doc = parser.parseFromString(html, 'text/html');
var newCommentList = doc.getElementById('comment_list');
var newCommentCount = doc.getElementById('comment_count');
if(newCommentList) {
document.getElementById('comment_list').innerHTML = newCommentList.innerHTML;
document.getElementById('comment_count').textContent = newCommentCount.textContent;
// 이벤트 재바인딩
initCommentEvents();
}
})
.catch(error => {
console.error('Error refreshing comments:', error);
});
}
// 메시지 표시
function showMessage(message) {
var messageDiv = document.createElement('div');
messageDiv.className = 'alert alert-success';
messageDiv.textContent = message;
messageDiv.style.position = 'fixed';
messageDiv.style.top = '20px';
messageDiv.style.right = '20px';
messageDiv.style.zIndex = '9999';
document.body.appendChild(messageDiv);
setTimeout(function() {
document.body.removeChild(messageDiv);
}, 3000);
}
// 초기화
document.addEventListener('DOMContentLoaded', function() {
initCommentEvents();
});
})();
3. CSS 스타일링¶
/* 댓글 영역 스타일 */
#comment_area {
margin-top: 30px;
border-top: 2px solid #eee;
padding-top: 20px;
}
.comment-header h3 {
margin-bottom: 20px;
color: #333;
}
.comment-item {
margin-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 15px;
}
.comment-content {
display: flex;
gap: 10px;
}
.comment-avatar .avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.comment-main {
flex: 1;
}
.comment-info {
margin-bottom: 8px;
}
.comment-author {
color: #333;
margin-right: 10px;
}
.comment-date {
color: #999;
font-size: 12px;
}
.author-badge {
background: #007bff;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
margin-left: 5px;
}
.comment-text {
margin-bottom: 10px;
line-height: 1.6;
word-wrap: break-word;
}
.comment-actions {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.comment-actions button {
background: none;
border: none;
color: #666;
font-size: 12px;
cursor: pointer;
padding: 2px 5px;
}
.comment-actions button:hover {
color: #007bff;
}
/* 댓글 작성/수정 폼 */
.comment-form {
margin-top: 10px;
}
.comment-form .form-group {
margin-bottom: 10px;
}
.comment-form textarea {
width: 100%;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
resize: vertical;
font-family: inherit;
}
.comment-form textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0,123,255,.25);
}
.form-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
.comment-options {
font-size: 12px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
.btn-cancel {
background: #6c757d;
color: white;
}
.btn-cancel:hover {
background: #545b62;
}
/* 댓글 깊이별 들여쓰기 */
.comment-item[data-depth="1"] .comment-content {
margin-left: 20px;
}
.comment-item[data-depth="2"] .comment-content {
margin-left: 40px;
}
.comment-item[data-depth="3"] .comment-content {
margin-left: 60px;
}
/* 로딩 상태 */
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 알림 메시지 */
.alert {
padding: 12px 16px;
border-radius: 4px;
margin-bottom: 15px;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
/* 반응형 */
@media (max-width: 768px) {
.comment-content {
flex-direction: column;
gap: 5px;
}
.comment-avatar {
align-self: flex-start;
}
.form-actions {
flex-direction: column;
gap: 10px;
align-items: stretch;
}
}
💡 고급 기능¶
1. 실시간 글자 수 제한¶
// 댓글 글자 수 제한 (예: 1000자)
function addCharacterLimit(textarea, maxLength = 1000) {
var counter = document.createElement('div');
counter.className = 'character-counter';
counter.textContent = '0 / ' + maxLength;
textarea.parentNode.appendChild(counter);
textarea.addEventListener('input', function() {
var length = this.value.length;
counter.textContent = length + ' / ' + maxLength;
if(length > maxLength) {
counter.style.color = 'red';
this.value = this.value.substring(0, maxLength);
} else {
counter.style.color = '#666';
}
});
}
2. 댓글 미리보기¶
// 댓글 미리보기 기능
function addPreviewFeature(textarea) {
var previewBtn = document.createElement('button');
previewBtn.type = 'button';
previewBtn.textContent = '미리보기';
previewBtn.className = 'btn btn-secondary btn-sm';
var previewArea = document.createElement('div');
previewArea.className = 'comment-preview';
previewArea.style.display = 'none';
textarea.parentNode.appendChild(previewBtn);
textarea.parentNode.appendChild(previewArea);
previewBtn.addEventListener('click', function() {
var content = textarea.value;
if(content.trim()) {
previewArea.innerHTML = content.replace(/\n/g, '<br>');
previewArea.style.display = 'block';
}
});
}