boardDetail.html
<th:block th:if="${board.memberNo == session.loginMember?.memberNo}">
<button id="updateBtn">수정</button>
<button id="deleteBtn">삭제(GET)</button>
<button id="deleteBtn2">삭제(POST)</button>
</th:block>
boardDetail.js
const updateBtn = document.querySelector("#updateBtn");
// 화면상에 수정 버튼이 존재할 때
if(updateBtn != null) {
updateBtn.addEventListener("click", () => {
// GET 방식
// 목표 : /editBoard/1/1990/update
// 게시글 상세 조회 시 현재 경로 : /board/1/1990?cp=1
location.href = location.pathname.replace('board', 'editBoard') + "/update" + location.search;
});
};
location. 현재 경로 뽑아내서
location.search 물음표 뒷부분부터 찾아서 붙여줌
-> /editBoard/1/1990/update?cp=1
/editBoard/1/1990/update?cp=1 이걸 받아줘야함
물음표 뒤에 붙은 쿼리스트링은 파라미터로 받는 거 (현재 페이지는 받을 필요가 없음)
받아서 써야한다면 @RequestParam("cp") int cp 이런 식으로 받아서 사용
게시글 상세조회할 때 수정하기 버튼 누르면 기존에 있던 페이지 그대로를 다시 보여줘야함
EditBoardController
/** 게시글 수정 화면으로 전환
* @param baordCode : 게시판 종류
* @param boardNo : 게시글 번호
* @param loginMember : 로그인한 회원이 작성한 글이 맞는지 검사하는 용도
* @param model : forward 시 request scope 로 값 전달하는 용도
* @param ra : redirect 시 request scope 로 값 전달하는 용도
* @return
*/
@GetMapping("{boardCode:[0-9]+}/{boardNo:[0-9]+}/update")
public String boardUpdate(@PathVariable("boardCode") int baordCode,
@PathVariable("boardNo") int boardNo,
@SessionAttribute("loginMember") Member loginMember,
Model model,
RedirectAttributes ra) {
게시글 상세조회 하는 서비스가 BoardServiceImpl 에 selectOne() 으로 있음
BoardServiceImpl
@Override
public Board selectOne(Map<String, Integer> map) {
return mapper.selectOne(map);
}
map으로 boardCode, boardNo 전달해줘야함
EditBoardController 필드
private final BoardService boardService;
EditBoardController 에 boardUpdate 메서드
// 수정 화면에 출력할 기존의 제목/내용/이미지 조회
// -> 게시글 상세 조회
Map<String, Integer> map = new HashMap<>();
map.put("boardCode", boardCode);
map.put("boardNo", boardNo);
// BoardService.selectOne(map) 호출 시 돌려 받는 값이 Board
Board board = boardService.selectOne(map);
String message = null;
String path = null;
if(board == null) {
message = "해당 게시글이 존재하지 않습니다.";
path = "redirect:/"; // 메인페이지
ra.addFlashAttribute("message", message);
} else if(board.getMemberNo() != loginMember.getMemberNo()) {
message = "자신이 작성한 글만 수정할 수 있습니다.";
// 해당 글 상세조회 리다이렉트
path = String.format("redirect:/board/%d/%d", boardCode, boardNo);
ra.addFlashAttribute("message", message);
} else {
// 해당 글 존재 + 글쓴이 (수정하겠다.)
path = "board/boardUpdate"; // templates/board/boardUpdate.html 로 forward
model.addAttribute("board", board);
}
return path;
}
boardUpdate.html
<!-- 이미지 미리보기 설정 -->
<script th:inline="javascript">
const imageList = /*[[${board.imageList}]]*/ [];
const previewList = document.querySelectorAll('img.preview');
const orderList = []; // 기존에 존재하던 이미지 순서를 기록
for(let img of imageList) {
previewList[img.imgOrder].src = img.imgPath + img.imgRename;
orderList.push(img.imgOrder);
// 다섯개 이미지가 전부 다 있었다
// -> [0,1,2,3,4]
}
</script>
<script src="/js/board/boardUpdate.js"></script>
html form 태그에서 요청한 주소
<!-- 현재 : /editBoard/1/2005/update?cp=1 -->
<!-- 상대경로로 요청한 주소 (목표 경로) : /editBoard/1/2005/update?cp=1 POST 요청 -->
<form th:action="@{update}" th:object="${board}" method="POST"
class="board-write" id="boardUpdateFrm" enctype="multipart/form-data">
boardUpdate.js
/* 선택된 이미지 미리보기 */
// const previewList = document.getElementsByClassName("preview"); // img 태그 5개
const inputImageList = document.getElementsByClassName("inputImage"); // input 태그 5개
const deleteImageList = document.getElementsByClassName("delete-image"); // x버튼 5개
// x버튼이 눌러져 삭제된 이미지의 순서를 저장
// * Set : 중복 저장 X, 순서 유지 X
const deleteOrder = new Set();
// 이미지 선택 이후 취소를 누를 경우를 대비한 백업 이미지
// (백업 원리 -> 복제품으로 기존 요소를 대체함)
const backupInputList = new Array(inputImageList.length);
/* ***** input 태그 값 변경 시(파일 선택 시) 실행할 함수 ***** */
/**
* @param inputImage : 파일이 선택된 input 태그
* @param order : 이미지 순서
*/
const changeImageFn = (inputImage, order) => {
// byte단위로 10MB 지정
const maxSzie = 1024 * 1024 * 10;
// 업로드된 파일 정보가 담긴 객체를 얻어와 변수에 저장
const file = inputImage.files[0];
// ------------- 파일 선택 -> 취소 해서 파일이 없는 경우 ----------------
if(file == undefined){
console.log("파일 선택 취소됨");
// 같은 순서(order)번째 backupInputList 요소를 얻어와 대체하기
/* 한 번 화면에 추가된 요소는 재사용(다른 곳에 또 추가) 불가능 */
// 백업본을 한 번 더 복제
const temp = backupInputList[order].cloneNode(true);
inputImage.after(temp); // 백업본을 다음 요소로 추가
inputImage.remove(); // 원본을 삭제
inputImage = temp; // 원본 변수에 백업본을 참조할 수 있게 대입
// 백업본에 없는 이벤트 리스너를 다시 추가
inputImage.addEventListener("change", e => {
changeImageFn(e.target, order);
})
return;
}
// ---------- 선택된 파일의 크기가 최대 크기(maxSize) 초과 ---------
if(file.size > maxSzie){
alert("10MB 이하의 이미지를 선택해주세요");
// 해당 순서의 backup 요소가 없거나,
// 요소는 있는데 값이 없는 경우 == 아무 파일도 선택된적 없을 때
if(backupInputList[order] == undefined
|| backupInputList[order].value == ''){
inputImage.value = ""; // 잘못 업로드된 파일 값 삭제
return;
}
// 이전에 정상 선택 -> 다음 선택에서 이미지 크기 초과한 경우
// 백업본을 한 번 더 복제
const temp = backupInputList[order].cloneNode(true);
inputImage.after(temp); // 백업본을 다음 요소로 추가
inputImage.remove(); // 원본을 삭제
inputImage = temp; // 원본 변수에 백업본을 참조할 수 있게 대입
// 백업본에 없는 이벤트 리스너를 다시 추가
inputImage.addEventListener("change", e => {
changeImageFn(e.target, order);
})
return;
}
// ------------ 선택된 이미지 미리보기 --------------
const reader = new FileReader(); // JS에서 파일을 읽고 저장하는 객체
// 선택된 파일을 JS로 읽어오기 -> reader.result 변수에 저장됨
reader.readAsDataURL(file);
reader.addEventListener("load", e => {
const url = e.target.result;
// img 태그(.preview)에 src 속성으로 url 값을 대입
previewList[order].src = url;
// 같은 순서 backupInputList에 input태그를 복제해서 대입
backupInputList[order] = inputImage.cloneNode(true);
// 이미지가 성공적으로 읽어진 경우
// deleteOrder에서 해당 순서를 삭제
deleteOrder.delete(order);
});
}
for(let i=0 ; i<inputImageList.length ; i++){
// **** input태그에 이미지가 선택된 경우(값이 변경된 경우) ****
inputImageList[i].addEventListener("change", e => {
changeImageFn(e.target, i);
})
// **** x 버튼 클릭 시 ****
deleteImageList[i].addEventListener("click", () => {
// img, input, backup의 인덱스가 모두 일치한다는 특징을 이용
// 삭제된 이미지 순서를 deleteOrder에 기록
// 미리보기 이미지가 있을 때에만
if(previewList[i].getAttribute("src") != null
&& previewList[i].getAttribute("src") != "" ){
// 기존에 이미지가 존재하고 있을 경우에만
if( orderList.includes(i) ){
deleteOrder.add(i);
}
}
previewList[i].src = ""; // 미리보기 이미지 제거
inputImageList[i].value = ""; // input에 선택된 파일 제거
backupInputList[i] = undefined; // 백업본 제거
});
}
// -------------------------------------------
// 제출 시 유효성 검사
const boardUpdateFrm = document.querySelector("#boardUpdateFrm");
boardUpdateFrm.addEventListener("submit", e => {
const boardTitle = document.querySelector("[name='boardTitle']");
const boardContent = document.querySelector("[name='boardContent']");
if(boardTitle.value.trim().length == 0){
alert("제목을 작성해 주세요");
boardTitle.focus();
e.preventDefault();
return;
}
if(boardContent.value.trim().length == 0){
alert("내용을 작성해 주세요");
boardContent.focus();
e.preventDefault();
return;
}
// input 태그에 삭제할 이미지 순서(Set)를 배열로 만든 후 대입
// -> value(문자열) 저장 시 배열은 toString()호출되서 양쪽 []가 사라짐
document.querySelector("[name='deleteOrder']").value
= Array.from( deleteOrder );
console.log(document.querySelector("[name='deleteOrder']"));
// deleteOrder에 {2, 3} 이 있다면
// <input type="hidden" name="deleteOrder" value="2,3">
// 현재 페이지에서 얻어온 querystring을 input 태그 hidden 타입에 value 값으로 대입하기
document.querySelector("[name='querystring']").value = location.search;
});
EditBoardController
/** 게시글 수정
* @param boardCode : 게시판 종류
* @param boardNo : 게시글 번호
* @param inputBoard : 커맨드 객체(제목, 내용)
* @param loginMember : 로그인한 회원 번호 이용(로그인 == 작성자)
* @param images : 제출된 input type ="file" 모든 요소 (안에 내용 있든 없든 5개 다 제출됨)
* @param ra : redirect 시 request scope 값 전달
* @param deleteOrder : 삭제된 이미지 순서가 기록된 문자열 (1,2,3) 이렇게 넘어옴
* @param queryString : 수정 성공 시 이전 파라미터 유지 (cp)
* @return
*/
@PostMapping("{boardCode:[0-9]+}/{boardNo:[0-9]+}/update")
public String boardUpdate(@PathVariable("boardCode") int boardCode,
@PathVariable("boardNo") int boardNo,
@ModelAttribute Board inputBoard,
@SessionAttribute("loginMember") Member loginMember,
@RequestParam("images") List<MultipartFile> images,
RedirectAttributes ra,
@RequestParam(value="deleteOrder", required = false) String deleteOrder,
@RequestParam(value="queryString", required = false, defaultValue="") String queryString) {
EditBoardController
// 1. 커맨드 객체(inputBoard)에 boardCode, boardNo, memberNo 세팅
inputBoard.setBoardCode(boardCode);
inputBoard.setBoardNo(boardNo);
inputBoard.setMemberNo(loginMember.getMemberNo());
// -> inputBoard (제목, 내용, boardCode, boardNo, memberNo)
// 2. 게시글 수정 서비스 호출 후 결과 반환 받기
int result = service.boardUpdate(inputBoard, images, deleteOrder);
ServiceImpl
// 1. 게시글 (제목/내용) 부분 수정
int result = mapper.boardUpdate(inputBoard);
// 수정 실패 시 바로 return;
if(result == 0) return 0;
mapper.xml
<!-- 게시글 부분 수정 (제목/내용) -->
<update id="boardUpdate">
UPDATE "BOARD" SET
BOARD_TITLE = #{boardTitle},
BOARD_CONTENT = #{boardContent}
WHERE BOARD_CODE = #{boardCode}
AND BOARD_NO = #{boardNo}
AND MEMBER_NO = #{memberNo}
</update>
deleteOrder == BOARD_IMG 테이블에 있는 IMG_ORDER
ServiceImpl
if(deleteOrder != null && !deleteOrder.equals("")) {
Map<String, Object> map = new HashMap<>();
map.put("deleteOrder", deleteOrder);
map.put("boardNo", inputBoard.getBoardNo());
result = mapper.deleteImage(map);
}
// 삭제 실패 시 (부분 실패 포함)
if(result == 0) {
throw new ImageDeleteException();
}
mapper.xml
<!-- 게시글 이미지 삭제 -->
<delete id="deleteImage">
DELETE FROM "BOARD_IMG"
WHERE IMG_ORDER IN (${deleteOrder})
AND BOARD_NO = #{boardNo}
</delete>
#{} : 해당 컬럼 자료형에 맞는 리터럴로 변환 ''
${} : SQL에 값 그대로 추가 2,3
ImageDeleteException 클래스 생성
package edu.kh.project.board.model.exception;
// 이미지 삭제 중 문제 발생 시 사용할 사용자 정의 예외
public class ImageDeleteException extends RuntimeException {
public ImageDeleteException() {
super("이미지 삭제 중 예외 발생");
}
public ImageDeleteException(String message) {
super(message);
}
}
예외 발생 시켜서 제목/내용 부분까지 날려줄 거임
ServiceImpl
List<BoardImg> uploadList = new ArrayList<>();
// images 리스트에서 하나씩 꺼내어 선택된 파일이 있는지 검사
for(int i = 0 ; i < images.size() ; i++) {
// 실제 선택된 파일이 존재하는 경우
if( !images.get(i).isEmpty() ) {
// 원본명
String originalName = images.get(i).getOriginalFilename();
// 변경명
String rename = Utility.fileRename(originalName);
// IMG_ORDER == i (인덱스 == 순서)
// 모든 값을 저장할 DTO 생성 (BoardImg - Builder 패턴 사용)
BoardImg img = BoardImg.builder()
.imgOriginalName(originalName)
.imgRename(rename)
.imgPath(webPath)
.boardNo(inputBoard.getBoardNo())
.imgOrder(i)
.uploadFile(images.get(i))
.build();
uploadList.add(img);
IMG_ORDER 를 조건으로 해서 수정, 삽입 수행
일치하는 행이 있으면 업데이트 해라 라고 명령을 보내서 행이 없으면 0 이 반환됨
// 1) 기존에 있을 때 -> 새 이미지로 변경하면 -> 수정 성공
result = mapper.updateImage(img);
if(result == 0) {
// 수정 실패 == 기존 해당 순서(IMG_ORDER)에 이미지가 없었음
// 2) 기존에 없었던 애 -> 새 이미지 추가
result = mapper.insertImage(img);
}
}
// 수정 또는 삭제가 실패한 경우
if(result == 0) {
throw new ImageUpdateException(); // 예외 발생 -> 롤백
}
}
// 선택한 파일이 없을 경우
if(uploadList.isEmpty()) {
return result;
}
// 수정, 새 이미지 파일을 서버에 저장
for(BoardImg img : uploadList) {
img.getUploadFile().transferTo(new File(folderPath+img.getImgRename()));
}
return result;
}
mapper.xml
<!-- 게시글 이미지 수정 -->
<update id="updateImage">
UPDATE "BOARD_IMG" SET
IMG_ORIGINAL_NAME = #{imgOriginalName},
IMG_RENAME = #{imgRename}
WHERE BOARD_NO = #{boardNo}
AND IMG_ORDER = #{imgOrder}
</update>
<!-- 게시글 이미지 삽입(1행) -->
<insert id="insertImage">
INSERT INTO "BOARD_IMG"
VALUES(NEXT_IMG_NO(),
#{imgPath},
#{imgOriginalName},
#{imgRename},
#{imgOrder},
#{boardNo}
)
</insert>
사용자 정의 예외 ImageUpdateException 클래스 생성
// 이미지 수정/삽입 중 문제 발생 시 사용할 사용자 정의 예외
public class ImageUpdateException extends RuntimeException {
public ImageUpdateException() {
super("이미지 수정/삽입 중 예외 발생");
}
public ImageUpdateException(String message) {
super(message);
}
}
String message = null;
String path = null;
if(result > 0) {
message = "게시글이 수정되었습니다.";
path = String.format("/board/%d/%d%s", boardCode, boardNo, queryString);
// 마지막 %d 뒤에는 슬래쉬 없음
} else {
message = "수정 실패";
path = "update"; // 수정 화면 전환 상태로 redirect하는 상대 경로
}
ra.addFlashAttribute("message", message);
return "redirect:" + path;
}