🐸SpringBoot 파일 처리 총 정리 2탄 (static 경로에 파일 업로드/다운로드, 파일 경로, 파일 삭제) springboot + thymeleaf
- -
위의 글에 이어 조금 더 보충하고 싶은 내용이 있어, 추가 정리를 해본다!
추가 포스팅 이유
앞선 포스팅에서 아래처럼 파일 경로를 외부 경로(디렉터리)로 하는 것이 일반적이라고 했는데, 반대로 프로젝트 내부에 업로드 파일을 올리는 경우엔 어떻게 할 수 있을지 정리해보려 한다.
Spring Boot 파일 구조
파일 구조를 살펴보면, 대략 이해가 될 텐데 우린 내부 디렉터리에 저장하기 위해서 static 폴더를 활용할 것이다.
참고로 나는 spring boot + gradle + thymeleaf 를 사용하고 있다.
my-spring-boot-project
│
├── build.gradle
├── settings.gradle
│
├── src
│ ├── main
│ │ ├── java
│ │ │ └── com
│ │ │ └── example
│ │ │ └── myproject
│ │ │ ├── MyProjectApplication.java
│ │ │ ├── controller
│ │ │ │ └── MyController.java
│ │ │ ├── service
│ │ │ │ └── MyService.java
│ │ │ ├── repository
│ │ │ │ └── MyRepository.java
│ │ │ ├── model
│ │ │ │ └── MyModel.java
│ │ │ └── config
│ │ │ └── WebConfig.java
│ │ ├── resources
│ │ │ ├── static (정적 파일 위치! => 파일 업로드 경로)
│ │ │ │ └── css
│ │ │ │ └── style.css
│ │ │ ├── templates
│ │ │ │ └── index.html
│ │ │ ├── application.properties
│ │ │ └── application.yml
│ ├── test
│ │ ├── java
│ │ │ └── com
│ │ │ └── example
│ │ │ └── myproject
│ │ │ └── MyProjectApplicationTests.java
│ └── target
└── README.md
기본 세팅
만약 STS를 쓰고 있다면, IDE 자동 새로고침을 켜야 한다. 그래야 파일 시스템 변경 사항을 자동으로 감지할 수 있기 때문이다. 만약 이 설정을 끄면, Spring Boot 프로젝트에서 정적 파일을 업로드한 후 STS (Spring Tool Suite) Package Explorer 창을 새로고침해야 값이 뜨는 걸 볼 수 있을 것이다.
자동 새로고침을 끄면, 아래 사진처럼 업로드한 파일을 찾아가지 못 한다.
Window > Preferences > General > Workspace > Refresh using native hooks or polling 를 체크!
파일 업로드 & 삭제
외부 경로에 파일을 업로드 하는 것보다 백만 배 간단하다. 뷰 파일은 앞선 포스팅을 참조해주시고 달라지는 건 service 부분 뿐이라 해당 내용만 상세히 정리해보겠다.
FileVO
@Data
public class FileVO {
int fileId;
/*
중략
*/
String originalName;
String serverName;
String filePath;
String createDatetime;
}
Service
블로그 템플릿 때문에 코드가 스크롤 형태로 나올 텐데 가능하면, copy해서 VSCode로 확인해보길 바란다.
주석을 꼼꼼히 달아뒀으니 도움이 되길!
+ 앞선 포스팅과 마찬가지로 세마포머는 필요하지 않다면 쓸 필요 없다.
세마포어(Semaphore)는 프로그래밍에서 여러 스레드가 공유 자원에 접근하는 것을 제어하는 데 사용되는 동기화 도구입니다.
package com.test.service;
import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.UUID;
import java.util.concurrent.Semaphore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import com.test.dto.FileVO;
import com.test.mapper.TestMapper;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Service
public class testService {
@Autowired
private TestMapper testMapper;
// 파일 경로 (PROJECT_PATH: 프로젝트 경로 / FOLDER_PATH: 저장할 static 경로)
private static final String PROJECT_PATH = System.getProperty("user.dir");
private static final String FOLDER_PATH = "/src/main/resources/static/upload";
// =========================== 파일 업로드 로직 ===========================
// 세마포어 객체 생성 (permit(1), fair(true) = 공유자원 1개, FIFO)
private final Semaphore semaphore = new Semaphore(1, true);
public void uploadTestFiles(MultipartFile[] files) throws IllegalStateException, IOException {
try {
semaphore.acquire(); // 세마포어 획득
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(PROJECT_PATH + FOLDER_PATH + "/test", fileName);
//throw new IllegalStateException("Illegal state occurred");
// 4) 파일을 해당 위치에 저장
file.transferTo(uploadFile);
// 5) 파일 객체에 파일 정보 설정
FileVO fileVo = new FileVO();
fileVo.setFileId(fileId + i);
fileVo.setOriginalName(file.getOriginalFilename()); // 01.jpg
fileVo.setServerName(fileName); // 240528103837555_88cf7914.jpg
fileVo.setFilePath("/test/" + 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 : {}", fileVo, e);
} finally {
semaphore.release(); // 세마포어 반환
}
}
// =========================== 파일 삭제 로직 ===========================
public void deleteTestFiles(ArrayList<FileVO> files) throws IllegalStateException, IOException {
try {
semaphore.acquire(); // 세마포어 획득
for (FileVO fileVo : files) {
// 1) 파일명
String filePath = fileVo.getFilePath();
String fullFilePath = PROJECT_PATH + FOLDER_PATH + 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 : {}", boardVo, 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;
}
// 날짜 폴더 생성 (not used)
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;
}
// =========================== 파일 업로드 로직 ===========================
// ========================= 미사용 but 필요 시 사용가능 ==========================
// 크로스 사이트 스트립트 공격을 방지 하기 위한 메소드
public static String XSSHandling(String content) {
if (content != null) {
content = content.replaceAll("&", "&");
content = content.replaceAll("<", "<");
content = content.replaceAll(">", ">");
content = content.replaceAll("\"", """);
}
return content;
}
// 크로스 사이트 스트립트 해제
public static String XSSClear(String content) {
if (content != null) {
content = content.replaceAll("&", "&");
content = content.replaceAll("<", "<");
content = content.replaceAll(">", ">");
content = content.replaceAll(""", "\"");
}
return content;
}
// 개행문자 처리
public static String newLineHandling(String content) {
return content.replaceAll("(\r\n|\r|\n|\n\r)", "<br>");
}
// 개행문자 해제
public static String newLineClear(String content) {
return content.replaceAll("<br>", "\n");
}
}
업로드 결과 보기
뷰에서도 크게 달라질 건 없다. 다만 따로 추가 매핑을 할 필요가 없다.
(thymeleaf 문법으로 작성됨)
참고로 static 폴더 안의 파일 경로는 웹 애플리케이션의 루트 경로(/)를 기준으로 접근할 수 있다. 따라서, static/upload 폴더에 파일을 저장하는 경우, 해당 파일에 접근하려면 /upload 경로를 사용하면 된다.
// 1) 이미지 보기
<img th:src="'/upload' + ${entry.filePath}" alt="사진">
// 위 코드는 아래와 동일하다. local 서버에서 띄우면 이렇게 뜸
// <img th:src="http://localhost/upload-path/test/240529135713642_90278e8c.jpg" alt="사진">
// 2) 파일 다운로드
// 이렇게 적어주면 다운될 때 serverName이 아닌 originalName으로 다운된다.
<a th:href="'/upload' + ${entry.filePath}" th:download="${entry.originalName}" class="cursor-pointer">
<span th:text="${entry.originalName}"></span>
</a>
내부 경로(/static) 에 파일 업로드 시 장/단점
결론을 말하자면, 개인적으론 선호하지 않는 방식이다.
다만 만약 1️⃣소규모의 프로젝트이거나 2️⃣파일 업로드 양이 적고 3️⃣보안이 중요하지 않다면 추천한다!
장점
1. 간편한 접근:
- 업로드된 파일이 `static` 디렉토리에 저장되면, 웹 애플리케이션을 통해 정적 파일처럼 간편하게 접근 가능
- 별도의 파일 서빙 설정 없이, (`/static/upload`) 경로를 통해 쉽게 파일에 접근 가능
2. 일관된 URL 구조:
- 모든 정적 자산을 한 곳에 모아두면 URL 구조가 일관되어 관리하기 쉬움
3. 배포의 일관성:
- 애플리케이션과 정적 파일이 같은 배포 단위에 포함되므로 배포 시 별도의 파일 동기화 과정이 불필요
단점
1. 파일 크기 제한:
- 업로드된 파일이 많거나 크기가 큰 경우, `static` 디렉토리에 저장하면 WAR/JAR 파일의 크기가 커져 배포와 로딩 시간이 증가
2. 보안 문제:
- 모든 파일이 웹 서버를 통해 직접 접근 가능하므로, 민감한 파일이 노출될 위험이 있음으로 적절한 접근 제어가 필요함
외부 경로에 파일을 저장하는 장점과 단점
그럼 반대로 외부 경로에 파일을 저장했을 때 장/단점을 살펴보자.
장점
1. 애플리케이션과 파일 분리:
- 정적 파일과 애플리케이션 코드를 분리하여 배포하고 관리
- 파일 저장 공간과 애플리케이션 서버의 디스크 공간을 독립적으로 관리
2. 동적 파일 관리:
- 대용량 파일이나 많은 수의 파일을 처리할 때 유리
3. 확장성:
- 파일 저장을 외부 스토리지 (예: Amazon S3, Google Cloud Storage 등)로 옮기면 확장성과 성능을 개선할 수 있음
4. 보안 관리:
- 파일 접근을 별도로 관리하고 보안 설정을 강화 가능
단점
1. 복잡한 설정:
- 파일 서버를 별도로 설정하고, 애플리케이션과 연동하는 과정이 복잡
- 외부 스토리지 서비스와의 연동 설정이 필요
2. 의존성 증가:
- 외부 파일 시스템이나 스토리지 서비스에 대한 의존성이 증가
3. 추가 비용:
- 외부 스토리지 서비스 사용 시 추가 비용이 발생
결론
- 내부 경로 (static 디렉토리)에 저장하는 경우 간편하지만, 파일 크기 및 수량이 많아지면 관리와 배포가 어려워질 수 있음
- 외부 경로에 저장하면 파일 관리가 유연해지지만, 설정과 연동 작업이 추가로 필요함
'SpringBoot' 카테고리의 다른 글
🐸SpringBoot 파일 처리 총 정리 (파일 업로드/다운로드, 파일 경로, 파일 삭제) springboot + thymeleaf (0) | 2024.05.29 |
---|---|
🐸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 |
소중한 공감 감사합니다