Path filePath = Paths.get(uploadPath, uploadedDate, filename): 앞선 과정 중 DB에서 조회하여 파라미터로 넘어온 파일의 정보를 이용해 해당 파일이 저장되어 있는 파일의 경로
Resource resource = new UrlResource(filePath.toUri()): 위에서 만든 파일의 경로를 Resource 인터페이스의 구현체인 UrlResource 객체 생성자에 집어넣어 결국 클라이언트에게 제공할 수 있는 Resource 객체를 만들게 된다.
생성한 Resource가 존재하지 않거나 파일이 아닐 경우에 인지할 수 있는 간단한 예외처리를 한다.
5. FileRestController 클래스 수정 - 파일 다운로드 매핑 메소드 추가
// 파일 다운로드@GetMapping("fileDownload")public ResponseEntity<Resource> downloadFile(@RequestParam("seq")int seq){
FileResponse file = fileService.getFileBySeq(seq);
Resource resource = fileUtils.readFileAsResource(file);
try {
String filename = URLEncoder.encode(file.getOriginalName(), "UTF-8"); // 다운로드 시 파일명return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM) // HTTP MIME 타입 설정
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; fileName=\""+filename+"\";") // 헤더에 파일명 설정
.header(HttpHeaders.CONTENT_LENGTH, file.getSize() + "") // 헤더에 파일크기 설정
.body(resource);
} catch (UnsupportedEncodingException e) {
thrownew RuntimeException("filename encoding failed: "+file.getOriginalName());
}
}
다운로드 요청을 받고 앞서 추가한 메서드들을 호출해서 파일의 정보를 조회하고, 조회된 파일 정보를 가지고 Resource 객체를 만들어서 클라이언트에게 반환한다.
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM) // HTTP MIME 타입 설정
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; fileName=\""+filename+"\";") // 이 응답은 파일 다운로드
.header(HttpHeaders.CONTENT_LENGTH, file.getSize() + "") // 헤더에 파일크기 설정
.body(resource);
클라이언트에게 반환되는 이 응답은 파일 다운로드이다. 라는 것을 알려주기 위한 HTTP 헤더 설정 방법이다.
HttpHeaders.CONTENT_DISPOSITION: 이 값을 HTTP 헤더에 설정해주어야 클라이언트가 파일을 다운로드 받을 수 있다.
DB에 저장된 파일 테이블을 통해 파일정보를 클라이언트에게 응답해주기 위한 응답용 객체이다.
2. FileMapper 인터페이스 수정 - 게시글에 등록된 파일 조회 메서드 추가
@MapperpublicinterfaceFileMapper{
voidsaveAll(List<FileRequest> files); // 파일 정보 저장List<FileResponse> getFilesByBoardSeq(int boardSeq); // 게시글 파일 리스트 조회
}
List<FileResponse> getFilesByBoardSeq(int boardSeq):게시글 기본키인 boardSeq 값을 참조하여 파일 테이블 내 해당 게시글의 파일들의 정보를 조회하는 메서드
리턴 타입은 방금 생성한 FileResponse 클래스로 지정한다.
3. FileMapper 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" >
<mappernamespace="com.example.file.service.FileMapper"><sqlid="fileColumns">
BOARD_SEQ
, ORIGINAL_NAME
, SAVE_NAME
, SIZE
, DELETE_YN
, CREATED_DATE
, DELETED_DATE
</sql><insertid="saveAll"parameterType="List">
INSERT INTO BOARD_FILE (
<includerefid="fileColumns"></include>
) VALUES
<foreachitem="file"collection="files"separator=",">
(
#{file.boardSeq}
, #{file.originalName}
, #{file.saveName}
, #{file.size}
, 'N'
, NOW()
, NULL
)
</foreach></insert><!-- getFilesByBoardSeq 조회 결과 테이블을 FileResponse 객체와 매핑 --><resultMaptype="egovframework.example.file.dto.FileResponse"id="fileResponse"><resultproperty="seq"column="SEQ"/><resultproperty="boardSeq"column="BOARD_SEQ"/><resultproperty="originalName"column="ORIGINAL_NAME"/><resultproperty="saveName"column="SAVE_NAME"/><resultproperty="size"column="SIZE"/><resultproperty="deleteYn"column="DELETE_YN"/><resultproperty="createdDate"column="CREATED_DATE"/><resultproperty="deletedDate"column="DELETED_DATE"/></resultMap><selectid="getFilesByBoardSeq"resultMap="fileResponse"><!-- 게시글 파일 조회 기능 추가 -->
SELECT
SEQ,
<includerefid="fileColumns"></include>
FROM
BOARD_FILE
WHERE
DELETE_YN = 'N'
AND BOARD_SEQ = #{boardSeq}
</select></mapper>
앞서 생성한 Mapper 인터페이스의 getFilesBoardSeq() 메서드와 연결할 쿼리를 작성한다.
게시글 테이블의 기본키인 BOARD_SEQ 칼럼을 참조하여 해당 게시글의 해당되는 파일들을 조회한다.
조회 결과를 FileResponse 객체로 변환하여야 하기 때문에 ResultMap 태그를 작성해준다.
4. FileService 클래스 수정 - 게시글에 등록된 파일 조회 메서드 추가
public List<FileResponse> getFilesByBoardSeq(int boardSeq){ // 파일 정보 조회return fileMapper.getFilesByBoardSeq(boardSeq);
}
Mapper 인터페이스를 통해 XML 쿼리까지 연결되기 위해 FileService 클래스에도 메서드를 추가해준다.
게시판 테이블의 기본키, serial 타입으로 인해 Auto Increament 속성 적용
title
게시글의 제목
writer
게시글의 작성자
reg_date
게시글의 작성일자
count
게시글의 조회수
content
게시글의 내용
해당 테이블이 존재한다는 가정하에 파일 테이블을 다음과 같이 생성한다.
Column
Description
seq
파일 테이블의 기본키, serial 타입으로 인해 Auto Increament 속성 적용
board_seq
게시판 테이블의 기본키를 참조하는 외래키, 게시글마다 첨부파일을 연결하기 위한 칼럼
original_name
업로드하는 첨부파일의 원본 파일명
save_name
컴퓨터 내 물리적으로 보관될 파일명
size
파일의 크기
delete_yn
파일의 삭제 여부
created_date
파일의 생성일자
deleted_date
파일의 삭제일자
여기서 핵심적인 칼럼은 original_name과 save_name이다. 두 칼럼 모두 파일의 이름을 의미하지만, DB 테이블에만 저장되는 original_name과 컴퓨터 내 물리적으로 저장되는 save_name은 서로 달라야 한다.
예를 들어, 서로 다른 사용자가 "춘식이.jpg" 라는 이름의 파일을 저장했을 때 이 첨부파일의 이름을 그대로 서버 컴퓨터 내 저장하게 되면 각각 어느 사용자가 업로드한 춘식이인지 구별할 수 없다. 그래서 실제로 저장되는 파일명은 랜덤값을 통해 고유한 식별값으로 지정되어야 한다.
파일을 업로드할 게시글의 번호와 파일 정보를 받아 fileMapper의 saveAll 메소드를 호출하여 DB까지 연결되도록 한다.
saveAll(files)를 통해 여러개의 파일이 삽입되던 중 한가지의 파일을 삽입하다가 문제가 발생되면 해당 메소드 호출 이전으로 롤백될 수 있도록 @Transactional 어노테이션을 사용한다.
6. FileUtils 클래스 생성
@ComponentpublicclassFileUtils{
// 물리적으로 파일을 저장할 위치(C:\\boardPrc\\upload-files)privatefinal String uploadPath = Paths.get("D:", "boardPrc", "upload-files").toString();
// 다중 파일 업로드public List<FileRequest> uploadFiles(final List<MultipartFile> multipartFiles){
List<FileRequest> files = new ArrayList<>();
for (MultipartFile multipartFile : multipartFiles) {
if(multipartFile.isEmpty()) {
continue;
}
files.add(uploadFile(multipartFile));
}
return files;
}
// 단일 파일 업로드public FileRequest uploadFile(final MultipartFile multipartFile){
if(multipartFile.isEmpty() ) {
returnnull;
}
// 디스크(폴더)에 저장할 파일명
String saveName = generateSaveFilename(multipartFile.getOriginalFilename());
// 오늘날짜 (240706)
String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd")).toString();
// 파일의 업로드 경로 (D:\\boardPrc\\upload-files\\240706\\saveName)
String uploadPath = getUploadPath(today) + File.separator + saveName;
// 업로드할 경로의 파일 객체
File uploadFile = new File(uploadPath);
try {
multipartFile.transferTo(uploadFile); // 파일 업로드
} catch (IOException e) {
thrownew RuntimeException(e);
}
return FileRequest.builder() // 업로드 한 파일의 정보를 빌더패턴으로 DTO에 담는다.
.originalName(multipartFile.getOriginalFilename())
.saveName(saveName)
.size(multipartFile.getSize())
.build();
}
// saveName 생성 (실제로 폴더에 저장될 파일명)private String generateSaveFilename(final String filename){
String uuid = UUID.randomUUID().toString().replaceAll("-", ""); // 파일의 식별을 위해 랜덤값 지정
String extension = StringUtils.getFilenameExtension(filename);
return uuid + "." + extension;
}
private String getUploadPath(){
return makeDirectories(uploadPath);
}
private String getUploadPath(final String addPath){
return makeDirectories(uploadPath + File.separator + addPath);
}
private String makeDirectories(final String path){
File dir = new File(path);
if(dir.exists() == false) {
dir.mkdirs();
}
return dir.getPath();
}
파일을 직접 MultipartFile 객체를 이용해 서버 컴퓨터 내 물리적으로 업로드를 하고, 업로드 할 파일의 정보를 가지고 FileRequest 객체를 만드는 클래스이다.
이곳에서 만들어진 FileRequest 객체를 가지고 DB의 파일 정보 테이블에 삽입하게 된다.
String uploadPath = Paths.get("D:", "boardPrc", "upload-files").toString(): 물리적으로 파일을 저장할 경로를 담는 변수이다.
FileRequest uploadFile(MultipartFile multipartFile): 물리적으로 저장되는 파일명인 saveName 및 추가적인 폴더명을 생성하여 만든 파일 저장 경로를 가지고 서버 컴퓨터 내 실제로 파일을 업로드하고, 업로드 된 정보를 빌더 패턴을 통해 FileRequest 객체로 변환하여 리턴한다.
List<FileRequest> uploadFiles(List<MultipartFile> multipartFiles): MultipartFile 객체가 여러개일 경우 List 형태로 받아서 반복문으로 각 파일을 uploadFile() 메소드를 사용하여 다중 파일 처리를 하는 메소드이다.
String generateSaveFilename(String filename): 서버 컴퓨터 내 폴더에 물리적으로 저장할 파일명을 생성하는 메소드이다. 앞서 설명했듯이 디스크에 저장되는 파일명들은 각 고유한 식별값을 가져야 하므로 UUID라는 랜덤값을 이용해 생성한다.
String getUploadPath(): 맨 끝 상단에서 정의한 uploadPath 변수에 설정된 경로를 리턴하는데, 추가적인 경로 문자열을 파라미터로 넘기면 해당 경로를 추가하여 채로 리턴한다.
String makeDirectories(String path): getUploadPath() 메소드에서 사용하는 메소드로, 경로를 파라미터로 받고 해당 경로가 존재하지 않으면 폴더를 새로 생성하고, 존재하면 경로 그대로를 다시 리턴한다.
BoardService의 registBoard() 메소드는 기존에 boardMapper를 통해 게시글만 DB에 삽입하는 역할 뿐만 아니라 MultipartFile 객체를 처리하여 파일 업로드, 파일정보 DB 삽입 기능 등을 FileUtils와 FileService 클래스에게 직접 위임하는 역할이 추가되었다.
List<FileRequest> files = fileUtils.uploadFiles(multipartFiles): MultipartFile 객체를 앞서 작성한 FileUtils의 uploadFiles() 메소드에게 넘기며 호출한다.
fileService.saveFiles((int) board.get("SEQ"), files): 생성된 게시글의 기본키 값과 생성된 FileRequest를 가지고 FileService에게 DB에 삽입되도록 saveFiles() 메소드를 호출한다.
스프링 부트에서는 요청되는 MultipartFile 객체 파일의 사이즈에 제한이 되어있는데, 요청에 포함되는 파일 한 개당 최대 1MB, 전체 파일의 합은 최대 10MB로 설정되어 있다.
이를 한 개당 10MB, 총 50MB로 바꿔보자.
10. 게시글 등록 폼에 파일 영역 추가
<formaction="/registBoard"method="post"enctype="multipart/form-data"><labelfor="title">제목:</label><inputtype="text"id="title"name="title"required><br><br><labelfor="author">작성자:</label><inputtype="text"id="author"name="author"required><br><br><labelfor="content">내용:</label><textareaid="content"name="content"rows="10"cols="50"required></textarea><br><br><labelfor="files">파일 선택:</label><!-- 파일 영역 추가 --><inputtype="file"id="files"name="files"multiple><br><br><inputtype="submit"value="작성"></form>
※ 결과
게시판 테이블파일 테이블, board_seq 칼럼 값이 게시글 테이블의 seq와 외래키로써 같은 것을 볼 수 있다.앞서 설장한 파일 경로에 파일 테이블의 save_name 값으로 파일명이 생성되고, 춘식이 사진이 잘 업로드 되었다.
type="com.example.file.dto.FileResponse": 쿼리 조회 결과를 담을 DTO 클래스 경로를 입력한다.
id="fileResponse": 해당 ResultMap 태그의 독자적인 식별 아이디를 입력한다.
property="DTO 변수명": 칼럼과 연결할 DTO의 변수명을 입력한다.
column="칼럼명": DTO 변수와 연결할 칼럼명을 입력한다.
2. Mapper.xml 내 Select 태그 수정
<selectid="getFilesByBoardSeq"resultMap="fileResponse">
SELECT
SEQ,
<includerefid="fileColumns"></include>
FROM
BOARD_FILE
WHERE
DELETE_YN = 'N'
AND BOARD_SEQ = #{boardSeq}
</select>
resultMap="fileResponse": 앞서 정의한 resultMap 태그의 id 값을 입력한다.