1. 기본 설정
application.properties
#spring multipart-file
spring.servlet.multipart.maxFileSize = 10MB
spring.servlet.multipart.maxRequestSize = 30MB
spring.servlet.multipart.enabled = true
#multipart-file-create-path
file.upload-dir=uploads/
maxFileSize : 파일 하나의 최대 가능 사이즈
maxRequestSize : 한번에 전송 가능한 최대 파일 사이즈
enabled : 파일 업로드 활성화(true)
file.upload-dir : 서버 내 파일 저장 경로
- 프로젝트 루트 디렉토리 기준으로 생성
- 예시) projectsㅡ|
ㅤㅤㅤㅤㅤㅤㅤㄴuploads
ㅤㅤㅤㅤㅤㅤㅤㄴ src.main.java ...
build.gradle
dependencies {
...
implementation 'commons-io:commons-io:2.6'
}
2. 구현
@PostMapping("/upload")
public ResponseEntity<String> upload(@RequestParam MultipartFile file) {
return ResponseEntity.ok("업로드 성공");
}
- `ImageUtil`: 이미지 조회 시 적절한 http 응답 객체를 생성하여 반환
- `ImageService`: 이미지 저장/조회/삭제/확장자 검증 로직 처리
- `ImageRepository`: 이미지의 메타정보를 DB에 저장
```java
@PostMapping("/users/profile")
public ResponseEntity<?> uploadProfile(Long userId,
@RequestParam MultipartFile image) {
userService.uploadProfileImg(userId, image);
return ResponseEntity.ok(ApiResponse.success());
}
@GetMapping("/users/profile")
public ResponseEntity<Resource> getImage(Long userId) {
return imageUtil.getImage(userService.getProfileImgId(userId));
}
이미지 업로드
- 프론트엔드에서
MultipartFile로 이미지를 보내온다고 가정
- 백엔드 컨트롤러는
MultipartFile 객체로 파일을 받아 처리
- 업로드된 파일은 서버 내부 디렉토리에 저장 후 파일 경로 및 메타데이터를 DB에 저장
이미지 조회
- 클라이언트에서 이미지 조회 요청 시, DB에 저장된 파일 경로를 기반으로 서버 내부에서 파일을 찾아 반환
- 반환 타입은
Resource
org.springframework.core.io.Resource
이미지 엔티티
public class Image {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
@Column(nullable = false, unique = true)
String path;
}
Image 엔티티는 id(primary), path로 구성
- 선택 사항
이미지 업로드 처리 로직 (ImageService)
private final ImageRepository imageRepository;
@Value("${file.upload-dir}")
private String RESOURCE_DIR;
private final List<String> ALLOWED_FILE_TYPES = List.of(
".jpeg", ".jpg", ".png"
);
@Transactional(rollbackFor = IOException.class)
public Image uploadImage(MultipartFile image) {
if (!isValidFileType(image.getOriginalFilename())) {
throw new BaseException(UNSUPPORTED_MEDIA_TYPE);
}
String uuidFilename = UUID.randomUUID() + "_" + image.getOriginalFilename();
Path filePath = Paths.get(RESOURCE_DIR + uuidFilename);
try {
Files.write(filePath, image.getBytes());
} catch (IOException e) {
log.info("FAILED TO WRITE FILE PATH: {}", filePath.toString());
throw new BaseException(FAILED_WRITE_FILE);
}
Image img = new Image(filePath.toString());
imageRepository.save(img);
return img;
}
- 프론트에서 넘어온
MultipartFile 파일을 static resource 폴더에 저장하고, 이미지는 DB에 Image 엔티티로 저장하여 관리하는 방식
@Transactional을 통해 파일 쓰기 실패(IOException.class) 등의 상황 발생 시 롤백 되게끔 정리
RESOURCE_DIR : @Value("${file.upload-dir}")에 설정한 파일 저장 경로값
ALLOWED_FILE_TYPES : 서버에서 허용하는 파일 타입 리스트
이미지 조회 처리 로직 (ImageUtil)
@Component
@RequiredArgsConstructor
public class ImageUtil {
private final ImageService imageService;
public ResponseEntity<Resource> getImage(@RequestParam Long imageId) {
if (imageId == null) {
return ResponseEntity.notFound().build();
}
Image image = imageService.getImage(imageId);
Path path = Path.of(image.getPath());
String fileExtension = getFileExtension(image.getPath());
MediaType mediaType = getMediaType(fileExtension);
Resource resource = new FileSystemResource(path.toFile());
return ResponseEntity.ok()
.contentType(mediaType)
.header(HttpHeaders.CONTENT_DISPOSITION,
"inline; filename=\"" + image.getPath() + "\"")
.body(resource);
}
private String getFileExtension(String filename) {
String extension = "";
int i = filename.lastIndexOf('.');
if (i > 0) {
extension = filename.substring(i + 1);
}
return extension;
}
private MediaType getMediaType(String extension) {
switch (extension.toLowerCase()) {
case "jpg":
case "jpeg":
return MediaType.IMAGE_JPEG;
case "png":
return MediaType.IMAGE_PNG;
default:
return MediaType.APPLICATION_OCTET_STREAM;
}
}
}
getImage
- 이미지 id값으로 DB에서 이미지 경로 조회, 경로를 가지고 내부 폴더에서 파일 불러오기
- 응답 객체 생성
- contentType : mediaType 설정 (
getMediaType)
- header : 바디에 바로 이미지 보이도록
inline 설정
- body : 불러온 파일을 바디에 설정
getFileExtension
이미지 조회 삭제 로직 (ImageUtil)
@Transactional(rollbackFor = IOException.class)
public void deleteImage(Long imageId) {
Image image = imageRepository.findById(imageId).orElseThrow(
() -> new BaseException(NOT_FOUND_FILE));
Path path = Path.of(image.getPath());
imageRepository.delete(image);
try {
Files.deleteIfExists(path);
} catch (IOException e) {
throw new BaseException(FAILED_DELETE_FILE);
}
}
- DB에서 파일 경로를 얻은 후, DB에서 파일 정보 삭제
- 그다음
Files로 파일 경로에 있는 파일을 삭제
⚙️ 전체 코드
Image
@Entity
public class Image {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Long id;
@Column(nullable = false, unique = true)
String path;
public Image(String path) {
this.path = path;
}
}
ImageUtil
@Component
@RequiredArgsConstructor
public class ImageUtil {
private final ImageService imageService;
public ResponseEntity<Resource> getImage(@RequestParam Long imageId) {
if (imageId == null) {
return ResponseEntity.notFound().build();
}
Image image = imageService.getImage(imageId);
Path path = Path.of(image.getPath());
String fileExtension = getFileExtension(image.getPath());
MediaType mediaType = getMediaType(fileExtension);
Resource resource = new FileSystemResource(path.toFile());
return ResponseEntity.ok()
.contentType(mediaType)
.header(HttpHeaders.CONTENT_DISPOSITION,
"inline; filename=\"" + image.getPath() + "\"")
.body(resource);
}
private String getFileExtension(String filename) {
String extension = "";
int i = filename.lastIndexOf('.');
if (i > 0) {
extension = filename.substring(i + 1);
}
return extension;
}
private MediaType getMediaType(String extension) {
switch (extension.toLowerCase()) {
case "jpg":
case "jpeg":
return MediaType.IMAGE_JPEG;
case "png":
return MediaType.IMAGE_PNG;
default:
return MediaType.APPLICATION_OCTET_STREAM;
}
}
}
ImageService
@Slf4j
@Service
@RequiredArgsConstructor
public class ImageService {
private final ImageRepository imageRepository;
@Value("${file.upload-dir}")
private String RESOURCE_DIR;
private final List<String> ALLOWED_FILE_TYPES = List.of(
".jpeg", ".jpg", ".png"
);
@Transactional(rollbackFor = IOException.class)
public Image uploadImage(MultipartFile image) {
if (!isValidFileType(image.getOriginalFilename())) {
throw new BaseException(UNSUPPORTED_MEDIA_TYPE);
}
String uuidFilename = UUID.randomUUID() + "_" + image.getOriginalFilename();
Path filePath = Paths.get(RESOURCE_DIR + uuidFilename);
try {
Files.write(filePath, image.getBytes());
} catch (IOException e) {
log.info("FAILED TO WRITE FILE PATH: {}", filePath.toString());
throw new BaseException(FAILED_WRITE_FILE);
}
Image img = new Image(filePath.toString());
imageRepository.save(img);
return img;
}
public Image getImage(Long imageId) {
return imageRepository.findById(imageId).orElseThrow(
() -> new BaseException(NOT_FOUND_FILE));
}
@Transactional(rollbackFor = IOException.class)
public void deleteImage(Long imageId) {
Image image = imageRepository.findById(imageId).orElseThrow(
() -> new BaseException(NOT_FOUND_FILE));
Path path = Path.of(image.getPath());
imageRepository.delete(image);
try {
Files.deleteIfExists(path);
} catch (IOException e) {
throw new BaseException(FAILED_DELETE_FILE);
}
}
private boolean isValidFileType(String uploadFileType) {
for (String fileType : ALLOWED_FILE_TYPES) {
if (uploadFileType.toLowerCase().endsWith(fileType)) {
System.out.println("FILE TYPE: " + uploadFileType);
return true;
}
}
return false;
}
}
향후 리팩토링
- 추후 실제 트랜잭션, 롤백 상황에서 어떻게 동작하는 지 테스트
- 기존 코드를 활용하여 AWS S3로 확장
- 이미지 핸들링 자체가 무거운 작업이니 멀티 스레딩으로 병행 작업 시도