CDN 및 lambda를 활용한 사진 리사이징 및 로드 시간 개선

김예지·2024년 2월 20일
0

집다방

목록 보기
5/5

개요

기술 블로그 참고

여러 이미지를 로드할 때, 시간을 줄이는 방법에 대해 고민하다가 기업들의 실제 서비스에서 트러블 슈팅한 경험들이 소개되어 있어 참고하였다.

1) https://medium.com/daangn/lambda-edge로-구현하는-on-the-fly-이미지-리사이징-f4e5052d49f3
2) https://www.slideshare.net/awskorea/ondemand-image-resizing

이번 리팩토링의 목표는 CDN과 리사이징을 통해, 레시피를 불러올 때 이미지 로드 시간을 줄이는 것이다.

CDN이란

CDN(Content Delivery Network)은 콘텐츠를 효율적으로 전달하기 위해 여러 노드를 가진 네트워크에 데이터를 저장하여 제공하는 시스템이다.

동작 원리

ISP(Internet Services Provider)의 CDN서버에 콘텐츠를 분산시켜, 유저의 네트워크 경로 상 가장 가까운 곳의 서버로 부터 콘텐츠를 전송받게 한다.

즉, 사용자는 CDN 서버에 캐싱된 데이터를 바로 전달받아 빠르게 콘텐츠를 얻을 수 있다. 만약 요청한 데이터가 CDN 서버에 없다면, 오리진(원본) 서버에서 콘텐츠를 조회하여 CDN에 저장하고 사용자에게 전달한다.

  • Static Caching: 사용자의 요청이 없어도 Origin Server의 콘텐츠를 운영자가 미리 Cache Service에 복사한다.

  • Dynamic Caching: 사용자가 콘텐츠를 요청하면, Origin Server에서 콘텐츠를 가져와 저장한다.

Temporal Locality

Temporal Locality(시간적 지역성)는 방금 사용된 데이터는 또 사용될 가능성이 높다는 원칙으로, Cache Hit Ratio가 높은 원인 중 하나이다.
(나머지 하나는 Spatial Locality. 하나의 데이터가 참조되면 곧바로 그 주위의 데이터가 참조될 가능성이 높다.)

집다방에서는 Temporal Locality에 특화된 기능이 다음과 같이 존재한다.

  • 금주의 베스트 레시피
  • 목록 조회(최신순, 인기순, 팔로우순)

CloudFront

AWS에서는 CloudFront를 통해 CDN을 적용할 수 있다.

프리티어로 제공되는 양이 꽤 많으니 참고해서 사용하면 좋을 듯 하다.

Lambda@Edge에 대해서

Lambda@Edge는 CloudFront Edge 전후 위치에서 실행되는 Lambda 서비스이다.

서버를 프로비저닝하거나 관리하지 않고 한 리전 US-East-1(버지니아 북부)에서 Node.js 또는 Python 함수를 작성한 후 뷰어에게 가까운 전 세계 AWS 위치에서 해당 함수를 실행할 수 있습니다.
https://docs.aws.amazon.com/ko_kr/AmazonCloudFront/latest/DeveloperGuide/lambda-at-the-edge.html

Lambda@Edge를 사용할 때는, us-east-1 리전에서 Lambda 함수를 작성해야 하며, 해당 함수는 CloudFront의 각 지역 단위 edge location에 복사해 배포된다.
유저는 CloudFront 사용 시, 가까운 edge location에 배포된 Lambda@edge 함수를 거쳐 응답받게 된다.

Lambda@Edge trigger Event

  • Viewer request: End User가 CloudFront로 보내는 요청
  • Origin request: CloudFront에서 cache miss가 일어나면 origin에 리소스 요청
  • Origin response: Origin으로부터 리소스를 CloudFront에 응답
  • Viewer response: Origin의 리소스/cache hit된 응답.

우리는 S3(Origin)으로부터 리소스를 CloudFront에 응답할 때 Lambda 함수를 적용할 것이므로 Origin response를 사용한다.

아키텍쳐

1. 기존 아키텍쳐

기존 방식은 단말에서 S3 URI로 이미지를 바로 호출하고 반환받는 방식이었다.

2. CloudFront (CDN) 적용 후

3. Lambda 통한 Resizing 적용 후

S3에는 원본을 저장하고, 이미지를 불러올 때 리사이징 함수를 호출해 CDN에 저장하도록 하였다.

왜 이미지를 저장할때 리사이징하지 않고 불러올 때 리사이징 하는 것일까?
처음 이미지를 호출하는 유저는 응답 시간이 오래 걸리지 않을까?

  1. 이미지는 한 사이즈로만 리사이징 되는 것이 아니다. 집다방 서비스의 예시로는, 썸네일/스텝/미리보기 이미지는 각각 리턴해야 하는 크기가 다르다. 이미지 저장 시, 한번에 필요한 모든 크기를 생성해 저장하는 것은 불필요하다.

  2. 또한 리사이징 후 저장된 이미지의 대부분은 거의 쓰이지 않고, S3 공간만 차지하게 된다. 위에서 언급한 Temporal Locality와 관련하여, 주로 호출되는 이미지만 계속 호출된다.

  3. 만약 이미지가 등록되어, Lambda가 열심히 리사이징 하는 중에 해당 이미지 삭제 요청이 들어온다면..?

위와 같은 이유로 공간적/비용적 효율을 위해 대부분의 아키텍쳐는 이미지 로드 시 리사이징 하는 방법을 채택하고 있다.

별개로, S3에 저장되는 원본 사이즈를 줄이기 위해 FE에서 이미지를 한번 압축하고 보내주는 것은 참 좋은 것 같다.

테스트 시나리오

  1. Figma에 디자이너가 세팅한 크기에 맞추어 리사이징하며, 원본의 90% 품질로 조정한다.

  2. 원본은 2.5MB2252 * 4000 px 이미지이다.

  3. 기존 방식 / cdn 적용 / resizing 적용(lambda) 후 각각 테스트를 진행하며,
    동시 요청하는 부하테스트 유저 수(Thread 수)는 다음과 같다.

  • 1명
  • 25명
    아무래도 2.5mb 파일을 S3에서 몇 천번씩 호출하기는 비용이 두려웠다
  1. 다만, resizing 후에는 전달하는 파일의 크기가 많이 줄어들 것이므로
  • 100명, 1,000명이 동시 요청하는 경우도 테스트 하였다.

테스트 시나리오 1.

베스트 레시피 하나에 여러 사람이 동시 접근하는 상황이다.
썸네일 한개와, 스텝 별 이미지 5개 총 6개의 이미지를 호출한다.

레시피 상세 페이지

  • Thumbnail: 360 * 360 px
  • Step Image: 328 * 328 px

테스트 시나리오 2.

동시에 다수의 유저가 레시피 목록을 조회하는 상황이다.
10개씩 페이징되므로, 미리보기 썸네일 총 10개의 이미지를 호출한다.

목록 조회(Preview) 페이지

  • Preview Thumbnail: 160 * 160 px

AWS CloudFront 적용

CloudFront 세팅 방법

(퍼블릭 엑세스가 허용된 S3 버켓이 있다는 가정하에 진행됩니다)

1. S3 버킷 정책 수정

1) 버킷 정책 생성

[CDN을 허용할 버킷 클릭] - [권한] - [버킷 정책] - [편집] - [정책 생성기]

  • Type of Policy: S3
  • Principal: *
  • Actions: GetObject
  • ARN: [CDN을 허용할 버킷]-[속성]-[Amazon 리소스 이름(ARN)] 복붙
  • [Add Statement]-[GeneratePolicy]-[Json Document 복사]

복사한 JSON을 [버킷 정책 편집]에 붙여넣는다.
이 때, 버킷에 있는 모든 객체에 대한 액세스를 허용하려면Resource/*를 추가한다. 특정 디렉토리만 선택할 수도 있다.
Principal*는 모든 사용자에 대해 이 정책이 적용됨을 의미한다.

2) 정적 웹 호스팅 활성화

정적 웹 사이트 호스팅은 S3 버킷을 홈페이지처럼 사용할 수 있는 기능이다.

[CDN을 허용할 버킷]-[속성]-[정적 웹 호스팅]-[편집]

  • 정적 웹 사이트 활성화
  • 호스팅 유형: 정적 웹 사이트 호스팅
  • 인덱스 문서: index.html
  • 오류 문서(선택): error.html

2. S3와 CloudFront 연결

1) CloudFront 생성

[CloudFront]-[배포]-[배포 생성]

  • 원본 도메인(Origin Domain)에 방금 설정한 S3 버킷을 설정한다
  • 필요에 따라 원본 액세스 방법과 캐싱(캐싱 만료 시점)을 Custom할 수 있다.
  • 로그가 필요한 상황이면 [표준 로깅]-[켜기]로 로그를 S3에 저장할 수 있다. 쿠키 로깅도 사용한다.

실제 서비스라면 WAF를 활성화 해야겠으나, 테스트 용도이므로 지금은 비활성화를 선택한다.

2) 동작 설정

[생성한 CloudFront]-[동작]-[동작 생성]

나중에 트리거에 적용할 경로 패턴을 설정한다.

3) S3에서 CloudFront로 파일 불러오기

파일을 저장할 때는, 기존에 저장하던 S3에 저장하되, DB에 저장하는 URI는 https://${CloudFront 도메인 이름}/${버킷}/example.jpg처럼 저장한다.

public String uploadFile(String KeyName, MultipartFile file) throws IOException {
    System.out.println(KeyName);
    ObjectMetadata metadata = new ObjectMetadata();
    metadata.setContentLength(file.getSize());
    amazonS3.putObject(new PutObjectRequest(amazonConfig.getBucket(), KeyName,file.getInputStream(), metadata));

	//CloudFront 도메인으로 저장하도록 설정
    return cloudfrontUri+amazonS3.getUrl(amazonConfig.getBucket(), KeyName).getPath();
}

동일한 이미지를 2번 요청 시 헤더를 확인하면,
X-Cache: Hit from cloudfront
CloudFront가 잘 작동하는 것을 확인할 수 있다.

지연시간 비교

1. 베스트 레시피 상세 조회

  • CloudFront를 적용하고, 평균 조회 시간이 약 30% 감소하였다.
  • 그러나, CDN을 거치는 과정이 추가되어 1명 조회시 p99는 오히려 증가하였으며, 25명 조회시에도 p99는 기존 방식과 큰 차이가 없다.

2. 레시피 목록 조회

  • CDN을 거치는 과정이 추가되어, 1명 조회 시 오히려 지연 시간이 더 길다.
  • 동시 조회 유저 수가 25명이 되자, CDN 적용시 평균 조회 시간이 약 15% 감소했다.
  • 그러나 p99는 오히려 기존보다 지연 시간이 길다.
    [1. 베스트 레시피 상세 조회]의 사례와 같이 생각해 보면, CDN 적용 후 동시 요청 수가 많아지면 평균 지연시간은 감소하지만, 상위 퍼센트의 지연 시간이 길어질 수 있다고 예상된다.

Lambda@Edge 적용

lambda 세팅 방법

1. 권한 설정

1) 정책 생성

[IAM]-[액세스 관리]-[정책]-[정책 생성]

총 9개의 권한을 설정한다.

{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Sid": "VisualEditor0",
			"Effect": "Allow",
			"Action": [
				"iam:CreateServiceLinkedRole",
                "s3:GetObject",
				"cloudfront:UpdateDistribution",
                "lambda:GetFunction",
				"lambda:EnableReplication",
                "logs:PutLogEvents",
                "logs:CreateLogStream"
			],
			"Resource": [
				"*"
			]
		},
		{
			"Sid": "VisualEditor1",
			"Effect": "Allow",
			"Action": [
				"logs:DescribeLogStreams",
				"logs:CreateLogGroup"
			],
			"Resource": "*"
		}
	]
}

2) 역할 생성

[IAM]-[액세스 관리]-[역할]-[역할 생성]

사용 사례에 Lambda를 선택하고, 방금 생성한 정책을 추가한다.

[방금 생성한 역할]-[신뢰 관계]-[신뢰 정책 편집]

방금 생성한 역할에서, 신뢰할 수 있는 개체를 수정한다.

{
	"Version": "2012-10-17",
	"Statement": [
		{
			"Effect": "Allow",
			"Principal": {
                "Service": [
                    "edgelambda.amazonaws.com",
                    "lambda.amazonaws.com"
                ]
			},
			"Action": "sts:AssumeRole"
		}
	]
}

2. Lambda@Edge

1) Lambda 생성

[버지니아 북부 리전]-[AWS Lambda]-[함수 생성]-[새로 작성]

사용할 언어-버전과, 방금 생성한 역할을 선택하고 함수를 생성한다.

2) AWS Cloud 9
(브라우저만으로 코드를 작성, 실행 및 디버깅할 수 있는 클라우드 기반 IDE)

[Cloud 9]-[환경 생성]

필요에 따라 인스턴스를 설정한다. 버지니아 북부 리전에 따로 설정한 vpc는 없으니, 네트워크 설정은 기본값으로 하였다.

[생성된 환경 선택]-[Cloud9 열기]

[좌측 메뉴바의 AWS 로고]-[Add regions to AWS Explorer]-[us-east-1 리전 선택]

[생성한 Lambda 함수 선택]-[Download]

2) npm 초기화 및 설치

cd ResizeImage #생성한 람다함수명으로 디렉토리가 구성되어있음.
npm init -y
npm install sharp 
npm install aws-sdk

창 아래의 bash에 명령어를 입력하여 npm 설치를 진행한다.

설치가 완료되면, 리사이징 동작을 할 수 있도록 ResizeImage의 index.js파일을 수정한다.
Node.js 오랜만에 썼더니 낯설다

'use strict';

const querystring = require('querystring');
const AWS = require('aws-sdk');
const Sharp = require('sharp');

const S3 = new AWS.S3({
  region: 'ap-northeast-2'
});
const BUCKET = 'example';

exports.handler = async (event, context, callback) => {
  const { request, response } = event.Records[0].cf;
  const params = querystring.parse(request.querystring);
  const { uri } = request;
  const [, imageName, extension] = uri.match(/\/?(.*)\.(.*)/);
  let width, height;

  // Set width and height based on querystring.
  switch (params.size) {
    case 'preview':
      width = height = 160;
      break;
    case 'thumbnail':
      width = height = 360;
      break;
    case 'step':
      width = height = 328;
      break;
    default:
      return callback(null, response);
  }
  
  // For AWS CloudWatch.
  console.log(`parmas: ${JSON.stringify(params)}`); // Cannot convert object to primitive value.
  console.log(`name: ${imageName}.${extension}`); // Favicon error, if name is `favicon.ico`.

  let s3Object, resizedImage;

  try {
    s3Object = await S3.getObject({
      Bucket: BUCKET,
      Key: decodeURI(imageName + '.' + extension)
    }).promise();
  } catch (error) {
    console.log('S3.getObject: ', error);
    return callback(error);
  }

  try {
    resizedImage = await Sharp(s3Object.Body)
      .resize(width, height)
      .toFormat ('jpeg', { quality: 90 })
      .toBuffer();
  } catch (error) {
    console.log('Sharp: ', error);
    return callback(error);
  }

  const resizedImageByteLength = Buffer.byteLength(resizedImage, 'base64');

  if (resizedImageByteLength >= 1 * 1024 * 1024) {
    return callback(null, response);
  }

  response.status = 200;
  response.body = resizedImage.toString('base64');
  response.bodyEncoding = 'base64';
  response.headers['content-type'] = [
    {
      key: 'Content-Type',
      value: `image/${extension}`
    }
  ];
  return callback(null, response);
};
  • QueryString에 들어오는 sizepreview/thumbnail/step 별로 사진 크기를 다르게 리사이징 한다.
  • 1MB가 넘으면 원래의 이미지를 리턴한다. Lambda@Edge의 응답 페이로드는 최대 크기가 1MB로 제한되어 있다.
  • sharp를 사용해 이미지를 조정하고 변환한다.
  • jpeg로 저장하며, 원본의 90% 품질 이미지를 출력하도록 한다.

[Upload Lambda]-[Directory]-[Yes]-[수정한 폴더 선택]-[Open]

작성 완료한 함수를 Lambda에 업로드한다.

3) 트리거 구성 및 배포

[Lambda]-[생성한 함수]-[작업]-[Lambda@Edge 배포]

  • 생성한 CloudFront(Distribution)와 그에 따른 Cache behavior를 설정한다.
  • CloudFront eventOrigin response로 설정한다.

3. Spring 구성

미리보기/썸네일/스텝별 이미지 사이즈가 각각 다르므로, 조회하는 메소드에서 Response DTO 생성 시 필요한 querystring을 추가한다.

최종 형태는 다음과 같다.
https://${cloudfront 도메인 이름}/${bucket}/${이미지명}?size=

Converter

//Thumbnail Size 360 * 360px 생성
return RecipeResponseDto.build()
	.thumbnailUrl(recipe.getThumbnailUrl()+"?size=thumbnail")
    .//...

//Step Image Size 328 * 328px 생성
build().image(step.getImageUrl()+"?size=step")

//Preview Size 160 * 160px 생성
build().thumbnailUrl(recipe.getThumbnailUrl()+"?size=preview")

결과

1. 리사이징 결과

  • 원본 요청시:

  • Thumbnail 요청시:

  • Step Image 요청시:

  • Preview 요청시:

2. 지연시간 비교

1. 베스트 레시피 상세 조회

2. 레시피 목록 조회

기존 방식 테스트 시 이미지가 FE에서의 압축(WebP 등)을 거치지 않아 좀 더 극명한 차이가 발생했으나 두 상황 모두 상당히 개선된 지연시간 결과를 나타낸다.

가장 처음 원본 이미지를 로드하는 유저는 약 4-5초의 시간이 소요되었지만, 평균적으로는 지연 시간이 8~9배 감소한 것을 확인할 수 있다.

3. Resize 적용하여 100명/1,000명 동시 요청

  • CDN 덕에 요청이 증가할수록 평균 지연 시간은 감소한다.
  • 그러나, 처음에 S3에서의 이미지 로드 시간 + Lambda 함수 동작 시간 때문에 P99는 1초 내지 2초를 넘어간다. 이 부분은 감수를 해야할 듯 하다.

참고
https://nayoungs.tistory.com/entry/AWS-CDN%EA%B3%BC-Amazon-CloudFront-S3-%EC%97%B0%EB%8F%99

https://devhaks.github.io/2019/08/25/aws-lambda-image-resizing/#CloudFront-%EC%99%80-CORS-Cross-Origin-Resource-Sharing

0개의 댓글

관련 채용 정보