AWS

[AWS] 이미지 업로드/읽기 속도 향상기 - Signed URL, Lambda@Edge, Cloud Front

폭풍저그김탁구 2022. 7. 26. 13:19

항상 어플의 속도에서 문제되던 것이 있다. 바로 이미지 로딩 속도.

이미지 로딩이 느리니 아무리 API가 빨리 돌아가도 그냥 느려보이는 것이었다.

항상 방법을 찾아야지 찾아야지 하다가 그냥 개발에 급급해 미뤄두던 문제를... 드디어 해결하려 한다.

 

 

 


 

 

* 기존의 이미지 업로드/쓰기 방식

먼저 Client에서 서버로 form-data에 이미지 파일을 바이너리로 보낸다. 서버는 busboy module로 이미지를 받고 S3에 직접 PUT한다. 또 Jimp 모듈을 이용해 이미지를 리사이징해, 원본이미지와 썸네일 이미지를 모두 저장한다. 그리고 이 이미지 URL을 DB에 저장한다.

이때 S3 버킷을 퍼블릭으로 권한을 설정해 두어 이미지 URL로 바로 Client가 읽을 수 있도록 하는 게 기존의 방식이었다.

 

위의 방식은 문제가 많았다.

1. 서버에 무리가 감

2. 속도가 느림

3. S3 보안 문제

... 등등

문제 투성이었다. 

 

 

 


 

 

 

* 새로 도입할 이미지 업로드 방식

새로운 방식은 Pre-Signed URL(미리 서명된 URL)을 이용해 Client에서 이미지를 업로드 하는 방식이다. 서버에 URL을 요청하면 서버는 signed-url을 반환하고, 그 url로 client가 이미지를 put하는 것이다.

 

이미지 업로드

 

구체적인 작동 방식은 다음과 같다.

  1. Client: 해당 bucket으로 key와 함께 url 요청
  2. Server: 해당 bucket과 key로 put 하는 signed URL 생성 후 반환
  3. Client: url로 이미지 업로드
  4. Client: key를 Server로 post
  5. Server: DB에 key insert

(이 방식은 각 앱에서 어떤 방식으로 DB를 쓰고 있냐에 따라 다를 수 있겠다.)

 

나에게서는 원래 key를 서버에서 만들고, 그 key로 S3에 업로드하고, 그 url과 key를 DB에 저장했다.

하지만 이제 CF를 도입했고 client에서 이미지를 업로드 하기 때문에 방식을 Client 중심으로 바꿨다.

key만 알면 client가 이미지를 맘대로 가져올 수 있으므로(도메인도 알긴 해야 한다. 하지만 CF 도메인이므로 보안에 좀 더 낫다고 할 수 있음) 이미지 url을 아예 DB에 저장하지 않으려고 한다.

 


 

먼저 signed-url을 반환할 api를 만든다.

static async getUrl(bucket: string, key: string) {
    //Expires: 60s
    try {
      const params = {
        Bucket: bucket,
        Key: key,
        Expires: 60,
      };
      const urlResult = await s3.getSignedUrl('putObject', params);
      return urlResult;
    } catch (error) {
      throw new Error(`S3 GET SIGNED URL/${error}`);
    }
  }

getUrl이라는 함수를 만들어 사용했다. expires는 60초로 잡았다. 적당한 시간인지는 잘 모르겠다.

테스트는 Postman으로 해봤고

위 getUrl로 반환받은 url로 binary에 사진을 넣어서 보내면,  S3에 잘 올라가 있는 걸 확인할 수 있다.

 

이제 이미지 리사이징을 진행해야 하는데, 여기서 두 가지를 선택할 수 있다.

 

하나는 On-The-Fly 방식으로 실시간으로 이미지를 리사이징 하는 방법과, 미리 크기 별로 S3에 저장하는 것이다.

나는 전자를 선택했다. 하지만 상황에 따라 후자가 좋을 수도 있으니 개발자가 적절히 선택해야 한다.

만약 후자를 선택한다면 S3에 PUT되는 상황을 트리거로 놓고, Lambda@Edge 함수를 만들어 이 함수가 리사이징+DB에 URL 업데이트를 진행하게 하면 된다.

 

하지만 나는 전자를 선택했으므로, 이미지 리사이징은 이미지를 읽는 상황에 진행하면 된다. 그러니 이미지 업로드에 관한 준비는 모두 끝났다고 볼 수 있다.

 

 

참고 자료

https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/PresignedUrlUploadObject.html

https://campkim.tistory.com/66

 

AWS S3 Client side에서 이미지 업로드하기

진행중인 프로젝트에서 이미지 저장용으로 S3를 사용해보려고 한다. S3 뜻은 별거없다 Simple Storage Service. S가 3개라... S3.... 이미지 업로드에서 중점적으로 고민하고 있는 부분 중 하나는 서버에 I/

campkim.tistory.com

 

미리 서명된 URL을 생성하여 객체 업로드 - Amazon Simple Storage Service

미리 서명된 URL을 생성하여 객체 업로드 미리 서명된 URL의 생성자가 해당 객체에 대한 액세스 권한을 보유할 경우, 미리 서명된 URL은 URL에서 식별된 객체에 대한 액세스를 부여합니다. 즉, 객체

docs.aws.amazon.com

 

 

 


 

 

* 새로 도입할 이미지 읽기 방식

읽기 방식은 상당히 복잡하다.

이미지 가져오기

지금까지는 Client와 S3만 있던 것에 비해 상당히 고도화가 됐다.

 

  1. Client는 서버에서 받아온 Key로 CF에 이미지를 요청한다.
  2. CF는 캐시 이미지가 있다면 바로 그 이미지를 반환한다(Cache Hit)
  3. 캐시 이미지가 없다면(Cahce Miss) Lambda Edge에 쿼리에 맞게 이미지 리사이징을 요청한다.
  4. 람다는 S3 버킷에 접속해 이미지를 리사이징 하고 CF에 리사이징한 이미지를 캐싱한다.
  5. CF는 Client에 그 이미지를 반환한다.

의 순서다. 

 

 


 

 

우선 Cloud Front이라는 것을 만들어보자.

 

Cloud Front이란 무엇이고 왜 써야 할까? 다음은 AWS 독스의 설명이다.

Amazon CloudFront는 .html, .css, .js 및 이미지 파일과 같은 정적 및 동적 웹 콘텐츠를 사용자에게 더 빨리 배포하도록 지원하는 웹 서비스입니다. CloudFront는 엣지 로케이션이라고 하는 데이터 센터의 전 세계 네트워크를 통해 콘텐츠를 제공합니다. CloudFront를 통해 서비스하는 콘텐츠를 사용자가 요청하면 지연 시간이 가장 낮은 엣지 로케이션으로 요청이 라우팅되므로 가능한 최고의 성능으로 콘텐츠가 제공됩니다.

심플하게 말하면 세계 곳곳의 엣지 로케이션에 사본을 만들어두어 빨리 이미지를 로드하겠다는 말이다.

또 내가 Cloud Front를 써야 했던 큰 이유 중 하나가, Cloud Front로 이미지를 읽기 때문에 버킷을 퍼블릭으로 설정하지 않아도 된다는 점이다. 버킷은 프라이빗으로 하고, Cloud Front의 도메인으로 이미지를 읽는 것이다.

Cloud Front 생성과 버킷 권한 설정은 참고자료 2-1 블로그를 참고했다.

 

 

 

참고 자료

https://docs.aws.amazon.com/ko_kr/AmazonCloudFront/latest/DeveloperGuide/Introduction.html

 

Amazon CloudFront란 무엇입니까? - Amazon CloudFront

Amazon CloudFront란 무엇입니까? Amazon CloudFront는 .html, .css, .js 및 이미지 파일과 같은 정적 및 동적 웹 콘텐츠를 사용자에게 더 빨리 배포하도록 지원하는 웹 서비스입니다. CloudFront는 엣지 로케이션

docs.aws.amazon.com

2-1)

https://velog.io/@metamata/AWS-S3-CloudFront%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-CDN-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0

 

AWS S3, CloudFront를 이용한 CDN 구축하기

Amazon CloudFront는 .html, .css, .js 및 이미지 파일과 같은 정적 및 동적 웹 콘텐츠를 사용자에게 더 빨리 배포하도록 지원하는 웹 서비스CloudFront는 엣지 로케이션이라고 하는 데이터 센터의 전 세계

velog.io

 

 


 

다음은 Lambda@Edge를 만들 차례다.(이하 엣지)

람다 엣지로 이미지를 리사이징 하는 예제는 정말 많다... 이게 AWS가 맞나 싶을 정도로 대단한 개발자 분들이 글을 열심히 써주셨다. 블로그 만세다... AWS 독스도 친절하게 잘 써있으니 글을 적당히 보고 잘 조합해 쓰면 된다.

 

엣지를 만드는 방법은 두 가지 정도가 있다.

  1. 로컬에서 만들고 zip 파일 업로드하기
  2. aws cloud 9이라는 IDE 쓰기

나는 윈도우를 쓰기도 하고, 이전에 sharp가 잘 안 됐던 기억이 있어서 2번을 쓰기로 했다. 

cloud 9도 처음 써보지만... 도전...

 

글 쓰는 시점에서 Cloud 9은 서울 리전을 지원하고 있다. 하지만 내가 따라한 예제들이 모두 지원하지 않는 시점에 작성이 되었고, Cloud Front는 서울 리전을 지원하고 있지 않으니 그냥 처음부터 미국 리전으로 시작했다.

괜히 aws님께 도전하면 안 되는 게 철칙,,, 겸손하게 시작합니다. (다른 lambda는 서울 리전을 사용하고 있습니다.)

 

 


 

1)

우선 IAM을 설정한다. 참고자료 3-1 참고.

나는 기존에 쓰던 Role이 있어 거기에 정책을 연결해주었다.

 

 


 

2)

다음은 Lambda에 들어가 함수를 생성해준다. 런타임은 node.js 14.x -> node.js12.x로 했다. (아래 삽질 기록 참고,,)

생성 후 구성에 들어가 제한시간을 10초로 설정해준다.

 


 

 

3)

Cloud 9으로 코딩해주자.

Cloud 9 환경을 세팅해주고 (t3.nano를 사용)

download를 눌러 방금 생성한 람다를 불러온다. 옛날엔 import였다가 이름이 바뀐 것 같다. 한참 찾았음..ㅜ

 

 


 

 

4) sharp 모듈을 설치한다.

$ npm init -y
$ npm install sharp

 

 


 

 

5) 3-1의 코드를 참고해 사용한다. 그대로 사용해도 문제 없다. (감사합니다!)

그리고 upload, 배포까지 하면 사용할 수 있다.

 

혹시 몰라 내 코드도 아래 첨부한다.

더보기

지금까지 확장자가 없이 저장해서, 확장자 판별하는 코드를 버렸다. 

또 비동기 관련해서 살짝 수정했다.

'use strict';

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

const S3 = new AWS.S3({
  signatureVersion: 'v4',
  region: 'ap-northeast-2'  // 버킷을 생성한 리전 입력
});

const BUCKET = '***'; // Input your bucket

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

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

  // Init format.
  format = format === 'jpg' ? 'jpeg' : format;

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

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

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

  try {
    await Sharp(s3Object.Body)
      .resize(width, height)
      .toFormat(format, {
        quality
      })
      .toBuffer()
      .then(buffer => {
        // 응답 이미지 용량이 1MB 이상일 경우 원본 반환.
        if (Buffer.byteLength(buffer, 'base64') >= 1048576) {
          responseHandler(
            404,
            'Not Found',
            'the image is too big.', 
            [{ key: 'Content-Type', value: 'text/plain' }],
          );
          return callback(null, response);
        }
        // response에 리사이징 한 이미지를 담아서 반환합니다.
        responseHandler(
          200,
          'OK',
          buffer.toString('base64'), 
          [{
            key: 'Content-Type',
            value: `image/${format}`
          }],
          'base64'
        );
        callback(null, response);
      }
    );
  }
  catch (error) {
    responseHandler(
      500,
      'Internal Server Error',
      'Fail to resize image.', 
      [{
        key: 'Content-Type',
        value: 'text/plain'
      }],
    );
    return callback(null, response);
  }

  function responseHandler(status, statusDescription, body, contentHeader, bodyEncoding) {
    response = {
      status: status,
      statusDescription: statusDescription,
      headers: {
        'content-type': contentHeader
      },
      body: body,
    };
    if (bodyEncoding) {
    response.bodyEncoding = bodyEncoding;
  }
  }
  console.log('Success resizing image');
  return callback(null, response);
};

 

 

 

 


 

이렇게 하면 이미지 크기를 줄이고 이미지 형식을 webp로 가져올 수 있으므로 로딩 속도가 확연히 준다.

이제 직접 어플에서 테스트만 해보면 된다.

잘 되길 바라며...

 

혹시 나같이 며칠 삽질할 사람들을 위해 아래 삽질 기록을 남깁니다.

엄청난 구글링이 당신을 기다리고 있을 예정^^,,,~


 

* 삽질 기록

쉽게만 살아가면 재미없어 빙고

역시나. 기대도 안 했다. 솔직히 한 번에 됐어도 매우 무서웠을 것 같다.

 

1. 람다 함수의 문제

우선 람다 단의 문제는 AWS 콘솔의 Lambda Event Test로 해결한다.

Lambda Test

버지니아 리전으로 접속, 해당 람다에 들어가서 테스트를 만들어 로그를 보내면 된다.

 

이벤트 JSON은 AWS에서 제공하는 템플릿을 사용했다.

{
  "Records": [
    {
      "cf": {
        "config": {
          "distributionId": "EXAMPLE"
        },
        "request": {
          "uri": "/${key}",
          "querystring": "w=200&h=150&f=png&q=90",
          "method": "GET",
          "clientIp": "****:****::****:****",
          "headers": {
            "host": [
              {
                "key": "Host",
                "value": /${CF-Domain}
              }
            ],
            "user-agent": [
              {
                "key": "User-Agent",
                "value": "Test Agent"
              }
            ],
            "user-name": [
              {
                "key": "User-Name",
                "value": "aws-cloudfront"
              }
            ]
          }
        }
      }
    }
  ]
}

 

람다에서 문제가 없는 걸 확인하면 트리거가 잘 걸렸는지 확인해본다. 나는 여기서 엄청 시간을 버렸는데, 감사하게도 도움을 받아 해결했다. 

 

2. 트리거의 문제

트리거

수신할 CF의 이벤트를 오리진 응답으로 바꿔야 한다. 이는 람다 트리거 설정/람다 배포 시/CF 행동 설정 등 여러 곳에서 바꿀 수 있다. 위의 사진은 람다 배포 시에 쓴 것이다. (참고자료 3-2)

 

3. 정책의 문제

CF와 Lambda가 함께 작동하기 때문에 쿼리를 잘 넘기도록 설정해줘야 한다.

도움주신 개발자님의 설명은 다음과 같다.

cache key 정책과 origin request 정책 각각에 해당 쿼리 스트링을 캐시하고 origin에 전달하도록 구성해야 합니다.
기본적으로 cf는 모든 쿼리스트링을 제거하고 origin에 전달하기 때문에 Origin response에서 쿼리스트링을 사용하고자 하면 Origin request 정책으로 별도 허용해줘야 합니다.

이 내용은 참고자료 3-2의 독스에서도 확인할 수 있다. 캐시와 오리진 정책 모두 Query All로 설정하면 된다.

 

 

4. node 버전의 문제

node.js 12.x를 썼더니 람다가 잘 돌아갔다. (참고자료 3-2)

 

 

 

 

참고자료

3-1)
https://devhaks.github.io/2019/08/25/aws-lambda-image-resizing/#4-IAM-%EC%97%AD%ED%95%A0-%EC%83%9D%EC%84%B1

 

[AWS] CloudFront Lambda@edge 를 이용한 이미지 리사이징

이번 글에서는 S3 에 있는 이미지를 Lambda@edge 를 통하여 리사이징 하고 새로운 이미지를 CloudFront 를 통하여 Caching 해보도록 하겠습니다.

devhaks.github.io

3-2)

https://manvscloud.com/?p=506 

 

[AWS] image resizing failure, trouble shooting

안녕하세요. ManVSCloud 김수현입니다. 오늘은 이전에 실패했던 image resizing 실패 원인을 확인 후 정상적으로 resizing 성공 후기를 남겨보려고 합니다. [AWS] CLOUDFRONT + LAMBDA@EDGE를 활용한 IMAGE RESIZING 안

manvscloud.com

3-3)

https://docs.aws.amazon.com/ko_kr/AmazonCloudFront/latest/DeveloperGuide/working-with-policies.html

 

정책 작업 - Amazon CloudFront

이 페이지에 작업이 필요하다는 점을 알려 주셔서 감사합니다. 실망시켜 드려 죄송합니다. 잠깐 시간을 내어 설명서를 향상시킬 수 있는 방법에 대해 말씀해 주십시오.

docs.aws.amazon.com

3-4) cloud 9 사용법

https://lemontia.tistory.com/1003

 

[aws] lambda@edge 를 이용해 이미지 리사이즈 하기(cloud9 사용)

여기서는 CloudFront를 사용한다고 전제되어 있으며 lambda@edge를 이용해 처리하는 방법이다. (CloudFront 란 AWS에서 제공하는 CDN서비스) 단계는 다음과 같다. 1) Cloud9 생성 및 코드 작성 2) IAM 등록 3) 만..

lemontia.tistory.com