이미지 최적화 - 2. On-Demand Resizing

신예찬·2024년 12월 29일

AWS 이미지 최적화

목록 보기
2/3

지난글에서는 CDN을 통해 이미지를 캐싱해 이미지 조회 퍼포먼스를 향상시키는 방법에 대해 간략하게 설명해보았다

다만 이것만으로는 조회 속도가 생각보다는 빨라지지 않음을 확인할 수 있다

왜냐하면 자체 컨텐츠의 크기가 너무 큰 경우 캐싱을 하더라도 만족할만한 수준의 속도를 낼 수 없기 때문이다

HTTP 아카이브 연구 결과 현대 웹페이지 용량의 대부분이 이미지라고 한다

그렇기에 이미지 조회 크기를 조정할 수 있다면 전체 요청 용량을 줄일 수 있고, 서비스 전송 속도를 개선할 수 있다

이를 이미지 리사이징이라고 하는데 정해진 방법이 있다기 보다는 사용처에 따라 다양한 방식으로 구현할 수 있다

이과정에서 이미지를 어떻게 전달할지 방법에 따라 성능 개선을 도모할 수 있다

How?

지난글에 이어 이번에도 AWS를 기준으로 예시를 들어보고자한다

다만 이번에는 처리 방식이 굉장히 다양하다

그래서 처리 방식에 대해 먼저 설명을 하고 AWS에서 이를 어떻게 적용하는지 알아보자

업로드 시점 resizing

이미지를 업로드하는 시점에 용량을 줄이는 방법이다

업로드시점에 작아진 이미지를 적재하고, CDN은 이를 캐싱하도록 연결한다

이방식은 조회를 하던 말던 항상 업로드 시점에 적용되기에 On The Fly Resizing 방식이라고 부른다

AWS에서 이를 적용하는 방법으로는 S3 Object Lambda를 사용하는 방법이 있다

장점으로는 업로드 시점에만 resizing이 동작하기 때문에 FaaS의 동작 횟수가 상당히 적다

하지만 업로드 시점에 redizing이 완료되지 않는다면 원본 이미지를 조회할 수 밖에 없기 때문에 문제가 발생할 수 있다

조회 시점 resizing

이미지를 조회하는 시점에 용량을 줄이는 방법이다

정확히 말하자면 이미지를 조회하는 시점보다는 CDN에서 원본 서버에 콘텐츠를 요구할때 즉각적으로 resizing을 하는 방법이다

이 방식을 On-Demand 방식이라고 부르는 분들도 계시고 On The Fly 방식이라고도 부르는거같은데 사실 뭐가 맞는지는 잘 모르겠다

caching이 안되어있는 경우(원본 요청을 해야하는 경우) FaaS등을 사용해 용량을 줄이는 방식이다

물론 CDN에 caching된 이미지가 만료될때마다 함수가 돌아가는 비용이 발생하기는 한다만 위의 업로드 시점에비해 안정적인 장점이 있다

AWS에서 적용하는 방법은 여러가지가 있다

  • Lambda@Edge
  • API Gateway + Lambda
  • CloudFront Function

찾아본 바로는 위와같은 방법들이 있는데 사실 더 있을지도 모른다

위 방법들이 비슷해보이지만 맨위의 Lambda@Edge라는 녀석이 조금 특이하다

Lambda@Edge는 다른 Lambda와 다르게 엣지로케이션에서 동작하는 Lambda라서 이미지 최적화와 같이 물리적 위치와도 관련된 작업을 할때 용이하다

차이점을 분석한 도 있으니 참고해보면 좋을거같다




Resizing in AWS

그럼 필자는 어떤 방법을 선택했느냐?

여러가지 이유가 있지만 Lambda@Edge가 당장의 서비스에 적합하다고 판단했다

이유는 다음과 같다
1. 서비스 특성상 업로드 직후에 즉각적으로 해당 컨텐츠를 조회할 일이 많기때문에 업로드 시점 최적화는 부적합
2. AWS API Gateway를 사용하지 않음
3. CloudFront Function의 경우 CPU와 메모리 지원이 상당히 제한적이지만 이미지 최적화 작업이 이를 얼마나 요구할지 알 수 없음

프리티어 적용이 안되는건 눈물이나지만 Lambda@Edge를 사용해 resizing을 적용해봤다

Lambda@Edge 적용 ㄱㄱ

조회 시점 Resizing 방식에서 썼던 시퀀스 다이어그램과 비슷하게 동작한다

인프라를 구축하는 방법이 그리 어렵지는 않으나 resizing function을 작성하는것이 쉽지 않았다 😂(이 이 아니었다면 꽤 고생했을거같다)

1. Lambda@Edge 구축

캐싱된 이미지가 없는 경우 아래의 코드가 Lambda@Edge를 통해 동작하게된다
이때 Lambda@Edge는 US NorthEast 1(버지니아 북부) 리전에서만 동작하니 인지해야한다

Edge Function이 멀면 resizing을 요청할때 먼곳에서 요청을 하기에 너무 느려지지는 않을까 하는 의문이 생겼는데 버지니아 북부에서 구축해도 전세계의 엣지 로케이션에 배포되니 걱정하지 않아도 된다고 한다



런타임은 Node.js를 선택하자

사용할 라이브러리가 Node.js의 Sharp이기 때문이다

2. CloudFront 정책 적용

CDN이 원본을 요청하기 위해 두가지 정책이 필요하다

  • 캐싱 정책: cache hit시 검증되는 정책

  • 원본 요청 정책: cache miss시 원본 요청 과정에서 검증되는 정책

위 두 정책에 쿼리 문자열에 대한 허용을 적용하는 모습을 확인할 수 있다

이는 쿼리 문자열로 Lambda@Edge에서 이미지 resizing을 적용할 매개변수를 받기 때문이다

3. Lambda@Edge에 트리거 추가

Lambda@Edge에서 동작할 함수가 필요하다

일단 원본 요청시 동작할 트리거를 추가하자

4. CORS 설정

당연하게도 리소스의 출처가 동일한곳에서 요청하는것이 아니기 때문에 CORS 설정도 해줘야한다

컨텐츠 저장 위치인 S3로 가서 원본 확보가 가능하도록 CORS 세팅을 해주자

5. IAM 적용

일단 정책 먼저 만들자

{
    "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": "*"
        }
    ]
}

객체에 대한 조회 권한과 lambda의 함수 접근 권한, edge location에 함수를 전달하기 위한 복사 권한이 보인다


정책을 연결할 IAM을 만들고


신뢰 관계에 위와 같이 추가해주자

6. resizing 함수 작성

이제 이미지 resizing을 도와줄 Sharp를 사용해보자
찾아보니 resizing말고도 워터마크 작업도 해준다고 하니 혹시라도 필요하게 된다면 적용해봐도 좋을거같다


이제 코드를 작성해볼건데 AWS console에서 작성하기에는 node.js를 제대로 쓰기 어려울거같아서 그냥 로컬에서 작업 후 압축파일을 올렸다

일단 package.json에 "type": "module"을 추가한 후 아래와같이 index.js를 작성하자

'use strict';

import Sharp from "sharp";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
const S3 = new S3Client({
    region: 'ap-northeast-2'
});

const getQuerystring = (querystring, key) => {
    return new URLSearchParams("?" + querystring).get(key);
};

export const imageResize = async (event, context) => {
    const { request } = event.Records[0].cf;
    let response = event.Records[0].cf.response || {
        status: 200,
        statusDescription: "OK",
        headers: {}
    };

    const querystring = request.querystring;
    if (!querystring) {
        return response;
    }

    const uri = decodeURIComponent(request.uri);

    const width = Number(getQuerystring(querystring, "w")) || null;
    const height = Number(getQuerystring(querystring, "h")) || null;
    const fit = getQuerystring(querystring, "f");
    const quality = Number(getQuerystring(querystring, "q")) || null;

    const s3BucketDomainName = request.origin.s3.domainName;
    let s3BucketName = s3BucketDomainName.replace(".s3.ap-northeast-2.amazonaws.com", "");
    s3BucketName = s3BucketName.replace(".s3.amazonaws.com", "");
    const s3Path = uri.substring(1);

    let s3Object = null;
    try {
        s3Object = await S3.send(new GetObjectCommand({
            Bucket: s3BucketName,
            Key: s3Path
        }));
    } catch (err) {
        console.log("S3 GetObject Fail!! \n" +
            "Bucket: " + s3BucketName + ", Path: " + s3Path + "\n" +
            "err: " + err);
        response.status = 404;
        response.statusDescription = "Not Found";
        response.body = "Image not found.";
        return response;
    }

    const s3Uint8ArrayData = await s3Object.Body.transformToByteArray();

    let resizedImage = null;
    try {
        resizedImage = await Sharp(s3Uint8ArrayData)
            .resize({
                width: width,
                height: height,
                fit: fit
            })
            .toFormat("webp", {
                quality: quality
            })
            .toBuffer();
        console.log("Sharp Resize Success");
    } catch (err) {
        console.log("Sharp Resize Fail!! \n" +
            "Bucket: " + s3BucketName + ", Path: " + s3Path + "\n" +
            "err: " + err);
        response.status = 500;
        response.statusDescription = "Internal Server Error";
        response.body = "Image processing failed.";
        return response;
    }

    const resizedImageByteLength = Buffer.byteLength(resizedImage);

    if (resizedImageByteLength >= 1048576) {
        response.status = 400;
        response.statusDescription = "Bad Request";
        response.body = "Image exceeds the size limit.";
        return response;
    }

    response.status = 200;
    response.body = Buffer.from(resizedImage).toString('base64');
    response.bodyEncoding = 'base64';
    response.headers['content-type'] = [{ key: 'Content-Type', value: `image/webp` }];
    return response;
};

막 복잡해보이지만 사실상 별거 없다

쿼리 파라미터로 받아온 값들에 맞춰 heagith, width, fit(크기에 이미지를 어떻게 맞출지?), quality를 Sharp가 알아서 처리해준다

파일 형식은 webp로 통일했는데, 왜 avif를 안했냐 하면 몰랐기 때문이다...(찾아보니 avif가 조금 더 빠른거 같기도...)

또 잘 보면 resizing한 이미지의 크기가 1048576바이트를 넘기는 경우 에러를 발생시키는데 이는 Lamgda@Edge의 제한사항 때문이다


작성한 프로젝트는 압축해서 Lambda에 올리자


그리고는 11번을 실패한다😢

로컬에서 작업하다보니 aws에서 사용하는 모듈을 제대로 못받아왔던것





severless framework를 사용하자

작성된 코드를 무작정 올려봐야 권한이 없기 때문에 객체를 가져오는 과정 자체를 sharp가 호출할 수 없다

Lambda의 실행 권한에 아까 만든 IAM을 추가해주자

적용 결과

크기는 14배 작아지고, 속도도 약 3.5배 빨라진걸 확인할 수 있다

올리브영 테크블로그 웹사이트 최적화 방법 - 이미지 파트
올리브영 테크블로그 AWS Lambda Image Resize 도입기
AWS 개발자 가이드 CloudFront Functions와 Lambda@Edge 간 차이점
온디맨드 리사이징 구현기(feat. CloudFront, Lambda@Edge)

0개의 댓글