이번 포스트에서는 AWS S3 파일 업로드 방식 중 MultipartFile을 사용한 방식과 Presigned URL을 사용한 방식에 대해 알아보고, Presigned URL로 S3에 파일을 업로드하는 방식을 코드로 직접 구현해본다.
1. MultipartFile을 사용한 기존 S3 파일 업로드
Spring Boot에서 S3 파일 업로드를 구현할 때 가장 많이 사용하는 방법이 MultipartFile을 사용하는 방법이다.
MultipartFile은 스피링에서 제공하는 인터페이스로, 파일 업로드를 손쉽게 다룰 수 있도록 기능을 제공한다.
클라이언트에서 파일을 업로드했을 때 WAS가 임시 디렉토리에 파일을 저장한다.
파일은 컨테이너가 실행되고 있는 서버 디스크에 저장되고, 요청 처리가 끝나면 임시 저장된 파일은 삭제된다.
MultipartFile 방식은 다수의 사용자로부터 동시에 요청이 들어올 경우, 서버의 스레드가 빠르게 소진될 수 있다는 문제가 있다.
2. Presigned URL을 사용한 S3 파일 업로드
먼저, Presigned URL이란 '미리 서명된 URL'이라는 뜻이다.
Presigned URL을 사용하는 로직은 다음과 같다.
1. 클라이언트가 파일을 올리기 위한 URL을 발급 요청한다.
2. AWS는 파일을 올릴 때 사용할 수 있는 URL을 발급해준다. (해당 URL을 Presigned URL이라고 한다.)
3. 클라이언트는 해당 URL에 PUT 요청으로 파일을 보낸다.
4. S3에 파일이 업로드된다!
Presigned URL을 사용하면 서버는 URL만 발급해주고, 파일 업로드 관리는 AWS 측에서 진행한다. 그러므로 서버의 네트워크 I/O 비용을 줄이고, 리소스를 절약할 수 있다.
그리고 후술하는 URL 파라미터에서 나오는 것처럼 특정 유효 기간동안만 S3 접근 권한을 지급할 수 있기 때문에 무분별한 S3 접근을 막아 보안을 강화할 수 있다.
3. Presigned URL 파라미터에 대해 자세히 알아보자
https://example-bucket.s3.amazonaws.com/images/2d098b12-5cd7-4f00-835b-a6c998a13617%EB%B8%94%EB%A1%9C%EA%B7%B8%20%EC%8D%B8%EB%84%A4%EC%9D%BC.png
?x-amz-acl=public-read
&X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Date=20230903T144326Z
&X-Amz-SignedHeaders=host
&X-Amz-Expires=120
&X-Amz-Credential=<ACCESS_KEY>/20230903/ap-northeast-2/s3/aws4_request
&X-Amz-Signature=<SIGNATURE_VALUE>
x-amz-acl
Presigned URL을 활용하여 업로드하고 리소스에 대한 GET 요청 시 access denied가 발생한다.
그러므로 Presigned URL을 생성할 때 public-read 권한을 설정해야 한다.
x-amz-algorithm
서명 버전과 알고리즘을 식별하고, 서명을 계산하는데 사용한다. 버전 4를 위해서 AWSS4-HMAC-SHA256
x-amz-date
날짜는 ISO 8601 형식을 사용한다.
ex) 20240118T000000Z
x-amz-signedheaders
서명을 계산하기 위해 사용되는 헤더 목록으로, HTTP host 헤더가 요구된다.
x-amz-expires
미리 선언된 URL이 유효한 시간 주기로, 초 단위이며 정수 값이다.
최소 1에서 최대 604800(7일)까지 가능하다.
x-amz-credential
Acces Key와 범위 정보(요청 날짜, 리전, 서비스 명)
x-amz-signature
요청을 인증하기 위한 서명
4. Spring Boot 구현
의존성 추가
의존성을 추가할 때는 항상 자신의 Spring Boot 버전에 맞는지, legacy 버전을 사용하는 것이 아닌지 확인하자.
이전 블로그를 참고할 경우 버전도 이전 버전을 사용하게 될 가능성이 크기 때문에 버전을 선택할 때는 공식 문서도 한번씩 확인하는 습관을 들이자.
// aws
implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4'
implementation 'io.awspring.cloud:spring-cloud-starter-aws-secrets-manager-config:2.4.4'
application.yml
S3 버킷 정보와 접근 권한(GetObject, PutObject)이 있는 User의 Access Key와 Secret Key를 추가해준다.
cloud:
aws:
region: ap-northeast-2
credentials:
access-key: {access key}
secret-key: {secret key}
s3:
bucket: {bucket name}
stack:
auto: false
EC2에서 Spring Cloud 프로젝트를 실행시키면 기본으로 CloudFormation 구성을 시작한다. 설정한 CloudFormation이 없으면 프로젝트 시작이 안되므로 해당 내용을 사용하지 않도록 stack.auto 옵션을 false로 해주자.
S3Config
S3 접근을 위해 필요한 인증 정보를 설정해준다.
@Configuration
public class S3Config {
@Value("${cloud.aws.credentials.access-key}")
private String accessKey;
@Value("${cloud.aws.credentials.secret-key}")
private String secretKey;
@Value("${cloud.aws.region}")
private String region;
@Bean
@Primary
public BasicAWSCredentials awsCredentialsProvider() {
BasicAWSCredentials basicAWSCredentials = new BasicAWSCredentials(accessKey, secretKey);
return basicAWSCredentials;
}
@Bean
public AmazonS3 amazonS3() {
return AmazonS3ClientBuilder.standard()
.withRegion(region)
.withCredentials(new AWSStaticCredentialsProvider(awsCredentialsProvider()))
.build();
}
}
FileService
Presigned URL을 발급 받는 로직이다.
- getPresignedUrl - Presigned URL을 발급한다.
- 아래 4개의 메서드를 사용하여 url을 발급하여 반환한다.
- getGeneratePresignedUrlRequest - 파일 업로드용 Presigned URL을 생성한다.
- getPresignedUrlExpiration - Presigned URL의 유효 기간을 설정한다.
- createFileId - UUID를 사용하여 파일 고유 ID를 생성한다.
- createPath - 파일의 전체 경로 생성한다.
- prefix와 fileName을 합쳐서 파일의 전체 경로를 생성한다.
- 파일의 이름이 중복될 수 있으므로 createFileld 메서드를 사용하여 UUID를 추가한다.
@Service
@RequiredArgsConstructor
public class FileService {
@Value("${cloud.s3.bucket}")
private String bucket;
private final AmazonS3 amazonS3;
public Map<String, String> getPresignedUrl(String prefix, String fileName) {
if (!prefix.isEmpty()) {
fileName = createPath(prefix, fileName);
}
GeneratePresignedUrlRequest generatePresignedUrlRequest = getGeneratePresignedUrlRequest(bucket, fileName);
URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest);
return Map.of("url", url.toString());
}
private GeneratePresignedUrlRequest getGeneratePresignedUrlRequest(String bucket, String fileName) {
GeneratePresignedUrlRequest generatePresignedUrlRequest = new GeneratePresignedUrlRequest(bucket, fileName)
.withMethod(HttpMethod.PUT)
.withExpiration(getPresignedUrlExpiration());
generatePresignedUrlRequest.addRequestParameter(
Headers.S3_CANNED_ACL,
CannedAccessControlList.PublicRead.toString()
);
return generatePresignedUrlRequest;
}
private Date getPresignedUrlExpiration() {
Date expiration = new Date();
long expTimeMillis = expiration.getTime();
expTimeMillis += 1000 * 60 * 2;
expiration.setTime(expTimeMillis);
return expiration;
}
private String createFileId() {
return UUID.randomUUID().toString();
}
private String createPath(String prefix, String fileName) {
String fileId = createFileId();
return String.format("%s/%s", prefix, fileId + "-" + fileName);
}
}
FileController
public DataResponseDto<Map<String, String>> getPresignedUrl(
@PathVariable(name = "fileName") @Schema(description = "확장자명을 포함해주세요")
String fileName) {
return DataResponseDto.from(fileService.getPresignedUrl("images", fileName));
}
API 테스트를 위해 간단한 API를 만들자.
기능 테스트
Swagger의 Execute 기능을 사용하여 Presigned URL을 발급 받았다.
이제 Presigned URL을 사용하여 S3에 파일을 업로드해보자.
앞서 발급 받은 Presigned URL에 PUT 메서드로 파일을 보낸다.
200 OK 응답이 나온 것을 통해 정상적으로 업로드된 것을 확인할 수 있다.
유효하지 않은 URL에 대해서는 아래와 같이 403 Forbidden 응답이 온다.
업로드한 사진에 접근할 때는 쿼리 파라미터(?x-amz-acl=~~~)를 제외한 URL을 사용하면 된다!