티스토리 뷰

개요

  • Amazon S3는 서버리스 오브젝트 저장소 상품이다. 이번 글에서는 Spring Boot 기반 애플리케이션에서 Amazon S3에 파일을 업로드하고 다운로드하는 방법을 설명하고자 한다.

사전 조건

  • Amazon S3를 제어할 수 있는 IAM 사용자 계정이 생성되어 있어야 한다.

Amazon S3 비공개 버킷 생성

  • 아래는 Amazon S3의 비공개 버킷을 생성하는 예이다. (이름 그대로 비공개 버킷은 외부에서 접근이 불가능하다.)
Amazon S3 콘솔 접속
→ [버킷 만들기] 클릭

# 버킷 만들기
→ 버킷 이름: foobar-dev (입력)
→ AWS 리전: [아시아 태평양(서울) ap-northeast-2] 선택

# 이 버킷의 퍼블릭 액세스 차단 설정
→ [모든 퍼블릭 액세스 차단] 체크 (기본값)

→ [버킷 만들기] 클릭
  • 버킷 생성 후, 생성되는 폴더와 파일명은 모두 키로 식별된다. 예를 들어 foobar-dev 버킷 내의 foo 폴더 밑에 bar 폴더를 생성하고 foobar.txt 파일을 업로드했다면 키는 foo/bar/foobar.txt가 된다.

AWS CloudFront Distribution 생성

  • 앞서 생성한 비공개 버킷에 대해 AWS CloudFront 서비스의 Distribution을 생성하면 전세계 엣지 로케이션을 이용한 CDN 서비스를 제공할 수 있다. 아래는 Distribution을 생성하는 예이다. (생성에는 수분이 소요된다.)
AWS CloudFront 콘솔 접속
→ [Distributions] 클릭
→ [Create Distribution] 클릭
→ [Web] → [Get Started] 클릭

# Create Distribution > Origin Settings
→ Origin Domain Name: foobar-dev.s3.amazonaws.com (선택)
→ Restrict Bucket Access: [Yes] 선택
→ Origin Access Identity: [Create a New Identity] 선택
→ Comment: access-identity-foobar-dev.s3.amazonaws.com (입력)
→ Grant Read Permissions on Bucket: [Yes, Update Bucket Policy] 선택

# Create Distribution > Default Cache Behavior Settings
→ Viewer Protocol Policy: [HTTP, HTTPS] 선택
→ Allowed HTTP Methods: [GET, HEAD, OPTIONS] 선택
→ Cache and origin request settings: [Use legacy cache settings] 선택
→ Cache Based on Selected Request Headers: [Whitelist] 선택
→ Whitelist Headers: [Access-Control-Request-Headers], [Access-Control-Request-Method], [Origin] 선택
→ Query String Forwarding and Caching: [Forward all, cache based on all] 선택
→ Restrict Viewer Access (Use Signed URLs or Signed Cookies): [No] 선택
→ Trusted Key Groups or Trusted Signer: [Trusted Signer] 선택

# Create Distribution > Distribution Settings
→ Price Class: [Use All Edge Locations (Best Performance)] 선택

→ [Create Distribution] 클릭
  • Restrict Bucket Access: Yes 옵션을 통해 외부에 파일 액세스에 해당하는 GET 요청만 허용할 수 있다. 한번 요청된 파일은 요청지로부터 가장 가까운 엣지 로케이션에 캐시로 저장되어 전세계에 걸쳐 빠른 액세스를 가능하게 해준다. (파일 업로드에 해당하는 PUT 요청 방법은 아래 Presigned URL 부분에 설명하였다.)
  • Distribution 생성이 완료되면 외부에는 https://d2va98swssmiqx.cloudfront.net/foo/bar/foobar.txt와 같이 CloudFront의 도메인으로 서비스가 가능하다.

build.gradle

  • 가장 손이 적게 가는 방법은 프로젝트에 spring-cloud-starter-aws 아티팩트를 추가하는 것이다. 프로젝트 루트의 /build.gradle 파일에 아래 내용을 추가한다.
dependencies {
    implementation("org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE")
    implementation("commons-io:commons-io:jar:2.8.0")
}

dependencyManagement {
    imports {
        mavenBom("org.springframework.cloud:spring-cloud-dependencies:2020.0.2")
    }
}
  • Spring Cloud는 릴리지 트레인 2020.0.x부터 Spring Boot 2.4.x를 지원하여 관련 내용을 추가했다.

application.yml

  • 프로젝트 루트의 /src/main/resources/application.yml 파일에 아래 내용을 추가한다.
cloud:
  aws:
    credentials:
      instance-profile: false
      access-key: *****
      secret-key: *****
    region:
      auto: false
      static: ap-northeast-2
    stack:
      auto: false

logging:
  level:
    com:
      amazonaws:
        util:
          EC2MetadataUtils: error
  • 앞서 사전 조건에 명시된 사용자 계정 정보를 입력할 차례이다. IAM 콘솔에서 생성한 사용자 계정에 할당된 액세스 키 IDaccess-key 항목에, 비밀 액세스 키secret-key 항목에 입력한다. 잘못된 값를 입력할 경우 Amazon S3 접근시 Access Denied 오류가 발생할 수 있다.
  • 위 프로파일 변수로 로컬 환경에서 애플리케이션 실행시 아래 오류가 발생한다.
java.net.SocketException: Network is unreachable: connect
  • 위 오류는 EC2 메타 데이터에 접근하여 발생한 것인데, 이를 무력화하려면 운영체제 환경 변수에 아래 내용을 추가해야 한다.
AWS_EC2_METADATA_DISABLED=true

AwsConfig.kt

  • 앞서 설명한 spring-cloud-starter-aws 아티팩트의 추가 만으로, amazonS3Client 빈이 자동 생성되어 별도의 빈 설정 없이 사용이 가능하다. 이러한 접근 방식은 편리하지만 하나의 빈이 특정 리전에 종속되기 때문에 다른 리전에 대한 접근이 불가능하다. 이 경우, 아래와 같이 추가 접근이 필요한 리전에 대한 빈을 자유롭게 생성할 수 있다.
import com.amazonaws.auth.AWSCredentials
import com.amazonaws.auth.AWSStaticCredentialsProvider
import com.amazonaws.auth.BasicAWSCredentials
import com.amazonaws.regions.Regions
import com.amazonaws.services.s3.AmazonS3
import com.amazonaws.services.s3.AmazonS3ClientBuilder
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class AwsConfig(
    @Value("\${cloud.aws.credentials.access-key}")
    val accessKey: String,

    @Value("\${cloud.aws.credentials.secret-key}")
    val secretKey: String
) {
    @Bean
    fun awsCredentials(): AWSCredentials {

        return BasicAWSCredentials(accessKey, secretKey)
    }

    // 서울 리전의 AmazonS3 빈을 생성
    @Bean
    fun amazonS3ClientSeoul(@Qualifier("awsCredentials") awsCredentials: AWSCredentials): AmazonS3 {

        return AmazonS3ClientBuilder
            .standard()
            .withCredentials(AWSStaticCredentialsProvider(awsCredentials))
            .withRegion(Regions.AP_NORTHEAST_2)
            .build()
    }

    // 오하이오 리전의 AmazonS3 빈을 생성
    @Bean
    fun amazonS3ClientOhio(@Qualifier("awsCredentials") awsCredentials: AWSCredentials): AmazonS3 {

        return AmazonS3ClientBuilder
            .standard()
            .withCredentials(AWSStaticCredentialsProvider(awsCredentials))
            .withRegion(Regions.US_EAST_2)
            .build()
    }
}

StorageService.kt

  • Amazon S3 업로드 및 다운로드를 수행하는 서비스 인터페이스를 아래와 같이 작성한다.
import java.io.File

interface StorageService {

    fun upload(bucket: String, key: String, file: File)
    fun download(bucket: String, key: String): File
}

StorageServiceImpl.kt

  • 마지막이다. Amazon S3 업로드 및 다운로드를 수행하는 서비스 구현체를 아래와 같이 작성한다.
import com.amazonaws.services.s3.AmazonS3
import com.amazonaws.services.s3.model.AmazonS3Exception
import com.amazonaws.services.s3.model.S3Object
import com.amazonaws.services.s3.model.S3ObjectInputStream
import com.funnc.shop.api.common.exception.ShopApiErrorCode
import com.funnc.shop.api.common.exception.ShopApiException
import com.funnc.shop.api.common.logging.GraylogHelper
import org.apache.commons.io.FileUtils
import org.apache.commons.io.FilenameUtils
import org.springframework.stereotype.Service
import java.io.File
import java.io.FileNotFoundException

@Service
class AmazonS3StorageServiceImpl(

        val amazonS3: AmazonS3

) : StorageService {

    override fun upload(bucket: String, key: String, file: File) {

        try {
            amazonS3.putObject(bucket, key, file)
            logger.info("Amazon S3 파일 업로드 성공, [uploadFile=$bucket$key]")
        } catch (ex: AmazonS3Exception) {
            when (ex.errorCode) {
                "NoSuchBucket" -> logger.error("Amazon S3 파일 업로드 실패, 버킷이 존재하지 않음")
                else -> logger.error("Amazon S3 파일 업로드 실패 [errorCode=${ex.errorCode}]")
            }
        } catch (ex: Exception) {
            when (ex.cause) {
                is FileNotFoundException -> logger.error("Amazon S3 파일 업로드 실패, 원본 파일 존재하지 않음")
                else -> logger.error("Amazon S3 파일 업로드 실패, 원인 미상")
            }
            throw SomeApiException(SomeApiErrorCode.FILE_UPLOAD_FAILED)
        }
    }

    override fun download(bucket: String, key: String): File {

        val downloadFilePath = "${System.getProperty("java.io.tmpdir")}/${FilenameUtils.getName(key)}"
        val downloadFile = File(downloadFilePath)

        try {
            val s3object: S3Object = amazonS3.getObject(bucket, key)
            val inputStream: S3ObjectInputStream = s3object.objectContent
            FileUtils.copyInputStreamToFile(inputStream, downloadFile)
        } catch (ex: AmazonS3Exception) {
            when (ex.errorCode) {
                "NoSuchBucket" -> logger.error("Amazon S3 파일 다운로드 실패, 버킷이 존재하지 않음")
                else -> logger.error("Amazon S3 파일 다운로드 실패 [errorCode=${ex.errorCode}]")
            }
        } catch (ex: Exception) {
            logger.error("Amazon S3 파일 다운로드 실패, 원인 미상")
            throw SomeApiException(SomeApiErrorCode.FILE_DOWNLOAD_FAILED)
        }

        logger.info("Amazon S3 파일 다운로드 성공, [downloadFile=$bucket$key]")

        return downloadFile
    }
}

Amazon S3 기본 명령어

  • 아래와 같이 Amazon S3의 기본 명령어를 사용할 수 있다.
# S3 저장소의 bar 폴더를 로컬의 foobar 폴더로 복사
$ aws s3 cp --region "us-east-2" --recursive s3://foo/bar c:\foobar

# S3 저장소의 foo 버킷를 로컬의 bar 버킷에 복사
$ aws s3 cp --region "us-east-2" --recursive s3://foo s3://bar

# S3 고속 복사 옵션 활성화
$ aws configure set default.s3.max_concurrent_requests 2000
$ aws configure set default.s3.use_accelerate_endpoint true

# S3 저장소에 이미 존재하는 파일은 무시하고 복사
$ aws s3 sync --region "us-east-2" --size-only --only-show-errors s3://{source-bucket} s3://{destination-bucket}

특정 버킷 전체 복사

  • 특정 버킷 전체를 복사하는 방법은 아래와 같다. [관련 링크]
val s3objects = amazonS3Client.listObjectsV2("{sourceBucket}")
s3objects.objectSummaries.forEach {
    amazonS3Client.copyObject(CopyObjectRequest("{sourceBucket}", it.key, "{destinationBucket}", it.key))
}

특정 오브젝트 삭제

  • 특정 오브젝트를 삭제하는 방법은 아래와 같다.
amazonS3Client.deleteObject(DeleteObjectRequest(it.bucket, it.key))

Presigned URL의 생성

  • Presigned URLAmazon S3에 대한 인증과 권한 정보가 없는 제3자에게, 특정 버킷에 대해 짧은 시간동안 제공하는 임시 URL을 의미한다. 이를 통해 제3자는 퍼블릭 버킷이 아니더라도 해당 파일에 대한 업로드 또는 다운로드가 가능하다. REST API를 제공하는 서비스에서는 클라이언트에게 이 기능을 제공하면 보안과 트래픽의 이점을 살릴 수 있다.
  • Amazon S3의 특정 버킷과 키에 대한 Presigned URL을 생성하는 방법은 아래와 같다.
val request = GeneratePresignedUrlRequest("foo-bucket", "bar-folder/foobar.txt")
request.method = HttpMethod.PUT
request.contentType = "text/plain"

val presignedUrl = amazonS3Client.generatePresignedUrl(request).toString()

Presigned URL로 파일 업로드

  • 아래는 제3자가 제공 받은 Presigned URL로 이미지 파일을 업로드하는 예이다.
# HTTPS PUT 요청
$ curl -H "Content-Type: text/plain" -T foobar.txt "{presigned_url_from_amazon_s3}"
  • Presigned URL을 생성하는 시점의 파일 형식과, 해당 URL로 파일 업로드하는 시점의 파일 형식이 다르면 SignatureDoesNotMatch 오류를 응답한다.

참고 글

댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함