[ 리팩토링을 하게 된 계기 ]
게시글 상세 화면은 기능이 늘어날수록
코드가 한 파일에 응집되고 조건 분기가
겹치면서 수정 난이도가 급격히 올라간다.
이번에는 기능을 ‘몇 개 더 붙인 것’에서 끝내지 않고,
기능 추가로 생긴 상호작용을
(노출 조건, 제재 정책, 동기/비동기 혼용)
유지보수 가능한 구조로 정리하는 쪽으로 방향을 잡았다.
이번 상세 화면에서 실제로 늘어난 기능 흐름은
아래처럼 한 덩어리로 얽혀 있었다.
- 1) 신고
로그인 + 본인 글 아님 + 미신고 + 작성자가
ADMIN이 아님일 때만 노출, 모달 접수 - 2) 좋아요
토글 + 카운트 동기화 + ‘좋아요 누른 사람’ 모달 - 3) 댓글
목록은 비동기 렌더(정렬/갱신),
작성/수정/삭제는 동기 submit - 4) 제재회원
댓글/신고/좋아요/게시글 수정 및
삭제까지 공통 UX로 차단
1. 이번 리팩토링의 목표
1) 서버 렌더(JSP)가 확정하는 것과
JS가 처리하는 것을 분리한다
- JSP
: 권한/상태 플래그 확정, 버튼 노출 조건 확정, 초기 상태값 주입 - JS
: 화면 상호작용(모달, fetch, 비동기 렌더), UI 동기화, 방어 로직
2) 동기/비동기를 의도적으로 섞되,
기준을 명확히 한다
- 비동기
: 좋아요 토글, 좋아요 목록 모달, 댓글 목록/정렬, 게시글 신고 - 동기
: 댓글 작성/수정/삭제(기존 redirect/message 흐름 유지)
3) 제재 정책은 한 번만 정의하고,
모든 기능에 동일하게 적용한다
- 제재회원이면 ‘요청을 보내기 전에’
프론트에서 1차 차단 - 동시에 서버에서도 최종 차단이
가능하도록 정책 지점을 명확히 남긴다
2. 변경 작업 리스트(실제로 바뀐 것들)
1) 구조/파일
- boardDetail.jsp에서 inline JS/CSS 최소화,
전용 js 파일로 상호작용 이동 - /js/boarddetail.js에서 기능별 섹션 분리
(좋아요/신고/댓글/제재)
2) 게시글 영역
- 작성일/수정일 표시 규칙 통일
(수정된 글이면 수정일만 표기) - 신고 버튼 노출 조건 강화
(본인 글 제외 + 중복 신고 제외 + ADMIN 작성 글 제외)
3) 좋아요 영역
- 좋아요 토글 비동기 처리 + 카운트 동기화
- ‘좋아요 누른 사람’ 모달로 목록 노출
- 제재회원은 좋아요 자체 차단
4) 댓글 영역
- 댓글 목록/정렬은 비동기 렌더링으로 전환
- 댓글 작성자 프로필(이미지/색/꾸밈 클래스) 렌더 반영
- 댓글 수정/삭제는 인라인 편집 UI
+ 최종 submit은 동기 처리(hidden form)
5) 제재회원 정책
- 제재회원이면 댓글/신고/좋아요,
게시글 수정 및 삭제를 공통 UX로 막음 - 안내 모달 문구를 기능별로 교체 가능하게 통일
3. JSP에서 상태 플래그를 확정하고, 화면 노출 조건을 고정
상세 화면에서 가장 중요한 건
‘상태를 어디서 확정하느냐’다.
이번 방향은 JSP가 상태를 확정하고,
JS는 그 상태를 기반으로만 동작하게 하는 구조다.
<%-- 상태 플래그는 JSP에서 확정하고, JS에는 결과만 내려준다 --%>
<c:set var='ctx' value='${pageContext.request.contextPath}' scope='request' />
<c:set var='isLogin' value='${not empty sessionScope.memberId}' />
<c:set var='sessionMemberId' value='${sessionScope.memberId}' />
<c:set var='sessionMemberRole' value='${sessionScope.memberRole}' />
<%-- 제재 상태는 memberStatus 한 군데에서만 판정한다 --%>
<c:set var='memberStatus' value='${sessionScope.memberStatus}' />
<c:set var='isBannedFlag'
value='${memberStatus eq 'SUSPEND_7D'
or memberStatus eq 'SUSPEND_30D'
or memberStatus eq 'BAN'}' />
<%-- 신고 노출 조건을 JSP에서 확정한다 --%>
<c:set var='isReportedFlag' value='${isReported == 1}' />
<c:set var='writerIsAdmin' value='${boardData.writerRole eq 'ADMIN'}' />
<c:set var='canReportPost'
value='${isLogin
and (sessionMemberId ne boardData.memberId)
and (not isReportedFlag)
and (not writerIsAdmin)}' />
변경 포인트
- 제재 판정 기준을 memberStatus로 통일했다
- 신고 노출 조건은 서버 렌더 단계에서 끝냈다
- JS는 ‘누를 수 있는 버튼만 화면에 있음’을 전제로 동작한다
4. 작성일/수정일 표시 규칙을 UI 레벨에서 고정
글이 수정됐는지 여부는 운영 UX에서 중요하다.
이번에는 ‘수정된 글이면 수정일만 노출’로
단일 규칙을 적용했다.
<%-- 수정된 글이면 작성일 대신 수정일만 표시한다 --%>
<c:set var='createdAt' value='${boardData.boardCreatedAt}' />
<c:set var='updatedAt' value='${boardData.boardUpdatedAt}' />
<c:set var='boardIsEdited'
value='${boardData.isEdited == 1
or boardData.isEdited == true
or boardData.isEdited == '1'
or (not empty updatedAt and updatedAt ne createdAt)}' />
<c:choose>
<c:when test='${boardIsEdited and not empty updatedAt}'>
수정일 <c:out value='${updatedAt}' />
</c:when>
<c:otherwise>
작성일 <c:out value='${createdAt}' />
</c:otherwise>
</c:choose>
변경 포인트
- 서버가 내려주는 isEdited가 흔들릴 수 있어서,
날짜 비교까지 포함해 판정했다 - 화면에 노출되는 시간 라인을
1개로 고정해 UI를 단순화했다
5. 제재회원 UX를 공용 모달로 통일
기능이 늘어날수록 제재 처리도
기능마다 흩어지기 쉽다.
그래서 안내 모달은 하나로 두고,
문구만 교체하는 방식으로 통일했다.
// 공용 제재 안내 모달
// - 신고/좋아요/기타 액션에서 재사용한다
// - 모달 내부 텍스트만 상황별로 교체한다
function openBanActionModal(htmlMsg, fallbackMsg) {
// 모달 본문 교체(HTML 허용: <br/> 같은 줄바꿈)
if ($banActionText && htmlMsg) $banActionText.innerHTML = htmlMsg;
// 부트스트랩 모달이 있으면 모달로, 없으면 alert로 폴백
if (window.jQuery) window.jQuery('#banReportModal').modal('show');
else alert(fallbackMsg || '제재회원은 해당 기능을 이용할 수 없습니다. 현재는 조회만 가능합니다.');
}
변경 포인트
- 제재 안내 UX를 한 군데로 모아,
문구만 바꾸면 되게 만들었다 - 모달이 없는 환경에서도 alert로
최소 기능이 유지되게 폴백을 넣었다
6. 좋아요는 비동기, 대신 제재회원은 요청 자체를 막는다
좋아요 토글은 대표적인 비동기 UI다.
중요한 건 '제재회원'이면 아예 요청을
보내지 않는 것까지 포함해야 한다는 점이다.
$btnLike.addEventListener('click', function () {
// 로그인 체크
if (!isLogin) {
alert('로그인 후 이용 가능합니다.');
return;
}
// 제재회원 차단: 요청 자체를 보내지 않는다
if (typeof isBanned !== 'undefined' && isBanned) {
openBanActionModal(
'제재회원은 좋아요를 누를 수 없습니다.<br/>현재는 조회만 가능합니다.',
'제재회원은 좋아요를 누를 수 없습니다. 현재는 조회만 가능합니다.'
);
return;
}
// 여기부터 서버 요청
var fd = new FormData();
fd.append('boardId', boardId);
httpPostForm(API.likeToggle, fd)
.then(function (res) { return res.json(); })
.then(function (json) {
// 서버 응답 기반으로 UI 동기화
if (!json || json.result !== 'OK') {
alert((json && json.msg) ? json.msg : '처리에 실패했습니다.');
return;
}
setLikeUI(Number(json.isLiked) === 1, json.likeCnt);
})
.catch(function () {
alert('서버 통신 오류가 발생했습니다.');
});
});
변경 포인트
- 비동기 액션일수록 ‘프론트 1차 차단’이 UX 품질을 좌우한다
- 다만 보안 관점에서는 서버에서도 동일 정책을 적용해야 한다
(프론트 차단은 UX, 서버 차단은 규칙)
7. 신고는 노출 조건을 JSP에서 끝내고, 제출은 JS에서 처리한다
신고 버튼은 조건이 많아서 JS에 두면
화면 깜빡임, 조건 누락이 생기기 쉽다.
그래서 버튼 노출 자체는 JSP에서 확정하고,
클릭/제출 흐름만 JS가 책임지게 했다.
function initReport() {
if (!$btnReport || !$btnReportSubmit) return;
// 제재회원 신고 차단도 공용 모달로 통일
function openBanReportModal() {
openBanActionModal(
'제재회원은 게시글 신고를 이용할 수 없습니다.<br/>현재는 조회만 가능합니다.',
'제재회원은 게시글 신고를 이용할 수 없습니다. 현재는 조회만 가능합니다.'
);
}
$btnReport.addEventListener('click', function () {
if (!isLogin) {
alert('로그인 후 이용 가능합니다.');
return;
}
// 제재회원이면 신고 모달 대신 안내 모달
if (typeof isBanned !== 'undefined' && isBanned) {
openBanReportModal();
return;
}
if (window.jQuery) window.jQuery('#reportModal').modal('show');
});
$btnReportSubmit.addEventListener('click', function () {
if (!isLogin) {
alert('로그인 후 이용 가능합니다.');
return;
}
// 제재회원이면 제출 자체 차단
if (typeof isBanned !== 'undefined' && isBanned) {
if (window.jQuery) window.jQuery('#reportModal').modal('hide');
openBanReportModal();
return;
}
// 여기부터 서버 접수
var fd = new FormData();
fd.append('boardId', boardId);
fd.append('reasonCode', String($reportReason.value || 'ETC'));
fd.append('reasonDetail', String($reportContent.value || '').trim());
httpPostForm(API.boardReport, fd)
.then(function () {
// 성공 UX: 모달 닫고, 버튼 숨김 처리
alert('신고가 접수되었습니다.');
if ($reportContent) $reportContent.value = '';
if (window.jQuery) window.jQuery('#reportModal').modal('hide');
if ($btnReport) $btnReport.style.display = 'none';
})
.catch(function () {
alert('신고 접수에 실패했습니다.');
});
});
}
변경 포인트
- 신고 버튼은 ‘보여줄지 말지’가 핵심이라 JSP에서 확정
- 제출 성공 시 버튼을 즉시 숨겨서 중복 시도 UX를 끊었다
8. 댓글은 ‘목록은 비동기, 변경은 동기’로 정책을 정했다
댓글은 기능이 늘어날수록 처리 방식이 흔들린다.
이번에는 기준을 이렇게 잡았다.
- 목록/정렬
: 비동기(화면에서 즉시 재렌더가 필요) - 작성/수정/삭제
: 동기(컨트롤러 redirect/message 흐름 유지)
그래서 수정/삭제는 인라인 편집 UI는 유지하되,
최종 반영은 hidden form submit으로 동기 전환했다.
// 댓글 수정: 인라인 편집 UI -> 최종 반영은 동기 submit
function submitReplyEdit(replyId, content) {
// hidden form이 있어야 동기 정책이 유지된다
$editBoardId.value = String(boardId);
$editReplyId.value = String(replyId);
$editReplyContent.value = String(content);
// submit 후 컨트롤러 redirect/message 흐름으로 화면이 정리된다
$replyEditForm.submit();
}
// 댓글 삭제도 동일하게 동기 submit
function submitReplyDelete(replyId) {
$delBoardId.value = String(boardId);
$delReplyId.value = String(replyId);
$replyDeleteForm.submit();
}
변경 포인트
- 인라인 편집은 UX를 위해 남기고,
서버 결과 흐름은 기존 정책을 유지했다 - 비동기/동기를 섞되, 기준을 코드에
명확히 박아두면 유지보수 비용이 줄어든다
9. 댓글 작성자 프로필 렌더링을 JS에서 한 번에 처리한다
댓글을 비동기로 렌더링하기로 했으면,
‘작성자 정보(이미지/색/꾸밈)’도
렌더링 레이어에 포함시키는 게 자연스럽다.
핵심은 데이터가 비어 있어도
깨지지 않게 폴백을 준비하는 것이다.
function renderReplyItem(r) {
// 닉네임 폴백: 닉네임이 없으면 아이디로
var nickname = (r.writerNickname && String(r.writerNickname).trim() !== '')
? r.writerNickname
: r.memberId;
// 프로필 이미지 폴백: 없으면 이니셜 아바타로
var profileImg = normalizeUrl(r.writerProfileImage || '');
var profileColor = sanitizeColor(r.writerProfileColor || '');
var decoClass = String(r.writerDecoClass || '').trim();
// 아바타 HTML 구성(이미지 있으면 img, 없으면 이니셜)
var avatarHtml = '';
if (profileImg) {
avatarHtml =
"<img class='reply-avatar' src='" + escapeHtml(profileImg) + "' alt='profile'/>";
} else {
var initial = String(nickname).charAt(0);
var bg = profileColor ? ('background:' + escapeHtml(profileColor) + ';')
: 'background:rgba(255,255,255,0.10);';
avatarHtml =
"<div class='reply-avatar reply-avatar--fallback' style='" + bg + "'>" +
escapeHtml(initial) +
"</div>";
}
// 시간 표기는 정책 고정: 수정됐으면 수정일만, 아니면 작성일만
var edited = isTruthy(r.isEdited);
var timeHtml = edited && r.replyUpdatedAt
? "<span class='t-time'>수정일 " + escapeHtml(r.replyUpdatedAt) + "</span>"
: "<span class='t-time'>작성일 " + escapeHtml(r.replyCreatedAt) + "</span>";
// 최종 마크업(프로필 + 닉네임 + 꾸밈 클래스 + 시간)
return (
"<div class='reply-item' data-reply-id='" + escapeHtml(r.replyId) + "'>" +
"<div class='reply-top'>" +
"<div class='reply-left'>" +
"<div class='reply-avatar-slot'>" + avatarHtml + "</div>" +
"<div class='reply-meta-col'>" +
"<div class='reply-writer " + escapeHtml(decoClass) + "'>" +
escapeHtml(nickname) +
"</div>" +
"<div class='reply-times'>" + timeHtml + "</div>" +
"</div>" +
"</div>" +
"</div>" +
"<div class='reply-content'>" + nl2br(r.replyContent) + "</div>" +
"</div>"
);
}
변경 포인트
- 댓글 데이터가 완벽하지 않아도 UI가 버티도록 폴백을 둔다
- 렌더 함수는 ‘데이터 → UI 규칙’을
한 곳에 모으는 역할이라, 유지보수 관점에서 가장 중요하다
10. nice-select 정렬 이슈는 ‘이벤트 경로를 여러 개’로 잡아서 해결
커스텀 셀렉트는 DOM change 이벤트를
안 타는 경우가 있다.
그래서 정렬 변경 트리거를
단일 이벤트에 의존하지 않고, 세 경로로 커버했다.
// 정렬 변경 트리거 3종 세트
// 1) 기본 DOM change
// 2) jQuery change (nice-select가 trigger만 쏘는 경우 대비)
// 3) nice-select option click (change 누락되는 버전 대비)
function bindReplySortEvents() {
if (!$replySort) return;
function requestReloadBySort() {
var cond = getSelectedCondition();
loadReplies(cond).catch(function () {});
}
$replySort.addEventListener('change', requestReloadBySort);
if (window.jQuery) {
window.jQuery($replySort).on('change.replySort', requestReloadBySort);
window.jQuery(document).on('click.replySort', '.reply-card .nice-select .option', function () {
setTimeout(requestReloadBySort, 0);
});
}
}
변경 포인트
- 플러그인 동작 방식이 버전/환경마다 달라질 수 있으니,
이벤트 경로를 넓게 잡는 게 안전하다 - 대신 중복 호출 방지를 같이 넣어주면
성능/로그가 깔끔해진다
11. 게시글 수정/삭제는 data-ban-lock으로 2중 차단
제재회원이 링크를 직접 누르거나
폼을 강제로 submit할 수 있는 경로를 남기면
유지보수 때 다시 구멍이 생긴다.
그래서 JSP에서 수정/삭제 요소에
data-ban-lock을 붙이고,
JS에서 캡처링 단계로 막는 구조를 넣었다.
function lockPostManageIfBanned() {
if (!(typeof isBanned !== 'undefined' && isBanned)) return;
// 클릭 차단: 캡처링 단계에서 먼저 잡는다(버블링 이전)
document.addEventListener('click', function (e) {
var lockEl = e.target && e.target.closest ? e.target.closest('[data-ban-lock="1"]') : null;
if (!lockEl) return;
e.preventDefault();
e.stopPropagation();
alert('제재회원은 게시글 수정/삭제가 제한됩니다.');
return false;
}, true);
// submit 차단: 폼 내부에 data-ban-lock이 있으면 submit 자체를 끊는다
document.addEventListener('submit', function (e) {
var form = e.target;
if (!form || !form.querySelector) return;
if (form.querySelector('[data-ban-lock="1"]')) {
e.preventDefault();
e.stopPropagation();
alert('제재회원은 게시글 수정/삭제가 제한됩니다.');
return false;
}
}, true);
}
변경 포인트
- 화면 렌더에서 1차로 숨기고,
이벤트 레벨에서 2차로 막는다 - 기능이 늘어날수록 이렇게 설계된
‘공통 차단 장치’가 전체 품질을 지켜준다
마무리
이번 작업의 핵심은 ‘기능 추가’ 자체보다,
기능이 늘면서 생긴 상호작용을
유지보수 가능한 구조로 정리한 데 있다.
- 상태/권한은 JSP에서 확정하고
JS는 그 결과로만 동작 - 비동기/동기 혼용 기준을 명확히 정해서
코드가 흔들리지 않게 고정 - 제재 정책은 공용 모달
+ 공통 차단 로직으로 통일 - 댓글 렌더 함수에 UI 규칙을 집중시켜
확장 포인트를 명확히 확보
'개주 훈련일지 > 🏋️ 전집중 호흡 훈련' 카테고리의 다른 글
| 비밀번호 보안 개선기: bcrypt 해시 저장/검증 구조 (Spring Boot + JDBC) (0) | 2026.02.22 |
|---|---|
| AI 추천 위젯 UX 개선기: 페이지 이동 후에도 '대화 복원'되도록 만들기 (0) | 2026.02.21 |
| 프론트 경로 통일 리팩토링: ctx 기준 링크와 리소스 안정화 (0) | 2026.02.19 |
| 네이버 스마트스토어 Oracle → MySQL 무중단 마이그레이션 사례 리뷰 (0) | 2026.02.18 |
| 게시글 상세보기 리뉴얼: 신고 버튼 + 댓글 프로필 + 수정일 표시 (0) | 2026.02.17 |