[2/2] AWS Lambda와 Sharp로 이미지 리사이징하기 (이미지 최적화)
사용 동기
웹 사이트 속도의 첫 인상을 결정하는 것은 아마도 첫 이미지 로딩 시간일 것이다.
50x50 사이즈의 이미지를 로딩하는데 10MB 짜리 400x400 사이즈 이미지를 로딩하며, 심지어 페이지에 다시 돌아왔을 때 캐싱도 되어있지 않다면 유저는 그 사이트가 느리다고 생각할지도 모른다.
이를 해결하기 위해 리사이징(Resizing)
기술을 제공하는 모듈(sharp)
이 나오게 되었고, 이미지 용량을 줄여 로딩 속도를 빠르게하는 Best practice
로서 널리 통용되어 사용되고 있다.
이 글에서는 1 편에서 소개했듯이 AWS Lambda 함수로 썸네일을 생성하는 방법을 소개한다. 근데 만약 Lambda를 한번이라도 써본적이 있다면, 여기서 이런 의문점이 생길 수도 있다.
굳이 Serverless 서비스인 AWS lambda로 썸네일을 생성할 필요가 있나?
그냥 우리 서버로 "GET /imageUrl?w=300&h=300"와 같은 요청을 보내 처리하면 되지 않는가?
물론 되지만, 위와 같이 한다면 이미지 용량이 크거나, 여러 요청이 들어올 때 서버에 부담이 갈 수 있다는 단점이 있다. 심지어 이미지 서버를 CDN으로 제공하고 있다면 서버로 요청을 보내는 것은 추가적인 Overhead일 것이다. 그렇기 때문에 리사이징만을 위하여 서버 자원을 쓰는 것이 좋아보이지는 않는다.
Lambda@Edge 탄생
그리하여 AWS Labmda@edge라는 서비스가 나오게되었다. 이는 AWS CloudFront의 기능 중 하나인데 독스에도 나와있듯이 CDN에 의해 생성된 이벤트에 대한 응답으로서 Lambda 함수를 실행하는 기술이다.
CDN 도메인으로 GET /CDN-IMAGE-URL?w=100&h=100&q=80
와 같이 width, height, quality 쿼리 파라미터가 붙어서 요청이 들어오면 Lambda 함수를 trigger하여 이미지를 리사이징하여 응답하는 것이다.
flow를 보자면 다음과 같다.
- 클라이언트에서
GET /CDN-IMAGE-URL?w=100&h=100&q=80
요청을 보냄. - CDN에서 w, h, q 쿼리 파라미터가 붙어있으면
Lambda 함수를 트리거
- 해당 Object(이미지)가 CDN에 캐싱이 되어있으면 해당 Buffer를 사용하고, 없다면 S3 origin에서 가져옴. (만약 없으면 400 error로 응답)
Sharp
모듈을 사용하여w, h, q
인자를 통해 이미지 리사이징- Base64 형식으로 인코딩하여 클라이언트에게 응답을 리턴
- 클라이언트는 응답을 읽어 화면에 렌더링.
그럼 이제 실습을 해보자.
참고로 Lambda@Edge 서비스는 버즈니아 북부 리전에서만 제공된다.
CDN은 이미 적용됐다고 가정하고 진행하겠다. 아직 안했다면 1 편을 참조하고오자.
적용해보기
Lambda 함수 생성
- Lambda 콘솔로 이동
- 함수 생성 클릭
- 런타임 Node.js 10 버전을 선택 (14 버전은 아직 지원을 하지 않는다고 함)
- 함수 생성
AWS Cloud9 IDE에서 코드 작성
Lambda 함수 작성을 위한 환경은 Cloud9에서 진행한다.
- AWS Cloud9 콘솔로 이동
- 역시 서울리전은 불가하니 버지니아 북부 리전을 선택
- Create environment 클릭
- Name을 작성하고 Next step 클릭
- 아래 사진과 같이 default 선택하고, Create environment 클릭
- 생성한 Cloud9 환경으로 이동
Cloud9의 좋은점은 Lambda에 설정되어 있는 코드를 호출할 수 있다는 점이다. download, upload를 마우스 클릭으로 쉽게 할 수 있다.
- 왼쪽 탭에
AWS 클릭
->Lambda 클릭
-> 앞에서 우리가 생성한 Lambda 함수를 클릭한다.
- 우 클릭 후, Download 클릭하면
정상적으로 Default Lambda 디렉토리가 import 된 것을 볼 수 있다.
- 이제 import한 Lambda 디렉토리로 접근한다.
cd demo-resizing
- Sharp 모듈을 설치한다.
npm i sharp
- index.js 파일 생성한다.
그리고 아래의 코드를 복붙한다.
'use strict';
const querystring = require('querystring'); // Don't install.
const AWS = require('aws-sdk'); // Don't install.
// http://sharp.pixelplumbing.com/en/stable/api-resize/
const Sharp = require('sharp');
const S3 = new AWS.S3({
region: 'ap-northeast-2' // 버킷을 생성한 리전 입력(여기선 서울)
});
const BUCKET = 'BUCKET_NAME' // Input your bucket
// Image types that can be handled by Sharp
const supportImageTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'tiff', 'jfif'];
exports.handler = async(event, context, callback) => {
const { request, response } = event.Records[0].cf;
console.log("request: ", request)
console.log("response: ", response)
// Parameters are w, h, f, q and indicate width, height, format and quality.
const { uri } = request;
const ObjectKey = decodeURIComponent(uri).substring(1);
const params = querystring.parse(request.querystring);
const { w, h, q, f } = params
/**
* ex) https://dilgv5hokpawv.cloudfront.net/dev/thumbnail.png?w=200&h=150&f=webp&q=90
* - ObjectKey: 'dev/thumbnail.png'
* - w: '200'
* - h: '150'
* - f: 'webp'
* - q: '90'
*/
// 크기 조절이 없는 경우 원본 반환.
if (!(w || h)) {
return callback(null, response);
}
const extension = uri.match(/\/?(.*)\.(.*)/)[2].toLowerCase();
console.log('extension : ', extension);
const width = parseInt(w, 10) || null;
const height = parseInt(h, 10) || null;
const quality = parseInt(q, 10) || 100; // Sharp는 이미지 포맷에 따라서 품질(quality)의 기본값이 다릅니다.
let format = (f || extension).toLowerCase();
let s3Object;
let resizedImage;
// 포맷 변환이 없는 GIF 포맷 요청은 원본 반환.
if (extension === 'gif' && !f) {
return callback(null, response);
}
// Init format.
format = format === 'jpg' ? 'jpeg' : format;
if (!supportImageTypes.some(type => type === extension )) {
responseHandler(
403,
'Forbidden',
'Unsupported image type', [{
key: 'Content-Type',
value: 'text/plain'
}],
);
return callback(null, response);
}
// Verify For AWS CloudWatch.
console.log(`parmas: ${JSON.stringify(params)}`); // Cannot convert object to primitive value.\
console.log('S3 Object key:', ObjectKey)
console.log('Bucket name : ', BUCKET);
try {
s3Object = await S3.getObject({
Bucket: BUCKET,
Key: ObjectKey
}).promise();
console.log('S3 Object:', s3Object);
}
catch (error) {
console.log('S3.getObject error : ', error);
responseHandler(
404,
'Not Found',
'OMG... The image does not exist.', [{ key: 'Content-Type', value: 'text/plain' }],
);
return callback(null, response);
}
try {
resizedImage = await Sharp(s3Object.Body)
.rotate()
.resize(width, height)
.toFormat(format, {
quality
})
.toBuffer();
}
catch (error) {
console.log('Sharp error : ', error);
responseHandler(
500,
'Internal Server Error',
'Fail to resize image.', [{
key: 'Content-Type',
value: 'text/plain'
}],
);
return callback(null, response);
}
// 응답 이미지 용량이 1MB 이상일 경우 원본 반환.
if (Buffer.byteLength(resizedImage, 'base64') >= 1048576) {
return callback(null, response);
}
responseHandler(
200,
'OK',
resizedImage.toString('base64'), [{
key: 'Content-Type',
value: `image/${format}`
}],
'base64'
);
/**
* @summary response 객체 수정을 위한 wrapping 함수
*/
function responseHandler(status, statusDescription, body, contentHeader, bodyEncoding) {
response.status = status;
response.statusDescription = statusDescription;
response.body = body;
response.headers['content-type'] = contentHeader;
if (bodyEncoding) {
response.bodyEncoding = bodyEncoding;
}
}
console.log('Success resizing image');
return callback(null, response);
};
위의 긴 코드를 간단히 요약하자면, CDN으로 들어온 URL 주소를 통해 Object Key와 w, h, q 쿼리 파라미터를 파싱한다. 만약 w, h 값이 없을 때는 리사이징이 필요없으니 함수를 종료한다. 그리고 이미지가 지원하는 format에 해당하는지 확인한다. 그리고 앞에서 파싱한 Object Key를 통해 S3 버킷에 접근하여 s3Object를 가져온다.
최종적으로 아래 코드를 통해 Sharp로 리사이징하고, 해당 Buffer를 base64로 인코딩하여 클라이언트에게 Response를 보내는 것이다.
resizedImage = Sharp(s3Object.Body).resize(width, height).toBuffer();
responseHandler(
200,
'OK',
resizedImage.toString('base64'), [{
key: 'Content-Type',
value: `image/${format}`
}],
'base64'
);
- 코드를 저장한 후, 우측 창에서 Upload Lambda 클릭 -> Directory 선택
가장 상위 디렉토리 선택 후 Open 클릭
Build 스테이지에서 No 선택 -> 마지막에 Yes 선택하여 코드 적용.
이렇게 하여 Lambda 함수에 이미지 리사이징 관련 코드를 업로드하였다. Lambda 콘솔에 가서 코드를 확인하면 바뀐 것을 확인 할 수 있다. 그런데 아직 배포할 수 없다. 왜냐하면 Lambda@Edge는 기본적으로 많은 AWS 서비스에 대한 접근을 하게되는데, 여기서 권한이 필요하기 때문이다.
IAM 권한 설정
Lambda@Edge는 CloudFront로 요청이 들어올 때 Trigger되고, S3 Origin에
접근하기 때문에 관련 권한을 가지고 있는 역할을 주어야한다.
- IAM 콘솔 이동 -> 역할 탭 -> 역할 만들기 클릭
- Lambda 서비스 클릭 -> 정책 생성 클릭하면 새 탭으로 넘어감.
- JSON 탭을 클릭 후 아래 코드를 복붙하여 위에서 언급한 정책을 준다.
{ "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor0", "Effect": "Allow", "Action": [ "iam:CreateServiceLinkedRole", "lambda:GetFunction", "lambda:EnableReplication", "cloudfront:UpdateDistribution", "s3:GetObject", "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogStreams" ], "Resource": "*" } ] }
- 이름은
resizing_policy
로 하고 정책을 생성한다. - 이제 다시 돌아와서 이
정책
을 사용하는역할
을 만들어야한다. - 방금 생성한 resizing_policy 정책을 선택하여
resizing_role
이라는 역할을 생성. - 생성 후, 생성한 resizing_role 역할을 클릭 ->
**신뢰 관계 편집**
클릭 -> 아래 코드 복붙한다. { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": [ "lambda.amazonaws.com", "edgelambda.amazonaws.com" ] }, "Action": "sts:AssumeRole" } ] }
만든 역할을 Lambda 함수에 주기
- Lambda 함수로 돌아와서
구성
->권한
->편집
클릭 - 기존 역할에서
resizing_role
선택
Lambda@Edge 배포
오른쪽 작업 탭 -> Lambda@Edge 배포 클릭.
위에서 미리 생성했던 CDN의 ARN을 선택한다. 또 중요한 것은 CloudFront 이벤트로서 오리진 응답
을 선택하자.
그 이유는 다음과 같다. 최초 요청시엔 CDN에 Cache miss가 날 텐데, 그럼 그때는 S3 Origin server로 요청이 갈 것이고 그 응답 객체를 CDN에 캐싱해 놓는다. 그리고 그 때에만 Lambda를 Trigger하여 이미지를 resizing 하고, 리사이징 된 이미지 버퍼도 CDN에 캐싱하는 것이다. 그렇기 때문에 Origin response 이벤트 발생 시에만 Lambda를 실행해야한다. (아래 사진 참조)
그럼 이제, 확인 해보자!
w, h, q 파라미터에 따라 정상적으로 리사이징이 되었다. 위 사진에 경우 500x500 사이즈일 때 10MB였던 이미지를 50x50 사이즈의 10KB 용량으로 줄이는 데 성공했다 !
정리
글이 좀 길어져서, 정리를 해보자면 다음과 같다.
CloudFront
가 S3로부터 Origin response
를 받을 때 Lambda 함수가 Trigger
되어 on-fly 상태에서 Serverless하게 sharp 모듈을 통한 Resizing
이 실행된다. 그리고 그 결괏값을 CDN에 캐싱
해놓고 추후 요청에서 **w, h, q**
파라미터가 같다면 Cache hit
을 통하여 클라이언트에게 응답하는 일련의 과정을 마쳤다.
성능 평가
부끄럽지만 리사이징 전 우리 서비스의 메인피드 이미지들이 모두 로딩되는데 걸리는 시간은 총 10초였다. Size를 보면 12MB 짜리 사진도 보인다. 평균적으로 500KB ~ 12MB 정도였다.
그리고, 아래 사진은 똑같은 사진 대상 리사이징 후의 네트워크 탭이다.
정말 엄청나지 않은가?
Size 범위는 200B ~ 20KB로 1000배 이상 용량이 줄었고, Time은 평균적으로 100ms 안으로 들어오게 되었다.
이렇게하여 이미지 최적화 편이 끝이 났다.
물론 이뿐만 아니라 더 많겠지만, Core한 Best-practice를 적용하여 100ms 안에 20개 가량의 이미지를 serving 하게되어 뿌듯하다.
엔지니어링은 끝이 없지만, 조금씩 개선해 나갈 때의 그 짜릿함 때문에 계속 공부하는 것 같다.
출처
https://aws.amazon.com/ko/lambda/edge/
https://bokyung.dev/2021/05/14/lambda-edge-resize/
https://heropy.blog/2019/07/21/resizing-images-cloudfrount-lambda/
https://lemontia.tistory.com/1003