728x90

이전 파일 업로드 글과 연관지어 게시글 작성의 구현을 마무리하고 이를 정리하겠습니다.

파일첨부에 대해서는 언급하지 않겠습니다!

이전 글 : https://black-mint.tistory.com/62?category=982467 

 

[Spring Boot] 파일 첨부 (게시글 이미지 첨부)

게시글 작성에서 파일 첨부하는 방법을 기록한다. (본 글은 이미지 첨부 방법을 다룸) 1. Mysql - file 테이블 CREATE TABLE `tb_file` ( `id` int NOT NULL AUTO_INCREMENT, `board_id` int NOT NULL, `original_..

black-mint.tistory.com

 


 

1. Mysql - 테이블

CREATE TABLE `tb_board` (
  `id` int NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `content` text NOT NULL,
  `writer_id` int NOT NULL,
  `writer` varchar(20) NOT NULL,
  `delete_yn` varchar(1) DEFAULT 'N',
  `create_date` datetime DEFAULT NULL,
  `views` int DEFAULT '0',
  `type` varchar(20) DEFAULT 'board',
  PRIMARY KEY (`id`),
  KEY `writer_id` (`writer_id`),
  CONSTRAINT `tb_board_ibfk_1` FOREIGN KEY (`writer_id`) REFERENCES `tb_userinfo` (`id`) ON DELETE CASCADE
)

 

2. BoardDTO

@Data
public class Board {
    private Long id;
    private String title;
    private String content;
    private Long writerId;
    private String writer;
    private String deleteYN;
    private Timestamp createDate;
    private Long views;
    private String type;
}

 

3. 게시글 작성 Controller

// 게시글 신규 작성 폼 진입 & 기존 게시글 불러오기
@GetMapping("/form")
public String form(Model model, @RequestParam(required = false) Long boardId) {
    if (boardId == null) {
        model.addAttribute("board", new Board());
    } else {
        Board board = boardService.contentLoad(boardId, "board");
        model.addAttribute("board", board);
    }

    return "board/form";
}
  • 게시글 작성, 수정 화면으로 이동하는 GetMapping
  • 기존 게시글을 수정하는 경우 기존 게시글 내용을 추가해주기 위해 Service를 통해 정보를 얻어와 addAttribute를 진행

 

// 게시글 작성 & 수정
@PostMapping("/form")
public String boardSubmit(Board board, Principal principal,
                          @RequestParam(value = "files", required = false) List<MultipartFile> files,
                          @RequestParam(value = "boardId", required = false) Long boardId) throws IOException, SQLException {
    if (board.getTitle().length() < 1 || board.getContent().length() < 1 || (!CollectionUtils.isEmpty(files) && files.size() > 7)) {
        // 잘못된 입력값이 들어왔을 때 다시 해당 페이지로 로딩
        if(boardId != null) {
            return "redirect:/board/form?boardId=" + boardId;
        }
        return "redirect:/board/form";
    }

    String loginUsername = principal.getName();
    String type = "board";

    if (boardId == null) { // 새 글 작성
        Long newBoardId = boardService.save(board, loginUsername, type); // Insert

        // 첨부파일 있을 때
        if (!files.get(0).getOriginalFilename().isEmpty()) {
            for (int i = 0; i < files.size(); i++) {
                if (files.get(i).getContentType().contains("image/")) {
                    fileService.saveFile(files.get(i), newBoardId);
                } else {
                    System.out.println("이미지 타입이 아닙니다");
                }
            }
        }
    } else { // 기존 글 수정
        boardService.update(board, boardId, type); // Update
    }

    return "redirect:/board/list";
}
  • 이 전글에선 @Valid를 사용했지만 이번 글에서는 Javascript를 이용해 View에서 제목과 내용의 유무를 체크할 예정입니다. 물론 첫 줄의 검증 코드로 어느정도 검증을 진행! (Valid를 통해서 값을 검증하니 여러 오류가 발생했어요.. 이에 대한 대체 방안입니다,,,)
  • 작성(create), 수정(update)를 하나의 Controller와 View에서 처리하기 때문에 boardId(게시판 id)가 null인 상태와 null이 아닌 상태를 나눠서 처리했습니다.
  • type : 향후 다른 종류 게시판을 만들거나 공지사항 추가 등 서비스의 확장을 고려해 추가했습니다.

 

4. Service

// id를 이용해서 해당 글 수정
public Board contentLoad(Long id, String type) {
    Board board = new Board();
    board.setId(id);
    board.setType(type);
    try {
        board = boardRepository.findById(board);
    } catch (Exception e) {
        System.out.println("boardService.contentLoad() .. error : " + e.getMessage());
    } finally {
        return board;
    }
}
  • GetMapping에서 사용되는 contentLoad입니다.
// 글 등록
public Long save(Board board, String loginUsername, String type) {
    board.setCreateDate(Timestamp.valueOf(LocalDateTime.now()));

    try {
        Member member = memberService.getMember(loginUsername);

        board.setWriterId(member.getId());
        board.setWriter(member.getUsername());
        board.setType(type);

        boardRepository.insertBoard(board);
    } catch (Exception e) {
        System.out.println("boardService.save() .. error : " + e.getMessage());
    }
    return board.getId();
}
// 글 수정
public Long update(Board board, Long boardId, String type) {
    board.setId(boardId);
    board.setType(type);

    try {
        boardRepository.updateBoard(board);
    } catch (Exception e) {
        System.out.println("boardService.update() .. error : " + e.getMessage());
    }
    return board.getId();
}

 

5. Mapper.xml

<!--특정 아이디 게시글 조회-->
<select id="findById" resultType="Board">
    SELECT
        <include refid="boardColumns"/>
    FROM
        tb_board
    WHERE
        id = #{id} AND type = #{type}
</select>
  • type : board, notice 등으로 조회합니다. (해당 포스트 같은 경우 board입니다.)
  • 만약 공지사항을 조회한다면 notice가 들어갈 것입니다.

 

<!--게시글 작성-->
<insert id="insertBoard" parameterType="Board" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO
         tb_board (title, content, writer_id, writer, create_date, type)
    VALUES
        (#{title}, #{content}, #{writerId}, #{writer}, #{createDate}, #{type})
</insert>

<!--게시글 업데이트-->
<update id="updateBoard" parameterType="Board" useGeneratedKeys="true" keyProperty="id">
    UPDATE
        tb_board
    SET
        title = #{title}
        ,content = #{content}
    WHERE
        id = #{id} AND type = #{type}
</update>
  • useGenratedKeys, keyProperty : insert와 update 시 결과값으로 완료된 row의 pk(id)를 반환받습니다.

 

6. View

			<form action="#" th:action="@{/board/form}" th:object="${board}" method="post" enctype="multipart/form-data"
				onsubmit="return checkForm(this);">
				<input type="hidden" name="boardId" th:value="*{id}">
				<input type="hidden" th:field="*{writerId}">
				<div class="mb-3">
					<label for="title" class="form-label">제목</label>
					<input type="text" class="form-control" id="title" th:field="*{title}">
				</div>
				<div class="mb-3">
					<label for="content" class="form-label">내용</label>
					<textarea class="form-control" id="content" rows="13" th:field="*{content}"></textarea>
				</div>

				<div id="imageThumbnail">
				</div>

				<!-- button -->
				<th:block th:if="${param.boardId} == null">
					<div id="uploadForm">
						<div id="uploadElement">
							<input id="uploadInput" type="file" class="btn btn-outline-primary" name="files"
								accept="image/*" onchange="setThumbnail(event);" multiple /> <span
								style="font-size: small;"> * jpeg / png 타입의
								이미지는
								최대 7개까지
								등록해주세요.</span>
						</div>
						<a id="reset" class="mt-3 btn btn-danger" onclick="resetImg()">초기화</a>
					</div>
				</th:block>


				<div class="nav justify-content-end mb-5">
					<button id="submit" type="submit" class="me-2 btn btn-primary">작성</button>
					<a type="button" class="btn btn-primary" th:href="@{/board/list}">나가기</a>
				</div>
			</form>
  • th:object - form을 제출할 때 설정된 객체로 리턴됩니다. (이 경우 컨트롤러의 Board)
  • th:field - 해당 값(name과 value)을 th:object에서 설정된 객체의 내부 값으로 설정됩니다. (Board.setId, Board.setTitle...등)
  • onsubmit : form 태그의 값을 서버로 전송하기 전에 실행하여 유효성을 체크합니다. 

 

7. form검증을 위한 script (파일 처리부분은 이 전글 참고)

		// form submit 검증
		function checkForm(form) {
			if (form.title.value == "") {
				alert('제목을 입력하세요.');
				form.title.classList.add('is-invalid');
				form.title.focus();
				return false;
			}

			if (form.content.value == "") {
				alert('내용을 입력하세요.');
				form.title.classList.remove('is-invalid');
				form.content.classList.add('is-invalid');
				form.content.focus();
				return false;
			}
			form.content.classList.remove('is-invalid');
			return true;
		}
  • onsubmit에서 this를 통해 받았기 때문에 form.[name]으로 element를 지정가능!
  • 제목이나 내용이 없을 경우 경고 팝업을 띄워주고, 해당 input 또는 textarea의 class에 is-invalid를 추가해줍니다. (bootstrap을 이용해 빨간 input칸으로 변화)
  • false를 return 받는 경우는 서버로 form 값이 제출 안됩니다.

 

8. 시연

 

728x90

파일 첨부를 통해 이미지를 서버에 저장까지 진행했으니, 그 이미지를 게시글에 보여주기 작업을 진행하고 기록한다.

지난 글 : https://black-mint.tistory.com/62

 

[Spring Boot] 파일 첨부 (게시글 이미지 첨부)

게시글 작성에서 파일 첨부하는 방법을 기록한다. (본 글은 이미지 첨부 방법을 다룸) 1. Mysql - file 테이블 CREATE TABLE `tb_file` ( `id` int NOT NULL AUTO_INCREMENT, `board_id` int NOT NULL, `original_..

black-mint.tistory.com

 

게시글을 조회하면 content(내용) 밑에 등록했던 이미지를 행으로 나열할 것이다.


1. 구현 로직

  • 처음 지식이 전무한 상태에서 생각한 방법이 ajax 통신을 통해 이미지정보를 DB에서 List형태로 받아 이미지 경로를 View에 띄워주려했다. (실패)
  • 여러 정보를 탐색했지만 여러 이미지를 리스트 형태로 리턴하는 방법이 없었다. (1번 실패 이유)
  • img태그의 src에 이미지 리턴 주소를 넣어준다면..? (선택!)
  • 최종 로직
    1.  클라이언트에서 게시판ID(PK)를 서버로 전달
    2.  서버에서 해당 게시글에 등록된 첨부파일(이미지) 데이터를 반환
    3.  클라이언트는 서버로부터 받은 데이터를 이용해 img태그의 src속성에 이미지 api 주소를 저장하고 div태그 자식으로 추가!
    4. View에 이미지 보여주기

2. Controller

@RestController
public class FileController {

    @Autowired
    private FileService fileService;

    // 게시글에 첨부된 이미지 ID값 리스트로 반환
    @GetMapping(value = "/images/{boardId}")
    public List<FileDTO> getFileList(@PathVariable(value = "boardId") Long boardId) throws SQLException {
        List<FileDTO> files = fileService.getFileList(boardId);
        return files;
    }

    // 이미지 리턴
    @GetMapping(value = "/image/{imageId}", produces = MediaType.IMAGE_JPEG_VALUE)
    public ResponseEntity<Resource> getViewImage(@PathVariable(value = "imageId") Long imageId) throws SQLException {
        FileDTO fileDTO = fileService.getFile(imageId);
        Resource resource = new FileSystemResource(fileDTO.getPath());
        return new ResponseEntity<Resource>(resource, HttpStatus.OK);
    }
}
  • getFileList를 통해 게시글에 등록된 첨부파일(이미지)의 정보를 리턴받는다 (최종로직의 1, 2번에 해당)
  • getViewImage를 통해 이미지를 리턴 (최종로직의 3번의 일부에 해당)

3. Service

@Service
public class FileService {

    private final FileMapper fileRepository;

    @Autowired
    public FileService(FileMapper fileMapper) {
        this.fileRepository = fileMapper;
    }

    // BoardId로 File리스트 반환
    public List<FileDTO> getFileList(Long boardId) throws SQLException {
        List<FileDTO> fileList = fileRepository.selectByBoardId(boardId);
        return fileList;
    }

    // ImageID로 FileDTO 반환
    public FileDTO getFile(Long id) throws SQLException {
        FileDTO fileDTO = fileRepository.selectByFileId(id);
        return fileDTO;
    }
}
  • getFileList : FileDTO 타입의 List를 반환
  • getFile : 컨트롤러의 getViewImage에서 단일 이미지를 다루기 때문에 불러오고자 하는 이미지 데이터를 DB에서 조회하여 fileDTO객체 하나만을 리턴

4. View (상세 글 페이지 일부)

							.
							.
							.
                    <h2>제목 : <span th:text="${board.title}">글 제목</span></h2>
                </div>
                <hr />
                <div class="mb-3">
                    <textarea type="text" class="form-control" th:text="${board.content}" rows="13"
                        style="background-color: white;" readonly>내용</textarea>
                </div>

                <div id="image">
                </div>

                <!-- Comment -->
                <h4>댓글<span>(<span id="count"></span>)</span></h4>
                <div class="mb-5">
                    <div id="comment">
                    </div>
							.
							.
							.
  • 위는 글 제목, 내용을 나타내고 아래는 이전에 다뤘던 댓글이 나타나는 곳!
  • <div id="image"></div> 에 자바스크립트를 통해 등록된 이미지를 추가 할 예정

5. Script (이미지 로드 부분)

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
    <script th:inline="javascript">
        $(document).ready(function () {
            getImgId() // 이미지 불러오기
            getComments() // 댓글 불러오기
        })

        // 해당 게시글에 등록된 이미지 ID를 리스트로 불러오기
        function getImgId() {
            var boardId = $('input[name=boardId]').val()
            var url = '/images/' + boardId
            var imgId = []

            $.ajax({
                type: 'GET',
                url: url,
                success: function (response) {
                    var idx = 0;

                    $.each(response, function (key, value) {
                        imgId[idx] = value.id
                        idx++
                    })
                    getImg(imgId)
                },
                error: function (response) {
                    console.log("이미지 ID 로딩 실패")
                }
            })
        }

        // 불러온 이미지 ID로 이미지 src요청
        function getImg(imgId) {
            var boardId = $('input[name=boardId]').val()
            var url = ''
            var a = ''

            for (var i = 0; i < imgId.length; i++) {
                url = '/image/' + imgId[i]
                a += '<img class="img-thumbnail mb-3 me-3" style="width: 200px; height: 200px" src="' + url + '" onclick="window.open(this.src)"/>'
            }
            $('#image').html(a)
        }
    </script>

페이지 로드가 완료되면 getImgId() 함수 실행

  • ajax 통신을 통해 서버의 getFileList를 실행
  • imgId 배열을 생성하고, ajax 통신에 성공하면 List의 FileDTO getId값을 배열에 초기화
  • value.id : 서버에서의 FileDTO.getId

getImgId()함수를 통해 배열에 이미지 id값이 모두 저장됐으면 getImg함수를 실행

  • img 태그를 생성하고 src에 서버의 getViewImage를 호출하는 url 값을 넣어준다.
  • 최종적으로 $('#image').html(a)를 통해 image태그 자식으로 img태그를 추가한다.

6. 결과

추가된 이미지

이 전 글에서 테스트로 추가한 이미지들이 보여진다. (귀엽다)

 

 

 

 

 

 

 

* [스프링에서 이미지를 다루는 방법] 참고 : https://www.baeldung.com/spring-mvc-image-media-data

 

728x90

게시글 작성에서 파일 첨부하는 방법을 기록한다. (본 글은 이미지 첨부 방법을 다룸)

 


1. Mysql - file 테이블

CREATE TABLE `tb_file` (
  `id` int NOT NULL AUTO_INCREMENT,
  `board_id` int NOT NULL,
  `original_file_name` varchar(100) NOT NULL,
  `size` int NOT NULL,
  `path` varchar(100) NOT NULL,
  `stored_file_name` varchar(100) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `board_id` (`board_id`),
  CONSTRAINT `tb_file_ibfk_1` FOREIGN KEY (`board_id`) REFERENCES `tb_board` (`id`) ON DELETE CASCADE
)
  • orginal_file_name : 사용자가 저장한 원본 파일이름
  • size : KB단위의 파일의 용량
  • path : 파일이 저장된 위치
  • stred_file_name : 서버에 저장된 uuid로 중복을 제거한 이름

2. FileDTO

@Data
public class FileDTO {
    private Long id;
    private Long boardId;
    private String originalFileName;
    private Long size;
    private String path;
    private String storedFileName;

    public FileDTO(Long boardId, String originalFileName, String storedFileName, Long size, String path) {
        this.boardId = boardId;
        this.originalFileName = originalFileName;
        this.storedFileName = storedFileName;
        this.size = size;
        this.path = path;
    }
}

3. 게시글 작성 Controller

    // 게시글 작성 & 수정
    @PostMapping("/form")
    public String boardSubmit(@Valid Board board, BindingResult bindingResult, Principal principal,
                              @RequestParam(value = "files", required = false) List<MultipartFile> files, Long id) throws IOException, SQLException {
        if (bindingResult.hasErrors() || files.size() > 7) {
            return "board/form";
        }

        String loginUsername = principal.getName();
        Long newBoardId = 0l;

        if (id == null) { // 새 글 작성
            newBoardId = boardService.save(board, loginUsername); // Insert

            // 첨부파일 있을 때
            if(!files.get(0).getOriginalFilename().isEmpty()) {
                for(int i = 0; i < files.size(); i ++) {
                    if(files.get(i).getContentType().contains("image/")) {
                        fileService.saveFile(files.get(i), newBoardId);
                    } else {
                        System.out.println("이미지 타입이 아닙니다");
                    }
                }
            }
        } else { // 기존 글 수정
            boardService.update(board, id); // Update
        }

        return "redirect:/board/list";
    }
  • @Valid : 게시글 작성 중에 제목과 내용의 제약조건(제목 2글자 이상 등)을 지키지 않으면 첫 줄의 if문을 통해 게시글 작성 홈페이지로 다시 돌아간다.
  • !files.get(0).getOriginalFilename().isEmpty() : MultipartFile 타입의 List의 첫 값을 통해 첨부파일의 존재 여부를 확인
  • getContentType을 통해 첨부파일의 형태가 IMAGE인지 확인 후 저장 로직 실행
  • @RequestParam(value = "files", required = false) : 값이 필수가 아닐 때 required = false 작성. (안적어주면 파일 첨부하지 않을 때 오류 Bad request 400 오류 발생)

4. Service

@Service
public class FileService {

    private final FileMapper fileRepository;

    @Autowired
    public FileService(FileMapper fileMapper) {
        this.fileRepository = fileMapper;
    }

    // 게시글 작성 & 수정에서 첨부파일 추가
    public void saveFile(MultipartFile file, Long boardId) throws IOException, SQLException {
        String uuid = UUID.randomUUID().toString(); // 파일명 중복 제거를 위한 uuid
        String originName = file.getOriginalFilename(); // 파일 원본명
        Long fileSize = file.getSize() / 1024; // kb

        String path = "C:/Temp/";
        String newName = "";

        if (originName.lastIndexOf(".") < 0) {
            newName = uuid + originName; // 확장자명이 없을 때
        } else {
            newName = uuid + StringUtils.substring(originName, originName.lastIndexOf(".")); //확장자명 포함
        }

        FileDTO fileDTO = new FileDTO(boardId, originName, newName, fileSize, path + newName);
        int result = fileRepository.insertFile(fileDTO);

        if (result > 0 && !file.getOriginalFilename().isEmpty()) {
            file.transferTo(new File(path + newName));
        }
    }
}
  • UUID를 이용해서 서버에 저장되는 파일명의 중복을 막는다.
  • StringUtils.substring을 이용해 확장자를 포함시켜 저장
  • result값을 받아서 insert가 바르게 진행 됐는지 확인 (바르게 저장 시 1 리턴)
  • MultipartFile.transferTo : if문 조건(result값과 MultipartFile의 null 체크)을 만족하면 서버로 첨부파일(이미지) 저장

5. View

    <main class="flex-shrink-0">
        <div class="container mt-3">
            <h2>게시판</h2>
            <form action="#" th:action="@{/board/form}" th:object="${board}" method="post"
                enctype="multipart/form-data">
                <input type="hidden" th:field="*{id}">
                <input type="hidden" th:field="*{writerId}">
                <div class="mb-3">
                    <label for="title" class="form-label">Title</label>
                    <input type="text" class="form-control"
                        th:classappend="${#fields.hasErrors('title')} ? 'is-invalid'" id="title" th:field="*{title}">
                    <div th:if="${#fields.hasErrors('title')}" th:errors="*{title}" id="validationServer03Feedback"
                        class="invalid-feedback">
                        Title Error
                    </div>
                </div>
                <div class="mb-3">
                    <label for="content" class="form-label">content</label>
                    <textarea class="form-control" id="content" rows="13"
                        th:classappend="${#fields.hasErrors('content')} ? 'is-invalid'"
                        th:field="*{content}"></textarea>
                    <div th:if="${#fields.hasErrors('content')}" th:errors="*{content}" id="validationServer03Feedback"
                        class="invalid-feedback">
                        Content Error
                    </div>
                </div>

                <div id="imageThumbnail">
                </div>

                <!-- button -->
                <div id="uploadForm" th:if="${param.boardId == null}">
                    <div id="uploadElement">
                        <input id="uploadInput" type="file" class="btn btn-outline-primary" name="files" accept="image/*"
                            onchange="setThumbnail(event);" multiple /> <span style="font-size: small;"> * jpeg / png 타입의
                            이미지를
                            7개까지
                            등록해주세요.</span>
                    </div>
                    <a id="reset" class="mt-3 btn btn-danger" onclick="resetImg()">Reset</a>
                </div>
                
                <div class="nav justify-content-end mb-5">
                    <button id="submit" type="submit" class="me-2 btn btn-primary">write</button>
                    <a type="button" class="btn btn-primary" th:href="@{/board/list}">exit</a>
                </div>
            </form>
        </div>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
        integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
        crossorigin="anonymous"></script>
  • th:if를 이용해서 새로운 글 작성 할 때만 이미지 첨부를 가능하게 제약조건 추가 (글 수정으로 글 작성 form으로 진입하는 경우 첨부파일 버튼을 html에서 제거)
  • <form> : enctype 속성을 "multipart/form-data" 로 설정해줘야 파일을 컨트롤러로 전달 가능
  • <div> : id="imageThumbnail"  <-- 여기에 첨부된 파일의 미리보기를 불러올 예정 (썸네일)
  • <input type="file" accept="image/*" multiple/> : accept로 첨부파일 선택 시 모든파일 -> image타입의 파일을 선택하도록 유도. multiple을 사용해야 다중 파일 선택 가능

6. script

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script>
        document.getElementById('submit').addEventListener('click', imageCounting)

        // 첨부 이미지 리셋 버튼
        function resetImg() {
            var fileUpload = document.getElementById('uploadInput')
            var img = document.querySelectorAll('img')
            fileUpload.value = null
            $('#imageThumbnail').children().remove()
        }

        // 이미지 개수 제한
        function imageCounting() {
            var fileUpload = $("input[type='file']")
            if (parseInt(fileUpload.get(0).files.length) > 7) {
                alert("이미지는 최대 7개까지 등록 가능합니다.")
            }
        }

        // 첨부 이미지 미리보기
        function setThumbnail(event) {
            for (var image of event.target.files) {
                var reader = new FileReader()

                reader.onload = function (event) {
                    var img = document.createElement("img")
                    img.className = "img-thumbnail mb-3 me-3"
                    img.name = "thumbnail"
                    img.style = "inline"
                    img.width = 200
                    img.height = 200
                    img.setAttribute("src", event.target.result)
                    document.querySelector("div#imageThumbnail").appendChild(img)
                }
                reader.readAsDataURL(image)
            }
        }
    </script>

 - resetImg (첨부 이미지 리셋, 첨부파일 리셋)

  • fileUpload.value = null 을 통해 지금까지 불러온 첨부파일 값을 null로 설정
  • $('#imageThumbnail').children().remove() : imageThumbnail 아이디의 태그 자식태그를 제거

 - imgCounting (첨부 개수제한)

  • $("input[type='file']") 를 이용해서 타입이 file인 input 태그를 가져와 저장

 - setThumbnail

  • FileReader를 이용해서 이미지 미리보기(썸네일) 정보 셋팅

7. 결과

테스트 전 file테이블 상태

 

 

시연

 

* 왜인지 모르겠으나 녹화할 때 파일 선택창이 안나와서 추가!

시연 후 MySQL 테이블
시연 후 추가된 파일들

8. 게시글 작성, 수정 (일부 코드 수정) : https://black-mint.tistory.com/70

 

[Spring Boot] 게시글 작성, 수정

이전 파일 업로드 글과 연관지어 게시글 작성의 구현을 마무리하고 이를 정리하겠습니다. 파일첨부에 대해서는 언급하지 않겠습니다! 이전 글 : https://black-mint.tistory.com/62?category=982467 [Spring Boot].

black-mint.tistory.com


6번 - setThumbnail 참고 : https://sinna94.tistory.com/entry/JavaScript-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C-%EB%AF%B8%EB%A6%AC%EB%B3%B4%EA%B8%B0-%EB%A7%8C%EB%93%A4%EA%B8%B0

728x90

게시판 첨부파일(이미지) 불러오기를 구현 중 만난 오류를 기록한다.

 


1. 테이블

CREATE TABLE `tb_file` (
  `id` int NOT NULL AUTO_INCREMENT,
  `board_id` int NOT NULL,
  `original_file_name` varchar(100) NOT NULL,
  `stored_file_name` varchar(100) NOT NULL,
  `size` int NOT NULL,
  `path` varchar(100) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `board_id` (`board_id`),
  CONSTRAINT `tb_file_ibfk_1` FOREIGN KEY (`board_id`) REFERENCES `tb_board` (`id`) ON DELETE CASCADE
)
  • id : 첨부파일 id
  • board_id : 첨부파일이 등록된 게시판 id
  • original_file_name : 첨부파일의 원래 이름 (확장자 포함)
  • stored_file_name : 첨부파일이 저장될 때 이름 (uuid로 중복제거한 이름)
  • size : 크기
  • path : 저장된 주소

2. FileMapper.xml

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.min.board.repository.mapper.FileMapper">

    <sql id="fileColumns">
        id
        ,board_id
        ,original_file_name
        ,stored_file_name
        ,size
        ,path
    </sql>

    <!-- 이미지 불러오기 -->
    <select id="selectByBoardId" resultType="FileDTO">
        SELECT
            <include refid="fileColumns"/>
        FROM
            tb_file
        WHERE
            board_id = #{boardId}
    </select>
</mapper>

3. FileDTO

@Data
public class FileDTO {
    private Long id;
    private Long boardId;
    private String originalFileName;
    private String storedFileName;
    private Long size;
    private String path;

    public FileDTO(Long boardId, String originalFileName, String storedFileName, Long size, String path) {
        this.boardId = boardId;
        this.originalFileName = originalFileName;
        this.storedFileName = storedFileName;
        this.size = size;
        this.path = path;
    }
}

4. 오류 내용

4.1 java.lang.NumberFormatException: For input string "컬럼데이터"

org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.executor.result.ResultMapException: Error attempting to get column 'stored_file_name' from result set.  Cause: java.lang.NumberFormatException: For input string: "a48e"

	at org.mybatis.spring.MyBatisExceptionTranslator.translateExceptionIfPossible(MyBatisExceptionTranslator.java:96)
	at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:441)
	at com.sun.proxy.$Proxy84.selectList(Unknown Source)
	at org.mybatis.spring.SqlSessionTemplate.selectList(SqlSessionTemplate.java:224)
	at org.apache.ibatis.binding.MapperMethod.executeForMany(MapperMethod.java:147)
	at org.apache.ibatis.binding.MapperMethod.execute(MapperMethod.java:80)
	at org.apache.ibatis.binding.MapperProxy$PlainMethodInvoker.invoke(MapperProxy.java:145)
	at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:86)
	at com.sun.proxy.$Proxy90.selectByBoardId(Unknown Source)
	at com.min.board.service.FileService.getFileList(FileService.java:50)
	at com.min.board.service.FileServiceTest.getFileListTest(FileServiceTest.java:23)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:566)
	at org.junit.platform.commons.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:688)
	at org.junit.jupiter.engine.execution.MethodInvocation.proceed(MethodInvocation.java:60)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain$ValidatingInvocation.proceed(InvocationInterceptorChain.java:131)
	at org.junit.jupiter.engine.extension.TimeoutExtension.intercept(TimeoutExtension.java:149)
	at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestableMethod(TimeoutExtension.java:140)
	at org.junit.jupiter.engine.extension.TimeoutExtension.interceptTestMethod(TimeoutExtension.java:84)
	at org.junit.jupiter.engine.execution.ExecutableInvoker$ReflectiveInterceptorCall.lambda$ofVoidMethod$0(ExecutableInvoker.java:115)
	at org.junit.jupiter.engine.execution.ExecutableInvoker.lambda$invoke$0(ExecutableInvoker.java:105)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain$InterceptedInvocation.proceed(InvocationInterceptorChain.java:106)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain.proceed(InvocationInterceptorChain.java:64)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain.chainAndInvoke(InvocationInterceptorChain.java:45)
	at org.junit.jupiter.engine.execution.InvocationInterceptorChain.invoke(InvocationInterceptorChain.java:37)
	at org.junit.jupiter.engine.execution.ExecutableInvoker.invoke(ExecutableInvoker.java:104)
	at org.junit.jupiter.engine.execution.ExecutableInvoker.invoke(ExecutableInvoker.java:98)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$invokeTestMethod$6(TestMethodTestDescriptor.java:210)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.invokeTestMethod(TestMethodTestDescriptor.java:206)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:131)
	at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.execute(TestMethodTestDescriptor.java:65)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$5(NodeTestTask.java:139)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$7(NodeTestTask.java:129)
	at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:127)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:126)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1541)
	at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$5(NodeTestTask.java:143)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$7(NodeTestTask.java:129)
	at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:127)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:126)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1541)
	at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$5(NodeTestTask.java:143)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$7(NodeTestTask.java:129)
	at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:127)
	at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:126)
	at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:84)
	at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:32)
	at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57)
	at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:51)
	at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:108)
	at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:88)
	at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:54)
	at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:67)
	at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:52)
	at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:96)
	at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:75)
	at com.intellij.junit5.JUnit5IdeaTestRunner.startRunnerWithArgs(JUnit5IdeaTestRunner.java:71)
	at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33)
	at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:235)
	at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:54)
Caused by: org.apache.ibatis.executor.result.ResultMapException: Error attempting to get column 'stored_file_name' from result set.  Cause: java.lang.NumberFormatException: For input string: "a48e"
	at org.apache.ibatis.type.BaseTypeHandler.getResult(BaseTypeHandler.java:87)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createUsingConstructor(DefaultResultSetHandler.java:711)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createByConstructorSignature(DefaultResultSetHandler.java:694)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createResultObject(DefaultResultSetHandler.java:658)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.createResultObject(DefaultResultSetHandler.java:631)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.getRowValue(DefaultResultSetHandler.java:398)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleRowValuesForSimpleResultMap(DefaultResultSetHandler.java:355)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleRowValues(DefaultResultSetHandler.java:329)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleResultSet(DefaultResultSetHandler.java:302)
	at org.apache.ibatis.executor.resultset.DefaultResultSetHandler.handleResultSets(DefaultResultSetHandler.java:195)
	at org.apache.ibatis.executor.statement.PreparedStatementHandler.query(PreparedStatementHandler.java:65)
	at org.apache.ibatis.executor.statement.RoutingStatementHandler.query(RoutingStatementHandler.java:79)
	at org.apache.ibatis.executor.SimpleExecutor.doQuery(SimpleExecutor.java:63)
	at org.apache.ibatis.executor.BaseExecutor.queryFromDatabase(BaseExecutor.java:325)
	at org.apache.ibatis.executor.BaseExecutor.query(BaseExecutor.java:156)
	at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:109)
	at org.apache.ibatis.executor.CachingExecutor.query(CachingExecutor.java:89)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:151)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:145)
	at org.apache.ibatis.session.defaults.DefaultSqlSession.selectList(DefaultSqlSession.java:140)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:566)
	at org.mybatis.spring.SqlSessionTemplate$SqlSessionInterceptor.invoke(SqlSessionTemplate.java:427)
	... 74 more
Caused by: java.lang.NumberFormatException: For input string: "a48e"
	at java.base/jdk.internal.math.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2054)
	at java.base/jdk.internal.math.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
	at java.base/java.lang.Double.parseDouble(Double.java:543)
	at com.mysql.cj.protocol.a.MysqlTextValueDecoder.getDouble(MysqlTextValueDecoder.java:249)
	at com.mysql.cj.result.AbstractNumericValueFactory.createFromBytes(AbstractNumericValueFactory.java:56)
	at com.mysql.cj.protocol.a.MysqlTextValueDecoder.decodeByteArray(MysqlTextValueDecoder.java:143)
	at com.mysql.cj.protocol.result.AbstractResultsetRow.decodeAndCreateReturnValue(AbstractResultsetRow.java:135)
	at com.mysql.cj.protocol.result.AbstractResultsetRow.getValueFromBytes(AbstractResultsetRow.java:243)
	at com.mysql.cj.protocol.a.result.ByteArrayRow.getValue(ByteArrayRow.java:91)
	at com.mysql.cj.jdbc.result.ResultSetImpl.getObject(ResultSetImpl.java:1321)
	at com.mysql.cj.jdbc.result.ResultSetImpl.getLong(ResultSetImpl.java:848)
	at com.mysql.cj.jdbc.result.ResultSetImpl.getLong(ResultSetImpl.java:854)
	at com.zaxxer.hikari.pool.HikariProxyResultSet.getLong(HikariProxyResultSet.java)
	at org.apache.ibatis.type.LongTypeHandler.getNullableResult(LongTypeHandler.java:37)
	at org.apache.ibatis.type.LongTypeHandler.getNullableResult(LongTypeHandler.java:26)
	at org.apache.ibatis.type.BaseTypeHandler.getResult(BaseTypeHandler.java:85)
	... 98 more

4.2 해결 방법을 시도하다가 만난 오류 - Cause: java.sql.SQLDataException: Cannot determine value type from string "컬럼 데이터" (캡처 못함)

5. 해결 시도

왜 String(JAVA) 타입에 VARCHAR 타입의 DB 데이터를 넣는데 NumberFormatException이 뜨는가..?>??!!?!?!

정말 여러 시도, 검색을 해봤지만 다른 오류(4.2)를 다시 가져올뿐이고, 검색에서 나오는 내용은 전혀 관련없는 Mapper.xml의 if문을 다루는 이야기 뿐이었다. (나는 if문을 쓰지 않았는데 말이다 ㅠ)

 

Mapper.xml의 resultType을 resultMap으로 바꿔봤다.

MySQL의 컬럼을 삭제하고 다시 생성도 해봤고, 컬럼명을 변경도 해봤다.

MySQL의 컬럼 type을 바꿔보기도 하고 사이즈 크기도 변경하는 등 여러 시도를 반복했지만 해결하지 못했다.

 

그러던중 혹시 original 이름과 uuid로 변형한 이름인 stored 이름의 컬럼을 떨어뜨려놔볼까? 라는 생각을 해봤다. (많은 시간을 오류와 싸우다 정신이 나간 듯)

6. 해결 방법

기존 [1, 2, 3]번의 코드 상태를 다음과 같이 바꿨다.

<?xml version="1.0" encoding="UTF-8"?>

<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.min.board.repository.mapper.FileMapper">

    <sql id="fileColumns">
        id
        ,board_id
        ,original_file_name
        ,size
        ,path
        ,stored_file_name
    </sql>

    <!-- 이미지 불러오기 -->
    <select id="selectByBoardId" resultType="FileDTO">
        SELECT
            <include refid="fileColumns"/>
        FROM
            tb_file
        WHERE
            board_id = #{boardId}
    </select>
</mapper>

 

 

@Data
public class FileDTO {
    private Long id;
    private Long boardId;
    private String originalFileName;
    private Long size;
    private String path;
    private String storedFileName;

    public FileDTO(Long boardId, String originalFileName, String storedFileName, Long size, String path) {
        this.boardId = boardId;
        this.originalFileName = originalFileName;
        this.storedFileName = storedFileName;
        this.size = size;
        this.path = path;
    }
}

 

 

CREATE TABLE `tb_file` (
  `id` int NOT NULL AUTO_INCREMENT,
  `board_id` int NOT NULL,
  `original_file_name` varchar(100) NOT NULL,
  `size` int NOT NULL,
  `path` varchar(100) NOT NULL,
  `stored_file_name` varchar(100) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `board_id` (`board_id`),
  CONSTRAINT `tb_file_ibfk_1` FOREIGN KEY (`board_id`) REFERENCES `tb_board` (`id`) ON DELETE CASCADE
)
  • 달라진 점 : 기존 코드는 original_file_name(originalFileName) 옆에 stored_file_name(storedFileName)가 위치해 있었다. 그러나 달라진 코드에서는 original_file_name에서 2칸 떨어진 위치에 stored_file_name이 위치해있다.

놀랍게도 이렇게 했더니 오류가 해결됐다!

728x90

첨부파일 관련 기능 구현 중 게시글 insert와 update 시 auto increment되는 게시글 번호를(PK) 가져와서 활용하려한다.

Mybatis에서 insert나 update 시 PK 값을 가져오는 방법을 기록한다.

 


1. Mapper 인터페이스

@Mapper
public interface BoardMapper {
	// 글 작성
	void insertBoard(Board board);
}
  • 파라미터로 받아진 Board 객체의 필드에 PK 값이 자동으로 들어간다. 때문에 void로 작성 가능

2. Mapper.xml

    <!--게시글 작성-->
    <insert id="insertBoard" parameterType="Board" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO
            tb_board (title, content, writer_id, writer, create_date)
        VALUES
            (#{title}, #{content}, #{writerId}, #{writer}, #{createDate})
    </insert>
  • useGeneratedKeys와 keyProperty (PK)를 추가해주면 insert, update 시 자동 증가하는 PK 값을 Board의 필드 안에 자동 주입해준다.
728x90

휴지통 삭제, 복원을 구현하는 방법으로 ajax 통신을 사용하기로 한다.

이 때 체크박스 체크된 게시글만 삭제, 복원 가능하도록 하려고한다.

 -> 리스트로 boardId를 서버로 보내서 처리하도록 한다!


1. Controller

    // 휴지통 게시글 복원
    @PatchMapping("/trash")
    @ResponseBody
    public void restoreBoards(@RequestParam(name = "boardIdList[]", required = false) List<String> boardIdList) {
        for(int i = 0; i < boardIdList.size(); i ++) {
            boardService.restore(Long.parseLong(boardIdList.get(i)));
        }
    }

    // 휴지통 게시글 영구 삭제
    @DeleteMapping("/trash")
    @ResponseBody
    public void clearBoards(@RequestParam(name = "boardIdList[]", required = false) List<String> boardIdList) {
        for(int i = 0; i < boardIdList.size(); i ++) {
            boardService.clear(Long.parseLong(boardIdList.get(i)));
        }
    }
  • @RequestParam(name = "boardIdList[]") : 배열을 List로 받을 것이기 때문에 변수 끝에 대괄호([])를 붙여준다.

2. View

            <!-- contents -->
            <form class="row g-3 justify-content-end">
                <table class="table caption-top table-bordered table-hover">
                    <caption>List</caption>
                    <thead>
                        <tr>
                            <th class="text-center" width="50" scope="col"></th>
                            <th class="text-center" width="50" scope="col">No</th>
                            <th class="text-center" width="950" scope="col">제목</th>
                            <th class="text-center" width="200" scope="col">작성일</th>
                            <th class="text-center" width="180" scope="col">작성자</th>
                        </tr>
                    </thead>

                    <tbody>
                        <tr th:each="board : ${boardList}">
                            <td class="mt-5 text-center" scope="row">
                                <div class="checkbox">
                                    <input type="checkbox" name="boardIdList" th:value="${board.id}">
                                </div>
                            </td>
                            <td class="mt-5 text-center" scope="row" th:text="${board.id}">1</td>
                            <td><a th:text="${board.title}" th:href="@{/board/post(boardId=${board.id})}"
                                    style="text-decoration:none; color:black;">제목</a></td>
                            <td class="text-center" th:text="${#dates.format(board.createDate, 'yyyy/MM/dd HH:mm')}">작성일
                            </td>
                            <td class="text-center" th:text="${board.writer}">작성자</td>
                        </tr>
                    </tbody>
                </table>
                <div class="nav justify-content-end">
                    <button type="submit" class="btn btn-primary me-2" onclick="restoreBoards()">복원하기</button>
                    <button type="submit" class="btn btn-danger" onclick="deleteBoards()">삭제하기</button>
                </div>
            </form>
  • form 태그안에 버튼을 생성하고 각 버튼의 onclick을 등록한다.

3. ajax(script코드)

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script th:inline="javascript">
        // 게시글 복원
        function restoreBoards() {
            var boardIdList = []

            $('input[name="boardIdList"]:checked').each(function (i) {
                boardIdList.push($(this).val());
            });

            if (!isEmptyArr(boardIdList)) {
                var param = { "boardIdList": boardIdList }

                $.ajax({
                    url: "/trash",
                    type: "PATCH",
                    data: param,
                    async: false,
                    success: function (response) {
                        alert('복원 됐습니다.')
                        location.reload()
                    },
                    error: function (response) {
                        alert('복원에 실패했습니다.')
                    }

                })
            } else {
                alert('복원할 게시글을 선택해주세요.')
            }
        }

        // 게시글 삭제
        function deleteBoards() {
            var boardIdList = []

            $('input[name="boardIdList"]:checked').each(function (i) {
                boardIdList.push($(this).val());
            });

            if (!isEmptyArr(boardIdList)) {
                var param = { "boardIdList": boardIdList }

                $.ajax({
                    url: "/trash",
                    type: "DELETE",
                    data: param,
                    async: false,
                    success: function (response) {
                        alert('영구 삭제 됐습니다.')
                        location.reload()
                    },
                    error: function (response) {
                        alert('영구 삭제 실패했습니다.')
                    }

                })
            } else {
                alert('삭제할 게시글을 선택해주세요.')
            }

        }

        // 빈배열 체크
        function isEmptyArr(arr) {
            if (Array.isArray(arr) && arr.length === 0) {
                return true;
            }

            return false;
        }
    </script>
  • var boardIdList = [] : 자바스크립트 배열 선언
  • $('input[name="boardIdList"]:checked').each(function (i) {  boardIdList.push($(this).val());  }); 
    • 체크박스에 체크된 개수만큼 each로 반복.
    • push를 통해 값을 배열에 초기화
  • isEmptyArr : 배열이 비었는지 체크

4. 결과

시연

 

+ 3번 스크립트 코드 수정!

만약 다중 게시글을 삭제하는데 팝업이 뜨지 않는다면? 당연하다.. 비동기적 통신으로 ajax를 실행해서 그렇다.

이를 해결하려면 동기적으로 ajax 통신을 사용해야하는데, async: false를 ajax 코드에 추가해주면된다.


* ajax 사용하기 : https://black-mint.tistory.com/35

 

[Spring Boot] Ajax(비동기) 통신으로 댓글 구현 (+ Jquery 사용법)

게시판 댓글을 구현하는 도중 비동기 호출에 대해 알게 됐고, 이를 이용하려면 Ajax를 활용해야 한다는 정보를 얻었다. 비동기란? 비동기의 반대인 동기적 통신의 경우 절차적으로 일을 차례로

black-mint.tistory.com

 

728x90

저번에 작성한 댓글 기능 구현 코드를 REST API 규칙에 맞게 수정하도록한다.

 

* 이전 댓글 구현 글

https://black-mint.tistory.com/35

 

[Spring Boot] Ajax(비동기) 통신으로 댓글 구현 (+ Jquery 사용법)

게시판 댓글을 구현하는 도중 비동기 호출에 대해 알게 됐고, 이를 이용하려면 Ajax를 활용해야 한다는 정보를 얻었다. 비동기란? 비동기의 반대인 동기적 통신의 경우 절차적으로 일을 차례로

black-mint.tistory.com


1. REST(Representational State Transfer) API 란?

  • 네트워크 아키텍처 원리의 모음
  • 네트워크 아키텍처 : 자원을 정의하고 자원에 대한 주소를 지정하는 방법 전반을 의미
  • 예를 들어 학생

2. REST API 설계

  • URI는 정보의 자원을 표현
  • 자원에 대한 행위로 HTTP Method(GET, POST, PUT, PATCH, DELETE)로 표현
GET 데이터를 조회 (게시글 조회) GET /boards/1
게시글 중 1번 게시글을 조회
POST 데이터를 등록 (게시글 작성) POST /boards
게시글 등록
PATCH 데이터를 일부 수정 (게시글 수정) PATCH / boards/1
게시글 중 1번 게시글을 수정
PUT 데이터를 전체적으로 수정 PUT /boards/1
게시글 중 1번 게시글을 수정
DELETE 데이터를 삭제 DELETE /boards/1
게시글 중 1번 게시글을 삭제

3. REST API 규칙

  • 소문자로 작성하고 밑줄(_) 대신 하이픈(-)을 사용
  • 동사가 아닌 명사로 작성
  • 슬래시 구분자(/)는 계층 관계를 나타낼 때 사용하고 URI 마지막에 쓰지 않는다.
  • URI에 파일 확장자는 작성하지 않는다.

이 외 규칙이 많지만 이러한 2~3번 규칙을 잘 지켜서 만든 API를 RESTful API라고 한다.


4. 컨트롤러

@RequestMapping("/comments")
@RestController
public class CommentController {

    @Autowired
    private CommentService commentService;

    // 댓글 조회
    @GetMapping("/{boardId}")
    public List<Comment> getCommentList(@PathVariable(value = "boardId") Long boardId) throws Exception {
        List<Comment> comments = commentService.getCommentList(boardId);
        return comments;
    }

    // 댓글 작성
    @PostMapping("/{boardId}")
    public void commentWrite(@PathVariable(value = "boardId") Long boardId,
                             @RequestBody Comment comment,
                               Principal principal) throws Exception {
        String username = principal.getName();
        commentService.write(boardId, comment.getContent(), username);
    }

    // 댓글 수정
    @PatchMapping("/{commentId}")
    public void updateComment(@PathVariable(value = "commentId") Long commentId,
                              @RequestBody Comment comment) throws Exception {
        commentService.update(commentId, comment.getContent());
    }

    // 댓글 삭제
    @DeleteMapping("/{commentId}")
    public void deleteComment(@PathVariable(value = "commentId") Long commentId) throws Exception {
        commentService.delete(commentId);
    }
  • @RestController : 클래스 레벨에 선언하면 이전에 각 매핑마다 @ResponseBody 선언 했던 것을 하지 않아도 된다.
  • @ResponseBody : 해당 어노테이션이 선언된 매핑은 HTTP의 BODY에 자바 객체를 직접 반환한다.
  • @RequestBody : View에서 받아오는 JSON데이터를 파싱해준다. (변환할 데이터를 매핑 가능하도록 필드명 맞춰줘야함) 단, Content-Type이 multipart/form-data로 전달받을 때는 오류를 일으킨다. (주로 파일, 이미지를 전달받을 때)
  • @PathVariable : REST방식에서 주로 사용하며, @RequestParam과 유사. URI에 전달받을 변수를 지정

5. Ajax 통신 스크립트 코드

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.24.0/moment.min.js"></script>
    <script th:inline="javascript">
        $(document).ready(function () {
            getComments()
        })

        function getComments() {
            var loginUsername = [[${ loginUser }]]
            var boardId = $('input[name=boardId]').val()
            var url = '/comments/' + boardId

            $.ajax({
                type: 'GET',
                url: url,
                success: function (response) {
                    var a = ''
                    var size = 0
                    $.each(response, function (key, value) {
                        size = size + 1;
                        a += '<hr /><div>'
                        a += '<input type="hidden" id="commentId" name="commentId" value="' + value.id + '">'
                        a += '<span id="writer" style="font-weight: bold;">' + value.writer + '</span>'
                        if (value.writer == loginUsername) {
                            a += '<ul name="commentChange" class="justify-content-end" style="display: inline;">'
                            a += '<li name="commentUpdate" type="button" style="display: inline; opacity: 0.7; font-size: small; margin-right: 5px" onclick="updateCommentForm(' + value.id + ')">수정</li>'
                            a += '<li name="commentDelete" type="button" style="display: inline; opacity: 0.7; font-size: small;" onclick="deleteComment(' + value.id + ')">삭제</li></ul>'
                        }
                        a += '<pre id="' + value.id + '" name="comment' + value.id + '" style="margin-bottom: 5px; font-size: large;">' + value.content + '</pre>'
                        a += '<p name="createDate' + value.id + '" style="margin-bottom: 5px; opacity: 0.5; font-size: small;">' + moment(value.createDate).format("YYYY-MM-DD HH:mm") + '</p></div>'
                    });
                    $("#count").html(size)
                    $("#comment").html(a)
                },
                error: function (response) {
                    console.log("getComments error : " + response)
                },
                complete: function () { }
            })
        }

        function insertComment() {
            var boardId = $('input[name=boardId]').val()
            var content = document.getElementById("content").value
            var param = {"content": content}
            var url = '/comments/' + boardId

            if (isEmpty(content) == true) {
                alert('댓글을 입력해주세요.')
                return false;
            } else {
                $.ajax({
                    contentType: 'application/json',
                    type: 'POST',
                    url: url,
                    data: JSON.stringify(param),
                    success: function (response) {
                        getComments()
                    },
                    error: function (response) {
                        console.log("insertComment error : " + response)
                    },
                })
            }
        }

        function updateCommentForm(id) {
            var commentId = id
            var content = document.getElementById(id).innerText

            $('ul[name=commentChange]').hide()
            $('pre[name=comment' + commentId + ']').contents().unwrap().wrap('<textarea id="newComment" class="form-control mt-2" name="updateContent" rows="4"></textarea>');
            $('p[name=createDate' + commentId + ']').contents().unwrap().wrap('<input name="update" type="button" class="me-2 mt-2 btn btn-primary" value="수정하기" onclick="updateComment(' + commentId + ')">');
            $('input[name=update]').after("<button class=\"me-2 mt-2 btn btn-primary\" onclick=\"getComments()\">취소</button>")
        }

        function updateComment(id) {
            var commentId = id
            var content = document.getElementById("newComment").value
            var param = {"content": content}
            var url = '/comments/' + commentId

            if (isEmpty(content) == true) {
                alert('댓글을 입력해주세요.')
                return false;
            } else {
                $.ajax({
                    contentType: 'application/json',
                    type: 'PATCH',
                    url: url,
                    data: JSON.stringify(param),
                    success: function (response) {
                        getComments()
                    },
                    error: function (response) {
                        console.log("updateComment error : " + response)
                    },
                    complete: function () { }
                })
            }
        }

        function deleteComment(id) {
            var commentId = id
            var url = '/comments/' + commentId

            if (confirm("정말 삭제하시겠습니까?")) {
                $.ajax({
                    type: 'DELETE',
                    url: url,
                    success: function (response) {
                        getComments()
                    },
                    error: function (response) {
                        console.log("deleteComment error : " + response)
                    },
                    complete: function () { }
                })
            } else {
                return;
            }
        }
        function isEmpty(strIn) {
            if (strIn === undefined) {
                return true;
            }
            else if (strIn == null) {
                return true;
            }
            else if (strIn == "") {
                return true;
            }
            else {
                return false;
            }
        }
    </script>
  • 이전 글과 바뀐 부분
    • Json으로 서버에 데이터를 보내주기 위해 contentType을 json으로 설정
    • url을 REST API 규칙에 맞춰 작성
    • data: json.stringify( ) : ajax 통신의 데이터 전달부분을 미리 만들어둔 json형식의 자바스크립트 변수를 서버로 전달
    • type을 REST API 규칙에 맞춰 작성
    • 날짜 포맷을 변경하기 위해 moment라이브러리 사용
    • https://black-mint.tistory.com/51
 

[JavaScript] 날짜 포맷 변경 라이브러리

서버에서 Json 형태로 프론트로 넘어오면 날짜 형식이 다음과 같이 변경돼서 날아온다. "createDate": "Jan 5, 2022, 4:25:48 PM" 원하는 날짜 형식으로 바꿔 보여주고 싶을 때 사용할 라이브러리 https://m

black-mint.tistory.com

 

* REST 관련 참고 

https://congsong.tistory.com/30?category=749196 

728x90

조회수 구현 게시글을 클릭해서 포스트를 조회할 때 조회수를 올려주기만 하면 된다.

그러나 이렇게만 한다면 댓글 작성할 때나 같은 아이디로 다시 들어갈 때나 조회수가 계속 올라간다.

이를 방지하기 위해 쿠키를 이용해서 조회수를 구현하는 방법을 기록한다.

쿠키란?

  • 서버가 클라이언트에게 보내는 데이터 중 하나로써, 클라이언트는 받은 쿠키 데이터를 로컬영역에 저장한다.
  • 다음에 서버를 방문한다면 쿠키를 요청 헤더에 포함해서 서버에게 전달한다.

1. 게시판 DB

CREATE TABLE `tb_board` (
  `id` int NOT NULL AUTO_INCREMENT,
  `title` varchar(255) NOT NULL,
  `content` text NOT NULL,
  `writer_id` int NOT NULL,
  `writer` varchar(20) NOT NULL,
  `delete_yn` varchar(1) DEFAULT 'N',
  `create_date` datetime DEFAULT NULL,
  `views` int DEFAULT '0',
  `image` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `writer_id` (`writer_id`),
  CONSTRAINT `tb_board_ibfk_1` FOREIGN KEY (`writer_id`) REFERENCES `tb_userinfo` (`id`) ON DELETE CASCADE
)
  • views : 조회수

2. View에 조회수 추가 (게시판 목록 html)

            <!-- contents -->
            <table class="table caption-top table-bordered table-hover">
                <caption>List</caption>
                <thead>
                    <tr>
                        <th class="text-center" width="50" scope="col">No</th>
                        <th class="text-center" width="950" scope="col">제목</th>
                        <th class="text-center" width="180" scope="col">작성자</th>
                        <th class="text-center" width="200" scope="col">작성일</th>
                        <th class="text-center" width="150" scope="col">조회수</th>
                    </tr>
                </thead>

                <tbody>
                    <tr th:each="board : ${boardList}">
                        <td class="mt-5 text-center" scope="row" th:text="${board.id}">1</td>
                        <td><a th:text="${board.title}" th:href="@{/board/post(id=${board.id})}" style="text-decoration:none; color:black;">제목</a></td>
                        <td class="text-center" th:text="${board.writer}">작성자</td>
                        <td class="text-center" th:text="${#dates.format(board.createDate, 'yyyy/MM/dd HH:mm')}">작성일</td>
                        <td class="text-center" th:text="${board.views}">조회수</td>
                    </tr>
                </tbody>
            </table>
  • 마지막 줄 th:text="${board.views}" : 조회수

3. Service

// 조회수 증가
    public void updateViews(Board board, String username, HttpServletRequest request,
                            HttpServletResponse response) throws Exception {
        Cookie[] cookies = request.getCookies();
        Map<String, String> mapCookie = new HashMap<>();

        if (request.getCookies() != null) {
            for (int i = 0; i < cookies.length; i++) {
                mapCookie.put(cookies[i].getName(), cookies[i].getValue());
            }

            String viewsCookie = mapCookie.get("views");
            String newCookie = "|" + board.getId();

            // 쿠키가 없을 경우 쿠키 생성 후 조회수 증가
            if (viewsCookie == null || !viewsCookie.contains(newCookie)) {
                Cookie cookie = new Cookie("views", viewsCookie + newCookie);
                response.addCookie(cookie);

                boardRepository.updateViews(board);
            }
        }
    }
  • request : 쿠키 정보를 얻을 HttpServletRequest 객체
  • response : 쿠키 정보를 추가할 HttpServletResponse 객체 
  • mapCookie.put : HashMap을 이용해 key value로 쿠키 값을 저장
  • viewsCookie : hashmap에 views라는 key값으로 저장된 value객체를 저장
  • newCookie : 새로운 쿠키를 추가할 때 사용 (id : 게시판 번호)
  • 쿠키 객체를 생성하고 추가할 쿠키 값을 초기화
    • 2번 게시글 조회 -> (views, null|2)
    • 3번 게시글 조회 -> (views, null|2|3) ...
  • response.addCookie를 통해 쿠키를 추가하고 조회수 1 증가
  • contains -> https://black-mint.tistory.com/39
 

[Java] String contains() 문자열 포함 여부

문자열 포함 여부 확인 방법을 기록한다. 1. 코드 String s = "hello World"; System.out.println("e : " + s.contains("e")); System.out.println("hello : " + s.contains("hello")); System.out.println("oW :..

black-mint.tistory.com

4. Controller

// 포스트 조회
@GetMapping("/post")
public String readPost(Model model, @RequestParam(required = false) Long id,
                       Principal principal, HttpServletRequest request,
                       HttpServletResponse response) throws Exception {
    String loginUser = principal.getName();
    Board board = boardService.contentLoad(id);

    boardService.updateViews(board, loginUser, request, response);

    model.addAttribute("board", board);
    model.addAttribute("loginUser", loginUser);

    return "board/post";
}
  • 쿠키를 사용하기 위해 HttpServletRequest request, HttpServletResponse response 를 파라미터로 받는다.
728x90

비밀번호 찾기를 시도한 사용자의 비밀번호를 초기화 시키고 초기화된 비밀번호를 사용자 이메일로 보낸다.

1. 인증 절차

  • 구글 계정 관리 -> 보안 -> 앱 비밀번호 -> [메일], [windows 컴퓨터] 2개 선택 후 생성
  • 여기서 나오는 비밀번호를 application.properties에 추가할 것이다.

2. 설정

  • gmail -> 톱니바퀴 클릭 후 모든 설정보기

톱니바퀴

  • 전달 및 모든 POP/IMAP 진입

  • POP 다운로드 -> 모든 메일에 POP 사용하기체크
  • IMAP 액세스 -> IMAP사용 체크
  • 변경사항 저장

3. 의존성 추가

// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-mail
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail'

 

4. SMTP 설정

# GoogleMail
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=사용자 구글 아이디
spring.mail.password=인증 비밀번호
spring.mail.properties.mail.smtp.starttls.enable=true
spring.mail.smtp.auth=true
  • username=test2@gmail.com
  • password=아까 인증에서 비밀번호

5. MailService

@Autowired
private JavaMailSender javaMailSender;

// 메일을 통해 임시 비밀번호 전송
public void sendMail(String username, String userEmail, String temporaryPassword) {
    List<String> toUserList = new ArrayList<>();
    toUserList.add(userEmail);
    int userSize = toUserList.size();

    SimpleMailMessage simpleMailMessage = new SimpleMailMessage();
    simpleMailMessage.setTo((String[]) toUserList.toArray(new String[userSize]));
    simpleMailMessage.setSubject("임시 비밀번호를 보내드립니다.");
    simpleMailMessage.setText(username + "님의 임시 비밀번호 : " + temporaryPassword);

    javaMailSender.send(simpleMailMessage);
}

 

* 참고 사이트

https://kitty-geno.tistory.com/43

 

Spring Boot | 메일 발송하기 (Google SMTP)

▶ 스프링 부트 메일 발송하기 (Google SMTP) 1. Google 홈페이지 > Google 계정 관리(우측상단) 2. 보안 > 앱 비밀번호 앱 비밀번호는 위에 2단계 인증을 해야 생성됩니다. 3. 메일, Windows 컴퓨터 4. 앱 비..

kitty-geno.tistory.com

 

728x90

게시판 댓글을 구현하는 도중 비동기 호출에 대해 알게 됐고, 이를 이용하려면 Ajax를 활용해야 한다는 정보를 얻었다.

비동기란?

  • 비동기의 반대인 동기적 통신의 경우 절차적으로 일을 차례로 하나씩 해 내려가는 것이다.
  • 비동기는 어떤 하나의 일을 하는 도중 기다리는 시간이 필요하다면 그 시간 동안 다른 일을 먼저 처리할 수 있도록 하는 것이다.
  • 즉, 절차적인 동기적 통신.   비절차적인 비동기 통신이라고 생각하면 된다!

Jquery 사용법과 Ajax를 통한 비동기 통신의 내용을 간단하게 정리. (1, 2번은 사용법)

만약 ajax를 사용할 때 동기적으로 실행하고 싶은 경우 코드에 async: false 를 붙여주면된다!

1. Jquery 시작

Jquery를 시작하기 위해 다음과 같은 코드를 html 내부에 작성한다.

<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
  • 어디에 적어도 상관없다고 한다.
  • 대부분 </body> 바로 윗 부분 즉, 마지막 부분에 작성하는 것을 많이 볼 수 있었다.

2. (Jquery) javascript 코드 작성

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script>
        $(document).ready(function() {
            getAlert();
        })

        function getAlert() {
            alert('gd')
        }
    </script>
  • $(document).ready(function() {} : 문서가 준비되면 매개변수를 넣은 콜백함수를 실행하라는 의미
    • 여기서는 html파일이 로드되면 alert로 팝업을 띄우게 했다.

3. Ajax(비동기 통신) 및 자바스크립트(Jquery)

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
    <script th:inline="javascript">
        $(document).ready(function () {
            getComments();
        })

        function getComments() {
            var loginUsername = [[${ loginUser }]]
            var boardId = $('input[name=boardId]').val()

            $.ajax({
                type: 'GET',
                url: '/board/comment/getCommentList',
                data: { boardId },
                success: function (response) {
                    var a = '';
                    var size = 0;
                    $.each(response, function (key, value) {
                        size = size + 1;
                        a += '<hr /><div>'
                        a += '<input type="hidden" id="commentId" name="commentId" value="' + value.id + '">'
                        a += '<span id="writer" style="font-weight: bold;">' + value.writer + '</span>'
                        if (value.writer == loginUsername) {
                            a += '<ul name="commentChange" class="justify-content-end" style="display: inline;">'
                            a += '<li name="commentUpdate" type="button" style="display: inline; opacity: 0.7; font-size: small; margin-right: 5px" onclick="updateCommentForm(' + value.id + ')">수정</li>'
                            a += '<li name="commentDelete" type="button" style="display: inline; opacity: 0.7; font-size: small;" onclick="deleteComment(' + value.id + ')">삭제</li></ul>'
                        }
                        a += '<pre id="' + value.id + '" name="comment' + value.id + '" style="margin-bottom: 5px; font-size: large;">' + value.content + '</pre>'
                        a += '<p name="createDate' + value.id + '" style="margin-bottom: 5px; opacity: 0.5; font-size: small;">' + value.createDate.substring(0, 10) + ' ' + value.createDate.substring(11, 19) + '</p></div>'
                    });
                    $("#count").html(size)
                    $("#comment").html(a)
                },
                error: function (response) {
                    console.log("error : " + response)
                },
                complete: function () { }
            })
        }

        function updateCommentForm(id) {
            var commentId = id;
            var content = document.getElementById(id).innerText;

            $('ul[name=commentChange]').hide()
            $('pre[name=comment' + commentId + ']').contents().unwrap().wrap('<textarea id="newComment" class="form-control mt-2" name="updateContent" rows="4"></textarea>');
            $('p[name=createDate' + commentId + ']').contents().unwrap().wrap('<input name="update" type="button" class="me-2 mt-2 btn btn-primary" value="수정하기" onclick="updateComment(' + commentId + ')">');
            $('input[name=update]').after("<button class=\"me-2 mt-2 btn btn-primary\" onclick=\"getComments()\">취소</button>")
        }

        function updateComment(id) {
            var commentId = id;
            var content = document.getElementById("newComment").value;

            $.ajax({
                type: 'POST',
                url: '/board/comment/update',
                data: { 
                    commentId:commentId,
                    content:content 
                },
                success: function (response) {
                    getComments()
                },
                error: function (response) {
                    console.log("update error : " + response)
                },
                complete: function () { }
            })
        }

        function deleteComment(id) {
            var commentId = id;

            if (confirm("정말 삭제하시겠습니까?")) {
                $.ajax({
                    type: 'POST',
                    url: '/board/comment/delete',
                    data: { commentId },
                    success: function (response) {
                        getComments()
                    },
                    error: function (response) {
                        console.log("delete error : " + response)
                    },
                    complete: function () { }
                })
            } else {
                return;
            }
        }
    </script>

댓글 [작성, 수정, 삭제]하는 동시에 페이지를 리로드하지 않고 비동기 통신으로 작성한 댓글을 바로 화면에 보여주는 코드이다. 즉, ajax 통신으로 수정이나 삭제 후 success: 에 getComments()를 호출하면 해당 페이지를 리로드 하지 않고 댓글이 최신화 된다. 

  • <script th:inline="javascript"> : 자바스크립에서 타임리프 변수를 쓰기위해 추가
  • [[${loginUser}]] : 컨트롤러에서 가져온 로그인해있는 사용자 아이디이다.
  • $('input[name=id]').val() : input 태그의 name이 id인 값을 가져와서 설정
    • 만약 $('input#idx').val() 이면 input태그의 id값이 idx인것을 가져와서 설정
    •  .val() : form 양식의 값을 설정하거나 가져옴
      • 만약 $('input#idx').val('abc') id가 idx인 input요소의 값을 abc로 설정
  • url : 서버의 컨트롤러에 호출할 url
  • data : 넘겨줄 데이터 (여기선 var id)
    • 여기서 들어가는 data 변수 이름은 컨트롤러의 인자명과 같아야 한다.
  • success : ajax 통신을 성공하고 서버로부터의 결과값을 response에 담아서 함수 실행
  • $.each(response, function(key, value) {}); : 서버에서 응답받은 데이터의 개수만큼 반복 (for or while)
    • 컨트롤러에서 List<Comment> comments 형태의 객체를 리턴했다.
    • key는 0, 1, 2, 3 ..
    • value.content 식으로 데이터 사용 가능. (comments.get(i).getContent와 동일)
    • size : 댓글 개수
    • $().html : 해당 태그의 요소를 수정
    • https://www.codingfactory.net/10324
  • if문을 이용해서 로그인한 사용자와 댓글 작성자가 동일하면 수정과 삭제를 가능하도록 버튼 표시한다.
  • error : ajax 통신을 실패하면 실행 (에러 정보 확인)
  • complete : success나 error가 끝나고 실행 (try catch의 finally와 동일)
  • 이외
    • datatype : 서버에서 받아올 데이터를 해석하는 형태 (xml, json 등)
    • cache : 요청 페이지의 캐시 여부 (true or false)
$('ul[name=commentChange]').hide()
  • name이 commentChange인 ul 태그를 숨김(한 댓글을 수정 중 다른 댓글을 수정, 삭제 못하게 함)
$('pre[name=comment' + commentId + ']').contents().unwrap().wrap('<p></p>')
  • 위 코드는 해당하는 name의 값을 가진 pre 태그를 p태그로 대체
$('input[name=a]').after("<button class=\"me-2 mt-2 btn btn-primary\" onclick=\"getComments()\">취소</button>")
  • 3번(ajax) 자바스크립트(jquery) 코드의 updateCommentForm 함수에 다음 코드를 추가해서 수정 버튼 옆에 취소버튼을 추가
  • 취소버튼 클릭 시 수정사항을 반영하지 않고 다시 댓글 목록을 보여줌
  • 이 외 자바스크립트 코드 https://black-mint.tistory.com/41
 

[JavaScript] 다른 태그 값, 양식 작성값(input, textarea ..)가져오기

1, 다른 태그 값 가져오기 var content = document.getElementById(id).innerText; 2. 사용자가 input 또는 textarea에 작성한 값 가져오기 (버튼 클릭시 실행) var content = document.getElementById("newCommen..

black-mint.tistory.com

4. 화면

                <!-- Comment -->
                <h4>댓글</h4>
                <div class="mb-5">
                    <div id="comment">
                    </div>

                    <form action="#" th:action="@{/board/comment/write}" method="post">
                        <input type="hidden" name="boardId" th:value="${board.id}">
                        <div class="mb-3">
                            <label for="content" class="form-label"></label>
                            <textarea class="form-control" id="content" name="content" rows="4"
                                placeholder="댓글을 작성해주세요."></textarea>
                        </div>
                        <button type="submit" class="me-2 btn btn-primary">write</button>
                    </form>
                </div>
  • div > id=comment : 작성된 댓글을 표시한 div 태그
  • 동적으로 댓글을 추가해주는 비동기 통신을 쓰기 때문에 3번 코드(자바스크립트)에 html이 닮겨 있다.

5. 컨트롤러

@Controller
@RequestMapping("/board/comment")
public class CommentController {

    @Autowired
    private CommentService commentService;

    // 댓글 작성
    @PostMapping("/write")
    @ResponseBody
    public void commentWrite(@RequestParam(name = "boardId", required = false) Long boardId,
                               @RequestParam(name = "content") String content,
                               Principal principal) throws Exception {
        String username = principal.getName();
        commentService.write(boardId, content, username);
    }

    // 댓글 조회
    @GetMapping("/getCommentList")
    @ResponseBody
    public List<Comment> getCommentList(@RequestParam(name = "boardId") Long boardId) throws Exception {
        List<Comment> comments = commentService.getCommentList(boardId);
        return comments;
    }

    // 댓글 수정
    @PostMapping("/update")
    @ResponseBody
    public void updateComment(@RequestParam(name = "commentId") Long commentId,
                              @RequestParam(name = "content") String content) throws Exception {
        commentService.update(commentId, content);
    }

    // 댓글 삭제
    @PostMapping("/delete")
    @ResponseBody
    public void deleteComment(@RequestParam(name = "commentId") Long commentId) throws Exception {
        commentService.delete(commentId);
    }
}
  • 댓글 작성
    • boardId : @RequestParam으로 url을 이용해 받는다. (현재 조회하고 있는 게시글)
    • content : 작성한 댓글 내용
  • 댓글 조회, 수정, 삭제
    • @ResponsBody : http요청의 body에 데이터를 넣어서 반환
    • @RequestParam(name = " ") : name은 ajax에서 보내주는 파라미터의 변수명과 같게 해야한다.

6. 서비스

    // 댓글 작성
    public void write(Long boardId, String content, String username) throws Exception {
        Comment comment = new Comment();
        Member member = memberService.getMember(username);

        comment.setBoardId(boardId);
        comment.setContent(content);
        comment.setWriter(member.getUsername());
        comment.setWriterId(member.getId());
        comment.setCreateDate(Timestamp.valueOf(LocalDateTime.now()));

        commentRepository.insertComment(comment);
    }

    // 댓글 조회
    public List<Comment> getCommentList(Long boardId) throws Exception {
        return commentRepository.selectCommentList(boardId);
    }

    // 댓글 수정
    public void update(Long commentId, String content) throws Exception {
        Comment comment = new Comment();

        comment.setId(commentId);
        comment.setContent(content);

        commentRepository.updateComment(comment);
    }

    // 댓글 삭제
    public void delete(Long commentId) throws Exception {
        commentRepository.deleteComment(commentId);
    }

7. 결과

시연 영상

 

+ Recent posts