1. 개요
최근 진행하는 그라밋 프로젝트에서 이미지 로딩 시간을 개선하기 위해 AWS Lambda를 통해 이미지를 WebP로 변환하는 작업을 했습니다.

S3 버킷에 올라가는 이미지라면 자동으로 WebP 변환이 되어 이미지 용량을 크게 개선해주어 문제가 없는 줄 알았으나... 프론트에서 WebP 변환된 이미지를 바로 불러오지 못하는 이슈가 있었습니다.
저희 서비스에서 실험자 역할의 회원이 실험 공고를 등록할 때, 이미지도 같이 업로드할 수 있는데 실험 공고 등록 완료 후, 회원이 등록한 실험 공고 상세 페이지로 바로 이동합니다. 이때, 원본 이미지를 업로드하고 이미지 변환 과정을 거치는데 변환 과정에서 시간이 걸려 회원이 공고를 등록하자마자 이동하는 공고 상세 페이지에서 이미지를 제대로 보여주지 못하는 문제가 발생했습니다.
프론트 단에서도 이미지를 주기적으로 폴링하는 방식으로 불러오고 있지만, 이미지가 느리게 로딩되어 사용자 경험이 떨어질 거라 판단해서 서버에서도 최적화가 필요하다 생각했습니다.
2. Lambda의 메모리 & CPU 리소스 조정
WebP 변환 람다 함수가 동작되면 CloudWatch로 로그를 기록합니다. 먼저, 약 400MB 이미지를 S3 버킷에 올렸을 때, 어떤 부분을 최적화할 수 있는지 살펴보겠습니다.

빨간 박스를 보면 WebP 변환 및 S3 업로드까지 걸리는 시간은 약 2초입니다. 이미지 업로드를 한 직후에 조회하는 상황이라고 감안해도 2초라는 지연 시간은 사용자 경험은 떨어질 수 있습니다.
현재, 람다의 메모리를 기본으로 제공되는 128MB를 사용하고 있는데 람다는 메모리 크기에 따라 CPU 성능이 결정됩니다. 메모리를 늘리면 CPU도 증가하므로 당연히 sharp를 통한 이미지 변환 속도가 빨라집니다.

물론, 메모리를 1024MB 이상으로 할당하면 그만큼 빨라지겠지만 비용적인 측면을 생각해서 위와 같이 512MB로 설정했습니다.
이렇게 간단히 스펙업을 한 후, 아까랑 같이 400MB 이미지를 업로드했을 때 로그는 다음과 같습니다.

WebP 변환 및 S3 업로드까지 걸리는 시간 평균 500ms로 메모리 사이즈가 128MB일 때보다 성능이 약 4배 개선된 것을 확인할 수 있습니다.
3. sharp 라이브러리를 Lambda Layer로 배포
이번 이슈를 해결하기 위해 Lambda를 최적화하기 위해 Cold start time이라는 개념을 처음 접했습니다. 콜드 스타트는 Lambda가 오랫동안 사용되지 않다가 처음 실행될 때 발생하는 지연 시간을 의미합니다.
AWS Lambda의 동작 방식을 살펴보면
1. Lambda 함수가 호출되면 AWS가 실행 환경을 준비함
2. 코드를 실행할 컨테이너를 생성하고, 코드 및 라이브러리를 로드함
3. Lambda 실행 후 컨테이너는 일정 시간 동안 유지됨 -> Warm 상태
4. 일정 시간이 지나면 컨테이너가 삭제됨 -> 이후 다시 호출되면 Cold start 발생!
Lambda 실행 최적화를 위해 함수 실행 시간뿐만 아니라 콜드 스타트 시간을 줄이는 것도 중요하다고 판단했습니다.
기존에는 Lambda 내부에 sharp를 설치하여 실행하고 있지만, 이 방식은 간편하다는 장점이 있는 반면, Lambda가 실행될 때마다 sharp가 압축된 Lambda 코드에서 해제되고 로드되는 과정에서 불필요한 지연 시간이 발생합니다. 이로 인해 콜드 스타트 시간이 길어지고, 전체 실행 속도가 느려지는 문제가 있습니다.
콜드 스타트를 최소화하면 Lambda의 전체 실행 속도가 향상됩니다. AWS는 sharp와 같은 별도 라이브러리를 실행할 때마다 다시 압축 해제하지 않고 미리 로드할 수 있도록 Lambda Layer 기능을 제공합니다. 즉, sharp를 Lambda Layer로 배포하면, 실행 환경이 시작될 때 이미 sharp가 로드된 상태이므로, 각 실행마다 불필요한 압축 해제 과정 없이 바로 실행할 수 있어 초기 실행 속도가 크게 향상됩니다.
const { S3Client, GetObjectCommand, PutObjectCommand } = require("@aws-sdk/client-s3");
const { Readable } = require("stream");
// Node.js에서 sharp 라이브러리를 불러옴
const sharp = require("sharp");
const s3 = new S3Client({ region: "ap-northeast-2" });
exports.handler = async (event) => {
const bucket = event.Records[0].s3.bucket.name;
const key = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, " "));
// 로그 추가: 어떤 파일이 처리되고 있는지 확인
console.log(`Processing file: ${key}`);
// 무한 업로드 방지
if (key.startsWith("resized-images/")) {
console.log("Skipping resized image to avoid infinite loop.");
return;
}
const fileName = key.split("/").pop();
const folderName = key.split("/").slice(-2, -1).join("");
const baseFileName = fileName.split(".").slice(0, -1).join(".");
const dstKey = `resized-images/${folderName}/${baseFileName}.webp`;
try {
const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
var stream = response.Body;
if (!(stream instanceof Readable)) {
console.log("Unknown object stream type");
return;
}
const content_buffer = Buffer.concat(await stream.toArray());
const output = await sharp(content_buffer)
.webp({ lossless: false })
.toBuffer();
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: dstKey,
Body: output,
ContentType: "image/webp",
})
);
console.log(`Successfully resized and uploaded: ${dstKey}`);
} catch (error) {
console.error("Error processing file", error);
}
};
위의 코드는 현재 그라밋에서 사용하고 있는 Lambda 코드입니다. sharp 라이브러리를 통해 이미지를 webp로 변환합니다. 기존에는 Lambda 패키지 내부에 sharp 라이브러리를 같이 설치하기 때문에 const sharp = require("sharp");를 통해 라이브러리를 불러옵니다.
하지만 콜드 스타트 타임을 줄이기 위해서는 패키지 내부에서 불러오면 안되고 Layer를 등록해서 sharp를 써야하니 이 부분을 개선해야겠죠?

Lambda의 계층 탭에 가면 계층을 생성할 수 있습니다. 이때 sharp 패키지를 zip 파일로 업로드해야 하는데 https://github.com/pH200/sharp-layer 에서 다운받아서 업로드하면 쉽습니다! 👍
호환 아키텍처와 호환 런타임은 각자 람다 함수의 설정에 맞게 선택하면 됩니다.



이렇게 추가된 Layer를 sharp 라이브러리가 필요한 람다 함수 계층에 추가해주면 됩니다! 그러면 함수 개요 칸에 Layer가 하나 추가된 걸 확인할 수 있네요~
const { S3Client, GetObjectCommand, PutObjectCommand } = require("@aws-sdk/client-s3");
const { Readable } = require("stream");
// sharp 라이브러리를 Layer에서 불러옴
const sharp = require("/opt/nodejs/node_modules/sharp");
const s3 = new S3Client({ region: "ap-northeast-2" });
exports.handler = async (event) => {
if (event.source === "aws.events") {
console.log("Warm-up event triggered, skipping execution.");
return;
}
// ...
}
};
이제 코드에서도 패키지 내부의 라이브러리를 사용하는 것이 아니라 AWS에 올라간 Layer를 사용하도록 수정하면 됩니다!
4. Warm up
AWS Lambda는 요청이 없을 경우 실행 환경(컨테이너)을 유지하지 않고, 5분 후 자동으로 종료됩니다. 이후 Lambda가 호출되면 새로운 컨테이너를 띄워야 하므로, 콜드 스타트가 발생하여 실행 속도가 느려지는 문제가 발생합니다.
이러한 Lambda의 동작 방식을 활용하면, 강제로 Lambda를 5분마다 실행하여 항상 Warm 상태를 유지할 수 있습니다. 즉, Lambda가 종료되기 전에 일정 주기로 호출해주면 매번 새 컨테이너를 생성할 필요가 없으므로 콜드 스타트를 방지할 수 있습니다.
AWS Lambda는 온디맨드 과금 정책을 적용하므로, 요청이 많을수록 비용이 증가할 수 있지만, 현재 프리 티어 계정을 사용 중이므로 월 100만 건까지 무료로 제공됩니다. 따라서 5분마다 실행하는 전략을 사용해도 추가 비용 걱정 없이 성능을 최적화할 수 있습니다.

5분마다 주기적으로 Lambda 함수를 실행시켜서 Warm up을 진행하기 위해 EventBridge 서비스에서 규칙을 생성해봅시다!


위와 같이 5분마다 실행되도록 하고 webp 변환을 수행하는 ImageWebpFunction 람다 함수를 대상으로 지정하면 됩니다!
const { S3Client, GetObjectCommand, PutObjectCommand } = require("@aws-sdk/client-s3");
const { Readable } = require("stream");
const sharp = require("/opt/nodejs/node_modules/sharp"); // Layer 사용
const s3 = new S3Client({ region: "ap-northeast-2" });
exports.handler = async (event) => {
// warm-up event일 경우 바로 리턴
if (event.source === "aws.events") {
console.log("Warm-up event triggered, skipping execution.");
return;
}
console.log("Processing image...");
const bucket = event.Records[0].s3.bucket.name;
const key = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, " "));
// 로그 추가: 어떤 파일이 처리되고 있는지 확인
console.log(`Processing file: ${key}`);
// 무한 업로드 방지
if (key.startsWith("resized-images/")) {
console.log("Skipping resized image to avoid infinite loop.");
return;
}
const fileName = key.split("/").pop();
const folderName = key.split("/").slice(-2, -1).join("");
const baseFileName = fileName.split(".").slice(0, -1).join(".");
const dstKey = `resized-images/${folderName}/${baseFileName}.webp`;
try {
const response = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
var stream = response.Body;
if (!(stream instanceof Readable)) {
console.log("Unknown object stream type");
return;
}
const content_buffer = Buffer.concat(await stream.toArray());
const output = await sharp(content_buffer)
.webp({ lossless: false })
.toBuffer();
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: dstKey,
Body: output,
ContentType: "image/webp",
})
);
console.log(`Successfully resized and uploaded: ${dstKey}`);
} catch (error) {
console.error("Error processing file", error);
}
};
람다 함수 코드에 aws에서 보낸 이벤트라면 바로 리턴하는 조건문을 추가해줍니다.

이거까지 수행하게 되면 5분 주기로 람다 함수를 실행시키는 이벤트가 트리거에 추가됐음을 확인할 수 있습니다.

Cloudwatch에서도 해당 이벤트가 5분 주기로 발생함을 확인할 수 있어요!
4. 마무리
여기까지 마쳤다면
1. Lambda 메모리를 늘려 함수 실행 속도를 개선하고
2. 이미지 변환을 돕는 sharp 라이브러리를 Lambda Layer로 배포하여 cold time을 줄이며
3. EventBridge를 통해 5분마다 람다 함수를 실행시켜 warm-up을 진행합니다
이 과정을 진행하며 로그들을 쭉 살펴본 결과 1번에서 가장 뚜렷하게 개선됨을 확인할 수 있고 2, 3번은 개선됨이 보이기는 하지만 없어도 실제 사용자가 불편함을 느낄 정도는 아니라 생각합니다. 각자의 상황에 맞게 커스텀해서 적용하면 좋을 거 같습니다!
가끔은 새로운 기술을 접할 때 "어떻게 구현하지?"에만 집중하게 되는 경우가 많습니다. 하지만 최근 이슈를 해결하면서 다시 한번 "왜 이 기술이 필요한가?", "이 기술이 등장한 배경은 무엇인가?" 같은 질문이 더 중요하다는 걸 다시 한 번 깨닫게 되었네요. 🤔
'백엔드 개발일지' 카테고리의 다른 글
[Spring] SpringBoot에서 AOP와 MDC를 활용한 로깅 시스템 구축하기 (feat. 민감 정보 마스킹) (0) | 2025.02.25 |
---|---|
[Architecture] 그라밋 프로젝트, 클린 아키텍처 입히기 (0) | 2025.02.23 |
[AWS] AWS Lambda를 이용해 이미지 리사이징 & WebP 변환 (0) | 2025.01.13 |
[Spring] FeignClient로 POST 요청 시 발생하는 Length Required(411) 에러 해결 방법 (2) | 2025.01.10 |
쉽게 알아보는 클린 아키텍처 (Clean Architecture) (2) | 2024.11.27 |