
MultipartFile+AWS S3를 사용하여 사용자 프로필 이미지를 업로드하는 과정을 정리해보았다.
보안이나 ACL(접근 제어 리스트) 관리 편리를 위해서 사용자를 우선 생성해주어야 한다. 이미 IAM 사용자가 있다면 이 단계는 패스하면 된다.

우선 IAM - 사용자 에서 사용자를 새로 생성한다.

이름 적어주고

직접 정책 연결에서 AmazonS3FullAccess 권한을 설정해준다.

사용자 생성 끝이다.
그리고 액세스 키 만들기에 들어가서 키를 생성한다.

생성된 키는 해당 화면을 나가면 다시 확인 못하므로 우측 하단에서 .csv 파일로 다운로드 받아두자.

S3에 들어가서 버킷을 생성한다.

S3 업로드 시 ACL을 통한 퍼블릭 공개 설정을 사용할 것이므로 버킷 - 보안설정에서 퍼블릭 액세스는 ACL을 제외한 나머지를 차단해준다.
// 이미지 업로드
implementation 'commons-io:commons-io:2.6'
// aws s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
#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=temp/
#aws-s3
cloud.aws.S3.bucket=버킷명
cloud.aws.s3.path.디렉토리명=디렉토리명
cloud.aws.region.static=ap-northeast-2
cloud.aws.stack.auto=false
cloud.aws.credentials.access-key=${S3_ACCESS_KEY}
cloud.aws.credentials.secret-key=${S3_SECRET_KEY}
IAM 사용자 액세스키(S3_ACCESS_KEY)와 시크릿키(S3_SECRET_KEY)를 각각 환경변수에 세팅해준다.
S3Config : S3 관련 설정 및 AmazonS3Client 빈 등록
S3Service : S3 과 통신하며 파일 업로드/조회/삭제 기능 담당
@Configuration
public class S3Config {
@Value("${cloud.aws.credentials.access-key}")
private String accessKey;
@Value("${cloud.aws.credentials.secret-key}")
private String accessSecret;
@Value("${cloud.aws.region.static}")
private String region;
@Bean
public AmazonS3 s3Client() {
AWSCredentials credentials = new BasicAWSCredentials(accessKey, accessSecret);
return AmazonS3ClientBuilder.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
s3Client : S3와의 통신을 담당하는 AmazonS3Client를 Bean으로 등록한다. AmazonS3Client는 AmazonS3 인터페이스의 구현체이다.
@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {
private final ImageRepository imageRepository;
@Value("${file.upload-dir}")
private String TEMP_RESOURCE_DIR;
private final List<String> ALLOWED_FILE_TYPES = List.of(
".jpeg", ".jpg", ".png"
);
private final AmazonS3 s3Client;
@Value("${cloud.aws.s3.bucket}")
private String bucket;
// 단일 파일 저장 후 이미지 반환
public String uploadFile(MultipartFile file) throws IOException {
// 파일 타입 검사
if (!isValidFileType(file.getOriginalFilename())) {
throw new InvalidRequestException("지원하지 않는 파일 형식입니다.");
}
// 파일 이름 중복 방지
String uuidFilename = UUID.randomUUID() + "_" + file.getOriginalFilename();
// Multipart -> File 로 변환 (로컬에 임시 저장)
Path filePath = Paths.get(TEMP_RESOURCE_DIR + uuidFilename);
File uploadFile = convert(file, filePath.toString())
.orElseThrow(() -> new UncheckedIOException(new IOException("로컬 파일 저장에 실패하였습니다.")));
// S3에 파일 업로드
String url;
try {
url = putS3(uploadFile, uuidFilename);
} catch (Exception e) {
throw new RuntimeException("S3 파일 업로드에 실패하였습니다: ", e);
} finally {
deleteLocalTempFile(uploadFile); // 실패 시 로컬 임시 파일 삭제
}
return url;
}
// 파일 확장자 검사
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;
}
// 파일을 로컬에 임시 업로드
private Optional<File> convert(MultipartFile file, String filePath) {
// 파일 임시 저장 경로 설정
File convertFile = new File(filePath);
// convertFile 에 작성
try (FileOutputStream fos = new FileOutputStream(convertFile)) {
fos.write(file.getBytes());
return Optional.of(convertFile);
}
catch (IOException e) {
log.error("FAILED TO WRITE FILE PATH: {}", filePath);
return Optional.empty();
}
}
// S3 에 파일 업로드
private String putS3(File uploadFile, String fileName) {
s3Client.putObject(new PutObjectRequest(bucket, fileName, uploadFile).withCannedAcl(
CannedAccessControlList.PublicRead)); // 공개 읽기 권한을 부여하여 파일 업로드
return s3Client.getUrl(bucket, fileName).toString();
}
// S3 에 업로드 된 파일 삭제
public boolean deleteFile(String filename) {
s3Client.deleteObject(new DeleteObjectRequest(bucket, filename));
return !s3Client.doesObjectExist(bucket, filename);
}
// 로컬 임시 파일 삭제: 비동기 실행
// 실패 시 최대 3회 실행
@Async
@Retryable(value = IOException.class, maxAttempts = 3)
public void deleteLocalTempFile(File tempFile) throws IOException{
if(tempFile.delete()) {
log.info("로컬 임시 파일 삭제에 성공하였습니다 : "+tempFile.getPath());
} else {
log.error("로컬 임시 파일 삭제에 실패하였습니다 : "+tempFile.getPath());
throw new IOException("로컬 임시 파일 삭제에 실패하였습니다 : \"+tempFile.getPath()");
}
}
}
uploadFile : 로컬에 MultipartFile을 File로 임시 저장 후 S3에 업로드. 완료 시 임시 파일 삭제 후 S3에 업로드 된 파일의 링크를 반환한다.
isValidFileType : 서버에서 허용하는 파일 확장자인지 검사
convert : MultipartFile을 임시로 로컬에 저장 후 File 반환
putS3 : 실제 S3에 파일 업로드를 담당. withCannedAcl으로 공개 읽기 권한을 부여하여 파일 업로드
deleteFile : S3에 업로드 된 파일 삭제
deleteLocalTempFile : 로컬에 저장된 임시 파일을 비동기 처리로 삭제한다.
조회 시에는 S3 파일 링크를 통해 이미지를 가져오고, 삭제 시에는 키 네임(버킷+객체 이름)을 통해 삭제한다.
링크보다는 키 네임을 통한 객체 식별이 편리하다.
2025-05-13T01:46:50.751+09:00 ERROR 22504 --- [spring-plus] [nio-8080-exec-4] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.RuntimeException: S3 파일 업로드에 실패하였습니다: ] with root cause
com.amazonaws.services.s3.model.AmazonS3Exception: The bucket does not allow ACLs (Service: Amazon S3; Status Code: 400; Error Code: AccessControlListNotSupported;
com.amazonaws.services.s3.model.AmazonS3Exception: User: arn:aws:iam::00000000:user/Admin is not authorized to perform: s3:PutObject on resource: "arn:aws:s3:::bucket/keyname.jpg" because public access control lists (ACLs) are blocked by the BlockPublicAcls block public access setting. (Service: Amazon S3; Status Code: 403; Error Code: AccessDenied; Request ID: xxxxxxx; S3 Extended Request ID: xxxxxxxx; Proxy: null)
로컬 파일 저장 및 MultipartFile 부분은 지난 포스트를 참고하였다. (https://velog.io/@yoon17710/TIL-Spring-Multipart-image-upload)
Controller 단에서 파일(MulitpartFile) 을 @RequestParam으로 받는 방식을 사용했었는데, 이번에 @RequestPart로 변경하였다.
@RequestPart는 ContentType이 multipart/form-data 인 요청에서만 동작하며, 주로 이미지와 같은 파일을 form-data 형식으로 보내올 때 사용한다.
물론 @RequestParam만 사용하여도 파일(MultipartFile)을 받을 수 있지만, 파일과 JSON 데이터를 동시에 처리하려면 @RequestPart가 더 적절하다.