Reese-log
  • About
  • Blog

© 2025 Reese. All rights reserved.

2024년 3월 23일

이미지 리사이징으로 LCP 개선하기 - CloudFront + Lambda@edge

#Optimizing Performance

Lambda@Edge

서비스 프로모션 페이지에 APNG 이미지가 삽입되어 있는데, 최적화된 WebP로 변경하더라도 이미지 크기가 2MB로 커서 LCP(Largest Contentful Paint)가 최대 7초까지 발생했습니다.

이 문제를 해결하기 위해 Lambda@Edge를 활용한 실시간 이미지 리사이징 을 도입해 성능을 개선한 과정과 트러블슈팅 경험을 정리했습니다.

왜 Lambda@Edge인가?

CloudFront Functions와 Lambda@Edge 중 어떤 것을 사용할지 고민될 수 있습니다.

CloudFront Functions vs Lambda@Edge를 참고하면,

  • CloudFront Functions는 성능은 좋지만 기능 제약이 큼.
  • 이미지 리사이징과 같은 복잡한 작업은 Lambda@Edge가 필요.
  • IAM 정책 & 역할 설정

    🔗https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/edge-functions-choosing.html

    함수 만들기에 앞서 함수에 적용할 정책과 역할을 생성해야합니다.

    IAM 정책 생성

    IAM 콘솔 > 정책 > 정책생성

  • S3에서 이미지를 가져올 수 있도록 s3:GetObject 권한 필요
  • 역할 생성 및 정책 연결

  • 1번에서 만든 정책을 연결합니다.
  • 신뢰관계에 엣지람다도 추가해줍니다.
  • 함수 생성 : Lambda 콘솔 > 함수 생성

  • 2번에서 만들었던 역할을 적용하고 함수를 생성합니다.
  • Lambda 함수 생성 및 로컬 개발

    초기 셋업

    javascript
    npm init -y
    npm install sharp

    처음엔 간단히 끝날 줄 알았지만, 실제로는 트러블의 연속이었습니다.

    cloudfront에서 람다 엣지를 활용하기 위한 레퍼런스를 찾아보면 node버전이 매우 낮고(e.g. node8 / node10) 현재 람다에서 지원하는 런타임 중 node16이 제일 낮다는 것을 감안했을때 아득했습니다.

    하지만 해내야죠?

    트러블슈팅 로그

    1차 시도

    node16으로 런타임 구성 후 블로그글을 참고하여 함수를 만들었습니다.

    sharp라이브러리를 install하려고 하자 node version을 18이상으로 올리라는 에러메세지를 만났습니다.

    2차 시도

    node 18로 런타임 구성을 변경한 후 맞닥뜨린 오류

    javascript
    {
    "errorType": "Error",
    "errorMessage": "Could not load the "sharp" module using the linux-x64 runtime\nPossible solutions:\n- Ensure optional dependencies can be installed:\n npm install --include=optional sharp\n yarn add sharp --ignore-engines\n- Ensure your package manager supports multi-platform installation:\n See https://sharp.pixelplumbing.com/install#cross-platform\n- Add platform-specific dependencies:\n npm install --os=linux --cpu=x64 sharp\n- Consult the installation documentation:\n See https://sharp.pixelplumbing.com/install",
    }

    해결 방법 → sharp설치시 cross 환경 대응 가능하도록 설치

    javascript
    npm install --arch=x64 --platform=linux --target=18x sharp 

    3차 시도

    javascript
    12024-03-22T14:39:10.928Z	undefined	ERROR	Uncaught Exception 	{
    2    "errorType": "ReferenceError",
    3    "errorMessage": "require is not defined in ES module scope, you can use import instead",
    4    "stack": [
    5        "ReferenceError: require is not defined in ES module scope, you can use import instead",
    6        "    at file:///var/task/index.mjs:3:21",
    7        "    at ModuleJob.run (node:internal/modules/esm/module_job:195:25)",
    8        "    at async ModuleLoader.import (node:internal/modules/esm/loader:336:24)",
    9        "    at async _tryAwaitImport (file:///var/runtime/index.mjs:1008:16)",
    10        "    at async _tryRequire (file:///var/runtime/index.mjs:1057:86)",
    11        "    at async _loadUserApp (file:///var/runtime/index.mjs:1081:16)",
    12        "    at async UserFunction.js.module.exports.load (file:///var/runtime/index.mjs:1119:21)",
    13        "    at async start (file:///var/runtime/index.mjs:1282:23)",
    14        "    at async file:///var/runtime/index.mjs:1288:1"
    15    ]
    16}
    17

    node18로 올렸다면… 맞닥뜨리게될 esm 관련 에러 (node18부터는 기본 ESM 환경이기에)

    esm 모듈시스템 문법에 맞춰 index.js의 코드를 재작성해야했습니다.

    4차 시도

  • aws-sdk v3 + ESM 호환 필요
  • sharp@0.32.6으로 다운그레이드 후 성공
  • ⇒ sharp버전을 0.32.6으로 낮췄습니다.

    ⇒ 람다 함수 테스트는 성공

    *로컬에서 작성한 람다 함수를 .zip으로 압축한 뒤 업로드하여 테스트 하는 방법

    Lambda > Test 탭의 기본 제공 템플릿 중 cloudfront-modify-querystring 선택 후 Test

    테스트 버튼 클릭시 위와 같이 결과값이 나오며 하이퍼 링크된 logs를 누르면 cloudwatch로 이동해 세세한 로그를 확인할 수 있습니다.

    5차 시도

    5차 시도 - AccessDenied(403)

    AWS S3에서 객체를 가져오려 할 때 권한 문제가 있거나 경로 설정이 잘못되어 접근을 못할 때 나는 에러입니다.

    IAM role / policy & S3 Bucket policy를 살펴봤지만 이 문제는 아니였습니다.

    아래 같이 찍힌 에러 로그 중 404 NoSuchKey 항목을 보고 객체를 접근하는 경로가 문제가 되고 있음을 파악해 함수 내부를 뜯어보기 시작했습니다.

    handler함수 내부에

    javascript
      const getObjectParams = {
          Bucket: BUCKET,
          Key: decodeURI("bus/image/" + imageName + "." + extension),
        };

    Key프로퍼티의 value가 기존에는 ‘/’였으나 해당 버킷의 구조가 /bus/image/이미지들.svg 이런식임을 감안해 수정해주었습니다.

    ⇒ NoSuchKey 로그를 보고 S3 Key 경로 수정 필요

    마지막 에러

    Sharp - "Input file is missing”

    query params는 잘 읽으나.. Input file is missing이라는 에러 로그가 확인되었고…

  • AWS SDK v3의 getObject().Body는 ReadableStream 반환하기에
  • Sharp에서 바로 처리 불가 → Buffer로 변환 필요하다
  • 라는 점을 알게되었습니다.

    🔗 관련 stackoverflow

    data의 body값으로 온 ReadableStream객체를 Buffer객체로 변환하는

    javascript
    1const streamToBuffer = async (stream) => {
    2  const chunks = [];
    3  for await (const chunk of stream) {
    4    chunks.push(chunk);
    5  }
    6  return Buffer.concat(chunks);
    7};
    8

    아래 함수를 추가하여 return된 Buffer를 Sharp함수에 전달하여 이미지를 로드합니다.

    Sharp에서 이미지 크기를 조정하고 포맷 변경 후 다시 Buffer객체로 변환한 다음, 변환된 이미지의 크기가 1MB를 초과하지 않으면 처리된 이미지를 base64인코딩하여 HTTP 응답에 포함시키고, 변환된 이미지의 MIME타입을 명시합니다. 최종 응답 객체는 callback을 통해 반환합니다.

    기존 esm + sdk3만 적용했던 코드▾
    javascript
    1"use strict";
    2import querystring from "querystring";
    3import Sharp from "sharp";
    4
    5import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
    6
    7const S3 = new S3Client({ region: "ap-northeast-2" });
    8
    9const BUCKET = "버킷명";
    10
    11export const handler = async (event, context, callback) => {
    12  const { request, response } = event.Records[0].cf;
    13  // Parameters are w, h, f, q and indicate width, height, format and quality.
    14  const params = querystring.parse(request.querystring);
    15
    16  if (!params.w && !params.h) {
    17    return callback(null, response);
    18  }
    19
    20  const { uri } = request;
    21  const [, imageName, extension] = uri.match(/\/?(.*)\.(.*)/);
    22
    23
    24  if (extension === "gif" && !params.f) {
    25    return callback(null, response);
    26  }
    27
    28  // Init variables
    29  let width;
    30  let height;
    31  let format;
    32  let quality;
    33  let s3Object;
    34  let resizedImage;
    35
    36  width = parseInt(params.w, 10) ? parseInt(params.w, 10) : null;
    37  height = parseInt(params.h, 10) ? parseInt(params.h, 10) : null;
    38
    39  if (parseInt(params.q, 10)) {
    40    quality = parseInt(params.q, 10);
    41  }
    42
    43  format = params.f ? params.f : extension;
    44  format = format === "jpg" ? "jpeg" : format;
    45
    46  // For AWS CloudWatch.
    47  console.log(`parmas: ${JSON.stringify(params)}`); // Cannot convert object to primitive value.
    48  console.log(`name: ${imageName}.${extension}`); // Favicon error, if name is `favicon.ico`.
    49
    50  try {
    51    s3Object = await S3.GetObjectCommand({
    52      Bucket: BUCKET,
    53      Key: decodeURI(imageName + "." + extension),
    54    }).promise();
    55  } catch (error) {
    56    console.log("S3.GetObjectCommand: ", error);
    57    return callback(error);
    58  }
    59
    60  try {
    61    resizedImage = await Sharp(s3Object.Body)
    62      .resize(width, height)
    63      .toFormat(format, {
    64        quality,
    65      })
    66      .toBuffer();
    67  } catch (error) {
    68    console.log("Sharp: ", error);
    69    return callback(error);
    70  }
    71
    72  const resizedImageByteLength = Buffer.byteLength(resizedImage, "base64");
    73  console.log("byteLength: ", resizedImageByteLength);
    74
    75  if (resizedImageByteLength >= 1 * 1024 * 1024) {
    76    return callback(null, response);
    77  }
    78
    79  response.status = 200;
    80  response.body = resizedImage.toString("base64");
    81  response.bodyEncoding = "base64";
    82  response.headers["content-type"] = [
    83    {
    84      key: "Content-Type",
    85      value: `image/${format}`,
    86    },
    87  ];
    88  return callback(null, response);
    89};
    90

    최종 Lambda 함수 코드 (ESM + SDK v3)

    javascript
    1"use strict";
    2import querystring from "querystring";
    3import Sharp from "sharp";
    4import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
    5
    6const S3 = new S3Client({ region: "ap-northeast-2" });
    7const BUCKET = "버킷명";
    8
    9const streamToBuffer = async (stream) => {
    10  const chunks = [];
    11  for await (const chunk of stream) {
    12    chunks.push(chunk);
    13  }
    14  return Buffer.concat(chunks);
    15};
    16
    17export const handler = async (event, context, callback) => {
    18  const { request, response } = event.Records[0].cf;
    19  const params = querystring.parse(request.querystring);
    20
    21  if (!params.w && !params.h) {
    22    return callback(null, response);
    23  }
    24
    25  const { uri } = request;
    26  const [, imageName, extension] = uri.match(/\/?(.*)\.(.*)/);
    27
    28  if (extension === "gif" && !params.f) {
    29    return callback(null, response);
    30  }
    31
    32  let width = parseInt(params.w, 10) || null;
    33  let height = parseInt(params.h, 10) || null;
    34  let quality = parseInt(params.q, 10) || 80; // NaN이면 기본값으로 80을 설정
    35  let format = params.f ? params.f : extension;
    36  format = format === "jpg" ? "jpeg" : format;
    37
    38  console.log(`params: ${JSON.stringify(params)}`);
    39  console.log(`name: ${imageName}.${extension}`);
    40
    41  try {
    42    const getObjectParams = {
    43      Bucket: BUCKET,
    44      Key: decodeURI("bus/image/" + imageName + "." + extension),
    45    };
    46    const command = new GetObjectCommand(getObjectParams);
    47    const data = await S3.send(command);
    48
    49    const bodyBuffer = await streamToBuffer(data.Body);
    50
    51    let resizedImage = await Sharp(bodyBuffer)
    52      .resize(width, height)
    53      .toFormat(format, { quality })
    54      .toBuffer();
    55
    56    const resizedImageByteLength = Buffer.byteLength(resizedImage, "base64");
    57    console.log("byteLength: ", resizedImageByteLength);
    58
    59    if (resizedImageByteLength >= 1 * 1024 * 1024) {
    60      return callback(null, response);
    61    }
    62
    63    response.status = 200;
    64    response.body = resizedImage.toString("base64");
    65    response.bodyEncoding = "base64";
    66    response.headers["content-type"] = [
    67      { key: "Content-Type", value: `image/${format}` },
    68    ];
    69
    70    return callback(null, response);
    71  } catch (error) {
    72    console.error("Error: ", error);
    73    return callback(error);
    74  }
    75};
    76

    기쁩니다,,,

    배포 및 테스트

    Lambda@Edge 트리거 연결

  • CloudFront Distribution의 Origin Request 이벤트에 연결
  • ZIP 업로드 & 테스트

  • .zip으로 압축한 후 업로드
  • Lambda 콘솔에서 cloudfront-modify-querystring 템플릿 선택 후 Test 진행
  • 테스트

    결과 확인

  • CloudWatch에서 상세 로그 확인
  • 에러 메시지를 보면 디버깅 힌트를 빠르게 얻을 수 있음
  • 결론 및 개선 효과

  • LCP 7초 → 2초 이내로 단축
  • 무거운 이미지도 유저에게 빠르게 전달 가능
  • GIF / PNG → WebP 변환, 해상도 조절도 가능
  • AWS 비용도 절감됨 (S3 bandwidth 감소)
  • Github Repo 코드 보러가기!

    https://github.com/reeseo3o/image-resizing-with-lambda-edge

    참고 자료

    Lambda@Edge AWS 문서