티스토리 뷰

배경

현재 프로젝트로 개발중인 snappy라는 서비스에서는 여러 사용자가 촬영한 사진을 다음과 같이 보여주게된다.

 

현재 각각의 사진들은 사용자가 촬영한 원본 사진 그대로 홈화면에서 사용중이다. 서비스의 특성상 사진을 확인하는 사용자가 많을텐데 이렇게 원본 사진을 그대로 사용자에게 보여주는 것은 네트워크 지연뿐만아니라 네트워크 트래픽도 많아져 aws에 지불해야할 요금이 서비스가 커지면 커질수록 높아질 것이다. 또한 사진뿐만아니라 모임의 logo 이미지 등 원본 이미지의 크기가 필요없는곳에 불필요하게 원본 이미지 크기만큼 다운받고 있어 이또한 문제가 된다.  

 

잘 생각해보면, UI에 맞는 크기의 이미지를 불러오면 원본 이미지를 불러오는 것에 비해 속도도 더 빠르고 네트워크 트래픽도 더 낮아질 것이다.

그럼 어떻게 이미지 크기를 UI에 맞는 다양한 크기로 변환할 수 있을까?
열심히 구글링을 한 결과 이미지의 다양한 크기를 어떻게 생산하고 전달하는지에 따라 다음과 같은 방법들이 있다는 것을 알게 되었다. 

  • 원본 이미지가 생성될때 백그라운드 작업으로 다양한 크기의 이미지를 생성 후 파일로 저장
  • 클라이언트에서 썸네일을 요청할 때 실시간으로 이미지를 리사이징하여 제공 

원본 이미지가 생성될때 백그라운드 작업으로 다양한 크기의 이미지를 생성 후 파일로 저장

해당 방식은 어떻게 구현할 수 있을까? 그리고 장단점이 뭘까?

먼저 우리팀은 AWS S3를 이용하여 이미지를 업로드 하고 있다. S3와 AWS Lambda를 활용하면 이를 구현할 수 있다.

클라이언트가 저장할 이미지를 API 서버로 보내게 되면 API 서버는 해당 이미지를 S3 저장소로 업로드하게 된다. 이때 S3에서 S3:ObjectCreated:Put 이벤트를 발행한다. AWS Lambda의 이벤트 트리거가 해당 이벤트를 받아 클라이언트에서 사용하는 다양한 이미지 크기로 리사이징하여 S3에 저장한다. 예를 들어 API 서버에서 /image 경로에 example.png 파일을 S3에 업로드하게 되면 AWS Lambda에 의해 /image/resize/s, /image/resize/l 경로에 이미지 크기에 맞게 저장하게 되는 것이다. 

 

해당 방식의 장점과 단점

먼저 이미지 리사이징이 API 서버가 아닌 AWS Lambda에서 실행이 되니 API 서버에 부담을 주지 않는다. 그리고 리사이징된 이미지가 S3 버킷에 저장이되니 한번 리사이징한 이미지는 삭제되지 않는한 다시 리사이징 될 필요가 없다. 

 

하지만 문제점도 있다. 여러 크기의 이미지를 하나의 Lambda 함수에서 생성하니 요청이 몰릴경우 제대로 응답하지 못하는 경우도 생긴다. 또한 다양한 크기의 이미지를 S3 버킷에 저장하게 되니 S3 저장소 사용량도 증가하게 된다. 단순하게 생각해봐도 1개의 이미지를 업로드할때 2개의 이미지가 더 생기게 된다하면 기존의 방식보다 S3 저장소의 사용량이 3배가 증가하게 된다. 

또한 클라이언트에서 필요한 이미지 사이즈 또는 포맷이 변경될 경우 S3에 저장된 리사이징된 이미지 크기를 재생성 해야 하는 일이 발생할 수도 있다.

 

S3 저장소의 용량이 늘어난다는 것은 요금이 그만큼 올라간다는 것과 같다. snappy 서비스 특성상 한 모임에서 사용자는 10개의 이미지를 생성할 수 있게 된다. 하지만 모임이 많아지고 사용자가 많아지면 그만큼 많은 사용자가 이미지를 업로드할 수 있게 된다. 이에따라 S3 저장소의 용량이 늘어나게 되는데 이미지 리사이징을 통해 여기에 더해 더 많은 이미지를 저장하게 된다면 요금은 나날이 늘어나게 될 것이다. 
그래서 S3 저장소의 용량이 늘어나지 않으면서도 이미지 리사이징을 할 수 있는 다른 방법을 찾아야했다. 

 

On-The-Fly 이미지 리사이징의 도입

On-The-Fly 이미지 리사이징은, 클라이언트에서 필요한 이미지 크기에 맞게 이미지를 요청할 때 실시간으로 이미지를 리사이징하여 제공하는 방식이다. 그래서 이미지가 업로드되고 처음 이미지를 요청하는 사용자는 이미지가 리사이징될때까지 기다려야된다. 하지만 이렇게 리사이징된 이미지는 CDN을 통해 캐싱이 되고 다음 사용자부터 CDN에 캐싱된 이미지를 응답받기 때문에 사용자 경험을 크게 해치지 않는다. 

 

On-The-Fly 방식으로 이미지 리사이징을 할 경우 S3에 리사이징된 이미지를 저장하지 않으니 위에서 고려한 S3 저장소의 용량이 늘어나는 문제를 해결할 수 있다. 특히, 구글에서 공개한 Webp, Alliance for Open Media에서 개발한 AVIF를 이미지 포맷으로 제공할 수 있게 된다. 이미지 크기뿐만 아니라 이미지 포맷또한 클라이언트에 맞추어 최적화 한다면 서버의 이미지 트래픽을 대폭 줄일 수 있다. 

원본이미지는 https://gusrb3164.github.io/web/2021/11/26/browser-image-optimzing/을 참고하였습니다.

 

그럼 어떻게 On-The-Fly 방식을 구현할 수 있을까? 일단 이미지 리사이징을 위한 서버가 필요할 것이다. 그리고 snappy에서 CDN으로 사용중인 CloudFront와 S3사이에 요청, 응답 흐름을 빼앗아 제어할 수 있는것이 필요하다. 놀랍게도 이런것이 가능하게 AWS에서 이미 제공해주고 있다. 즉 서버를 두지 않고 CloudFront와 S3사이에 응답 흐름을 제어하여 이미지 리사이징을 해주는 Lambda@Edge를 도입하기로 하였다. 

Lambda@Edge

Lambda@Edge는 cloudfront의 기능이다. 별도의 API Gateway 없이, CloudFront에 의해 생성된 이벤트를 트리거로 하여 함수를 실행할 수 있다. 4가지 이벤트를 사용할 수 있는데 다음과 같다. 

  • Viewer Request – CloudFront가 뷰어로부터 요청을 수신하고 요청된 객체가 엣지 캐시에 있는지 확인하기 전에 실행된다.
  • Origin Request – CloudFront가 요청을 오리진에 전달할 때만 실행된다. 요청된 객체가 엣지 캐시에 있는 경우 이 함수는 실행되지 않는다.
  • Origin Response – CloudFront가 원본에서 응답을 수신하고 응답에서 객체를 캐싱하기 전에 실행된다.
  • Viewer Response – 요청된 객체를 뷰어에 반환하기 전에 실행된다. 이 함수는 객체가 이미 엣지 캐시에 있는지 여부에 관계없이 실행된다.

이 중 나는 Origin Response를 Lambda의 트리거로 사용하여 이미지 리사이징을 구현하였다.

 

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

위의 그림과 같이 On-The-Fly 이미지 리사이징이 동작되게 구현하였다. 다음은 예시이다.

  1. 클라이언트는 300x300 사이즈인 AVIF 포맷의 썸네일을 다음 URL과 같이 요청하게 된다.
    https://CDN_ID.cloudfront.net/sample.jpg?w=300&h=300&f=avif  
  2. 이때 CloudFront에서 캐싱되어 있다면 cache hit이 발생하고 이미지는 CloudFront 캐시에서 반환된다. 
  3. 캐싱되어 있지 않다면 S3의 응답 이벤트가 트리거가 되어 Lambda@Edge 함수를 동작시킨다. 해당 함수에서 주어진 파라미터에 따라 이미지가 리사이징 되고 그 결과가 CloudFront에 캐싱된다. 
  4. 이제 클라이언트는 캐싱되어 있는 이미지를 곧바로 CDN에서 제공받을 수 있게된다.

내가 작성한 Lambda@Edge 코드는 다음과 같다.

const sharp = require("sharp");
const { S3Client, GetObjectCommand } = require("@aws-sdk/client-s3");

// AWS SDK v3 클라이언트 설정
const s3 = new S3Client({
  region: "ap-northeast-2",
});

const BUCKET = "dnd-11th-6";
const DEFAULT_QUALITY = 80;
const DEFAULT_TYPE = "contain";

exports.handler = async (event, _, 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);

  // width와 height가 모두 없으면 원본 이미지를 반환
  if (!searchParams.get("w") && !searchParams.get("h")) {
    return callback(null, response);
  }

  const { uri } = request;
  const match = uri.match(/\/?(.*)\.(.*)/);
  if (!match) {
    // 유효하지 않은 URI 형식 처리
    response.status = "400";
    response.statusDescription = "Bad Request";
    response.body = "Invalid image URI.";
    response.headers["content-type"] = [
      { key: "Content-Type", value: "text/plain" },
    ];
    return callback(null, response);
  }
  const imageName = match[1];
  const extension = match[2].toLowerCase();

  const width = searchParams.get("w")
    ? parseInt(searchParams.get("w"), 10)
    : null;
  const height = searchParams.get("h")
    ? 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;

  try {
    const s3Object = await getS3Object(s3, BUCKET, `${imageName}.${extension}`);
    let image = sharp(s3Object.Body).rotate();
    const metaData = await image.metadata();

    // 원본 이미지가 지정된 크기보다 클 때만 리사이징
    if (
      (width && metaData.width > width) ||
      (height && metaData.height > height)
    ) {
      image = image.resize(width, height, {
        fit: type,
        withoutEnlargement: true,
      });
    }

    // 포맷 변환 및 품질 설정
    image = image.toFormat(format, { quality });
    const resizedBuffer = await image.toBuffer();

    response.status = "200";
    response.body = resizedBuffer.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" },
    ];
    return callback(null, response);
  } catch (error) {
    console.error("Error processing image:", error);
    response.status = "500";
    response.statusDescription = "Internal Server Error";
    response.body = "Error processing image.";
    response.headers["content-type"] = [
      { key: "Content-Type", value: "text/plain" },
    ];
    return callback(null, response);
  }
};

async function getS3Object(s3Client, bucket, key) {
  try {
    const command = new GetObjectCommand({
      Bucket: bucket,
      Key: decodeURI(key),
    });
    const s3Object = await s3Client.send(command);

    // Body는 ReadableStream 형태이므로, Buffer로 변환 필요
    const streamToBuffer = stream =>
      new Promise((resolve, reject) => {
        const chunks = [];
        stream.on("data", chunk => chunks.push(chunk));
        stream.on("end", () => resolve(Buffer.concat(chunks)));
        stream.on("error", reject);
      });

    const body = await streamToBuffer(s3Object.Body);
    return { ...s3Object, Body: body };
  } catch (error) {
    console.error("s3.getObject error:", error);
    throw new Error("Could not retrieve image from S3.");
  }
}

 

결과

Lambda@Edge를 통한 이미지 리사이징을 도입하여 한 이미지에 대해 테스트한 결과는 다음과 같다. 

 

처음 리사이징 없이 한개의 이미지 요청시 다음과 같은 결과를 얻었다.

 

이미지의 크기는 245KB이고 시간은 192밀리초가 걸린것을 볼 수 있다.

 

다음은 홈 화면에 필요한 크기(250x250) 으로 리사이즈하고 webp형식으로 변환을 위해 w=250&h=250&f=webp 쿼리 파라미터를 붙여 요청한 결과이다. 

 

크기는 13.2KB로 이전에 245KB인거에 비해 확연히 줄어든것을 볼 수 있다. 하지만 아직 CDN에 캐싱이 안돼 시간은 219밀리초로 이전에 192밀리초가 걸린것보다 오래 걸린것을 확인할 수 있다.

 

응답 헤더를 보면 X-Cache에 Miss fron cloudfront가 있는것을 확인할 수 있다. 

 

같은 URL로 한번더 요청을 해보았다.

 

시간이 31밀리초로 아주적게 걸린것을 확인할 수 있다.

 

이유는 X-Cache에 Hit from cloudfront를 통해 알 수 있듯이 CloudFront에 캐싱된 이미지를 가지고 왔기 때문이다. 즉 이미지가 업로드되고 처음 이미지를 요청하는 사용자는 이미지가 리사이징될때까지 기다려야된다. 하지만 이렇게 리사이징된 이미지는 CDN을 통해 캐싱이 되고 다음 사용자부터 CDN에 캐싱된 이미지를 응답받기 때문에 사용자 경험을 크게 해치지 않는다. 

 

이를 종합적으로 분석하면 다음과 같은 결과를 확인할 수 있었다.

 

snappy에서 홈화면 첫페이지에 대해 필요한 이미지 개수는 10개이다. 평균적으로 이미지의 크기는 200KB이다. 즉, 홈화면의 첫 페이지에서 사용자가 이미지를 로드할 때 총 약 2MB의 데이터가 전송된다. 그러나 Lambda@Edge를 통해 이미지가 250x250 크기로 리사이징되고 WebP 형식으로 변환되면 각 이미지의 크기가 평균적으로 15KB로 감소하여 총 150KB로 줄어든다. 이는 약 92%의 데이터 절감 효과를 가져왔다.

위 예시에서 초기 요청 시 리사이징 과정으로 인해 첫 번째 사용자는 약 219밀리초의 시간이 소요되지만, 이후의 요청은 CloudFront 캐시에 의해 31밀리초로 단축된다. 이러한 캐싱 메커니즘을 고려할 때, 첫 번째 사용자는 약간의 지연을 경험할 수 있으나, 이후 사용자는 빠른 응답 속도를 누릴 수 있다. 전체적으로 보면, 초기 데이터 전송량이 크게 줄어들어 사용자에게 더 빠른 페이지 로딩 경험을 제공하게 되었다.

 

구현할때 주의사항

Lambda@Edge코드 작성을 위해 Sharp라이브러리를 설치할텐데 MAC중 M시리즈를 사용중이라면 이를 압축해 Lambda@Edge로 배포하게 된다면 503에러가 날것이다. (나도 그랬다..) 이는 Lambda@Edge는 arm 아키텍처를 지원하지 않아서인데 이걸 해결하기 위해서는 Docker를 활용해서 node_modules을 mac에서 만들어내는 것이 아닌 Docker환경에서 만들어진 node_modules을 사용해야한다. 

나는 다음 블로그를 활용하여 이를 해결하였다.

 

https://velog.io/@ycoding/람다와-ARM-아키텍처-오류-해결

 

람다와 ARM 아키텍처 오류 해결

해당 포스트는 AWS Lambda x86_64 아키텍처를 ARM 기반의 로컬에서 작성하시는 분들 중 같은 오류(Cannot find module / Could not load module) 를 겪는 분들을 위해 작성되었습니다.

velog.io

 

또한 Lambda@Edge에서 Node 18버전 이상부터는 AWS SDK 2버전 지원을 중단하였다. 그래서 AWS SDK 3버전을 이용해서 코드를 작성해야 오류가 발생하지 않는다. 나같은 경우에도 SDK 2버전을 이용해 코드를 작성하니 오류가 발생해 SDK 3버전을 이용해 다시 코드를 작성하였다.

https://github.com/aws-amplify/amplify-cli/issues/13278#issuecomment-1739700795

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함