[TIL] Spring MultipartFile+AWS S3 적용

YJin·2025년 5월 12일

[내배캠 Spring 6기_TIL]

목록 보기
33/56
post-thumbnail

MultipartFile+AWS S3를 사용하여 사용자 프로필 이미지를 업로드하는 과정을 정리해보았다.

AWS S3 설정

사용자 그룹 생성

보안이나 ACL(접근 제어 리스트) 관리 편리를 위해서 사용자를 우선 생성해주어야 한다. 이미 IAM 사용자가 있다면 이 단계는 패스하면 된다.


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


이름 적어주고


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


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


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


버킷


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

  • 버킷: 프로젝트 폴더
  • 디렉토리: 버킷 내부의 폴더
  • 객체: 버킷 내부에 저장된 각각의 파일


S3 업로드 시 ACL을 통한 퍼블릭 공개 설정을 사용할 것이므로 버킷 - 보안설정에서 퍼블릭 액세스는 ACL을 제외한 나머지를 차단해준다.


환경 설정

build.gradle

// 이미지 업로드
implementation 'commons-io:commons-io:2.6'

// aws s3
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

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=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 과 통신하며 파일 업로드/조회/삭제 기능 담당

S3Config

@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 인터페이스의 구현체이다.



S3Service

@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 파일 링크를 통해 이미지를 가져오고, 삭제 시에는 키 네임(버킷+객체 이름)을 통해 삭제한다.
링크보다는 키 네임을 통한 객체 식별이 편리하다.



오류

버킷 ACL 문제

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;
  • 처음에는 모든 퍼블릭 액세스를 차단하여 다음과 같은 오류가 발생하였다.
  • 버킷에서 ACL을 허용하지 않아 발생하는 오류이다.
  • 따라서 버킷의 퍼블릭 액세스 중 ACL을 제외한 나머지 것들만 차단하는 것으로 변경하여 해결하였다.


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로 변경하였다.

  • @RequestPartContentType이 multipart/form-data 인 요청에서만 동작하며, 주로 이미지와 같은 파일을 form-data 형식으로 보내올 때 사용한다.

  • 물론 @RequestParam만 사용하여도 파일(MultipartFile)을 받을 수 있지만, 파일과 JSON 데이터를 동시에 처리하려면 @RequestPart가 더 적절하다.



profile
백엔드 개발도 락이다

0개의 댓글