AWS S3 버킷 파일 업로드, 삭제, 다운로드 기능 구현 (AWS / Spring Boot / Gradle / IntelliJ)
🌜 Server/AWS & Linux

AWS S3 버킷 파일 업로드, 삭제, 다운로드 기능 구현 (AWS / Spring Boot / Gradle / IntelliJ)

728x90

안녕하세요. 그린주입니다 ๑'ٮ'๑
경험이 많이 부족하지만 최선을 다해 적어보겠습니다!

 

개요

이번 글에서는 Spring Boot & AWS S3를 연동하여 이미지 파일을 업로드, 삭제,  다운로드를 구현했던
방법을 공유하고자 합니다.
  참고로 S3에 bucket이 이미 생성되어있다는 가정하에 시작합니다.

 

Spring Boot 버전

plugins {
    id 'org.springframework.boot' version '2.4.4'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
    id 'java'
}

group = 'com.*****'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

목차

AWS S3 설명

build.gradle 설정

application.yml 설정

FileController.java

S3 FileComponent.java

Api Test


AWS S3 설명

 

1. AWS S3란 무엇인가?

 

S3란 Simple Storage Service의 약자로 AWS에서 제공하는 인터넷 저장소로 파일을 저장하는 용도로 사용되며 용량은 무제한이다. HTTP를 이용하여 파일 접근 및 업로드/다운로드가 가능하다. S3는 버킷(Bucket)과 객체(Object)로 구성된다.

 

버킷(Bucket) : S3에서 생성할 수 있는 최상위 디렉터리로, 각 리전(Region) 별로 생성 가능하고 버킷의 모든 이름은 모든 S3 Region에서 유일해야 하며 계정별로 100개까지 생성할 수 있다. 또한 버킷 안에 객체(Object)가 저장되고 디렉터리 생성이 가능하며 저속 제어 및 권한 관리가 가능하다. 

 

객체(Object) : S3에 데이터가 저장되는 최소 단위로 파일과 메타데이터로 구성된다. 기본적으로 객체의 Key가 데이터 이름이고, Value가 데이터 타입이며, 객체 하나의 크기는 최소 1Byte부터 최대 4TB까지 가능하다. 

 

 

2. S3를 사용하는 이유?

 

SNS와 같이 서버에 많은 미디어 파일을 저장해야 하는 경우 EC2와 EBC만을 사용해서 저장을 하게 되면 용량에 따른 과금 및 저장소 구축/관리로 인한 성능 문제 등이 있을 수 있다. 하지만 S3를 사용하면 S3 한 곳에 모든 미디어 파일을 관리할 수 있고 비용도 EC2나 EBC 조합보다 상대적으로 싼 가격에 이용할 수 있으며, AWS에서 스스로 S3 서버를 증설하고 성능을 관리하기 때문에 성능이나 용량을 높이는 기술력을 갖추지 않아도 된다.

 

 

자, 그럼 실전으로 들어가 보겠습니다.


build.gradle 설정

먼저 프로젝트 구성을 위해 의존성을 추가합니다.

dependencies {
    // AWS S3
    implementation 'org.springframework.cloud:spring-cloud-starter-aws'
    implementation group: 'org.apache.commons', name: 'commons-io', version: '1.3.2'
}

dependencyManagement {
    imports {
        mavenBom 'org.springframework.cloud:spring-cloud-aws:2.2.1.RELEASE'
    }
}

application.yml 설정

AWS S3 연동을 위해서는 필요한 정보를 applivation.yml에 추가하는 것입니다.
프로젝트의 src/main/resources/application.yml 폴더에 아래와 같이 설정값을 추가했습니다.

  저는 /config/application.yml로 설정 파일을 따로 관리하고 있어 여기에 추가했습니다.

1. application.yml

cloud:
  aws:
    s3:
      bucket: ******
    region:
      static: ap-northeast-2
    stack:
      auto: false
    credentials:
      instanceProfile: true

기본 설정값

  cloud.aws.s3.bucket: ******

 

버킷 이름

 

 

  cloud.aws.regin.static: ap-northeast-2

 

리전 ap-northeast-2는 Asia Pacific (Seoul)

 

배포 환경 설정값

  cloud.aws.stack.auto: false 

 

EC2에서 Spring Cloud 프로젝트를 실행시키면 기본적으로  CloudFormation 구성을 시작하기 때문에
설정한 CloudFormation이 없으면 프로젝트 실행되지 않습니다. 해당 기능을 사용하지 않도록 false로 설정합니다.

 

 

  cloud.aws.credentials.instanceProfile: true

 

EC2 인스턴스의  IAM Role에 생성된 accessKey와 secretKey를 사용하겠다는 의미입니다.
만약 이 값이 없다면 application.yml이나 environment 등에서 해당 키가 있는지 찾아서 사용합니다.

 


2. application-aws.yml

로컬 환경에서 관리하는 두 개의 키는 application-aws.yml 파일에 별도로 관리해야 합니다.

application.yml에서 다루기엔 너무 위험하기 때문입니다. Github에 올라가게 되면 언제든 다른 사람이 가져갈 수 있기 때문에 git에서 관리되지 않는 파일에서 별도로 관리하는 것입니다.

 

AWS에서 accessKey와 secretKey을 발급 받아 application-aws.yml를 생성해서 추가합니다.

  로컬 환경에서는 아래와 같은 방법으로 액세스 키을 yml에 추가하고, 배포 환경에서는 EC2의 IAM Role을 활용합니다.

cloud:
  aws:
    credentials:
      accessKey: ******
      secretKey: ******

그다음 현재 프로젝트에서 application-aws.yml도 사용할 수 있도록 application.yml
아래 내용을 추가합니다.

// 추가 내용
spring:
  profiles:
    include:
      - aws
// 기존 내용  
cloud:
  aws:
    s3:
      bucket: ******
    region:
      static: ap-northeast-2
    stack:
      auto: false
    credentials:
      instanceProfile: true

3. gitignore

가장 중요한 .gitignore에 application-aws.yml을 등록합니다.
  가끔
 .gitignore를 적었지만 커밋에 포함되어 있는 경우가 있습니다.!! 꼭!! 잘 확인해 보시고 푸시하시길 바랍니다.

### my ignore ###
config/application-aws.yml

이렇게 Git 관리 항목에서 제외(.gitignore) 했기 때문에 더 이상 액세스 키가 외부에 공개되지 않을 것입니다.


FileController.java

Controller의 코드는 아래와 같습니다.

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

    private final S3FileComponent fileComponent;
    
    @PostMapping(value = "/upload", consumes = "multipart/*")
    public List<String> upload(Long parentId, String parentCode, @RequestPart("files") MultipartFile[] files) throws IOException { 
        return fileComponent.upload(files, parentCode + "/" + parentId);
    }
    
    @DeleteMapping
    public ResponseEntity<?> delete(String filePath) {
        return fileComponent.delete(filePath);
    }

    @GetMapping(value = "/download")
    public ResponseEntity<byte[]> download(String fileUrl) throws IOException {
        String filePath = fileUrl.substring(52);
        return fileComponent.download(filePath);
    }
}

  upload 메서드

 

버튼 하나로 멀티 파일 업로드 기능을 구현했습니다.
파라미터 parentId와 parentCode는 지정된 buket에 /parentCode/parentId라는 디렉터리로 files를 업로드합니다.

 

 

  delete 메서드

 

객체 URL을 전달받아 S3 파일 삭제 기능을 구현했습니다.

 

 

  download 메서드

 

객체 URL을 전달받아 S3 파일 다운로드 기능을 구현했습니다.


S3 FileComponent.java

S3에 업로드, 삭제, 다운로드 기능을 하는 S3 FileComponent.java파일을 생성합니다.

  저는 이미지 파일만 올리는 기능을 구현했습니다.

1. upload

@Slf4j
@Component
@RequiredArgsConstructor
public class S3FileComponent { // S3 연동 - 업로드, 삭제, 다운로드

    private final AmazonS3Client amazonS3Client; // aws정보가 담겨있는 client

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    public List<String> upload(MultipartFile[] multipleFile, String dirName) throws IOException { // 객체 업로드
        // 파일의 확장자 추출
        for (MultipartFile mf : multipleFile) {
            String contentType = mf.getContentType();
            // 확장자명이 존재하지 않을 경우 전체 취소 처리 x
            if (ObjectUtils.isEmpty(contentType)) {
                throw new CustomException(ErrorCode.INVALID_FILE_CONTENT_TYPE);
            } else if (!(contentType.equals(ContentType.IMAGE_JPEG.toString())
                    || contentType.equals(ContentType.IMAGE_PNG.toString()))) {
                throw new CustomException(ErrorCode.MISMATCH_IMAGE_FILE);
            }
        }
        // 파일 리스트 하나씩 업로드
        List<String> listUrl = new ArrayList<>();
        for (MultipartFile mf : multipleFile) {
            File uploadFile = convert(mf)
                    .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File로 전환이 실패했습니다."));

            // 파일명 중복을 피하기위해 날짜 추가
            String formatDate = LocalDateTime.now().format(DateTimeFormatter.ofPattern("/yyyy-MM-dd HH:mm"));
            String fileName = dirName + formatDate + uploadFile.getName();
            // put - S3로 업로드
            String uploadImageUrl = putS3(uploadFile, fileName);
            // 로컬 파일 삭제
            removeFile(uploadFile);

            listUrl.add(uploadImageUrl);
        }
        return listUrl;
    }

    private Optional<File> convert(MultipartFile file) throws IOException { // 파일화
        File convertFile = new File(file.getOriginalFilename());
        file.transferTo(convertFile);
        return Optional.of(convertFile);
    }

    private String putS3(File uploadFile, String fileName) { // S3로 업로드
        amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, uploadFile).withCannedAcl(CannedAccessControlList.PublicRead));
        return amazonS3Client.getUrl(bucket, fileName).toString();
    }

    private void removeFile(File targetFile) { // 로컬파일 삭제
        if (targetFile.exists()) {
            if (targetFile.delete()) {
                log.info("파일이 삭제되었습니다.");
            } else {
                log.info("파일이 삭제되지 못했습니다.");
            }
        }
    }
}

  @RequiredArgsConstructor

 

해당 어노테이션은 final 멤버 변수가 있으면 생성자 항목에 포함시킵니다.
여기서는 AmazonS3 Client amazonS3 Client만 DI를 받게 됩니다.
반대로 String bucket은 생성자 항목에 포함되지 않아 평범한 멤버 변수로 남습니다.

 

 

  upload 코드 순서

 

  • MultipartFile[]을 전달받고 forEach문으로 하나씩 이미지 파일이 맞는지 검사합니다.
        ˇ 에러 처리-  확장자명이 존재하지 않을 경우(isEmpty)
                               확장자명이 image/jpeg or image/png가 아닐 경우

  • S3에 전달할 수 있도록 MultiParFile File로 전환(convert메서드)
        ˇ S3에 Multipartfile타입은 전송이 안됩니다.

  • 파일명 중복을 막기 위해 변경합니다.
        ˇ 파일명이 중복될 경우 새로운 파일로 덮어집니다.

  • 전환된 File을 S3에 public 읽기 권한으로 putObject(putS3 메서드)
        ˇ 외부에서 정적 파일을 읽을 수 있도록 하기 위함입니다.

  • 로컬에 생성된 File 삭제(removeFile메서드)
        ˇ Multipartfile -> File로 전환되면서 로컬에 파일 생성된 것을 삭제합니다.

  • 업로드된 파일의 S3 URL 주소를 List <String> listUrl에 추가

  • 최종 listUrl를 반환

 

 

  이미지 파일 업로드 관련하여 생각했던 두 가지 방법


첫 번째, 멀티 파일 중 이미지 파일만 업로드
두 번째, 하나라도 이미지 파일이 아닐 경우 전체 취소
두 번째를 선택한 이유?
어떤 파일은 되고 어떤 파일은 안되고 하는 처리 방식보다
하나로 묶어 결과를 처리하는 방식이 깔끔하다고 생각되어 결정하게 되었습니다.
다른 분들은 어떤 선택을 했을지 의견 남겨주세요^^

 

 


Spring Boot Cloud AWS를 사용하게 되면 S3 관련 Bean을 자동 생성해주기 때문에
AmazonS3 Client는 별다른 Configuration 코드 없이 DI를 받을 수 있습니다.

 

그래서 아래 3개는 직접 설정할 필요가 없습니다.
- AmazonS3
- AmazonS3 Client
- ResourceLoader

 

[ Api Test ]

test 실행

 

{
    "timestamp": "2021-09-28T16:55:00.6723698",
    "status": 400,
    "error": "BAD_REQUEST",
    "code": "MISMATCH_IMAGE_FILE",
    "message": "파일 확장자가 일치하지 않습니다."
}

에러 처리 - 하나라도 이미지 파일이 아닐 경우

 

[
    "https://버킷주소/parentCode/parentId/날짜+파일명.확장자",
    "https://버킷주소/parentCode/parentId/날짜+파일명.확장자",
    "https://버킷주소/parentCode/parentId/날짜+파일명.확장자"
]

업로드 성공!!

 


 

2. delete

public ResponseEntity<CommonResponse> delete(String filePath) { // 객체 삭제  filePath : 폴더명/파일네임.파일확장자
        try {
            // S3에서 삭제
            amazonS3Client.deleteObject(new DeleteObjectRequest(bucket, filePath));
            System.out.println(String.format("[%s] deletion complete", filePath));
        } catch (AmazonServiceException e) {
            e.printStackTrace();
        } catch (SdkClientException e) {
            e.printStackTrace();
        }
        return CommonResponse.toResponseEntity(ResultCode.DELETE_SUCCESS);
    }

delete 코드 순서

 

  • 전달받은 filePath를 S3로 deleteObject

 

[ Api Test ]

test 실행

 

{
  "timestamp": "2021-09-28T17:19:27.4308364",
  "status": 200,
  "code": "DELETE_SUCCESS",
  "message": "삭제성공"
}

삭제 성공!!

 


 

3. download

    public ResponseEntity<byte[]> download(String fileUrl) throws IOException { // 객체 다운  fileUrl : 폴더명/파일네임.파일확장자
        S3Object s3Object = amazonS3Client.getObject(new GetObjectRequest(bucket, fileUrl));
        S3ObjectInputStream objectInputStream = s3Object.getObjectContent();
        byte[] bytes = IOUtils.toByteArray(objectInputStream);

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(contentType(fileUrl));
        httpHeaders.setContentLength(bytes.length);
        String[] arr = fileUrl.split("/");
        String type = arr[arr.length - 1];
        String fileName = URLEncoder.encode(type, "UTF-8").replaceAll("\\+", "%20");
        httpHeaders.setContentDispositionFormData("attachment", fileName); // 파일이름 지정

        return new ResponseEntity<>(bytes, httpHeaders, HttpStatus.OK);
    }

    private MediaType contentType(String keyname) {
        String[] arr = keyname.split("\\.");
        String type = arr[arr.length - 1];
        switch (type) {
            case "txt":
                return MediaType.TEXT_PLAIN;
            case "png":
                return MediaType.IMAGE_PNG;
            case "jpg":
                return MediaType.IMAGE_JPEG;
            default:
                return MediaType.APPLICATION_OCTET_STREAM;
        }
    }

  download코드 순서

 

  • 전달받은 filePath를 S3에서 getObject

 

[ Api Test ]

test 실행

 

프런트에서는 blob파일로 받아서 다운

 

다운로드 성공!!

 

실서버에서도 다운로드 성공!!


마무리

이렇게 AWS S3 버킷에 이미지 파일을 업로드, 삭제, 다운로드하는 기능을 구현해 보았습니다.
다들 성공하셨을까요?? 끝까지 파이팅입니다!


긴 글 봐주셔서 감사합니다!
오늘도 행복한 하루 보내세요 '◡'✿


참고

아래 블로그들 덕분에 구현하는데 헤매지 않고 구현하게 되었습니다.
항상 도움 많이 받고 있습니다. 감사합니다!!

 

https://twofootdog.tistory.com/36

https://bamdule.tistory.com/178

https://victorydntmd.tistory.com/334

https://leveloper.tistory.com/46

https://jojoldu.tistory.com/300

 


728x90
728x90