S3에 이미지 호스팅을 하는 당신을 위한 종합선물 세트

2024. 10. 10. 18:35Cloud/AWS

이번에 새로운 사이드 프로젝트인 몽글을 개발하게 되면서 이미지를 업로드 및 관리해야 하는 로직이 필요했었습니다.

 

이전 프로젝트에도 S3에 이미지를 호스팅해서 서비스를 한적이 있었지만 이전에 했던 방식은 몇가지 문제점을 갖고 있었습니다.

 

첫번째, 이미지 로딩 속도입니다.

이미지를 호스팅하고 해당 이미지 URL 이를 클라이언트에서 받아 처리하였을때

이미지가 로딩되는 속도가 비교적 느린편에 속했었습니다.

조사한 결과 이미지의 크기가 너무 큰것과 이미지의  링크가 http로 되어있었던 점입니다.

그 전에도 CloudFront를 활용해서 캐싱 등을 진행했었지만 Flutter 쪽 이슈(https 사용시 간헐적으로 Exception 발생)이 있어 결국은 S3로만 호스팅을 하고 http로만 서빙을 했었습니다.

 

갑자기 HTTPS가 왜나오고 무슨 의미가 있냐라는 생각이 들수 있을겁니다.

HTTPS는 기본적으로 HTTP 2.0을 기반으로 하여금 동작하기 때문에

멀티 플렉싱, 헤더 압축 등을 활용하여 훨씬 빠르게 이미지를 처리할 수 있습니다.

 

해당 영상을 보면 1.1과  2.0이 꽤 속도 차이가 난다는것을 보실 수 있습니다.

 

그외의 HTTP 1.1과 HTTP 2.0의 차이는 따로 글로 작성하도록하겠습니다.

 

단순 S3로는 HTTPS 통신이 불가하기 때문에 CloudFront를 활용하여 HTTPS로 통신하도록해야 합니다.

 

😲 CloudFront?

CloudFront는 AWS에서 제공하는 CDN 서비스로, 웹 사이트, 비디오, 이미지 등 다양한 콘텐츠를 저지연 및 높은 전성 속도로 사용자에게 제공할 수 잇도록 하는 서비스입니다.

 

※ CDN

- CDN은 글로벌 서비스에서 주로 사용되는 기술로서, 콘텐츠를 여러 지역에 위치한 엣지 로케이션(Edge Location)이라는 지점에 분산하여 복제하도록하고 사용자가 요청시에 물리적으로 가장 가까운 서버에 콘텐츠를 제공하는 방식입니다.

예를들어 한국에서 미국에 있는 웹사이트에 접속할 경우, 미국에서 데이터를 전송받아야하기 때문에 속도가 느려질 수 있습니다.

이때 CDN을 적용하게되면 한국에 있는 엣지 로케이션에서 콘텐츠를 제공받아 훨씬 높은 속도로 콘텐츠를 제공받을 수 있습니다.

- 현재 여기서는 CDN의 이점을 가져간다기 보다는 CloudFront의 Lmabda@Edge를 통해 이미지를 캐싱하기 위해 사용됩니다.

 

두 번째 이미지의 크기입니다.

기술의 발전에 따라 작은 스마트폰에 있는 카메라로도 꽤 높은 화질의 사진을 촬영할 수 있습니다.

옵션에 따라 다르지만 보통 1MB ~ 70MB까지 높아져만 갑니다.

 

이러한 이미지들은 사진을 호스팅 한다고 한들 앱 혹은 웹에서 로딩하는 시간이 길어납니다.

 

이러한 문제점을 해결하기 위해선 크게 두가지의 방법이 있습니다.

1) 이미지 저장시에 리사이징 및 조정하여 여러개의 사진을 저장

  • 예를 들어 S3에 이미지가 저장되었다면 이미지의 크기가 특정 크기가 넘어간 경우 EventNotification 혹은 EvnetBridge를 통해 Lambda를 실행시켜 해당 S3 Object를 가져와 리사이징하고 적절한 해상도로 이미지를 다시 저장합니다.
  • 이 방법은 응답시간이 제일 빠르다는 장점을 갖고 있습니다.
  • 하지만 이미지 리사이징에 실패하여 S3에 다시 저장하지 못하였을때 Client에서 해당 이미지를 수신 못할 가능성이 존재하며 
    똑같은 이미지를 여러개 생성시키고 저장하기 때문에 비용이 좀더 발생할 수 있다는 단점이 있습니다.

 

2) 이미지 요청시 요청 파라미터에 따라 리사이징을 하고 이를 캐싱

  • 예를 들어 S3에 이미지를 저장된 상태인 경우 Client에서 특정 쿼리스트링으로 요청을 보냅니다 ex) image.link?w=320&h=300
  • 요청을 수신한 CloudFront가 Lambda를 실행시켜 이미지를 실시간으로 리사이징 합니다. 
  • 이 방법은 Client에서 자유롭게 요청하여 원하는 width, height값을 지정하여 사진을 리사이징할 수 있다는 장점이 있습니다.
  • 이미지를 여러개 생성 및 저장하는게 아니기 때문에 비용을 최적화 할 수 있습니다.
  • 하지만 실시간으로 이미지를 리사이징하기 때문에 리사이징에 소요된 시간 만큼 응답시간이 지연될 수 있습니다.
  • 다행인건 CloudFront 자체적으로 해당 이미지를 캐싱하도록 되어 있기 때문에 이미 요청받은적 있는 이미지에 대해서는 빠른 응답시간을 받을 수 있습니다. (12시간 간격으로 캐시 무효화가 되기 때문에 12시간 마다 첫 요청에 대해서 응답시간이 지연될 수 있습니다.)

 

두가지 방법에 대해 고려하였을 때

앱기반 서비스인 몽글에서는 여러가지의 사이즈인 이미지가 필요하다고 판단하여

쿼리 파라미터를 통한 실시간 리사이징으로 선택했습니다.

 

아래 사진은 쿼리 파라미터를 사용한 경우의 원리를 설명하는 사진입니다.

 

배포생성

 

Origin domain을 선택해서 CloudFront로 배포하려는 S3를 선택합니다 (이름은 Origin Domain을 선택하면 자동으로 입력됩니다.)

 

이때 원본 액세스는 설정하는것이 권장됩니다.

이는 S3로 들어오는 모든 요청을 블락하고 오직 CloudFront를 통해서만 S3 버킷에 접근 가능하도록 합니다.

 

만약 OAC가 없다면 생성해주시면 됩니다. 

이렇게 OAC를 설정해주었으면 S3 버킷에 대한 정책을 수정해야 합니다. 

 

기본 캐시 동작, 함수 연결, WAF는 선택사항이니 필요시 설정해주시면 됩니다.

 

설정 부분에서는 엣지 로케이션이 어디까지 배포될것인지와 도메인 및 SSL인증서를 셋팅할  수 있습니다.

본인이 필요한 서브도메인을 CNAME으로 추가한 후  서브도메인과 맞는 루트 도메인 SSL 인증서를 설정합니다. 

여기서는 SSL인증서 발급 방법은 따로 소개하지 않겠습니다. 

 

이후 배포생성을 하시게 되면 아래와 같은 포멧의 JSON이 표시됩니다

{
    "Version": "2008-10-17",
    "Id": "PolicyForCloudFrontPrivateContent",
    "Statement": [
        {
            "Sid": "AllowCloudFrontServicePrincipal",
            "Effect": "Allow",
            "Principal": {
                "Service": "cloudfront.amazonaws.com"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::[s3 버킷 주소]/*",
            "Condition": {
                "StringEquals": {
                    "AWS:SourceArn": "CloudFront ARN / 배포된 CloudFront ARN을 복사해주시면 됩니다. 세부정보에서 확인 가능합니다."
                }
            }
        }
    ]
}

 

해당 JSON을 복사한후

 

원본 S3 버킷으로 이동합니다.

이후 중간에 버킷정책에서 해당 JSON을 넣으시면 됩니다.

 

이렇게 되면 

위에서 설정한 domain을 통해서 image를 받을 수 있으며 SSL 인증서 또한 설정 되었기 때문에 HTTPS를 사용하실 수 있게 됩니다.

 

실제로 S3에 등록된 이미지, 도메인 및 SSL 인증서 설정이 된것을 확인할 수 있다.

 

Lambda@Edge

이제 리사이징을 Lambda를 사용해봅시다.

CloudFront가 CDN이고 여러 엣지 로케이션을 두고 있기 때문에 Lambda@Edge를 통해야 합니다.

리전을 버지니아 북부(us-east-1)로 변경 후 Lambda로 이동합니다.

 

저희는 리사이징을 위해 Node 라이브러리인 sharp를 사용 했습니다.

많은 이미지 리사이징 패키지들이 존재하지만 sharp가 리사이징 동작 대비 CPU, Memory 점유율이 낮고 속도가 빠르기 때문에 채택합니다.

 

 

함수를 생성 후 노드 프로젝트를 만들어 해당 코드를 입력합니다.

 

npm install sharp
// index.js 예시

const sharp = require('sharp');
const aws = require('aws-sdk');

const s3 = new aws.S3({
  region: 'ap-northeast-2'
});
const BUCKET = 'mgmg-image';

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);

  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);
  const height = parseInt(searchParams.get('h'), 10);
  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);
    const resizedImage = await resizeImage(s3Object, width, height, format, type, quality);

    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' }];
    return callback(null, response);

  } catch (error) {
    return callback(error);
  }
};

const DEFAULT_QUALITY = 80;
const DEFAULT_TYPE = 'contain';

async function getS3Object(s3, bucket, imageName, extension) {
  try {
    let key = imageName + '.' + extension;
    console.log(`key: ${key}`)
    const s3Object = await s3.getObject({
      Bucket: bucket,
      Key: decodeURI(key)
    }).promise();

    return s3Object;
  } 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, { failOn: 'truncated' })
    .resize(width, height, { fit: type })
    .toFormat(format, {
      quality
    })
    .toBuffer();

    return resizedImage;
  } catch (error) {
    console.log('resizeImage error: ', error);
    return s3Object;
  }
}

 

코드의 흐름은 다음과 같습니다.

  • s3.getObject(): s3 에서 업로드된 원본 이미지 가져오기
  • sharp.resize(): 가져온 원본 이미지의 데이터를 통해 이미지 리사이징
  • s3.putObject(): 리사이징된 이미지를 s3 에 업로드

 

빌드 후 해당 파일을 압축하여 zip파일로 만든 후 람다에 업로드 합니다. 

잘 업로드가 완료되었다면 함수 ARN을 복사한 후 CloudFront로 이동합니다.

이후 이미 생성해놓은 배포의 설정을 편집하고 함수 연결로 이동합니다.

 

이후 원본 응답을 Lambda@Edge로 설정하고 이전에 복사해두었던 Lambda ARN을 붙여넣기합니다.

CloudFront 함수 연결

이후 다시 Lambda로 이동 후 트리거를 추가합니다.

 

 

 

CloudFront로 선택 후 Lambda@Edge로 배포를 클릭하고 아래와 같이 설정합니다.

배포 실시 후 배포가 완료되었으면

이제 CloudFront 쪽으로 이미지를 쿼리 파라미터를 지정해서 이미지가 리사이징 되는지 확인해봅니다.

 

왼쪽이 원본 사이즈고 이를 크게 사용하기 위해 쿼리 파라미터로 '?w=1000&h=1000'를 추가 입력해주었습니다.

정상적으로 이미지가 리사이징 되는것을 확인했습니다.

 

단순 image 호스팅을 위해서라면 여기서 끝내도 문제가 되지 않습니다.

하지면 여기엔 한가지 문제점이 있습니다.

 

등록된 경우에는 CloudFront에서 S3에 접근해 새롭게 캐시를 생성하기 때문에 문제가 되지않습니다.

하지만 기존의 이미지가 다른 이미지로 변경되었다면 어떨까요? 

 

CloudFront에서는 기존 이미지가 새로운 이미지로 교체된 사실을 알 방법이 없기때문에

기존에 캐시에 있던 이미지를 내보냅니다.

 

즉, 교체된 이미지는 캐시 정책에 따라서 일정 시간이 지난 뒤에 새로운 이미지를 확인할 수 있게 됩니다.

이러한 문제를 해결하기 위해 몽글에서는 EventBridge를 통해서 Lambda를 실행시키도록 했습니다.

 

🙄 EventBridge?

EventBridgeAmazon CloudWatch Events의 업그레이드된 서비스로, 서버리스 이벤트 버스 역할을 합니다. 다양한 AWS 서비스 및 타사 SaaS(Software-as-a-Service) 응용 프로그램의 이벤트를 수집하고, 이를 기반으로 복잡한 이벤트 처리 워크플로를 구성할 수 있습니다. EventBridge는 S3 이벤트를 포함하여 다양한 서비스의 이벤트를 처리할 수 있습니다.

 

물론 단순한 이벤트인 경우에는 S3에 기본적으로 있는 Event 기능을 사용하면 되지만 

해당 기능은 단 하나의 서비스로만 이벤트를 전달할 수 있기때문에 EventBridge를 사용합니다. (이후 내용에서 한번 더 사용합니다)

 

이미 S3, CloudFront는 배포되어 있으니 Lambda로 이동합니다.

이땐 리전이 버지니아가 아니어도 됩니다. (S3가 이미 서울리전이기 때문)

간단한 파이썬 코드로 CloudFront에 무효화 요청을 보낼 수 있기 때문에 Python으로 함수를 생성합니다.

이후 코드에 해당 코드를 입력합니다.

import boto3
import time
import os

def handle_s3_change(event, context):
  paths = []
  for items in event["Records"]:
    key = items["s3"]["object"]["key"]
    if key.endswith("index.html"):
      paths.append("/" + key[:-10])
    paths.append("/" + key + "*")
  print("Invalidating " + str(paths))
   
  client = boto3.client('cloudfront')
  batch = {
    'Paths': {
      'Quantity': len(paths),
      'Items': paths
    },
    'CallerReference': str(time.time())
  }
  invalidation = client.create_invalidation(
    DistributionId=os.environ['CLOUDFRONT_DISTRIBUTION_ID'],
    InvalidationBatch=batch,
  )
  return batch

 

해당 코드에 대한 대략적인 흐름은 다음과 같습니다.

 

S3 이벤트 처리

  • S3에서 파일이 변경되었을 때 해당 이벤트가 발생하면, event["Records"] 배열에서 변경된 파일의 경로(키)를 추출합니다.
  • 각 파일에 대해 경로를 처리하고, index.html 파일인 경우에는 그 파일이 속한 디렉토리 경로까지 포함해 경로를 추가하고, 그 외의 파일은 해당 파일과 하위 파일들을 무효화할 수 있도록 경로 리스트를 생성합니다.

CloudFront Invalidation

  • 추출한 경로들을 기반으로 CloudFront 캐시를 무효화하기 위해 경로 리스트(paths)를 작성합니다. 이때 각 경로는 S3에서 변경된 파일이나 디렉터리를 포함하며, 무효화할 경로가 여러 개일 수 있습니다.
  • CloudFront 클라이언트(boto3)를 사용하여, 특정 CloudFront 배포(DistributionId)에서 해당 경로의 캐시를 무효화하는 요청을 만듭니다. 이때, 요청마다 고유한 참조값을 부여하여 중복 요청이 되지 않도록 합니다.

캐시 무효화 호출

  • create_invalidation 함수는 CloudFront에 요청을 보내어, 지정된 경로에 있는 파일들에 대해 캐시를 무효화합니다. 이렇게 하면 CloudFront에서 해당 경로에 대해 최신 S3 콘텐츠를 가져와 제공하게 됩니다.

 

 

이후 기존 S3 버킷의 권한 탭으로 이동합니다.

우리는 Amazon EventBridge를 활용할것이기 때문에 알림 전송 항목을 활성화로 변경합니다.

이를 활성화 하게되면 S3에서 발생하는 모든 이벤트들이 EventBridge로 전송됩니다.

 

이후 이벤트 알림을 생성합니다.

현재 요구사항은 이미지가 변경되었을때이기 때문에 전송만을 선택합니다.

접두사, 접미사를 통해 이벤트가 발생되는 조건을 지정할 수 있습니다.

 

 

이후 이전에 생성해두었던 Lambda를 호출하기 위해 대상을 지정하고 저장합니다.

해당 설정이 완료됨으로써 이미지가 업데이트 되어도 해당 이미지만 무효화 시켜 교체된 이미지를 빠르게 확인할 수 있습니다.

 

대략적인 프로세스는 다음과 같습니다.

이제 업로드된 이미지를 최적화해서 사용할 수 있습니다.

 

다만 아직 까지는 아쉬운점이 있는데요 

업로드된 이미지를 사용하는데 있어서는 최적화해서 사용이 가능하지만

업로드할때가 문제가 될 수 있습니다.

 

기본적으로는 클라이언트가 이미지를 백엔드로 전송하고

백엔드가 S3로 업로드 하는 방식이 제일 간단합니다만 이는 비효율적이기도 하고 

중간에 데이터가 끊기거나, 응답까지 너무 오래걸리거나, 서버의 자원을 과하게 사용하는 등 여러 문제가 발생할 가능성이 큽니다.

 

이를 해결하기 위해서 S3에서는 Presigned URL이라는 기능을 제공합니다.

 

🤨 Presigned URL ?

Presigned URL은 AWS에서 제공하는 기능으로, 특정 S3 객체에 대한 일시적인 액세스 권한을 부여하는 URL입니다. 이를 통해 S3 버킷의 객체에 대한 읽기(Read) 또는 쓰기(Write) 권한을 부여할 수 있습니다. Presigned URL은 짧은 시간 동안만 유효하며, 주로 인증된 사용자가 아닌 외부 사용자에게 임시로 파일을 공유하거나 업로드할 수 있게 할 때 사용됩니다.

 

즉, 서버는 S3에 대한 임시 권한을 가진 URL을 발급받고 이를 클라이언트에게 전달합니다.

클라이언트는 해당 URL로 이미지 업로드 요청을 함으로써 S3에 다이렉트로 이미지를 전송할 수 있게 됩니다.

이로써 서버는 빠르게 응답을 내려주면서 이미지 처리에 대한 부담을 줄일 수 있습니다.

 

아래는 이를 시각화한 간단한 시퀀스 다이어그램입니다.

 

다만 여기서 문제가 되는 지점은 S3에 저장이 완료되었는지 이미지의 엔드포인트는 어떻게 되는지 서버에서는 알 수 있는 방법이 없습니다.

이를 위해 앞에서 이용한 EventBridge와 SQS를 사용합니다.

 

😲 SQS ?

Amazon SQS (Simple Queue Service)는 AWS에서 제공하는 완전 관리형 메시지 큐 서비스입니다.

이를 통해 애플리케이션 간에 비동기적으로 메시지를 송수신할 수 있습니다.

SQS는 분산 시스템에서 데이터를 안전하게 전달하고 처리하는 데 사용되며,

보통 비동기 작업이나 마이크로서비스 아키텍처에서 많이 사용됩니다.

뿐만아니라 메시지를 보존하고 있기 때문에 처리에 실패한 이벤트인 경우 추후 다시 처리할 수 있습니다.

 

TMI. SQS는 AWS에서 가장 오래된 서비스입니다.

 

 

대략적인 이미지를 업로드하는 프로세스입니다.

S3와 EventBridge는 이미 처리 가능한 상황이기 때문에 SQS를 만들러갑시다.

SQS 페이지로 넘어가 대기열을 생성합니다.

SQS 구성설정

표시 제한 시간

대기열에서 수신한 메시지가 다른 메시지 소비자에게 보이지 않게 되는 시간을 설정합니다.

 

메시지 보존 시간

Amazon SQS가 삭제되지 않은 메시지를 보관하는 기간입니다. Amazon SQS는 최대 메시지 보존 기간을 초과하여 대기열에 보관된 메시지를 자동으로 삭제합니다.

 

전송지연

소비자가 메시지를 처리하는 데 추가 시간이 필요한 경우 대기열에 들어오는 각 새 메시지의 전송을 지연시킬 수 있습니다.

 

메시지 수신 대기 시간

메시지 수신 대기 시간은 폴링이 메시지를 수신할 수 있을 때까지 대기하는 최대 시간입니다.

 

SQS 권한설정

 

생성이 되었다면 S3로 다시 이동하여 이벤트를 설정하고 SQS를 선택합니다.

S3 이벤트 설정

 

그 다음 Nest에서 SQS에 대한 송수신을 할 수 있도록 IAM에서 권한을 설정해줍니다.

IAM 권한설정

 

자 이제 마지막으로 코드를 작성하러 가봅시다.

 

 

다음과 같이 진행합니다.

yarn add @ssut/nestjs-sqs
// consumer.module.ts

import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SqsModule } from '@ssut/nestjs-sqs';
import { SQSClient } from '@aws-sdk/client-sqs';
export const sqsName = {
  s3ImageCreated: 's3-image-object-created',
};

@Module({
  imports: [
    SqsModule.registerAsync({
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        const sqsClient = new SQSClient({
          region: configService.get<string>('AWS_REGION'),
          credentials: {
            accessKeyId: configService.get<string>('AWS_IAM_ACCESS_KEY_ID'),
            secretAccessKey: configService.get<string>(
              'AWS_IAM_SECRET_ACCESS_KEY',
            ),
          },
        });

        return {
          consumers: [
            {
              name: sqsName.s3ImageCreated,
              queueUrl: configService.get<string>(
                `sqs/url/${sqsName.s3ImageCreated}`,
              ),
              region: configService.get<string>('AWS_REGION'),
              sqs: sqsClient,
            },
          ],
        };
      },
    }),
  ],
})
export class ConsumerModule {}

 

이후 SQS에서 S3에 관한 정보를 받기 위한 DTO를 생성합니다.

S3 Event로 보내주는 정보들은 다음과 같은 포멧을 갖고 있습니다.

 

import {
  IsString,
  IsNotEmpty,
  IsArray,
  ValidateNested,
  IsNumber,
} from 'class-validator';
import { Type } from 'class-transformer';

class S3Bucket {
  @IsString()
  @IsNotEmpty()
  name: string;
}

class S3Object {
  @IsString()
  @IsNotEmpty()
  key: string;

  @IsNumber()
  @IsNotEmpty()
  size: number;

  @IsString()
  @IsNotEmpty()
  etag: string;

  @IsString()
  @IsNotEmpty()
  sequencer: string;
}

class S3Detail {
  @IsString()
  @IsNotEmpty()
  version: string;

  @ValidateNested()
  @Type(() => S3Bucket)
  bucket: S3Bucket;

  @ValidateNested()
  @Type(() => S3Object)
  object: S3Object;

  @IsString()
  @IsNotEmpty()
  requester: string;

  @IsString()
  @IsNotEmpty()
  reason: string;
}

export class S3EventDetailDto {
  @IsString()
  @IsNotEmpty()
  version: string;

  @IsString()
  @IsNotEmpty()
  id: string;

  @IsString()
  @IsNotEmpty()
  source: string;

  @IsString()
  @IsNotEmpty()
  account: string;

  @IsString()
  @IsNotEmpty()
  time: string;

  @IsString()
  @IsNotEmpty()
  region: string;

  @IsArray()
  @IsString({ each: true })
  resources: string[];

  @ValidateNested()
  @Type(() => S3Detail)
  detail: S3Detail;
}

 

 

이제 마지막으로 

// image.consumer.ts

import { Injectable, Logger } from '@nestjs/common';
import { SqsConsumerEventHandler, SqsMessageHandler } from '@ssut/nestjs-sqs';
import { Message } from 'aws-sdk/clients/sqs';
import { S3EventDetailDto } from '../../cloud/aws/sqs/presentation/s3-image-created-event-message.dto';
import { ImageService } from './image.service';
import { Image } from '../../../schemas/image.entity';
import { validateOrReject as validation } from 'class-validator';
import { ConfigService } from '@nestjs/config';
import { SQS } from 'aws-sdk';
import { plainToInstance } from 'class-transformer';

@Injectable()
export class ImageConsumer {
  private readonly logger: Logger = new Logger(ImageConsumer.name);
  private readonly sqs: SQS = new SQS({
    region: this.configService.get<string>('AWS_REGION'),
    credentials: {
      accessKeyId: this.configService.get<string>('AWS_IAM_ACCESS_KEY_ID'),
      secretAccessKey: this.configService.get<string>(
        'AWS_IAM_SECRET_ACCESS_KEY',
      ),
    },
  });

  constructor(
    private readonly imageService: ImageService,
    private readonly configService: ConfigService,
  ) {}

  @SqsMessageHandler(sqsName.s3ImageCreated, false)
  public async consumeMessage(message: Message): Promise<Image> {
    if (!message.Body) {
      this.logger.warn(`Message body is empty`);
      return;
    }

    const s3Event = plainToInstance(S3EventDetailDto, JSON.parse(message.Body));
    await validation(s3Event);

    const imageKey = s3Event.detail.object.key;
    const uuid = imageKey.match(/images\/([^/]+)\//)[1];

    return await this.imageService
      .create({
        uuid: uuid,
        imageUrl: `https://image.mgmg.life/${imageKey}`,
      })
      .then((v) => {
        this.deleteMessage(sqsName.s3ImageCreated, message);
        return v;
      });
  }

  private deleteMessage(queueName: string, message: SQS.Message) {
    this.sqs.deleteMessage(
      {
        QueueUrl: this.configService.get<string>(`sqs/url/${queueName}`),
        ReceiptHandle: message.ReceiptHandle,
      },
      (err, data) => {
        if (err) {
          this.logger.error(`Error occurred while deleting message: ${err}`);
          this.logger.error(`data: ${data}`);
        }
      },
    );
  }

  @SqsConsumerEventHandler(sqsName.s3ImageCreated, 'error')
  public async errorHandler(error: Error, message: Message): Promise<void> {
    this.logger.error(
      `Error occurred while processing message: ${message.MessageId}`,
    );
  }
}

 

  • SqsMessageHandler: 해당 데코레이터는 특정 SQS 큐에서 들어오는 메시지를 처리하는 메서드를 지정합니다.
  • validateOrReject: 메시지에 포함된 데이터가 DTO에 정의된 규칙에 맞는지 확인하기 위해 사용하는 유효성 검사 함수입니다.
  • sqs.deleteMessage: SQS에서 특정 메시지를 삭제하는 AWS SDK 메서드입니다. 완료되었다면 다른곳에서 사용할 이유가 없기 때문에 message를 SQS에서 삭제합니다.

 

이렇게 Presigned URL까지 끝났나면서 AWS를 사용하며 이미지를 최적화하는 여정이 끝이 났습니다.

아무래도 여러 문제를 한 글에 담으려고 하다보니 글이 좀 길어졌네요

다들 고생많으셨습니다. 😄

 

 

Reference.

https://blog.kmong.com/%EB%8D%94-%EB%82%98%EC%9D%80-%EC%82%AC%EC%9A%A9%EC%9E%90-%EA%B2%BD%ED%97%98%EC%9D%84-%EC%9C%84%ED%95%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%A6%AC%EC%82%AC%EC%9D%B4%EC%A7%95%EC%9D%84-%ED%95%B4%EB%B3%B4%EC%9E%90-cffbb55b9d95

 

더 나은 사용자 경험을 위한 이미지 리사이징을 해보자

안녕하세요! 크몽의 푸른피, 떠오르는 블루칩 Blue(블루)입니다.

blog.kmong.com

 

https://techblog.gccompany.co.kr/cloudfront-invalidations-%EA%B5%AC%EC%84%B1-297f7b5cce0

 

CloudFront Invalidations 구성

안녕하세요. 여기어때컴퍼니 인프라개발팀 한니발 입니다. 프론트개발팀과 협업을 하면서 AWS CDN 서비스인 CloudFront를 운영하는 중에 특정 오브젝트가 업데이트 되지 않는 이슈가 있었습니다. 이

techblog.gccompany.co.kr