AWS Lambda@Edge에서 실시간 이미지 리사이징(On-The-Fly) & WebP 형식으로 변환

503·2022년 10월 18일
7

안녕하세요, 503입니다.

이번 포스팅은 AWS Lambda@Edge에서 실시간 이미지 리사이징하는 방법에 대해 정리해보려고 합니다. 이전의 AWS S3 이미지 처리 개선하기에서 이어지는 2번째 포스팅입니다.

본격적으로 시작하기 전, 이슈를 다시 언급해보자면 다음과 같았습니다.

상품 리스트를 불러올 때, S3 버킷에 있는 큰 이미지를 그대로 가져오느라 로딩시간이 엄청 오래걸리는 문제점이 발생한다.

이러한 문제를 해결하기 위한 방법으로 캐싱 기능을 구현하기 위해 AWS CloudFront로 CDN 프록시 서버를 구현했었는데요. 이번 포스팅에서는 썸네일 이미지를 위한 이미지 리사이징 기능을 구현한 방법에 대해 설명하려고 합니다.


이미지 리사이징

이미지 로딩시간 문제를 해결하기 위해 리사이징(Resizing)기술을 제공하는 모듈(sharp)이 나오게 되었고, 이미지 용량을 줄여 로딩 속도를 빠르게 하는 Best practice로서 널리 통용되어 사용되고 있습니다.

이는 서버에 이미지를 요청할 때 GET /imageUrl?w=300&h=300와 같이 썸네일의 크기에 맞춰서 리사이징 요청을 보내 처리하는 방법인데요. 이를 구현하는 방법으로 AWS Lambda 함수로 썸네일을 생성하는 방법을 많이 사용하고 있습니다

AWS Lambda?

공식문서를 참고하면 아래와 같습니다.

AWS Lambda는 서버를 프로비저닝 또는 관리하지 않고도 실제로 모든 유형의 애플리케이션 또는 백엔드 서비스에 대한 코드를 실행할 수 있는 이벤트 중심의 서버리스 컴퓨팅 서비스입니다.

위에서 언급한 이미지 리사이징을 구현하는 방법으로 AWS Lambda 함수로 썸네일을 생성하는 방법을 많이 사용한다고 했는데, 그러다보면 아래와 같은 의문이 생길 수 있습니다.

굳이 Serverless 서비스인 AWS lambda로 썸네일을 생성할 필요가 있나? 그냥 우리 서버로 GET /imageUrl?w=300&h=300와 같은 요청을 보내 처리하면 되지 않는가?


On-The-Fly 이미지 리사이징

클라이언트에서 썸네일을 요청할 때 '실시간'으로 이미지를 리사이징하여 제공하는 방식

위와 같이 필요할 때마다 실시간으로 이미지 리사이징을 요청해 썸네일을 구현한다면 s3 저장소에 썸네일을 저장할 필요가 없어서 저장소 용량 문제를 고려하지 않아도 될 것 같습니다.

또한, 클라이언트에 따라 다른 이미지 포맷으로 응답할 수 있고 서버의 트래픽을 줄일 수 있을 것입니다.

예를 들어 WebP 포맷은 구글에서 공개한 이미지 포맷인데, 화질에는 거의 차이가 없지만 jpge, png에 비해 용량이 20% 적습니다.

실제로 1280x1280의 같은 이미지를 포맷형식만 바꿨을 때 WebP 포맷의 이미지가 32%나 작은 것을 확인할 수 있습니다.


그렇다면 이러한 실시간 이미지 리사이징 방식을 어떻게 구현해야 효율적일까요?

  1. 서버를 따로두고 클라이언트의 요청이 있을 때 이미지를 리사이징하여 제공

위에서 언급했던 방식입니다. 하지만 이렇게 구현한다면 아래와 같은 문제가 생길 수 있습니다.

  • 이미지 용량이 크거나여러 요청이 들어올 때 서버에 부담이 가는 문제 발생
  • 이미지 서버를 CDN으로 제공하고 있다면 서버로 요청을 보내는 것은 추가적인 Overhead

즉, 리사이징만을 위하여 서버 자원을 쓰는 것은 좋지않아보입니다.

  1. 서버를 두지 않고 AWS Lamdba로 구현

클라이언트가 썸네일 요청할 때, API를 통해 AWS Lambda를 호출하면 이미지를 리사이징하여 제공합니다.

AWS Lambda를 사용해 이미지 리사이징을 구현한다면 메인 서버와 로직이 분리되어 서버에 부담을 주지 않을 수 있습니다. 또한, 서버리스 환경에 구축하여 서버 유지 관리가 필요가 없다는 장점이 있습니다.

하지만 단순히 AWS Lambda를 통해 생성된 썸네일을 S3 저장소에 모두 저장하는 것은 저장소의 사용량이 늘어나게 될 것 입니다. 하지만 이미지 서버로 CDN을 제공하고 있다면 우리는 캐싱 기능을 통해 사용자 경험을 보다 나아지게 만들어 질 수 있을 것입니다.

  1. Lambda@Edge 사용

API를 통해 Lambda를 호출하는 방식보다 더 나은 성능을 보여줍니다. 이번 포스팅에서는 이 방식으로 썸네일 생성을 구현할 것이고, 이제부터 자세히 설명해 보겠습니다.


Lambda@Edge?

마찬가지로 공식문서의 설명을 참고하겠습니다.

Amazon CloudFront의 기능 중 하나로서 애플리케이션의 사용자에게 더 가까운 위치에서 코드를 실행하여 성능을 개선하고 지연 시간을 단축할 수 있게 해 줍니다.

Lambda@Edge는 Amazon CloudFront 콘텐츠 전송 네트워크(CDN)에 의해 생성된 이벤트에 대한 응답으로서 Lambda 함수를 실행합니다. 코드를 업로드하기만 하면 AWS Lambda가 최종 사용자와 가장 가까운 AWS 로케이션에서 코드를 실행하고 확장하는데 필요한 모든 작업을 처리합니다.

Lambda@Edge는 CloudFront에 접근할 때 실행되는 Lambda의 확장판으로 CloudFront 이벤트가 발생할 때(트리거) Lambda 함수를 실행할 수 있습니다. 이벤트는 4가지가 있습니다.

  • Viewer Request : CloudFront가 뷰어로부터 요청을 받고 요청한 개체가 edge cache에 있는지 확인하기 전에 함수를 실행합니다.
  • Origin Request : CloudFront가 오리진으로 요청을 전달할 때만 실행됩니다. 요청한 개체가 edge cache에 있으면 함수가 실행되지 않습니다.
  • Origin Response : CloudFront가 오리진으로부터 응답을 받은 후 응답에 개체를 cache하기 전에 함수를 실행합니다. CDN에 연결된 Origin(이 경우, S3)이 응답(원본 이미지, 헤더를 비롯한 Event 객체)을 반환한 후에 동작하는 이벤트입니다.
  • Viewer Response : 요청한 개체를 뷰어에 반환하기 전에 기능이 실행됩니다. 이 함수는 개체가 edge cache에 이미 있는지 여부에 관계없이 실행됩니다.

이제 우리는 Lambda@Edge를 사용한 이미지 리사이징을 구현하도록 하겠습니다.


이미지 리사이징 구현하기

flow는 다음과 같습니다.

Origin Response 이벤트 발생 시, Lambda@Edge 함수 실행하는 방법을 이용합니다.

클라이언트에서 GET /CDN-IMAGE-URL?w=100&h=100&q=80 요청을 보냄.
-> CloudFront에서 이미지를 요청했지만 cache 되어있지 않은 상태면 CloudFront가 s3(오리진)에 요청
-> s3 오리진에 이미지 존재하면 s3 오리진에서 응답 (Origin Response 이벤트)
-> Lambda 함수를 실행하여 이미지 리사이징
-> 리사이징한 이미지를 CloudFront에 캐싱처리 요청
-> CloudFront는 캐싱 후 브라우저에 리사이징 된 이미지를 표시


Lambda@Edge 배포하기

1. s3 버킷 만들기

이 때 외부(링크)에서 접근해야하기 때문에 액세스 차단을 열어줘야합니다.

2. cloudFront 생성(배포)

3. IAM 생성

  1. 정책생성
  2. 역할 생성해서 만들어준 정책 선택

4. Lambda 함수 생성

이때 주의할 점!! 꼭 리전을 버지니아북부(east-1)로 선택해야합니다. (서울은 Lambda@Edge 지원 안됨)

5. Cloud9 생성 및 설정

왼쪽 aws탭에서 Lambda 함수를 download 합니다. 그러면 알아서 Resizing_Lambda 폴더(내가 만든 Lambda 함수랑 같은 이름)가 생성되고 내부에 index.js 파일을 볼 수 있습니다.

다운로드한 폴더로 접근해서 sharp 패키지를 설치합니다.

cd Resizing_Lambda
npm init -y
npm install sharp

npm init -y로 초기화 시 node_modules 폴더 및 package.json 자동 생성됩니다.

index.js 파일에 아래 코드를 추가합니다.

'use strict';

const querystring = require('querystring'); // Don't install.
const AWS = require('aws-sdk'); // Don't install.

// http://sharp.pixelplumbing.com/en/stable/api-resize/
const Sharp = require('sharp');

const S3 = new AWS.S3({
  region: 'ap-northeast-2'  // 버킷을 생성한 리전 입력(여기선 서울)
});

const BUCKET = 'tenshopbucket' // Input your bucket

// Image types that can be handled by Sharp
const supportImageTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'tiff', 'jfif'];

exports.handler = async(event, context, callback) => {
  const { request, response } = event.Records[0].cf;

  console.log("request: ", request)
  console.log("response: ", response)

  // Parameters are w, h, f, q and indicate width, height, format and quality.
  const { uri } = request;

  const ObjectKey = decodeURIComponent(uri).substring(1);
  const params = querystring.parse(request.querystring);
  const { w, h, q, f } = params

  /**
   * ex) https://dilgv5hokpawv.cloudfront.net/dev/thumbnail.png?w=200&h=150&f=webp&q=90
   * - ObjectKey: 'dev/thumbnail.png'
   * - w: '200'
   * - h: '150'
   * - f: 'webp'
   * - q: '90'
   */

  // 크기 조절이 없는 경우 원본 반환.
  if (!(w || h)) {
    return callback(null, response);
  }


  const extension = uri.match(/\/?(.*)\.(.*)/)[2].toLowerCase();
  console.log('extension : ', extension);

  const width = parseInt(w, 10) || null;
  const height = parseInt(h, 10) || null;
  const quality = parseInt(q, 10) || 100; // Sharp는 이미지 포맷에 따라서 품질(quality)의 기본값이 다릅니다.
  let format = (f || extension).toLowerCase();
  let s3Object;
  let resizedImage;

  // 포맷 변환이 없는 GIF 포맷 요청은 원본 반환.
  if (extension === 'gif' && !f) {
    return callback(null, response);
  }

  // Init format.
format = params.f ? params.f : 'webp'; //따로 포맷형태를 주지않으면 webp로 변경
  format = format === 'jpg' ? 'jpeg' : format;

  if (!supportImageTypes.some(type => type === extension )) {
    responseHandler(
      403,
      'Forbidden',
      'Unsupported image type', [{
        key: 'Content-Type',
        value: 'text/plain'
      }],
    );
    return callback(null, response);
  }


  // Verify For AWS CloudWatch.
  console.log(`parmas: ${JSON.stringify(params)}`); // Cannot convert object to primitive value.\
  console.log('S3 Object key:', ObjectKey)
  console.log('Bucket name : ', BUCKET);

  try {
    s3Object = await S3.getObject({
      Bucket: BUCKET,
      Key: ObjectKey
    }).promise();

    console.log('S3 Object:', s3Object);
  }
  catch (error) {
    console.log('S3.getObject error : ', error);
    responseHandler(
      404,
      'Not Found',
      'OMG... The image does not exist.', [{ key: 'Content-Type', value: 'text/plain' }],
    );
    return callback(null, response);
  }

  try {
    resizedImage = await Sharp(s3Object.Body)
      .rotate()
      .resize(width, height)
      .toFormat(format, {
        quality
      })
      .toBuffer();
  }
  catch (error) {
    console.log('Sharp error : ', error);
    responseHandler(
      500,
      'Internal Server Error',
      'Fail to resize image.', [{
        key: 'Content-Type',
        value: 'text/plain'
      }],
    );
    return callback(null, response);
  }

  // 응답 이미지 용량이 1MB 이상일 경우 원본 반환.
  if (Buffer.byteLength(resizedImage, 'base64') >= 1048576) {
    return callback(null, response);
  }

  responseHandler(
    200,
    'OK',
    resizedImage.toString('base64'), [{
      key: 'Content-Type',
      value: `image/${format}`
    }],
    'base64'
  );

  /**
   * @summary response 객체 수정을 위한 wrapping 함수
   */
  function responseHandler(status, statusDescription, body, contentHeader, bodyEncoding) {
    response.status = status;
    response.statusDescription = statusDescription;
    response.body = body;
    response.headers['content-type'] = contentHeader;
    if (bodyEncoding) {
      response.bodyEncoding = bodyEncoding;
    }
  }

  console.log('Success resizing image');

  return callback(null, response);
};

위와 같이 수정한 index.js 파일을 저장한 다음 다시 왼쪽 창에서 Aws => lambda => [Lambda이름] 마우스 우클릭 => Upload Lambda 를 클릭합니다.

  1. Direcotry 클릭, 지금까지 작업한 폴더(Resizing_Lambda)를 클릭해 오픈합니다!(꼭 지금까지 작업한 폴더를…)
  2. Build directory에서는 no 선택. 코드 즉시 배포는 yes 선택

6. Lambda@Edge 배포

AWS consle => Lambda에 들어가보면 함수코드가 다음과 같이 변경되어 있습니다.

이제 작업 - Lambda@Edge 배포를 눌러서 이벤트를 오리진 응답으로 설정해줍니다.

이렇게 배포하면 새 버전이 만들어집니다.


리사이징 후 비교

이미지 주소는 아래와 같습니다. 이미지 파일 이름 뒤에 파라미터를 통해 이미지 리사이징을 해줍니다.

https://[clounfrontId].cloudfront.net/[이미지파일이름]?파라미터..

  • 높이가 300이었던 이미지를 h=50로 리사이징 했을 때 size가 172kB → 3.9KB로 줄어듬
  • f=webp 으로 확장자를 바꿨을 때 size가 3.9KB ⇒ 3.0KB로 줄어들음 (확장자 안주면 webp으로 확장자 고정시켜놓음)

  • 첫번째 호출한건 캐시 miss, 두번째부턴 hit from cloudfront 성공
  • time을 보면 캐싱에 성공했을 때부턴 응답속도가 많이 줄어듬을 확인
profile
얼레벌레 개발자로 살아가기. 개발하면서 만났던 이슈를 기록합니다.

0개의 댓글