66일차 내용 정리

채공부·2025년 8월 26일

게시판

상세보기

  • SecurityConfig 에 업로드 경로 화이트 리스트 추가
String[] whiteList = {"/", "/user/loginform", "/user/login-fail",
			"/user/expired", "/user/signup-form", "/user/signup",
			"/test/**", "/user/check-id", "/board/list", "/board/view",
			"/upload/**"};
  • view.html 생성
<body>
	<div class="container pt-3">
  
	</div>    
</body>
  • include 코드에 ~{ } 붙이기 (빨간색 오류 표시 없앨 수 있다)
<th:block th:insert="~{/include/resource :: bootstrap}"></th:block>
  • disabled 클래스를 추가할지 말지 결정

  • th:classappend

<h1>게시글 상세보기</h1>

<div class="btn-group mb-2">
	<a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
	   th:classappend="${dto.prevNum eq 0 ? 'disabled' : null}"
	   th:href="@{/board/view(num=${dto.prevNum})}">
		<i class="bi bi-arrow-left"></i>
		Prev
	</a>
	<a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
	   th:classappend="${dto.nextNum eq 0 ? 'disabled' : null}"
	   th:href="@{/board/view(num=${dto.nextNum})}">
		Next
		<i class="bi bi-arrow-right"></i>
	</a>
</div>
  • 언 이스케이프
  1. th:utext="${}"
  2. [(${})]
<div class="card mt-4">
	<div class="card-header bg-light">
		<strong>본문 내용</strong>
	</div>
	<div class="card-body p-1">
		[(${dto.content})]
	</div>
</div>
  • dto : BoardDto Type
    userName : String Type
<div th:if="${dto.writer eq userName}" class="text-end pt-2">
	<a class="btn btn-warning btn-sm" th:href="@{/board/edit(num=${dto.num})}>">Edit</a>
	<a class="btn btn-danger btn-sm" th:href="@{/board/delete(num=${dto.num})}>">Delete</a>
</div>
  • 댓글 입력
<div class="card my-3">
	<div class="card-header bg-primary-subtle text-white">
		댓글을 입력해 주세요
	</div>
	<div class="card-body">
		
		<!-- 원글의 댓글을 작성할 폼 -->
		<form th:action="@{/board/save-comment}" method="post">
			
			<!-- 숨겨진 입력값 -->
			<input type="hidden" name="parentNum" th:value="${dto.num}" />
			<input type="hidden" name="targetWriter" th:value="${dto.writer}" />

			<div class="mb-3">
				<label for="commentContent" class="form-label">댓글 내용</label>
				<textarea id="commentContent" name="content" rows="5" class="form-control" placeholder="댓글을 입력하세요"></textarea>
			</div>

			<button type="submit" class="btn bg-primary-subtle text-white">등록</button>
		</form>
	</div>
</div>

view.html 에 게시글 상세보기

<h1>게시글 상세보기</h1>
<div class="btn-group mb-2">
	<a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
	   th:classappend="${dto.prevNum eq 0 ? 'disabled' : null}"
	   th:href="@{/board/view(num=${dto.prevNum})}">
		<i class="bi bi-arrow-left"></i>
		Prev
	</a>
	<a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
	   th:classappend="${dto.nextNum eq 0 ? 'disabled' : null}"
	   th:href="@{/board/view(num=${dto.nextNum})}">
		Next
		<i class="bi bi-arrow-right"></i>
	</a>
</div>
<table class="table table-striped">
	<colgroup>
		<col class="col-2" />
		<col class="col" />
	</colgroup>
	<tr>
		<th>글번호</th>
		<td th:text="${dto.num}"></td>
	</tr>
	<tr>
		<th>작성자</th>
		<td>
			<i th:if="${dto.profileImage == null}" style="font-size:100px;" class="bi bi-person-circle"></i>  
			<img th:unless="${dto.profileImage == null}"
				 th:src="@{/upload/{name}(name=${dto.profileImage})}"
				 style="width:100px;height:100px;border-radius:50%;" />
			[[${dto.writer}]]
		</td>
	</tr>
	<tr>
		<th>제목</th>
		<td th:text="${dto.title}"></td>
	</tr>
	<tr>
		<th>조회수</th>
		<td th:text="${dto.viewCount}"></td>
	</tr>
	<tr>
		<th>작성일</th>
		<td th:text="${dto.createdAt}"></td>
	</tr>
</table>
<!-- 
	클라이언트가 작성한 글 제목이나 내용을 그대로 클라이언트에게 출력하는 것은
	javascript 주입 공격을 받을 수 있다
	따라서 해당 문자열을 escape 해서 출력하는 것이 안전하다
 -->
<div class="card mt-4">
	<div class="card-header bg-light">
		<strong>본문 내용</strong>
	</div>
	<div class="card-body p-1">
		[(${dto.content})]
	</div>
</div>
<div th:if="${dto.writer eq userName}" class="text-end pt-2">
	<a class="btn btn-warning btn-sm" th:href="@{/board/edit(num=${dto.num})}">Edit</a>
	<a class="btn btn-danger btn-sm" th:href="@{/board/delete(num=${dto.num})}">Delete</a>
</div>
<div class="card my-3">
	<div class="card-header bg-primary-subtle text-white">
		댓글을 입력해 주세요
	</div>
	<div class="card-body">
		<!-- 원글의 댓글을 작성할 폼 -->
		<form th:action="@{/board/save-comment}" method="post">
			<!-- 숨겨진 입력값 -->
			<input type="hidden" name="parentNum" th:value="${dto.num}" />
			<input type="hidden" name="targetWriter" th:value="${dto.writer}" />

			<div class="mb-3">
				<label for="commentContent" class="form-label">댓글 내용</label>
				<textarea id="commentContent" name="content" rows="5" class="form-control" placeholder="댓글을 입력하세요"></textarea>
			</div>

			<button type="submit" class="btn bg-primary-subtle text-white">등록</button>
		</form>
	</div>
</div>
  • 반복문 돌면서 card div 를 여러개 만들어내야 되는 상황
<div th:each="tmp : ${commentList}" class="card mb-3"
	th:classappend="${tmp.num == tmp.groupNum ? null : 'ms-5 re-re'}">
  • 속성 추가

  • th:attr="속성명=${속성값}"

<button th:if="${tmp.writer eq userName}"
		th:attr="data-num=${tmp.num}" 
		class="btn-close position-absolute top-0 end-0 m-1"></button>
  • 타임리프 블럭을 이용해서 코드 묶기
<th:block th:if="${tmp.writer eq userName}">
  	<!-- 수정 버튼 (본인에게만 보임)-->
	<button class="btn btn-sm bg-primary-subtle btn-outline-secondary edit-btn">수정</button>  			                    
	<!-- 댓글 수정 폼 (처음에는 숨김) -->
	<div class="d-none form-div">
		<form th:action="@{/board/comment-update}" method="post">
			<!-- 댓글을 수정하기 위한 댓글의 번호, 이 페이지로 다시 돌아오기 위한 parentNum 도 같이 전송되도록 -->
			<input type="hidden" name="num" th:value="${dto.num}" />
			<input type="hidden" name="parentNum" th:value=${tmp.num} />
			<textarea name="content" class="form-control mb-2" rows="2" th:text="${tmp.content)"></textarea>
			<button type="submit" class="btn btn-sm btn-success">수정 완료</button>
			<button type="reset" class="btn btn-sm btn-secondary cancel-edit-btn">취소</button>
		</form>
	</div>
</th:block>

<th:block th:unless="${tmp.writer eq userName}">
	<button class="btn btn-sm bg-primary-subtle btn-outline-secondary show-reply-btn">댓글</button>  
	<!-- 댓글 입력 폼 (처음에는 숨김) -->
	<div class="d-none form-div">
		<form th:action="@{/board/save-comment}" method="post">
			<!-- 원글의 글번호, 댓글 대상자의 userName, 댓글의 그룹번호도 같이 전송해야 한다 -->
			<input type="hidden" name="parentNum" th:value="${dto.num}" />
			<input type="hidden" name="targetWriter" th:value="${tmp.writer}" />
			<input type="hidden" name="groupNum" th:value="${tmp.groupNum}" />
			<textarea name="content" class="form-control mb-2" rows="2" placeholder="댓글을 입력하세요"></textarea>
			<button type="submit" class="btn btn-sm btn-outline-primary">등록</button>
			<button type="reset" class="btn btn-sm btn-outline-secondary cancel-reply-btn">취소</button>
		</form>
	</div>
</th:block>

view.html 에 댓글 목록 출력

<div class="comments">
	<!-- 대댓글은 자신의 글번호와 댓글의 그룹번호가 다르다, 그런 경우 왼쪽 마진을 부여 -->
	<div th:each="tmp : ${commentList}" class="card mb-3"
		 th:classappend="${tmp.num == tmp.groupNum ? null : 'ms-5 re-re'}">

		<!-- 삭제된 댓글 -->
		<div th:if="${tmp.deleted eq 'yes'}" class="card-body bg-light text-muted rounded">
			삭제된 댓글입니다
		</div>

		<!-- 삭제되지 않은 댓글 -->
		<div th:unless="${tmp.deleted eq 'yes'}" class="card-body d-flex flex-column flex-sm-row position-relative">
			<button th:if="${tmp.replyCount ne 0 and tmp.num eq tmp.groupNum}"
					class="dropdown-btn btn btn-outline-secondary btn-sm position-absolute"
					style="bottom:16px; right:16px;">
				<i class="bi bi-caret-down"></i>
				답글 [[${tmp.replyCount}]] 개
			</button>

			<i th:if="${tmp.num ne tmp.groupNum}" class="bi bi-arrow-return-right position-absolute"
			   style="top:0;left:-30px"></i>

			<!-- 댓글 작성자가 로그인된 userName과 같을 때만 삭제 버튼 출력 -->
			<button th:if="${tmp.writer eq userName}"
					th:attr="data-num=${tmp.num}"
					class="btn-close position-absolute top-0 end-0 m-1"></button>

			<i th:if="${tmp.profileImage == null}"
			   style="font-size:50px;" class="bi bi-person-circle me-3 align-self-center"></i>
			<img th:unless="${tmp.profileImage eq null}"
				 class="rounded-circle me-3 align-self-center"
				 th:src="@{/upload/{name}(name=${tmp.profileImage})}"
				 alt="프로필 이미지"
				 style="width:50px; height:50px;">

			<div class="flex-grow-1">
				<div class="d-flex justify-content-between">
					<div>
						<strong th:text="${tmp.writer}"></strong>
						<span>@[[${tmp.targetWriter}]]</span>
						<small class="text-muted" th:text="${tmp.createdAt}"></small>
					</div>
				</div>
				<pre th:text="${tmp.content}"></pre>

				<!-- 댓글 작성자가 로그인된 userName과 같으면 수정폼, 다르면 댓글폼 출력 -->
				<th:block th:if="${tmp.writer eq userName}">
					<!-- 수정 버튼 (본인에게만 보임) -->
					<button class="btn btn-sm bg-primary-subtle btn-outline-secondary edit-btn">수정</button>
					<!-- 댓글 수정 폼 (처음에는 숨김) -->
					<div class="d-none form-div">
						<form th:action="@{/board/comment-update}" method="post">
							<!-- 댓글 번호, 부모 글 번호 전송 -->
							<input type="hidden" name="num" th:value="${tmp.num}" />
							<input type="hidden" name="parentNum" th:value="${dto.num}" />
							<textarea name="content" class="form-control mb-2" rows="2" th:text="${tmp.content}"></textarea>
							<button type="submit" class="btn btn-sm btn-success">수정 완료</button>
							<button type="reset" class="btn btn-sm btn-secondary cancel-edit-btn">취소</button>
						</form>
					</div>
				</th:block>

				<th:block th:unless="${tmp.writer eq userName}">
					<button class="btn btn-sm bg-primary-subtle btn-outline-secondary show-reply-btn">댓글</button>
					<!-- 댓글 입력 폼 (처음에는 숨김) -->
					<div class="d-none form-div">
						<form th:action="@{/board/save-comment}" method="post">
							<!-- 원글 번호, 댓글 대상자, 그룹 번호 전송 -->
							<input type="hidden" name="parentNum" th:value="${dto.num}" />
							<input type="hidden" name="targetWriter" th:value="${tmp.writer}" />
							<input type="hidden" name="groupNum" th:value="${tmp.groupNum}" />
							<textarea name="content" class="form-control mb-2" rows="2" placeholder="댓글을 입력하세요"></textarea>
							<button type="submit" class="btn btn-sm btn-outline-primary">등록</button>
							<button type="reset" class="btn btn-sm btn-outline-secondary cancel-reply-btn">취소</button>
						</form>
					</div>
				</th:block>
			</div>
		</div> <!-- card body -->
	</div> <!-- card -->
</div>
  • 원글의 글 번호가 1인 경우

  • "/spring08/user/loginform?url=/spring08/board/view?num=1";

location.href= "[[@{/user/loginform}]]?url=[[@{/board/view(num=${dto.num})}]]";
  • /spring08/board/comment-delete?num=${num}&parentNum=1
location.href = `[[@{/board/comment-delete}]]?num=${num}&parentNum=[[${dto.num}]]`;
  • view.html 에 js 코드
// 클라이언트가 로그인 했는지 여부
const isLogin = [[${isLogin}]];

document.querySelector("#commentContent").addEventListener("input", () => {
    // 원글의 댓글 입력란에 입력했을 때, 만일 로그인 하지 않았다면
    if (!isLogin) {
        alert("댓글 작성을 위해 로그인이 필요합니다!");
        location.href =
            "[[@{/user/loginform}]]?url=[[@{/board/view(num=${dto.num})}]]";
    }
});

// 모든 댓글 버튼에 이벤트 등록
document.querySelectorAll(".show-reply-btn").forEach(item => {
    // 매개변수에 전달된 item은 댓글 button의 참조값이다
    item.addEventListener("click", () => {
        // 댓글 버튼을 눌렀을 때, 만일 로그인 하지 않았다면
        if (!isLogin) {
            alert("댓글 작성을 위해 로그인이 필요합니다!");
            location.href =
                "[[@{/user/loginform}]]?url=[[@{/board/view(num=${dto.num})}]]";
            return;
        }

        // 클릭한 버튼의 다음 형제요소의 class 목록에서 d-none 제거
        item.nextElementSibling.classList.remove("d-none");
        // 클릭한 버튼의 class 목록에 d-none 추가
        item.classList.add("d-none");
    });
});

document.querySelectorAll(".cancel-reply-btn").forEach(item => {
    item.addEventListener("click", () => {
        // 가장 가까운 부모 요소 중에 클래스 속성이 form-div인 요소
        const formDiv = item.closest(".form-div");
        // formDiv에 d-none 클래스 추가해서 안 보이게 하고
        formDiv.classList.add("d-none");
        // formDiv의 이전 형제 요소(댓글버튼)에 d-none 클래스 제거해서 보이게 한다
        formDiv.previousElementSibling.classList.remove("d-none");
    });
});

// 클래스명이 edit-btn인 모든 버튼에 "click" 이벤트 리스너 등록
document.querySelectorAll(".edit-btn").forEach(item => {
    item.addEventListener("click", () => {
        // 클릭한 버튼의 다음 형제 요소의 class 목록에서 d-none 제거
        item.nextElementSibling.classList.remove("d-none");
        // 클릭한 버튼의 class 목록에 d-none 추가
        item.classList.add("d-none");
    });
});

// 취소 버튼을 눌렀을 때 이벤트 리스너 등록
document.querySelectorAll(".cancel-edit-btn").forEach(item => {
    item.addEventListener("click", () => {
        // 가장 가까운 부모 요소 중에 클래스 속성이 form-div인 요소
        const formDiv = item.closest(".form-div");
        // formDiv에 d-none 클래스 추가해서 안 보이게 하고
        formDiv.classList.add("d-none");
        // formDiv의 이전 형제 요소(댓글 버튼)에 d-none 클래스 제거해서 보이게 한다
        formDiv.previousElementSibling.classList.remove("d-none");
    });
});

// 삭제 버튼을 눌렀을 때
document.querySelectorAll(".btn-close").forEach(item => {
    item.addEventListener("click", () => {
        // data-num 속성에 출력된 삭제할 댓글의 번호
        const num = item.getAttribute("data-num");
        const isDelete = confirm(num + "번 댓글을 삭제하시겠습니까?");
        // confirm은 확인:true, 취소:false
        if (isDelete) {
            location.href = `[[@{/board/comment-delete}]]?num=${num}&parentNum=[[${dto.num}]]`;
        }
    });
});

document.querySelectorAll(".dropdown-btn").forEach(item => {
    item.addEventListener("click", (e) => {
        // click 이벤트가 발생한 그 버튼의 자손 요소 중에서 caret up 또는 caret down 요소를 찾는다
        const caret = item.querySelector(".bi-caret-up, .bi-caret-down");
        // caret 모양을 위 아래로 토글 시킨다
        caret.classList.toggle("bi-caret-down");
        caret.classList.toggle("bi-caret-up");

        // 1. 버튼(item)의 두 단계 부모 요소로 이동
        const grandParent = item.parentElement.parentElement;
        // 2. 두 단계 부모 요소의 바로 다음 형제 요소의 참조값을 얻어낸다
        let next = grandParent.nextElementSibling;
        // 3. 반복문 돌면서 (다음 형제 요소가 있는 동안에 반복문 돌기)
        while (next) {
            // 만일 re-re 클래스가 존재한다면
            if (next.classList.contains("re-re")) {
                // d-block 클래스를 토글시켜서 보였다 숨겼다를 반복시킨다
                next.classList.toggle("d-block");
            } else {
                // 존재하지 않으면 반복문 탈출
                break;
            }
            // 다음 형제 요소의 참조값 얻어내기
            next = next.nextElementSibling;
        }
    });
});

view.html 전체 코드

<style>
	/* 대댓글이 처음에는 보이지 않도록 하기 위해*/
	.re-re{
		display:none;
	}
</style>
<body>
	<div class="container pt-3">
        <nav aria-label="breadcrumb">
            <ol class="breadcrumb">
                <li class="breadcrumb-item">
                    <a th:href="@{/}">Home</a>
                </li>
                <li class="breadcrumb-item">
                    <a th:href="@{/board/list}">Board</a>
                </li>
                <li class="breadcrumb-item active">Detail</li>
            </ol>
        </nav>
        <h1>게시글 상세보기</h1>
        <div class="btn-group mb-2">
            <a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
            		th:classappend="${dto.prevNum eq 0 ? 'disabled' : null}"
            		th:href="@{/board/view(num=${dto.prevNum})}">    
                <i class="bi bi-arrow-left"></i>
                Prev
            </a>
            <a class="btn btn-outline-secondary bg-primary-subtle btn-sm"
            		th:classappend="${dto.nextNum eq 0 ? 'disabled' : null}"
            		th:href="@{/board/view(num=${dto.nextNum})}">
                Next
                <i class="bi bi-arrow-right"></i>
            </a>
        </div>
        <table class="table table-striped">
            <colgroup>
                <col class="col-2" />
                <col class="col" />
            </colgroup>
            <tr>
                <th>글번호</th>
                <td th:text="${dto.num}"></td>
            </tr>
            <tr>
                <th>작성자</th>
                <td>
					<i th:if="${dto.profileImage == null}" style="font-size:100px;" class="bi bi-person-circle"></i>  
                        <img th:unless="${dto.profileImage == null}"
                        		th:src="@{/upload/{name}(name=${dto.profileImage})}"
                             style="width:100px;height:100px;border-radius:50%;"/>
                    [[${dto.writer}]]
                </td>
            </tr>
            <tr>
                <th>제목</th>
                <td th:text="${dto.title}"></td>
            </tr>
            <tr>
                <th>조회수</th>
                <td th:text="${dto.viewCount}"></td>
            </tr>
            <tr>
                <th>작성일</th>
                <td th:text="${dto.createdAt}"></td>
            </tr>
        </table>
        <!-- 
            클라이언트가 작성한 글 제목이나 내용을 그대로 클라이언트에게 출력하는 것은
            javascript 주입 공격을 받을 수 있다
            따라서 해당 문자열을 escape 해서 출력하는 것이 안전하다
         -->
        <div class="card mt-4">
            <div class="card-header bg-light">
                <strong>본문 내용</strong>
            </div>
            <div class="card-body p-1">
		    		[(${dto.content})]
            </div>
        </div>
        <div th:if="${dto.writer eq userName}" class="text-end pt-2">
			<a class="btn btn-warning btn-sm" th:href="@{/board/edit(num=${dto.num})}">Edit</a>
			<a class="btn btn-danger btn-sm" th:href="@{/board/delete(num=${dto.num})}">Delete</a>
		</div>
        <div class="card my-3">
            <div class="card-header bg-primary-subtle text-white">
                댓글을 입력해 주세요
            </div>
            <div class="card-body">
                
                <!-- 원글의 댓글을 작성할 폼 -->
                <form th:action="@{/board/save-comment}" method="post">
                    
                    <!-- 숨겨진 입력값 -->
                    <input type="hidden" name="parentNum" th:value="${dto.num}"/>
                    <input type="hidden" name="targetWriter" th:value="${dto.writer}"/>

                    <div class="mb-3">
                        <label for="commentContent" class="form-label">댓글 내용</label>
                        <textarea id="commentContent" name="content" rows="5" class="form-control" placeholder="댓글을 입력하세요"></textarea>
                    </div>

                    <button type="submit" class="btn bg-primary-subtle text-white">등록</button>
                </form>
            </div>
        </div>
        <!-- 댓글 목록 출력 -->
        <div class="comments">
        	
        		<!-- 대댓글은 자신의 글번호와 댓글의 그룹번호가 다르다, 그런 경우 왼쪽 마진을 부여 -->
			<div th:each="tmp : ${commentList}" class="card mb-3"
				th:classappend="${tmp.num == tmp.groupNum ? null : 'ms-5 re-re'}">
				
				<!--  삭제된 댓글 -->
				<div th:if="${tmp.deleted eq 'yes'}" class="card-body bg-light text-muted rounded">삭제된 댓글입니다</div>
				
				<!-- 삭제되지 않은 댓글 -->
				<div th:unless="${tmp.deleted eq 'yes'}" class="card-body d-flex flex-column flex-sm-row position-relative">
					<button th:if="${tmp.replyCount ne 0 and tmp.num eq tmp.groupNum}"
						class="dropdown-btn btn btn-outline-secondary btn-sm position-absolute"
						style="bottom:16px; right:16px;">
						<i class="bi bi-caret-down"></i>
						답글 [[${tmp.replyCount}]] 개
					</button>					
					
					<i th:if="${tmp.num ne tmp.groupNum}"
						class="bi bi-arrow-return-right position-absolute" style="top:0;left:-30px"></i>
					
					<!-- 앳글 작성자가 로그인된 userName 과 같을 때만 삭제 버튼 출력 -->
					<button th:if="${tmp.writer eq userName}"
						th:attr="data-num=${tmp.num}" 
						class="btn-close position-absolute top-0 end-0 m-1"></button>
						
					<i th:if="${tmp.profileImage == null}"
						style="font-size:50px;" class="bi bi-person-circle me-3 align-self-center"></i>			    
				    <img th:unless="${tmp.profileImage eq null}"
				    		class="rounded-circle me-3 align-self-center"
				    		th:src="@{/upload/{name}(name=${tmp.profileImage})}"
						alt="프로필 이미지"
						style="width:50px; height:50px;">
						
					<div class="flex-grow-1">
						<div class="d-flex justify-content-between">
							<div>
								<strong th:text="${tmp.writer}"></strong>
								<span>@[[${tmp.targetWriter}]]</span>
								<small class="text-muted" th:text="${tmp.createdAt}"></small>
							</div>
						</div>
						<pre th:text="${tmp.content}"></pre>
						<!-- 댓글 작성작가 로그인된 userName 과 같으면 수정폼, 다르면 댓글폼을 출력한다 -->
						<th:block th:if="${tmp.writer eq userName}">
							<!-- 수정 버튼 (본인에게만 보임)-->
							<button class="btn btn-sm bg-primary-subtle btn-outline-secondary edit-btn">수정</button>  			                    
							<!-- 댓글 수정 폼 (처음에는 숨김) -->
							<div class="d-none form-div">
								<form th:action="@{/board/comment-update}" method="post">
									<!-- 댓글을 수정하기 위한 댓글의 번호, 이 페이지로 다시 돌아오기 위한 parentNum 도 같이 전송되도록 -->
									<input type="hidden" name="num" th:value="${tmp.num}"/>
									<input type="hidden" name="parentNum" th:value="${dto.num}"/>
									<textarea name="content" class="form-control mb-2" rows="2" th:text="${tmp.content}"></textarea>
									<button type="submit" class="btn btn-sm btn-success">수정 완료</button>
									<button type="reset" class="btn btn-sm btn-secondary cancel-edit-btn">취소</button>
								</form>
							</div>
						</th:block>
						
						<th:block th:unless="${tmp.writer eq userName}">
							<button class="btn btn-sm bg-primary-subtle btn-outline-secondary show-reply-btn">댓글</button>  
							<!-- 댓글 입력 폼 (처음에는 숨김) -->
							<div class="d-none form-div">
								<form th:action="@{/board/save-comment}" method="post">
									<!-- 원글의 글번호, 댓글 대상자의 userName, 댓글의 그룹번호도 같이 전송해야 한다 -->
									<input type="hidden" name="parentNum" th:value="${dto.num}"/>
									<input type="hidden" name="targetWriter" th:value="${tmp.writer}"/>
									<input type="hidden" name="groupNum" th:value="${tmp.groupNum}"/>
									<textarea name="content" class="form-control mb-2" rows="2" 
										placeholder="댓글을 입력하세요"></textarea>
									<button type="submit" class="btn btn-sm btn-outline-primary">등록</button>
									<button type="reset" class="btn btn-sm btn-outline-secondary cancel-reply-btn">취소</button>
								</form>
							</div>
						</th:block>
					</div>
				</div> <!-- card body -->  
			</div> <!-- card -->
		</div>
    </div> <!-- container -->
    <script>
		// 클라이언트가 로그인 했는지 여부
    		const isLogin = [[${isLogin}]];
    	
	    	document.querySelector("#commentContent").addEventListener("input", ()=>{
	    		//원글의 댓글 입력란에 입력했을때 만일 로그인 하지 않았다면
	    		if(!isLogin){
	    			alert("댓글 작성을 위해 로그인이 필요합니다!");
	    			location.href=
	    				"[[@{/user/loginform}]]?url=[[@{/board/view(num=${dto.num})}]]";
	    		}
	    	});
    
        // 모든 댓글 버튼에 이벤트 등록
        document.querySelectorAll(".show-reply-btn").forEach(item=>{
            // 매개변수에 전달된 item 은 댓글 button 의 참조값이다 
            item.addEventListener("click", ()=>{
            	//댓글 버튼을 눌렀을때 만일 로그인 하지 않았다면
        		if(!isLogin){
        			alert("댓글 작성을 위해 로그인이 필요합니다!");
        			location.href=
        				"[[@{/user/loginform}]]?url=[[@{/board/view(num=${dto.num})}]]";
        			return;
        		}
            
                // 클릭한 버튼의 다음 형제요소의 class 목록에서 d-none 을 제거 
                item.nextElementSibling.classList.remove("d-none");
                // 클릭한 버튼의 class 목록에 d-none 을 추가
                item.classList.add("d-none");
            });
        });

        document.querySelectorAll(".cancel-reply-btn").forEach(item=>{
            item.addEventListener("click", ()=>{
                // 가장 가까운 부모 요소중에 클래스 속성이 form-div 인요소  
                const formDiv=item.closest(".form-div");
                // formDiv 에 d-none 클래스 추가해서 안보이게 하고
                formDiv.classList.add("d-none");
                // formDiv 의 이전 형제요소(댓글버튼)에 d-none 클래스 제거해서 보이게 한다  
                formDiv.previousElementSibling.classList.remove("d-none");
            });
        });
        
     	// 클래스명이 edit-btn 인 모든 버튼에 "click" 이벤트 리스너 등록
        document.querySelectorAll(".edit-btn").forEach(item=>{
            item.addEventListener("click", ()=>{
                // 클릭한 버튼의 다음 형제 요소의 class 목록에서 d-none 을 제거
                item.nextElementSibling.classList.remove("d-none");
                // 클릭한 버튼의 class 목록에 d-none 을 추가
                item.classList.add("d-none");
            });
        });
     	
        // 취소 버튼을 눌렀을 때 이벤트 리스너 등록
        document.querySelectorAll(".cancel-edit-btn").forEach(item=>{
            item.addEventListener("click", ()=>{
                // 가장 가까운 부모 요소 중에 클래스 속성이 form-div 인 요소
                const formDiv = item.closest(".form-div");
                // formDiv 에 d-none 클래스르 추가해서 안 보이게 하고
                formDiv.classList.add("d-none");
                // formDiv 의 이전 형제 요소(댓글 버튼)에 d-none 클래스 제거해서 보이게 한다
                formDiv.previousElementSibling.classList.remove("d-none");
            });
        });

        // 삭제 버튼을 눌렀을 때 
        document.querySelectorAll(".btn-close").forEach(item=>{
            item.addEventListener("click", ()=>{
                // data-num 속성에 출력된 삭제할 댓글의 번호
                const num = item.getAttribute("data-num");
                const isDelete = confirm(num+"번 댓글을 삭제하시겠습니까?");
                // confirm 은 확인:true 취소:false
                if(isDelete){
                		location.href = `[[@{/board/comment-delete}]]?num=${num}&parentNum=[[${dto.num}]]`;
                }
            });
        });
        
    	document.querySelectorAll(".dropdown-btn").forEach(item => {
    		item.addEventListener("click", (e) => {
     			// click 이벤트가 발생한 그 버튼의 자손요소 중에서 caret up 또는 caret down 요소를 찾는다
     			const caret = item.querySelector(".bi-caret-up, .bi-caret-down");
     			// caret 모양을 위 아래로 토글 시킨다
				caret.classList.toggle("bi-caret-down");
     			caret.classList.toggle("bi-caret-up");
     			
     			// 1. 버튼(item)의 두 단계 부모 요소로 이동
     			const grandParent = item.parentElement.parentElement;
				// 2. 두 단계 부모 요소의 바로 다음 형제 요소의 참조값을 얻어낸다
     			let next = grandParent.nextElementSibling;
				// 3. 반복문 돌면서 (다음 형제 요소가 있는 동안에 반복문 돌기)
  	   			while (next) {
  	   				// 만일 re-re 클래스가 존재한다면
					if (next.classList.contains("re-re")) {
						// d-block 클래스를 토글시켜서 보였다 숨겼다를 반복 시킨다
  	   		    		next.classList.toggle("d-block");
  	   				}else{ // 존재하지 않으면
  	   					// 반복문 탈출
  	   					break;
  	   				}
  	   				// 다음 형제 요소의 참조값 얻어내기
  	   				next = next.nextElementSibling;
  	   			}
     		});
		});
    </script>
</body>

댓글

댓글 삽입

  • CommentMapper 에 insert & getSequence 문 추가
<insert id="insert" parameterType="commentDto">
	INSERT INTO comments
	(num, writer, targetWriter, content, parentNum, groupNum)
	VALUES(#{num}, #{writer}, #{targetWriter}, #{content}, #{parentNum}, #{groupNum})	
</insert>

<select id="getSequence" resultType="int">
	SELECT comments_seq.NEXTVAL AS num FROM DUAL
</select>
  • CommentDao 에 insert 메소드 생성
public void insert(CommentDto dto);
  • CommentDaoImpl 에 추상 메소드 오버라이드
@Override
public void insert(CommentDto dto) {
	session.insert("comment.insert", dto);		
}
  • BoardService 에 createComment 메소드 생성
public void updateComment(CommentDto dto); //댓글 수정
  • BoardServiceImpl 에 추상 메소드 오버라이드
@Override
public void createComment(CommentDto dto) {
	// 댓글의 글번호가 넘어오지 않으면 dto.getGroupNum() 은 0 을 리턴한다
		
	// 저장할 댓글의 pk 를 미리 얻어낸다
	int num = commentDao.getSequence();
	dto.setNum(num);
		
	// 만일 원글의 댓글이면
	if(dto.getGroupNum() == 0) {
		dto.setGroupNum(num); // 원글의 댓글은 자신의 글번호가 댓글의 그룹번호이고
	}
			
	// 댓글 작성자를 얻어내서 dto 에 담는다
	String userName = SecurityContextHolder.getContext().getAuthentication().getName();
	dto.setWriter(userName);
		
	// 대댓글이면 이미 dto 에 댓글의 그룹번호가 들어 있다
	commentDao.insert(dto);		
}
  • BoardController 에 boardSaveComment 메소드 생성
@PostMapping("/board/save-comment")
public String boardSaveComment(CommentDto dto) {
	// 서비스를 이용해서 새로운 댓글을 저장
	service.createComment(dto);
	// 댓글을 작성한 원글 자세히 보기로 다시 리다일렉트 이동		
	return "redirect:/board/view?num="+dto.getParentNum();
}

댓글 삭제

  • CommentMapper 에 delete 문 추가
<update id="delete" parameterType="int">
	UPDATE comments
	SET deleted='yes'
	WHERE num=#{num}
</update>
  • CommentDao 에 delete 메소드 생성
public int delete(int num);
  • CommentDaoImpl 에 추상 메소드 오버라이드
@Override
public int delete(int num) {
	return session.update("comment.delete", num);
}
  • BoardService 에 deleteComment 메소드 생성
public void deleteComment(int num); // 댓글 삭제
  • BoardController 에 boardDeleteComment 메소드 생성
@GetMapping("/board/comment-delete")
public String boardDelete(CommentDto dto) {
	// dto 에는 삭제할 댓글의 글번호와 원글의 글번호가 들어 있다
	service.deleteComment(dto.getNum());
	return "redirect:/board/view?num="+dto.getParentNum();
}
  • CommentDao 에 getByNum 메소듯 생성
public CommentDto getByNum(int num);
  • CommentDaoImpl 에 추상 메소드 오버라이드
@Override
public CommentDto getByNum(int num) {
	return session.selectOne("comment.getByNum", num);
}
  • CommentMapper 에 getByNum 문 추가
<select id="getByNum" parameterType="int" resultType="commentDto">
	SELECT num, writer, targetWriter, content, parentNum, groupNum, createdAt
	FROM comments
	WHERE num=#{num}
</select>
  • BoardServiceImpl 에 추상 메소드 오버라이드
@Override
public void deleteComment(int num) {
	// 글 작성자와 로그인된 userName 이 동일한지 비교해서 동일하지 않으면 예외를 발생시킨다
	String writer = commentDao.getByNum(num).getWriter();
	String userName = SecurityContextHolder.getContext().getAuthentication().getName();
	if(!writer.equals(userName)) {
		throw new RuntimeException("남의 글을 지울 수 없습니다");
	}
	// 글 삭제하기
	commentDao.delete(num);
}

댓글 수정

  • CommentDao 에 update 메소드 생성
<update id="update" parameterType="commentDto">
	UPDATE comments
	SET content=#{content}
	WHERE num=#{num}
</update>
  • CommentDao 에 update 메소드 생성
public int update(CommentDto dto);
  • CommentDaoImpl 에 추상 메소드 오버라이드
@Override
public int update(CommentDto dto) {
	return session.update("comment.update", dto);
}
  • BoardService 에 updateComment 메소드 생성
public void updateComment(CommentDto dto); //댓글 수정
  • BoardController 에 boardUpdateComment 메소드 생성
@PostMapping("/board/comment-update")
public String boardUpdateComment(CommentDto dto) {
	service.updateComment(dto);
		
	return "redirect:/board/view?num="+dto.getParentNum();
}
  • BoardServiceImpl 에 추상 메소드 오버라이드
@Override
public void updateComment(CommentDto dto) {
	// 글 작성자와 로그인된 userName 이 동일한지 비ㅛ해서 동일하지 않으면 예외를 발생시킨다
	String writer = commentDao.getByNum(dto.getNum()).getWriter();
	String userName = SecurityContextHolder.getContext().getAuthentication().getName();
	if(!writer.equals(userName)) {
		throw new RuntimeException("남의 글을 수정할 수 없습니다");
	}
	commentDao.update(dto);
}

게시글 검색

  • list.html 에 select & option 코드 추가
<select name="search" class="form-select">
	<option value="title_content">제목+내용</option>
	<option value="title">제목</option>
	<option value="writer">작성자</option>
</select>
  • BoardDto 에 search 필드 추가

  • 검색 조건을 담기 위한 필드

private String search;
  • BoardService 에 getBoardList 메소드의 파라미터 수정
public BoardListResponse getBoardList(int pageNum, BoardDto dto);
  • BoardDao 에 메소드 수정 및 삭제
public List<BoardDto> selectPage(BoardDto dto);
public int getCount(BoardDto dto);
  • BoardDaoImpl 에 오버라이드 수정 및 삭제
@Override
public List<BoardDto> selectPage(BoardDto dto) {
	return session.selectList("board.selectPage", dto);
}

@Override
public int getCount(BoardDto dto) {
	// resultType : int
	return session.selectOne("board.getCount", dto);
}
⭐ selectPage 메소드에 dto
   keyword 가 있을 수도 있고 없을 수도 있다
   keyword 가 있다면 search 는 'title_content' or 'title' or 'writer'
  • boardMapper 에 selectPage 문 수정
  • 검색 키워드는 조건을 달아 검색 키워드가 없다면 조건 발동X
  • list.html 에 select 의 option value
  • 해당 조건이 다 false 면 where절 X
<select id="selectPage" parameterType="boardDto" resultType="boardDto">
	SELECT *
	FROM
		(SELECT result1.*, ROWNUM AS rnum
		FROM	
			(SELECT num, writer, title, viewCount, createdAt
			FROM board
		
			<choose>
				<when test="search == 'writer'">
					WHERE writer LIKE '%' || #{keyword} || '%'
				</when>
				<when test="search == 'title'">
					WHERE title LIKE '%' || #{keyword} || '%'
				</when>
				<when test="search == 'title_content'">
					WHERE title LIKE '%' || #{keyword} || '%' OR content LIKE '%' || #{keyword} || '%' 
				</when>
			</choose>			
 			ORDER BY num DESC
			) result1)
	WHERE rnum BETWEEN #{startRowNum} AND #{endRowNum}
</select>
	
<select id="getCount" parameterType="boardDto" resultType="int">
	SELECT NVL(MAX(ROWNUM), 0) AS count
	FROM board
	<choose>
		<when test="search == 'writer'">
			WHERE writer LIKE '%' || #{keyword} || '%'
		</when>
		<when test="search == 'title'">
			WHERE title LIKE '%' || #{keyword} || '%'
		</when>
		<when test="search == 'title_content'">
			WHERE title LIKE '%' || #{keyword} || '%' OR content LIKE '%' || #{keyword} || '%' 
		</when>
	</choose>
</select>
  • BoardListResponse 에 search & query 필드 추가
private String keyword; // 검색 키워드
private String search; // 검색 조건
private String query; // GET 방식 파라미터 keyword "search=xxx&keyword=xxx" or ""
  • BoardServiceImpl 오버라이드 수정
@Override
public BoardListResponse getBoardList(int pageNum, BoardDto dto) {
		
	//한 페이지에 몇개씩 표시할 것인지
	final int PAGE_ROW_COUNT=3;
		
	//하단 페이지를 몇개씩 표시할 것인지
	final int PAGE_DISPLAY_COUNT=3;

	//보여줄 페이지의 시작 ROWNUM
	int startRowNum=1+(pageNum-1)*PAGE_ROW_COUNT; //공차수열
	//보여줄 페이지의 끝 ROWNUM
	int endRowNum=pageNum*PAGE_ROW_COUNT; //등비수열 
		
	//하단 시작 페이지 번호 (정수를 정수로 나누면 소수점이 버려진 정수가 나온다)
	int startPageNum = 1 + ((pageNum-1)/PAGE_DISPLAY_COUNT)*PAGE_DISPLAY_COUNT;
	//하단 끝 페이지 번호
	int endPageNum=startPageNum+PAGE_DISPLAY_COUNT-1;
			
	// 전체글의 갯수
	int totalRow = boardDao.getCount(dto);
	// 전체 페이지의 갯수 구하기
	int totalPageCount=(int)Math.ceil(totalRow/(double)PAGE_ROW_COUNT);
	// 끝 페이지 번호가 이미 전체 페이지 갯수보다 크게 계산되었다면 잘못된 값이다
	if(endPageNum > totalPageCount){
		endPageNum=totalPageCount; //보정해 준다
	}
		
	// startRowNum 과 endRowNum 을 BoardDto 객체에 담아서
	dto.setStartRowNum(startRowNum);
	dto.setEndRowNum(endRowNum);
		
	// 글 목록 얻어오기 (검색 키워드가 있다면 조건에 맞는 목록만 얻어낸다)
	List<BoardDto> list = boardDao.selectPage(dto);
		
	// query 문자열을 미리 구성해서 view page 에 전달하도록 한다
	String query = "";
	if(dto.getKeyword() != null) {
		query = "search="+dto.getSearch()+"&keyword="+dto.getKeyword();
	}

	// 한줄 coding 으로 BoardListResponse 객체를 만들어서 리턴
	return BoardListResponse.builder()
			.list(list)
			.pageNum(pageNum)
			.keyword(dto.getKeyword())
			.search(dto.getSearch())
			.query(query)
			.startPageNum(startPageNum)
			.endPageNum(endPageNum)
			.totalPageCount(totalPageCount)
			.totalRow(totalRow)
			.build();
}
  • BoardController 에 list 메소드 수정
@GetMapping("/board/list")
public String list(Model model,
		@RequestParam(defaultValue = "1") int pageNum,
		BoardDto dto) {
	// BoardDto 객체에는 keyword 와 search 가 있을 수도 있다 (없을 수도 있다 null)
				
	// 응답에 필요한 데이터를 얻어내서
	BoardListResponse listResponse = service.getBoardList(pageNum, dto);
	// 모델 객체에 담고
	model.addAttribute("dto", listResponse);
	// 타임리프 페이지에서 응답
	return "board/list";
}
  • list.html select 코드 수정 및 코드 추가

  • th:selected

<select name="search" class="form-select">
	<option th:selected="${dto.search eq 'title_content'}" value="title_content">제목+내용</option>
	<option th:selected="${dto.search eq 'title'}" value="title">제목</option>
	<option th:selected="${dto.search eq 'writer'}" value="writer">작성자</option>
</select>
<p th:if="${dto.keyword ne null}">
	<strong th:text="${dto.keyword}"></strong> 에 대한 검색 결과
	<strong th:text="${dto.totalRow}"></strong></p>
profile
학원 공부 내용 정리

0개의 댓글