[TIL] Spring MultipartFile 이미지 업로드 구현

YJin·2025년 4월 29일

[내배캠 Spring 6기_TIL]

목록 보기
28/56

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));
}
  • 이미지 업로드/조회하는 API가 있다고 가정

이미지 업로드

  • 프론트엔드에서 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()); // TODO : 핸들러로 빼서 병행 처리?
    } catch (IOException e) {
      log.info("FAILED TO WRITE FILE PATH: {}", filePath.toString());
      throw new BaseException(FAILED_WRITE_FILE);
    }

    // image 엔티티에 경로 등등 정보 저장
    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;                // jpg, jpeg

      case "png":
        return MediaType.IMAGE_PNG;                 // png

      default:
        return MediaType.APPLICATION_OCTET_STREAM;  // 바이너리 파일
    }
  }
}

getImage

  • 이미지 id값으로 DB에서 이미지 경로 조회, 경로를 가지고 내부 폴더에서 파일 불러오기
  • 응답 객체 생성
    • contentType : mediaType 설정 (getMediaType)
    • header : 바디에 바로 이미지 보이도록 inline 설정
    • body : 불러온 파일을 바디에 설정

getFileExtension

  • 점 ( . ) 을 기준으로 파일 확장자 추출

getMediaType

  • 파일 확장자에 맞는 mediaType 매치


이미지 조회 삭제 로직 (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;                // jpg, jpeg

      case "png":
        return MediaType.IMAGE_PNG;                 // 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 엔티티에 경로 등등 정보 저장
    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로 확장
  • 이미지 핸들링 자체가 무거운 작업이니 멀티 스레딩으로 병행 작업 시도
profile
백엔드 개발도 락이다

0개의 댓글