AJAX 댓글 시스템

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';
        }
    });
}

🔗 관련 문서