2022. 8. 5. 11:04ㆍSpring Boot
이번 포스팅은 파일 업로드, 다운로드에 대한 간단한 예제를 만들어보려고 한다.
DB없는 방식으로 만든 후, 이후에 Oracle과 연동하여 작성할것이다.
프로젝트 생성 - dependency 관리
먼저 선호하는 이름으로 프로젝트를 생성하면 된다.
필자의 개발환경은 다음과 같다.
IDE : STS4
Build : Gradle
OS : window
필요한 라이브러리를 먼저 설정해야 한다. maven의 경우 이곳을 클릭하여 maven버전으로 다운받으면 된다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation group: 'commons-io', name: 'commons-io', version: '2.11.0'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
파일 업로드/다운로드 로직 구현 시 commons-io 라이브러리를 통해 편리한 메소드를 사용할 수 있으니 다운받도록 하자.
View 페이지 생성
본격적인 비즈니스 로직 생성 전, 화면에 보일 업로드, 다운로드 페이지를 만들어보자.
html, js, css는 해당 github 코드를 참고했다.
https://github.com/callicoder/spring-boot-file-upload-download-rest-api-example
src/main/resources/static/index.html
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" charset="utf-8" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<title>Spring Boot File Upload / Download Rest API Example</title>
<link rel="stylesheet" href="/css/main.css" />
</head>
<body>
<noscript>
<h2>Sorry! Your browser doesn't support Javascript</h2>
</noscript>
<div class="upload-container">
<div class="upload-header">
<h2>Spring Boot File Upload / Download 예제</h2>
<h3>Jimkwon</h3>
</div>
<div class="upload-content">
<div class="single-upload">
<h3>Upload Single File</h3>
<form id="singleUploadForm" name="singleUploadForm">
<input id="singleFileUploadInput" type="file" name="file" class="file-input" required />
<button type="submit" class="primary submit-btn">Submit</button>
</form>
<div class="upload-response">
<div id="singleFileUploadError"></div>
<div id="singleFileUploadSuccess"></div>
</div>
</div>
<div class="multiple-upload">
<h3>Upload Multiple Files</h3>
<form id="multipleUploadForm" name="multipleUploadForm">
<input id="multipleFileUploadInput" type="file" name="files" class="file-input" multiple required />
<button type="submit" class="primary submit-btn">Submit</button>
</form>
<div class="upload-response">
<div id="multipleFileUploadError"></div>
<div id="multipleFileUploadSuccess"></div>
</div>
</div>
</div>
</div>
<script src="/js/main.js" ></script>
</body>
</html>
src/main/resources/static/css/main.css
* {
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-weight: 400;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 1rem;
line-height: 1.58;
color: #333;
background-color: #f4f4f4;
}
body:before {
height: 50%;
width: 100%;
position: absolute;
top: 0;
left: 0;
background: #128ff2;
content: "";
z-index: 0;
}
.clearfix:after {
display: block;
content: "";
clear: both;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 20px;
margin-bottom: 20px;
}
h1 {
font-size: 1.7em;
}
a {
color: #128ff2;
}
button {
box-shadow: none;
border: 1px solid transparent;
font-size: 14px;
outline: none;
line-height: 100%;
white-space: nowrap;
vertical-align: middle;
padding: 0.6rem 1rem;
border-radius: 2px;
transition: all 0.2s ease-in-out;
cursor: pointer;
min-height: 38px;
}
button.primary {
background-color: #128ff2;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);
color: #fff;
}
input {
font-size: 1rem;
}
input[type="file"] {
border: 1px solid #128ff2;
padding: 6px;
max-width: 100%;
}
.file-input {
width: 100%;
}
.submit-btn {
display: block;
margin-top: 15px;
min-width: 100px;
}
@media screen and (min-width: 500px) {
.file-input {
width: calc(100% - 115px);
}
.submit-btn {
display: inline-block;
margin-top: 0;
margin-left: 10px;
}
}
.upload-container {
max-width: 750px;
margin-left: auto;
margin-right: auto;
background-color: #fff;
box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27);
margin-top: 60px;
min-height: 400px;
position: relative;
padding: 20px;
}
.upload-header {
border-bottom: 1px solid #ececec;
}
.upload-header h2 {
font-weight: 500;
}
.single-upload {
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 1px solid #e8e8e8;
}
.upload-response {
overflow-x: hidden;
word-break: break-all;
}
src/main/resources/static/js/main.js
'use strict';
var singleUploadForm = document.querySelector('#singleUploadForm');
var singleFileUploadInput = document.querySelector('#singleFileUploadInput');
var singleFileUploadError = document.querySelector('#singleFileUploadError');
var singleFileUploadSuccess = document.querySelector('#singleFileUploadSuccess');
var multipleUploadForm = document.querySelector('#multipleUploadForm');
var multipleFileUploadInput = document.querySelector('#multipleFileUploadInput');
var multipleFileUploadError = document.querySelector('#multipleFileUploadError');
var multipleFileUploadSuccess = document.querySelector('#multipleFileUploadSuccess');
function uploadSingleFile(file) {
var formData = new FormData();
formData.append("file", file);
var xhr = new XMLHttpRequest();
xhr.open("POST", "/uploadFile");
xhr.onload = function() {
console.log(xhr.responseText);
var response = JSON.parse(xhr.responseText);
if(xhr.status == 200) {
singleFileUploadError.style.display = "none";
singleFileUploadSuccess.innerHTML = "<p>File Uploaded Successfully.</p><p>DownloadUrl : <a href='" + response.fileDownloadUri + "' target='_blank'>" + response.fileDownloadUri + "</a></p>";
singleFileUploadSuccess.style.display = "block";
} else {
singleFileUploadSuccess.style.display = "none";
singleFileUploadError.innerHTML = (response && response.message) || "Some Error Occurred";
}
}
xhr.send(formData);
}
function uploadMultipleFiles(files) {
var formData = new FormData();
for(var index = 0; index < files.length; index++) {
formData.append("files", files[index]);
}
var xhr = new XMLHttpRequest();
xhr.open("POST", "/uploadMultipleFiles");
xhr.onload = function() {
console.log(xhr.responseText);
var response = JSON.parse(xhr.responseText);
if(xhr.status == 200) {
multipleFileUploadError.style.display = "none";
var content = "<p>All Files Uploaded Successfully</p>";
for(var i = 0; i < response.length; i++) {
content += "<p>DownloadUrl : <a href='" + response[i].fileDownloadUri + "' target='_blank'>" + response[i].fileDownloadUri + "</a></p>";
}
multipleFileUploadSuccess.innerHTML = content;
multipleFileUploadSuccess.style.display = "block";
} else {
multipleFileUploadSuccess.style.display = "none";
multipleFileUploadError.innerHTML = (response && response.message) || "Some Error Occurred";
}
}
xhr.send(formData);
}
singleUploadForm.addEventListener('submit', function(event){
var files = singleFileUploadInput.files;
if(files.length === 0) {
singleFileUploadError.innerHTML = "Please select a file";
singleFileUploadError.style.display = "block";
}
uploadSingleFile(files[0]);
event.preventDefault();
}, true);
multipleUploadForm.addEventListener('submit', function(event){
var files = multipleFileUploadInput.files;
if(files.length === 0) {
multipleFileUploadError.innerHTML = "Please select at least one file";
multipleFileUploadError.style.display = "block";
}
uploadMultipleFiles(files);
event.preventDefault();
}, true);
먼저 화면만 테스트해보자. 아래와 같이 뜰것이다.
필자는 포트번호를 8888로 설정했지만, 따로 설정하지 않았다면 localhost:8080으로 들어가면 된다.
Controller & DTO 구현
1. 파일 업로드
이제 본격적으로 파일 업로드 Backend 로직을 짜보자. 먼저 방금 작성한 js 파일에서 요청하는 경로에 맞는 컨트롤러를 생성해야한다.
import com.example.file.dto.FileUploadDTO;
import com.example.file.service.FileService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@Slf4j
public class FileController {
private final FileService fileService;
public FileController(FileService fileService) {
this.fileService = fileService;
}
//단일 파일 업로드
@PostMapping("/uploadFile")
public FileUploadDTO uploadFile(@RequestParam("file") MultipartFile file) {
String fileName = fileService.storeFile(file);
String fileDownloadUri = ServletUriComponentsBuilder.fromCurrentContextPath()
.path("/downloadFile/")
.path(fileName)
.toUriString();
return new FileUploadDTO(fileName, fileDownloadUri,
file.getContentType(), file.getSize());
}
// 다중
@PostMapping("/uploadMultipleFiles")
public List<FileUploadDTO> uploadMultipleFiles(@RequestParam("files") MultipartFile[] files) {
return Arrays.asList(files)
.stream()
.map(file -> uploadFile(file))
.collect(Collectors.toList());
}
}
아직 Service 코드를 구성하지 않았으니 빨간줄이 뜨더라도 이악물고 모른척 해보자.
단일 파일 업로드의 경우 service측에서 실제 파일을 저장하는 로직을 수행한 후, download를 위한 파일 Uri를 생성해서 반환해주는 방식이다. 업로드하는 파일이 "사진.png"라면 fileName에 해당 문자가 들어가고, ServletUriComponentBuilder에 의해 /downloadFile/사진.png의 경로로 다운로드용 uri 링크를 생성하여 DTO 형식에 담아 반환하게 된다.
다중 파일 업로드의 경우 람다식을 이용하여 리스트로 들어오는 MultipartFile 매개변수를 하나씩 분리하여 단일 업로드인 uploadFile 메소드를 호출하는 형식이다. 즉, 3개의 파일을 업로드하면 단일 파일 업로드를 3번 호출하는 방식으로 이루어진다.
MultiPartFile?
웹 클라이언트가 Multipart방식으로 데이터를 전송하면, Http body부분에 데이터가 여러 부분으로 나눠서 보내게 된다. 보통 파일을 이러한 방식으로 보내게 되는데, 스프링에서 해당 방식으로 업로드된 파일을 매개변수로 손쉽게 받아올 수 있게 하는 '인터페이스'이다.
MultiPartFile 인터페이스로 파일을 받아오면 업로드한 파일의 이름, 실제 데이터, 파일 크기 등을 구할 수 있다.
2. 파일 다운로드
파일 업로드 controller에서 이미 다운로드용 uri를 만들어 반환했기 때문에 따로 view단에서 다운로드 요청을 하는 부분을 구현하지 않았다.
/downloadFile/사진.png uri를 view에 뿌려주면, 클릭할 시 다운로드 요청이 컨트롤러로 들어올것이다.
(.+ 부분은 파일이름.확장자 <- 의 format을 지정해준것이다.)
@GetMapping("/downloadFile/{fileName:.+}")
public ResponseEntity<Resource> downloadFile(@PathVariable String fileName, HttpServletRequest request) {
// 파일을 Resource타입으로 받아온다.
Resource resource = fileService.loadFileAsResource(fileName);
// 파일 content type 확인 (jpg, png 등..)
String contentType = null;
try {
contentType = request.getServletContext().getMimeType(resource.getFile().getAbsolutePath());
} catch (IOException ex) {
log.info("Could not determine file type.");
}
// 파일 타입을 알 수 없는 경우의 기본값
if(contentType == null) {
contentType = "application/octet-stream";
}
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"")
.body(resource);
}
getMimeType을 통해서 해당 파일의 확장자를 파악할 수 있다. 만약 사진.jfff와 같은 확장자의 경우는 일반적인 확장자가 아니기 때문에 null이 담기고, 이러한 경우를 대비하여 알려지지 않은 나머지 확장자는 application/octet-stream으로 설정해주는 것이다. 이렇게 알려지지 않은 확장자는 보안 상 위험하기 때문에 다운로드를 막거나 따로 조치를 취하는 것이 좋다.
리소스 다운로드와 관련된 HTML 표준 (링크 참고)에 따르면, 파일 다운로드 링크를 제공할 때 HTTP응답 헤더에 Content-Dispotion 필드에 attachmanet 타입을 설정하고, 그 뒤에 다운로드될 filename 명을 기입해야 한다고 명시되어있다.
Content-Disposition: attachment; filename="filename.ext"
3. FileUploadDto 생성
public class FileUploadDTO {
private String fileName;
private String fileDownloadUri;
private String fileType;
private long size;
public FileUploadDTO(String fileName, String fileDownloadUri, String fileType, long size) {
this.fileName = fileName;
this.fileDownloadUri = fileDownloadUri;
this.fileType = fileType;
this.size = size;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public String getFileDownloadUri() {
return fileDownloadUri;
}
public void setFileDownloadUri(String fileDownloadUri) {
this.fileDownloadUri = fileDownloadUri;
}
public String getFileType() {
return fileType;
}
public void setFileType(String fileType) {
this.fileType = fileType;
}
public long getSize() {
return size;
}
public void setSize(long size) {
this.size = size;
}
}
반환할 값은 각자 사용 용도에 맞춰 구현해주면 될것이다. 필자의 경우 사실상 view에서는 fileDownloadUri 변수만 사용된다. (다운로드 링크 생성용)
application.yml 설정파일 구현
설정파일을 작성해보자.
server:
port: 8888
spring:
servlet:
multipart:
enabled: true
location: C:/Temp
file-size-threshold: 2KB
max-file-size: 200MB
max-request-size: 215MB
file:
uploadDir: ${user.home}/jimkwon
application.properties 인 경우
server.port=8888
spring.servlet.multipart.enabled=true
spring.servlet.multipart.location=C:/Temp
spring.servlet.multipart.file-size-threshold=2KB
spring.servlet.multipart.max-file-size=200MB
spring.servlet.multipart.max-request-size=215MB
file.uploadDir=${user.home}/jimkwon
port설정을 8888로 했기 때문에 실제 구동해볼 때 localhost:8888로 접속해야한다. 설정해두지 않으면 기본값 8080으로 들어가면 된다.
- multipart.enabled : true 멀티파트 업로드 지원여부 (default: true)
- multipart.file-size-threshold : 2KB 파일이 메모리에 기록되는 임계값 임계값 이하라면 임시 파일을 생성하지 않고 메모리에서 즉시 파일을 읽어 생성 가능함. 속도는 빠르지만 쓰레드가 작업을 수행하며 부담이 될 수 있으니 적절한 값 설정 필요
- multipart.max-file-size 파일의 최대 사이즈 (default: 1MB)
- multipart.max-request-size 요청의 최대 사이즈 (default: 10MB)
- multipart.location 설정하지 않으면 System.getProperty("java.io.tmpdir"); 경로에 저장 요청 처리 후에는 파일이 삭제되도록 되어있지만, 운영하다 보면 남을 가능성이 있으므로 작업과 관리가 용이하도록 경로를 직접 설정해주는 것이 좋음
- file.upload-dir 파일이 저장될 경로
Service 구현
1. 파일 Exception
Service 로직 구현 중,
1. 해당 파일의 부적절한 경로나 문자가 포함된 경우
2. 파일 저장 경로가 부적절한 경우
에러 처리를 구현해두었다.
exception패키지 안이나 필요한 곳에 에러 클래스를 정의해두자.
@ResponseStatus(HttpStatus.NOT_FOUND)
public class FileNotFoundException extends RuntimeException {
public FileNotFoundException(String message) {
super(message);
}
public FileNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}
public class FileStorageException extends RuntimeException {
public FileStorageException(String message) {
super(message);
}
public FileStorageException(String message, Throwable cause) {
super(message, cause);
}
}
2. 파일 업로드 생성자
import com.example.file.exception.FileNotFoundException;
import com.example.file.exception.FileStorageException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
@Service
@Slf4j
public class FileService {
private final Environment env;
private final Path fileLocation;
@Autowired
public FileService(Environment env) {
this.env = env;
this.fileLocation = Paths.get(env.getProperty("file.uploadDir")).toAbsolutePath().normalize();
try {
Files.createDirectories(this.fileLocation);
} catch (Exception ex) {
throw new FileStorageException("Could not create the directory where the uploaded files will be stored.", ex);
}
}
applications.yml 에서 설정한 파일 저장 경로를 Environment 변수를 통해 주입받도록 하였다.
(흔히 쓰는 @Value 어노테이션을 써도 상관없다..!)
다만 특이사항이 있다면, 생성자 안에서 미리 디렉토리를 생성하는 부분이다.
만약 C:jimkwon/files 폴더에 업로드한 사진을 저장한다고 가정하면, 실제로 해당 경로에 맞는 디렉터리를 직접 생성해놔야한다. 이러한 번거로움을 막기 위해 코드로 작성하여 해당 경로에 디렉토리가 없는 경우 디렉토리를 생성하도록 설정해뒀다.
3. 파일 업로드 로직
public String storeFile(MultipartFile file) {
// cleanPath : 역슬래시를 /슬래시로 바꿔줌
String fileName = StringUtils.cleanPath(file.getOriginalFilename());
try {
if(fileName.contains("..")) {
throw new FileStorageException("Sorry! Filename contains invalid path sequence " + fileName);
}
// 저장할 fileStorageLocation 경로 뒤에 파일명을 붙여준다. (경로 조합)
Path targetLocation = this.fileLocation.resolve(fileName);
//업로드할 file을 targetLocation에 복사한다. (동일한 이름일 경우 replace)
Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);
return fileName;
} catch (IOException ex) {
throw new FileStorageException("Could not store file " + fileName + ". Please try again!", ex);
}
}
resolve 메소드의 경우 "C:temp/jimkwon/files" 와 같은 파일 경로 뒤에 "사진.jpg" 와 같은 파일명을 붙여서 경로를 조합해주는 역할을 한다.
업로드를 요청한 파일을 copy메소드를 통해 원하는 location에 저장한다.
StandardCopyOption.REPLACE_EXISTING : 기존에 동일한 이름의 파일이 있으면 대체한다.
4. 파일 다운로드 로직
public Resource loadFileAsResource(String fileName) {
try {
Path filePath = this.fileLocation.resolve(fileName).normalize();
Resource resource = new UrlResource(filePath.toUri());
if(resource.exists()) {
return resource;
} else {
throw new FileNotFoundException("File not found " + fileName);
}
} catch (MalformedURLException ex) {
throw new FileNotFoundException("File not found " + fileName, ex);
}
}
다운로드 가능한 url 형식으로 만들기 위해 Resource 타입으로 추출해서 반환한다.
.normalize() : 현재, 부모 디렉토리의 중복된 부분을 제거 (../ ./ 과 같은 형식 제거)
이제 비즈니스 로직 구현이 끝났다! 한번 실행해보도록 하자.
실행 결과
어플리케이션을 구동시킨 후, 가장 먼저 디렉토리가 만들어졌는지 확인해보자.
내가 환경설정 파일에 지정해둔 경로로 이동해보면?
무사히 생성된 것을 확인!
이제 파일을 업로드하면?
아래에 다운로드용 Url가 생성되었고, 폴더에도 사진이 잘 들어가있다.
다운로드 링크를 클릭하면 무사히 다운로드가 되는 것을 볼 수 있다.
그런데, 원래 저장 이름이였던 '사진.jpg'가 우리의 의도와는 다르게 엉뚱한 이름으로 저장된다. 그 이유는 한글파일인 경우 인코딩이 되어있지 않아 깨지는 현상이 일어나기 때문이다! 간단한 코드로 인코딩을 설정할 수 있다.
// 파일 content type 확인 (jpg, png 등..)
String encodedFileName = null;
try {
encodedFileName = URLEncoder.encode(resource.getFilename(),"UTF-8").replaceAll("\\+", "%20");
} catch (IOException ex) {
log.info("Could not determine file type.");
}
encode 메소드는 꼭 에러 처리를 해줘야 한다.
위 코드를 추가한 컨트롤러의 downloadFile 메소드를 살펴보면 아래와 같다.
@GetMapping("/downloadFile/{fileName:.+}")
public ResponseEntity<Resource> downloadFile(@PathVariable String fileName, HttpServletRequest request) {
// 파일을 Resource타입으로 받아온다.
Resource resource = fileService.loadFileAsResource(fileName);
// 파일 content type 확인 (jpg, png 등..)
String encodedFileName = null;
String contentType = null;
try {
contentType = request.getServletContext().getMimeType(resource.getFile().getAbsolutePath());
encodedFileName = URLEncoder.encode(resource.getFilename(),"UTF-8").replaceAll("\\+", "%20");
} catch (IOException ex) {
log.info("Could not determine file type.");
}
// 파일 타입을 알 수 없는 경우의 기본값
if(contentType == null) {
contentType = "application/octet-stream";
}
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + encodedFileName + "\"")
.body(resource);
}
다시 프로젝트를 돌려서 한글 파일을 다운로드 받으면?
한글로 인코딩된 파일명을 확인할 수 있다!
다음은 JPA방식을 사용하여 Oracle과 연동한 파일 업로드, 다운로드를 다뤄볼 것이다. 코드는 해당 포스팅에서 다룬 내용을 조금 수정해서 다뤄볼것이다~
[참고 링크]
https://developer.mozilla.org/ko/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
https://html.spec.whatwg.org/dev/links.html#downloading-resources
https://github.com/callicoder/spring-boot-file-upload-download-rest-api-example
'Spring Boot' 카테고리의 다른 글
[Spring Boot] 이메일 보내기 (2) - 참조(cc), 첨부 파일 (8) | 2022.08.10 |
---|---|
[Spring Boot] SMS 전송 - NAVER SMS API 연동 (16) | 2022.08.10 |
[Spring Boot] File Upload & Download (2) - ORACLE DB 연동 (0) | 2022.08.05 |
[Spring Boot] Slack Bot을 생성하여 알림 보내기 (2) | 2022.02.21 |
[Spring Boot] EMail 보내기 (4) | 2022.02.16 |