commons-io로 구현하는 Multipart

🗓️

관리 차원에서 후속편 안내

이 글 역시 이 블로그에서 꾸준히 인기가 좋아 commons-io가 아닌 스프링 multipart를 사용한 파일 업로드 기능을 다시 정리해서 올려봤다. 업로드와 다운로드 두편으로 나눠서 올라가고 우선 업로드편 링크를 남긴다.

👉 스프링 부트 Multipart 업로드편


파일 업로드와 다운로드에서 고려해야 할 부분

  1. 첨부파일의 유효성 검사
  2. 파일 전송의 진행률 표시
  3. 예외처리
  4. 사용자 편의성의 드래그 앤 드롭 기능

파일 업로드 scheme

CREATE TABLE t_file(
    idx int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '일련번호',
    board_idx int(10) unsigned NOT NULL COMMENT '게시글 번호',
    original_file_name varchar(255) NOT NULL COMMENT '원본 파일 이름',
    stored_file_path varchar(500) NOT NULL COMMENT '파일 저장 경로',
    file_size int(15) unsigned NOT NULL COMMENT '파일 크기',
    creator_id varchar(50) NOT NULL COMMENT '작성자 아이디',
    created_datetime datetime NOT NULL COMMENT '작성 시간',
    updator_id varchar(50) DEFAULT NULL COMMENT '수정자 아이디',
    updated_datetime datetime DEFAULT NULL COMMENT '수정 시간',
    deleted_yn char(1) NOT NULL DEFAULT 'N' COMMENT '삭제 여부',
    PRIMARY KEY(idx)    
);

사용자 업로드 파일 이름을 변경하는 이유

→ 동일한 이름을 가진 파일이 업로드 될 경우 사용 중인 운영체제에 따라서 저장 되지 않거나 파일명이 바뀌거나 덮어쓰기가 될 수 있다. 이같은 경우 먼저 업로드 된 파일이 수정될 가능성이 있다. 이 같은 문제를 해결하기 위해 파일을 젖아할 때는 각 프로젝트에서 정한 규칙에 따라 새로운 파일 이름으로 저장하고 데이터베이스에는 저장된 파일 이름과 원본 파일 이름을 같이 저장한다.

MultipartResolver

  • Spring framework에는 파일 업로드를 위한 MultipartResolver 인터페이스가 정의되어 있다.
  • Apache Commin Fileupload를 이용한 CommonsMultipartResolver와 Servlet 3.0 API를 이용한 StandardServletMultipartResolver 두가지가 있다.
  • 여기서는 아파치의 CommonsMultipartResolver를 사용한다.

build.gradle

compile group: 'commons-io', name: 'commons-io', version:'2.5'
compile group: 'commons-fileupload', name: 'commons-fileupload', version:'1.3.3'
  • commons-fileupload는 commons-io에 의존을 갖고 있다.
  • 근데 gradle 반영이 잘 안된다.. 이거때문에 한시간 삽질함.. 왠만하면 터미널에서 직접 gradle build합시다.

Bean configuration

  • CommonsMultipartResolver를 이용해 MultipartResolver를 구현한다
/// WebMVCConfiguration.java
@Bean
public CommonsMultipartResolver multipartResolver(){
    CommonsMultipartResolver commonsMultipartResolver = new CommonsMultipartResolver();
    commonsMultipartResolver.setDefaultEncoding("UTF-8");
    commonsMultipartResolver.setMaxUploadSize(50 * 1024 * 1024);
    return commonsMultipartResolver;
}
  • 파일 이름 인코딩과 용량을 설정한다 (UTF-8, 50MB)

MultipartAutoConfiguration 제거

  • 앞에서 MultipartResolver를 구현했기 때문에 자동구성을 제거한다
// WebappBoardApplication.java
@SpringBootApplication(exclude={MultipartAutoConfiguration.class})

1) 파일 업로드 및 파일 확인

  • 파일 업로드를 구현한다.

template (view) code

<!-- boardWrite.html -->
<form id="frm" name="frm" method="post" action="/board/insertBoard.do"
enctype="multipart/form-data">
  <!-- ... -->
      </table>
<input type="file" id="files" name="files" multiple="multiple">
<input type="submit" id="submit" value="글 저장" class="btn">
  • 파일이 post에 첨부 될 수 있도록 enctype을 지정한다.
  • 여러개의 파일을 첨부할 수 있도록 multiple attribute를 첨부한 input을 추가한다.

controller code

// BoardController
@RequestMapping("/board/insertBoard.do")
public String insertBoard(BoardDto board,
    MultipartHttpServletRequest multipartHttpServletRequest) throws Exception {
    boardService.insertBoard(board, multipartHttpServletRequest);
    return "redirect:/board/openBoardList.do";
}

MultipartHttpServletRequest 파라미터를 추가한다.

service code

//BoardService
void insertBoard(BoardDto board, MultipartHttpServletRequest multipartHttpServletRequest)
        throws Exception;

//BoardServiceImplement
@Override
public void insertBoard(BoardDto board, MultipartHttpServletRequest multipartHttpServletRequest)
    throws Exception {
//        boardMapper.insertBoard(board);
    if (ObjectUtils.isEmpty(multipartHttpServletRequest) == false) {
        Iterator<String> filenameIterator = multipartHttpServletRequest.getFileNames();
        String name;
        while (filenameIterator.hasNext()) {
            name = filenameIterator.next();
            log.debug("File name tag : " + name);
            List<MultipartFile> fileList = multipartHttpServletRequest.getFiles(name);
            for (MultipartFile multipartFile : fileList) {
                log.debug("--- start file ---");
                log.debug("File name : " + multipartFile.getOriginalFilename());
                log.debug("File size : " + multipartFile.getSize());
                log.debug("File content-type : " + multipartFile.getContentType());
                log.debug("--- end file ---");

            }
        }
    }
}
  • Iterator<String> filenameIterator = Iterator<String> filenameIterator = multipartHttpServletRequest.getFileNames(); : 설명 추가하기
  • multipartHttpServletRequest.getFiles(name); : 설명 추가하기

디버그 로깅 확인

2021-03-06 15:34:06,171 DEBUG [org.platanus.webappboard.interceptor.LoggerInterceptor]  Request URI     :  /board/insertBoard.do
2021-03-06 15:34:06,179 DEBUG [org.platanus.webappboard.aop.LoggerAspect] Controller     :org.platanus.webappboard.app.controller.BoardController.insertBoard()
2021-03-06 15:34:06,180 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] File name tag : files
2021-03-06 15:34:06,180 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] --- start file ---
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] File name : 20160915-154647-61-LR71.jpg
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] File size : 888198
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] File content-type : image/jpeg
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] --- end file ---
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] --- start file ---
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] File name : 20161004-141324-61-LR8-4K.jpg
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] File size : 3839475
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] File content-type : image/jpeg
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] --- end file ---
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] --- start file ---
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] File name : 20190316-4K-134442-8692-LR71-2.jpg
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] File size : 4638664
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] File content-type : image/jpeg
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.app.service.BoardServiceImplement] --- end file ---
2021-03-06 15:34:06,181 DEBUG [org.platanus.webappboard.interceptor.LoggerInterceptor] ---- END ----

잘 불러와진다

2) 업로드 된 파일 저장하기

  • 파일을 직접 저장하기 위해 DTO를 지정하고 DB에 매핑한다.
  • Multipart를 이용해 파일의 정보를 가져오고 File로 저장한다.

DTO creation

//common/FileUtils
public class FileUtils {

    int boardIdx;
    MultipartHttpServletRequest multipartHttpServletRequest;

    public FileUtils(int boardIdx, MultipartHttpServletRequest multipartHttpServletRequest) {
        this.boardIdx = boardIdx;
        this.multipartHttpServletRequest = multipartHttpServletRequest;
    }

    public List<BoardFileDto> parseFileInfomation() throws Exception {
        if (ObjectUtils.isEmpty(multipartHttpServletRequest)) {
            return null;

        }

        List<BoardFileDto> dtoFileList = new ArrayList<>();
        DateTimeFormatter dateFormat = DateTimeFormatter.ofPattern("yyyyMMdd");
        ZonedDateTime currentTime = ZonedDateTime.now();
        String path = "images/" + currentTime.format(dateFormat);
        File file = new File(path);
        if (!file.exists()) {
            file.mkdirs();
        }

        Iterator<String> filenameIterator = multipartHttpServletRequest.getFileNames();
        String replaceFileName, originalFileExtension, contentType;

        while (filenameIterator.hasNext()) {
            List<MultipartFile> fileList = multipartHttpServletRequest
                .getFiles(filenameIterator.next());
            for (MultipartFile multipartFile : fileList) {
                if (!multipartFile.isEmpty()) {
                    contentType = multipartFile.getContentType();
                    if (ObjectUtils.isEmpty(contentType)) {
                        break;
                    } else {
                        if (contentType.contains("image/jpeg")) {
                            originalFileExtension = ".jpg";
                        } else if (contentType.contains("image/png")) {
                            originalFileExtension = ".png";
                        } else if (contentType.contains("image/gif")) {
                            originalFileExtension = ".gif";
                        } else {
                            break;
                        }
                    }
                    replaceFileName = System.nanoTime() + originalFileExtension;
                    BoardFileDto boardFile = new BoardFileDto();
                    boardFile.setBoardIdx(boardIdx);
                    boardFile.setFileSize(multipartFile.getSize());
                    boardFile.setOriginalFileName(multipartFile.getOriginalFilename());
                    boardFile.setStoredFilePath(path + "/" + replaceFileName);
                    dtoFileList.add(boardFile);

                    file = new File(path + "/" + replaceFileName);
                    multipartFile.transferTo(file);
                }
            }
        }
        return dtoFileList;
    }

}

책의 코드는 겁도 없이 pulic static으로 잡혀있길래 일단 @Componen 걷어내고 수동으로 DI만 구현함.. 나머지 코드 지저분한건 구현하고 나서 나중에 정리하겠음.

//controller
@RequestMapping("/board/openBoardDetail.do")
    public ModelAndView openBoardDetail(@RequestParam int boardIdx) throws Exception {
        ModelAndView modelAndView = new ModelAndView("/board/boardDetail");

        BoardDto board = boardService.selectBoardDetail(boardIdx);
        modelAndView.addObject("board", board);

        return modelAndView;
    }

여기도 마찬가지로 DI

mapper code

    void insertBoardFileList(List<BoardFileDto> fileList) throws Exception;

SQL query

<insert id="insertBoard" parameterType="org.platanus.webappboard.app.dto.BoardDto"
  useGeneratedKeys="true" keyProperty="boardIdx">
  • useGeneratedKeys : DBMS의 Auto-increment를 지원하면 사용 할 수 있다.
  • keyProperty : useGeneratedKeys나 selectKey의 하위 엘리먼트에 의해 리턴되는 키를 의미힌다. 게시글의 경우 board_idx 칼럼이 PK면서 자동생성이 되게끔 했기 때문에 이 칼럼을 사용한다. DB에서 새로운 게시글이 등록되면 BoardDtoboardIdx에 새로운 게시글 번호가 저장되어 반환된다. 즉, 특별한 코드 없이도 새로운 게시글 번호를 사용할 수 있다.
  <insert id="insertBoardFileList" parameterType="org.platanus.webappboard.app.dto.BoardFileDto">
<![CDATA[
        INSERT INTO t_file
        (
            board_idx,
            original_file_name,
            stored_file_path,
            file_size,
            creator_id,
            created_datetime
        )
        VALUES
    ]]>
<foreach collection="list" item="item" separator=",">
  (
  #{item.boardIdx},
  #{item.originalFileName},
  #{item.storedFilePath},
  #{item.fileSize},
  'admin',
  NOW()
  )
</foreach>
</insert>

Mapper의 메소드에서 List\<BoardFileDto>형을 받았기 때문에 iteration 가능하다.

파일이 저장되는 위치

  • images로 경로를 지정한 파일 위치는 프로젝트 루트에 저장된다

3) 첨부된 파일 목록 보여주기

  • 업로드된 파일을 저장까지 하도록 했다. 이제 첨부한 파일의 목록까지 출력해보자.

SQL query

<select id="selectBoardFileList" parameterType="int"
resultType="org.platanus.webappboard.app.dto.BoardFileDto">
    <![CDATA[
SELECT idx,
       board_idx,
       original_file_name,
       ROUND(file_size / 1024) AS file_size
FROM t_file
WHERE board_idx = #{boardIdx}
  AND deleted_yn = 'N'
]]>
</select>

mapper code

  • 추가한 SQL에 연결되는 Mapper를 추가한다.
// BoardMapper
List<BoardFileDto> selectBoardFileList(int boardIdx) throws Exception;

DTO code

  • DTO에 마찬가지로 파일DTO를 추가한다.
// BoardDto
private List<BoardFileDto> fileList;

service code

  • 글 조회 화면에서 파일DTO를 가지고 올 수 있도록 한다.
@Override
public BoardDto selectBoardDetail(int boardIdx) throws Exception {

    BoardDto board = boardMapper.selectBoardDetail(boardIdx);
    List<BoardFileDto> fileList = boardMapper.selectBoardFileList(boardIdx);
    board.setFileList(fileList);

    boardMapper.updateHitCount(boardIdx);

    return board;
}

게시물DTO에 파일DTO를 List로 넣도록 한다.

template (view) code

  • 웹페이지에 출력 될 수 있도록 thymeleaf코드를 수정한다.
<div class="file_list">
<a th:each="list : ${board.fileList}"
   th:text="|${list.originalFileName}(${list.fileSize} KB)|"></a>
</div>

결과

174750d431b0702d0f4c932865cf1fdb.png
  • 게시글에 해당하는 첨부파일 목록이 잘 출력된다.

4) 파일 다운로드

  • 첨부파일을 목록에 띄우는것 까지 했으니 이제 다운로드까지 (URL mapping)까지 구현한다.

template (view) code

  • 파일 다운로드 URI를 붙인 코드로 변경한다
<a th:each="list : ${board.fileList}"
   th:href="@{/board/downloadBoardFile.do(idx=${list.idx}, boardIdx=${list.boardIdx})}"
   th:text="|${list.originalFileName} (${list.fileSize} kb)|"></a>

SQL query

  • 파일을 다운로드하기 위한 sql query를 작성한다
<select id="selectBoardFileInformation" parameterType="map"
resultType="org.platanus.webappboard.app.dto.BoardFileDto">
<![CDATA[
SELECT original_file_name,
       stored_file_path,
       file_size
FROM t_file
WHERE idx = #{idx}
  AND board_idx = #{boardIdx}
  AND deleted_yn = 'N'
]]>
</select>

mapper code

  • selectBoardFileInformation에 해당하는 mapper를 추가한다.
//BoardMapper
BoardFileDto selectBoardFileInformation(
    @Param("idx") int idx, @Param("boardIdx") int boardIdx);
  • Mybatis에서 인자가 2개 이상인 매퍼를 연결하기 위해 명시하는 어노테이션 @param이 있다.

service code

  • 서비스 코드를 구현한다.
//board (interface)
BoardFileDto selectBordFileInformation(int idx, int boardIdx) throws Exception;
//board (implement)
@Override
public BoardFileDto selectBordFileInformation(int idx, int boardIdx) throws Exception {
    return boardMapper.selectBoardFileInformation(idx, boardIdx);
}

controller

  • 요청에 실제로 파일을 응답으로 내려주는 기능을 구현한다.
///controller
@RequestMapping("/board/downloadBoardFile.do")
public void downloadBoardFile(
    @RequestParam int idx,
    @RequestParam int boardIdx,
    HttpServletResponse response) throws Exception {
    BoardFileDto boardFile = boardService.selectBordFileInformation(idx, boardIdx);
    if (!ObjectUtils.isEmpty(boardFile)) {
        String fileName = boardFile.getOriginalFileName();

        byte[] files = FileUtils.readFileToByteArray(
            new File(boardFile.getStoredFilePath()));

        response.setContentType("application/octet-stream");
        response.setContentLength(files.length);
        response.setHeader(
            "Content-Disposition",
            "attachment; fileName=\"" +
                URLEncoder.encode(fileName, "UTF-8") +
                "\";");
        response.setHeader(
            "Content-Transfer-Encoding", 
            "binary");
        response.getOutputStream().write(files);
        response.getOutputStream().flush();
        response.getOutputStream().close();
    }
}
  • BoardFileDto boardFile = boardService.selectBordFileInformation(idx, boardIdx); : 설명 추가

Response setter

  • setContentType : 설명 추가하기
  • setContentLength : 설명 추가하기
  • setHeader (Content-Disposition) : 설명 추가하기
  • setHeader (Content-Transfer-Encoding) : 설명 추가하기
  • getOutputStream : 설명 추가하기