Spring Boot Board Project_23 게시글 수정

송지윤·2024년 4월 26일
0

Spring Framework

목록 보기
56/65

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>

1. 수정 버튼 클릭 시 수정페이지로 넘어가게

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

2. 주소 받아줄 Controller 필요

/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) {

2-1. 수정 화면에 출력할 기존의 제목/내용/이미지 조회 후 분기처리

게시글 상세조회 하는 서비스가 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;
	}

3. 수정 html 로 이동했을 때 기존에 들어있던 image 보이게 해주기

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">  

4. 연결된 js 에서 처리

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

5. 수정하고 등록하기 눌렀을 때 요청된 주소 받아줄 Controller

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) {

5-1. 커맨드 객체 setting 해주고 게시글 수정 서비스 호출해주기(반환값은 숫자)

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

6. ServiceImpl 로직 처리

6-1. 게시글 (제목/내용) 부문 수정

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>

6-2. 기존에 이미지가 있었는데 없어진 경우 (삭제된 이미지(deleteOrder)가 있는 경우)

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

예외 발생 시켜서 제목/내용 부분까지 날려줄 거임

6-3. 선택한 파일이 존재할 경우(해당 파일 정보만 모아두는 List 생성)

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

6-4. 업로드하려는 이미지 정보(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);
	}
}

7. Controller로 돌아와 분기처리

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

0개의 댓글