0. Intro
이미지와 같은 파일을 AWS S3에 업로드하면 객체 URL에 접근하여 불러올 수 있다.
어떤 웹 페이지에서 S3에 저장해둔 이미지를 불러와야 한다고 생각해보자.
이미지 1개를 불러와야 한다면 이미지 크기가 로딩 속도에 큰 영향을 주지 않을지도 모른다.
만약 여러 이미지 사진, 이를테면 동시에 수 백개를 불러와서 웹 페이지를 로딩해야한다면??
이미지 크기가 로딩 속도에 큰 영향을 줄 수 있다.
✅ 그렇기에 빠른 이미지 로딩을 위해서 이미지 크기를 줄이는 것은 중요한 부분이다.
🚨 문제 상황
프로젝트를 진행하면서 이와 관련한 문제가 있었다.
우선, 상품과 브랜드 이미지를 웹 페이지 한 곳에서 동시에 불러올 일이 많을 것이라 예상했다.
뿐 만 아니라, 썸네일 혹은 미리보기와 같은 곳에서도 이미지를 불러올 상황이 많을 것인데 이 때 원본 이미지 그대로를 불러온다면 이미지 크기로 인해 로딩 속도에 문제가 생길 수도 있다고 생각했다.
❓ 이미지 크기를 줄이는 방법
이미지 크기를 줄이는 단순한 방법은 1️⃣ 서버에서 직접 이미지 크기를 조정하는 로직을 작성하는 것이다.
🚫 그런데, 이 방법은 앞서 말했듯이 동시에 많은 이미지를 조정하려고 한다면 CPU 리소스를 잡아먹기 때문에 서버에 상당한 부하를 줄 수 있다.
다음으로 2️⃣ AWS Lambda를 이용하는 방법이 있다.
AWS Lambda
AWS Lambda는 서버리스 컴퓨팅 Faas(Function as a Service)이다.
여기서 서버리스란 개발자가 서버를 관리할 필요 없이 애플리케이션을 빌드하고 실행할 수 있도록 하는 것을 말한다.
즉, Lambda의 경우 AWS가 서버 인프라에 대한 프로비저닝, 유지 관리 등을 대신 처리해준다는 것을 의미한다.
간단히 말해, AWS가 개발자가 할 일을 대신 처리해준다는 뜻이다.
이렇게 되면 개발자는 비즈니스 로직 작성에 집중할 수 있다는 장점이 있다.
AWS Lambda와 이미지 resizing
다시 본론으로 돌아와, 서버리스 개념에 따르면, AWS Lambda에 원하는 함수를 작성하고, 필요할 때 그 함수를 실행시켜 우리가 원하는 동작을 하게끔 할 수 있다는 의미가 된다.
그렇다면 Lambda로 어떻게 이미지 크기를 조정할 수 있는지에 대한 의문이 들 것이다.
간단하다.
이미지 크기를 조정하는 함수를 작성해 Lambda에 등록해두어 Lambda가 처리하게끔 하는 것이다.
고맙게도 AWS 공식문서에 이미지 리사이징과 관련한 코드가 자세히 나와있으며, AWS Lambda는 다른 AWS 서비스들과의 연동 또한 용이하기에 S3 버킷에 저장해둔 이미지 파일이라면 쉽게 이미지 크기를 조정할 수 있다.
그래서 결론은, 프로젝트엔 이 AWS Lambda를 이용할 것이다.
브랜드, 상품을 등록할 때 이미지 파일을 보내게 될텐데 원본 이미지 파일이 등록됨과 동시에 Lambda의 트리거가 동작하고, 이미지 리사이징 함수를 통해서 크기가 작아진 이미지 파일을 생성해 S3 버킷에 저장하도록 할 것이다.
적용 순서
AWS 공식문서에 보면 이미지 리사이징은 아래와 같은 순서를 밟으면 된다고 나와있는데, 이를 기본으로 하여 진행해보겠다.
1. IAM 정책 생성
우린 이미 브랜드, 상품 이미지를 저장할 S3 버킷이 존재하기 때문에 단계 1은 패스하고, IAM 정책 생성 단계로 넘어간다.
IAM 콘솔의 정책 페이지를 열고, 아래 사진에서 정책 생성 버튼을 누른다.
정책 생성 페이지에 들어오면 JSON 탭을 선택한 뒤 정책을 입력해야 한다.
정책은 다음과 같다.
개인마다 달라서 바꿔야 할 부분은 "Resource": "arn:aws:s3:::sourcebucket/*"
와, "Resource": "arn:aws:s3:::sourcebucket-resized/*"
부분이다.
sourcebucket
부분엔 원본 이미지를 등록해둔 S3 버킷 이름으로 바꿔준다.
sourcebucket-resized
부분엔 리사이징한 이미지를 등록할 S3 버킷 이름으로 바꿔준다.
당연히, 리사이징한 이미지를 등록할 S3 버킷은 생성된 상태여야 한다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:PutLogEvents",
"logs:CreateLogGroup",
"logs:CreateLogStream"
],
"Resource": "arn:aws:logs:*:*:*"
},
{
"Effect": "Allow",
"Action": [
"s3:GetObject"
],
"Resource": "arn:aws:s3:::sourcebucket/*"
},
{
"Effect": "Allow",
"Action": [
"s3:PutObject"
],
"Resource": "arn:aws:s3:::sourcebucket-resized/*"
}
]
}
태그 추가 부분은 default로 두고, 아래와 같이 정책 검토의 이름에 AWSLambdaS3Policy
를 입력하고, 정책 생성 버튼을 선택한다.
2. 실행 역할 생성
다음으로, Lambda 함수에 AWS 리소스 액세스 권한을 제공하는 실행 역할을 만들어야 한다.
IAM 콘솔에서 역할 페이지를 연다.
역할 만들기를 선택한다.
다음 속성을 사용하여 역할을 만든다.
- 신뢰할 수 있는 엔터티 –
Lambda
- 권한 정책 –
AWSLambdaS3Policy
- 역할 이름 –
lambda-s3-role
3. 배포 패키지 생성
이제 이미지 리사이징을 위한 함수를 작성해야 한다.
여러 언어가 지원되지만 Java/Spring 기반의 프로젝트로 진행하고 있기에 Java11로 진행했다.
Java11은 콘솔 창에서 바로 편집할 수 없기 때문에 따로 프로젝트를 만들어 그 안에 함수를 작성하고 .zip파일로 빌드하는 과정을 거쳐야 한다.
우선 새 프로젝트를 만들고 의존성을 추가해줘야 한다.
.zip 파일 아카이브 배포를 참고하여 build.gradle엔 아래와 같은 종속성을 추가해주었다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
implementation platform('software.amazon.awssdk:bom:2.15.0')
implementation 'software.amazon.awssdk:s3'
implementation 'com.amazonaws:aws-lambda-java-core:1.2.2'
implementation 'com.amazonaws:aws-lambda-java-events:3.11.1'
implementation 'com.amazonaws:aws-java-sdk-s3:1.12.429'
implementation 'com.google.code.gson:gson:2.10.1'
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.2'
}
다음으로, Handler.java
클래스를 만들고, 그 안에 이미지 리사이징 함수를 만들면 된다.
리사이징 로직은 예시 코드 그대로 가져오고, jpeg
와 gif
타입에 관한 것만 추가해주었다.
Handler.java
public class Handler implements RequestHandler<S3Event, String> {
private static final float MAX_DIMENSION = 100;
private final String REGEX = ".*\\.([^\\.]*)";
private final String JPG_TYPE = (String) "jpg";
private final String JPG_MIME = (String) "image/jpeg";
private final String JPEG_TYPE = (String) "jpeg";
private final String JPEG_MIME = (String) "image/jpeg";
private final String PNG_TYPE = (String) "png";
private final String PNG_MIME = (String) "image/png";
private final String GIF_TYPE = (String) "gif";
private final String GIF_MIME = (String) "image/gif";
@Override
public String handleRequest(S3Event s3event, Context context) {
LambdaLogger logger = context.getLogger();
try {
S3EventNotificationRecord record = s3event.getRecords().get(0);
String srcBucket = record.getS3().getBucket().getName();
// Object key may have spaces or unicode non-ASCII characters.
String key = record.getS3().getObject().getUrlDecodedKey();
String dstBucket = srcBucket + "-resized";
String dstKey = "resized-" + key;
// Infer the image type.
Matcher matcher = Pattern.compile(REGEX).matcher(key);
if (!matcher.matches()) {
logger.log("Unable to infer image type for key " + key);
return "";
}
String imageType = matcher.group(1);
if (!(JPG_TYPE.equals(imageType)) && !(JPEG_TYPE.equals(imageType))
&& !(PNG_TYPE.equals(imageType)) && !(GIF_TYPE.equals(imageType))) {
logger.log("Skipping non-image " + key);
return "";
}
// Download the image from S3 into a stream
S3Client s3Client = S3Client.builder().build();
InputStream s3Object = getObject(s3Client, srcBucket, key);
// Read the source image
BufferedImage srcImage = ImageIO.read(s3Object);
BufferedImage newImage = resizeImage(srcImage);
// Re-encode image to target format
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ImageIO.write(newImage, imageType, outputStream);
// Upload new image to S3
putObject(s3Client, outputStream, dstBucket, dstKey, imageType, logger);
logger.log(
"Successfully resized " + srcBucket + "/" + key + " and uploaded to " + dstBucket
+ "/" + dstKey);
return "Ok";
} catch (IOException e) {
throw new RuntimeException(e);
}
}
private void putObject(S3Client s3Client, ByteArrayOutputStream outputStream,
String bucket, String key, String imageType, LambdaLogger logger) {
Map<String, String> metadata = new HashMap<>();
metadata.put("Content-Length", Integer.toString(outputStream.size()));
if (JPG_TYPE.equals(imageType)) {
metadata.put("Content-Type", JPG_MIME);
}
if (PNG_TYPE.equals(imageType)) {
metadata.put("Content-Type", PNG_MIME);
}
if (JPEG_TYPE.equals(imageType)) {
metadata.put("Content-Type", JPEG_MIME);
}
if (GIF_TYPE.equals(imageType)) {
metadata.put("Content-Type", GIF_MIME);
}
software.amazon.awssdk.services.s3.model.PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.metadata(metadata)
.build();
// Uploading to S3 destination bucket
logger.log("Writing to: " + bucket + "/" + key);
try {
s3Client.putObject(putObjectRequest,
RequestBody.fromBytes(outputStream.toByteArray()));
}
catch(AwsServiceException e)
{
logger.log(e.awsErrorDetails().errorMessage());
System.exit(1);
}
}
private InputStream getObject(S3Client s3Client, String bucket, String key) {
software.amazon.awssdk.services.s3.model.GetObjectRequest getObjectRequest = software.amazon.awssdk.services.s3.model.GetObjectRequest.builder()
.bucket(bucket)
.key(key)
.build();
return s3Client.getObject(getObjectRequest);
}
private BufferedImage resizeImage(BufferedImage srcImage) {
int srcHeight = srcImage.getHeight();
int srcWidth = srcImage.getWidth();
// Infer scaling factor to avoid stretching image unnaturally
float scalingFactor = Math.min(
MAX_DIMENSION / srcWidth, MAX_DIMENSION / srcHeight);
int width = (int) (scalingFactor * srcWidth);
int height = (int) (scalingFactor * srcHeight);
BufferedImage resizedImage = new BufferedImage(width, height,
BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = resizedImage.createGraphics();
// Fill with white before applying semi-transparent (alpha) images
graphics.setPaint(Color.white);
graphics.fillRect(0, 0, width, height);
// Simple bilinear resize
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
graphics.drawImage(srcImage, 0, 0, width, height, null);
graphics.dispose();
return resizedImage;
}
}
4. 배포 패키지 빌드
함수를 다 작성했으면 .zip파일로 build해야 한다.
Gradle을 사용하여 배포 패키지 빌드 공식문서에 따르면 gradle 빌드의 경우 build.gradle
에 다음과 같은 코드를 작성하면 build시 .zip파일이 생성된다고 한다.
task buildZip(type: Zip) {
from compileJava
from processResources
into('lib') {
from configurations.runtimeClasspath
}
}
build.dependsOn buildZip
위 코드를 잠시 설명하면, build/distributions
디렉터리에 배포 패키지를 생성한다.
compileJava
작업은 함수의 클래스를 컴파일하며, 이 processResources
작업은 Java 프로젝트 리소스를 대상 디렉터리에 복사하고 잠재적으로 처리한다.
그런 다음 into('lib')
명령이 빌드의 클래스 경로에서 lib라는 폴더로 종속성 라이브러리를 복사하게 된다.
build.gradle
에 작성이 끝났다면 build
한 뒤,
build/distributions
디렉터리에 .zip 파일이 생성됐는지 확인한다.
5. Lambda 함수 생성
빌드한 배포 패키지를 Lambda에 등록하기 위한 과정은 Lambda 배포 패키지를 참고했다.
Lambda 콘솔을 사용해서 .zip파일을 업로드해보겠다.
우선, Lambda > 함수 > 함수 생성
에서 새로 작성
을 선택한다.
기본 정보에는 적절한 이름과 Java 11, 역할엔 앞서 생성한 lambda-s3-role
을 선택한다.
다음으로, 코드 소스에 .zip
혹은 .jar
파일로 업로드하면 되는데,
용량이 10MB를 초과하면 다음과 같이 S3를 사용해서 업로드 하라고 한다.
그래서 임의의 S3 버킷을 새로 생성해서 .zip파일을 업로드 후 해당 .zip파일 객체 URL을 가져오는 방법으로 진행해야 한다.
다음과 같이 임의의 S3 버킷을 생성하고, zip파일을 업로드했다.
그러면 다시 돌아와서 Lambda 코드 소스에 해당 객체 URL을 입력하면 된다.
그리고, 런타임 설정의 핸들러 값을 [패키지명.클래스명::메서드명]
으로 설정하고 등록한다.
6. Amazon S3 트리거를 생성
한 가지 과정이 더 남아있다.
우리가 Lambda에 등록해 둔 함수를 언제 실행할건지에 관한 설정을 해주어야 한다.
Amazon S3 트리거를 사용하여 Lambda 함수를 호출할 것이고, Amazon S3 트리거를 사용하여 Lambda 함수 호출의 순서를 참고했다.
문서에 나와 있는 순서는 다음과 같다.
함수 개요 에서 트리거 추가를 클릭 한 뒤,
S3를 소스로 선택한다.
버킷은 이미지를 변환할 대상 버킷
을 선택한다.
그리고, 이벤트 유형은 모든 객체 생성 이벤트
를 선택하고, 재귀 호출 경고를 체크 한뒤, 트리거를 추가한다.
7. 요청 확인
다음과 같이 나이키 로고 브랜드 생성 요청을 보내면,
원본 이미지가 shoekream
버킷의 brand
폴더에 250.6KB
용량으로 저장되고,
리사이징 이미지는 shoekream-resized
버킷의 resized-brand
폴더에 1.4KB
용량으로 저장되었다.
모니터링 > 로그 > CloudWatch Logs
를 살펴보면, 작성한 트리거가 실행되어 Lambda가 정상적으로 호출되었고, 그 결과인 리사이징 작업이 잘 이루어졌음을 알 수 있다.
마무리
이미지를 불러올 때 동시에 여러 이미지를 불러와야 한다면 이미지 크기가 로딩 속도에 영향을 줄 수 있고, 썸네일 혹은 미리보기와 같은 곳에서도 크기가 큰 원본 이미지를 계속해서 요청할 필요가 없기 때문에 이미지 크기를 줄이는 작업이 필요하다.
이미지 크기를 줄이는 로직을 내 서버 안에서 작성할 수도 있겠지만 그러한 방법은 동시에 여러 이미지를 줄여야 하기엔 서버의 부하가 심해져 적합한 방법은 아니다.
그래서 서버리스 Faas인 AWS Lambda를 이용하게 되었다.
내가 처리해야 할 이미지 크기 조정 작업을 AWS Lambda에 함수로 등록해두면, Lambda가 특정 조건이 만족할 때 해당 작업을 실행시켜 리사이징 이미지를 생성하게 된다.
이렇게 함으로써 이미지 크기 조정 작업을 내 애플리케이션 서버 로직에서 실행하지 않기 때문에 서버의 부하를 줄일 수 있고, 목적에 맞는 이미지를 불러올 수 있기에 여러 이미지가 포함된 웹 페이지의 로딩 속도를 빠르게 할 수 있다.
Leave a comment