안녕하세요. 팀 디스턴스의 프론트엔드 개발자 노주영입니다.
디스턴스 앱에서는 가까이 있는 이성과 1:1 익명채팅 기능을 제공하고 있는데요. 채팅방 내 이미지 전송 기능을 도입하기로 하면서 이미지 최적화에 대한 필요성이 대두되었습니다. 팀 디스턴스에서 여러가지 방법을 놓고 고민한 결과, 즉석으로 이미지를 리사이징할 수 있는 CDN을 구축하기로 했습니다.
이 포스트에서는 즉석 이미지 리사이징을 도입하게 된 배경과 구현 결과에 대해 이야기하고자 합니다.
(흔히 On-the-fly Image resize라는 이름으로 알려져 있는 방식입니다. 이 포스트에서는 On-the-fly 대신 ‘즉석’이라는 용어를 사용하겠습니다.)
팀 디스턴스가 여러 이미지 최적화 방식을 놓고 고민할 때 다음 두 가지를 놓고 고민했습니다.
첫번째 방식은 유저가 이미지를 업로드하는 시점에 미리 여러 사이즈로 리사이즈해서 S3에 저장하기 때문에 로딩이 빠릅니다. 하지만 그만큼 S3 용량을 더 차지하고, 유연한 사이즈 대응이 어렵습니다.
두번째 방식은 유저가 이미지를 업로드하고 나중에 이미지를 불러올 때 필요한 만큼의 사이즈로 즉석으로 줄인 후 캐싱하기 때문에 변화에 유연합니다. S3에도 원본 이미지만 저장하면 되기 때문에 용량 부담도 줄어듭니다. 하지만 이미지 크기를 즉석으로 줄이는 만큼, 처음 이미지를 불러오는 사람은 리사이징하는 시간만큼 로딩이 오래걸리는 단점이 있습니다.
저희 팀은 미래에 있을지도 모를 UI 디자인 변경이나 여러 모바일 디바이스의 해상도를 대응할 때를 고려하여 두번째 방식을 도입하기로 했습니다. 또한 S3에 원본 이미지만 저장하면 되기 때문에 비용 또한 절약할 수 있어 이 방식을 선택했습니다.
이미지 리사이징은 유저가 Cloudfront 주소로 원하는 사이즈와 함께 이미지를 요청하면 Lambda@edge가 트리거되어 동작합니다. (Lambda@edge는 Cloudfront에 의해 생성된 이벤트에 대해 응답할 수 있는 함수입니다.)
위 이미지를 보면 유저가 Cloudfront로 이미지를 요청할 때, 캐싱되어 있지 않다면 S3로 이미지를 요청합니다. 이때 3번 오리진 응답을 조작할 수 있습니다. Lambda@Edge를 이용하여 이미지를 리사이즈하여 응답의 Body로 설정하면 유저는 리사이즈된 이미지를 받아볼 수 있습니다.
단점은 최초로 이미지를 요청한 유저는 이미지 리사이즈 시간 때문에 이미지 로딩 시간이 느립니다. 하지만 처음 한 사람이 요청한 이후로는 해당 이미지가 캐싱되어 다른 모든 사람이 요청했을 때 빠른 로딩으로 이미지를 받아볼 수 있습니다. 이미지의 포맷과 크기를 적절히 설정한다면 모든 유저의 데이터 사용량과 저장 공간을 절약할 수 있습니다.
Lambda에 작성한 전체 코드입니다. Node.js용 이미지 프로세싱 라이브러리인 Sharp를 사용했습니다. 이렇게 작성한 후 Cloudfront의 오리진 요청을 트리거로 설정하고 Lambda@Edge 함수를 배포하면 사용할 수 있습니다.
"use strict";
import Sharp from "sharp";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
const S3 = new S3Client({
region: "S3 리전",
});
const BUCKET_NAME = "S3 버킷명";
const MIME_TYPE = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
webp: "image/webp",
tiff: "image/tiff",
jfif: "image/jpeg",
};
const MAX_FILE_SIZE = 1048576;
export const imageResize = async (event, context) => {
console.log("이미지 리사이즈를 시작");
const { request, response } = event.Records[0].cf;
const querystring = request.querystring;
console.log("Request", JSON.stringify(request));
console.log("Response", JSON.stringify(response));
if (!querystring) {
console.log("Querystring이 없습니다.");
return response;
}
if (response.status !== "200") {
console.log("응답 상태가 200이 아닙니다.");
return response;
}
const uri = decodeURIComponent(request.uri);
const originalContentType = response?.headers["content-type"][0]?.value;
const originalFileType = getOriginalType(originalContentType, uri);
if (!originalFileType || originalFileType === "gif") {
console.log("원본 파일이 gif이거나 확장자가 없습니다.");
return response;
}
const width = Number(getQuerystring(querystring, "w")) || null;
const height = Number(getQuerystring(querystring, "h")) || null;
const format = (() => {
const f = getQuerystring(querystring, "f")?.toLowerCase();
if (originalFileType === "heic") return "jpeg";
else return f;
})();
const quality = Number(getQuerystring(querystring, "q")) || null;
console.log({ width, height, format, quality });
const objectKey = uri.substring(1);
let s3Object;
let s3Uint8ArrayData;
try {
s3Object = await S3.send(
new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: objectKey,
})
);
s3Uint8ArrayData = await s3Object.Body.transformToByteArray();
console.log("S3 가져오기 성공");
} catch (error) {
console.log("S3 가져오기 실패");
console.log(`Bucket: ${BUCKET_NAME}, Path: ${objectKey}`);
console.log(`Error: ${error}`);
return response;
}
let resizedImage = null;
try {
resizedImage = await Sharp(s3Uint8ArrayData)
.rotate()
.resize({
width: width,
height: height,
})
.toFormat(format, {
quality: quality,
})
.toBuffer();
console.log("이미지 리사이징 성공");
} catch (error) {
console.log(`이미지 리사이징 실패`);
console.log(`Bucket: ${BUCKET_NAME}, Path: ${objectKey}`);
console.log(`Error: ${error}`);
return response;
}
const resizedImageByteLength = Buffer.byteLength(resizedImage, "base64");
console.log("resizedImageByteLength:", resizedImageByteLength);
if (resizedImageByteLength >= MAX_FILE_SIZE) {
console.log(
"리사이징한 이미지의 크기가 1MB를 초과했습니다. 원본을 반환합니다."
);
return response;
}
response.status = 200;
response.headers["content-type"] = [
{ key: "Content-Type", value: MIME_TYPE[format] },
];
response.body = resizedImage.toString("base64");
response.bodyEncoding = "base64";
console.log("이미지 리사이징 종료");
return response;
};
function getQuerystring(querystring, key) {
return new URLSearchParams("?" + querystring).get(key);
}
function getOriginalType(originalContentType, uri) {
if (Object.values(MIME_TYPE).includes(originalContentType)) {
return originalContentType.split("/")[1];
} else if (new RegExp(/(.*)\.(.*)/).test(uri)) {
const extension = uri.match(/(.*)\.(.*)/)[2]?.toLowerCase();
return extension === "jpg" ? "jpeg" : extension;
} else {
return null;
}
}
이 코드가 수행하는 기능은 다음과 같습니다.
그래서 이미지를 요청할 때 주소 뒤에 적절한 Query String을 붙여서 요청하면 원하는 사이즈의 이미지를 받을 수 있습니다.
(예: CDN주소.com/image1.png?w=1000&h=500&f=webp&q=75)
저희 S3에 있는 이미지들이 파일명에 확장자가 없고 메타데이터 상에만 이미지 타입들이 저장되어 있습니다. 그래서 원본 이미지의 format을 평가할 때 파일명의 확장자보다 S3 메타데이터에 있는 MIME 타입을 우선으로 판단하는 로직을 추가하였습니다. 인터넷에 예제 코드들이 많이 있으니 참고하시면 좋을 것 같습니다.
현재 저희 채팅방 UI는 아래 스크린샷과 같습니다.
Layout shifting을 방지하기 위해 채팅 이미지에 200 * 200px의 고정 사이즈를 사용하고 있는데요. 테스트를 위해 28개의 임의의 이미지를 사용하여 적용하기 전과 후를 비교해서 보여드리겠습니다.
우선 이미지 리사이징을 적용하기 전입니다. 채팅 이미지 박스의 크기를 고려하지 않은 원본 이미지가 오고 있는 모습인데요. 채팅방의 모든 이미지를 불러오기 위해 59.25 MB를 사용했습니다.
다음은 이미지 리사이징을 적용한 후입니다. 채팅방 이미지 박스 크기에 맞춰 600px 너비로 리사이징했습니다(HiDPi 해상도 고려). 그리고 파일 크기 측면에서 효율적인 webp 포맷으로 변환하고 이미지 퀄리티도 75%로 낮춘 결과, 채팅방의 모든 이미지를 불러오기 위해 1.04 MB를 사용했습니다.
이 테스트 환경에서는 전체 이미지 용량을 59.25 MB → 1.04 MB로 크게 낮출 수 있었습니다.
이미지 박스 사이즈에 맞게 리사이징했기 때문에 사용자 경험을 떨어뜨리지 않으면서도 원본 대비 1~2%의 용량으로 유저 입장에서 동일한 결과를 낼 수 있도록 구현했습니다.
쿠팡이나 인스타그램 등 이미지가 주를 이루는 웹사이트들은 굉장히 짧은 로딩만으로 수많은 이미지를 보여줍니다. 가끔은 아예 로딩이 없이 이미지가 바로 나타날 때도 있습니다. 평소에는 별 느낌이 없다가 프론트엔드 개발 공부를 시작하고 보니 이렇게 빠른 이미지 로딩 속도는 어떻게 구현했는지 궁금했습니다.
이미지 최적화 관련 자료를 조사하다가 여러가지 최적화 기법들을 접하면서 그 궁금함이 해소된 것 같습니다. 이번 포스트에서 이야기한 즉석 이미지 리사이징 CDN도 그 중 하나입니다.
디스턴스 유저가 쿠팡이나 인스타그램만큼 좋은 사용자 경험을 얻을 수 있도록 Pre-fetching이나 Lazy loading 등의 기법을 차근차근 적용해 나갈 예정입니다.
다음 포스팅은 채팅방에서 유저가 원본 이미지를 불러올 때의 사용자 경험 개선에 대한 내용으로 돌아오겠습니다.
AWS Lambda@Edge에서 실시간 이미지 리사이즈 & WebP 형식으로 변환
안녕하세요, 당근마켓에서 백엔드 서버 개발 인턴으로 근무하고 있는 Marco입니다. 저는 이번에 당근마켓 서비스의 썸네일 생성 방식을 On-The-Fly 이미지 리사이징으로 새롭게 구현하였습니다. 이
medium.com
Resizing Images with Amazon CloudFront & Lambda@Edge | AWS CDN Blog | Amazon Web Services
Do you have lots of images that need to be modified before delivery? No problem with Amazon CloudFront and Lambda@Edge. Read more on how you can use our services to modify image dimensions, apply watermarks, or optimize formats based on browser support all
aws.amazon.com
슬라이드 업 메뉴 구현하기 (with React) (0) | 2024.08.09 |
---|---|
토스트 메세지 전역 컴포넌트로 관리하기 (0) | 2024.08.02 |
리액트에서 전역 상태로 모달 관리하기 (0) | 2024.07.30 |