관리 차원에서 후속편 안내
이 글 역시 이 블로그에서 꾸준히 인기가 좋아 commons-io가 아닌 스프링 multipart를 사용한 파일 업로드 기능을 다시 정리해서 올려봤다. 업로드와 다운로드 두편으로 나눠서 올라가고 우선 업로드편 링크를 남긴다.
파일 업로드와 다운로드에서 고려해야 할 부분
- 첨부파일의 유효성 검사
- 파일 전송의 진행률 표시
- 예외처리
- 사용자 편의성의 드래그 앤 드롭 기능
파일 업로드 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에서 새로운 게시글이 등록되면BoardDto
의boardIdx
에 새로운 게시글 번호가 저장되어 반환된다. 즉, 특별한 코드 없이도 새로운 게시글 번호를 사용할 수 있다.
<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>
결과
- 게시글에 해당하는 첨부파일 목록이 잘 출력된다.
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
: 설명 추가하기