이번 프로젝트에서는 기존 영화 CRUD 프로젝트를 확장하여 이미지 파일 업로드 기능을 구현하였다. 단순히 영화 제목과 가격 같은 텍스트 데이터만 처리하는 것이 아니라, 사용자가 여러 장의 이미지를 선택하여 업로드하고 이를 서버(Local) 또는 S3와 같은 외부 저장소에 저장하는 구조를 학습하였다.
특히 MultipartFile을 이용한 파일 처리, UploadFile을 활용한 파일 정보 관리, FileStore 인터페이스를 이용한 저장소 추상화, StorageProperty를 통한 설정 관리 등을 구현하면서 객체지향적인 파일 저장 구조를 이해할 수 있었다.
기존 프로젝트에서는 사용자가 입력한 값이 모두 문자열이나 숫자였다.
제목
가격
하지만 이번 프로젝트에서는 사용자가 파일도 함께 업로드한다.
제목
이미지1
이미지2
이미지3
Spring에서는 업로드된 파일을 MultipartFile 객체로 변환하여 전달한다.
즉,
브라우저
↓
파일 선택
↓
HTTP Multipart Request
↓
MultipartFile 객체 생성
↓
Controller
과정으로 동작한다.
사용자가 입력한 데이터는 MovieDTO에서 관리하였다.
public record MovieDTO(
@NotBlank String title,
@NotEmpty List<MultipartFile> images
) {
}
@NotBlank String title영화 제목은 반드시 입력되어야 한다.
Validation을 통해 잘못된 입력을 사전에 방지한다.
List<MultipartFile> images파일 하나가 아니라
포스터
스틸컷1
스틸컷2
처럼 여러 장을 업로드할 수 있으므로
MultipartFile image;
가 아니라
List<MultipartFile> images;
를 사용하였다.
MultipartFile은 단순히 파일만 저장하는 객체가 아니다.
다음과 같은 정보를 포함한다.
| 메서드 | 의미 |
|---|---|
getOriginalFilename() | 원본 파일 이름 |
getContentType() | MIME 타입 |
getSize() | 파일 크기 |
getInputStream() | 파일 내용 |
transferTo() | 실제 파일 저장 |
파일을 저장한 뒤에는 MultipartFile 자체를 계속 사용할 필요가 없다.
public record UploadFile(
String originalName,
String storedName
) {
public String url() {
return "/upload/%s".formatted(storedName);
}
}
파일을 저장하면 두 가지 이름이 존재한다.
예를 들어 사용자가
cat.jpg
를 업로드하였다.
실제로 서버에는
2fa39fd8-8ab2.jpg
처럼 저장된다.
따라서
| originalName | storedName |
|---|---|
| cat.jpg | 2fa39fd8-8ab2.jpg |
를 모두 저장해야 한다.
사용자는
cat.jpg
를 보고 싶다.
다운로드 화면에서도
cat.jpg
라고 표시되어야 한다.
서버에서는 파일명이 중복되면 안 된다.
예를 들어
두 사용자가 모두
cat.jpg
를 업로드하면 기존 파일이 덮어써질 수 있다.
그래서 UUID를 이용하여
cat.jpg
↓
7fd81e2d.jpg
처럼 저장한다.
이를 통해 파일명 충돌을 방지할 수 있다.
UploadFile에는
public String url() {
return "/upload/%s".formatted(storedName);
}
도 존재한다.
이 메서드는 저장된 파일명을 이용하여 브라우저에서 접근 가능한 URL을 생성한다.
예를 들어 storedName이
abc123.jpg
이면
/upload/abc123.jpg
를 반환한다.
화면에서는 이 URL을 <img> 태그의 src 속성으로 사용하여 이미지를 출력한다.
public interface FileStore {
UploadFile storeFile(MultipartFile image);
}
MovieService는 파일을 어떻게 저장하는지 알 필요가 없다.
UploadFile upload = fileStore.storeFile(image);
만 호출한다.
실제로 저장하는 방법은
FileStore
↓
LocalFileStore 또는 S3FileStore가 담당한다.
즉, MovieService는 인터페이스에만 의존한다.
LocalFileStore는 프로젝트 내부 폴더에 파일을 저장한다.
@Component
public class LocalFileStore implements FileStore {
private final Path uploadPath =
Path.of("src/main/resources/static/upload");
}
업로드된 파일은
src
↓
main
↓
resources
↓
static
↓
upload
폴더에 저장된다.
브라우저는
/upload/파일명
으로 접근할 수 있다.
LocalFileStore 내부에서는
다음 순서로 동작한다.
MultipartFile
↓
비어있는지 검사
↓
확장자 검사
↓
UUID 생성
↓
파일 저장
↓
UploadFile 반환
즉, MovieService는 파일이 어디에 저장되는지 전혀 알지 못한다.
S3 정보를 관리하기 위해 StorageProperty를 사용하였다.
@ConfigurationProperties(prefix = "app.storage")
@Validated
public record StorageProperty(
String bucket,
String endpoint,
String region,
String access_key,
String secret_key
) {
}
기존에는
@Value(...)
를 여러 번 사용해야 했다.
하지만 ConfigurationProperties를 사용하면
application.yml
↓
app.storage
↓
StorageProperty
로 자동 매핑된다.
따라서 S3 관련 설정을 하나의 객체로 관리할 수 있다.
현재는
LocalFileStore
를 사용할 수도 있고
S3FileStore
를 사용할 수도 있다.
MovieService는
private final FileStore fileStore;
만 알고 있다.
따라서
나중에
AWS S3
↓
Azure Blob
↓
Google Cloud Storage
등으로 변경하더라도 MovieService는 전혀 수정하지 않아도 된다.
이것이 다형성과 DIP(의존성 역전 원칙) 의 대표적인 예시이다.
| 학습 내용 | 세부 내용 |
|---|---|
| MultipartFile | 업로드된 파일을 Java 객체로 처리하는 방법 |
| MovieDTO | 여러 장의 이미지를 List<MultipartFile>로 관리하는 방법 |
| UploadFile | 원본 파일명과 저장 파일명을 분리하여 관리하는 이유 |
| UUID | 파일명 중복을 방지하기 위한 고유 식별자 생성 |
| FileStore | 저장 방식을 추상화한 인터페이스 |
| LocalFileStore | 프로젝트 내부에 파일을 저장하는 구현체 |
| StorageProperty | @ConfigurationProperties를 활용한 설정 관리 |
| DIP | 인터페이스에 의존하여 저장소 변경에 유연하게 대응하는 구조 |