비즈니스 로직을 담당하는 모델, 사용자가 보는 화면을 처리하는 뷰, 서비스와 ui를 연결해주는 컨트롤러 개발
package com.study.domain.post;
import com.study.common.dto.SearchDto;
import com.study.common.paging.Pagination;
import com.study.common.paging.PagingResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.util.Collections;
import java.util.List;
@Service
@RequiredArgsConstructor
public class PostService {
private final PostMapper postMapper;
/**
* 게시글 저장
* @param params - 게시글 정보
* @return Generated PK
*/
@Transactional
public Long savePost(final PostRequest params) {
postMapper.save(params);
return params.getId();
}
/**
* 게시글 상세정보 조회
* @param id - PK
* @return 게시글 상세정보
*/
public PostResponse findPostById(final Long id) {
return postMapper.findById(id);
}
/**
* 게시글 수정
* @param params - 게시글 정보
* @return PK
*/
@Transactional
public Long updatePost(final PostRequest params) {
postMapper.update(params);
return params.getId();
}
/**
* 게시글 삭제
* @param id - PK
* @return PK
*/
public Long deletePost(final Long id) {
postMapper.deleteById(id);
return id;
}
/**
* 게시글 리스트 조회
* @param params - search conditions
* @return list & pagination information
*/
public PagingResponse<PostResponse> findAllPost(final SearchDto params) {
// 조건에 해당하는 데이터가 없는 경우, 응답 데이터에 비어있는 리스트와 null을 담아 반환
int count = postMapper.count(params);
if (count < 1) {
return new PagingResponse<>(Collections.emptyList(), null);
}
// Pagination 객체를 생성해서 페이지 정보 계산 후 SearchDto 타입의 객체인 params에 계산된 페이지 정보 저장
Pagination pagination = new Pagination(count, params);
params.setPagination(pagination);
// 계산된 페이지 정보의 일부(limitStart, recordSize)를 기준으로 리스트 데이터 조회 후 응답 데이터 반환
List<PostResponse> list = postMapper.findAll(params);
return new PagingResponse<>(list, pagination);
}
}
@Service : postmapper인터페이스의 @Mapper와 유사하며 해당 클래스가 비즈니스 로직을 담당하는 service layer클래스임을 의미한다.
@RequireArgsConstructor : 클래스내의 final로 선언된 모든 멤버에 대한 생성자를 만들어주는 역할을 한다.
@postMapper: 게시판 CRUD 기능을 포함하고 있는 mapper인터페이스이다.
@Transactional : 스프링에서 제공하는 트랜잭션 처리 방법 중 하나. 선언적 트랜잭선으로 불리는 기능. 호출된 메서드에 해당 어노테이션이 선언되어 있으면 메서드의 실행과 동시에 트랜잭션이 시작되고, 메서드의 정상 종료 여부에 따라 commit 또는 rollback 된다.
트랜잭션이란?
작업 중 오류 발생 시 없었던 일처럼 완전히 되돌리는 기능
savePost() : 게시글을 생성한다. insert가 완료되면 생성된 게시글 id를 리턴한다.
findPostById : 특정 게시글의 상세정보를 조회한다.
updatePost: 게시글을 수정한다. update가 완료되면 게시글 id를 리턴한다.
deletePost: 게시글을 삭제한다. update가 완료되면 게시글 id를 리턴한다.
findAllPost() : 게시글 목록 (리스트)를 조회한다.
- 게시글 리스트 페이지 메서드 추가
@GetMapping : get 방식의 http 요청 메서드를 의미한다. 데이터를 조회하거나 화면을 리턴하는 경우에 get방식을 이용한다.
post: PostService의 findAllPost()의 실행 결과를 담은 게시글 리스트 객체이다. 모델 인터페이스의 addAttribute()를 이용해 posts라는 이름으로 리스트 데이터를 화면 html으로 전달한다.
return: 컨트롤러의 리턴 타입이 string 이면, 리턴 문의 선언된 경로의 html 이 화면에 출력된다.
-
- 컨트롤러 클래스 생성
모델(서비스) 과 view(화면)의 중간 다리 역할을 하는 영역이다. 화면에서 사용자의 요청이 들어오명 가장 먼저 컨트롤러를 경유한다. 컨트롤러는 사용자의 요구사항을 처리해줄 서비스의 메서드를 호출하고 그에대한 실행 결과를 다시 화면으로 전당하는 역할을 한다.
package com.study.domain.post;
import com.study.common.dto.MessageDto;
import com.study.common.dto.SearchDto;
import com.study.common.paging.PagingResponse;
import com.study.domain.file.FileRequest;
import com.study.domain.file.FileResponse;
import com.study.domain.file.FileService;
import com.study.domain.file.FileUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Controller
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
private final FileService fileService;
private final FileUtils fileUtils;
// 사용자에게 메시지를 전달하고, 페이지를 리다이렉트 한다.
private String showMessageAndRedirect(final MessageDto params, Model model) {
model.addAttribute("params", params);
return "common/messageRedirect";
}
// 쿼리 스트링 파라미터를 Map에 담아 반환
private Map<String, Object> queryParamsToMap(final SearchDto queryParams) {
Map<String, Object> data = new HashMap<>();
data.put("page", queryParams.getPage());
data.put("recordSize", queryParams.getRecordSize());
data.put("pageSize", queryParams.getPageSize());
data.put("keyword", queryParams.getKeyword());
data.put("searchType", queryParams.getSearchType());
return data;
}
// 게시글 작성 페이지
@GetMapping("/post/write.do")
public String openPostWrite(@RequestParam(value = "id", required = false) final Long id, Model model) {
if (id != null) {
PostResponse post = postService.findPostById(id);
model.addAttribute("post", post);
}
return "post/write";
}
// 게시글 리스트 페이지
@GetMapping("/post/list.do")
public String openPostList(@ModelAttribute("params") final SearchDto params, Model model) {
PagingResponse<PostResponse> response = postService.findAllPost(params);
model.addAttribute("response", response);
return "post/list";
}
// 게시글 상세 페이지
@GetMapping("/post/view.do")
public String openPostView(@RequestParam final Long id, Model model) {
PostResponse post = postService.findPostById(id);
model.addAttribute("post", post);
return "post/view";
}
// 신규 게시글 생성
@PostMapping("/post/save.do")
public String savePost(final PostRequest params, Model model) {
Long id = postService.savePost(params); // 1. 게시글 INSERT
List<FileRequest> files = fileUtils.uploadFiles(params.getFiles()); // 2. 디스크에 파일 업로드
fileService.saveFiles(id, files); // 3. 업로드 된 파일 정보를 DB에 저장
MessageDto message = new MessageDto("게시글 생성이 완료되었습니다.", "/post/list.do", RequestMethod.GET, null);
return showMessageAndRedirect(message, model);
}
// 기존 게시글 수정
@PostMapping("/post/update.do")
public String updatePost(final PostRequest params, final SearchDto queryParams, Model model) {
// 1. 게시글 정보 수정
postService.updatePost(params);
// 2. 파일 업로드 (to disk)
List<FileRequest> uploadFiles = fileUtils.uploadFiles(params.getFiles());
// 3. 파일 정보 저장 (to database)
fileService.saveFiles(params.getId(), uploadFiles);
// 4. 삭제할 파일 정보 조회 (from database)
List<FileResponse> deleteFiles = fileService.findAllFileByIds(params.getRemoveFileIds());
// 5. 파일 삭제 (from disk)
fileUtils.deleteFiles(deleteFiles);
// 6. 파일 삭제 (from database)
fileService.deleteAllFileByIds(params.getRemoveFileIds());
MessageDto message = new MessageDto("게시글 수정이 완료되었습니다.", "/post/list.do", RequestMethod.GET, queryParamsToMap(queryParams));
return showMessageAndRedirect(message, model);
}
// 게시글 삭제
@PostMapping("/post/delete.do")
public String deletePost(@RequestParam final Long id, final SearchDto queryParams, Model model) {
postService.deletePost(id);
MessageDto message = new MessageDto("게시글 삭제가 완료되었습니다.", "/post/list.do", RequestMethod.GET, queryParamsToMap(queryParams));
return showMessageAndRedirect(message, model);
}
}
@Controller : 해당 클래스가 사용자의 요청과 응답을 처리하는 컨트롤러 클래스임을 의미한다.
@GetMapping : HTTP Get Method에 해당하는 단축 표현으로 서버의 리소스를 조회할 때 사용한다.
리턴타입: 컨트롤러메서드는 어떤 타입이던 리턴 타입으로 선언할 수 있다. html 처리시 리턴타입을 string으로 선언하고 리턴문에 html 파일의 경로를 선언해주면 된다.
Model: 메서드의 파라미터로 선언된 model인터페이스는 데이터를 html으로 전달하는데 사용된다.
- 게시글 작성 페이지
@RequestParam: 화면htmld에서 보낸 파라미터를 전달 받는데 사용된다.
신규 게시글을 등록하는 경우에 게시글 번호(id)가 null로 전달된다. 하지만 기존 게시글을 수정하는 경우에는 수정할 게시글 번호가(id) 가 openPostwirte()의 파라미터로 전송되고 전달받은 게시글 번호(id) 를 이용해 게시글 상세 정보를 조회한 후 html화면으로 전달한다.
신규 게시글 등록에는 게시글 번호가 필요하지 않기 때문에 required 속성을 false으로 지정한다. 필수 required속성은 default 값이 true 이며 required 속성을 false로 지정하지 않으면 id가 파라미터로 넘어오지 않았을 때 예외가 발생한다.
-
-신규 게시글 생성
폼 태그는 사용자 입력 (선택) 필드의 name 값을 통해 컨트롤러 메서드로 파라미터를 전송한다. 요청 객체 postrequest 의 멤버 변수명과 사용자 입력 필드의 name값이 동일하면 postrequest타입의 객체인 params의 각 멤버 변수에 name 값을 통해 전달된 필드의 value 가 배핑된다.-
-게시글 상세 페이지
id: PostMapper의 findById쿼리의 where조건으로 사용되는 게시글 번호(pk) 이다.
post:postservice의 findPostByid()의 실행 결과 (특정 게시글의 상세정보)를 담은 게시글 응답 객체. 화면 html에서는 ${post.변수명} 으로 응답 객체의 각 데이터에 접근한다.
return: 화면html의 경로를 의미 -
- 게시글 삭제
게시글 번호 (id)를 파라미터로 전달받아 특정 게시물을 삭제한다. 여기서 삭제는 테이블상에서 물리적인 delete 가 아닌 삭제여부 delete_yn칼럼의 상태 값을 변경하는 논리삭제이다. 게시글이 삭제된 후에는 리스트 페이지로 리다이렉트한다.
- 화면 처리기
PostController의 openPostWrite 와 연결할 html을 추가
화면 생성
html 생성 - src/main/resources/templates 에 write.html 생성
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="layout/basic">
<th:block layout:fragment="title">
<title>글작성 페이지</title>
</th:block>
<th:block layout:fragment="content">
<div class="page_tits">
<h3>게시판 관리</h3>
<p class="path"><strong>현재 위치 :</strong> <span>게시판 관리</span> <span>리스트형</span> <span>글작성</span></p>
</div>
<div class="content">
<section>
<form id="saveForm" method="post" autocomplete="off" enctype="multipart/form-data">
<!--/* 게시글 수정인 경우, 서버로 전달할 게시글 번호 (PK) */-->
<input type="hidden" id="id" name="id" th:if="${post != null}" th:value="${post.id}" />
<!--/* 서버로 전달할 공지글 여부 */-->
<input type="hidden" id="noticeYn" name="noticeYn" />
<!--/* 삭제 처리할 파일 번호 */-->
<input type="hidden" id="removeFileIds" name="removeFileIds" />
<table class="tb tb_row">
<colgroup>
<col style="width:15%;" /><col style="width:35%;" /><col style="width:15%;" /><col style="width:35%;" />
</colgroup>
<tbody>
<tr>
<th scope="row">공지글</th>
<td><span class="chkbox"><input type="checkbox" id="isNotice" name="isNotice" class="chk" /><i></i><label for="isNotice"> 설정</label></span></td>
<th scope="row">등록일</th>
<td colspan="3"><input type="text" id="createdDate" name="createdDate" readonly /></td>
</tr>
<tr>
<th>제목 <span class="es">필수 입력</span></th>
<td colspan="3"><input type="text" id="title" name="title" maxlength="50" placeholder="제목을 입력해 주세요." /></td>
</tr>
<tr>
<th>이름 <span class="es">필수 입력</span></th>
<td colspan="3"><input type="text" id="writer" name="writer" maxlength="10" placeholder="이름을 입력해 주세요." /></td>
</tr>
<tr>
<th>내용 <span class="es">필수 입력</span></th>
<td colspan="3"><textarea id="content" name="content" cols="50" rows="10" placeholder="내용을 입력해 주세요."></textarea></td>
</tr>
<tr>
<th>첨부파일</th>
<td colspan="3">
<div class="file_list">
<div>
<div class="file_input">
<input type="text" readonly />
<label> 첨부파일
<input type="file" name="files" onchange="selectFile(this);" />
</label>
</div>
<button type="button" onclick="removeFile(this);" class="btns del_btn"><span>삭제</span></button>
<button type="button" onclick="addFile();" class="btns fn_add_btn"><span>파일 추가</span></button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</form>
<p class="btn_set">
<button type="button" id="saveBtn" onclick="savePost();" class="btns btn_st3 btn_mid">저장</button>
<button type="button" onclick="goListPage();" class="btns btn_bdr3 btn_mid">뒤로</button>
</p>
</section>
</div> <!--/* .content */-->
</th:block>
<th:block layout:fragment="script">
<script th:inline="javascript">
/*<![CDATA[*/
// 파일 삭제 처리용 익명 함수
const removeFileId = (function() {
const ids = [];
return {
add(id) {
if (ids.includes(id)) {
return false;
}
ids.push(id);
},
getAll() {
return ids;
}
}
}());
window.onload = () => {
renderPostInfo();
findAllFile();
}
// 전체 파일 조회
function findAllFile() {
// 1. 신규 등록/수정 체크
const post = [[ ${post}]];
if ( !post ) {
return false;
}
// 2. API 호출
const response = getJson(`/posts/${post.id}/files`);
// 3. 로직 종료
if ( !response.length ) {
return false;
}
// 4. 업로드 영역 추가
for (let i = 0, len = (response.length - 1); i < len; i++) {
addFile();
}
// 5. 파일 선택 & 삭제 이벤트 재선언 & 파일명 세팅
const filenameInputs = document.querySelectorAll('.file_list input[type="text"]');
filenameInputs.forEach((input, i) => {
const fileInput = input.nextElementSibling.firstElementChild;
const fileRemoveBtn = input.parentElement.nextElementSibling;
fileInput.setAttribute('onchange', `selectFile(this, ${response[i].id})`);
fileRemoveBtn.setAttribute('onclick', `removeFile(this, ${response[i].id})`);
input.value = response[i].originalName;
})
}
// 게시글 상세정보 렌더링
function renderPostInfo() {
const post = [[ ${post} ]];
if ( !post ) {
initCreatedDate();
return false;
}
const form = document.getElementById('saveForm');
const fields = ['id', 'title', 'content', 'writer', 'noticeYn'];
form.isNotice.checked = post.noticeYn;
form.createdDate.value = dayjs(post.createdDate).format('YYYY-MM-DD HH:mm');
fields.forEach(field => {
form[field].value = post[field];
})
}
// 등록일 초기화
function initCreatedDate() {
document.getElementById('createdDate').value = dayjs().format('YYYY-MM-DD');
}
// 게시글 저장(수정)
function savePost() {
const form = document.getElementById('saveForm');
const fields = [form.title, form.writer, form.content];
const fieldNames = ['제목', '이름', '내용'];
for (let i = 0, len = fields.length; i < len; i++) {
isValid(fields[i], fieldNames[i]);
}
new URLSearchParams(location.search).forEach((value, key) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = value;
form.append(input);
})
document.getElementById('saveBtn').disabled = true;
form.noticeYn.value = form.isNotice.checked;
form.removeFileIds.value = removeFileId.getAll().join();
form.action = [[ ${post == null} ]] ? '/post/save.do' : '/post/update.do';
form.submit();
}
// 게시글 리스트 페이지로 이동
function goListPage() {
const queryString = new URLSearchParams(location.search);
queryString.delete('id');
location.href = '/post/list.do' + '?' + queryString.toString();
}
// 파일 선택
function selectFile(element, id) {
const file = element.files[0];
const filename = element.closest('.file_input').firstElementChild;
// 1. 파일 선택 창에서 취소 버튼이 클릭된 경우
if ( !file ) {
filename.value = '';
return false;
}
// 2. 파일 크기가 10MB를 초과하는 경우
const fileSize = Math.floor(file.size / 1024 / 1024);
if (fileSize > 10) {
alert('10MB 이하의 파일로 업로드해 주세요.');
filename.value = '';
element.value = '';
return false;
}
// 3. 파일명 지정
filename.value = file.name;
// 4. 삭제할 파일 id 추가
if (id) {
removeFileId.add(id);
}
}
// 파일 추가
function addFile() {
const fileDiv = document.createElement('div');
fileDiv.innerHTML =`
<div class="file_input">
<input type="text" readonly />
<label> 첨부파일
<input type="file" name="files" onchange="selectFile(this);" />
</label>
</div>
<button type="button" onclick="removeFile(this);" class="btns del_btn"><span>삭제</span></button>
`;
document.querySelector('.file_list').appendChild(fileDiv);
}
// 파일 삭제
function removeFile(element, id) {
// 1. 삭제할 파일 id 추가
if (id) {
removeFileId.add(id);
}
// 2. 파일 영역 초기화 & 삭제
const fileAddBtn = element.nextElementSibling;
if (fileAddBtn) {
const inputs = element.previousElementSibling.querySelectorAll('input');
inputs.forEach(input => input.value = '')
return false;
}
element.parentElement.remove();
}
/*]]>*/
</script>
</th:block>
</html>
글작성 페이지 처리하기, 글 작성 영역 추가,
- 화면 html 생성
openPostList()의 리턴문에 선언된 list.html을 추가해준다.
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="layout/basic">
<th:block layout:fragment="title">
<title>리스트 페이지</title>
</th:block>
<th:block layout:fragment="content">
<div class="page_tits">
<h3>게시판 관리</h3>
<!-- <p class="path"><strong>현재 위치 :</strong> <span>게시판 관리</span> <span>리스트형</span> <span>리스트</span></p> -->
</div>
<div class="content">
<section>
<!--/* 검색 */-->
<div class="search_box">
<form id="searchForm" onsubmit="return false;" autocomplete="off">
<div class="sch_group fl">
<select id="searchType" name="searchType" title="검색 유형 선택">
<option value="">전체 검색</option>
<option value="title">제목</option>
<option value="content">내용</option>
<option value="writer">작성자</option>
</select>
<input type="text" id="keyword" name="keyword" placeholder="키워드를 입력해 주세요." title="키워드 입력" />
<button type="button" class="bt_search" onclick="movePage(1);"><i class="fas fa-search"></i><span class="skip_info">검색</span></button>
</div>
</form>
</div>
<!--/* 리스트 */-->
<table class="tb tb_col">
<colgroup>
<col style="width:50px;"/><col style="width:7.5%;"/><col style="width:auto;"/><col style="width:10%;"/><col style="width:15%;"/><col style="width:7.5%;"/>
</colgroup>
<thead>
<tr>
<th scope="col"><input type="checkbox"/></th>
<th scope="col">번호</th>
<th scope="col">제목</th>
<th scope="col">작성자</th>
<th scope="col">등록일</th>
<th scope="col">조회</th>
</tr>
</thead>
<!--/* 리스트 데이터 렌더링 영역 */-->
<tbody id="list">
</tbody>
</table>
<!--/* 페이지네이션 렌더링 영역 */-->
<div class="paging">
</div>
<!--/* 버튼 */-->
<p class="btn_set tr">
<a th:href="@{/post/write.do}" class="btns btn_st3 btn_mid">글쓰기</a>
</p>
</section>
</div> <!--/* .content */-->
<th:block layout:fragment="script">
<script th:inline="javascript">
/*<![CDATA[*/
// 페이지가 로드되었을 때, 딱 한 번만 함수를 실행
window.onload = () => {
setQueryStringParams();
findAllPost();
}
// 쿼리 스트링 파라미터 셋팅
function setQueryStringParams() {
if ( !location.search ) {
return false;
}
const form = document.getElementById('searchForm');
new URLSearchParams(location.search).forEach((value, key) => {
if (form[key]) {
form[key].value = value;
}
})
}
// 게시글 리스트 조회
function findAllPost() {
// 1. PagingResponse의 멤버인 List<T> 타입의 list를 의미
const list = [[ ${response.list} ]];
// 2. 리스트가 비어있는 경우, 행에 "검색 결과가 없다"는 메시지를 출력하고, 페이지 번호(페이지네이션) HTML을 제거(초기화)한 후 로직을 종료
if ( !list.length ) {
document.getElementById('list').innerHTML = '<td colspan="6"><div className="no_data_msg">검색된 결과가 없습니다.</div></td>';
drawPage();
}
// 3. PagingResponse의 멤버인 pagination을 의미
const pagination = [[ ${response.pagination} ]];
// 4. @ModelAttribute를 이용해서 뷰(HTML)로 전달한 SearchDto 타입의 객체인 params를 의미
const params = [[ ${params} ]];
// 5. 리스트에 출력되는 게시글 번호를 처리하기 위해 사용되는 변수 (리스트에서 번호는 페이지 정보를 이용해서 계산해야 함)
let num = pagination.totalRecordCount - ((params.page - 1) * params.recordSize);
// 6. 리스트 데이터 렌더링
drawList(list, num);
// 7. 페이지 번호 렌더링
drawPage(pagination, params);
}
// 리스트 HTML draw
function drawList(list, num) {
// 1. 렌더링 할 HTML을 저장할 변수
let html = '';
/*
* 2. 기존에 타임리프(Thymeleaf)를 이용해서 리스트 데이터를 그리던 것과 유사한 로직
* 기존에는 게시글 번호를 (전체 데이터 수 - loop의 인덱스 번호)로 처리했으나, 현재는 (전체 데이터 수 - ((현재 페이지 번호 - 1) * 페이지당 출력할 데이터 개수))로 정밀히 계산
*/
list.forEach(row => {
html += `
<tr>
<td><input type="checkbox" /></td>
<td>${row.noticeYn === false ? num-- : '공지'}</td>
<td class="tl"><a href="javascript:void(0);" onclick="goViewPage(${row.id});">${row.title}</a></td>
<td>${row.writer}</td>
<td>${dayjs(row.createdDate).format('YYYY-MM-DD HH:mm')}</td>
<td>${row.viewCnt}</td>
</tr>
`;
})
// 3. id가 "list"인 요소를 찾아 HTML을 렌더링
document.getElementById('list').innerHTML = html;
}
// 페이지 HTML draw
function drawPage(pagination, params) {
// 1. 필수 파라미터가 없는 경우, 페이지 번호(페이지네이션) HTML을 제거(초기화)한 후 로직 종료
if ( !pagination || !params ) {
document.querySelector('.paging').innerHTML = '';
throw new Error('Missing required parameters...');
}
// 2. 렌더링 할 HTML을 저장할 변수
let html = '';
// 3. 이전 페이지가 있는 경우, 즉 시작 페이지(startPage)가 1이 아닌 경우 첫 페이지 버튼과 이전 페이지 버튼을 HTML에 추가
if (pagination.existPrevPage) {
html += `
<a href="javascript:void(0);" onclick="movePage(1)" class="page_bt first">첫 페이지</a>
<a href="javascript:void(0);" onclick="movePage(${pagination.startPage - 1})" class="page_bt prev">이전 페이지</a>
`;
}
/*
* 4. 시작 페이지(startPage)와 끝 페이지(endPage) 사이의 페이지 번호(i)를 넘버링 하는 로직
* 페이지 번호(i)와 현재 페이지 번호(params.page)가 동일한 경우, 페이지 번호(i)를 활성화(on) 처리
*/
html += '<p>';
for (let i = pagination.startPage; i <= pagination.endPage; i++) {
html += (i !== params.page)
? `<a href="javascript:void(0);" onclick="movePage(${i});">${i}</a>`
: `<span class="on">${i}</span>`
}
html += '</p>';
// 5. 현재 위치한 페이지 뒤에 데이터가 더 있는 경우, 다음 페이지 버튼과 끝 페이지 버튼을 HTML에 추가
if (pagination.existNextPage) {
html += `
<a href="javascript:void(0);" onclick="movePage(${pagination.endPage + 1});" class="page_bt next">다음 페이지</a>
<a href="javascript:void(0);" onclick="movePage(${pagination.totalPageCount});" class="page_bt last">마지막 페이지</a>
`;
}
// 6. class가 "paging"인 요소를 찾아 HTML을 렌더링
document.querySelector('.paging').innerHTML = html;
}
// 페이지 이동
function movePage(page) {
// 1. 검색 폼
const form = document.getElementById('searchForm');
// 2. drawPage( )의 각 버튼에 선언된 onclick 이벤트를 통해 전달받는 page(페이지 번호)를 기준으로 객체 생성
const queryParams = {
page: (page) ? page : 1,
recordSize: 10,
pageSize: 10,
searchType: form.searchType.value,
keyword: form.keyword.value
}
/*
* 3. location.pathname : 리스트 페이지의 URI("/post/list.do")를 의미
* new URLSearchParams(queryParams).toString() : queryParams의 모든 프로퍼티(key-value)를 쿼리 스트링으로 변환
* URI + 쿼리 스트링에 해당하는 주소로 이동
* (해당 함수가 리턴해주는 값을 브라우저 콘솔(console)에 찍어보시면 쉽게 이해하실 수 있습니다.)
*/
location.href = location.pathname + '?' + new URLSearchParams(queryParams).toString();
}
// 게시글 상세 페이지로 이동
function goViewPage(id) {
const queryString = (location.search) ? location.search + `&id=${id}` : `?id=${id}`;
location.href = '/post/view.do' + queryString;
}
/*]]>*/
</script>
</th:block>
</html>
- 화면 생성하기
openPostView()의 리턴 문에 선언된 view.html을 추가.
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="layout/basic">
<th:block layout:fragment="title">
<title>상세 페이지</title>
</th:block>
<th:block layout:fragment="content">
<div class="page_tits">
<h3>게시판 관리</h3>
<p class="path"><strong>현재 위치 :</strong> <span>게시판 관리</span> <span>리스트형</span> <span>상세정보</span></p>
</div>
<div class="content">
<section>
<table class="tb tb_row">
<colgroup>
<col style="width:10%;"/><col style="width:23%;"/><col style="width:10%;"/><col style="width:23%;"/>
</colgroup>
<tbody>
<tr>
<th scope="row">글 유형</th>
<td th:text="${post.noticeYn == false ? '일반' : '공지'}"></td>
<th scope="row">등록일</th>
<td th:text="${#temporals.format( post.createdDate, 'yyyy-MM-dd HH:mm' )}"></td>
</tr>
<tr>
<th scope="row">제목</th>
<td>[[ ${post.title} ]]</td>
<th scope="row">조회</th>
<td colspan="3">[[ ${post.viewCnt} ]]</td>
</tr>
<tr>
<th scope="row">이름</th>
<td colspan="3">[[ ${post.writer} ]]</td>
</tr>
<tr>
<th scope="row">내용</th>
<td colspan="3">[[ ${post.content} ]]</td>
</tr>
<tr>
<th scope="row">첨부파일</th>
<td id="files" colspan="3">
</td>
</tr>
</tbody>
</table>
<p class="btn_set">
<button type="button" onclick="goWritePage();" class="btns btn_bdr4 btn_mid">수정</button>
<button type="button" onclick="deletePost();" class="btns btn_bdr1 btn_mid">삭제</button>
<button type="button" onclick="goListPage();" class="btns btn_bdr3 btn_mid">뒤로</button>
</p>
<!--/* 댓글 작성 */-->
<div class="cm_write">
<fieldset>
<legend class="skipinfo">댓글 입력</legend>
<div class="cm_input">
<p><textarea id="content" name="content" onkeyup="countingLength(this);" cols="90" rows="4" placeholder="댓글을 입력해 주세요."></textarea></p>
<span><button type="button" class="btns" onclick="saveComment();">등 록</button> <i id="counter">0/300자</i></span>
</div>
</fieldset>
</div>
<!--/* 댓글 렌더링 영역 */-->
<div class="cm_list">
</div>
<!--/* 페이지네이션 렌더링 영역 */-->
<div class="paging">
</div>
</section>
</div> <!--/* .content */-->
<!--/* 댓글 수정 popup */-->
<div id="commentUpdatePopup" class="popLayer">
<h3>댓글 수정</h3>
<div class="pop_container">
<table class="tb tb_row tl">
<colgroup>
<col style="width:30%;" /><col style="width:70%;" />
</colgroup>
<tbody>
<tr>
<th scope="row">작성자<span class="es">필수 입력</span></th>
<td><input type="text" id="modalWriter" name="modalWriter" placeholder="작성자를 입력해 주세요." /></td>
</tr>
<tr>
<th scope="row">내용<span class="es">필수 입력</span></th>
<td><textarea id="modalContent" name="modalContent" cols="90" rows="10" placeholder="수정할 내용을 입력해 주세요."></textarea></td>
</tr>
</tbody>
</table>
<p class="btn_set">
<button type="button" id="commentUpdateBtn" class="btns btn_st2">수정</button>
<button type="button" class="btns btn_bdr2" onclick="closeCommentUpdatePopup();">취소</button>
</p>
</div>
<button type="button" class="btn_close" onclick="closeCommentUpdatePopup();"><span><i class="far fa-times-circle"></i></span></button>
</div>
</th:block>
<th:block layout:fragment="script">
<script th:inline="javascript">
/*<![CDATA[*/
window.onload = () => {
findAllFile();
findAllComment();
}
// 전체 파일 조회
function findAllFile() {
// 1. API 호출
const postId = [[ ${post.id}]];
const response = getJson(`/posts/${postId}/files`);
// 2. 로직 종료
if ( !response.length ) {
return false;
}
// 3. 파일 영역 추가
let fileHtml = '<div class="file_down"><div class="cont">';
response.forEach(row => {
fileHtml += `<a href="/posts/${postId}/files/${row.id}/download"><span class="icons"><i class="fas fa-folder-open"></i></span>${row.originalName}</a>`;
})
fileHtml += '</div></div>';
// 4. 파일 HTML 렌더링
document.getElementById('files').innerHTML = fileHtml;
}
// 전체 댓글 조회
function findAllComment(page) {
const uriPage = new URLSearchParams(location.search).get('page');
page = (page) ? page : (uriPage ? Number(uriPage) : 1);
const postId = [[ ${post.id}]];
const uri = `/posts/${postId}/comments`;
const params = {
page : page,
recordSize : 5,
pageSize : 10,
postId : postId,
}
const response = getJson(uri, params);
const pagination = response.pagination;
drawComments(response.list);
drawPage(pagination, page);
}
// 게시글 삭제
function deletePost() {
const id = [[ ${post.id} ]];
if ( !confirm(id + '번 게시글을 삭제할까요?') ) {
return false;
}
let inputHtml = '';
new URLSearchParams(location.search).forEach((value, key) => {
inputHtml += `<input type="hidden" name="${key}" value="${value}" />`;
})
const formHtml = `
<form id="deleteForm" action="/post/delete.do" method="post">
${inputHtml}
</form>
`;
const doc = new DOMParser().parseFromString(formHtml, 'text/html');
const form = doc.body.firstChild;
document.body.append(form);
document.getElementById('deleteForm').submit();
}
// 게시글 수정 페이지로 이동
function goWritePage() {
location.href = '/post/write.do' + location.search;
}
// 게시글 리스트 페이지로 이동
function goListPage() {
const queryString = new URLSearchParams(location.search);
queryString.delete('id');
location.href = '/post/list.do' + '?' + queryString.toString();
}
// 댓글 길이 카운팅
function countingLength(content) {
if (content.value.length > 300) {
alert('댓글을 300자 이하로 입력해 주세요.');
content.value = content.value.substring(0, 300);
content.focus();
}
document.getElementById('counter').innerText = content.value.length + '/300자';
}
// 댓글 저장
function saveComment() {
const content = document.getElementById('content');
isValid(content, '댓글');
const postId = [[ ${post.id} ]];
const uri = `/posts/${postId}/comments`;
const params = {
postId : postId,
content : content.value,
writer : 'user' // 여기에 로그인한 사용자의 id,loginId
}
callApi(uri, 'post', params);
alert('저장되었습니다.');
content.value = '';
document.getElementById('counter').innerText = '0/300자';
findAllComment(1);
}
// 댓글 수정 팝업 open
function openCommentUpdatePopup(id) {
const postId = [[ ${post.id} ]];
const uri = `/posts/${postId}/comments/${id}`;
const response = getJson(uri);
document.getElementById('modalWriter').value = response.writer;
document.getElementById('modalContent').value = response.content;
document.getElementById('commentUpdateBtn').setAttribute('onclick', `updateComment(${id})`);
layerPop('commentUpdatePopup');
}
// 댓글 수정 팝업 close
function closeCommentUpdatePopup() {
document.querySelectorAll('#modalContent, #modalWriter').forEach(element => element.value = '');
document.getElementById('commentUpdateBtn').removeAttribute('onclick');
layerPopClose('commentUpdatePopup');
}
// 댓글 수정
function updateComment(id) {
const writer = document.getElementById('modalWriter');
const content = document.getElementById('modalContent');
isValid(writer, '작성자');
isValid(content, '수정할 내용');
const postId = [[ ${post.id} ]];
const uri = `/posts/${postId}/comments/${id}`;
const params = {
id : id,
postId : postId,
content : content.value,
writer : writer.value
}
callApi(uri, 'patch', params);
alert('수정되었습니다.');
closeCommentUpdatePopup();
findAllComment();
}
// 댓글 삭제
function deleteComment(id) {
if ( !confirm('선택하신 댓글을 삭제할까요?') ) {
return false;
}
const postId = [[ ${post.id} ]];
const uri = `/posts/${postId}/comments/${id}`;
callApi(uri, 'delete');
alert('삭제되었습니다.');
findAllComment();
}
// 댓글 HTML draw
function drawComments(list) {
if ( !list.length ) {
document.querySelector('.cm_list').innerHTML = '<div class="cm_none"><p>등록된 댓글이 없습니다.</p></div>';
return false;
}
let commentHtml = '';
list.forEach(row => {
commentHtml += `
<div>
<span class="writer_img"><img src="/images/default_profile.png" width="30" height="30" alt="기본 프로필 이미지"/></span>
<p class="writer">
<em>${row.writer}</em>
<span class="date">${dayjs(row.createdDate).format('YYYY-MM-DD HH:mm')}</span>
</p>
<div class="cont"><div class="txt_con">${row.content}</div></div>
<p class="func_btns">
<button type="button" onclick="openCommentUpdatePopup(${row.id});" class="btns"><span class="icons icon_modify">수정</span></button>
<button type="button" onclick="deleteComment(${row.id});" class="btns"><span class="icons icon_del">삭제</span></button>
</p>
</div>
`;
})
document.querySelector('.cm_list').innerHTML = commentHtml;
}
// 페이지네이션 HTML draw
function drawPage(pagination, page) {
// 1. 필수 파라미터가 없는 경우, 페이지네이션 HTML을 제거한 후 로직 종료
if ( !pagination || !page ) {
document.querySelector('.paging').innerHTML = '';
throw new Error('Missing required parameters...');
}
// 2. 페이지네이션 HTML을 그릴 변수
let html = '';
// 3. 첫/이전 페이지 버튼 추가
if (pagination.existPrevPage) {
html += `
<a href="javascript:void(0);" onclick="findAllComment(1)" class="page_bt first">첫 페이지</a>
<a href="javascript:void(0);" onclick="findAllComment(${pagination.startPage - 1})" class="page_bt prev">이전 페이지</a>
`;
}
// 4. 페이지 번호 추가
html += '<p>';
for (let i = pagination.startPage; i <= pagination.endPage; i++) {
html += `<a href="javascript:void(0);" onclick="findAllComment(${i});">${i}</a>`
}
html += '</p>';
// 5. 다음/끝 페이지 버튼 추가
if (pagination.existNextPage) {
html += `
<a href="javascript:void(0);" onclick="findAllComment(${pagination.endPage + 1});" class="page_bt next">다음 페이지</a>
<a href="javascript:void(0);" onclick="findAllComment(${pagination.totalPageCount});" class="page_bt last">마지막 페이지</a>
`;
}
// 6. <div class="paging"></div> 태그에 변수 html에 담긴 내용을 렌더링
const paging = document.querySelector('.paging');
paging.innerHTML = html;
// 7. 사용자가 클릭한 페이지 번호(page) 또는 끝 페이지 번호(totalPageCount)에 해당되는 a 태그를 찾아 활성화(active) 처리한 후 클릭 이벤트 제거
const currentPage = Array.from(paging.querySelectorAll('a')).find(a => (Number(a.text) === page || Number(a.text) === pagination.totalPageCount));
currentPage.classList.add('on');
currentPage.removeAttribute('onclick');
// 8. 페이지 URI 강제 변경
const postId = new URLSearchParams(location.search).get('id');
history.replaceState({}, '', location.pathname + `?id=${postId}&page=${currentPage.text}`);
}
/*]]>*/
</script>
</th:block>
</html>
window.onload():
자바스트립트의 onload 이벤트로 로딩되는 시점에 단 한 번만실행되는 코드. 게시글 상세 정보는 상세 페이지에 접속했을 때 한 번만 보여주면 되기 때문에 여러 번 호출 할 필요가 없다.
renderPostInfo():
게시글 상세정보를 화면에 렌더링하는 역할을 하는 함수. 신규 게시글 작성은 post객체를 화면으로 전달하지 않기 때문에 오늘 날짜를 렌더링한 후 로직을 종료하고 기존 게시글을 수정하는 경우에만 메인 로직이 실행된다.
메인 로직이 실행되면 saveForm에서 fields에 선언한 필드명에 해당되는 필드를 찾아 기존 게시글을 렌더링한다.
게시글의 삭제는 상세 페이지에서 이루어지며 삭제하기 버튼에 onclick이벤트를 바인딩해 게시글 삭제를 처리한다. 삭제 버튼 클릭 시 deletePost()함수 실행.
'JAVA > SpringBoot' 카테고리의 다른 글
게시판 프로젝트 - REST API 방식으로 댓글 등록 기능 구현 (0) | 2023.11.06 |
---|---|
게시판 프로젝트 - REST API 방식 (0) | 2023.11.06 |
게시판 프로젝트 - 댓글 CRUD 처리 (0) | 2023.11.06 |
게시판 프로젝트 2, CRUD (0) | 2023.11.06 |
IntelliJ, SpringBoot, MariaDB, Thymeleaf, MyBatis로 만드는 게시판 1 (0) | 2023.11.06 |