들어가며
안녕하세요! 요즘 플러피(Fluffy)라는 온라인 시험 제작 및 관리 서비스를 개발하고 있습니다. 보통 시험 문제에는 다양한 이미지들이 포함되어 있습니다. 플러피 서비스 또한 시험 지문에 이미지를 추가할 수 있는 기능을 만들었습니다.
처음에는 클라이언트에서 이미지를 서버로 전송하고, 서버에서 다시 AWS S3로 업로드하는 방식으로 작업했습니다. 이 방식이 궁금하신 분들은, 이전에 작성한 스프링에서 AWS S3를 이용한 이미지 업로드 방법 글을 참고해주세요.
하지만 이 방식에는 몇 가지 아쉬운 점이 있었습니다. 이미지 업로드의 경우, 이미지가 서버를 거쳐야 하기 때문에 서버의 부하가 커지고, 업로드 속도 또한 상대적으로 느립니다. 이미지 조회의 경우, S3에서 이미지를 직접 가져오기 때문에 조회 속도가 느리고, 비용도 상대적으로 많이 듭니다. S3에서 직접 조회하는 경우 S3 Bucket URL을 노출하기 때문에 보안상 좋지 않기도 합니다.
이러한 문제들을 해결하기 위해서, 다음과 같은 개선 작업을 진행했습니다. 이미지 업로드는 서버가 아닌, 클라이언트가 직접 Presigend URL을 통해 S3로 업로드하도록 변경했습니다. 이미지 조회는 S3가 아닌, CloudFront CDN을 통해 제공되도록 변경했습니다.
이번 글에서는 Spring에서 Presigend URL을 이용한 이미지 업로드 방법과 CloudFront CDN을 이용한 이미지 조회 방법에 대해서 알아보고, 소소한 팁들에 대해서도 공유해보겠습니다.
개발 환경
구현 방법을 설명하기에 앞서, 플러피 서비스에서 사용한 개발 환경에 대해서 설명하겠습니다. 다르신 분들은 참고하시길 바랍니다.
- Spring Boot 3
- 'io.awspring.cloud:spring-cloud-aws-starter-s3' 의존성
- AWS EC2
- AWS S3
- AWS CloudFront
- 가비아 도메인 서비스 이용
Presigned URL 설명
기존 방식 (일반적인 Multipart 방식)
플러피 서버의 기존 이미지 방식은 다음과 같습니다.
- 클라이언트 -> 서버 전송: 클라이언트에서 multipart/form-data 형식으로 서버에 이미지 파일을 전송합니다.
- 서버 내 처리: 파일 검증 및 파일 사이즈, 확장자 등을 확인합니다.
- 서버 -> S3 업로드: 서버에서 AWS SDK를 이용하여, S3에 이미지를 업로드합니다.
- 응답 반환: 이미지 업로드가 완료되면 서버에서 클라이언트로 저장된 이미지 URL을 반환합니다.
이 방식은 서버에서 파일을 직접 관리하므로, 추가적인 검증, 변환 등의 처리가 용이합니다. 하지만 모든 파일 데이터가 서버를 거치기 때문에, 특히 대용량 파일이나 다수의 파일이 동시에 업로드될 경우 서버 리소스와 대역폭에 큰 부담이 됩니다. 파일이 클라이언트에서 서버로, 서버에서 S3로 두 번 전송되므로, 전체 업로드 속도가 또한 느려질 수 있습니다.
Presigned URL이란?
간단하게 말해서 Presigned URL은 AWS S3 같은 스토리지 서비스에서 일정 시간 동안 제한된 권한을 가진 URL을 생성하는 방식입니다. 이 방식을 사용하면 다른 사람이 AWS 보안 자격이나 권한이 없어도 파일을 업로드할 수 있습니다. 서버를 통해서 파일 업로드를 하지 않고, AWS S3에 직접 업로드하기 때문에 서버의 부하를 줄일 수 있습니다.
개선 방식 (Presigend URL 방식)
플러피 서버의 개선된 이미지 업로드 방식은 다음과 같습니다.
- Presigned URL 요청: 클라이언트는 파일 업로드 전에, 서버에 업로드할 파일에 대한 정보를 전달하고 Presigned URL 발급을 요청합니다.
- Presigned URL 발급: 서버는 AWS SDK를 이용해, 특정 시간 동안 유효한 Presigned URL을 생성하고, 클라이언트에게 반환합니다.
- 클라이언트 -> S3 업로드: 클라이언트는 Presigned URL을 이용하여, 직접 AWS S3에 파일을 업로드합니다.
파일이 직접 S3로 업로드되므로, 서버는 Presigned URL 생성만 담당하고, 실제 파일 데이터 처리에서 벗어납니다. 클라이언트와 S3 사이에서 직접 데이터 전송이 이루어지기 때문에 업로드 속도가 향상됩니다. 하지만 서버에서 파일을 직접 관리하지 않기 때문에, 파일 검증이나 가공이 어렵고, 파일이 실제로 업로드되었는지 확인이 어렵습니다.
Presigned URL 구현
먼저, Storage와 통신하는 클라이언트 인터페이스를 만들어줍니다. 인터페이스로 만드는 이유는 추후 다른 스토리지 서비스로 변경할 때, 클라이언트를 쉽게 교체할 수 있기 때문입니다. 애플리케이션 계층에 StorageClient를 두고, 이를 구현한 것을 infrastrucutre 계층에 둡니다.
// 스토리지와 통신하는 클라이언트 인터페이스
public interface StorageClient {
PresignedUrlResponse getPresignedUrl(String filePath);
}
// Presigned URL을 응답받을 DTO
public record PresignedUrlResponse(
String presignedUrl,
String fileUrl
) {
}
다음으로, infrastructure 계층에서 StorageClient를 구현한 AwsS3Client를 만들어줍니다. 전체 코드를 먼저 확인하고, 필드에 있는 AwsS3ClientProperties에 대해서 설명하겠습니다.
import java.net.URI;
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
@Component
@RequiredArgsConstructor
public class AwsS3Client implements StorageClient {
private static final Duration PRESIGNED_URL_EXPIRATION = Duration.ofMinutes(5);
private final S3Presigner s3Presigner;
private final AwsS3ClientProperties properties;
@Override
public PresignedUrlResponse getPresignedUrl(String filePath) {
PutObjectPresignRequest presignRequest = buildPresignedRequest(filePath);
String presignedUrl = s3Presigner.presignPutObject(presignRequest).url().toString();
String fileUrl = createFileUrl(filePath);
return new PresignedUrlResponse(presignedUrl, fileUrl);
}
private String createFileUrl(String filePath) {
URI domain = URI.create(properties.domain());
return domain.resolve(filePath).toString();
}
private PutObjectPresignRequest buildPresignedRequest(String filePath) {
PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder()
.bucket(properties.bucket())
.key(filePath);
return PutObjectPresignRequest.builder()
.signatureDuration(PRESIGNED_URL_EXPIRATION)
.putObjectRequest(requestBuilder.build())
.build();
}
}
aws 의존성에 있는 DefaultCredentialsProvider는 access-key, secret-key를, DefaultAwsRegionProviderChain는 region.static을 찾아 자동으로 환경을 구성합니다. s3에 있는 bucket, domain은 제가 임의로 부여한 값이고, 각각 bucket 이름과 bucket의 경로를 의미합니다.
// application.yml
spring:
cloud:
aws:
credentials:
access-key: ${AWS_ACCESS_KEY}
secret-key: ${AWS_SECRET_KEY}
region:
static: ${AWS_S3_REGION}
s3:
bucket: ${AWS_S3_BUCKET}
domain: ${AWS_S3_DOMAIN}
AwsS3ClientProperties는 bucket과 domain에 대한 Property를 읽어오도록 합니다.
@ConfigurationProperties(prefix = "spring.cloud.aws.s3")
public record AwsS3ClientProperties(
@NotBlank String bucket,
@NotBlank String domain
) {
}
다시 AwsS3Client로 돌아와서 getPresignedUrl에 대해서 설명하겠습니다. 간단하게 말해서, 파일 경로를 기반으로 S3에 Presigned URL을 요청하는 것입니다. 그리고 업로드된 후 조회할 때 쓰일 URL을 생성해서, Presigned URL와 함께 반환합니다.
@Override
public PresignedUrlResponse getPresignedUrl(String filePath) {
// 파일 경로를 기반으로 S3에 PUT 요청을 위한 Presigned 요청 객체를 생성합니다.
PutObjectPresignRequest presignRequest = buildPresignedRequest(filePath);
// S3에게 Presigned URL을 요청하고, URL을 문자열로 변환합니다.
String presignedUrl = s3Presigner.presignPutObject(presignRequest).url().toString();
// S3에 업로될 시 조회할 수 있는 URL을 생성합니다.
String fileUrl = createFileUrl(filePath);
// 업로드 시 필요한 Presigned URL과 조회 시 필요한 URL을 응답합니다.
return new PresignedUrlResponse(presignedUrl, fileUrl);
}
buildPresignedRequest 메서드는 파일 경로를 기반으로 S3에 PUT 요청을 위한 Presigned 요청 객체를 생성합니다. 여기서 key는 bucket 내 저장될 위치입니다. 주의할 점으로 filePath는 "/"로 시작하면 안됩니다. 예를 들어, "example/apple.png"와 같은 형태여야 합니다.
Presigned URL은 유효 기간을 설정할 수 있습니다. 만약 URL이 외부에 노출되어도 유효 기간이 지나면 사용할 수 없게되어 보안상 이점이 있습니다. 저는 주고 받는 이미지의 사이즈가 크지 않아 적당히 5분으로 지정했습니다.
private PutObjectPresignRequest buildPresignedRequest(String filePath) {
PutObjectRequest.Builder requestBuilder = PutObjectRequest.builder()
.bucket(properties.bucket())
.key(filePath);
return PutObjectPresignRequest.builder()
.signatureDuration(PRESIGNED_URL_EXPIRATION) // 5분
.putObjectRequest(requestBuilder.build())
.build();
}
createFileUrl은 S3의 도메인과 filePath를 합쳐서 이미지 업로드 후 조회할 URL을 생성해줍니다. 예를 들어, domain이 "https://my-bucket.com"이고, filePath가 "example/apple.png"이면 합쳐서 "https://my-bucket.com/example/apple.png"를 반환하게 됩니다. 혹시 모를 실수로 "/"가 두 번 쓰여 문제를 일으킬 위험이 있기 때문에 URI를 사용하여 "/" 중복 문제를 예방했습니다.
private String createFileUrl(String filePath) {
URI domain = URI.create(properties.domain());
return domain.resolve(filePath).toString();
}
자 이제 Presigned URL 생성 코드는 끝이 났습니다. 나머지 application, ui 계층의 코드는 상황에 따라 다르므로 생략하도록 하겠습니다. Postman을 통해서 성공적으로 작동하는지 알아보겠습니다. Post 요청을 통해 서버로부터 Presigned URL을 받아오고, 그 경로로 Put 요청을 해서 S3에 이미지를 업로드 하겠습니다.
Postman을 통해서는 성공적으로 작동하는 것을 알 수 있습니다. 하지만 로컬의 리액트와 같은 프론트엔드에서 요청하거나 실제 배포 환경에서 요청할 때는, CORS(Cross-origin 리소스 공유) 문제를 마주치게 됩니다. 이 문제를 해결하기 위해서는 S3 Bucket에서 CORS (Cross Origin 리소스 공유) 설정을 해줘야 합니다.
아래의 설정을 붙여 넣어 줍니다. 이미지 업로드 시 필요한 Put Http 메서드를 허용합니다. AllowedOrigins에 실제 배포 도메인을 넣어줍니다. 로컬의 경우 테스트 시에만 넣어보고, 지워주는게 좋을 것 같습니다. 클라이언트가 접근할 수 있는 헤더에 ETAG를 추가해줍니다. ETAG는 파일의 버전을 나타내는 식별자입니다. 파일이 변경될 때마다 ETAG도 변경되어 캐싱 제어에 활용될 수 있습니다.
// CORS(Cross-origin 리소스 공유)
[
{
"AllowedHeaders": [
"*"
],
"AllowedMethods": [
"PUT"
],
"AllowedOrigins": [
"http://localhost:5173", // 로컬을 열어두는 것은 좋지 않을 수 있습니다.
"https://www.fluffy.run" // 실제 배포 도메인
],
"ExposeHeaders": [
"ETAG" // 파일의 버전을 나타내는 식별자
],
"MaxAgeSeconds": 3000
}
]
플러피의 프론트엔드는 리액트를 사용 중입니다. 클라이언트에서는 다음과 같은 코드를 통해 이미지 업로드를 처리하고 있습니다. 먼저, 서버로부터 Presigend URL을 받고, 그 URL을 통해 S3에 이미지를 업로드합니다. 프론트엔드를 개발하시는 분들은 참고하시길 바랍니다.
uploadImage: async ({ examId, image }: { examId: number; image: File }) => {
// 서버로부터 Presigned URL을 받습니다.
const { presignedUrl, imageUrl } = await ExamAPI.getPresignedUrl(examId);
// Presigned URL로 S3에 이미지를 저장합니다.
await axios.put<void>(presignedUrl, image, {
headers: { 'Content-Type': image.type },
withCredentials: false, // 불필요한 쿠키 전송 방지
});
return { imageUrl };
}
CloudFront CDN 설명
지금까지 이미지 업로드를 개선하는 방법에 대해서 알아보았습니다. 이번에는 이미지 조회를 개선하는 방법에 대해서 알아보겠습니다.
CloudFront는 AWS에서 제공하는 글로벌 컨텐츠 전송 네트워크(CDN) 서비스입니다. 전 세계에 분산된 엣지 로케이션을 통해 사용자에게 가까운 서버에서 캐싱된 콘텐츠를 제공하기 때문에 지연 시간을 줄일 수 있습니다. CloudFront는 S3와 간편하게 연동할 수 있고, S3에 저장된 이미지를 CloudFront를 통해 제공하면 이미지 조회 속도를 높일 수 있습니다. S3와 비교해서 CloudFront의 데이터 전송 비용이 더 저렴하기 때문에 성능 향상은 물론 비용 절감 효과까지 기대할 수 있습니다.
참고로, 원래는 Cloudflare를 사용할 계획이었으나, 망사용료 이슈로 인해 CDN을 이용하기 위해 해외로 나갔다가 들어오는 문제가 있다고 한다. 이로 인해 CDN의 빠른 조회 성능을 이용할 수 없습니다. 관련 블로그를 참고하시면 좋을 것 같습니다.
CloudFront CDN 구현
AWS CloudFront로 들어가서 배포 생성을 하겠습니다. Origin domain에서 연결할 S3를 지정합니다. 원본 액세스의 경우 권장인 "원본 액세스 제어 설정"을 선택합니다. "Create new OAC"를 눌러 제어 설정을 생성합니다.
방화벽은 비용이 발생하므로 상황에 따라 비활성화 합니다.
로깅 또한 상황에 따라 선택합니다. 나머지들은 넘어가줍니다.
배포 생성을 하면 S3 버킷 정책을 업데이트하라는 알림이 뜹니다. 정책을 복사하고, S3의 권한에서 변경해줍니다. 적용이 완료되면 기존에 접근 가능했던 Bucket 객체 URL(예를 들어, "https://my-bucket/example/apple.png")로 접근이 거부되고, CloudFront에서 제공하는 배포 도메인 이름을 통해 객체에 접근해야 합니다. 배포 도메인 이름이 "https://d2wfhl3yrqhfu9.cloudfront.net"이라면 "https://d2wfhl3yrqhfu9.cloudfront.net/example/apple.png"으로 객체에 접근이 가능합니다.
CloudFront 배포 도메인 변경
기본적으로 CloudFront에서 제공하는 배포 도메인은 다소 직관적이지 않습니다. 그래서 저는 별도의 도메인을 연결해 배포 도메인 이름을 변경하려고 합니다. 현재 "fluffy.run" 도메인을 보유하고 있으며, 이 도메인의 서브 도메인인 "cdn.fluffy.run"을 배포 도메인으로 설정할 예정입니다. 저는 가비아를 통해 도메인을 구매했기 때문에, 이 글에서는 가비아를 기준으로 설정하는 방법을 설명드리겠습니다. 각자 상황에 맞게 적절하게 참고하시길 바랍니다.
배포한 CloudFront의 설정에서 "편집"을 선택합니다. 대체 도메인 이름에 원하는 서브 도메인을 작성합니다. SSL 인증서가 필요하므로 아래의 Request certificate로 이동합니다.
퍼블릭 인증서 요청을 클릭하고 다음으로 넘어갑니다. 모든 서브 도메인에 해당하는 "*.fluffy.run"을 등록합니다. 필요에 따라서 루트 도메인인 "fluffy.run"도 등록해줍니다.
인증서를 요청하면 다음과 같이 CNAME 이름과 CNAME 값을 알 수 있습니다. 이 값들은 DNS 검증을 위해서 사용됩니다.
가비아의 DNS 관리에 들어가 레코드를 추가해줍니다. 시간이 지난 후 DNS 검증이 완료되면, 인증서의 상태가 "발급됨"으로 변합니다.
이제 다시 돌아가서 생성된 인증서를 선택하고, 변경 사항을 저장합니다. 이제 DNS 검증에 사용했던 값들은 지워주고, cdn.fluffy.run으로 접속 시 CloudFront의 원본 도메인으로 이동하게 합니다.
AwsS3Client를 구현할 때 properties.domain()에는 S3 Bucket URL이 있었습니다. 이것을 CloudFront 대체 도메인을 변경해줍니다. 플러피 서비스의 경우 "https://cdn.fluffy.run"이 됩니다.
private String createFileUrl(String filePath) {
URI domain = URI.create(properties.domain());
return domain.resolve(filePath).toString();
}
CloudFront CDN 적용 전 후 비교
S3에서 직접 조회를 했을 경우와 CloudFront로 캐싱된 데이터를 조회하는 속도를 비교해봤습니다. 정확한 테스트를 위해서 "캐시 사용 중지" 옵션을 선택했습니다. CloudFront를 사용했을 경우 이전과 비교해서 상대적으로 조회 속도가 빨라졌음을 알 수 있습니다.
마치며
이번 글에서는 Presigned URL과 CloudFront CDN을 구현하는 방법에 대해서 알아보았습니다. 각각을 분리해서 작성할까도 생각했는데 이미지 업로드 기능 구현 시 빠질 수 없는 연관된 것들이기 때문에 같이 글을 작성해보았습니다. 여러분들도 한 번 적용해보시길 권장드립니다.
아래는 플러피 서비스에서 Presigend URL을 통해 이미지를 업로드하고, CDN을 통해 이미지를 조회하는 영상입니다.
Presigned URL을 구현한 플러피 서버의 코드는 다음을 참고하시면 됩니다.
fluffy/server/src/main/java/com/fluffy/infra/storage at b05d1264431b88c5d503a2e920d6c95f637901e0 · alstn113/fluffy
온라인 시험 문제 제작 및 관리 서비스 - 플러피. Contribute to alstn113/fluffy development by creating an account on GitHub.
github.com
긴 글 읽어주셔서 감사합니다. 잘못된 부분은 언제나 환영입니다. 😄
참고
'서버' 카테고리의 다른 글
코틀린 기본기 정리 (0) | 2025.03.20 |
---|---|
테스트 후 데이터 정리를 통해 테스트 간 데이터 독립성 보장 (0) | 2025.03.03 |
Testcontainers로 실제 서비스와 유사한 환경에서 테스트하기 (1) | 2025.03.02 |
분산락을 이용한 중복 생성 문제 해결 (2) | 2025.02.23 |
로컬에서 AWS Private Subnet에 있는 인스턴스에 접속하는 방법 (0) | 2025.02.13 |