[AWS] Lambda@Edge와 CloudFront를 활용한 온디맨드 이미지 리사이징 적용기

조성우·2025년 6월 1일
0

AWS

목록 보기
8/9
post-thumbnail

배경

  1. 데이트 코스 추천 서비스의 추천 음식점 이미지 로딩 속도를 개선하고자 함

(음식점 이미지는 다른 음식점 이미지들과 함께 나열되어 고해상도일 필요가 없었음)

  1. 음식점 추가 시 업로드되는 고해상도의 이미지를 AWS Lambda를 활용해 리사이징하기로 함

  2. Amazon S3를 사용 중이므로, 캐싱을 통한 응답 속도 향상과 비용 절감의 이점을 누리기 위해 Amazon CloudFront(CDN)을 활용하기로 함

시나리오 1 (폐기)

원래는 위 UML을 설계하여, 음식점 사진 등록 시에 리사이징을 적용하고 해당 사진을 /resized에 따로 저장하여 제공하려고 하였다.

그러나 좀 더 진지하게 고민해본 결과, 이 시나리오는 아래의 이유들로 폐기하였다.

  1. 이미지 사이즈에 대한 요구사항이 달라졌을 때 유연하게 대응하지 못한다.
    1-1. 다른 사이즈가 요구되면?
    1-2. 여러 개의 사이즈가 요구되면?
    → 재업로드하거나 수동으로 람다를 재실행 해야 한다!
  2. 관리해야 할 객체가 늘어나 비용 및 관리 복잡도가 증기한다.
  3. (이번 프로젝트의 경우) 이미 업로드된 객체들에 대해 일일히 리사이징을 수행하는 것은 시간과 비용 측면에서 부담이 크다.

시나리오 2 (채택)

따라서 이미지 리사이징을 프론트에서 이미지가 필요할 때마다 요청 시 처리하는 on-the-fly(on-demand) 방식으로 수행하기로 하였다.

이번에 만든 UML은 Lambda@Edge를 사용하여 원본 이미지를 응답 받는 시점(트리거)에 람다 함수를 실행시켜 이미지를 리사이징 하고 캐싱하는 방법이다.
Lambda@Edge는 CloudFront의 기능 중 하나로, 사용자에게 더 가까운 위치에서 코드를 실행하여 성능 개선 및 지연 시간 단축을 기대할 수 있다.

  1. 다양한 사이즈 요청에 유연하게 대응할 수 있다.
  2. 원본 이미지 하나만 S3에 저장한다. → 비용 및 관리 복잡도가 낮아진다!

Lambda@Edge를 통한 이미지 리사이징을 도입하여... (중략)... CloudFront 트래픽 감소와, S3 저장소 정리를 통해 한 달에 약 3,000달러의 비용이 절약되었습니다. - 당근 테크 블로그

왜 Lambda@Edge인가?

Lambda@Edge는 CloudFront의 기능 중 하나로, 사용자에게 더 가까운 위치에서 코드를 실행하여 성능 개선 및 지연 시간 단축을 기대할 수 있다.

그런데 공식 홈페이지에 적힌 내용 말고, 온디맨드 리사이징에 이용하는 진짜 이유는 CloudFront 요청/응답 흐름 중간에 개입이 가능하다는 점이라고 생각한다. CloudFront의 이벤트 기반 트리거가 가능하므로 CDN을 통한 최적화를 람다와 연결지어 극한으로 사용할 수 있다.

쉽게 말하면, 이번 사례의 경우는 람다를 통한 리사이징 결과를 바로 캐싱할 수 있다는 것이다.


HOW TO

기존에 이미 퍼블릭으로 설정된 S3 버킷이 존재한다고 가정하고 진행하겠다.

1. CloudFront 생성 (+ 캐시 정책)

CloudFront 배포를 생성한다.
Origin domain에 연결하고자 하는 S3 도메인 선택한다.

캐시 정책을 생성하기 위해 표시된 부분에 Create cache policy를 누른다.

정책 이름을 입력하고, 쿼리 문자열은 '모두'로 하면 되겠다

w(weight), h(height) 등의 쿼리 파라미터가 전달되는데, 동일한 URL에 대해 서로 다른 쿼리 문자열을 가진 요청이 각각 별도의 캐시 항목으로 저장되도록 하는 것이다.
추가로 아래 원본 요청 정책은 간혹 설정하기도 하는데, 원본에 요청할 때에는 별도로 쿼리 파라미터가 필요하지 않아 설정해주지 않아도 된다.

이후, 방금 만든 캐시 정책을 선택해주고, 배포를 생성한다.
(AWS WAF는 비활성화하였다.)


2. IAM 역할 생성

역할을 생성한다. Lambda 서비스를 선택하고 넘어간다.

AWSLambdaExecute 정책을 검색하여 선택하고 넘어간다. (우리가 필요한 GetObject 권한이 포함되어 있다.)

역할 이름과 설명을 적고 생성한다.

신뢰관계 탭에서 우리가 사용할 Lambda@Edge 또한 등록해줘야 한다.
edgelambda.amazonaws.com를 추가하자.


3-1. Lambda 함수 생성

우선 리전을 버지니아 북부로 변경한다.

Lambda@Edge는 CloudFront의 글로벌 엣지 네트워크에서 실행된다. 그리고 모든 글로벌 배포의 기준점은 버지니아 북부(us-east-1)이다.

Lambda 함수를 생성한다. (여기서는 Node.js 22.x(최신 버전)를 사용하겠다!)
아래에 기본 실행 역할은 위에서 생성했던 역할을 선택하자.

3-2. Lambda가 실행할 리사이징 코드 작성 및 업로드

const sharp = require('sharp');  // 이미지 리사이징을 위한 라이브러리
const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');
const s3 = new S3Client({ region: 'ap-northeast-2' });

exports.handler = async (event, context, callback) => {
    const { request, response } = event.Records[0].cf;
    
    /** 쿼리 설명
     * w : width (가로 크기)
     * h : height (세로 크기) 
     * f : format (이미지 포맷)
     * q : quality (이미지 품질)
     * t : type (리사이즈 타입: contain, cover, fill, inside, outside)
     */
    const querystring = request.querystring;
    const searchParams = new URLSearchParams(querystring);
    
    // w와 h 둘 다 없으면 원본 이미지 반환
    if (!searchParams.get('w') && !searchParams.get('h')) {
        return callback(null, response);
    }
    
    const { uri } = request;
    const [, imageName, extension] = uri.match(/\/?(.*)\.(.*)/);
    
    // 파라미터 파싱
    const width = parseInt(searchParams.get('w'), 10) || null;
    const height = parseInt(searchParams.get('h'), 10) || null;
    const quality = parseInt(searchParams.get('q'), 10) || DEFAULT_QUALITY;
    const type = searchParams.get('t') || DEFAULT_TYPE;
    const f = searchParams.get('f');
    const format = (f === 'jpg' ? 'jpeg' : f) || extension;

    // 버킷 이름 지정
    const s3BucketName = '본인의 버킷명을 입력하세요!';
    console.log("s3BucketName", s3BucketName);
    
    try {
        const s3Object = await getS3Object(s3, s3BucketName, imageName, extension);  // S3에서 원본 이미지 가져오기
        const resizedImage = await resizeImage(s3Object, width, height, format, type, quality);  // 이미지 리사이징
        
        // 리사이즈된 이미지를 base64로 인코딩하여 응답
        response.status = 200;
        response.body = resizedImage.toString('base64');
        response.bodyEncoding = 'base64';
        response.headers['content-type'] = [
            { key: 'Content-Type', value: `image/${format}` }
        ];
        response.headers['cache-control'] = [{
            key: 'cache-control',
            value: 'max-age=31536000' // 캐싱 기간 1년 설정
        }];
        
        return callback(null, response);
    } catch (error) {
        return callback(error);
    }
};

// 기본값 설정
const DEFAULT_QUALITY = 80; // 기본 이미지 품질
const DEFAULT_TYPE = 'contain'; // 기본 리사이즈 타입

// S3에서 이미지 객체 가져오기
async function getS3Object(s3, bucket, imageName, extension) {
    try {
        const command = new GetObjectCommand({
            Bucket: bucket,
            Key: decodeURI(imageName + '.' + extension)
        });
        const s3Object = await s3.send(command);
        
        // Body를 Uint8Array로 변환
        const bodyBytes = await s3Object.Body.transformToByteArray();
        return { Body: bodyBytes };
    } catch (error) {
        console.log('s3.getObject error: ', error);
        throw new Error(error);
    }
}

// 이미지 리사이징 처리 함수
async function resizeImage(s3Object, width, height, format, type, quality) {
    try {
        const resizedImage = await sharp(s3Object.Body)
            .resize(width, height, { fit: type })
            .toFormat(format, { quality })
            .toBuffer();
        return resizedImage;
    } catch (error) {
        console.log('resizeImage error: ', error);
        throw new Error(error);
    }
}

s3BucketName(버킷명) 반드시 수정한 후 사용하세요!

아래 옵션을 쿼리 파라미터로 제공할 수 있다.

  • w : width (가로)
  • h : height (세로)
  • q : quality (품질)
  • f : format (포맷)
  • t : type (방식: contain, cover, fill, inside, outside)

sharp 라이브러리가 필요하기 때문에 위 코드는 AWS 콘솔 에디터에 그대로 넣어서 Deploy가 불가능하다.

따라서 직접 Node.js를 설치해 작업하도록 하자.

# mkdir lambda
# cd lambda
npm init

다음으로 sharp 라이브러리 다운로드이다.
'Linux x64'가 아닌 환경에서 npm을 통해 설치하면 절대!!!(2시간 소요...) AWS Lambda 환경에 맞게 빌드되지 않는다.

https://github.com/pH200/sharp-layer?tab=readme-ov-file
따라서 위 링크의 release-x64.zip를 압축해제하여 node_modules/에 그대로 넣어주자.

vi index.js  # 위 코드를 넣는다 (vi 명령어를 모른다면 직접 파일을 만들어서 넣자!)
zip -r lambda.zip .  # 현재 폴더 압축

이제 결과물을 람다 함수에 업로드해주면 된다.

혹시나 오류가 나거나 미래에 이 글을 보시는 분들을 위해

만약 버전 등의 문제로 배포 시 503 ERROR가 뜬다면,
테스트 탭에서 손쉽게 테스트를 진행할 수 있다.
테스트 이벤트를 아래의 JSON 형태로 생성한 뒤 실행해서 디버깅하기 바란다.

{
  "Records": [
    {
      "cf": {
        "request": {
          "uri": "/tmp.png",
          "querystring": "w=300"
        },
        "response": {
          "status": "200",
          "statusDescription": "OK",
          "headers": {}
        }
      }
    }
  ]
}

4. Lambda@Edge 배포

이제 본격적으로 배포해보자!

여기서 Lambda@Edge 배포를 선택하면 자동으로 새 버전을 CloudFront와 연결해준다.

트리거는 CloudFront 이벤트 중 오리진 응답을 선택한다.
밑에 체크까지!

사실 여기에서 "반드시 오리진 응답으로 해야하는 이유가 있을까?"하는 생각을 했고. 이 부분에 대해서는 따로 글을 쓰려고 한다.

[AWS] 온디맨드 이미지 리사이징을 위한 Lambda@Edge 트리거는? (개선 버전 코드도 있음!)

그러면 새 버전의 람다가 발행되며 CloudFront와 자동으로 연결된다.

추후 람다 함수를 수정한다면 이 함수에 기존 CloudFront 트리거 사용으로 쉽게 새 버전 발행과 CloudFront 연결을 수행할 수 있다.

5. 실행 결과

이제 (CloudFront 도메인)/(객체명)?(쿼리 파라미터)에 접속해 리사이징을 요청해보자.
?w=400, ?w=500&h=300 등의 파라미터로 결과물을 확인해볼 수 있다.


6. 이미지 응답 및 로딩 시간 측정 결과

측정 결과는 다음과 같다.

최종적으로 Lambda@Edge까지 적용된 이후에는 서버 응답 대기 시간이 약 79% 개선되었고, 이미지 다운로드 시간이 약 85% 개선되었다.

평균적으로 개별 이미지의 로딩 속도가 약 82% 개선되었다.

캐시 미스로 인해 Origin Access가 일어나는 경우, 응답 시간은 700~800ms까지 늘어났다.

그러나 다음과 같은 이유로 충분히 적용할만 했다고 생각한다.
1. 음식점 이미지는 추가 및 변경이 잦지 않다.
2. 변경되더라도 객체명은 변경되어 업데이트가 지연되지 않는다.
3. 리사이징할 사이즈는 제한적이다. (람다 함수 실행이 자주 일어나지 않음)

참고로 캐시 만료 시간(TTL)은 람다 코드에 명시되어 있는데, 1년으로 하였다.
자료를 찾아본 결과, 최대한 길게 설정하는 게 비용적으로 좋다고 한다.

0개의 댓글