DevOps/AWS

[AWS] Springboot에 AWS S3 연동 (이미지, 동영상 업로드)

킵고잉 개발자 2022. 6. 2. 17:23

안녕하세요? 이번 시간엔 SpringBoot & AWS S3 연동하기를 진행해보려고 합니다.

따라 하시기만 해도 로컬에서 이미지, 동영상 파일 업로드가 가능하고, EC2에 배포한 환경에서도 파일 업로드가 가능합니다.

코드는 Github에 있고, 함께 보시면 더 이해하기 쉬우실 것 같습니다.

 

목차

 

1. AWS S3 버킷 설정

2. AWS IAM User 생성

3. 로컬 환경 개발

4. 이미지, 동영상 업로드 결과 확인

5. 배포 환경에서 업로드 확인

 

1. AWS S3 버킷 생성

S3 버킷을 생성할 때 원하는 이름을 기입합니다.

 

첫 번째 ACL(액세스 제어 목록)을 통해 부여된 버킷 및 객체에 대한 퍼블릭 액세스 차단 을 해제해서 객체를(이미지 파일) 업로드할 수 있게 합니다.

두 번째 의의 ACL(액세스 제어 목록)을 통해 부여된 버킷 및 객체에 대한 퍼블릭 액세스 차단 을 해제해서 업로드한 객체를 별도의 볼 수 있게 설정합니다.

위 두 설정을 해제 안하면 각각 파일 업로드시, 업로드된 파일에 접근할 시에 403 Access Denied를 만나게 됩니다.

‘버킷 만들기’를 클릭해 버킷을 생성합니다.

 

2. AWS IAM 생성

AWS 환경이 아닌 로컬 환경에서 S3를 사용하기 위해 별도의 User가 필요합니다.

사용자 > 사용자 추가를 클릭합니다.

프로그래밍 방식 액세스를 클릭하고

AmazonS3FullAccess를 선택합니다.

생성된 액세스키와 비밀키를 csv 파일로 저장합니다.

 

 

3. 로컬 환경에서 이미지, 동영상 업로드하기

먼저 프로젝트 구성을 위해 의존성을 추가합니다. 여기서는 Gradle을 사용합니다.

build.gradle

주요 사용 라이브러리

  • spring-cloud-starter-aws
  • spring-boot-starter-web
  • lombok
  • Thymleaf

뷰 템플릿은 본인이 원하는 거 사용하면 됩니다. (Freemarker, mustache 등등)

저는 개인적으로 Thymleaf를 선호하기 때문에 Thymleaf를 사용했습니다.

plugins {
    id 'org.springframework.boot' version '2.6.3'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.keepseung'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

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

    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'

    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

S3UploaderService.java

다음으로 S3에 정적 파일을 올리는 기능을 하는 S3UploaderService.java 파일을 생성합니다.

@@Slf4j
@Service
@RequiredArgsConstructor
public class S3UploaderService {

    // local, development 등 현재 프로파일
    @Value("${spring.environment}")
    private String environment;

    // 파일이 저장되는 경로
    @Value("${spring.file-dir}")
    private String rootDir;
    private String fileDir;

    private final AmazonS3Client amazonS3Client;

     /**
     * 서버가 시작할 때 프로파일에 맞는 파일 경로를 설정해줌
     */
    @PostConstruct
    private void init(){
        if(environment.equals("local")){
            this.fileDir = System.getProperty("user.dir") + this.rootDir;
        }
        else if(environment.equals("development")){
            this.fileDir = this.rootDir;
        }

    }

    public String upload(MultipartFile multipartFile, String bucket, String dirName) throws IOException {
        File uploadFile = convert(multipartFile)  // 파일 변환할 수 없으면 에러
                .orElseThrow(() -> new IllegalArgumentException("error: MultipartFile -> File convert fail"));

        return upload(uploadFile, bucket, dirName);
    }

    // S3로 파일 업로드하기
    private String upload(File uploadFile, String bucket, String dirName) {
        String fileName = dirName + "/" + UUID.randomUUID() + uploadFile.getName();   // S3에 저장된 파일 이름
        String uploadImageUrl = putS3(uploadFile, bucket, fileName); // s3로 업로드
        removeNewFile(uploadFile);
        return uploadImageUrl;
    }

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

    // 로컬에 저장된 이미지 지우기
    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("File delete success");
            return;
        }
        log.info("File delete fail");
    }

    /**
     * @param multipartFile
     * 로컬에 파일 저장하기
     */
    private Optional<File> convert(MultipartFile multipartFile) throws IOException {
        if (multipartFile.isEmpty()) {
            return Optional.empty();
        }

        String originalFilename = multipartFile.getOriginalFilename();
        String storeFileName = createStoreFileName(originalFilename);

        //파일 업로드
        File file = new File(fileDir+storeFileName);
        multipartFile.transferTo(file);

        return Optional.of(file);
    }

    /**
     * @description 파일 이름이 이미 업로드된 파일들과 겹치지 않게 UUID를 사용한다.
     * @param originalFilename 원본 파일 이름
     * @return 파일 이름
     */
    private String createStoreFileName(String originalFilename) {
        String ext = extractExt(originalFilename);
        String uuid = UUID.randomUUID().toString();
        return uuid + "." + ext;
    }

    /**
     * @description 사용자가 업로드한 파일에서 확장자를 추출한다.
     *
     * @param originalFilename 원본 파일 이름
     * @return 파일 확장자
     */
    private String extractExt(String originalFilename) {
        int pos = originalFilename.lastIndexOf(".");
        return originalFilename.substring(pos + 1);
    }

}

코드의 순서는 간단합니다.

  • MultipartFile을 전달받고
  • S3에 전달할 수 있도록 MultiPartFile을 File로 전환 및 저장. S3에 Multipartfile 타입은 전송이 안됩니다.
  • 전환된 File을 S3에 public 읽기 권한으로 업로드합니다. 외부에서 정적 파일을 읽을 수 있도록 하기 위함입니다.
  • 로컬에 생성된 File 삭제. Multipartfile -> File로 전환되면서 로컬에 생성된 파일을 삭제합니다.
  • 업로드된 파일의 S3 URL 주소를 반환

여기서 특이한 부분은 별다른 Configuration 코드 없이 AmazonS3Client 를 DI 받은것인데요. Spring Boot Cloud AWS를 사용하게 되면 S3 관련 Bean을 자동 생성해줍니다.

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

  • AmazonS3
  • AmazonS3Client
  • ResourceLoader

기타 코드 설명

  • basicDir, fileDir는 Profile별 저장될 파일 경로입니다.

로컬에서 테스트할 경우 {프로젝트 경로}/resource/static/files 경로에 파일을 저장할 수 있게 하기 위함입니다.

EC2에서 테스트할 경우 /home/ec2-user/files 에 저장이 될 것입니다.

  • dirName은 S3에 생성된 디렉터리를 나타냅니다. 저 같은 경우 1-source라는 bucket에 image, video란 디렉터리를 사용합니다.

S3UploaderController.java

@RequiredArgsConstructor
@Controller
public class S3UploaderController {
    private final S3UploaderService s3UploaderService;

    @GetMapping("/image")
    public String image() {
        return "image-upload";
    }

    @GetMapping("/video")
    public String video() {
        return "video-upload";
    }

    @PostMapping("/image-upload")
    @ResponseBody
    public String imageUpload(@RequestParam("data") MultipartFile multipartFile) throws IOException {
        return s3UploaderService.upload(multipartFile, "1-source", "image");
    }

    @PostMapping("/video-upload")
    @ResponseBody
    public String videoUpload(@RequestParam("data") MultipartFile multipartFile) throws IOException {
        return s3UploaderService.upload(multipartFile, "1-source", "video");
    }
}
  • 여기서는 4가지 기능이 있습니다.
    • image-upload 웹 페이지 반환
    • video-upload 웹 페이지 반환
    • data로 넘어오는 이미지 MultipartFile을 S3UploaderService로 전달
    • data로 넘어오는 동영상 MultipartFile을 S3UploaderService로 전달

템플릿 엔진 starter를 사용하시면 src/main/resources/templates/에 뷰 파일을 넣으면 자동으로 Controller에서 사용할 수 있습니다.  src/main/resources/templates/에 image-upload.html, video-upload.html 파일을 생성하겠습니다.

image-upload.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <title> SpringBoot & AWS S3</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>

    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css">
    <script
            src="https://code.jquery.com/jquery-3.3.1.js"
            integrity="sha256-2Kok7MbOyxpgUVvAk/HJ2jigOSYS2auK4Pfzbm7uH60="
            crossorigin="anonymous"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js"></script>
</head>
<body>
<h1>
    S3 이미지 업로더
</h1>
<div class="col-md-12">
    <div class="col-md-2">
        <form>
            <div class="form-group">
                <label for="img">파일 업로드</label>
                <input type="file" id="img">
            </div>
            <button type="button" class="btn btn-primary" id="btn-save">저장</button>
        </form>
    </div>
    <div class="col-md-10">
        <p><strong>결과 이미지입니다.</strong></p>
        <img src="" id="result-image">
    </div>
</div>


<script>
    $('#btn-save').on('click', uploadImage);

    function uploadImage() {
        var file = $('#img')[0].files[0];
        var formData = new FormData();
        formData.append('data', file);

        $.ajax({
            type: 'POST',
            url: '/image-upload',
            data: formData,
            processData: false,
            contentType: false
        }).done(function (data) {
            $('#result-image').attr("src", data);
        }).fail(function (error) {
            alert(error);
        })
    }
</script>
</body>
</html>
  • uploadImage 함수로 등록한 이미지 파일 Ajax로 전송
    • 전송 결과가 성공이면 <img src="" id="result-image">에 해당 이미지 주소 등록

video-upload.html

video-upload.html는 image-upload.html에서 POST API 호출하는 경로만 변경해주면 됩니다.

application.yml

가장 먼저 할 일은 AWS S3에 필요한 정보를 application.yml에 추가하는 것입니다.

프로젝트의 src/main/resources/application.yml에 아래와 같이 설정값을 추가합니다.

spring:
  profiles:
    group:
      "local": "local, common"
      "development": "development,common"
    active: local

---
# 공통
spring:
  config:
    activate:
      on-profile: "common"
  servlet:
    multipart:
      max-file-size: 1GB
      max-request-size: 1GB

# s3에 필요한 정보
cloud:
  aws:
    region:
      static: ap-northeast-2
    s3:
      bucket: 1-source
    stack:
      auto: false

logging:
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: error

---
# 로컬 환경
spring:
  environment: "local"
  config:
    activate:
      on-profile: "local"
  file-dir: /src/main/resources/static/files/
---
# 배포 환경
spring:
  environment: "development"
  config:
    activate:
      on-profile: "development"
  file-dir: /home/ec2-user/files/
  • spring.profile.group
    • 로컬과 배포 프로파일을 구별하기 위해 프로파일 그룹을 명시했습니다.
    • 공통되는 설정은 “common”에 있고, 로컬은 “local”, 배포용 설정은 “development”에 있습니다.
    • 로컬과 배포 환경에서 유일한 차이는 파일이 저장되는 경로입니다. “spring.file-dir”라는 속성을 통해 환경 별로 경로를 지정했습니다.
  • cloud.aws
    • 현재는 리전, s3에 대한 설정을 기입했습니다.
  • cloud.aws.stack.auto: false
    • EC2에서 Spring Cloud 프로젝트를 실행시키면 기본으로 CloudFormation 구성을 시작합니다.
    • 설정한 CloudFormation이 없으면 프로젝트 시작이 안되니, 해당 내용을 사용하지 않도록 false를 등록합니다.
  • logging.level.com.amazonaws.util.EC2MetadataUtils: error
    • 해당 옵션을 주지 않으면 밑의 예외 로그가 발생해서 안 나오게 하기 위한 설정입니다.
    • com.amazonaws.AmazonClientException: EC2 Instance Metadata Service is disabled

aws.yml

위에서 생성한 AWS User의 access-key와, secret-key는 탈취되면 다른 사람들이 우리의 AWS에 접근하는 문제가 발생할 수 있습니다.

Github에 올라가게 되면 언제든 다른 사람이 가져갈 수 있기 때문에 git에서 관리되지 않는 파일에서 별도로 관리하기 위해 aws.yml을 만듭니다.

cloud:
  aws:
    credentials:
      access-key: 발급받은 access-key
      secret-key: 발급받은 secret-key

.gitignore

Git 관리 항목에서 aws.yml 파일을 제거합니다.

더 이상 이 키가 외부에 공개될 것을 걱정하지 않아도 됩니다.

aws.yml

S3uploaderApplication.java

그리고 현재 프로젝트에서 aws.yml도 사용할 수 있도록 Application.java 코드를 변경합니다.

public static final String APPLICATION_LOCATIONS = "spring.config.location="
            + "classpath:application.yml,"
            + "classpath:aws.yml";

    public static void main(String[] args) {
        new SpringApplicationBuilder(S3uploaderApplication.class)
                .properties(APPLICATION_LOCATIONS)
                .run(args);
    }

프로파일 별로 파일이 저장될 폴더를 만들기 위해 스프링 부트가 시작할 때 폴더를 만드는 코드를 추가합니다.

@SpringBootApplication
public class S3uploaderApplication {
    @Value("${spring.environment}")
    private String environment;
		
    @Value("${spring.file-dir}")
    private String fileDir;

    ...
    ...
		
		/**
     * @description 이미지, 영상 업로드할 폴더를 프로파일 별로 다른 경로에 생성한다.
     */
    @PostConstruct
    private void init() {

        if (environment.equals("local")) {
            String staticFolder = System.getProperty("user.dir") + "/src/main/resources/static";
            mkdirResource(staticFolder);

            String files = System.getProperty("user.dir") + fileDir;
            mkdirResource(files);
        } else if (environment.equals("development")) {
            String filesFolder = "/var/www/html/files";
            mkdirResource(filesFolder);
        }
    }

    /**
     * @param fileDir 생성을 위한 폴더명
     * @description 주어진 경로에 폴더를 생성함
     */
    private static void mkdirResource(String fileDir) {
        File Folder = new File(fileDir);

        // 해당 디렉토리가 없을경우 디렉토리를 생성합니다.
        if (!Folder.exists()) {
            try {
                Folder.mkdir(); //폴더 생성합니다.
            } catch (Exception e) {
                e.getStackTrace();
            }
        }
    }
}

VM option 추가

이대로 실행 시 EC2에서 실행하지 않아서 예외가 발생합니다.

com.amazonaws.SdkClientException: Failed to connect to service endpoint: 
at com.amazonaws.internal.EC2ResourceFetcher.doReadResource(EC2ResourceFetcher.java:100) ~[aws-java-sdk-core-1.11.792.jar:na]
at com.amazonaws.internal.InstanceMetadataServiceResourceFetcher.getToken(InstanceMetadataServiceResourceFetcher.java:91) ~[aws-java-sdk-core-1.11.792.jar:na]

Caused by: java.net.ConnectException: Host is down (connect failed)....

이를 해결하기 위해 vmOption에 다음 옵션을 추가합니다.

Dcom.amazonaws.sdk.disableEc2Metadata=true

 

4. 이미지, 동영상 업로드 결과 확인

이미지 업로드 페이지

동영상 업로드 페이지

S3에 업로드된 이미지

S3에 업로드된 동영상

 

 

5. EC2에 배포한 환경에서 이미지, 동영상 업로드하기

active 한 프로파일을 development로 설정하고 BootJar를 실행합니다.

 

EC2에 해당 프로젝트를 배포하고, (필자는 Amazon Linux2를 사용했습니다.)

해당 EC2의 도메인으로 접근해서 테스트해보시면 파일 업로드가 잘 되는 것을 확인할 수 있습니다.

 

참고 : https://jojoldu.tistory.com/300