Spring Boot로 S3 이미지 업로드 기능 구현하기 (MultipartFile 업로드)

Spring Boot를 활용하여 S3 이미지 업로드 기능을 구현하는 방법을 담은 글이다. Spring에서 제공하는 MultipartFile 인터페이스를 이용하여 파일을 업로드하는 방식을 사용하여 업로드하려고 한다.

이 글이 담고 있는 내용

  • 세 가지 업로드 방법 소개
  • S3 버킷 생성
  • IAM 계정 생성
  • 스프링 부트 애플리케이션과 S3 연동
  • Postman을 통한 테스트

환경

  • Java 17
  • Gradle
  • Spring Boot 3.2.4
  • IntelliJ Ultimate

1. Spring Boot에서 S3에 파일을 업로드하는 세 가지 방법

S3에 파일을 업로드하는 방법에는 3가지가 있다.

  1. Stream 업로드
  2. MultipartFile 업로드
  3. AWS Multipart 업로드

1. 1 Stream 업로드

Stream 업로드 방식은 파일을 chunk 단위로 읽어서 서버에 전송하는 방식이다. 직렬화된 데이터를 순차적으로 보내므로, 대용량 파일을 안정적으로 전송할 수 있지만, 각 chunk를 순차적으로 전송하기 때문에 전체 파일을 한 번에 업로드하는 방식보다 더 많은 시간이 소요될 수 있다.

1.2 MultipartFile 업로드

MultipartFile 업로드는 Spring에서 제공하는 MultipartFile 인터페이스를 이용하여 파일을 업로드하는 방식이다. 대부분의 웹 개발 프레임워크 및 라이브러리에서 기본적으로 지원하는 방식이므로 구현이 간단하다. 또한 사용자가 파일을 선택하고 업로드 버튼을 클릭하는 것으로 파일을 서버에 업로드할 수 있다. 드어마 파일 전체를 메모리에 로드하므로, 대용량 파일을 처리할 때 메모리 부담이 있을 수 있다. 또한 파일을 서버에 업로드할 때 보안 취약성이 발생할 수 있다.

1. 3 AWS Multipart 업로드

AWS Multipart 업로드는 AWS S3에서 제공하는 파일 업로드 방식으로 업로드할 파일을 작은 part로 나누어 개별적으로 업로드한다. 파일의 바이너리가 Spring Boot를 거치지 않고 AWS S3에 다이렉트로 업로드되기 때문에 서버의 부하를 고려하지 않아도 된다. 업로드 중에 오류가 발생해도 해당 부분만 재시도할 수 있다. 그러나 복잡한 구현을 필요로 하고 여러 요청을 병렬로 보내므로, 이에 따른 네트워크 및 데이터 전송 비용이 발생할 수 있다.

원래 Presigned URL을 이용하는 3번 방식을 통해 구현해보고 싶었으나 다음과 같은 이유로 2번을 사용하려고 한다.

  • 3번의 경우 클라이언트로 부터 파일 업로드 요청을 받으면 프론트는 다시 백엔드로 그 파일을 전달해주고 백엔드에서 다시 S3로 업로드하는 불필요한 과정이 발생한다.
  • 프로젝트 규모가 현재 크지 않고, 빠르게 기능 구현을 해야하는 상황에서 나만의 호기심으로 사용해보기엔 구현 복잡도가 높다.

따라서 현실과 타협해 Multipart 파일을 S3에 업로드하는 방법을 통해 이미지 업로드 기능을 구현하려고 한다.

2. S3 버킷 생성

2.1 AWS 루트 계정으로 로그인한 뒤, S3 서비스를 찾아 왼쪽 상단 버킷을 클릭한다.

 

2.2 버킷 만들기

버킷 만들기 클릭버킷 만들기를 클릭하면 다음과 같이 뜨는데, 버킷 이름은 고유한 이름으로 설정해준다.

버킷 리전, 이름 설정 ACL을 통해 버킷이나 객체에 대해 요청자의 권한 허용 범위를 어디까지 설정할 것인가에 대해 간단하게 설정할 수 있다. 객체 소유권 지정에서 ACL 활성화를 클릭한다. 

@danger
만약 ACL을 비활성화한다면 The bucket does not allow ACLs 에러를 마주할 수 있다.

 

 

다음은 퍼블릭 액세스다. 이 버킷의 퍼블릭 액세스를 차단한다는 것은 외부에서도 파일을 읽게 하지 못한다는 의미다. 퍼블릭 액세스를 경우에 따라 차단하고 싶다면, "모든 퍼블릭 액세스 차단"은 비활성화하고 세부적인 옵션을 선택하면 된다.

@warning
실무에서 사용할 경우에는 모든 액세스 차단 혹은 ACL을 이용하여 액세스 차단해주는 것이 보안을 위해 좋다.
더보기

@ 각 항목은 다음을 의미한다

  • 새 ACL(액세스 제어 목록)을 통해 부여된 버킷 및 객체에 대한 퍼블릭 액세스 차단 : 지정된 ACL이 퍼블릭이거나, 요청에 퍼블릭 ACL이 포함되어 있으면 PUT 요청을 거절한다.
  • 임의의 ACL(액세스 제어 목록)을 통해 부여된 버킷 및 객체에 대한 퍼블릭 액세스 차단 : 버킷의 모든 퍼블릭 ACL과 그 안에 포함된 모든 Object를 무시하고, 퍼블릭 ACL를 포함하는 PUT 요청은 허용한다.
  • 새 퍼블릭 버킷 또는 액세스 지점 정책을 통해 부여된 버킷 및 객체에 대한 퍼블릭 액세스 차단 : 지정된 버킷 정책이 퍼블릭이면 PUT 요청을 거절한다. 이 설정을 체크하면 버킷 및 객체에 대한 퍼블릭 액세스를 차단하고 사용자가 버킷 정책을 관리할 수 있으며, 이 설정 활성화는 기존 버킷 정책에 영향을 주지 않는다.
  • 임의의 퍼블릭 버킷 또는 액세스 지점 정책을 통해 부여된 버킷 및 객체에 대한 퍼블릭 액세스 차단 : AWS 서비스로만 제한되며, 이 설정 활성화는 기존 버킷 정책에 영향을 주지 않는다.

다른 설정은 default로 해주고, 버킷을 생성한다.

2. 버킷 정책 편집

생성한 버킷을 클릭한다.

권한 -> 버킷정책 -> 편집 -> 버킷 ARN 복사 -> 정책 생성기에 접속한다.

그러면 위와 같은 창이 뜬다. 빨간색 네모 친 부분들을 다음과 같이 설정해준다.

  • Policy : S3 Bucket Policy 선택
  • Principal : * 입력
  • Action :  DeleteObject, GetObject, PutObject 선택
  • ARN : 복사한 버킷 ARN 붙여넣기한 후 /* 추가 (특정 폴더에만 접근 가능하게 하고 싶으면 /폴더이름 을 입력하면 된다.)
나는 사진 업로드, 조회, 삭제를 위해 Action을 위 3가지(GetObject, PutObject, DeleteObject)만 설정했다.

Add Statement 클릭 후 Generate Policy를 클릭한다. 그럼 아래와 같은 JSON이 뜨는데, 이를 복사한다.

{
  "Id": "Policy1743160837925",
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Stmt1743160836720",
      "Action": [
        "s3:DeleteObject",
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Effect": "Allow",
      "Resource": "arn:aws:s3:::버킷명/*",
      "Principal": "*"
    }
  ]
}

다음과 같이 정책에 붙여넣고 변경 사항을 저장한다.

3. IAM 계정 생성하기

AWS 계정에서 IAM 사용자를 생성하고, 이 사용자에게 Amazon S3 버킷과 이 버킷에 대한 액세스 권한을 부여할 수 있다. IAM 계정 생성 전, IAM이 무엇인지 알고 IAM 관리자에 대한 결제 액세스를 활성화하고자 한다면 다음 글을 참고하면 좋다.

3.1 루트 사용자로 로그인 후, 검색창에서 IAM을 검색한다

IAM 검색

3.2 사용자를 생성한다

사용자 생성을 클릭한다.

 

 이름을 설정한 뒤 다음을 클릭해준다.

사용자 세부 정보 지정

우리는 별도의 그룹에 속해 있지 않고, S3만을 위한 권한이 필요하기 때문에 직접 정책 연결을 선택해준다.

권한 설정

권한 정책에서 S3를 검색하고 AmazonS3FullAccess 정책을 선택한다. S3에 대한 모든 권한을 소유한다는 의미다.

AmazonS3FullAccess 선택권한 선택

 후 권한 요약을 확인한 뒤 생성을 누른다. 그럼 다음과 같이 생성이 되었을 것이다.

IAM 사용자 생성 완료

3.3 키 발급하기

아직 IAM 사용자가 AWS CLI 또는 AWS API를 통해 프로그래밍 방식으로 AWS 서비스에 액세스할 수 있게 해주는 액세스 키를 발급하지 않았다. 스프링부트 애플리케이션에서 IAM 계정에 기반한 엑세스 키를 활용해서 S3 에 파일을 읽고 쓰는 작업을 진행할 것인데, 엑세스 키가 활성화 되지 않으면 S3 에 대한 접근권한이 없어서 작업 진행이 되지 않는다. 따라서 엑세스 키를 활성화해줘야 한다. 

 

보안자격 증명에 들어가 액세스 키 만들기를 선택한다.

보안자격 증명 > 액세스 키 만들기

액세스 키 모범 사례 및 대안은 보기 중 한 개를 선택해주면 된다. 기능의 차이는 없다.

액세스 키 모범 사례 및 대안

다음에는 설명 태그 설정이 나오는데, 키에 대한 설명을 적어주면 된다. 그럼 아래와 같은 화면이 나온다.

액세스 키 검색

@danger
액세스 키를 발급받은 경우 발급 받은 직후에만 조회가 가능하니 따로 메모를 해두거나 파일을 다운 받기를 권장한다.

4. 스프링부트 애플리케이션과 S3 연동하기

4.1 의존성 추가

다음과 같이 spring cloud AWS 외부 라이브러리를 활용할 것이다. build.gradle에 다음과 같은 의존성을 추가한다.

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

4.2 application.yml 파일 작성

application.yml 파일에 생성한 IAM의 계정 정보와 액세스 키, 시크릿 키를 등록해준다. 이 때 나는 Intellij -> Edit Configuration -> Environment variables에서 다음과 같이 액세스 키, 시크릿 키에 대하여 환경 변수를 설정했다. IntelliJ 환경 변수 설정은 다음 글을 참고하면 좋다.

환경변수 설정

 

# AWS S3
cloud:
  aws:
    credentials:
      access-key: ${ACCESS_KEY}
      secret-key: ${SECRET_KEY} 
    region:
      static: ap-northeast-2  # 버킷의 리전
    s3:
      bucket: happinessql   # 버킷 이름
    stack:
      auto: false

spring:
  servlet:
    multipart:
      max-file-size: 10MB // 업로드할 수 있는 개별 파일의 최대 크기. 기본 1MB
      max-request-size: 10MB //multipart/form-data 요청의 최대 허용 크기. 기본 10MB

환경 변수 설정을 하지 않고, 시크릿 키와 액세스 키를 그대로 작성한 후 application.yml 파일을 gitignore해주어도 된다.

4.3 Config 파일

S3용 Config 파일을 생성해준다. 이 설정을 통해 애플리케이션은 AWS S3 버킷에 파일을 업로드하거나 다운로드할 수 있게 된다.

@Slf4j
@Configuration
public class AwsS3Config {
    @Value("${cloud.aws.credentials.access-key}") // application.yml 에 명시한 내용
    private String accessKey;

    @Value("${cloud.aws.credentials.secret-key}")
    private String secretKey;

    @Value("${cloud.aws.region.static}")
    private String region;

    @Bean
    public AmazonS3Client amazonS3Client() {
        BasicAWSCredentials awsCreds = new BasicAWSCredentials(accessKey, secretKey);
        return (AmazonS3Client) AmazonS3ClientBuilder.standard()
                .withRegion(region)
                .withCredentials(new AWSStaticCredentialsProvider(awsCreds))
                .build();
    }
}
  • @Value 어노테이션: application.yml 또는 application.properties 파일에 정의된 값을 주입받기 위해 사용된다. 여기서는 AWS의 accessKey, secretKey, 그리고 region 정보를 가져온다.
  • amazonS3Client() 메서드: Amazon S3 클라이언트 객체를 생성하고 구성하는 빈을 정의한다. Spring Boot Cloud AWS를 이용하면 AmazonS3Client와 같은 S3 관련 Bean들이 자동 생성된다. 즉, 여기서는 AmazonS3Client를 재정의한 것이다.
  • BasicAWSCredentials 객체 : AWS에 접근하기 위한 accessKey와 secretKey를 사용하여 생성된다.
  • AmazonS3ClientBuilder를 사용하여 클라이언트 객체를 구성한다.

4.4 S3 컨트롤러 작성

@RestController
@RequiredArgsConstructor
@RequestMapping("/file")
public class AmazonS3Controller {

    private final AwsS3Service awsS3Service;

    @PostMapping
    public ResponseEntity<List<String>> uploadFile(List<MultipartFile> multipartFiles){
         return ResponseEntity.ok(awsS3Service.uploadFile(multipartFiles));
    }

    @DeleteMapping
    public ResponseEntity<String> deleteFile(@RequestParam String fileName){
        awsS3Service.deleteFile(fileName);
        return ResponseEntity.ok(fileName);
    }
}

4.5 S3 서비스 작성

다중 파일 업로드

다중 파일 업로드 시 다음과 같이 작성한다.

@Service
@RequiredArgsConstructor
public class AwsS3Service {
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    private final AmazonS3 amazonS3;

    public List<String> uploadFile(List<MultipartFile> multipartFiles){
        List<String> fileNameList = new ArrayList<>();

        // forEach 구문을 통해 multipartFiles 리스트로 넘어온 파일들을 순차적으로 fileNameList 에 추가
        multipartFiles.forEach(file -> {
            String fileName = createFileName(file.getOriginalFilename());
            ObjectMetadata objectMetadata = new ObjectMetadata();
            objectMetadata.setContentLength(file.getSize());
            objectMetadata.setContentType(file.getContentType());

            try(InputStream inputStream = file.getInputStream()){
                amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata)
                        .withCannedAcl(CannedAccessControlList.PublicRead));
            } catch (IOException e){
                throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다.");
            }
            fileNameList.add(fileName);

        });

        return fileNameList;
    }

    // 파일명을 난수화하기 위해 UUID 를 활용하여 난수를 돌린다.
    public String createFileName(String fileName){
        return UUID.randomUUID().toString().concat(getFileExtension(fileName));
    }

    //  "."의 존재 유무만 판단
    private String getFileExtension(String fileName){
        try{
            return fileName.substring(fileName.lastIndexOf("."));
        } catch (StringIndexOutOfBoundsException e){
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 형식의 파일" + fileName + ") 입니다.");
        }
    }


    public void deleteFile(String fileName){
        amazonS3.deleteObject(new DeleteObjectRequest(bucket, fileName));
        System.out.println(bucket);
    }
}

개별 파일 업로드

내 프로젝트의 경우 이미지를 하나만 필요로 하고, 이미지가 필수값이 아니기 때문에 다음과 같이 작성했다.

// AWSS3Controller.java
@PostMapping
public ResponseEntity<String> uploadFile(MultipartFile multipartFile){
    return ResponseEntity.ok((awsS3Service.uploadFile(multipartFile)));
}
// AWSS3Service.java
public String uploadFile(MultipartFile multipartFile){

    if (multipartFile == null || multipartFile.isEmpty()) {
        return null;
    }

    String fileName = createFileName(multipartFile.getOriginalFilename());
    ObjectMetadata objectMetadata = new ObjectMetadata();
    objectMetadata.setContentLength(multipartFile.getSize());
    objectMetadata.setContentType(multipartFile.getContentType());

    try(InputStream inputStream = multipartFile.getInputStream()){
        amazonS3.putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMetadata)
                .withCannedAcl(CannedAccessControlList.PublicRead));
    } catch (IOException e){
        throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다.");
    }

    return fileName;
}

5. Postman에서 확인해보기

구현한 기능이 잘 작동하는지 Postman에서 확인해보자. 개별 파일 업로드 기준으로 테스트하였다.

5.1 Upload 테스트

Upload 테스트

5.2 Delete 테스트

Delete 테스트

참고자료