꽤나 애를 먹인 파일 처리 부분을 완벽 정리해봤다.
최대한 자세히 정리하겠지만, 이해가 안 가거나 어려운 내용이 있다면 댓글을 달아주세요!

1. 파일 경로 결정
가장 먼저 파악해야 할 것은 파일 경로다.
스프링부트는 내장톰캣을 사용하기 때문에 (but 현재 회사에선 배포할 때 외장톰캣을 씀)
임시 폴더에 넣으면 톰캣을 재실행했을 때 날라가기 때문에 (⬅️이게 젤 큰 이유)
프로젝트 내부가 아닌 외부에 따로 폴더를 만들어서 저장 하기로 했다.(주로 이렇게 하는 듯)
OS 에 따라 조금씩 달라지는데
Window D:/upload
Linux /upload
MacOS /Users/upload
참고로 나는 Windows(회사) Linux(회사 서버) Mac(개인 프로젝트) 을 모두 사용하고 있고, 모두 잘 작동하는 걸 확인했다.
굳이 나처럼 최상단에 둘 필요는 없고, 원하는 곳으로 지정하면 된다. 핵심은 파일 경로를 외부에 두고 관리한다는 점!
2. 기본 설정(backend)
기본 세팅이 필요하다. 우선 springboot는 모든 기본 설정(DB 연결, 특정 경로 지정 등) 을 application.properties에서 한다. 아래와 같이 file을 어느 경로에, 한 번에 최대 어느 용량으로 저장할 건지 결정할 수 있다.
application.properties
# file setting (default limits individual: 1MB / the total size of files: 10MB.)
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=30MB
spring.servlet.multipart.max-request-size=150MB
# file upload path
#(windows)
upload.folder.path=D:/upload
#(linux)
#upload.folder.path=/upload
#(macOS)
#upload.path.userphoto=/Users/upload
WebMvcConfigurer.java

그리고 application.properties에서 설정한 값을 특정 경로에 매핑하겠다고 설정하면 된다! 이 설정은 WebMvcConfigurer 을 implements 한 class 파일에 적어주면 된다. 나는 WebMvcConfigurer 라고 이름한 클래스 파일에 설정했다.
package com.***.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
@Configuration
public class WebMvcConfigurer implements org.springframework.web.servlet.config.annotation.WebMvcConfigurer {
// 파일 경로용 매핑
@Value("${upload.folder.path}")
private String uploadFolderPath;
// '/upload-path'라는 경로를 application.properties에서 설정한 value로 매핑
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/upload-path/**")
.addResourceLocations("file:///" + uploadFolderPath + "/"); // windows
// .addResourceLocations(uploadFolderPath + "/"); // linux
// .addResourceLocations("file://"+rentalUploadPath + "/"); // macOs
}
}
3. 파일 업로드 처리 (view)
form 을 넘기는 2가지의 방법이 있는데, 둘다 잘 사용하고 있다만 방법이 조금 다르다.
옵션1) form 태그를 이용해서 넘길 때
enctype="multipart/form-data" 를 form 에 추가해서 적어주고 submit 시키면 된다.
<form id="fileSubmit" action="/경로" method="post" enctype="multipart/form-data">
<input type="file" name="file" id="fileInput" class="form-input">
</form>
옵션2) ajax로 form 값을 넘길 때
중요한 점은 ajax을 통해 백으로 form 을 넘길 때, formData를 사용해야 한다는 것이다! 아래 예시를 잘 확인해보도록!
이제 파일 업로드를 해보자. 참고로 뷰는 본인 하기 나름이라 최대한 간단하게 만들었다. 최대 5개의 파일을 30MB의 용량까지 업로드할 수 있게 해뒀다.
현재 alertify 를 쓰고 있는데 만약 싫다면, 그냥 alert로 바꿔도 무방하다.
본인에게 맞춰 view를 변경하시길!
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
</head>
<body>
<div>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th colspan="2"></th>
</tr>
</thead>
<tbody>
<tr>
<td><label for="title"><b>제목</b></label></td>
<td>
<input type="text" id="title" class="form-control" value="" title="제목">
</td>
</tr>
<tr>
<td><b>작성자</b></td>
<td>관리자</td>
</tr>
<tr>
<td><b>내용</b></td>
<td>
<textarea class="form-control" id="content" rows="3" title="내용" style="height:412px; resize:none;"></textarea>
</td>
</tr>
<tr>
<td><b>첨부파일</b></td>
<td>
<input class="form-control mb-1" type="file" id="file_1">
<input class="form-control mb-1" type="file" id="file_2">
<input class="form-control mb-1" type="file" id="file_3">
<input class="form-control mb-1" type="file" id="file_4">
<input class="form-control mb-1" type="file" id="file_5">
<small><em>첨부파일 가능 용량은 파일 당 최대 30MB 입니다.</em></small>
</td>
</tr>
</tbody>
</table>
</div>
<div class="quform-submit-inner text-center">
<button class="butn" type="button" onclick="validateForm()"><span>전송</span></button>
</div>
</div>
<script>
function validateForm() {
const title = document.getElementById('title').value.trim();
const content = document.getElementById('content').value.trim();
const files = [
document.getElementById('file_1').files[0],
document.getElementById('file_2').files[0],
document.getElementById('file_3').files[0],
document.getElementById('file_4').files[0],
document.getElementById('file_5').files[0]
];
// 1) 제목 공백 유무
if (title === "") {
alertify.notify("<strong><u>제목</u></strong>을 입력해주세요.", "error");
return;
}
// 2) 내용 공백 유무
if (content === "") {
alertify.notify("<strong><u>내용</u></strong>을 입력해주세요.", "error");
return;
}
// 3) 파일 사이즈 확인
// 3-1) 파일 사이즈 및 파일명 확인
let totalSize = 0;
let oversizedFiles = [];
for (let fileInfo of files) {
if (fileInfo) { // fileInfo가 undefined가 아닌 경우
totalSize += fileInfo.size;
if (fileInfo.size > 30 * 1024 * 1024) { // 30MB
oversizedFiles.push(fileInfo.name);
}
}
}
// 3-2) 알림
if (totalSize > 30 * 1024 * 1024) {
alertify.notify("첨부파일 총 용량은 최대 <strong><u>30MB</u></strong> 입니다.", "error");
return;
}
if (oversizedFiles.length > 0) {
let oversizedFilesNames = oversizedFiles.join(', ');
alertify.notify(`파일 ${oversizedFilesNames}의 크기가 30MB를 초과합니다.`, "error");
return;
}
// 4-1) formData에 추가
const formData = new FormData();
formData.append('title', title);
formData.append('content', content);
// 4-2) 파일 배열을 formData에 추가
files.forEach((file, index) => {
if (file) {
formData.append('files', file); // 'files' 키를 사용하여 배열 형태로 추가
}
});
// formData 확인
// for (let [key, value] of formData.entries()) {
// console.log(`${key}: ${value}`);
// }
// 5) 전송
$.ajax({
url: '/archive/register',
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function() {
alertify.notify("<strong>[등록]</strong><br/>성공적으로 업로드 되었습니다.<br/><u>페이지를 이동</u>합니다.", "success");
setTimeout(() => location.href = '/이동경로', 1500); // 1.5초 후 페이지 이동
// setTimeout(() => location.reload(), 2500); // 2.5초 후 새로고침
},
error: function (error) {
alert('[오류 발생] 담당자에게 문의해주세요.');
console.log('Error 발생: ', error);
}
});
}
</script>
</body>
</html>
4. 파일 업로드 처리 (back)
이제 중요한 back 부분을 다룰 건데, 파일 관련 코드만 적어뒀으니 그 부분만 주의깊게 보면 된다.
FileVO
@Data 는 비추 하지만, 편리를 위해 여기선 사용했다.
@Data
public class FileVO {
int fileId;
/*
중략
*/
String originalName;
String serverName;
String filePath;
String createDatetime;
}
controller
옵션2 ajax로 form 값을 넘길 때 를 기준으로 짜봤다!
@ResponseBody
@PostMapping("/경로")
public void postTestRegister(
HttpServletRequest request,
@RequestParam("title") String archiveTitle,
@RequestParam("content") String archiveContent,
@RequestParam(name="files", required = false) MultipartFile[] files) throws IllegalStateException, IOException {
/*
중략
*/
// 파일 저장
testService.uploadFiles(files);
}
service
동시에 여러 접속이 발생할 수 있기 때문에 세마포어를 활용했다. 필요없다면 지워도 무방하다.
세마포어(Semaphore)는 프로그래밍에서 여러 스레드가 공유 자원에 접근하는 것을 제어하는 데 사용되는 동기화 도구입니다.
주석을 열심히 달아두었으니 꼼꼼히 확인해볼 것!
앞서 WebMvcConfigurer.java에서 활용한 것 처럼 application.properties에 한 설정(uploadFolderPath)를 가져와서 파일을 만드는 걸 확인할 수 있다.
// 파일을 저장할 경로 설정 (static 디렉토리)
@Value("${upload.folder.path}")
private String uploadFolderPath;
// =========================== 파일 업로드 로직 ===========================
// 세마포어 객체 생성 (permit(1), fair(true) = 공유자원 1개, FIFO)
private final Semaphore semaphore = new Semaphore(1, true);
public void uploadFiles(MultipartFile[] files) throws IllegalStateException, IOException {
try {
semaphore.acquire(); // 세마포어 획득
// 1) 파일 끝 경로
String folderPath = "/test";
for (int i=0; i<files.length; i++) {
MultipartFile file = files[i];
// 2) 파일 이름 변경
String fileName = fileRename(file.getOriginalFilename());
// log.info("file : {}" ,file);
// log.info("folderPath : {}" ,folderPath);
// log.info("fileName : {}" ,fileName);
// log.info("============================");
// 3) 이미지를 저장할 파일 객체 생성
File uploadFile = new File(uploadFolderPath + folderPath, fileName);
//throw new IllegalStateException("Illegal state occurred");
// 4) 파일을 해당 위치에 저장
file.transferTo(uploadFile);
// 5) 파일 객체에 파일 정보 설정
FileVO fileVo = new FileVO();
/*
중략
*/
fileVo.setOriginalName(file.getOriginalFilename()); // 01.jpg
fileVo.setServerName(fileName); // 240528103837555_88cf7914.jpg
fileVo.setFilePath(folderPath + "/" + fileName); // /test/240528103837555_88cf7914.jpg
// System.out.println("fileVo : " + fileVo);
// 6) DB 저장
testMapper.insertTestFile(fileVo);
}
} catch (InterruptedException e) {
log.error("Semaphore acquisition interrupted : {}", e);
Thread.currentThread().interrupt(); // 스레드 인터럽트 처리
} catch (Exception e) {
log.error("File data inserted Error : {}", archiveVo, e);
} finally {
semaphore.release(); // 세마포어 반환
}
}
// 파일명 변경 메소드
public static String fileRename(String originalFileName) {
SimpleDateFormat sdf = new SimpleDateFormat("yyMMddHHmmssS");
String date = sdf.format(new java.util.Date(System.currentTimeMillis()));
UUID uuid = UUID.randomUUID();
String uuidString = uuid.toString();
String first8Characters = uuidString.substring(0, 8);
String ext = originalFileName.substring(originalFileName.lastIndexOf("."));
// return date + "_" + first8Characters + "_" + originalFileName;
return date + "_" + first8Characters + ext;
}
만약 날짜별로 값을 가져와, 폴더를 "/test/24/05/29/240528103837555_88cf7914.jpg" 이렇게 날짜 폴더를 생성시켜주고 싶다면 아래의 메소드를 활용하면 된다!
// 1) 날짜별 폴더 생성 => 여기에 파일 넣음!
String folderPath = makeFolder("/test");
// 앞선 코드의 1)을 위처럼 변경해주면 된다.
//====================================================================================
// 날짜 폴더 생성
private String makeFolder(String uploadPath) {
// 현재 날짜를 기준으로 년/월/일의 계층 구조를 가진 폴더 경로 생성
String str = LocalDate.now().format(DateTimeFormatter.ofPattern("yy/MM/dd"));
// 파일 시스템에 맞는 구분자로 변경
String folderPath = str.replace("/", File.separator);
// 폴더 객체 생성
File uploadPathFolder = new File(uploadPath, folderPath);
// 폴더가 존재하지 않으면 생성
if (!uploadPathFolder.exists()) {
boolean mkdirs = uploadPathFolder.mkdirs();
// 폴더 생성 로그 출력 (optional)
log.info("-------------------makeFolder------------------");
log.info("uploadPathFolder.exists(): " + uploadPathFolder.exists());
log.info("mkdirs: " + mkdirs);
}
return "/" + folderPath;
}
자 다시 돌아와서 FileVO를 DB에 잘 저장했다면, 이렇게 저장되어 있을 것이다.

5. 업로드 결과 보기
잘 업로드된 값을 확인해보자! 참고로 현재 이 코드는 thymeleaf를 사용 중!
// 1) 이미지 보기
<img th:src="'/upload-path' + ${entry.filePath}" alt="사진">
// 위 코드는 아래와 동일하다. local 서버에서 띄우면 이렇게 뜸
// <img th:src="http://localhost/upload-path/test/240529135713642_90278e8c.jpg" alt="사진">
// 2) 파일 다운로드
// 이렇게 적어주면 다운될 때 serverName이 아닌 originalName으로 다운된다.
<a th:href="'/upload-path' + ${entry.filePath}" th:download="${entry.originalName}" class="cursor-pointer">
<span th:text="${entry.originalName}"></span>
</a>
좀 더 멋지게 파일을 뿌려주고 싶다면, https://www.lightgalleryjs.com/ 같은 라이브러리도 추천한다 😘😊

참고로 파일은 우리가 초기에 설정한 D드라이버에 잘 들어가 있다.

6. 업로드 한 파일 삭제하기
삭제는 정말 쉽다. service 만 확인해보자.
service
파일 정보 (경로+이름) 을 가져와서 해당 파일이 실제로 존재하면 삭제하는 코드를 적어줬다.
public void deleteTestFiles(ArrayList<FileVO> files) throws IllegalStateException, IOException {
try {
semaphore.acquire(); // 세마포어 획득
for (FileVO fileVo : files) {
// 1) 파일명
String filePath = fileVo.getFilePath();
String fullFilePath = uploadFolderPath + filePath;
File file = new File(fullFilePath);
// 2) 해당 파일 존재할 경우, 삭제처리
if (file.exists()) {
if (!file.delete()) {
log.warn("Failed to delete file: {}", fullFilePath);
}
}
else log.warn("File not found: {}", fullFilePath);
// 3) DB에서 삭제
testMapper.deleteTestFile(fileVo);
}
} catch (InterruptedException e) {
log.error("Semaphore acquisition interrupted : {}", e);
Thread.currentThread().interrupt(); // 스레드 인터럽트 처리
} catch (Exception e) {
log.error("File data inserted Error : {}", archiveVo, e);
} finally {
semaphore.release(); // 세마포어 반환
}
}
'SpringBoot' 카테고리의 다른 글
| 🐸SpringBoot 파일 처리 총 정리 2탄 (static 경로에 파일 업로드/다운로드, 파일 경로, 파일 삭제) springboot + thymeleaf (0) | 2024.05.30 |
|---|---|
| 🐸iframe에서 부모창 함수 호출 (0) | 2024.05.14 |
| 🐸[Select-Picker] Uncaught TypeError: Cannot read property of undefined (reading 'Constructor') 해결 (0) | 2024.05.02 |
| 🐸STS 퀵 서치 (Quick Search) 플러그인 설치 (0) | 2024.04.22 |
| 🐸스프링부트 A project with the name already exists. 해결 (0) | 2024.03.18 |