본문 바로가기

Program/Spring

Clone_Project - 3(파일 업로드, 비동기 파일 업로드)

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>&nbsp;" +
                              "         <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>