Lambda@Edge를 사용한 이미지 리사이징 적용기 본문
커뮤니티 사이트를 만들던 중, 프론트 페이지 로딩 속도가 너무 느린 문제가 발생했습니다.
홈 화면은 아래 처럼 이미지들이 많이 필요한데, 모든 이미지는 S3에 원본 사이즈 (1280p) png로 저장되어있습니다.
AWS CloudFront를 적용하여 이미지 캐싱을 추가 하였지만, 여전히 속도 개선이 이루어지지 않았습니다.
프론트는 next를 사용하여 Next/Image를 사용하고 있고, Sharp를 통해 최적화를 하고 있습니다. 하지만, 원본 이미지가 너무 크기에, 한번에 많은 사진을 프론트 서버에서 리사이징 하기에 부담이 크고, 이로 인해 속도가 느리다는 판단을 하였습니다.
프론트 서버에서 리사이징에 대한 부담을 적게 하기 위해, 뒷단에서 미리 필요한 사이즈의 사진을 주려면 어떻게 해야 할까요?
원본 사진을 썸네일 사이즈, 알람에 들어갈 매우 작은 사이즈, 컨텐츠에서 보일 원본 사이즈 등등.. 다양한 사이즈로 제공하기 위해서는 다양한 방법이 있습니다.
가장 쉬운 방법은 원본 이미지를 올렸을 때, 백그라운드 작업으로 미리 다양한 사이즈의 이미지로 프로세싱하여 S3에 저장하는 방법이 있습니다.
사진 업로드 → 백 서버 → 이미지 프로세싱 → N개의 사진 → s3 적재
라던가,
사진 업로드 → s3 적재 → 람다를 통한 이미지 프로세싱 → N개의 사진 생성 → 적재
등의 방법이 있을것 입니다.
- 단점
- UI 개편(이미지 사이즈 변경) 시 지금까지 생성한 모든 이미지를 재생성 해야 합니다.
- M 개의 이미지를 올릴 경우, M*N개의 이미지가 생기므로, 저장공간이 많이 필요함
이런 이유로, 많이 사용하는 방법이 온디맨드 리사이징 입니다.
클라이언트가 이미지를 요청하는 시점에 실시간으로 생성 하는 방법입니다.
사진 업로드 → S3 적재 -> 클라이언트가 요청시 그때 그때 요청한 사이즈로 리사이징한 썸네일을 생성해 전달
- 단점
- 썸네일 이미지 크기 변경시 한번에 요청이 밀려서 이미지 변환에 실패할 수 있음
두번째 방법을 효율적으로 적용하기 위해, 다양한 기법들이 존재 합니다. 그중 대표적인 on-the-fly( 날아가는 중에!) 기법을 적용해보겠습니다.
On-The-Fly 이미지 리사이징 도입
클라이언트에서 썸네일을 요청할 때 실시간으로 이미지를 리사이징하여 제공합니다.
이미지 업로드 후 처음 이미지를 요청하는 클라이언트는 리사이징하는 시간을 기다려야 하지만, 그 후로는 CDN에 이미지가 캐싱되어 다음 요청부터는 곧바로 캐싱된 썸네일을 제공할 수 있습니다.
때문에 사용자 경험을 크게 해치지 않고, S3에 모든 사진을 적재하지 않아 용량을 아낄 수 있습니다.
Lambda@Edge?
CDN을 구성하면, 각 리전에 CDN이 하나씩 생기고, 리전의 고객들은 CDN의 캐싱된 정보를 가져올 수 있습니다. 물리적 거리가 좁아져 빠른 응답 속도를 기대할 수 있죠.
만약, 각 리전의 CDN에 Lambda를 통해 어떤 작업을 하고 싶다면, 각 CDN 마다 람다를 붙여야 할까요? 얼마나 귀찮은 작업입니까! DevOps에서 제 목을 틀어쥘지도 몰라요!
다행히 AWS에서 Lambda를 모든 리전에 복제하고, 각 리전별 람다 트리거를 걸 수 있는 방법이 있습니다.
이것이 바로 Lambda@Edge 입니다.
(Lambda at Edge : 엣지에 존재하는 람다!)
한번 작성해서 글로벌 하게 적용해라! 라는 말에 잘 어울리는 서비스명 입니다.

Lambda@Edge란?
Lambda 함수를 복제하여 여러 CDN(Cloudfront)의 Edge location에 전역 배포되고 클라이언트는 어떤 리전에서 요청하던지 각 리전의 Edge location에 배포된 Lambda 함수를 거쳐 응답받게 됩니다.
Lambda@Edge 함수는 콘텐츠를 요청하는 사용자와 가장 가까운 엣지 위치에서 실행되기 때문에 지연 시간을 최소화할 수 있습니다.
AWS Lambda 와 차이점
- 중앙 집중식 컴퓨팅 서비스로, 사용자가 선택한 AWS 리전의 서버에서 실행
- 선택한 리전에서 실행되니 리전에서 멀리 떨어져있으면 지연 시간 발생
- 하지만, 다양한 AWS 서비스 이벤트에 의해 트리거 될 수 있음
Lambda@Edge 적용법
Lambda@Edge 생성
- Lambda@Edge는 us-east-1(버지니아 북부 리전) 에만 만들 수 있습니다.
여기에 만들어도, 배포하면 각 CDN에 람다가 적용되니 걱정마요!
자습서: 기본 Lambda@Edge 함수 생성 - Amazon CloudFront
배포 ID를 기록해 둡니다. 이 자습서 후반부에서 함수의 CloudFront 트리거를 추가할 때 드롭다운 목록에서 배포 ID(예: E653W22221KDDL)를 선택해야 합니다.
docs.aws.amazon.com
리사이징 코드는 아래에서 따로 설명 하겠습니다.
CloudFront - Lambda@Edge 연결
Lambda@Edge를 사용하여 엣지에서 사용자 지정 - Amazon CloudFront
이 페이지에 작업이 필요하다는 점을 알려 주셔서 감사합니다. 실망시켜 드려 죄송합니다. 잠깐 시간을 내어 설명서를 향상시킬 수 있는 방법에 대해 말씀해 주십시오.
docs.aws.amazon.com
Cloud Front에서 Lambda@Edge를 적용할 path - 동작 편집 으로 들어가면, 함수를 연결할 수 있습니다.
여기에 Lambda@Edge를 연결하면, CF가 엣지로케이션에서 요청/응답을 가로챕니다. 그리고, 다음과 같은 이벤트가 발생할 때, 람다를 실행할 수 있습니다.
- 사용자로부터 요청을 받은 경우(Viewer request)
- 오리진에 요청을 전달하기 전(Origin request)
- 오리진으로부터 응답을 받은 시점(Origin response) - 선택
- 사용자에게 응답을 반환하기 전(Viewer response)
Origin Response는 CDN에 연결된 Origin(S3)이 응답(원본 이미지, 헤더를 비롯한 Event 객체)을 반환한 후에 동작하는 이벤트입니다.

예를 들어보겠습니다! 클라이언트가 홈 리스트에 필요한 썸네일 이미지를 요청합니다. 캐싱되어 있지 않다면 CloudFront는 S3로 이미지를 요청하고 S3는 해당하는 이미지로 응답합니다.
이 때, 람다를 통해 Origin Response를 조작할 수 있습니다. 요청받은 이미지가 S3에 존재한다면 원본 이미지를 조작하여 응답의 Body로 설정할 수 있습니다.
그리고 이를 CloudFront에 전달하면, 최종적으로 클라이언트에 썸네일을 제공할 수 있습니다.
이렇게 제공된 이미지는 CF에 캐싱되고, 동일한 요청이 온 경우 캐싱된 이미지를 바로 전달합니다.
또한, Cloud Front 에서 캐시 키로 쿼리 스트링을 추가 해야 합니다.
쿼리 문자열을 ALL로 설정해도 문제가 없지만, 람다 코드상에서 사용할 쿼리 파람만 넣어야겠죠?
문자열을 추가하지 않는다면, 자동으로 쿼리 스트링은… 다 날라가버립니다.
람다 코드에서 쿼리 스트링을 사용해 클라이언트가 필요한 사이즈를 알아야 하므로, 필요한 쿼리 스트링을 추가해주세요.

Lambda@Edge의 제한사항
기본적으로 Lambda와 같은 방식으로 동작하지만, Lambda@Edge만의 제한 사항이 있으므로 이에 유의해야 합니다.
- Origin Response를 트리거로 하는 Lambda에는 Event 객체가 전달됩니다. 이 때 Body에 담긴 원본 이미지를 사용할 수 없습니다. 따라서 이미지의 사이즈를 조절하기 위해 S3로 다시 객체를 요청해야 합니다. 또한 Accept 헤더가 Event 객체에 담겨있지 않습니다. 따라서 Lambda 함수에서 WebP 지원 여부를 알 수 없습니다. 헤더를 캐싱하도록 CloudFront 설정을 하면 Accept 헤더를 사용할 수 있지만, 모든 Accept에 대해 개별적으로 캐싱하도록 동작하기 때문에 사용하기에 알맞지 않다고 판단했습니다. 서버에서 WebP 지원여부를 확인하여 쿼리스트링의 파라미터로 추가하도록 구현했습니다.
- 만약 응답의 Body를 조작한다면 그 크기는 1MB 이하여야 합니다. 따라서 이미지의 크기를 한 번 조절했을 때 1MB를 넘는 경우를 대처해야 합니다. 응답을 조작하지 않는다면 1MB 이상의 응답이 가능합니다.
- CloudFront는 쿼리 문자열 파라미터를 기반으로 컨텐츠를 캐싱합니다. 이 때, 파라미터의 순서, 대소문자 등에 따라 다르게 캐싱됩니다. 리사이즈할 정보를 쿼리 문자열에 담아 리사이징 하도록 구현한다면 파라미터의 순서가 경우에 따라 다르지 않도록 해야합니다.
- Node.js만 사용 가능합니다.
- us-east-1 리전에만 Lambda@Edge를 배포할 수 있습니다. 결과적으로 모든 리전에 함수가 복사되기 때문에 큰 문제가 되지는 않습니다.
Lambda@Edge 코드
'use strict';
import 'dotenv/config'
import Sharp from 'sharp'; // http://sharp.pixelplumbing.com/en/stable/api-resize/
import Querystring from 'querystring';
import AwsS3 from './core/aws/AwsS3.js'
// Sharp 에서 지원하는 이미지 타입 목록
const SUPPORT_IMAGE_TYPES = ['jpg', 'jpeg', 'png', 'webp', 'svg'];
const DEFAULT_ENCODING = 'base64';
const MB = 1 * 1024 * 1024; // 1MB
export const handler = async (event, context, callback) => {
const {request, response} = event.Records[0].cf;
/**
* ex) https://service-static.tistory.com/test/code/picture/WtMnW3bzBn3B/f2bf78d1-c6a4-4b48-868d-13b18908bd22?w=358&h=244&f=webp&q=50
* - objectKey: '/test/code/picture/WtMnW3bzBn3B/f2bf78d1-c6a4-4b48-868d-13b18908bd22'
* - w: '200' (width)
* - h: '150' (height)
* - f: 'webp' (format)
* - q: '90' (quality)
*/
const {uri} = request;
const objectKey = decodeURIComponent(uri).substring(1);
const params = Querystring.parse(request.querystring);
const {w, h, q, f} = params
// AWS CloudWatch 로깅
console.log(`params: ${JSON.stringify(params)}`);
console.log('S3 Object key:', objectKey)
// webp 포맷 default 적용
let format = f == null ? 'webp' : f.toLowerCase();
format = format === 'jpg' ? 'jpeg' : format;
// format 파라미터를 체크
if (format && !SUPPORT_IMAGE_TYPES.some(type => type === format)) {
console.warn(`Unsupported image format: ${format}`);
return callback(null, response);
}
let s3Object;
try {
s3Object = await AwsS3.getObject(objectKey);
// 리사이징 옵션 없이 2MB 넘는 이미지는 원본 반환
if (!(w || h) && s3Object.ContentLength >= 2 * MB) {
return callback(null, response);
}
} catch (error) {
console.error(`The image does not exist : ${error.message}`)
responseHandler(
404,
'Not Found',
'The image does not exist.'
);
return callback(null, response);
}
const width = parseInt(w, 10) || null;
const height = parseInt(h, 10) || null;
const quality = parseInt(q, 10) || 100; // Sharp 는 이미지 포맷에 따라서 품질(quality)의 기본값이 다릅니다.
let resizedImage;
try {
resizedImage = await Sharp(s3Object.Body)
.resize(width, height)
.withMetadata()
.toFormat(format, {
quality
})
.toBuffer();
} catch (error) {
console.error(`Fail to resize image : ${error.message}`)
return callback(null, response);
}
// 응답 이미지 용량이 1MB 이상일 경우 원본 반환.
if (Buffer.byteLength(resizedImage, DEFAULT_ENCODING) >= MB) {
console.warn('Response image size larger than 1MB');
return callback(null, response);
}
responseHandler(
200,
'OK',
resizedImage.toString(DEFAULT_ENCODING),
[{
key: 'Content-Type',
value: `image/${format}`
}],
DEFAULT_ENCODING
);
/**
* @summary response 객체 수정을 위한 wrapping 함수
*/
function responseHandler(status, statusDescription, body, contentHeader, bodyEncoding) {
response.status = status;
response.statusDescription = statusDescription;
response.body = body;
if (!response.headers) {
response.headers = {};
}
response.headers['content-type'] = contentHeader;
if (bodyEncoding) {
response.bodyEncoding = bodyEncoding;
}
}
console.log('Success resizing image');
return callback(null, response);
};
import AWS from "aws-sdk";
import {Properties} from '../properties/AwsProperties.js'
const S3 = new AWS.S3({
signatureVersion: 'v4',
region: Properties.REGION
});
async function getObject(objectKey) {
return await S3.getObject({
Bucket: Properties.BUCKET,
Key: objectKey
}).promise();
}
export default {getObject}
Properties 에서 환경 변수를 받습니다!
람다 테스트
AWS 람다는 테스트를 해볼 수 있습니다. 어차피 트리거 되는 순간은 origin(S3) 에서 응답이 올 때 이므로 이 방법을 제외하면 딱히 알아볼 수 있는 방법도 없습니다.
오리진에서 오는 응답에 대한 예제 입니다.
예제를 따라 다음과 같이 이벤트 json을 구성했습니다.
{
"Records": [
{
"cf": {
"config": {
"distributionId": "EXAMPLE"
},
"request": {
"uri": "/test/code/picture/WtMnW3bzBn3B%2Fc9e956cc-1a70-463d-94de-1fe905d0be84",
"method": "GET",
"querystring": "w=200&h=100&f=webp&q=90",
"headers": {
"host": [
{
"key": "Host",
"value": "service-static.tistory.com"
}
]
}
},
"response": {
"status": "200",
"statusDescription": "OK"
}
}
}
]
}
테스트를 해보면,
이렇게 200이 떨어진다면, 끝!
적용 결과
실험을 위해 <img> 태그를 사용했습니다.
Next 에서 제공하는 <Image> 태그를 사용하는 경우, 내부적으로 최적화가 따로 이루어 지기 때문에, CDN 확인이 어렵습니다.
<img> 태그를 사용한 이미지 다운로드가 일어났을 때, X-Cache에 Hit from cloudfront 가 있다면 CDN에서 캐싱된 이미지라는 뜻 입니다.
Next/Image가 제공하는 다양한 것을 사용하면서, CloudFront에서 온 사진을 바로 사용하고 싶다면, unoptimize={true} 를 추가하는 것도 고려할 수 있습니다.
Lambda@Edge가 없는 경우

CDN 에서 원본을 가져왔습니다. 117KB 정도의 크기 입니다.
Lambda@Edge 적용

이미지 요청시에 쿼리스트링을 통해 크기와 받을 이미지 형식, quality를 지정했습니다.
이미 전에 똑같은 쿼리스트링으로 캐싱된 이미지가 있어 Hit From cloudFront가 떳고, 사이즈는 3.9KB로 확연하게 줄은 것을 볼 수 있습니다.
CF의 캐싱은 쿼리 스트링 기준으로 됩니다. 즉 쿼리 스트링의 순서가 바뀌면, 다시 람다가 동작합니다. 같은 형식의 경우 항상 쿼리스 트링 순서를 동일하게 맞춰야 합니다.


'Backend' 카테고리의 다른 글
Kafka.00 카프카 개요 (0) | 2025.03.06 |
---|---|
Intellij 에서 Springboot 시작하기 (0) | 2023.01.15 |