22.11.01 - Clone_Project - 3(파일 업로드, 비동기 파일 업로드)
파일 업로드
파일 저장
소규모 업로드 : 프로젝트 내부에 저장
대규모 업로드 : 다른 서버(FTP)
View
<%@ page contentType="text/html; charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body>
<!-- 파일 업로드를 위한 form -->
<form action="/upload" method="post" enctype="multipart/form-data">
<input type="file" name="file" multiple><!-- multiple이 있으면 여러 파일 등록 가능-->
<button type="submit">업로드</button>
</form>
</body>
</html>
Controller
common/UploadController.java
package com.project.web_prj.common; import com.project.web_prj.util.FileUtils; import lombok.extern.log4j.Log4j2; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; import java.util.List; @Controller @Log4j2 public class UploadController { // upload-form.jsp로 포워딩하는 요청 @GetMapping("/upload-form") public String uploadForm() { return "upload/upload-form"; } // 파일 업로드 처리를 위한 요청 // MultipartFile: 클라이언트가 전송한 파일 정보들을 담은 객체 // ex) 원본 파일명, 파일 용량, 파일 컨텐츠타입... @PostMapping("/upload") public String upload(@RequestParam("file") List<MultipartFile> fileList) { log.info("/upload POST! - {}", fileList); for (MultipartFile file: fileList) { log.info("file-name: {}", file.getName()); log.info("file-origin-name: {}", file.getOriginalFilename()); log.info("file-size: {}KB", (double) file.getSize() / 1024); log.info("file-type: {}", file.getContentType()); System.out.println("=================================================================="); // 서버에 업로드파일 저장 // 업로드 파일 저장 경로 String uploadPath = "D:\\sl_hsg\\upload"; // 1. 세이브파일 객체 생성 // - 첫번째 파라미터는 파일 저장경로 지정, 두번째 파일명지정 /*File f = new File(uploadPath, file.getOriginalFilename()); try { file.transferTo(f); } catch (IOException e) { e.printStackTrace(); }*/ FileUtils.uploadFile(file, uploadPath); } return "redirect:/upload-form"; } }
resources
application.properties
# file upload max-size spring.servlet.multipart.max-file-size=20MB spring.servlet.multipart.max-request-size=20MB
Util
FileUtils
package com.project.web_prj.util; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; import java.text.DecimalFormat; import java.time.LocalDateTime; import java.util.UUID; public class FileUtils { // 1. 사용자가 파일을 업로드했을 때 새로운 파일명을 생성해서 // 반환하고 해당 파일명으로 업로드하는 메서드 // ex) 사용자가 상어.jpg를 올렸으면 이름을 저장하기 전에 중복없는 이름으로 바꿈 /** * * @param file - 클라이언트가 업로드한 파일 정보 * @param uploadPath - 서버의 업로드 루트 디렉토리 (E:/sl_dev/upload) * @return - 업로드가 완료된 새로운 파일의 이름 */ public static String uploadFile(MultipartFile file, String uploadPath) { // 중복이 없는 파일명으로 변경하기 // ex) 상어.png -> 3dfsfjkdsfds-djksfaqwerij-dsjkfdkj_상어.png String newFileName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename(); // 업로드 경로를 변경 // E:/sl_dev/upload -> E:/sl_dev/upload/2022/08/01 String newUploadPath = getNewUploadPath(uploadPath); // 파일 업로드 수행 File f = new File(newUploadPath, newFileName); try { file.transferTo(f); } catch (IOException e) { e.printStackTrace(); } return newFileName; } /** * 원본 업로드 경로를 받아서 일자별 폴더를 생성 한 후 최종경로를 리턴 * @param uploadPath - 원본 업로드 경로 * @return 일자별 폴더가 포함된 새로운 업로드 경로 */ private static String getNewUploadPath(String uploadPath) { // 오늘 년,월,일 정보 가져오기 LocalDateTime now = LocalDateTime.now(); int y = now.getYear(); int m = now.getMonthValue(); int d = now.getDayOfMonth(); // 폴더 생성 String[] dateInfo = { String.valueOf(y) , len2(m) , len2(d) }; String newUploadPath = uploadPath; // File.separator : 운영체제에 맞는 디렉토리 경로구분문자를 생성 // 리눅스 : / , 윈도우 : \ for (String date : dateInfo) { newUploadPath += File.separator + date; // 해당 경로대로 폴더를 생성 File dirName = new File(newUploadPath); if (!dirName.exists()) dirName.mkdir(); } return newUploadPath; } // 한자리수 월, 일 정보를 항상 2자리로 만들어주는 메서드 private static String len2(int n) { return new DecimalFormat("00").format(n); } }
비동기 파일 업로드
build.gradle
//파일업로드 라이브러리
implementation 'commons-io:commons-io:2.8.0'
//이미지 썸네일 라이브러리
implementation 'org.imgscalr:imgscalr-lib:4.2'
View
upload-form.jsp
<%@ page contentType="text/html; charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%> <!DOCTYPE html> <html lang="ko"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title></title> <!-- jquery --> <script src="/js/jquery-3.3.1.min.js"></script> <style> .fileDrop { width: 800px; height: 400px; border: 1px dashed gray; display: flex; justify-content: center; align-items: center; font-size: 1.5em; } .uploaded-list { display: flex; } .img-sizing { display: block; width: 100px; height: 100px; } </style> </head> <body> <!-- 파일 업로드를 위한 form - 동기 처리 --> <form action="/upload" method="post" enctype="multipart/form-data"> <input type="file" name="file" multiple> <button type="submit">업로드</button> </form> <!-- 비동기 통신을 통한 실시간 파일 업로드 처리 --> <div class="fileDrop"> <span>DROP HERE!!</span> </div> <!-- - 파일 정보를 서버로 보내기 위해서는 <input type="file"> 이 필요 - 해당 input태그는 사용자에게 보여주어 파일을 직접 선택하게 할 것이냐 혹은 드래그앤 드롭으로만 처리를 할 것이냐에 따라 display를 상태를 결정 --> <div class="uploadDiv"> <input type="file" name="files" id="ajax-file" style="display:none;"> </div> <!-- 업로드된 이미지의 썸네일을 보여주는 영역 --> <div class="uploaded-list"> </div> <script> // start JQuery $(document).ready(function () { function isImageFile(originFileName) { //정규표현식 //jpg로 끝나거나 gif로 끝나거나 png로 끝나는 대소문자 구분없이 const pattern = /jpg$|gif$|png$/i; return originFileName.match(pattern); } // 파일의 확장자에 따른 렌더링 처리 function checkExtType(fileName) { //원본 파일 명 추출 let originFileName = fileName.substring(fileName.indexOf("_") + 1); //확장자 추출후 이미지인지까지 확인 if (isImageFile(originFileName)) { // 파일이 이미지라면 const $img = document.createElement('img'); $img.classList.add('img-sizing'); $img.setAttribute('src', '/loadFile?fileName=' + fileName); $img.setAttribute('alt', originFileName); $('.uploaded-list').append($img); } // 이미지가 아니라면 다운로드 링크를 생성 else { const $a = document.createElement('a'); $a.setAttribute('href', '/loadFile?fileName=' + fileName); const $img = document.createElement('img'); $img.classList.add('img-sizing'); $img.setAttribute('src', '/img/file_icon.jpg'); $img.setAttribute('alt', originFileName); $a.append($img); $a.innerHTML += '<span>' + originFileName + '</span'; $('.uploaded-list').append($a); } } // 드롭한 파일을 화면에 보여주는 함수 function showFileData(fileNames) { // 이미지인지? 이미지가 아닌지에 따라 구분하여 처리 // 이미지면 썸네일을 렌더링하고 아니면 다운로드 링크를 렌더링한다. for (let fileName of fileNames) { checkExtType(fileName); } } // drag & drop 이벤트 //jquery는 querySelectorAll로 잡아옴 const $dropBox = $('.fileDrop'); // drag 진입 이벤트 $dropBox.on('dragover dragenter', e => { e.preventDefault(); $dropBox .css('border-color', 'red') .css('background', 'lightgray'); }); // drag 탈출 이벤트 $dropBox.on('dragleave', e => { e.preventDefault(); $dropBox .css('border-color', 'gray') .css('background', 'transparent'); }); // drop 이벤트 $dropBox.on('drop', e => { e.preventDefault(); // console.log('드롭 이벤트 작동!'); // 드롭된 파일 정보를 서버로 전송 // 1. 드롭된 파일 데이터 읽기 // console.log(e); const files = e.originalEvent.dataTransfer.files; // console.log('drop file data: ', files); // 2. 읽은 파일 데이터를 input[type=file]태그에 저장 const $fileInput = $('#ajax-file'); $fileInput.prop('files', files); console.log('input : ',$fileInput); // 3. 파일 데이터를 비동기 전송하기 위해서는 FormData객체가 필요 const formData = new FormData(); // 4. 전송할 파일들을 전부 FormData안에 포장 //$fileInput[0]인 이유는 jquery가 배열로 잡아오니까 처음의 files를 잡는다. for (let file of $fileInput[0].files) { formData.append('files', file); } // 5. 비동기 요청 전송 const reqInfo = { //form 형태로 데이터를 보낼때 headers가 기본값으로 적용 method: 'POST', body: formData }; fetch('/ajax-upload', reqInfo) .then(res => { //console.log(res.status); return res.json(); }) .then(fileNames => { console.log(fileNames); showFileData(fileNames); }); }); }); // end jQuery </script> </body> </html>
Controller
UploadController
package com.project.web_prj.common; import com.project.web_prj.util.FileUtils; import lombok.extern.log4j.Log4j2; import org.apache.commons.io.IOUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.util.ArrayList; import java.util.List; @Controller @Log4j2 public class UploadController { // 업로드 파일 저장 경로 private static final String UPLOAD_PATH = "D:\\sl_hsg\\upload"; // upload-form.jsp로 포워딩하는 요청 @GetMapping("/upload-form") public String uploadForm() { return "upload/upload-form"; } // 파일 업로드 처리를 위한 요청 // MultipartFile: 클라이언트가 전송한 파일 정보들을 담은 객체 // ex) 원본 파일명, 파일 용량, 파일 컨텐츠타입... @PostMapping("/upload") public String upload(@RequestParam("file") List<MultipartFile> fileList) { log.info("/upload POST! - {}", fileList); for (MultipartFile file: fileList) { log.info("file-name: {}", file.getName()); log.info("file-origin-name: {}", file.getOriginalFilename()); log.info("file-size: {}KB", (double) file.getSize() / 1024); log.info("file-type: {}", file.getContentType()); System.out.println("=================================================================="); // 서버에 업로드파일 저장 // 업로드 파일 저장 경로 // String uploadPath = "D:\\sl_hsg\\upload"; // 1. 세이브파일 객체 생성 // - 첫번째 파라미터는 파일 저장경로 지정, 두번째 파일명지정 /*File f = new File(uploadPath, file.getOriginalFilename()); try { file.transferTo(f); } catch (IOException e) { e.printStackTrace(); }*/ FileUtils.uploadFile(file, UPLOAD_PATH); } return "redirect:/upload-form"; } // 비동기 요청 파일 업로드 처리 @PostMapping("/ajax-upload") @ResponseBody // ResponseEntity=>서버 상태를 보낼 수 있다. public ResponseEntity<List<String>> ajaxUpload(List<MultipartFile> files) { log.info("/ajax-upload POST! - {}", files.get(0).getOriginalFilename()); // 클라이언트에게 전송할 파일경로 리스트 List<String> fileNames = new ArrayList<>(); // 클라이언트가 전송한 파일 업로드하기 for (MultipartFile file : files) { String fullPath = FileUtils.uploadFile(file, UPLOAD_PATH); fileNames.add(fullPath); } return new ResponseEntity<>(fileNames, HttpStatus.OK); } // 파일 데이터 로드 요청 처리 /* 비동기 통신 응답시 ResponseEntity를 쓰는 이유는 이 객체는 응답 body정보 이외에도 header정보를 포함할 수 있고 추가로 응답 상태코드도 제어할 수 있다. */ @GetMapping("/loadFile") @ResponseBody // fileName = /2022/08/01/변환된 파일명 public ResponseEntity<byte[]> loadFile(String file Name) { log.info("/loadFile GET - {}", fileName); // 클라이언트가 요청하는 파일의 진짜 바이트 데이터를 갖다줘야 함. // 1. 요청 파일 찾아서 file객체로 포장 File f = new File(UPLOAD_PATH + fileName); if (!f.exists()) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } // 2. 해당 파일을 InputStream을 통해 불러온다. try (FileInputStream fis = new FileInputStream(f)) { // 3. 클라이언트에게 순수 이미지를 응답해야 하므로 MIME TYPE을 응답헤더에 설정 // ex) image/jpeg, image/png, image/gif // 확장자를 추출해야 함. String ext = FileUtils.getFileExtension(fileName); MediaType mediaType = FileUtils.getMediaType(ext); // 응답헤더에 미디어 타입 설정 HttpHeaders headers = new HttpHeaders(); if (mediaType != null) { // 이미지라면 headers.setContentType(mediaType); } else { // 이미지가 아니면 다운로드 가능하게 설정 headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); // 파일명을 원래대로 복구 fileName = fileName.substring(fileName.lastIndexOf("_") + 1); // 파일명이 한글인 경우 인코딩 재설정 String encoding = new String( fileName.getBytes("UTF-8"), "ISO-8859-1"); // 헤더에 위 내용들 추가 headers.add("Content-Disposition" , "attachment; fileName=\"" + encoding + "\""); } // 4. 파일 순수데이터 바이트배열에 저장. byte[] rawData = IOUtils.toByteArray(fis); // 5. 비동기통신에서 데이터 응답할 때 ResponseEntity객체를 사용 return new ResponseEntity<>(rawData, headers, HttpStatus.OK); // 클라이언트에 파일 데이터 응답 } catch (Exception e) { e.printStackTrace(); return new ResponseEntity<>(HttpStatus.BAD_REQUEST); } } }
Util
FileUtils
package com.project.web_prj.util; import org.springframework.http.MediaType; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; import java.text.DecimalFormat; import java.time.LocalDateTime; import java.util.HashMap; import java.util.Map; import java.util.UUID; public class FileUtils { // MIME TYPE 설정을 위한 맵 만들기 private static final Map<String, MediaType> mediaMap; static { mediaMap = new HashMap<>(); mediaMap.put("JPG", MediaType.IMAGE_JPEG); mediaMap.put("GIF", MediaType.IMAGE_GIF); mediaMap.put("PNG", MediaType.IMAGE_PNG); } // 확장자를 알려주면 미디어타입을 리턴하는 메서드 public static MediaType getMediaType(String ext) { String upperExt = ext.toUpperCase(); if (mediaMap.containsKey(upperExt)) { return mediaMap.get(upperExt); } return null; } // 1. 사용자가 파일을 업로드했을 때 새로운 파일명을 생성해서 // 반환하고 해당 파일명으로 업로드하는 메서드 // ex) 사용자가 상어.jpg를 올렸으면 이름을 저장하기 전에 중복없는 이름으로 바꿈 /** * * @param file - 클라이언트가 업로드한 파일 정보 * @param uploadPath - 서버의 업로드 루트 디렉토리 (E:/sl_dev/upload) * @return - 업로드가 완료된 새로운 파일의 full path */ public static String uploadFile(MultipartFile file, String uploadPath) { // 중복이 없는 파일명으로 변경하기 // ex) 상어.png -> 3dfsfjkdsfds-djksfaqwerij-dsjkfdkj_상어.png String newFileName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename(); // 업로드 경로를 변경 // E:/sl_dev/upload -> E:/sl_dev/upload/2022/08/01 String newUploadPath = getNewUploadPath(uploadPath); // 파일 업로드 수행 File f = new File(newUploadPath, newFileName); try { file.transferTo(f); } catch (IOException e) { e.printStackTrace(); } // 파일의 풀 경로 (디렉토리경로 + 파일명) String fileFullPath = newUploadPath + File.separator + newFileName; // 풀 경로 - 루트 경로 문자열 생성 // full-path => E:/sl_dev/upload/2022/08/01/dfsdjfksfdkjs_상어.jpg // res-path => /2022/08/01/dfsdjfksfdkjs_상어.jpg // uploadPath => E:/sl_dev/upload String responseFilePath = fileFullPath.substring(uploadPath.length()); return responseFilePath.replace("\\", "/"); } /** * 원본 업로드 경로를 받아서 일자별 폴더를 생성 한 후 최종경로를 리턴 * @param uploadPath - 원본 업로드 경로 * @return 일자별 폴더가 포함된 새로운 업로드 경로 */ private static String getNewUploadPath(String uploadPath) { // 오늘 년,월,일 정보 가져오기 LocalDateTime now = LocalDateTime.now(); int y = now.getYear(); int m = now.getMonthValue(); int d = now.getDayOfMonth(); // 폴더 생성 String[] dateInfo = { String.valueOf(y) , len2(m) , len2(d) }; String newUploadPath = uploadPath; // File.separator : 운영체제에 맞는 디렉토리 경로구분문자를 생성 // 리눅스 : / , 윈도우 : \ for (String date : dateInfo) { newUploadPath += File.separator + date; // 해당 경로대로 폴더를 생성 File dirName = new File(newUploadPath); if (!dirName.exists()) dirName.mkdir(); } return newUploadPath; } // 한자리수 월, 일 정보를 항상 2자리로 만들어주는 메서드 private static String len2(int n) { return new DecimalFormat("00").format(n); } // 파일명을 받아서 확장자를 반환하는 메서드 public static String getFileExtension(String fileName) { return fileName.substring(fileName.lastIndexOf(".") + 1); } }
게시판 파일 업로드 적용
SQL
# Oracle
-- 첨부파일 정보를 가지는 테이블 생성
CREATE TABLE file_upload (
file_name VARCHAR2(150), -- /2022/08/01/asdjlfkasjfd_상어.jpg
reg_date DATE DEFAULT SYSDATE,
bno NUMBER(10) NOT NULL
);
-- PK, FK 부여
ALTER TABLE file_upload
ADD CONSTRAINT pk_file_name
PRIMARY KEY (file_name);
ALTER TABLE file_upload
ADD CONSTRAINT fk_file_upload
FOREIGN KEY (bno)
REFERENCES tbl_board (board_no)
ON DELETE CASCADE;
# Mysql
-- 첨부파일 정보를 가지는 테이블 생성
CREATE TABLE file_upload (
file_name VARCHAR(150), -- /2022/08/01/asdjlfkasjfd_상어.jpg
reg_date DATETIME DEFAULT current_timestamp,
bno INT(10) NOT NULL
);
-- PK, FK 부여
ALTER TABLE file_upload
ADD CONSTRAINT pk_file_name
PRIMARY KEY (file_name);
ALTER TABLE file_upload
ADD CONSTRAINT fk_file_upload
FOREIGN KEY (bno)
REFERENCES tbl_board (board_no)
ON DELETE CASCADE;
Domain
Board
package com.project.web_prj.board.domain; import lombok.*; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Date; import java.util.List; @Setter @Getter @ToString @EqualsAndHashCode @NoArgsConstructor @AllArgsConstructor public class Board { // 테이블 컬럼 필드 private Long boardNo; private String writer; private String title; private String content; private Long viewCnt; private Date regDate; // 커스텀 데이터 필드 private String shortTitle; // 줄임 제목 private String prettierDate; // 변경된 날짜포맷 문자열 private boolean newArticle; // 신규 게시물 여부 private int replyCount; // 댓글 수 private List<String> fileNames; // 첨부파일들의 이름 목록 public Board(ResultSet rs) throws SQLException { this.boardNo = rs.getLong("board_no"); this.title = rs.getString("title"); this.writer = rs.getString("writer"); this.content = rs.getString("content"); this.viewCnt = rs.getLong("view_cnt"); this.regDate = rs.getTimestamp("reg_date"); } }
Controller
BoardController
package com.project.web_prj.board.controller; import com.project.web_prj.board.domain.Board; import com.project.web_prj.board.service.BoardService; import com.project.web_prj.common.paging.Page; import com.project.web_prj.common.paging.PageMaker; import com.project.web_prj.common.search.Search; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.support.RedirectAttributes; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.ArrayList; import java.util.List; import java.util.Map; /** * 게시물 목록요청: /board/list: GET * 게시물 상세조회요청: /board/content: GET * 게시글 쓰기 화면요청: /board/write: GET * 게시글 등록요청: /board/write: POST * 게시글 삭제요청: /board/delete: GET * 게시글 수정화면요청: /board/modify: GET * 게시글 수정요청: /board/modify: POST */ @Controller @Log4j2 @RequiredArgsConstructor @RequestMapping("/board") public class BoardController { private final BoardService boardService; // 게시물 목록 요청 @GetMapping("/list") public String list(@ModelAttribute("s") Search search, Model model) { log.info("controller request /board/list GET! - search: {}", search); Map<String, Object> boardMap = boardService.findAllService(search); log.debug("return data - {}", boardMap); // 페이지 정보 생성 PageMaker pm = new PageMaker( new Page(search.getPageNum(), search.getAmount()) , (Integer) boardMap.get("tc")); model.addAttribute("bList", boardMap.get("bList")); model.addAttribute("pm", pm); return "board/board-list"; } // 게시물 상세 조회 요청 @GetMapping("/content/{boardNo}") public String content(@PathVariable Long boardNo , Model model, HttpServletResponse response, HttpServletRequest request , @ModelAttribute("p") Page page //받은걸 바로 p에 담는다 ) { log.info("controller request /board/content GET! - {}", boardNo); Board board = boardService.findOneService(boardNo, response, request); log.info("return data - {}", board); model.addAttribute("b", board); return "board/board-detail"; } // 게시물 쓰기 화면 요청 @GetMapping("/write") public String write() { log.info("controller request /board/write GET!"); return "board/board-write"; } // 게시물 등록 요청 @PostMapping("/write") public String write(Board board, //@RequestParam("files") List<MultipartFile> fileList, RedirectAttributes ra) { log.info("controller request /board/write POST! - {}", board); /*if (fileList != null) { List<String> fileNames = new ArrayList<>(); for (MultipartFile f : fileList) { log.info("attachmented file-name: {}", f.getOriginalFilename()); fileNames.add(f.getOriginalFilename()); } // board객체에 파일명 추가 board.setFileNames(fileNames); }*/ boolean flag = boardService.saveService(board); // 게시물 등록에 성공하면 클라이언트에 성공메시지 전송 if (flag) ra.addFlashAttribute("msg", "reg-success"); return flag ? "redirect:/board/list" : "redirect:/"; } // 게시물 삭제 요청 @GetMapping("/delete") public String delete(Long boardNo) { log.info("controller request /board/delete GET! - bno: {}", boardNo); return boardService.removeService(boardNo) ? "redirect:/board/list" : "redirect:/"; } // 수정 화면 요청 @GetMapping("/modify") public String modify(Long boardNo, Model model, HttpServletRequest request, HttpServletResponse response) { log.info("controller request /board/modify GET! - bno: {}", boardNo); Board board = boardService.findOneService(boardNo, response, request); log.info("find article: {}", board); model.addAttribute("board", board); return "board/board-modify"; } // 수정 처리 요청 @PostMapping("/modify") public String modify(Board board) { log.info("controller request /board/modify POST! - {}", board); boolean flag = boardService.modifyService(board); return flag ? "redirect:/board/content/" + board.getBoardNo() : "redirect:/"; } // 특정 게시물에 붙은 첨부파일경로 리스트를 클라이언트에게 비동기 전송 @GetMapping("/file/{bno}") @ResponseBody public ResponseEntity<List<String>> getFiles(@PathVariable Long bno) { List<String> files = boardService.getFiles(bno); log.info("/board/file/{} GET! ASYNC - {}", bno, files); return new ResponseEntity<>(files, HttpStatus.OK); } }
common
UploadController
package com.project.web_prj.common; import com.project.web_prj.util.FileUtils; import lombok.extern.log4j.Log4j2; import org.apache.commons.io.IOUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.util.ArrayList; import java.util.List; @Controller @Log4j2 public class UploadController { // 업로드 파일 저장 경로 private static final String UPLOAD_PATH = "D:\\sl_hsg\\upload"; // upload-form.jsp로 포워딩하는 요청 @GetMapping("/upload-form") public String uploadForm() { return "upload/upload-form"; } // 파일 업로드 처리를 위한 요청 // MultipartFile: 클라이언트가 전송한 파일 정보들을 담은 객체 // ex) 원본 파일명, 파일 용량, 파일 컨텐츠타입... @PostMapping("/upload") public String upload(@RequestParam("file") List<MultipartFile> fileList) { log.info("/upload POST! - {}", fileList); for (MultipartFile file: fileList) { log.info("file-name: {}", file.getName()); log.info("file-origin-name: {}", file.getOriginalFilename()); log.info("file-size: {}KB", (double) file.getSize() / 1024); log.info("file-type: {}", file.getContentType()); System.out.println("=================================================================="); // 서버에 업로드파일 저장 // 업로드 파일 저장 경로 // String uploadPath = "D:\\sl_hsg\\upload"; // 1. 세이브파일 객체 생성 // - 첫번째 파라미터는 파일 저장경로 지정, 두번째 파일명지정 /*File f = new File(uploadPath, file.getOriginalFilename()); try { file.transferTo(f); } catch (IOException e) { e.printStackTrace(); }*/ FileUtils.uploadFile(file, UPLOAD_PATH); } return "redirect:/upload-form"; } // 비동기 요청 파일 업로드 처리 @PostMapping("/ajax-upload") @ResponseBody // ResponseEntity=>서버 상태를 보낼 수 있다. public ResponseEntity<List<String>> ajaxUpload(List<MultipartFile> files) { log.info("/ajax-upload POST! - {}", files.get(0).getOriginalFilename()); // 클라이언트에게 전송할 파일경로 리스트 List<String> fileNames = new ArrayList<>(); // 클라이언트가 전송한 파일 업로드하기 for (MultipartFile file : files) { String fullPath = FileUtils.uploadFile(file, UPLOAD_PATH); fileNames.add(fullPath); } return new ResponseEntity<>(fileNames, HttpStatus.OK); } // 파일 데이터 로드 요청 처리 /* 비동기 통신 응답시 ResponseEntity를 쓰는 이유는 이 객체는 응답 body정보 이외에도 header정보를 포함할 수 있고 추가로 응답 상태코드도 제어할 수 있다. */ @GetMapping("/loadFile") @ResponseBody // fileName = /2022/08/01/변환된 파일명 public ResponseEntity<byte[]> loadFile(String fileName) { log.info("/loadFile GET - {}", fileName); // 클라이언트가 요청하는 파일의 진짜 바이트 데이터를 갖다줘야 함. // 1. 요청 파일 찾아서 file객체로 포장 File f = new File(UPLOAD_PATH + fileName); if (!f.exists()) { return new ResponseEntity<>(HttpStatus.NOT_FOUND); } // 2. 해당 파일을 InputStream을 통해 불러온다. try (FileInputStream fis = new FileInputStream(f)) { // 3. 클라이언트에게 순수 이미지를 응답해야 하므로 MIME TYPE을 응답헤더에 설정 // ex) image/jpeg, image/png, image/gif // 확장자를 추출해야 함. String ext = FileUtils.getFileExtension(fileName); MediaType mediaType = FileUtils.getMediaType(ext); // 응답헤더에 미디어 타입 설정 HttpHeaders headers = new HttpHeaders(); if (mediaType != null) { // 이미지라면 headers.setContentType(mediaType); } else { // 이미지가 아니면 다운로드 가능하게 설정 headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); // 파일명을 원래대로 복구 fileName = fileName.substring(fileName.lastIndexOf("_") + 1); // 파일명이 한글인 경우 인코딩 재설정 String encoding = new String( fileName.getBytes("UTF-8"), "ISO-8859-1"); // 헤더에 위 내용들 추가 headers.add("Content-Disposition" , "attachment; fileName=\"" + encoding + "\""); } // 4. 파일 순수데이터 바이트배열에 저장. byte[] rawData = IOUtils.toByteArray(fis); // 5. 비동기통신에서 데이터 응답할 때 ResponseEntity객체를 사용 return new ResponseEntity<>(rawData, headers, HttpStatus.OK); // 클라이언트에 파일 데이터 응답 } catch (Exception e) { e.printStackTrace(); return new ResponseEntity<>(HttpStatus.BAD_REQUEST); } } //서버에 있는 파일 삭제 요청처리 //URI: /deleteFile?fileName=/2019/09/22/djfksldfjs_abc.jpg @DeleteMapping("/deleteFile") public ResponseEntity<String> deleteFile(String fileName) throws Exception { try { //파일 삭제 File delFile = new File(UPLOAD_PATH + fileName); if(delFile.exists()) delFile.delete(); return new ResponseEntity<>("fileDelSuccess", HttpStatus.OK); } catch (Exception e) { return new ResponseEntity<>(e.getMessage(), HttpStatus.BAD_REQUEST); } } }
Service
BoardService
package com.project.web_prj.board.service; import com.project.web_prj.board.domain.Board; import com.project.web_prj.board.repository.BoardMapper; import com.project.web_prj.common.paging.Page; import com.project.web_prj.common.search.Search; import com.project.web_prj.reply.repository.ReplyMapper; import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.util.WebUtils; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @Service @Log4j2 @RequiredArgsConstructor public class BoardService { // private final BoardRepository repository; private final BoardMapper boardMapper; private final ReplyMapper replyMapper; // 게시물 등록 요청 중간 처리 @Transactional public boolean saveService(Board board) { log.info("save service start - {}", board); // 게시물 내용 DB에 저장 boolean flag = boardMapper.save(board); List<String> fileNames = board.getFileNames(); if (fileNames != null && fileNames.size() > 0) { for (String fileName : fileNames) { // 첨부파일 내용 DB에 저장 boardMapper.addFile(fileName); } } return flag; } // 게시물 전체 조회 요청 중간 처리 public List<Board> findAllService() { log.info("findAll service start"); List<Board> boardList = boardMapper.findAll(); // 목록 중간 데이터처리 processConverting(boardList); return boardList; } // 게시물 전체 조회 요청 중간 처리 with paging public Map<String, Object> findAllService(Page page) { log.info("findAll service start"); Map<String, Object> findDataMap = new HashMap<>(); List<Board> boardList = boardMapper.findAll(page); // 목록 중간 데이터 처리 processConverting(boardList); findDataMap.put("bList", boardList); findDataMap.put("tc", boardMapper.getTotalCount()); return findDataMap; } // 게시물 전체 조회 요청 중간 처리 with searching public Map<String, Object> findAllService(Search search) { log.info("findAll service start"); Map<String, Object> findDataMap = new HashMap<>(); List<Board> boardList = boardMapper.findAll2(search); // 목록 중간 데이터 처리 processConverting(boardList); findDataMap.put("bList", boardList); findDataMap.put("tc", boardMapper.getTotalCount2(search)); return findDataMap; } private void processConverting(List<Board> boardList) { for (Board b : boardList) { convertDateFormat(b); substringTitle(b); checkNewArticle(b); setReplyCount(b); } } private void setReplyCount(Board b) { b.setReplyCount(replyMapper.getReplyCount(b.getBoardNo())); } // 신규 게시물 여부 처리 private void checkNewArticle(Board b) { // 게시물의 작성일자와 현재 시간을 대조 // 게시물의 작성일자 가져오기 - 16억 5초 long regDateTime = b.getRegDate().getTime(); // 현재 시간 얻기 (밀리초) - 16억 10초 long nowTime = System.currentTimeMillis(); // 현재시간 - 작성시간 long diff = nowTime - regDateTime; // 신규 게시물 제한시간 long limitTime = 60 * 5 * 1000; if (diff < limitTime) { b.setNewArticle(true); } } private void convertDateFormat(Board b) { Date date = b.getRegDate(); SimpleDateFormat sdf = new SimpleDateFormat("yy-MM-dd a hh:mm"); b.setPrettierDate(sdf.format(date)); } private void substringTitle(Board b) { // 만약에 글제목이 5글자 이상이라면 // 5글자만 보여주고 나머지는 ...처리 String title = b.getTitle(); if (title.length() > 5) { String subStr = title.substring(0, 5); b.setShortTitle(subStr + "..."); } else { b.setShortTitle(title); } } // 게시물 상세 조회 요청 중간 처리 @Transactional public Board findOneService(Long boardNo, HttpServletResponse response, HttpServletRequest request) { log.info("findOne service start - {}", boardNo); Board board = boardMapper.findOne(boardNo); // 해당 게시물 번호에 해당하는 쿠키가 있는지 확인 // 쿠키가 없으면 조회수를 상승시켜주고 쿠키를 만들어서 클라이언트에 전송 makeViewCount(boardNo, response, request); return board; } private void makeViewCount(Long boardNo, HttpServletResponse response, HttpServletRequest request) { // 쿠키를 조회 - 해당 이름의 쿠키가 있으면 쿠키가 들어오고 없으면 null이 들어옴 Cookie foundCookie = WebUtils.getCookie(request, "b" + boardNo); if (foundCookie == null) { boardMapper.upViewCount(boardNo); Cookie cookie = new Cookie("b" + boardNo, String.valueOf(boardNo));// 쿠키 생성 cookie.setMaxAge(60); // 쿠키 수명 설정 cookie.setPath("/board/content"); // 쿠키 작동 범위 response.addCookie(cookie); // 클라이언트에 쿠키 전송 } } // 게시물 삭제 요청 중간 처리 @Transactional public boolean removeService(Long boardNo) { log.info("remove service start - {}", boardNo); // 댓글 먼저 모두 삭제 replyMapper.removeAll(boardNo); // 원본 게시물 삭제 boolean remove = boardMapper.remove(boardNo); return remove; } // 게시물 수정 요청 중간 처리 public boolean modifyService(Board board) { log.info("modify service start - {}", board); return boardMapper.modify(board); } // 첨부파일 목록 가져오는 중간처리 public List<String> getFiles(Long bno) { return boardMapper.findFileNames(bno); } }
View
board-write.jsp
<%@ page contentType="text/html; charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <!DOCTYPE html> <html lang="ko"> <head> <%@ include file="../include/static-head.jsp" %> <style> .write-container { width: 50%; margin: 200px auto 150px; font-size: 1.2em; } .fileDrop { width: 600px; height: 200px; border: 1px dashed gray; display: flex; justify-content: center; align-items: center; font-size: 1.5em; } .uploaded-list { display: flex; } .img-sizing { display: block; width: 100px; height: 100px; } </style> </head> <body> <div class="wrap"> <%@ include file="../include/header.jsp" %> <div class="write-container"> <form id="write-form" action="/board/write" method="post" autocomplete="off" enctype="multipart/form-data">
<div class="mb-3">
<label for="writer-input" class="form-label">작성자</label>
<input type="text" class="form-control" id="writer-input" placeholder="이름" name="writer"
maxlength="20">
</div>
<div class="mb-3">
<label for="title-input" class="form-label">글제목</label>
<input type="text" class="form-control" id="title-input" placeholder="제목" name="title">
</div>
<div class="mb-3">
<label for="exampleFormControlTextarea1" class="form-label">내용</label>
<textarea name="content" class="form-control" id="exampleFormControlTextarea1" rows="10"></textarea>
</div>
<!-- 첨부파일 드래그 앤 드롭 영역 -->
<div class="form-group">
<div class="fileDrop">
<span>Drop Here!!</span>
</div>
<div class="uploadDiv">
<input type="file" name="files" id="ajax-file" style="display:none;">
</div>
<!-- 업로드된 파일의 썸네일을 보여줄 영역 -->
<div class="uploaded-list">
</div>
</div>
<div class="d-grid gap-2">
<button id="reg-btn" class="btn btn-dark" type="button">글 작성하기</button>
<button id="to-list" class="btn btn-warning" type="button">목록으로</button>
</div>
</form>
</div>
<%@ include file="../include/footer.jsp" %>
</div>
<script>
// 게시물 등록 입력값 검증 함수
function validateFormValue() {
// 이름입력태그, 제목 입력태그
const $writerInput = document.getElementById('writer-input');
const $titleInput = document.getElementById('title-input');
let flag = false; // 입력 제대로하면 true로 변경
console.log('w: ', $writerInput.value);
console.log('t: ', $titleInput.value);
if ($writerInput.value.trim() === '') {
alert('작성자는 필수값입니다~');
} else if ($titleInput.value.trim() === '') {
alert('제목은 필수값입니다~');
} else {
flag = true;
}
console.log('flag:', flag);
return flag;
}
// 게시물 입력값 검증
const $regBtn = document.getElementById('reg-btn');
$regBtn.onclick = e => {
// 입력값을 제대로 채우지 않았는지 확인
if (!validateFormValue()) {
return;
}
// 필수 입력값을 잘 채웠으면 폼을 서브밋한다.
const $form = document.getElementById('write-form');
$form.submit();
};
//목록버튼 이벤트
const $toList = document.getElementById('to-list');
$toList.onclick = e => {
location.href = '/board/list';
};
</script>
<script>
// start JQuery
$(document).ready(function () {
function isImageFile(originFileName) {
//정규표현식
const pattern = /jpg$|gif$|png$/i;
return originFileName.match(pattern);
}
// 파일의 확장자에 따른 렌더링 처리
function checkExtType(fileName) {
//원본 파일 명 추출
let originFileName = fileName.substring(fileName.indexOf("_") + 1);
// hidden input을 만들어서 변환파일명을 서버로 넘김
const $hiddenInput = document.createElement('input');
$hiddenInput.setAttribute('type', 'hidden');
$hiddenInput.setAttribute('name', 'fileNames');//doamin의 fileNames에 매칭하기 위해
$hiddenInput.setAttribute('value', fileName);
$('#write-form').append($hiddenInput);
//확장자 추출후 이미지인지까지 확인
if (isImageFile(originFileName)) { // 파일이 이미지라면
const $img = document.createElement('img');
$img.classList.add('img-sizing');
$img.setAttribute('src', '/loadFile?fileName=' + fileName);
$img.setAttribute('alt', originFileName);
$('.uploaded-list').append($img);
}
// 이미지가 아니라면 다운로드 링크를 생성
else {
const $a = document.createElement('a');
$a.setAttribute('href', '/loadFile?fileName=' + fileName);
const $img = document.createElement('img');
$img.classList.add('img-sizing');
$img.setAttribute('src', '/img/file_icon.jpg');
$img.setAttribute('alt', originFileName);
$a.append($img);
$a.innerHTML += '<span>' + originFileName + '</span';
$('.uploaded-list').append($a);
}
}
// 드롭한 파일을 화면에 보여주는 함수
function showFileData(fileNames) {
// 이미지인지? 이미지가 아닌지에 따라 구분하여 처리
// 이미지면 썸네일을 렌더링하고 아니면 다운로드 링크를 렌더링한다.
for (let fileName of fileNames) {
checkExtType(fileName);
}
}
// drag & drop 이벤트
const $dropBox = $('.fileDrop');
// drag 진입 이벤트
$dropBox.on('dragover dragenter', e => {
e.preventDefault();
$dropBox
.css('border-color', 'red')
.css('background', 'lightgray');
});
// drag 탈출 이벤트
$dropBox.on('dragleave', e => {
e.preventDefault();
$dropBox
.css('border-color', 'gray')
.css('background', 'transparent');
});
// drop 이벤트
$dropBox.on('drop', e => {
e.preventDefault();
// console.log('드롭 이벤트 작동!');
// 드롭된 파일 정보를 서버로 전송
// 1. 드롭된 파일 데이터 읽기
// console.log(e);
const files = e.originalEvent.dataTransfer.files;
// console.log('drop file data: ', files);
// 2. 읽은 파일 데이터를 input[type=file]태그에 저장
const $fileInput = $('#ajax-file');
$fileInput.prop('files', files);
// console.log($fileInput);
// 3. 파일 데이터를 비동기 전송하기 위해서는 FormData객체가 필요
const formData = new FormData();
// 4. 전송할 파일들을 전부 FormData안에 포장
for (let file of $fileInput[0].files) {
formData.append('files', file);
}
// 5. 비동기 요청 전송
const reqInfo = {
method: 'POST',
body: formData
};
fetch('/ajax-upload', reqInfo)
.then(res => {
//console.log(res.status);
return res.json();
})
.then(fileNames => {
console.log(fileNames);
showFileData(fileNames);
});
});
});
// end jQuery
</script>
</body>
</html>
```board-detail.jsp
<%@ page contentType="text/html; charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <!DOCTYPE html> <html lang="ko"> <head> <%@ include file="../include/static-head.jsp" %> <style> .content-container { width: 60%; margin: 150px auto; position: relative; } .content-container .main-title { font-size: 24px; font-weight: 700; text-align: center; border-bottom: 2px solid rgb(75, 73, 73); padding: 0 20px 15px; width: fit-content; margin: 20px auto 30px; } .content-container .main-content { border: 2px solid #ccc; border-radius: 20px; padding: 10px 25px; font-size: 1.1em; text-align: justify; min-height: 400px; } .content-container .custom-btn-group { position: absolute; bottom: -10%; left: 50%; transform: translateX(-50%); } /* 페이지 액티브 기능 */ .pagination .page-item.p-active a { background: #333 !important; color: #fff !important; cursor: default; pointer-events: none; } .pagination .page-item:hover a { background: #888 !important; color: #fff !important; } .uploaded-list { display: flex; } .img-sizing { display: block; width: 100px; height: 100px; } </style> </head> <body> <div class="wrap"> <%@ include file="../include/header.jsp" %> <div class="content-container"> <h1 class="main-title">${b.boardNo}번 게시물</h1> <div class="mb-3"> <label for="exampleFormControlInput1" class="form-label">작성자</label> <input type="text" class="form-control" id="exampleFormControlInput1" placeholder="이름" name="writer" value="${b.writer}" disabled> </div> <div class="mb-3"> <label for="exampleFormControlInput2" class="form-label">글제목</label> <input type="text" class="form-control" id="exampleFormControlInput2" placeholder="제목" name="title" value="${b.title}" disabled> </div> <div class="mb-3"> <label for="exampleFormControlTextarea1" class="form-label">내용</label> <p class="main-content"> ${b.content} </p> </div> <!-- 파일 첨부 영역 --> <div class="form-group"> <ul class="uploaded-list"></ul> </div> <div class="btn-group btn-group-lg custom-btn-group" role="group"> <button id="mod-btn" type="button" class="btn btn-warning">수정</button> <button id="del-btn" type="button" class="btn btn-danger">삭제</button> <button id="list-btn" type="button" class="btn btn-dark">목록</button> </div> <!-- 댓글 영역 --> <div id="replies" class="row"> <div class="offset-md-1 col-md-10"> <!-- 댓글 쓰기 영역 --> <div class="card"> <div class="card-body"> <div class="row"> <div class="col-md-9"> <div class="form-group"> <label for="newReplyText" hidden>댓글 내용</label> <textarea rows="3" id="newReplyText" name="replyText" class="form-control" placeholder="댓글을 입력해주세요."></textarea> </div> </div> <div class="col-md-3"> <div class="form-group"> <label for="newReplyWriter" hidden>댓글 작성자</label> <input id="newReplyWriter" name="replyWriter" type="text" class="form-control" placeholder="작성자 이름" style="margin-bottom: 6px;"> <button id="replyAddBtn" type="button" class="btn btn-dark form-control">등록</button> </div> </div> </div> </div> </div> <!-- end reply write --> <!--댓글 내용 영역--> <div class="card"> <!-- 댓글 내용 헤더 --> <div class="card-header text-white m-0" style="background: #343A40;"> <div class="float-left">댓글 (<span id="replyCnt">0</span>)</div> </div> <!-- 댓글 내용 바디 --> <div id="replyCollapse" class="card"> <div id="replyData"> <!-- < JS로 댓글 정보 DIV삽입 > --> </div> <!-- 댓글 페이징 영역 --> <ul class="pagination justify-content-center"> <!-- < JS로 댓글 페이징 DIV삽입 > --> </ul> </div> </div> <!-- end reply content --> </div> </div> <!-- end replies row --> <!-- 댓글 수정 모달 --> <div class="modal fade bd-example-modal-lg" id="replyModifyModal"> <div class="modal-dialog modal-lg"> <div class="modal-content"> <!-- Modal Header --> <div class="modal-header" style="background: #343A40; color: white;"> <h4 class="modal-title">댓글 수정하기</h4> <button type="button" class="close text-white" data-bs-dismiss="modal">X</button> </div> <!-- Modal body --> <div class="modal-body"> <div class="form-group"> <input id="modReplyId" type="hidden"> <label for="modReplyText" hidden>댓글내용</label> <textarea id="modReplyText" class="form-control" placeholder="수정할 댓글 내용을 입력하세요." rows="3"></textarea> </div> </div> <!-- Modal footer --> <div class="modal-footer"> <button id="replyModBtn" type="button" class="btn btn-dark">수정</button> <button id="modal-close" type="button" class="btn btn-danger" data-bs-dismiss="modal">닫기</button> </div> </div> </div> </div> <!-- end replyModifyModal --> </div> <%@ include file="../include/footer.jsp" %> </div> <!-- 게시글 상세보기 관련 script --> <script> const [$modBtn, $delBtn, $listBtn] = [...document.querySelector('div[role=group]').children]; // const $modBtn = document.getElementById('mod-btn'); //수정버튼 $modBtn.onclick = e => { location.href = '/board/modify?boardNo=${b.boardNo}'; }; //삭제버튼 $delBtn.onclick = e => { if (!confirm('정말 삭제하시겠습니까?')) { return; } location.href = '/board/delete?boardNo=${b.boardNo}'; }; //목록버튼 $listBtn.onclick = e => { location.href = '/board/list?pageNum=${p.pageNum}&amount=${p.amount}'; }; </script> <!-- 댓글관련 script --> <script> //원본 글 번호 const bno = '${b.boardNo}'; // console.log('bno:', bno); // 댓글 요청 URL const URL = '/api/v1/replies'; //날짜 포맷 변환 함수 function formatDate(datetime) { //문자열 날짜 데이터를 날짜객체로 변환 const dateObj = new Date(datetime); // console.log(dateObj); //날짜객체를 통해 각 날짜 정보 얻기 let year = dateObj.getFullYear(); //1월이 0으로 설정되어있음. let month = dateObj.getMonth() + 1; let day = dateObj.getDate(); let hour = dateObj.getHours(); let minute = dateObj.getMinutes(); //오전, 오후 시간체크 let ampm = ''; if (hour < 12 && hour >= 6) { ampm = '오전'; } else if (hour >= 12 && hour < 21) { ampm = '오후'; if (hour !== 12) { hour -= 12; } } else if (hour >= 21 && hour <= 24) { ampm = '밤'; hour -= 12; } else { ampm = '새벽'; } //숫자가 1자리일 경우 2자리로 변환 (month < 10) ? month = '0' + month: month; (day < 10) ? day = '0' + day: day; (hour < 10) ? hour = '0' + hour: hour; (minute < 10) ? minute = '0' + minute: minute; return year + "-" + month + "-" + day + " " + ampm + " " + hour + ":" + minute; } // 댓글 페이지 태그 생성 렌더링 함수 function makePageDOM(pageInfo) { let tag = ""; const begin = pageInfo.beginPage; const end = pageInfo.endPage; //이전 버튼 만들기 if (pageInfo.prev) { tag += "<li class='page-item'><a class='page-link page-active' href='" + (begin - 1) + "'>이전</a></li>"; } //페이지 번호 리스트 만들기 for (let i = begin; i <= end; i++) { let active = ''; if (pageInfo.page.pageNum === i) { active = 'p-active'; } tag += "<li class='page-item " + active + "'><a class='page-link page-custom' href='" + i + "'>" + i + "</a></li>"; } //다음 버튼 만들기 if (pageInfo.next) { tag += "<li class='page-item'><a class='page-link page-active' href='" + (end + 1) + "'>다음</a></li>"; } // 페이지태그 렌더링 const $pageUl = document.querySelector('.pagination'); $pageUl.innerHTML = tag; // ul에 마지막페이지 번호 저장. $pageUl.dataset.fp = pageInfo.finalPage; } // 댓글 목록 DOM을 생성하는 함수 function makeReplyDOM({ replyList, count, maker }) { // 각 댓글 하나의 태그 let tag = ''; if (replyList === null || replyList.length === 0) { tag += "<div id='replyContent' class='card-body'>댓글이 아직 없습니다! ㅠㅠ</div>"; } else { for (let rep of replyList) { tag += "<div id='replyContent' class='card-body' data-replyId='" + rep.replyNo + "'>" + " <div class='row user-block'>" + " <span class='col-md-3'>" + " <b>" + rep.replyWriter + "</b>" + " </span>" + " <span class='offset-md-6 col-md-3 text-right'><b>" + formatDate(rep.replyDate) + "</b></span>" + " </div><br>" + " <div class='row'>" + " <div class='col-md-6'>" + rep.replyText + "</div>" + " <div class='offset-md-2 col-md-4 text-right'>" + " <a id='replyModBtn' class='btn btn-sm btn-outline-dark' data-bs-toggle='modal' data-bs-target='#replyModifyModal'>수정</a> " + " <a id='replyDelBtn' class='btn btn-sm btn-outline-dark' href='#'>삭제</a>" + " </div>" + " </div>" + " </div>"; } } // 댓글 목록에 생성된 DOM 추가 document.getElementById('replyData').innerHTML = tag; // 댓글 수 배치 document.getElementById('replyCnt').textContent = count; // 페이지 렌더링 makePageDOM(maker); } // 댓글 목록을 서버로부터 비동기요청으로 불러오는 함수 function showReplies(pageNum = 1) { fetch(URL + '?boardNo=' + bno + '&pageNum=' + pageNum) .then(res => res.json()) .then(replyMap => { // console.log(replyMap.replyList); makeReplyDOM(replyMap); }); } // 페이지 버튼 클릭이벤트 등록 함수 function makePageButtonClickEvent() { // 페이지 버튼 클릭이벤트 처리 const $pageUl = document.querySelector('.pagination'); $pageUl.onclick = e => { if (!e.target.matches('.page-item a')) return; e.preventDefault(); // 누른 페이지 번호 가져오기 const pageNum = e.target.getAttribute('href'); // console.log(pageNum); // 페이지 번호에 맞는 목록 비동기 요청 showReplies(pageNum); }; } // 댓글 등록 이벤트 처리 핸들러 등록 함수 function makeReplyRegisterClickEvent() { document.getElementById('replyAddBtn').onclick = makeReplyRegisterClickHandler; } // 댓글 등록 이벤트 처리 핸들러 함수 function makeReplyRegisterClickHandler(e) { const $writerInput = document.getElementById('newReplyWriter'); const $contentInput = document.getElementById('newReplyText'); // 서버로 전송할 데이터들 const replyData = { replyWriter: $writerInput.value, replyText: $contentInput.value, boardNo: bno }; // POST요청을 위한 요청 정보 객체 const reqInfo = { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(replyData) }; fetch(URL, reqInfo) .then(res => res.text()) .then(msg => { if (msg === 'insert-success') { alert('댓글 등록 성공'); // 댓글 입력창 리셋 $writerInput.value = ''; $contentInput.value = ''; // 댓글 목록 재요청 showReplies(document.querySelector('.pagination').dataset.fp); } else { alert('댓글 등록 실패'); } }); } // 댓글 수정화면 열기 상세처리 function processModifyShow(e, rno) { // console.log('수정버튼 클릭함!! after'); // 클릭한 버튼 근처에 있는 댓글 내용텍스트를 얻어온다. const replyText = e.target.parentElement.parentElement.firstElementChild.textContent; //console.log('댓글내용:', replyText); // 모달에 해당 댓글내용을 배치한다. document.getElementById('modReplyText').textContent = replyText; // 모달을 띄울 때 다음 작업(수정완료처리)을 위해 댓글번호를 모달에 달아두자. const $modal = document.querySelector('.modal'); $modal.dataset.rno = rno; } // 댓글 삭제 상세처리 function processRemove(rno) { if (!confirm('진짜로 삭제합니까??')) return; fetch(URL + '/' + rno, { method: 'DELETE' }) .then(res => res.text()) .then(msg => { if (msg === 'del-success') { alert('삭제 성공!!'); showReplies(); // 댓글 새로불러오기 } else { alert('삭제 실패!!'); } }); } // 댓글 수정화면 열기, 삭제 처리 핸들러 정의 function makeReplyModAndDelHandler(e) { const rno = e.target.parentElement.parentElement.parentElement.dataset.replyid; e.preventDefault(); // console.log('수정버튼 클릭함!! before'); if (e.target.matches('#replyModBtn')) { processModifyShow(e, rno); } else if (e.target.matches('#replyDelBtn')) { processRemove(rno); } } // 댓글 수정 화면 열기, 삭제 이벤트 처리 function openModifyModalAndRemoveEvent() { const $replyData = document.getElementById('replyData'); $replyData.onclick = makeReplyModAndDelHandler; } // 댓글 수정 비동기 처리 이벤트 function replyModifyEvent() { const $modal = $('#replyModifyModal'); document.getElementById('replyModBtn').onclick = e => { // console.log('수정 완료 버튼 클릭!'); // 서버에 수정 비동기 요청 보내기 const rno = e.target.closest('.modal').dataset.rno; // console.log(rno); const reqInfo = { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ replyText: $('#modReplyText').val(), replyNo: rno }) }; fetch(URL + '/' + rno, reqInfo) .then(res => res.text()) .then(msg => { if (msg === 'mod-success') { alert('수정 성공!!'); $modal.modal('hide'); // 모달창 닫기 showReplies(); // 댓글 새로불러오기 } else { alert('수정 실패!!'); } }); }; } // 메인 실행부 (function () { // 초기 화면 렌더링시 댓글 1페이지 렌더링 showReplies(); // 댓글 페이지 버튼 클릭이벤트 처리 makePageButtonClickEvent(); // 댓글 등록 버튼 클릭이벤트 처리 makeReplyRegisterClickEvent(); // 댓글 수정 모달 오픈, 삭제 이벤트 처리 openModifyModalAndRemoveEvent(); // 댓글 수정 완료 버튼 이벤트 처리 replyModifyEvent(); })(); </script> <script> // start JQuery $(document).ready(function () { function isImageFile(originFileName) { //정규표현식 const pattern = /jpg$|gif$|png$/i; return originFileName.match(pattern); } // 파일의 확장자에 따른 렌더링 처리 function checkExtType(fileName) { //원본 파일 명 추출 let originFileName = fileName.substring(fileName.indexOf("_") + 1); //확장자 추출후 이미지인지까지 확인 if (isImageFile(originFileName)) { // 파일이 이미지라면 const $img = document.createElement('img'); $img.classList.add('img-sizing'); $img.setAttribute('src', '/loadFile?fileName=' + fileName); $img.setAttribute('alt', originFileName); $('.uploaded-list').append($img); } // 이미지가 아니라면 다운로드 링크를 생성 else { const $a = document.createElement('a'); $a.setAttribute('href', '/loadFile?fileName=' + fileName); const $img = document.createElement('img'); $img.classList.add('img-sizing'); $img.setAttribute('src', '/img/file_icon.jpg'); $img.setAttribute('alt', originFileName); $a.append($img); $a.innerHTML += '<span>' + originFileName + '</span'; $('.uploaded-list').append($a); } } // 드롭한 파일을 화면에 보여주는 함수 function showFileData(fileNames) { // 이미지인지? 이미지가 아닌지에 따라 구분하여 처리 // 이미지면 썸네일을 렌더링하고 아니면 다운로드 링크를 렌더링한다. for (let fileName of fileNames) { checkExtType(fileName); } } // 파일 목록 불러오기 function showFileList() { fetch('/board/file/' + bno) .then(res => res.json()) .then(fileNames => { showFileData(fileNames); }); } showFileList(); }); // end jQuery </script> </body> </html>
resources
board.repository.BoardMapper.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.project.web_prj.board.repository.BoardMapper"> <resultMap id="boardMap" type="com.project.web_prj.board.domain.Board"> <result property="boardNo" column="board_no" /> <result property="regDate" column="reg_date" /> <result property="viewCnt" column="view_cnt" /> </resultMap> <!-- 동적 SQL 코드 재사용하기 --> <sql id="search"> <if test="type == 'title'">WHERE title LIKE CONCAT('%', #{keyword}, '%')</if> <if test="type == 'writer'">WHERE writer LIKE CONCAT('%', #{keyword}, '%')</if> <if test="type == 'content'">WHERE content LIKE CONCAT('%', #{keyword}, '%')</if> <if test="type == 'tc'"> WHERE title LIKE CONCAT('%', #{keyword}, '%') OR content LIKE CONCAT('%', #{keyword}, '%') </if> </sql> <insert id="save"> INSERT INTO tbl_board (writer, title, content) VALUES (#{writer}, #{title}, #{content}) </insert> <select id="findAll" resultMap="boardMap"> SELECT * FROM tbl_board ORDER BY board_no DESC LIMIT #{start}, #{amount} </select> <select id="findAll2" resultMap="boardMap"> SELECT * FROM tbl_board <include refid="search" /> ORDER BY board_no DESC LIMIT #{start}, #{amount} <!-- SELECT *--> <!-- FROM (--> <!-- SELECT ROWNUM rn, v_board.*--> <!-- FROM (--> <!-- SELECT *--> <!-- FROM tbl_board--> <!-- <if test="type == 'title'">WHERE title LIKE '%' || #{keyword} || '%'</if>--> <!-- <if test="type == 'writer'">WHERE writer LIKE '%' || #{keyword} || '%'</if>--> <!-- <if test="type == 'content'">WHERE content LIKE '%' || #{keyword} || '%'</if>--> <!-- <if test="type == 'tc'">--> <!-- WHERE title LIKE '%' || #{keyword} || '%'--> <!-- OR content LIKE '%' || #{keyword} || '%'--> <!-- </if>--> <!-- ORDER BY board_no DESC--> <!-- ) v_board--> <!-- )--> <!-- WHERE rn BETWEEN (#{pageNum} - 1) * #{amount} + 1 AND (#{pageNum} * #{amount})--> </select> <select id="findOne" resultMap="boardMap"> SELECT * FROM tbl_board WHERE board_no=#{boardNo} </select> <delete id="remove"> DELETE FROM tbl_board WHERE board_no=#{boardNo} </delete> <update id="modify"> UPDATE tbl_board SET writer = #{writer}, title=#{title}, content=#{content} WHERE board_no=#{boardNo} </update> <select id="getTotalCount" resultType="int"> SELECT COUNT(*) FROM tbl_board </select> <select id="getTotalCount2" resultType="int"> SELECT COUNT(*) FROM tbl_board <include refid="search" /> </select> <update id="upViewCount"> UPDATE tbl_board SET view_cnt = view_cnt + 1 WHERE board_no=#{boardNo} </update> <!-- Oracle 첨부파일 추가 --> <insert id="addFile"> INSERT INTO file_upload (file_name, bno) VALUES (#{fileName}, seq_tbl_board.currval) </insert> <!-- MySql 첨부파일 추가 --> <insert id="addFile"> INSERT INTO file_upload (file_name, bno) VALUES (#{fileName}, LAST_INSERT_ID()) </insert> <select id="findFileNames" resultType="string"> SELECT file_name FROM file_upload WHERE bno = #{bno} </select> </mapper>
'Program > Spring' 카테고리의 다른 글
| Clone_Project - 5(자동 로그인, KAKAO Login) (0) | 2022.12.30 |
|---|---|
| Clone_Project - 4(로그인 처리, 인터셉터(Interceptor), 사용자 권한) (0) | 2022.12.30 |
| Clone_Project - 2(게시물 목록 요청 ,동적 SQL, 댓글) (0) | 2022.12.30 |
| Clone_Project -1 (Cookie, 페이징 처리, 글 상세 → 이전 글 목록 페이지) (0) | 2022.12.30 |
| VO/DTO, @ToString, 생성자/빌더, 단위 테스트, 생성자주입VS필드 주입, Controller VS Service, Log4j2, JSP 분리 (0) | 2022.12.30 |