해당 포스팅은 구현의 상세 내용보다 이미지 최적화를 적용하면서 겪은 경험 위주의 내용으로 이루어져 있습니다.
PC 디바이스의 메인 페이지에서 사용되는 포지션 썸네일의 경우 4:3 비율의 280 x 210px
컴포넌트 이미지 크기를 가지고 있습니다.
하지만, 사용되는 이미지의 원본 크기를 조사해 보니 가장 큰 사이즈의 이미지는 2944 x 3312px
이어서 이처럼 불필요하게 큰 이미지를 저장하고 조회하고 있는 상황입니다.
실제 메인페이지 접근 시 최대 11MB 크기의 원본 이미지를 로드하고 있어 이미지 하나에 1.28s의 응답 시간이 발생하고 있었습니다.
이렇게 매번 불필요하게 큰 이미지를 조회하게 되면 웹페이지의 성능 이슈는 없을까요?
Lighthouse 사용하여 성능을 측정해보았습니다.
Chrome의 Lighthouse
툴을 이용하여 웹 페이지 성능 측정을 하였습니다.
Lighthouse란?
Lighthouse가 제공한 성능 저하 개선 방법
PNG 또는 JPEG 대신 차세대 포맷인 WebP 과 AVIF 포맷을 이용할 것
HTTP/2 프로토콜을 사용할 것
이미지를 최적화 할 것
막대한 네트워크 페이로드 피하기
효율적인 캐시 정책으로 정적 자산 제공
위 내용 기반으로 확인했을때, 웹페이지 성능이 크게 저하된 상태로 판단했고 영향도가 가장 높은 이미지 관련한 최적화를 진행하게 되었습니다.
사전 테스트를 통해 이미지 파일 용량이 크게 개선이 된 것으로 확인되었으며,
썸네일 등 리사이징 된 이미지를 여럿 노출시키는 페이지에서 네트워크 비용 및 로드 시간 단축 효과를 기대할 수 있었습니다.
2944x3312
원본 이미지 cdn 통해 조회 시 → 원본 이미지 (최적화x)
height:360
리사이징 후 조회 시 → 리사이징 이미지
height:360 + quality:70%
리사이징 및 품질 조정 후 조회 시 → 리사이징 + 품질조정 이미지
height:360 + quality:70% + WebP
리사이징 및 품질조정 및 WebP 변환 후 조회 시 → 리사이징 + 품질조정 + WebP 이미지
파일 용량 기존 (11.2MB) 대비 약 1000배 이상 감소
서비스를 운영할 때, 대부분 이미지는 배경과 배너 또는 썸네일로 사용합니다.
운영하면서 이미지가 많아지고 고화질의 이미지 일 수록 이미지의 로딩 시간이 길어져 사용자 경험이 떨어지게 됩니다.
그래서 각 서비스에 맞는 이미지 크기로 리사이징 하여 작은 용량의 이미지를 불러오거나 압축된 이미지를 전달하여 성능과 비용에 더불어 편리한 사용자 경험도 제공할 수 있게 됩니다.
모바일 앱 사용자들의 데이터 비용이 대량 발생하게 되고 PC에 비해 상대적으로 디바이스 및 네트워크 성능이 안 좋은 상황에 더불어 원본 이미지를 불러와 페이지 로드 성능이 좋지 않아 개선이 가능합니다
PC, MO 상관없이 원본 이미지를 조회하는 구조는 웹 페이지의 성능 저하를 가져오고 이는 페이지에 접속하는 사용자의 경험을 헤치기 때문에 컴포넌트 크기에 맞는 px로 리사이징 하여 보다 빠른 리소스를 제공이 가능합니다.
WebP 포맷을 사용하여 png, jpg 대비 20% 압축된 이미지 파일을 제공 가능합니다.
리사이징된 이미지를 저장하는 방법과 필요할때 리사이징하는 방법 2가지를 고려했습니다.
업로드시, 리사이징된 이미지 자체를 저장하는 방법
따라서 저희는 필요할때 리사이징을 처리하는 On-The-Fly 방식
을 채택했습니다.
Lambda@Edge 를 사용하여 원본에서 데이터를 응답 받는 시점에 리사이징 로직이 적용된 람다 함수를 실행시켜 이미지를 리사이징 하고 캐싱하는 방법입니다.
Ref. 당근마켓 (On-The-Fly 방식)
Ref. AWS Resizing Images
S3를 사용하여 데이터를 저장하고 CDN을 사용하여 정적 리소스를 모든 리전에게 빠르게 제공하는 구조가 일반적으로 서비스 리소스를 제공하는 기본 틀입니다.
이렇게 배포된 CDN 각각에 Lambda 이벤트 트리거를 걸 수 있게 해주는 것이 Lambda@Edge
입니다.
Lambda@Edge란?
Lambda 함수를 복제하여 여러 CDN(Cloudfront)의 Edge location에 전역 배포되고 클라이언트는 어떤 리전에서 요청하던지 각 리전의 Edge location에 배포된 Lambda 함수를 거쳐 응답받게 됩니다.
Lambda@Edge 함수는 콘텐츠를 요청하는 사용자와 가장 가까운 엣지 위치에서 실행되기 때문에 지연 시간을 최소화할 수 있습니다.
AWS Lambda 와 차이점
- 중앙 집중식 컴퓨팅 서비스로, 사용자가 선택한 AWS 리전의 서버에서 실행
- 선택한 리전에서 실행되니 리전에서 멀리 떨어져있으면 지연 시간 발생
- 하지만, 다양한 AWS 서비스 이벤트에 의해 트리거 될 수 있음
Ref. Using AWS Lambda with CloudFront Lambda@Edge
Lambda@eEdge의 이벤트
다음과 같은 이벤트들이 발생할 때 CloudFront가 Lambda 함수를 호출하도록 할 수 있습니다
사용자로부터 요청을 받은 경우(Viewer request)
오리진에 요청을 전달하기 전(Origin request)
오리진으로부터 응답을 받은 시점(Origin response)
사용자에게 응답을 반환하기 전(Viewer response)
사용 가능 리전
Lambda@Edge의 리전은 us-east-1에서만 가능합니다.
이유는 CloudFront가 콘텐츠 배포를 위한 글로벌 CDN 서비스이지만, 실제 인프라는 us-east-1 리전에 있기 때문이며 Lambda@Edge는 결국 CloudFront와 연관되어 있어서라고 합니다.
결과적으로는 CloudFront 배포와 연계되어 모든 리전에 함수가 복사되기 때문에 큰 문제는 없습니다.
언어는 Node.js
or Python
만 가능합니다.
상세 구현은 위에서 공유 드렸던 것 처럼 기존에 제공되던 자료가 많아서
실무에 적용하면서 추가로 고려해야할 부분들 위주로 설명 드리고자 합니다.
lambda 설정 시에는 제한 시간과 메모리 설정을 적절하게 커스텀하여 조정해주어야 합니다.
적절하게 조정하지 않으면 Timeout이 발생하거나 메모리 이슈로 람다 함수가 실패하여 502, 503 에러를 응답한다.
해당 쿼리 스트링을 캐시 키에 등록하는 이유는 쿼리 문자열 전달 및 캐싱
옵션을 None
으로 설정한 경우 쿼리 스트링 포함 URL을 캐싱 시키지 않기 때문에 위처럼 명시적으로 등록하여 쿼리 스트링의 요청들 각각을 별도로 캐싱 시키기 위함입니다.
Ref. Caching and query string parameters
Origin-Response의 응답 본문 크기는 1.33MB 까지만 내릴 수 있습니다.
응답 시 base64 인코딩이 적용되어 반환되는데 base64는 응답 크기가 원본에 비해서 30% 증가되기 때문에 lambda 함수 로직에서 1MB가 넘어가는 경우엔 원본을 반환하도록 설정해주어야 합니다.
// 응답 이미지 용량이 1MB 이상일 경우 원본 반환.
if (Buffer.byteLength(resizedImage, 'base64') >= 1 * MB) {
return callback(null, response);
}
또는, 응답 객체가 1MB 이하가 될 때 까지 quality를 계속 낮추어 응답하는 방법을 채택하여도 괜찮습니다.
while (byteLength >= 1 * MB) {
quality -= 10;
resizedImage.toFormat(requiredFormat, { quality: quality });
}
w
, h
리사이징 옵션 조정 없이 요청 온 경우, 원본이 4MB 이상인 경우 원본을 반환하도록 설정 하였습니다.
let s3Object;
try {
s3Object = await AwsS3.getObject(objectKey);
// 리사이징 옵션 없이 2MB 넘는 이미지는 원본 반환
if (!(w || h) && s3Object.ContentLength >= 4 * 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); // 404
}
충분한 Timeout 설정과 충분한 메모리 설정을 했더라도
일반적으로 원본 용량이 큰 이미지를 리사이징 할때, Timeout이 발생하거나 메모리 초과되는 이슈가 있어서 위와 같이 처리하였습니다.
해당 설정은 각 서비스의 요구사항에 맞게 처리하면 될 것 같습니다.
리사이징에 실패하거나, 지원하지 않는 타입이거나 했을 경우 등 오류를 반환하지 않고 원본을 반환할 수 있도록 처리하였습니다.
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); // 원본 반환
}
// Sharp 에서 지원하는 이미지 타입 목록
const SUPPORT_IMAGE_TYPES = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'tiff'];
if (!SUPPORT_IMAGE_TYPES.some(type => type === extension)) {
console.warn(`Unsupported image type : ${extension}`)
return callback(null, response); // 원본 반환
}
WebP 파일의 정의
WebP은 인터넷에서 이미지가 로딩되는 시간을 단축하기 위해 Google이 출시한 파일 포맷입니다.
WebP를 사용하면 웹 사이트에서 고품질 이미지를 표현할 수 있지만 PNG, JPEG 등 기존 포맷보다 파일 크기가 대략 20% 줄일 수 있다고 이야기합니다.
Ref.
WebP 파일의 정의 - Adobe
WebP(웹피)란? - bandisoft
Ref. WebP image format
대부분의 최신 브라우저에서는 WebP 포맷을 지원합니다.
72% 사용자가 WebP를 지원하는 Chrome 브라우저를 이용하고 있어서 WebP 포맷을 적용해도 괜찮다고 판단했고 해당 포맷을 default로 가져가는 방향으로 개발했습니다.
// webp 포맷 default 적용
let format = (f == null) ? 'webp' : f.toLowerCase();
WebP로 변환 후 원본 이미지 파일 크기보다 파일 크기가 더 커지는 케이스도 존재했습니다.
Ref. WebP로 바꿀 때 주의할 점
1번, 3번 케이스의 경우엔 필요하다면 리사이징 이후 원본과 파일 크기를 비교하여 어떤 파일을 내릴지 결정하도록 처리하면 문제 없다고 판단 했고
2번 케이스의 경우 일반적으로 리사이징시에 손실 압축을 이용하므로 큰 문제가 되지 않았습니다.
저희 서비스는 Bitbucket 을 사용하여 소스 관리를 진행하고 있습니다.
따라서, 배포시에는 Bitbucket pipeline을 사용하게 되었는데요.
배포 파이프라인 구축을 도와주신 Devops 분들께 감사드립니다.
아래와 같은 Workflow를 통하여 람다 소스를 배포하였습니다.
람다 함수 배포는 Bitbucket에서 지원해주는 이미지가 있어 활용이 되었으나 람다 엣지 함수를 모든 리전에 배포할 수 있도록 도와주는 CloudFront Update
는 지원하지 않아서 직접 필요한 인자들을 주입하여 AWS CLI로 배포하도록 하였습니다.
Workflow
code build
name: build node
image: node:18.14.2-slim
caches:
- node
size: 2x
script:
- echo APP_S3_BUCKET_DIR=${APP_S3_BUCKET_DIR} >> .env
- echo APP_AWS_REGION=${APP_AWS_REGION} >> .env
- npm install
- apt-get update && apt-get install -y zip
- zip -r code.zip .
artifacts:
- code.zip
Lambda Deploy
name: Deploy lambda & Update CloudFront
image: amazon/aws-cli:2.8.13
size: 2x
script:
- pipe: atlassian/aws-lambda-deploy:1.8.2
variables:
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION}
FUNCTION_NAME: ${LAMBDA_FUNCTION}
COMMAND: 'update'
ZIP_FILE: 'code.zip'
CloudFront Update
- BITBUCKET_PIPE_SHARED_STORAGE_DIR="/opt/atlassian/pipelines/agent/build/.bitbucket/pipelines/generated/pipeline/pipes"
- export FUNCTION_ARN=$(jq --raw-output '.FunctionArn' $BITBUCKET_PIPE_SHARED_STORAGE_DIR/aws-lambda-deploy-env)
- ETAG=`aws cloudfront get-distribution --id ${DISTRIBUTION_ID} | jq -r .ETag`
- aws cloudfront get-distribution-config --id ${DISTRIBUTION_ID} | jq .DistributionConfig > output.json
- cat output.json | jq '. | .CacheBehaviors.Items=(.CacheBehaviors.Items |
map(if (.PathPattern == "/public/*") then
(.LambdaFunctionAssociations.Items[0].LambdaFunctionARN=env.FUNCTION_ARN) else . end))' > deploy.json
- aws cloudfront update-distribution --id $DISTRIBUTION_ID --distribution-config file://./deploy.json --if-match $ETAG > check.json
다양한 관점과 개선 사항을 고민해 보고 이미지 최적화를 진행하여 실제 실무에 적용까지 할 수 있어서 좋은 경험이었고
리사이징 적용 후 프론트엔드 개발자 분들에게 이미지 리사이징 사용법을 공유 드리고 서비스에 적용을 요청 드렸고 정상적으로 반영되어 성능 개선까지 성공적으로 완료했습니다.
현재 서비스의 문제점이 무엇인지 지표를 확인하고 어떻게 개선할 수 있을지 함께 고민하고 적용까지 해서 재밌었습니다.
개발 인프라까지 제공 받아 사전 테스트를 진행하여 명확한 요구 사항을 Devops에 요청할 수 있었고
서로의 역할 분담이 명확하지만 개발자가 Devops 영향 범위까지 명확히 알고 요청하면 보다 수월하게 작업이 될 수 있다는 경험도 했습니다.