Today
-
Yesterday
-
Total
-

ABOUT ME

-

  • Spring Boot (게시판) | 첨부파일 업로드, 다운로드 (MultipartHttpServletRequest)
    ▼ Backend/└ 게시판 만들기 2021. 11. 5. 11:21
    반응형
    스프링 부트 게시판 만들기와 관계없이 파일 업로드, 다운로드 구현이 필요하다면 아래 글을 참조한다.

    [자바 (JAVA)] | 파일 다운로드 구현하기

    [자바 (JAVA)] | 파일 업로드 구현하기

     

    #구성환경

    SpringBoot, Gradle, Thymeleaf, Jpa(JPQL), Jar, MariaDB

     

    게시판 파일을 관리하는 테이블을 생성하고, 파일 업로드, 다운로드, 썸네일 이미지를 구성하는 기능을 만들어본다.

    #Step

    1) 지정된 경로에 업로드 후 파일 테이블에 게시글 번호를 포함한 데이터를 입력한다.

    2) 파일 삭제 시에는 실제 데이터를 삭제하지 않고, 삭제 여부를 업데이트한다.

    3) 수정시에는 기존에 파일 데이터 삭제 여부를 업데이트하고, 새로운 데이터로 입력한다.

    4) 조회 시에는 해당 게시글 번호로 파일 데이터의 삭제 여부가 N인 데이터를 가져온다.

     

    게시판 파일을 관리할 테이블 만들기

     

    CREATE TABLE `board_file` (
    	`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT 'PK',
    	`board_id` BIGINT(20) NOT NULL COMMENT '게시글 번호',
    	`orig_file_name` VARCHAR(250) NULL DEFAULT NULL COMMENT '파일 원본 이름' ,
    	`save_file_name` VARCHAR(500) NULL DEFAULT NULL COMMENT '파일 이름' ,
    	`file_size` INT(11) NULL DEFAULT '0' COMMENT '파일 크기',
    	`file_ext` VARCHAR(10) NULL DEFAULT NULL COMMENT '파일 확장자' ,
    	`file_path` VARCHAR(250) NULL DEFAULT NULL COMMENT '파일 경로' ,
    	`delete_yn` CHAR(1) NULL DEFAULT 'N' COMMENT '삭제여부' ,
    	`register_time` DATETIME NULL DEFAULT NULL COMMENT '작성일',
    	PRIMARY KEY (`id`) USING BTREE,
    	INDEX `BOARD_ID` (`board_id`) USING BTREE
    )
    COMMENT='게시판 파일관리'

     

    build.gradle

    Dependencies 추가
    저장될 파일명을 랜덤 문자열로 만들기 위해 사용

     

    implementation 'org.apache.commons:commons-lang3'

     

    컨트롤러(Controller)

    BoardFileController

     

    package com.board.study.web;
    
    import java.io.BufferedInputStream;
    import java.io.BufferedOutputStream;
    import java.io.File;
    import java.io.FileInputStream;
    import java.io.FileNotFoundException;
    import java.net.URLEncoder;
    import javax.servlet.http.HttpServletResponse;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    import com.board.study.dto.board.file.BoardFileRequestDto;
    import com.board.study.dto.board.file.BoardFileResponseDto;
    import com.board.study.service.BoardFileService;
    import lombok.RequiredArgsConstructor;
    
    @RequiredArgsConstructor
    @Controller
    public class BoardFileController {
    
        private final BoardFileService boardFileService;
    
        @GetMapping("/file/download")
        public void downloadFile(@RequestParam() Long id, HttpServletResponse response) throws Exception {
            try {
                // 파일정보를 조회한다.
                BoardFileResponseDto fileInfo = boardFileService.findById(id);
    
                if (fileInfo == null) throw new FileNotFoundException("Empty FileData.");
    
                // 경로와 파일명으로 파일 객체를 생성한다.
                File dFile = new File(fileInfo.getFilePath(), fileInfo.getSaveFileName());
    
                // 파일 길이를 가져온다.
                int fSize = (int) dFile.length();
    
                // 파일이 존재하면
                if (fSize > 0) {
                    // 파일명을 URLEncoder 하여 attachment, Content-Disposition Header로 설정
                    String encodedFilename = "attachment; filename*=" + "UTF-8" + "''" + URLEncoder.encode(fileInfo.getOrigFileName(), "UTF-8");
    
                    // ContentType 설정
                    response.setContentType("application/octet-stream; charset=utf-8");
    
                    // Header 설정
                    response.setHeader("Content-Disposition", encodedFilename);
    
                    // ContentLength 설정
                    response.setContentLengthLong(fSize);
    
                    BufferedInputStream in = null;
                    BufferedOutputStream out = null;
    
                    /* BufferedInputStream
                     * 
                    java.io의 가장 기본 파일 입출력 클래스
                    입력 스트림(통로)을 생성해줌
                    사용법은 간단하지만, 버퍼를 사용하지 않기 때문에 느림
                    속도 문제를 해결하기 위해 버퍼를 사용하는 다른 클래스와 같이 쓰는 경우가 많음
                    */
                    in = new BufferedInputStream(new FileInputStream(dFile));
    
                    /* BufferedOutputStream
                     * 
                    java.io의 가장 기본이 되는 파일 입출력 클래스
                    출력 스트림(통로)을 생성해줌
                    사용법은 간단하지만, 버퍼를 사용하지 않기 때문에 느림
                    속도 문제를 해결하기 위해 버퍼를 사용하는 다른 클래스와 같이 쓰는 경우가 많음
                    */
                    out = new BufferedOutputStream(response.getOutputStream());
    
                    try {
                        byte[] buffer = new byte[4096];
                        int bytesRead = 0;
    
                        /*
    		    모두 현재 파일 포인터 위치를 기준으로 함 (파일 포인터 앞의 내용은 없는 것처럼 작동)
    		    int read() : 1byte씩 내용을 읽어 정수로 반환
    		    int read(byte[] b) : 파일 내용을 한번에 모두 읽어서 배열에 저장
    		    int read(byte[] b. int off, int len) : 'len'길이만큼만 읽어서 배열의 'off'번째 위치부터 저장
    		    */
                        while ((bytesRead = in .read(buffer)) != -1) {
                            out.write(buffer, 0, bytesRead);
                        }
    
                        // 버퍼에 남은 내용이 있다면, 모두 파일에 출력					
                        out.flush();
                    } finally {
                        /*
                        현재 열려 in,out 스트림을 닫음
                        메모리 누수를 방지하고 다른 곳에서 리소스 사용이 가능하게 만듬
                         */
                        in .close();
                        out.close();
                    }
                } else {
                    throw new FileNotFoundException("Empty FileData.");
                }
            } catch (Exception e) {
                throw new Exception(e.getMessage());
            }
        }
    
        @PostMapping("/file/delete.ajax")
        public String updateDeleteYn(Model model, BoardFileRequestDto boardFileRequestDto) throws Exception {
            try {
                model.addAttribute("result", boardFileService.updateDeleteYn(boardFileRequestDto.getIdArr()));
            } catch (Exception e) {
                throw new Exception(e.getMessage());
            }
    
            return "jsonView";
        }
    }

     

     

    서비스(Service)

    BoardFileService

     

    filePath 변수에 파일이 실제로 업로드될 경로를 설정한다.

    C:\dev_tools\eclipse\workspace\uploadFiles\년도\월 경로에 파일이 업로드된다.

     

    package com.board.study.service;
    
    import java.io.File;
    import java.security.SecureRandom;
    import java.util.ArrayList;
    import java.util.Calendar;
    import java.util.Iterator;
    import java.util.List;
    import java.util.Map;
    import java.util.Map.Entry;
    import org.apache.commons.lang3.RandomStringUtils;
    import org.springframework.stereotype.Service;
    import org.springframework.web.multipart.MultipartFile;
    import org.springframework.web.multipart.MultipartHttpServletRequest;
    import com.board.study.dto.board.file.BoardFileResponseDto;
    import com.board.study.entity.board.file.BoardFile;
    import com.board.study.entity.board.file.BoardFileRepository;
    import lombok.RequiredArgsConstructor;
    
    @RequiredArgsConstructor
    @Service
    public class BoardFileService {
    
        private final BoardFileRepository boardFileRepository;
    
        public BoardFileResponseDto findById(Long id) throws Exception {
            return new BoardFileResponseDto(boardFileRepository.findById(id).get());
        }
    
        public List < Long > findByBoardId(Long boardId) throws Exception {
            return boardFileRepository.findByBoardId(boardId);
        }
    
        public boolean uploadFile(MultipartHttpServletRequest multiRequest, Long boardId) throws Exception {
    
            if (boardId == null) throw new NullPointerException("Empty BOARD_ID.");
    
            // 파라미터 이름을 키로 파라미터에 해당하는 파일 정보를 값으로 하는 Map을 가져온다.
            Map < String, MultipartFile > files = multiRequest.getFileMap();
    
            // files.entrySet()의 요소를 읽어온다.
            Iterator < Entry < String, MultipartFile >> itr = files.entrySet().iterator();
    
            MultipartFile mFile;
    
            String savaFilePath = "", randomFileName = "";
    
            Calendar cal = Calendar.getInstance();
    
            List < Long > resultList = new ArrayList < Long > ();
    
            while (itr.hasNext()) {
    
                Entry < String, MultipartFile > entry = itr.next();
    
                mFile = entry.getValue();
    
                int fileSize = (int) mFile.getSize();
    
                if (fileSize > 0) {
    
                    String filePath = "C:\\dev_tools\\eclipse\\workspace\\uploadFiles\\";
    
                    // 파일 업로드 경로 + 현재 년월(월별관리)
                    filePath = filePath + File.separator + String.valueOf(cal.get(Calendar.YEAR)) + File.separator + String.valueOf(cal.get(Calendar.MONTH) + 1);
                    randomFileName = "FILE_" + RandomStringUtils.random(8, 0, 0, false, true, null, new SecureRandom());
    
                    String realFileName = mFile.getOriginalFilename();
                    String fileExt = realFileName.substring(realFileName.lastIndexOf(".") + 1);
                    String saveFileName = randomFileName + "." + fileExt;
                    String saveFilePath = filePath + File.separator + saveFileName;
    
                    File filePyhFolder = new File(filePath);
    
                    if (!filePyhFolder.exists()) {
                        // 부모 폴더까지 포함하여 경로에 폴더를 만든다.
                        if (!filePyhFolder.mkdirs()) {
                            throw new Exception("File.mkdir() : Fail.");
                        }
                    }
    
                    File saveFile = new File(saveFilePath);
    
                    // saveFile이 File이면 true, 아니면 false
                    // 파일명이 중복일 경우 파일명(1).확장자, 파일명(2).확장자 와 같은 형태로 생성한다.
                    if (saveFile.isFile()) {
                        boolean _exist = true;
    
                        int index = 0;
    
                        // 동일한 파일명이 존재하지 않을때까지 반복한다.
                        while (_exist) {
                            index++;
    
                            saveFileName = randomFileName + "(" + index + ")." + fileExt;
    
                            String dictFile = filePath + File.separator + saveFileName;
    
                            _exist = new File(dictFile).isFile();
    
                            if (!_exist) {
                                savaFilePath = dictFile;
                            }
                        }
    
                        mFile.transferTo(new File(savaFilePath));
                    } else {
                        // 생성한 파일 객체를 업로드 처리하지 않으면 임시파일에 저장된 파일이 자동적으로 삭제되기 때문에 transferTo(File f) 메서드를 이용해서 업로드처리한다.
                        mFile.transferTo(saveFile);
                    }
    
                    BoardFile boardFile = BoardFile.builder()
                        .boardId(boardId)
                        .origFileName(realFileName)
                        .saveFileName(saveFileName)
                        .fileSize(fileSize)
                        .fileExt(fileExt)
                        .filePath(filePath)
                        .deleteYn("N")
                        .build();
    
                    resultList.add(boardFileRepository.save(boardFile).getId());
                }
            }
    
            return (files.size() == resultList.size()) ? true : false;
        }
    
        public int updateDeleteYn(Long[] deleteIdList) throws Exception {
            return boardFileRepository.updateDeleteYn(deleteIdList);
        }
    
        public int deleteBoardFileYn(Long[] boardIdList) throws Exception {
            return boardFileRepository.deleteBoardFileYn(boardIdList);
        }
    }

     

     

    리포지토리(Repository)

    BoardFileRepository

     

    package com.board.study.entity.board.file;
    
    import java.util.List;
    import org.springframework.data.jpa.repository.JpaRepository;
    import org.springframework.data.jpa.repository.Modifying;
    import org.springframework.data.jpa.repository.Query;
    import org.springframework.data.repository.query.Param;
    import org.springframework.transaction.annotation.Transactional;
    
    public interface BoardFileRepository extends JpaRepository < BoardFile, Long > {
    
        static final String SELECT_FILE_ID = "SELECT ID FROM board_file " +
        "WHERE BOARD_ID = :boardId AND DELETE_YN != 'Y'";
    
        static final String UPDATE_DELETE_YN = "UPDATE board_file " +
        "SET DELETE_YN = 'Y' " +
        "WHERE ID IN (:deleteIdList)";
    
        static final String DELETE_BOARD_FILE_YN = "UPDATE board_file " +
        "SET DELETE_YN = 'Y' " +
        "WHERE BOARD_ID IN (:boardIdList)";
    
        @Query(value = SELECT_FILE_ID, nativeQuery = true)
        public List < Long > findByBoardId(@Param("boardId") Long boardId);
    
        @Transactional
        @Modifying
        @Query(value = UPDATE_DELETE_YN, nativeQuery = true)
        public int updateDeleteYn(@Param("deleteIdList") Long[] deleteIdList);
    
        @Transactional
        @Modifying
        @Query(value = DELETE_BOARD_FILE_YN, nativeQuery = true)
        public int deleteBoardFileYn(@Param("boardIdList") Long[] boardIdList);
    }

     

     

    엔티티(Entity)

    BoardFile

     

    package com.board.study.entity.board.file;
    
    import java.time.LocalDateTime;
    import javax.persistence.Entity;
    import javax.persistence.GeneratedValue;
    import javax.persistence.GenerationType;
    import javax.persistence.Id;
    import org.springframework.data.annotation.CreatedDate;
    import lombok.AccessLevel;
    import lombok.Builder;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @Getter
    @Entity
    public class BoardFile {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
        private Long boardId;
        private String origFileName;
        private String saveFileName;
        private int fileSize;
        private String fileExt;
        private String filePath;
        private String deleteYn;
    
        @CreatedDate
        private LocalDateTime registerTime;
    
        @Builder
        public BoardFile(Long id, Long boardId, String origFileName, String saveFileName, int fileSize, String fileExt,
            String filePath, String deleteYn, LocalDateTime registerTime) {
            this.id = id;
            this.boardId = boardId;
            this.origFileName = origFileName;
            this.saveFileName = saveFileName;
            this.fileSize = fileSize;
            this.fileExt = fileExt;
            this.filePath = filePath;
            this.deleteYn = deleteYn;
            this.registerTime = registerTime;
        }
    }

     

     

    DTO(Data transfer object)

    BoardFileRequestDto

     

    package com.board.study.dto.board.file;
    
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    import lombok.Setter;
    
    @Getter
    @Setter
    @NoArgsConstructor
    public class BoardFileRequestDto {
        private Long id;
        private Long[] idArr;
        private String fileId;
    }

     

     

    BoardFileResponseDto

     

    package com.board.study.dto.board.file;
    
    import com.board.study.entity.board.file.BoardFile;
    import lombok.Getter;
    
    @Getter
    public class BoardFileResponseDto {
    
        private String origFileName;
        private String saveFileName;
        private String filePath;
    
        public BoardFileResponseDto(BoardFile entity) {
            this.origFileName = entity.getOrigFileName();
            this.saveFileName = entity.getSaveFileName();
            this.filePath = entity.getFilePath();
        }
    
        @Override
        public String toString() {
            return "FileMstResponseDto [origFileName=" + origFileName + ", saveFileName=" + saveFileName + ", filePath=" +
                filePath + "]";
        }
    }

     

     

    등록 HMTL 추가된 부분

    write.html (/src/main/resources/templates/board)

     

    <div class="mb-3">
        <label class="form-label font-weight-bold">File Upload.</label>
        <div id="fileDiv">
            <div class="custom-file mt-1">
                <input type="file" class="custom-file-input" id="customFile" name="customFile" onchange="fnChngFile(this);">
                <label class="custom-file-label" for="customFile">Choose file</label>
            </div>
        </div>
        <div class="float-right mt-2">
            <a class="btn btn-primary text-white" href="javascript:fnAddFileDiv();">+</a>
            <a class="btn btn-danger text-white" href="javascript:fnDelFileDiv();">-</a>
        </div>
    </div>

     

    Javascript

    let $origFileDiv = $(".custom-file");
    let fileMaxCnt = 3,
        fileMaxSize = 10485760,
        fileAllowExt = ["jpg", "jpeg", "png"];
        /*
        파일 등록 최대 개수는 3개
        파일 사이즈는 10MB
        파일 허용 확장자는 jpg, jpeg, png
        (properties로 관리하는게 더 용이하다.)*/
    
    
    function fnAddFileDiv() {
        let fileDivCnt = $(".custom-file").length;
    
        if (fileDivCnt >= fileMaxCnt) {
            alert("Can't add any more file.");
            return false;
        }
    
        let $copyFileDiv = $origFileDiv.clone(true);
    
        $copyFileDiv.find("input").val("");
        $copyFileDiv.find("label").text("Choose file");
        $copyFileDiv.find("label").attr("for", "customFile" + fileDivCnt);
        $copyFileDiv.find("input").attr("id", "customFile" + fileDivCnt);
        $copyFileDiv.find("input").attr("name", "customFile" + fileDivCnt);
    
        $("#fileDiv").append($copyFileDiv);
    }
    
    function fnDelFileDiv() {
        if ($(".custom-file").length <= 1) {
            alert("Can't Delete any more file.");
            return false;
        }
        $(".custom-file")[$(".custom-file").length - 1].remove();
    }
    
    function fnChngFile(obj) {
        let fileObj = $(obj)[0];
        let fileVal = fileObj.files[0].name;
        let fileSize = fileObj.files[0].size;
        let fileExt = fileVal.substring(fileVal.lastIndexOf(".") + 1, fileVal.length);
        let flag = true;
    
        if (fileAllowExt.indexOf(fileExt.toLowerCase()) < 0) {
            alert("It is not a registrable extension.");
        } else if (fileSize > fileMaxSize) {
            alert("Attachments can be registered up to 10MB.");
        } else {
            flag = false;
            $(obj).next("label").text(fileVal);
        }
    
        if (flag) {
            $(obj).val("");
            $(obj).next("label").text("Choose file");
        }
    }

     

    등록 HTML 전체 코드

    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorator="layout/default_layout">
    <!-- layout Content -->
    <th:block layout:fragment="content">
        <div class="container">
            <h1>Board Register.</h1>
            <form id="frm" action="/board/write/action" method="post" enctype="multipart/form-data">
                <div class="mb-3">
                    <label class="form-label font-weight-bold"><span class="text-danger">* </span>Title.</label>
                    <input type="text" class="form-control" name="title" required>
                </div>
                <div class="mb-3">
                    <label class="form-label font-weight-bold"><span class="text-danger">* </span>Content.</label>
                    <textarea class="form-control" rows="5" name="content" required></textarea>
                </div>
                <div class="mb-3">
                    <label class="form-label font-weight-bold"><span class="text-danger">* </span>Writer.</label>
                    <input type="text" class="form-control" name="registerId" required>
                </div>
                <div class="mb-3">
                    <label class="form-label font-weight-bold">File Upload.</label>
                    <div id="fileDiv">
                        <div class="custom-file mt-1">
                            <input type="file" class="custom-file-input" id="customFile" name="customFile" onchange="fnChngFile(this);">
                            <label class="custom-file-label" for="customFile">Choose file</label>
                        </div>
                    </div>
                    <div class="float-right mt-2">
                        <a class="btn btn-primary text-white" href="javascript:fnAddFileDiv();">+</a>
                        <a class="btn btn-danger text-white" href="javascript:fnDelFileDiv();">-</a>
                    </div>
                </div>
                <div class="mt-5">
                    <button type="button" class="btn btn-success" onclick="javascript:location.href='/board/list'">Previous</button>
                    <button type="button" class="btn btn-primary" onclick="fnSubmit();">Submit</button>
                </div>
            </form>
        </div>
        <script th:inline="javascript">
            let frm = $("#frm");
            let $origFileDiv = $(".custom-file");
            let fileMaxCnt = 3,
                fileMaxSize = 10485760,
                fileAllowExt = ["jpg", "jpeg", "png"];
                /*
                파일 등록 최대 개수는 3개
                파일 사이즈는 10MB
                파일 허용 확장자는 jpg, jpeg, png
                (properties로 관리하는게 더 용이하다.)*/
    
            function fnAddFileDiv() {
                let fileDivCnt = $(".custom-file").length;
    
                if (fileDivCnt >= fileMaxCnt) {
                    alert("Can't add any more file.");
                    return false;
                }
    
                let $copyFileDiv = $origFileDiv.clone(true);
    
                $copyFileDiv.find("input").val("");
                $copyFileDiv.find("label").text("Choose file");
                $copyFileDiv.find("label").attr("for", "customFile" + fileDivCnt);
                $copyFileDiv.find("input").attr("id", "customFile" + fileDivCnt);
                $copyFileDiv.find("input").attr("name", "customFile" + fileDivCnt);
    
                $("#fileDiv").append($copyFileDiv);
            }
    
            function fnDelFileDiv() {
                if ($(".custom-file").length <= 1) {
                    alert("Can't Delete any more file.");
                    return false;
                }
                $(".custom-file")[$(".custom-file").length - 1].remove();
            }
    
            function fnChngFile(obj) {
                let fileObj = $(obj)[0];
                let fileVal = fileObj.files[0].name;
                let fileSize = fileObj.files[0].size;
                let fileExt = fileVal.substring(fileVal.lastIndexOf(".") + 1, fileVal.length);
                let flag = true;
    
                if (fileAllowExt.indexOf(fileExt.toLowerCase()) < 0) {
                    alert("It is not a registrable extension.");
                } else if (fileSize > fileMaxSize) {
                    alert("Attachments can be registered up to 10MB.");
                } else {
                    flag = false;
                    $(obj).next("label").text(fileVal);
                }
    
                if (flag) {
                    $(obj).val("");
                    $(obj).next("label").text("Choose file");
                }
            }
    
            function fnSubmit() {
                $("#frm").submit();
            }
    
            $(function() {
                frm.validate({
                    messages: {
                        // Message Custom..
                        title: {
                            required: "Custom required, Please enter a title."
                        }
                    },
                    submitHandler: function(form) {
                        // Submit Action..
                        form.submit();
                    }
                });
            });
        </script>
    </th:block>
    </html>

     

    상세 HMTL 추가된 부분

    view.html (/src/main/resources/templates/board)

     

    <div class="mb-3">
        <label class="form-label font-weight-bold mt-2">File Upload.</label>
        <div class="mb-3" id="fileDiv">
            <div class="custom-file mt-1">
                <input type="file" class="custom-file-input" id="customFile" name="customFile" onchange="fnChngFile(this);">
                <label class="custom-file-label" for="customFile">Choose file</label>
            </div>
        </div>
        <div class="float-right mt-2">
            <a class="btn btn-primary text-white" href="javascript:fnAddFileDiv();">+</a>
            <a class="btn btn-danger text-white" href="javascript:fnDelFileDiv();">-</a>
        </div>
        <th:block th:if="${resultMap.fileList}">
            <div class="fileList mt-3" th:each="id : ${resultMap.fileList}">
                <img alt="image file" style="width: 50%" class="form-control img-thumbnail mt-3" th:src="@{/file/download(id=${id})}">
                <div class="mt-2">
                    <a class="btn btn-dark" th:href="@{/file/download(id=${id})}">Download</a>
                    <button type="button" class="btn btn-danger" th:onclick="fnFileDelete(this, [[${id}]])">Delete File</button>
                </div>
            </div>
        </th:block>
    </div>

     

     

    Javascript

    let $origFileDiv = $(".custom-file");
    let fileMaxCnt = 3,
        fileMaxSize = 10485760,
        fileAllowExt = ["jpg", "jpeg", "png"];
    let deleteFileIdArr = [];
    
    function fnAddFileDiv() {
        let fileDivCnt = $(".custom-file").length;
    
        if (fileDivCnt >= fileMaxCnt) {
            alert("Can't add any more file.");
            return false;
        }
    
        let $copyFileDiv = $origFileDiv.clone(true);
    
        $copyFileDiv.find("input").val("");
        $copyFileDiv.find("label").text("Choose file");
        $copyFileDiv.find("label").attr("for", "customFile" + fileDivCnt);
        $copyFileDiv.find("input").attr("id", "customFile" + fileDivCnt);
        $copyFileDiv.find("input").attr("name", "customFile" + fileDivCnt);
    
        $("#fileDiv").append($copyFileDiv);
    }
    
    function fnDelFileDiv() {
        if ($(".custom-file").length <= 1) {
            alert("Can't Delete any more file.");
            return false;
        }
        $(".custom-file")[$(".custom-file").length - 1].remove();
    }
    
    function fnChngFile(obj) {
        let fileObj = $(obj)[0];
        let fileVal = fileObj.files[0].name;
        let fileSize = fileObj.files[0].size;
        let fileExt = fileVal.substring(fileVal.lastIndexOf(".") + 1, fileVal.length);
        let flag = true;
    
        if (fileAllowExt.indexOf(fileExt.toLowerCase()) < 0) {
            alert("It is not a registrable extension.");
        } else if (fileSize > fileMaxSize) {
            alert("Attachments can be registered up to 10MB.");
        } else if (($(".fileList").length + $(".custom-file-input").length) > 3) {
            alert("Attachments can be registered up to 3number.");
        } else {
            flag = false;
            $(obj).next("label").text(fileVal);
        }
    
        if (flag) {
            $(obj).val("");
            $(obj).next("label").text("Choose file");
        }
    }
    
    function fnFileDelete(obj, id) {
        if (confirm("Do you want to file delete it?")) {
            $(obj).parents(".fileList").remove();
            deleteFileIdArr.push(id);
        }
    }

     

    상세 HTML 전체 코드

    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorator="layout/default_layout">
    <!-- layout Content -->
    <th:block layout:fragment="content">
        <div class="container">
            <h1>Board View.</h1>
            <form id="frm" action="/board/view/action" method="post" th:with="info=${resultMap.info}" enctype="multipart/form-data">
                <input type="hidden" name="id" th:value="${info.id}">
                <div class="mb-3">
                    <label class="form-label font-weight-bold"><span class="text-danger">* </span>Title.</label>
                    <input type="text" class="form-control" name="title" th:value="${info.title}" required>
                </div>
                <div class="mb-3">
                    <label class="form-label font-weight-bold"><span class="text-danger">* </span>Content.</label>
                    <textarea class="form-control" rows="5" name="content" th:text="${info.content}" required></textarea>
                </div>
                <div class="mb-3">
                    <label class="form-label font-weight-bold"><span class="text-danger">* </span>Writer.</label>
                    <input type="text" class="form-control" name="registerId" th:value="${info.registerId}" required>
                </div>
                <div class="mb-3">
                    <label class="form-label font-weight-bold mt-2">File Upload.</label>
                    <div class="mb-3" id="fileDiv">
                        <div class="custom-file mt-1">
                            <input type="file" class="custom-file-input" id="customFile" name="customFile" onchange="fnChngFile(this);">
                            <label class="custom-file-label" for="customFile">Choose file</label>
                        </div>
                    </div>
                    <div class="float-right mt-2">
                        <a class="btn btn-primary text-white" href="javascript:fnAddFileDiv();">+</a>
                        <a class="btn btn-danger text-white" href="javascript:fnDelFileDiv();">-</a>
                    </div>
                    <th:block th:if="${resultMap.fileList}">
                        <div class="fileList mt-3" th:each="id : ${resultMap.fileList}">
                            <img alt="image file" style="width: 50%" class="form-control img-thumbnail mt-3" th:src="@{/file/download(id=${id})}">
                            <div class="mt-2">
                                <a class="btn btn-dark" th:href="@{/file/download(id=${id})}">Download</a>
                                <button type="button" class="btn btn-danger" th:onclick="fnFileDelete(this, [[${id}]])">Delete File</button>
                            </div>
                        </div>
                    </th:block>
                </div>
                <div class="clearfix mt-5">
                    <div class="">
                        <button type="button" class="btn btn-success" onclick="javascript:location.href='/board/list'">Previous</button>
                        <button type="button" class="btn btn-primary" onclick="fnSubmit();">Edit</button>
                        <button type="button" class="btn btn-danger" th:onclick="fnViewDelete()">Delete</button>
                    </div>
                </div>
            </form>
        </div>
        <script th:inline="javascript">
            let frm = $("#frm");
            let $origFileDiv = $(".custom-file");
            let fileMaxCnt = 3,
                fileMaxSize = 10485760,
                fileAllowExt = ["jpg", "jpeg", "png"];
            let deleteFileIdArr = [];
    
            function fnAddFileDiv() {
                let fileDivCnt = $(".custom-file").length;
    
                if (fileDivCnt >= fileMaxCnt) {
                    alert("Can't add any more file.");
                    return false;
                }
    
                let $copyFileDiv = $origFileDiv.clone(true);
    
                $copyFileDiv.find("input").val("");
                $copyFileDiv.find("label").text("Choose file");
                $copyFileDiv.find("label").attr("for", "customFile" + fileDivCnt);
                $copyFileDiv.find("input").attr("id", "customFile" + fileDivCnt);
                $copyFileDiv.find("input").attr("name", "customFile" + fileDivCnt);
    
                $("#fileDiv").append($copyFileDiv);
            }
    
            function fnDelFileDiv() {
                if ($(".custom-file").length <= 1) {
                    alert("Can't Delete any more file.");
                    return false;
                }
                $(".custom-file")[$(".custom-file").length - 1].remove();
            }
    
            function fnChngFile(obj) {
                let fileObj = $(obj)[0];
                let fileVal = fileObj.files[0].name;
                let fileSize = fileObj.files[0].size;
                let fileExt = fileVal.substring(fileVal.lastIndexOf(".") + 1, fileVal.length);
                let flag = true;
    
                if (fileAllowExt.indexOf(fileExt.toLowerCase()) < 0) {
                    alert("It is not a registrable extension.");
                } else if (fileSize > fileMaxSize) {
                    alert("Attachments can be registered up to 10MB.");
                } else if (($(".fileList").length + $(".custom-file-input").length) > 3) {
                    alert("Attachments can be registered up to 3number.");
                } else {
                    flag = false;
                    $(obj).next("label").text(fileVal);
                }
    
                if (flag) {
                    $(obj).val("");
                    $(obj).next("label").text("Choose file");
                }
            }
    
            function fnFileDelete(obj, id) {
                if (confirm("Do you want to file delete it?")) {
                    $(obj).parents(".fileList").remove();
                    deleteFileIdArr.push(id);
                }
            }
    
            function fnViewDelete() {
                if (confirm("Do you want to delete it?")) {
                    frm.attr("action", "/board/view/delete");
                    frm.submit();
                }
            }
    
            function fnSubmit() {
                if (confirm("Do you want to edit it?")) {
                    $("#frm").submit();
                }
            }
    
            $(function() {
                frm.validate({
                    messages: {
                        title: {
                            required: "Custom required, Please enter a title."
                        }
                    },
                    submitHandler: function(form) {
                        if (deleteFileIdArr.length > 0) {
                            $.ajax({
                                url: "/file/delete.ajax",
                                type: "post",
                                data: {
                                    idArr: deleteFileIdArr
                                },
                                dataType: "json",
                                success: function(r) {
                                    if (r.result) {
                                        form.submit();
                                    } else {
                                        alert("A problem occurred, and progress is interrupted. Try again in a few minutes.");
                                    }
                                },
                                error: function(e) {
                                    console.log(e);
                                }
                            });
                        } else {
                            form.submit();
                        }
                    }
                });
            });
        </script>
    </th:block>
    </html>

     

     

    테스트

     

     

     

    #마무리

    • 파일 삭제시 실제 파일이 삭제되지 않고 삭제 여부만 업데이트되는데 실제 파일을 삭제하고 싶은 경우 File 객체의 delete() 메서드를 사용
    • 파일 개수, 사이즈, 확장자에 대한 검증이 화면에서만 이루어져 있기 때문에 서버에서도 한번 더 검증하는 걸 권장
    • 파일 개수, 사이즈, 확장자, 파일 업로드 경로 등의 관리가 필요한 데이터는 properties에서 관리
    • 반복되는 함수는 공통 스크립트 파일을 만들어 관리

     

    #GitHub

    https://github.com/conf312/board_study.git

     

    #프로젝트 Import 방법

    압축을 풀고, 이클립스 import    General  →  Existing Projects into Workspace

    board.zip
    0.23MB

    반응형

    댓글

Designed by Tistory.