이미지와 같은 정적 파일을 효율적으로 관리하는 것은 웹 애플리케이션 개발에서 자주 마주하게 됩니다. 이때, 클라우드 기반 저장소인 AWS S3 (Simple Storage Service)는 높은 확정성과 안정성을 제공합니다.   
이번 포스팅에서는 Java 기반 Spring Boot 환경에서 MultipartFile방식을 활용하여 다중으로 AWS S3에 이미지파일을 업로드하는 방법과 업로드된 이미지파일을 삭제하는 방법에 대해 알아보도록 하겠습니다.

 

MultipartFile 업로드 방식 개념

시작하기에 앞서 MultipartFile 업로드 방식의 개념에 대해 먼저 알아보도록 하겠습니다.

 

MultipartFile 플로우 이미지

MultipartFile은 Spring에서 제공하는 인터페이스로, 파일 업로드를 간편하게 처리할 수 있도록 돕는 기능입니다. 이 인터페이스는 업로드된 파일의 이름, 크기, 내용 등에 접근할 수 있는 메서드를 제공하며, 파일 업로드 작업에 있어 높은 수준의 추상화와 편의성을 제공합니다.

  • 업로드된 파일을 임시 디렉터리에 저장 후, 처리 요청이 끝나면 해당 파일을 자동으로 삭제합니다.
  • 파일을 메모리가 아닌 Servlet Container Disk에 저장하여 메모리 부담을 줄입니다.
  • Spring이 제공하는 설정을 통해 업로드 제한 및 동작 방식을 세부적으로 제어할 수 있습니다.

 

spring:
servlet:
multipart:
enabled: true # 멀티파트 업로드 지원 여부 (default: true)
file-size-threshold: 0B # 메모리 대신 디스크에 저장하는 파일의 최소 크기 (default: 0B)
location: /Users/tao/Documents # 업로드된 파일이 임시로 저장될 디렉터리 경로
max-file-size: 100MB # 업로드할 단일 파일의 최대 크기 (default: 1MB)
max-request-size: 100MB # 업로드 요청 전체의 최대 크기 (default: 10MB)

MultipartFile 업로드 방식을 사용 시 위와 같은 설정을 추가할 수 있습니다.

 

  • 클라이언트 업로드: 클라이언트가 파일을 업로드하면 AWS(Tomcat)가 해당 파일을 설정된 임시 디렉터리 location에 저장합니다.
  • 임시 파일 관리: 임시 디렉터리에 저장된 파일은 요청 처리가 끝나면 자동으로 삭제됩니다.
  • 예외 상황: 업로드 중 배포나 서버 장애가 발생하면, 임시 파일이 삭제되지 않고 남아 있을 수 있습니다. 이 경우 별도의 삭제 작업이 필요하므로 location 경로를 직접 설정하여 관리를 용이하게 할 수 있습니다.
  • 파일 크기와 메모리 할당: file-size-threshold 값을 기준으로 파일의 크기가 결정됩니다.
    • 파일의 크기가 file-size-threshold 이하일 경우 파일은 메모리에 직접 할당됩니다. 이 방식은 빠르지만 메모리에 부담을 줄 수 있어 적절한 크기 조정이 필요합니다.
    • 파일 크기가 file-size-threshold를 초과할 경우 파일은 지정된 location 경로에 저장됩니다. 이후 Spring에서 필요한 경우 해당 파일을 읽어 작업을 수행합니다.

 

 

AWS S3 인프라 구축

AWS S3를 활용하기 위해 먼저 기본적인 인프라 구축이 필요합니다. 이를 위해 S3 버킷을 생성하고, Spring Boot 애플리케이션에서 사용할 수 있도록 필요한 권한 및 설정을 구성해야 합니다.

 

AWS 리전 설정 이미지

AWS 사이트에 접속 후 로그인 한 뒤 우측 상단에 위치한 리전을 서울로 설정합니다.

 

S3 버킷 생성

AWS S3 검색 이미지
버킷 만들기 선택 이미지

S3를 검색하여 이동 후 버킷 만들기를 클릭합니다.

 

버킷 생성 이미지
버킷 생성 이미지
버킷 생성 이미지

버킷 이름 입력ACL 활성화퍼블릭 액세스 차단 설정 해제

위 과정을 마치고 버킷 생성을 마무리합니다.

 

ACL 활성화 및 퍼블릭 액세스 차단 설정을 해제한 이유는 이후 ACL 설정으로 퍼블릭 읽기권한을 부여하기 위함입니다.

 

IAM 사용자 생성

AWS IAM 검색 이미지
IAM 사용자 생성 선택 이미지
IAM 사용자 생성 이미지
IAM 사용자 생성 이미지
IAM 사용자 생성 이미지

위의 과정을 따라 IAM 사용자를 생성합니다.

 

IAM 액세스 키 생성

IAM 액세스 키 생성
IAM 액세스 키 생성
IAM 액세스 키 생성
IAM 액세스 키 생성

표시된 액세스 키, 비밀 액세스 키를 따로 메모하여 저장하거나, .csv파일을 다운로드하여 보관합니다. 액세스 키는 Spring properties에 등록하여 AWS 리소스에 접근하는 데에 사용됩니다.

해당 페이지를 벗어나면 이후 액세스 키를 확인할 수 없어 새로 생성해야하니 잘 저장해두어야 합니다.

 

이미지 업로드 API 구현

AWS 인프라 구축을 마치고 Java 기반 Spring Boot 환경에서 MultipartFile방식을 활용하여 AWS S3에 이미지를 업로드하는 방법에 대해 알아보도록 하겠습니다.

 

의존성 설정

implementation 'software.amazon.awssdk:s3:2.29.50'

먼저 Spring Boot에서 AWS S3 리소스 접근을 위해 해당 의존성을 추가합니다.

필자의 경우 awssdk:s3 2.x 버전을 사용하였습니다.

 

예제를 다룰 때 awssdk:s3 1.x, awssdk:s3 2.x, spring-cloud-starter-aws 등 여러가지 의존성을 사용하는 모습을 볼 수 있습니다. 각 차이점에 대해 간단하게 알아보겠습니다.

  • spring-cloud-starter-aws awssdk:~~ 처럼 AWS의 단일된 리소스를 사용하는게 아닌 AWS 리소스에 통합적으로 접근하기 위해 사용됩니다. (Spring 한정)
  • awssdk:s3 1.x AWS S3 단일 리소스 접근에 사용되며, 클라이언트 클래스는 amazonS3Client가 사용됩니다. (Java 8 이전 버전의 코드스타일)
  • awssdk:s3 2.x AWS S3 단일 리소스 접근에 사용되며, 클라이언트 클래스는 S3Client가 사용됩니다. (Java 8 이상 버전의 코드스타일)

 

Spring 환경에서 AWS 리소스에 통합적으로 접근하고자 할때는 spring-cloud-starter-aws를, AWS S3 리소스만 다루고자 할때는 awssdk:s31.x 또는 2.x를 사용할 수 있습니다.

 

application.yml 설정

spring:
servlet:
multipart:
enabled: true # 멀티파트 업로드 지원여부 (default: true)
file-size-threshold: 0B # 파일을 디스크에 저장하지 않고 메모리에 저장하는 최소 크기 (default: 0B)
location: /Users/tao/test # 업로드된 파일이 임시로 저장되는 디스크 위치 (default: WAS가 결정)
max-file-size: 100MB # 한개 파일의 최대 사이즈 (default: 1MB)
max-request-size: 100MB # 한개 요청의 최대 사이즈 (default: 10MB)
aws:
s3:
access-key: ${AWS_ACCESS_KEY}
secret-key: ${AWS_SECRET_KEY}
bucket-name: ${BUCKET_NAME}
region: ${AWS_REGION}

앞서 설명한 multipart에 대한 설정을 하고, IAM 생성 시 발급받은 accessKey와 secretKey를 입력합니다.

해당 Key는 외부에 노출되지 않도록 주의가 필요합니다.

 

S3 Config 설정

@Configuration
public class S3Config {
@Value("${aws.s3.access-key}")
private String accessKey;
@Value("${aws.s3.secret-key}")
private String secretKey;
@Value("${aws.region}")
private String region;
@Bean
public S3Client s3Client() {
AwsBasicCredentials awsBasicCredentials = AwsBasicCredentials.create(accessKey, secretKey);
return S3Client.builder()
.credentialsProvider(StaticCredentialsProvider.create(awsBasicCredentials))
.region(Region.of(region))
.build();
}
}

S3Client를 Spring Bean에 등록하고, yml에 설정한 Key와 region 값을 @Value 어노테이션을 통해 주입합니다.

 

Service : 이미지 업로드 비즈니스 로직 작성

@Slf4j
@Service
@RequiredArgsConstructor
public class ImageService {
private final S3Client s3Client;
@Value("${aws.s3.bucket-name}")
private String bucketName;
// [public 메서드] 외부에서 사용, S3에 저장된 이미지 객체의 public url을 반환
public List<String> upload(List<MultipartFile> files) {
// 각 파일을 업로드하고 url을 리스트로 반환
return files.stream()
.map(this::uploadImage)
.toList();
}
// [private 메서드] validateFile메서드를 호출하여 유효성 검증 후 uploadImageToS3메서드에 데이터를 반환하여 S3에 파일 업로드, public url을 받아 서비스 로직에 반환
private String uploadImage(MultipartFile file) {
validateFile(file.getOriginalFilename()); // 파일 유효성 검증
return uploadImageToS3(file); // 이미지를 S3에 업로드하고, 저장된 파일의 public url을 서비스 로직에 반환
}
// [private 메서드] 파일 유효성 검증
private void validateFile(String filename) {
// 파일 존재 유무 검증
if (filename == null || filename.isEmpty()) {
throw new CustomApplicationException(ErrorCode.NOT_EXIST_FILE);
}
// 확장자 존재 유무 검증
int lastDotIndex = filename.lastIndexOf(".");
if (lastDotIndex == -1) {
throw new CustomApplicationException(ErrorCode.NOT_EXIST_FILE_EXTENSION);
}
// 허용되지 않는 확장자 검증
String extension = URLConnection.guessContentTypeFromName(filename);
List<String> allowedExtentionList = Arrays.asList("jpg", "jpeg", "png", "gif");
if (extension == null || !allowedExtentionList.contains(extension)) {
throw new CustomApplicationException(ErrorCode.INVALID_FILE_EXTENSION);
}
}
// [private 메서드] 직접적으로 S3에 업로드
private String uploadImageToS3(MultipartFile file) {
// 원본 파일 명
String originalFilename = file.getOriginalFilename();
// 확장자 명
String extension = Objects.requireNonNull(originalFilename).substring(originalFilename.lastIndexOf(".") + 1);
// 변경된 파일
String s3FileName = UUID.randomUUID().toString().substring(0, 10) + "_" + originalFilename;
// 이미지 파일 -> InputStream 변환
try (InputStream inputStream = file.getInputStream()) {
// PutObjectRequest 객체 생성
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucketName) // 버킷 이름
.key(s3FileName) // 저장할 파일 이름
.acl(ObjectCannedACL.PUBLIC_READ) // 퍼블릭 읽기 권한
.contentType("image/" + extension) // 이미지 MIME 타입
.contentLength(file.getSize()) // 파일 크기
.build();
// S3에 이미지 업로드
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize()));
} catch (Exception exception) {
log.error(exception.getMessage(), exception);
throw new CustomApplicationException(ErrorCode.IO_EXCEPTION_UPLOAD_FILE);
}
// public url 반환
return s3Client.utilities().getUrl(url -> url.bucket(bucketName).key(s3FileName)).toString();
}
}

다중 이미지 업로드를 수행하는 비즈니스 로직을 작성하였으며, 주석으로 각 메서드의 역할과 메서드에 대한 세부 설명에 대해 남겨놓았습니다.

 

Test : 이미지 업로드 Controller 생성

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/s3")
public class ImageController {
private final ImageService imageService;
@PostMapping("/upload")
public ResponseEntity<List<String>> s3Upload(@RequestPart(value = "image") List<MultipartFile> multipartFile) {
List<String> upload = imageService.upload(multipartFile);
return ResponseEntity.ok(upload);
}
}

MultipartFile방식으로 이미지를 업로드하므로 @RequestPart를 사용하여 Controller에서 요청 데이터를 받습니다.

 

Test : Pstman 요청

Postman 요청 이미지
AWS S3 객체 생성 이미지
브라우저로 확인 이미지

Postman을 통해 이미지 업로드 요청 시 S3에 정상적으로 객체가 생성되는 모습을 확인할 수 있습니다.

추가로 ACL 설정퍼블릭 읽기 권한으로 설정했기 때문에 응답받은 이미지 url을 브라우저에 입력하여 업로드된 이미지를 확인할 수 있습니다.

 

Postman 요청 이미지
Postman 요청 이미지

파일 존재 유무와 확장자에 대한 유효성 검증도 정상적으로 이루어지는 모습을 확인할 수 있습니다.

 

Service : 이미지 삭제 비즈니스 로직 작성

@Slf4j
@Service
@RequiredArgsConstructor
public class ImageService {
private final S3Client s3Client;
@Value("${aws.s3.bucket-name}")
private String bucketName;
// [public 메서드] 이미지의 public url을 이용하여 S3에서 해당 이미지를 제거, getKeyFromImageAddress 메서드를 호출하여 삭제에 필요한 key 획득
public void delete(List<String> imageUrls) {
List<String> keys = imageUrls.stream()
.map(this::getKeyFromImageUrls)
.toList();
try {
// S3에서 파일을 삭제하기 위한 요청 객체 생성
DeleteObjectsRequest deleteObjectsRequest = DeleteObjectsRequest.builder()
.bucket(bucketName) // S3 버킷 이름 지정
.delete(delete -> delete.objects(
// S3 객체들을 삭제할 객체 목록을 생성
keys.stream()
.map(key -> ObjectIdentifier.builder().key(key).build())
.toList()
))
.build();
s3Client.deleteObjects(deleteObjectsRequest); // S3에서 객체 삭제
} catch (Exception exception) {
log.error(exception.getMessage(), exception);
throw new CustomApplicationException(ErrorCode.IO_EXCEPTION_DELETE_FILE);
}
}
// [private 메서드] 삭제에 필요한 key 반환
private String getKeyFromImageUrls(String imageUrl) {
try {
URL url = new URI(imageUrl).toURL(); // 인코딩된 주소를 URI 객체로 변환 후 URL 객체로 변환
String decodedKey = URLDecoder.decode(url.getPath(), StandardCharsets.UTF_8);// URI에서 경로 부분을 가져와 URL 디코딩을 통해 실제 키로 변환
return decodedKey.substring(1); // 경로 앞에 '/'가 있으므로 이를 제거한 뒤 반환
} catch (Exception exception) {
log.error(exception.getMessage(), exception);
throw new CustomApplicationException(ErrorCode.INVALID_URL_FORMAT);
}
}
}

다중 이미지 삭제를 수행하는 비즈니스 로직을 작성하였으며, 주석으로 각 메서드의 역할과 메서드에 대한 세부 설명에 대해 남겨놓았습니다.

 

Test : 이미지 삭제 Controller 생성

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/s3")
public class ImageController {
private final ImageService imageService;
@DeleteMapping("/delete")
public ResponseEntity<String> s3Delete(@RequestBody ImageDeleteRequest imageDeleteRequest) {
imageService.delete(imageDeleteRequest.getImageUrls());
return ResponseEntity.ok("이미지 삭제 성공");
}
}

 

Request Dto

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ImageDeleteRequest {
private List<String> imageUrls;
public ImageDeleteRequest(List<String> imageUrls) {
this.imageUrls = imageUrls;
}
}

요청 List 객체를 사용하여 컨트롤러를 작성하였습니다.

 

Test : Postman 요청

Postman 요청 이미지
AWS S3 버킷 이미지
AWS S3 버킷 이미지

Postman을 통해 이미지 삭제 요청 시 요청 받은 이미지가 전부 삭제되는 모습을 볼 수 있습니다.

 

마무리

이번 포스팅에서는 Spring Boot 환경에서 MultipartFile 방식을 사용하여 AWS S3에 다중의 이미지 파일을 업로드하고 삭제하는 방법에 대해 알아보았습니다.

필자의 경우 MultipartFile 방식을 사용하여 예제를 구현하였는데, 파일 업로드 방식에는 Stream, MultipartFile, AWS Multipart 등 여러 방식이 존재하며 각 상황에 따라 적절한 방법을 선택할 수 있습니다.

 

참고

https://techblog.woowahan.com/11392

 

Spring Boot에서 S3에 파일을 업로드하는 세 가지 방법 | 우아한형제들 기술블로그

Spring Boot에서 S3에 파일을 업로드하는 세 가지 방법 | 안녕하세요. 세일즈서비스팀에서 전자계약서 시스템을 개발하고 있는 박민규입니다. 최근 저는 Spring Boot + Kotlin을 활용한 프로젝트에서

techblog.woowahan.com

 

소스코드

https://github.com/o-tao/upload-image

 

GitHub - o-tao/upload-image: AWS S3 이미지 업로드

AWS S3 이미지 업로드. Contribute to o-tao/upload-image development by creating an account on GitHub.

github.com