AWS Lambda@Edge에서 실시간 이미지 리사이즈 & WebP 형식으로 변환

Marco
Marco
Jan 24 · 16 min read

안녕하세요, 당근마켓에서 백엔드 서버 개발 인턴으로 근무하고 있는 Marco입니다. 저는 이번에 당근마켓 서비스의 썸네일 생성 방식을 On-The-Fly 이미지 리사이징으로 새롭게 구현하였습니다. 이번 글을 통해 그 과정을 공유하려고 합니다.

당근마켓은 당신 근처의 마켓이라는 뜻으로 따뜻하고 건강한 지역 기반의 정보를 연결하는 서비스입니다. 이용자 수가 꾸준히 증가하고 있으며 작년 12월을 기준으로 월간 유니크 활동 유저 수(MAU)가 160만을 넘어섰습니다.

이러한 이용자 수의 증가와 더불어 당근마켓에는 많은 게시글이 올라오고 있습니다. 또한 게시글과 함께 하루에 약 50만 장의 이미지를 사용자들이 업로드하고 있고, 이미지에 대한 요청은 하루에 약 2억 건 발생하고 있습니다. 이 수치는 꾸준히 증가하고 있기 때문에 이미지 저장 및 처리 방식에 개선이 필요하게 되었습니다.


기존의 썸네일 이미지 생성 방법

기존의 당근마켓의 썸네일 이미지 생성 방법은 다음과 같습니다. 클라이언트가 이미지를 S3 저장소로 직접 업로드하면, S3에서s3:ObjectCreated:Put 이벤트를 개시합니다. AWS Lambda의 이벤트 트리거는 이벤트를 받아서, S3 Bucket에 당근마켓에서 사용하는 썸네일을 생성합니다. 예를 들어, origin/article 폴더에 이미지를 업로드하면, resize/s/article, resize/l/article 경로에 썸네일을 생성하고 이에 대한 주소를 DB에 저장합니다. 기존의 방식에 대한 자세한 내용은 AWS Lambda를 이용한 이미지 썸네일 생성 개발 후기에서 볼 수 있습니다.

기존 방식의 장단점

AWS Lambda에서 이미지 리사이징이 동작하므로 메인 서버와 로직이 분리되어 서버에 부담을 주지 않습니다. 그리고 서버리스 환경에 구축하여 서버 유지 관리가 필요 없습니다. 하지만 문제점도 있습니다. 여러 형식의 썸네일 이미지를 하나의 Lambda 함수에서 생성하도록 동작합니다.이 때, 미처 썸네일이 생성 되기 전에 요청이 들어와 제대로 응답하지 못하는 경우가 있습니다. 또한 모든 썸네일이 S3에 저장되어 저장소 사용량도 증가합니다. 클라이언트에서 필요한 썸네일 이미지 형식이 변경될 경우 모든 썸네일을 재생성 해야 하는 일이 발생할 수도 있습니다.


새로운 썸네일 생성 방식의 필요성

당근마켓의 서비스가 널리 이용되면서 인프라 비용이 증가하고 있습니다. 먼저, S3 저장소의 요금이 용량에 비례하기 때문에 썸네일을 모두 S3 저장소에 저장하는 방식에서는 더욱 요금이 빠르게 증가합니다. 또한 서버의 데이터 전송량이 증가하면서 CloudFront의 비용 또한 예년에 비해 2배 이상 증가하였습니다. 서비스가 갈수록 가파르게 성장하고 있기 때문에 이러한 인프라 비용은 더욱 크게 증가할 것으로 예상됩니다. 상승하고 있는 인프라 비용의 증가폭을 줄이기 위해서 트래픽의 많은 비중을 차지하고 있는 이미지 전송 트래픽을 개선하는 방법을 고려하게 되었습니다. 그 결과, 사용자 경험을 해치지 않으면서 트래픽을 개선할 수 있는 새로운 썸네일 생성 방식이 필요하게 되었습니다.

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

On-The-Fly 이미지 리사이징은, 클라이언트에서 썸네일을 요청할 때 실시간으로 이미지를 리사이징하여 제공하는 방식입니다. 이미지가 업로드되고 처음 이미지를 요청하는 클라이언트는 이미지를 리사이징하는 시간을 기다려야 합니다. 하지만 CDN에 이미지가 캐싱되면 클라이언트가 썸네일을 요청할 때 곧바로 CDN에서 캐싱된 썸네일을 제공하기 때문에 사용자 경험을 크게 해치지 않습니다. 장점은 다음과 같습니다. On-The-Fly 방식으로 썸네일 생성을 구현하면 S3에 모든 썸네일을 생성하지 않기 때문에 S3 저장소 용량의 증가폭을 줄일 수 있습니다. 또한 클라이언트에 따라 다른 이미지 포맷으로 응답할 수 있습니다. 특히, 구글에서 2010년 공개한 WebP를 지원하는 클라이언트에 WebP 포맷의 이미지를 제공할 수 있게 됩니다. WebP는 화질에는 거의 차이가 없으면서도 JPEG이나 PNG에 비해 20% 이상 용량이 적습니다. 따라서, 이미지 포맷을 클라이언트에 맞추어 최적화 한다면 서버의 트래픽을 대폭 줄일 수 있습니다. 당근마켓 사용자의 클라이언트 중 80% 이상이 WebP를 지원하는 Android이므로, WebP의 도입은 반드시 요구되는 사항 중 하나입니다.

On-The-Fly 이미지 리사이징의 여러가지 방식

On-The-Fly 이미지 리사이징에 여러 방식을 시도할 수 있습니다. 서버를 따로 두어 클라이언트의 요청이 있을 때 이미지를 리사이징하여 제공할 수 있습니다. 하지만 이러한 방식은 서버를 관리해야 하는 단점이 있습니다. 서버를 두지 않고 기존처럼 AWS Lambda로 구현할 수도 있습니다. 클라이언트가 썸네일을 요청할 때, API를 통해 AWS Lambda를 호출하면 이미지를 리사이징하여 제공하는 방법입니다. 당근마켓에서는 Lambda@Edge를 도입하여, API를 통해 Lambda를 호출하는 방식보다 더 나은 성능을 보여주도록 구현하였습니다.


Lambda@Edge

Lambda@Edge는 AWS CloudFront의 기능입니다. Lambda 코드를 CloudFront에 배포하여, 최종 사용자에 대한 응답 속도를 높일 수 있습니다. 또한 별도의 API Gateway 없이, CloudFront에 의해 생성된 이벤트를 트리거로 하여 함수를 실행할 수 있습니다. 4가지 이벤트를 사용할 수 있는데 이 중 Origin Response를 Lambda의 트리거로 사용하여 이미지 리사이징을 구현하였습니다. Origin Response는 CDN에 연결된 Origin(이 경우, S3)이 응답(원본 이미지, 헤더를 비롯한 Event 객체)을 반환한 후에 동작하는 이벤트입니다.

Lambda@Edge를 이용한 이미지 리사이징의 간단한 예를 들어 보겠습니다. 클라이언트가 게시글 리스트에 필요한 썸네일 이미지를 요청합니다. 캐싱되어 있지 않다면 CloudFront는 S3로 이미지를 요청하고 S3는 해당하는 이미지로 응답합니다. 이 때, Origin Response를 조작할 수 있습니다. 요청받은 이미지가 S3에 존재한다면 원본 이미지를 조작하여 응답의 Body로 설정할 수 있습니다. 그리고 이를 CloudFront에 전달하면, 최종적으로 클라이언트에 썸네일을 제공할 수 있습니다.

Lambda@Edge의 제한사항

기본적으로 Lambda와 같은 방식으로 동작하지만, Lambda@Edge만의 제한 사항이 있으므로 이에 유의해야 합니다. 특히 이번에 구현하면서 주의해야했던 점은 다음과 같습니다.

  1. Origin Response를 트리거로 하는 Lambda에는 Event 객체가 전달됩니다. 이 때 Body에 담긴 원본 이미지를 사용할 수 없습니다. 따라서 이미지의 사이즈를 조절하기 위해 S3로 다시 객체를 요청해야 합니다. 또한 Accept 헤더가 Event 객체에 담겨있지 않습니다. 따라서 Lambda 함수에서 WebP 지원 여부를 알 수 없습니다. 헤더를 캐싱하도록 CloudFront 설정을 하면 Accept 헤더를 사용할 수 있지만, 모든 Accept에 대해 개별적으로 캐싱하도록 동작하기 때문에 사용하기에 알맞지 않다고 판단했습니다. 서버에서 WebP 지원여부를 확인하여 쿼리스트링의 파라미터로 추가하도록 구현했습니다.
  2. 만약 응답의 Body를 조작한다면 그 크기는 1MB 이하여야 합니다. 따라서 이미지의 크기를 한 번 조절했을 때 1MB를 넘는 경우를 대처해야 합니다. 응답을 조작하지 않는다면 1MB 이상의 응답이 가능합니다.
  3. CloudFront는 쿼리 문자열 파라미터를 기반으로 컨텐츠를 캐싱합니다. 이 때, 파라미터의 순서, 대소문자 등에 따라 다르게 캐싱됩니다. 리사이즈할 정보를 쿼리 문자열에 담아 리사이징 하도록 구현한다면 파라미터의 순서가 경우에 따라 다르지 않도록 해야합니다.
  4. Node.js만 사용 가능합니다. 만약 Node.js에 익숙하지 않다면 구현에 어려움이 따를 수 있습니다. 하지만 AWS에서 제공하는 기본 예제가 충실한 편이어서 어렵지 않게 구현할 수 있습니다.
  5. us-east-1 리전에만 Lambda@Edge를 배포할 수 있습니다. 결과적으로 모든 리전에 함수가 복사되기 때문에 큰 문제가 되지는 않습니다.

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

위의 그림과 같이 On-The-Fly 이미지 리사이징이 동작합니다. 예를 들어, 클라이언트가 300x300 사이즈인 WebP 포맷의 썸네일을 요청하는 상황을 가정하겠습니다. 클라이언트에서 https://CDN_ID.cloudfront.net/sample.jpg?s=300x300&f=webp 와 같은 경로에 이미지를 요청합니다. 이 때, 캐싱되어 있다면 CloudFront는 304 Status Code 와 함께 이미지를 응답합니다. 캐싱되어 있지 않다면 S3 Bucket에 원본 이미지를 요청합니다. 그리고 S3의 응답 이벤트가 트리거가 되어 Lambda@Edge 함수를 동작시킵니다. S3에 원본 이미지가 존재한다면 응답 이벤트에는 200 Status Code 가 담겨 있습니다. 함수에서, 주어진 파라미터에 따라 이미지가 리사이징 되고 그 결과가 CloudFront에 캐싱됩니다. 이제 클라이언트는 캐싱되어 있는 썸네일을 곧바로 CDN에서 제공받습니다.

AWS CDN Blog의 예제는 Node 6.10 기반이기도 하고 실제 서비스에서 곧바로 사용하기에는 부족한 면이 있어 이번에 구현한 코드를 첨부합니다. Node.js 8.10에 맞추어 작성되었습니다. 일부 참고하시고, 본인의 서비스에 맞게 수정하여 사용하시면 됩니다. 코드에 대해 지적할 부분이나 개선 사항이 있으시다면, 부디 gist에 댓글을 달아주시거나, 이 글에 댓글을 달아주시면 감사하겠습니다. Node.js를 처음 다루어, 부족한 부분이 많습니다:)

Tips for beginners

서비스에 적용하는 방법은 AWS CDN Blog의 예제를 참고하면 쉽게 따라할 수 있습니다. 이에 더해 AWS에 익숙하지 않은 분들을 위한 팁을 공유합니다.

  • AWS CDN Blog의 예제에서는 S3 Bucket의 정책을 PublicRead로 선언하지만, 실제 서비스에서는 PublicRead로 권한을 설정하지 않는 것이 좋습니다. PublicRead의 경우 익명의 누군가가 S3 Bucket에 접근할 수 있기 때문에 보안에 취약합니다. 당근마켓에서는 정해진 CloudFornt에 Origin Access Identity를 부여하여 사용합니다. 이 때, S3의 버킷 정책에 GetObjectListBucket 액션에 대한 권한을 추가해야 합니다. 예시는 아래와 같습니다.
{
"Version": "2008-10-17",
"Id": "PolicyForCloudFrontPrivateContent",
"Statement": [
{
"Sid": "1",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::cloudfront:user/YOUR_IDENTITY"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR_BUCKET/*"
},
{
"Sid": "2",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::cloudfront:user/YOUR_IDENTITY"
},
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::YOUR_BUCKET"
},
{
"Sid": "3",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/YOUR_EDGE_ROLE"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR_BUCKET/*"
}
]
}
  • 함수는 버지니아 북부 리전(us-east-1)에 배포되지만, 서울에 있는 클라이언트에게는 서울 리전(ap-northeast-2)에 복제된 함수가 실행됩니다. 이 함수는 서울 리전의 Lambda 함수 목록에서 복제된 함수 표시 옵션을 켜면 볼 수 있습니다. CloudWatch 통계 또한 각 리전에 복사된 함수 별로 제공됩니다.
  • Lambda 함수의 메모리와 제한 시간은 본인의 서비스에 맞게 설정합니다. 이 곳에서 메모리 설정에 관한 팁을 얻을 수 있습니다.
  • AWS Lambda 함수 대시보드의 테스트를 통해 브라우저 상에서 함수를 테스트를 할 수 있습니다. 이 때, 아래와 같이 테스트 이벤트를 구성할 수 있습니다. 하지만 테스트 이벤트와 실제 발생하는 이벤트는 다를 수 있기 때문에 AWS 설명서에서 실제 이벤트가 어떻게 구성되는지 확인해야 합니다.
{
"Records": [
{
"cf": {
"config": {
"distributionDomainName": "YOUR_CDN.cloudfront.net",
"distributionId": "YOUR_CDN_ID",
"eventType": "origin-response",
"requestId": "xGN4KWpVEmB9Dp5ctcVFQC0E-nrcOcEKS3HyAez--19dV7TEXABCDE=="
},
"request": {
"clientIp": "1234:0db8:85a3:0:0:8a2e:5678:9012",
"method": "GET",
"uri": "/assets/images/sample.jpg",
"querystring": "s=256x256&f=webp",
"headers": {
"host": [
{
"key": "Host",
"value": "YOUR_CDN.cloudfront.net"
}
],
"user-agent": [
{
"key": "User-Agent",
"value": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"
}
]
}
},
"response": {
"status": "200",
"statusDescription": "OK",
"headers": {
"server": [
{
"key": "Server",
"value": "MyCustomOrigin"
}
],
"set-cookie": [
{
"key": "Set-Cookie",
"value": "theme=light"
},
{
"key": "Set-Cookie",
"value": "sessionToken=abc123; Expires=Wed, 09 Jun 2021 10:18:14 GMT"
}
]
}
}
}
}
]
}

결과

Lambda@Edge를 통한 이미지 리사이징을 도입하여 생긴 변화는 다음과 같습니다.

  • cache miss일 때, 응답 시간이 평균 105ms에서 501ms로 늘어났습니다. miss는 첫 번째 클라이언트에게만 발생하기 때문에 크게 사용자 경험에 지장을 주지 않는다고 판단했습니다.
  • 초기 리사이징할 때를 제외하고는 cache hit인 경우입니다. WebP 지원으로 인하여 이미지의 용량이 20% 줄었습니다. 따라서 사용자는 더 빠르게 이미지를 다운로드 받을 수 있고, 클라이언트의 모바일 데이터 사용량과, 저장 공간 사용량이 줄어드는 장점이 있습니다.
  • 512MB의 메모리를 할당받은 Lambda 함수가 평균 0.492초 동안 동작하고 일 평균 6.12M회 호출됩니다. 계산해보면 그 비용은 한 달에 약 2,400달러 입니다. 이는 최적화 과정을 통해 다소 줄일 수 있을 것으로 예상됩니다.
  • CloudFront의 트래픽이 약 20% 정도 감소하였습니다. 그래프에서 트래픽의 감소를 눈으로 확인할 수 있습니다. 1월 8일부터 21일까지 일간 요청 횟수는 약 2.1억 건을 전후로 조금씩 증가하는 추세입니다. 하지만 트래픽의 양은 On-The-Fly 이미지 리사이징이 적용된 15일 이후로 유의미하게 감소하였습니다. 또한 S3에 저장되어 있던 썸네일을 정리하고, 신규 썸네일을 S3에 저장하지 않게 되었습니다. CloudFront 트래픽 감소와, S3 저장소 정리를 통해 한 달에 약 3,000달러의 비용이 절약됩니다.

당근마켓에서는 이번에 Lambda@Edge를 통한 이미지 리사이징을 구현하여 크게 사용자 경험에 지장을 주지 않고 인프라 비용을 절감하였습니다. 당근마켓은 통계를 사용하여 문제를 인식하고 새로운 기술의 도입으로 그 문제를 해결하고 있습니다. 글의 서문에 언급한 것처럼, 당근마켓은 MAU 160만을 넘어 빠르게 성장하고 있는 서비스입니다. 이에 따라 크고 작은 도전 과제들이 생겨나고 있습니다. 이러한 도전 과제를 같이 해결하고 서비스를 개선해나갈 개발자를 찾고있습니다. 함께 하고 싶으시다면 이 곳을 확인해주시길 바랍니다.


당근마켓 팀블로그

당근마켓은 동네 이웃 간의 연결을 도와 따뜻하고 활발한 교류가 있는 지역 사회를 꿈꾸고 있어요.

Marco

Written by

Marco

developing…

당근마켓 팀블로그

당근마켓은 동네 이웃 간의 연결을 도와 따뜻하고 활발한 교류가 있는 지역 사회를 꿈꾸고 있어요.