본문 바로가기
Cloud Service/AWS

[AWS] Spring Boot에서 AWS S3와 연계한 파일 업로드처리

by 임채훈 2022. 3. 7.

이번 포스팅은 Spring Boot 환경에서 AWS S3(Simple Storage Service)를 연계하여 파일을 업로드하는것으로 직접 버킷 생성을 시작으로 Spring Application을 구현하기까지 작성하고자 합니다.

본 포스팅을 작성하는데에 저의 개발환경은 다음과 같습니다.

  • AWS Free Tier 계정(루트 사용자 계정입니다)
  • Gradle
  • Spring Boot 2.5.3

구현에 참고한 자료

버킷 생성

S3 관리 콘솔 접속

서비스 검색 - S3

버킷 만들기

[버킷 만들기]
[버킷 만들기] 입력 폼

버킷의 이름은 Region Group 전역적으로 고유하게 설정해야 됩니다.

ACL은 우선 활성화로 설정해주고 모든 퍼블릭 액세스 차단 또한 체크를 해제하도록 하고 혹여나 생성 이후에도 설정 변경이 가능합니다.

버킷 생성이 성공적으로 이루어졌는지 확인

버킷 목록 페이지

생성이 완료되었으면 버킷 목록에 방금전 설정한 버킷명이 목록에 나타날것입니다.

보안 자격 증명(IAM) 설정(Access Key, Secret Key 발급)

계정 영역에서 [보안 자격 증명]

AWS IAM은 사용자의 계정 또는 그룹에 따라 독립적으로 AWS 자원에 접근을 제어하고 권한을 제어하는 등의 자격 증명을 관리하는 서비스입니다.

자세한 설명은 아래의 링크를 통해 좀 더 알아보실 수 있습니다.

 

IAM이란 무엇입니까? - AWS Identity and Access Management

이 페이지에 작업이 필요하다는 점을 알려 주셔서 감사합니다. 실망시켜 드려 죄송합니다. 잠깐 시간을 내어 설명서를 향상시킬 수 있는 방법에 대해 말씀해 주십시오.

docs.aws.amazon.com

[보안 자격 증명] 페이지에서 [사용자] 클릭
[사용자 추가] 클릭

IAM 콘솔에서 사용자를 하나 추가해주도록 합니다.

여기서 추가할 사용자는 하나의 접근 계정이라고 생각을 하면 되겠고 어플리케이션에서 버킷에 접근을 위해 필요한 계정입니다.

사용자 추가를 마치면 해당 추가된 사용자로부터 발급되는 Access Key와 Secret Key를 활용하여 우리가 작성하게될 Spring Boot Application에서 버킷에 접근할 수 있습니다.

사용자 설정

[사용자 추가] 사용자 이름 입력, 자격 증명 유형 선택

사용자명은 자유롭게 지어주시고 자격 증명 유형은 액세스 키 - 프로그래밍 방식 액세스로 설정합니다.

[사용자 추가] - [기존 정책 직접 연결] - S3 검색 - 'AmazonS3FullAccess' 체크

정책으로 AmazonS3FullAccess를 설정해줍니다.

아래에 따르면 명칭 그대로 전체 액세스에 대한 권한입니다.

AWS 공식 문서상 AmazonS3FullAccess에 대한 설명
[사용자 추가] 검토 페이지
[사용자 추가] 최종 확인 및 Access Key, Secret Key 확인

성공적으로 사용자 추가가 이루어졌습니다.

마지막 페이지에서 나타나는 액세스 키 ID와 비밀 액세스 키를 어딘가에 잘 저장해두도록 하고 추후에 어플리케이션에서 사용할 값들입니다.

Spring Boot에서 S3와 연계

Gradle Dependency 추가

  • build.gradle
dependencies {
    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
}

프로퍼티 작성

  • application.yml
cloud:
  aws:
    s3:
      bucket: my.first.example.sss.bucket
    credentials:
      access-key: AKIA4LMXKQQFGT365H7R
      secret-key: SpmcRDmbpr+NrCRxfCSMVucuV5LaMZ0QV4HJvygk
    region:
      static: ap-northeast-2
      auto: false
    stack:
      auto: false

우선 region과 credentials를 설정해주도록 합니다.

cloud.aws.s3.bucket은 bucket명을 지정해준 임의로 속성값으로 다른 명칭으로 설정해주어도 관계 없습니다.

cloud.aws.stack.auto 속성을 false로 지정하지 않으면 다음과 같은 StackTrace가 발생합니다.

Caused by: java.lang.IllegalArgumentException: No valid instance id defined
    at org.springframework.util.Assert.notNull(Assert.java:201) ~[spring-core-5.3.9.jar:5.3.9]
    at org.springframework.cloud.aws.core.env.stack.config.AutoDetectingStackNameProvider.autoDetectStackName(AutoDetectingStackNameProvider.java:85) ~[spring-cloud-aws-core-2.2.6.RELEASE.jar:2.2.6.RELEASE]
    at org.springframework.cloud.aws.core.env.stack.config.AutoDetectingStackNameProvider.afterPropertiesSet(AutoDetectingStackNameProvider.java:70) ~[spring-cloud-aws-core-2.2.6.RELEASE.jar:2.2.6.RELEASE]

이는 Spring Cloud AWS는 기본적으로 서비스가 Cloud Formation 스택내에서 실행된다고 가정하기 때문에 그렇지 않은 경우 임의로 false 값으로 설정을 해줘야됩니다.

com.amazonaws.sdk.disableEc2Metadata 설정

  • @SpringBootApplication 클래스
@SpringBootApplication
public class GettingStartedSpringAwsS3Application {

    static {
        System.setProperty("com.amazonaws.sdk.disableEc2Metadata", "true");
    }

    public static void main(String[] args) {
        SpringApplication.run(GettingStartedSpringAwsS3Application.class, args);
    }

}

여기서의 핵심은 com.amazonaws.sdk.disableEc2Metadata 속성을 true로 설정해주는것입니다.

만약 해당 설정을 하지 않을 경우 서비스가 실행되는 시점에 약간의 지연이 발생하고 다음과 같은 예외 메세지가 발생합니다.

2022-03-07 10:16:16.883  WARN 2568 --- [  restartedMain] com.amazonaws.util.EC2MetadataUtils      : Unable to retrieve the requested metadata (/latest/meta-data/instance-id). EC2 Instance Metadata Service is disabled

Caused by: java.net.ConnectException: Host is down (connect failed)
    at java.base/java.net.PlainSocketImpl.socketConnect(Native Method) ~[na:na]
    at java.base/java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:399) ~[na:na]
    at java.base/java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:242) ~[na:na]

저의 경우 위와같이 EntryPoint인 메인 함수가 존재하는 클래스에서 설정을 해준것이고 어플리케이션을 실행하는 시점에 VM Arguments로 설정해줄수도 있습니다.

인텔리제이를 사용하는 경우 다음과 같이 설정해주면 됩니다.

[Run Configurations 창]

해당 설정이 완료되면 서비스가 실행되는 시점에 발생하던 약간의 지연은 사라진것을 알 수 있지만 여전히 오류메세지는 발생하고 있습니다.

만약 이 메세지가 거슬린다 하신다면 해당 메세지를 발생시키는 클래스의 로깅 수준을 바꿔주면 됩니다.

아래의 로그를 보면 Warning 로그가 발생하고 있고 해당 클래스의 로깅 수준을 Error로 설정해주면 해당 로그가 발생하지 않을겁니다.

2022-03-07 10:16:16.883  WARN 2568 --- [  restartedMain] com.amazonaws.util.EC2MetadataUtils      : Unable to retrieve the requested metadata (/latest/meta-data/instance-id). EC2 Instance Metadata Service is disabled
  • application.yml
logging:
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: ERROR

자 이제 여기까지 자잘하게 귀찮은 처리는 모두 완료되었습니다. 이제 실질적으로 파일 업로드 요청에 따라 S3에 파일을 업로드하도록 코드를 작성하면 됩니다.

Controller 작성

  • UploadController
@RestController
@RequestMapping(value = "/upload", produces = APPLICATION_JSON_VALUE)
@RequiredArgsConstructor
public class UploadController {
        private final FileUploadService fileUploadService;

    @PostMapping
    public ResponseEntity<FileDetail> post(
            @RequestPart("file") MultipartFile multipartFile) {
        return ResponseEntity.ok(fileUploadService.save(multipartFile));
    }
}

간단하게 POST/uploadfile이라는 값으로 파일 업로드 요청을 받아주는 메소드를 하나 작성해줍니다.

반환 타입인 FileDetail은 파일 업로드 처리 이후에 파일의 속성(파일 고유 ID, 파일명, 용량 등등)에 대한 값을 객체로 리턴해줄겁니다.

Service 클래스 작성

  • FileUploadService
@Service
@RequiredArgsConstructor
public class FileUploadService {
    private final AmazonS3ResourceStorage amazonS3ResourceStorage;

    public FileDetail save(MultipartFile multipartFile) {
        FileDetail fileDetail = FileDetail.multipartOf(multipartFile);
        amazonS3ResourceStorage.store(fileDetail.getPath(), multipartFile);
        return fileDetail;
    }
}

서비스 클래스에서는 요청을 통해 받은 MultipartFile 객체에서 파일의 핵심 속성을 골라서 하나의 FileDetail DTO 객체로 만들어주고 실제 물리적인 File은 업로드 처리를 하고 DTO를 반환합니다.

DTO 클래스 작성

  • FileDetail
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class FileDetail {
    private String id;
    private String name;
    private String format;
    private String path;
    private long bytes;

    @Builder.Default
    private LocalDateTime createdAt = LocalDateTime.now();

    public static FileDetail multipartOf(MultipartFile multipartFile) {
        final String fileId = MultipartUtil.createFileId();
        final String format = MultipartUtil.getFormat(multipartFile.getContentType());
        return FileDetail.builder()
                .id(fileId)
                .name(multipartFile.getOriginalFilename())
                .format(format)
                .path(MultipartUtil.createPath(fileId, format))
                .bytes(multipartFile.getSize())
                .build();
    }
}

업로드 파일에 대한 핵심 속성을 가지는 데이터 클래스입니다.

fileId는 36자리의 UUID 형태로 생성해주도록 합니다.

format은 해당 파일의 확장자에 대한 값입니다. ex) .jpg, .png, .pdf, .xls 등등

name은 파일 업로드 시점의 파일명이고

path는 파일의 실제 물리적 경로값입니다.

유틸 클래스 작성

  • MultipartUtil
public final class MultipartUtil {
    private static final String BASE_DIR = "images";

    /**
     * 로컬에서의 사용자 홈 디렉토리 경로를 반환합니다.
     */
    public static String getLocalHomeDirectory() {
        return System.getProperty("user.home");
    }

    /**
     * 새로운 파일 고유 ID를 생성합니다.
     * @return 36자리의 UUID
     */
    public static String createFileId() {
        return UUID.randomUUID().toString();
    }

    /**
     * Multipart 의 ContentType 값에서 / 이후 확장자만 잘라냅니다.
     * @param contentType ex) image/png
     * @return ex) png
     */
    public static String getFormat(String contentType) {
        if (StringUtils.hasText(contentType)) {
            return contentType.substring(contentType.lastIndexOf('/') + 1);
        }
        return null;
    }

    /**
     * 파일의 전체 경로를 생성합니다.
     * @param fileId 생성된 파일 고유 ID
     * @param format 확장자
     */
    public static String createPath(String fileId, String format) {
        return String.format("%s/%s.%s", BASE_DIR, fileId, format);
    }
}

getLocalHomeDirectory() 메소드는 결국 서비스 기동 환경에서의 홈 디렉토리 경로를 반환하는데 OS X의 경우 파일 시스템 쓰기 권한 문제로 인해 필히 사용자 홈 디렉토리 또는 쓰기가 가능한 경로로 설정해주어야 됩니다.

S3 업로드 수행 클래스 작성

  • AmazonS3ResourceStorage
@Component
@RequiredArgsConstructor
public class AmazonS3ResourceStorage {
    @Value("${cloud.aws.s3.bucket}")
    private String bucket;
    private final AmazonS3Client amazonS3Client;

    public void store(String fullPath, MultipartFile multipartFile) {
        File file = new File(MultipartUtil.getLocalHomeDirectory(), fullPath);
        try {
            multipartFile.transferTo(file);
            amazonS3Client.putObject(new PutObjectRequest(bucket, fullPath, file)
                    .withCannedAcl(CannedAccessControlList.PublicRead));
        } catch (Exception e) {
            throw new RuntimeException();
        } finally {
            if (file.exists()) {
                file.delete();
            }
        }
    }
}

실제 S3로 파일을 업로딩하는 부분입니다.

우선 MultipartFileFile 객체의 형태로 변환해주어야 됩니다.

이때 로컬(서비스가 구동하는 서버)에서 파일이 복사되어 임시파일과 같이 로컬에 저장이되는데 finally 구문에서 해당 파일을 제거해주도록 합니다.

그리고 나서 S3에 파일을 업로드할 때에는 해당 파일(S3에서는 객체)의 권한을 CannedAccessControlList.PublicRead설정해주어야 누구나 파일에 접근이 가능합니다.

업로드 수행 테스트(Postman 사용)

Postman 요청 양식

위와 같이 POST 요청으로 form-data에서 key의 타입을 File로 설정하면 파일 탐색기를 통해 파일을 추가할 수 있습니다.

이후 요청을 진행하면 정상적으로 파일이 생성된 결과를 반환하는것을 확인할 수 있습니다.

S3에 파일이 실제로 업로드 됐는지 확인

S3 객체 목록

S3 콘솔에서 버킷에 들어가서 보면 실제로 파일(객체)이 업로드된것도 확인이 됩니다.

해당 파일을 클릭하면 아래의 이미지와 같이 객체의 상세 정보가 표시되고 여기서 나타나있는 객체 URL이 실제 파일을 조회하는 URL입니다.

S3 객체 상세
실제 업로드 된 이미지 조회

여기까지입니다. 읽어주셔서 감사합니다.

실제 코드는 아래의 Github URL을 통해 자세하게 확인할 수 있습니다.

 

youspend8/getting-started-spring-aws-s3

Contribute to youspend8/getting-started-spring-aws-s3 development by creating an account on GitHub.

github.com

 

댓글