AWS Lambda를 이용한 이미지 썸네일 생성 개발 후기

서비스를 만들다 보면 이미지를 UI에 맞는 다양한 크기로 변환할 필요가 있습니다. 이미지의 다양한 크기를 어떻게 생성하고 전달하는지에 따라 다음과 같은 방법들이 있습니다.

  • 원본 이미지가 생성되면 백그라운드 작업으로 썸네일을 생성 후 파일로 저장
  • 썸네일 이미지를 요청하는 시점에 실시간으로 생성. 온디맨드 이미지 리사이징

전자의 방법을 이용해서 썸네일을 만드는 것이 일반적입니다. 하지만 여기에는 몇 가지 단점이 있는데요

  • UI 개편 등으로 필요로 하는 이미지의 크기가 변경되면 지금까지 생성한 모든 이미지의 썸네일을 다시 생성해야 한다.
  • 모든 이미지에 대해 썸네일을 미리 생성하므로 이미지 x 썸네일 종류가 많을수록 점점 더 많은 저장공간을 많이 사용한다.

그래서 요즘 많이 사용하는 것이 두 번째 방법인 온디맨드 이미지 리사이징입니다. 온디맨드 이미지 리사이징의 장점은 다음과 같습니다

  • UI 개편 등으로 썸네일 이미지의 크기가 변경되더라도 기존의 이미지들을 변환할 필요 없다.
  • 썸네일 이미지를 미리 생성해서 저장해두지 않고 CDN을 통해 썸네일 이미지를 캐시 하므로 저장공간은 이미지 개수 만큼만 사용한다. (참고 : 서버 비용을 70%나 줄인 온디맨드 리사이징 이야기 — VCNC 엔지니어링 블로그)

물론 온디맨드 이미지 리사이징 역시 단점은 있습니다

  • 썸네일 이미지 크기가 변경되는 경우 한 번에 너무 요청이 밀려서 이미지 변환이 실패할 수 있다
  • CDN에 캐시 되기 전까지는 같은 이미지 리사이즈 요청을 반복해서 처리해야 될 수 있다.

당근마켓은 온디맨드 이미지 리사이징 방식을 사용하고 있었습니다.(과거형이네요 ㅎㅎ) 루비에서 온디맨드 이미지 리사이즈를 쉽게 해주는 Refile이라는 젬이 있었고 이를 이용했습니다.

Refile에서 사용하는 온디맨드 이미지 리사이즈와 개념에 만족했었지만, 시간이 지날수록 몇 가지 문제가 있었습니다.

  • CDN을 통해 같은 썸네일 요청은 다시 들어오는 것을 줄여야 하는데 당근마켓 특성상 새로 올라온 중고매물의 이미지가 CDN을 통해 캐시 되기 전에 동시에 접근하는 경우가 많아서 이미지 리사이즈하는 서버에 부담이 많았습니다.
  • Refile의 경우 레일스 서버와 같이 실행되는 구조라 별도의 설정 없이 쉽게 시작할 수 있었지만, 이미지 변환의 부담이 많아질수록 서비스를 느리게 만들었습니다.
  • 레일스 서버가 필요로하는 자원의 종류와 Refile과 같은 이미지 리사이즈할때 필요로 하는 자원의 종류가 달랐습니다. 레일스는 CPU보다는 메모리가 큰 게 중요했고 이미지 리사이즈는 메모리보다 CPU가 중요했는데 서로 다른 2개가 하나에 있으니 서버를 업그레이드 할 때 비용도 더 많이 필요로 합니다.

이러한 문제들을 해결하기 위해 여러 가지 대안을 생각해봤습니다. 우선은 기존의 온디맨드 리사이즈의 장점이 마음에 들어서 온디맨드 리사이즈를 효율적으로 하는 방법을 고려해봤습니다.

온디맨드 이미지 리사이징

기존에 사용하던 Refile이 문제 되었던 것은 레일스 서버와 붙어 있어서 이미지 처리가 서비스 속도에 영향을 주는 것이라서 온디맨드 이미지 리사이징을 별도로 처리하면 좋겠다고 생각했습니다

온디맨드로 이미지 리사이즈를 해주는 서비스들처럼 URL에 썸네일 크기를 입력하면 썸네일을 제공해주는 간단한 프로그램이 있을 것 같았는데요. 검색도 해보고 주변 분들한테 물어보면서 몇 가지를 찾을 수 있었습니다.

imageproxy의 경우 요즘 핫한 Go 언어로 만들어져 있었고 기능도 원하는 만큼에 실행도 쉬워서 너무 좋았습니다. 간단한 이미지 썸네일 서버를 운영하기에 최적이었죠

Nginx 모듈의 경우 기능은 imageproxy보다 부족하지만 nginx에 모듈만 간단히 붙이면 된다는 장점도 있었죠 ~

그래서 imageproxy를 사용하려고 준비하고 있었는데… 어디선가 메일을 하나 받고 다시 고민에 빠졌습니다.

토스트 클라우드

아래와 같은 이메일을 받게 됩니다 ~

… 생략
토스트 클라우드는 nhn엔터의 자회사와 관계사 (한게임, 벅스, 팅크웨어, 티켓링크, 티몬 등) 에서 사용하고 있으며, 판교 자체 IDC구축으로 안정적이고 KT와 AWS에 비해 30% 정도 저렴한 비용으로 인프라를….
…생략

nhn 엔터에서 클라우드(TOAST Cloud) 사업을 하는 건 알고 있었는데 마침 회사가 판교에 있기도 하고 메일 주신 분이 당근마켓 사용자인 데다가 설명까지 해주신다는데 마다할 이유가 없어서 만나게 되었습니다.

설명을 듣다 보니 마침 저희가 고민하고 있던 문제를 해결해줄 만한 솔루션이 있었습니다. Contents > Image, CDN 항목이었는데요. 이미지를 스토리지에 업로드하면 미리 지정한 썸네일로 생성하고 CDN까지 연동되는 상품이었습니다.

네이버 내부에서도 이와 비슷한 솔루션이 있어서 이미지 썸네일 생성에 대한 고민은 안 했었던 기억이 나면서 온디맨드 리사이징도 좋지만 별도 서버를 운영하는 노력을 생각하면 토스트 클라우드의 이미지 솔루션은 저희의 고민을 해결해줄 최적의 방법이었습니다. 게다가 고도몰등 nhn 엔터의 쇼핑 자회사들에서도 사용하고 있기에 속도 등에서도 걱정할 필요가 없었습니다.

그래서 자체적으로 토스트 클라우드로 이전하자! 라고 이야기하고 진행하게 됩니다.

클라이언트가 이미지 직접 업로드

당근마켓은 기존에 사용자가 이미지를 레일스 서버로 업로드하면 레일스 서버에서 다시 S3로 업로드 하는 방식이었습니다. 하지만 레일스 서버의 자원을 이미지 업로드에 사용돼서 이미지가 크거나 많이 올라오면 서비스에 부담이 갈 수 있다는 단점이 있었습니다. 이미지를 그대로 S3에 저장만 하는데 서버 자원을 사용하는 것이 좋아 보이지 않았고 사용자들의 이미지 업로드 속도도 느리다는 문제가 있어서 다른 방법을 생각하게 됩니다.

iOS, 안드로이드에서 이미지를 토스트 클라우드의 스토리지에 직접 업로드하고 해당 경로만 서버에 전달하면 업로드 속도도 빠르고 서비스의 부담도 줄어들게 되는 장점이 있어 클라이언트에서 이미지 서비스로 직접 업로드 하는 방식으로 변경하기로 합니다.

결정은 되었고 iOS, 안드로이드 개발자들은 각각 토스트 클라우드로 이미지를 직접 업로드 하는 테스트를 하는데 문제가 생겼습니다. 토스트 클라우드는 API 연동 시 키를 사용하는데 이것은 클라이언트에 저장하면 안 되는 중요한 키라는 것입니다.

S3 같은 경우 키를 여러 개 만들어서 이미지 업로드만 가능하게 하는 키를 별도로 생성하기도 하는데 토스트 클라우드에는 문의결과 키는 하나만 생성 가능하고 아직 클라이언트에서 직접업로드 하는 것에 대한 고려는 안 되어있으며 서버를 통해 업로드해야 된다는 답변을 받았습니다.

AWS Lambda 로 썸네일 생성

온디맨드로 다시 구축할까 싶다가 별도의 온디맨드 서버 구축하는 건 별로라고 생각되었고 다시 다른 방법을 찾다가 AWS Lambda를 떠올렸습니다.

우선 S3는 클라이언트에서 S3로 직접업로드할때 Cognito를 이용해 안전하게 업로드 할 수 있었습니다. 그리고 AWS Lambda의 이벤트 트리거는 S3 이벤트를 받아서 처리할 수 있었죠.

AWS Lambda는 개인 프로젝트에서 몇 번 사용해봤던 경험이 있어서 좋은 인상을 받고 있었고 서버를 별도로 운영하지 않아도 돼서 토스트 클라우드의 이미지 서비스와 비슷하게 구현할 수 있을 것 같았습니다. 실제로 사용하는 산타토익김한기님께 조언을 듣고 시작하게 됩니다.

AWS Lambda를 이용한 이미지 리사이즈는 AWS에서도 예제로 제공해줍니다 ~ AWS 에서는 원본과 썸네일 S3 버킷을 별도로 나누라고 했었는데 저희는 리사이즈 하지 않는 경우도 있고해서 S3 버킷 하나에 원본과 썸네일을 저장하기로 했습니다.(근데 이거 실수하면 엄청 무시무시합니다…. 무한 루프가 돌 수도 있어서 조심해야 됩니다)

AWS에서 제공하는 예제는 실제 서비스에서 사용하기에는 부족한 면도 있고 Node.js 최신버전도 아니라서 다른 분들에게 도움이 될까해서 소스코드를 첨부합니다. 내용이 길지만, 중간에 잘리면 도움이 안될것 같아서 쭉 붙여넣습니다. 아래 코드를`index.js` 파일로 저장합니다.

'use strict';

let aws = require('aws-sdk');
let s3 = new aws.S3({ apiVersion: '2006-03-01' });
let async = require('async');
let gm = require('gm')
.subClass({ imageMagick: true });

const supportImageTypes = ["jpg", "jpeg", "png", "gif"];
const ThumbnailSizes = {
PROFILE: [
{size: 80, alias: 's', type: 'crop'},
{size: 256, alias: 'm', type: 'crop'},
{size: 640, alias: 'l', type: 'crop'}
],
ARTICLE: [
{size: 192, alias: 's'},
{size: 1280, alias: 'l'}
],
MESSAGE: [
{size: 1280, alias: 'l'}
],
BUSINESS_ARTICLE_THUMB: [
{size: 192, alias: 's', type: 'crop'}
],
sizeFromKey: function(key) {
const type = key.split('/')[1];
if (type === 'article') {
return ThumbnailSizes.ARTICLE;
} else if (type === 'profile') {
return ThumbnailSizes.PROFILE;
} else if (type === 'message') {
return ThumbnailSizes.MESSAGE;
} else if (type === 'business_article_thumb') {
return ThumbnailSizes.BUSINESS_ARTICLE_THUMB;
}
return null;
}
}

function destKeyFromSrcKey(key, suffix) {
return key.replace('origin/', `resize/${suffix}/`)
}

function resizeAndUpload(response, size, srcKey, srcBucket, imageType, callback) {
const pixelSize = size["size"];
const resizeType = size["type"];

function resizeWithAspectRatio(resizeCallback) {
gm(response.Body)
.autoOrient()
.resize(pixelSize, pixelSize, '>')
.noProfile()
.quality(95)
.toBuffer(imageType, function(err, buffer) {
if (err) {
resizeCallback(err);
} else {
resizeCallback(null, response.ContentType, buffer);
}
});
}

function resizeWithCrop(resizeCallback) {
gm(response.Body)
.autoOrient()
.resize(pixelSize, pixelSize, '^')
.gravity('Center')
.extent(pixelSize, pixelSize)
.noProfile()
.quality(95)
.toBuffer(imageType, function(err, buffer) {
if (err) {
resizeCallback(err);
} else {
resizeCallback(null, response.ContentType, buffer);
}
});
}

async.waterfall(
[
function resize(next) {
if (resizeType == "crop") {
resizeWithCrop(next)
} else {
resizeWithAspectRatio(next)
}
},
function upload(contentType, data, next) {
const destKey = destKeyFromSrcKey(srcKey, size["alias"]);
s3.putObject(
{
Bucket: srcBucket,
Key: destKey,
ACL: 'public-read',
Body: data,
ContentType: contentType
},
next
);
}
], (err) => {
if (err) {
callback(new Error(`resize to ${pixelSize} from ${srcKey} : ${err}`));
} else {
callback(null);
}
}
)
}

exports.handler = (event, context, callback) => {
const bucket = event.Records[0].s3.bucket.name;
const key = decodeURIComponent(event.Records[0].s3.object.key.replace(/\+/g, ' '));
// Lambda 타임아웃 에러는 로그에 자세한 정보가 안남아서 S3 파일 이름으로 나중에 에러처리하기위해 에러를 출력하는 코드
const timeout = setTimeout(() => {
callback(new Error(`[FAIL]:${bucket}/${key}:TIMEOUT`));
}, context.getRemainingTimeInMillis() - 500);

if (!key.startsWith('origin/')) {
clearTimeout(timeout);
callback(new Error(`[FAIL]:${bucket}/${key}:Unsupported image path`));
return;
}

const params = {
Bucket: bucket,
Key: key
};
const keys = key.split('.');
const imageType = keys.pop().toLowerCase();
if (!supportImageTypes.some((type) => { return type == imageType })) {
clearTimeout(timeout);
callback(new Error(`[FAIL]:${bucket}/${key}:Unsupported image type`));
return;
}

async.waterfall(
[
function download(next) {
s3.getObject(params, next);
},
function transform(response, next) {
let sizes = ThumbnailSizes.sizeFromKey(key);
if (sizes == null) {
next(new Error(`thumbnail type is undefined(allow articles or profiles), ${key}`));
return;
}
async.eachSeries(sizes, function (size, seriesCallback) {
resizeAndUpload(response, size, key, bucket, imageType, seriesCallback);
}, next);
}
], (err) => {
if (err) {
clearTimeout(timeout);
callback(new Error(`[FAIL]:${bucket}/${key}:resize task ${err}`));
} else {
clearTimeout(timeout);
callback(null, "complete resize");
}
}
);
};

위 코드는 ‘origin/profile’ 폴더 밑에 이미지가 올라오면 지정된 사이즈에 따라 `resize/l/profile`, `resize/m/profile`, `resize/s/profile` 폴더에 썸네일을 생성후 저장합니다. 썸네일 사이즈, 경로, 크롭 옵션은 코드 첫부분에서 정의해두었고 이것을 사용합니다.(profile 뿐만 아니라 article, message 등 여러 가지 타입이 있습니다)

Node.js를 AWS Lambda 사용하면서 처음 접한 거라 코드에 부족한 부분이 많을 텐데 알려주시면 저한테도 도움이 많이 될 거 같습니다.

Lambda를 이용한 이미지 리사이즈 구조

AWS Lambda 런타임은 Node.js 4.3 이며 코드를 실행하기 위해서는 async, gm 모듈을 설치해야 합니다. `npm install async`, `npm install gm` 명령어를 입력하면 현재 폴더의 `node_modules` 폴더에 설치됩니다.

위 코드를 저장한 파일과 node_modules 디렉토리를 하나로 압축해서 Lambda에 업로드 하면 되는데요. 이것을 쉽게 하기 위해 `zip.sh` 파일을 만들었습니다

#!/bin/sh
zip -r lambda index.js node_modules

zip.sh 파일을 실행하면 lambda.zip 파일이 생성되고 zip 파일을 업로드하면됩니다.


여기까지 정리

위의 여러 가지 개선으로 당근마켓의 이미지 업로드/다운로드가 더 빨라졌습니다. 수치를 저장하고 있지는 않아서 정확하게 언급할 수 없지만, 눈으로 보기에도 빨라진 게 느껴질 정도였습니다. 물론 기존에 너무 느렸던 것도 있겠지요 ㅎㅎ

  • 이미지를 Refile을 이용한 온디맨드 이미지 리사이즈 방식에서 AWS Lambda를 이용한 백그라운드 썸네일 생성방식으로 변경 => 서버 부담 및 운영 부담이 사라짐
  • iOS, 안드로이드 클라이언트에서 이미지 업로드 시 AWS S3에 바로 저장 => 서버 부담이 낮아지고 업로드 속도도 빨라짐

새로 구축한 이미지 리사이즈 방식에도 몇 가지 단점은 있습니다

  • Lambda 의 Throttles 제한에 걸리면 1 이라고만 에러가 발생해서 정확히 어느 정도의 Throttles 이 더 필요한지 알 방법이 없습니다. Throttles 은 동시에 실행되는 Lambda 의 개수인데 기본값으로 계정&지역당 100개입니다. 이 값이 어느 정도까지 차올랐는지 모니터링이 안 되니 사전에 제한을 풀어달라고 신청하기 힘들고 지금 신청한 지 2주인데 아직 최종적으로 제한을 풀었다는 답변을 받지 못했습니다 ㅜㅜ
  • 특정 이미지의 썸네일 생성이 실패하는 경우 로그를 확인하기 쉽지 않습니다. 그냥 파일에 쌓인 로그를 보는 게 아니라 CloudWatch를 통해서 봐야 되는데 아직 익숙하지 않네요

하고 싶은 이야기가 많은데 내용이 너무 길어지는것 같아서 아쉽네요 ㅜㅜ


나중에

개인적인 생각으로는 당근마켓 사용자가 더 많아지면 나중에 다시 온디맨드 이미지 리사이즈 방식으로 돌아가고 싶은 마음이 있습니다. 그때는 AWS Lambda + AWS API GateWay + CDN 조합을 사용할까 싶네요 ~ 지금 만든 Lambda 코드를 거의 그대로 재활용할 수 있으니까요. 아니면 imageproxy를 사용할수도 있을것 같구요.

당근마켓 사용자가 많아져서 공유할 거리가 더 많이 생기기를 바랍니다 ~


마지막으로 몇가지 도움이 될만한 내용들을 나열해봤습니다.

CloudWatch 로그 확인하기

$ aws logs filter-log-events \
--start-time `date -v-2H "+%s"`000 \
--log-group-name "/aws/lambda/MakeImageResize" \
--filter-pattern "Error" \
--interleaved \
--output json | jq '.events[]|.message|split("\t")[2]|fromjson|.errorMessage'

위의 명령어는 aws-cli를 이용해 MakeImageResize Lambda의 최근 2시간 동안의 에러를 이쁘게 출력합니다. jq를 이용해 json의 특정 항목만 가져오는 거라서 jq가 설치되어 있어야 합니다.

AWS Lambda의 오류처리 및 재시도

Lambda는 코드를 실행하다가 에러가 발생하거나 실패로 반환하면(메모리 부족, Throttle 제한, 실행시간 타임아웃, 알 수 없는 에러) 재시도 하는데 이는 이벤트 타입과 트리거하는 소스에 따라 다른데요. 위에서 사용한 S3의 객체 생성을 이벤트 소스로 하는 경우 3번의 재시도를 합니다.

이러한 재시도 정책으로 인해 당근마켓 썸네일 이미지의 경우 에러가 가끔 발생하는데 확인해보면 모두 썸네일이 정상적으로 생성되어 있었습니다.

자세한 내용은 AWS Lambda : Retries on Errors, AWS Lambda : FAQ : Scalability and Availability를 참고하시기 바랍니다.

AWS Lambda는 서울지역에서 사용불가

저는 당연히 서울지역에서 사용 가능할 줄 알았는데 서울지역에서 사용 불가능합니다 ㅜㅜ 언제 열어줄지 모르겠지만, 많이아쉽더라고요. 그래서 저희는 이미지 저장하는 S3를 도쿄에서 운영 중입니다. Lambda에 S3 데이터를 읽거나 저장할때 Lambda와 S3의 지역이 같아야 된다고 했거든요.

어차피 CDN이 있어서 이미지 다운로드는 크게 상관은 없는데 클라이언트에서 직접 업로드하는 속도에 조금이나마 영향이 있을 거라 아쉬웠습니다. S3에서 업로드를 빠르게 해주는 Amazon S3 Transfer Acceleration를 사용해서 속도를 높여볼까도 생각했는데 테스트 페이지에서 보니까 별 차이가 안 나서 적용하지 않았습니다.(도쿄 지역에 업로드할때 16% 빠르더라고요…)

썸네일이 미처 생성되지 못한 경우 대비

썸네일을 백그라운드에서 생성하기 때문에 사용자가 썸네일을 요청했을 때 파일이 아직 생성되어있지 못하는 경우가 있을 수 있습니다. 그래서 저희는 2가지 방법으로 이 문제를 해결했습니다.

첫 번째 방법은 iOS, 안드로이드 클라이언트에서 썸네일 이미지 요청이 실패하면 원본 이미지를 호출하도록 했습니다. 썸네일이 생성되어 있지 않아도 원본 이미지를 보여주기에 사용자가 이미지를 못 보는 문제를 해결합니다.(Lambda로 썸네일 서비스를 제공하는 산타토익김한기님한테서 얻은 조언)

두 번째 방법은 이미지를 생성한 지 10초 이내에는 썸네일 URL 대신 원본 이미지 URL을 이용합니다. 서버에서 DB에 있는 레코드의 생성시간을 보고 판단하는 건데요. 이는 첫 번째 방법이 적용되지 못한 버전의 클라이언트 사용자를 위한 임시코드로 언젠가는 없어질 예정입니다.

토스트 클라우드에 대해

클라이언트에서 이미지 업로드 하는 것 때문에 토스트 클라우드 대신 S3, Lambda를 선택했지만, 서버에서 직접 이미지를 업로드 하는 경우였다면 토스트 클라우드를 사용했을 것 같습니다. 이미지 썸네일을 생성하는 것이 저희의 주된 개발목표가 아니라서 이런 것들은 다른 데서 잘 만들어둔 것을 사용하면 되니까요.

이미지는 토스트 클라우드를 사용하지 않지만, SMS 전송은 조만간 토스트 클라우드로 이전할 예정입니다. 설명해주시기로는 NHN 엔터의 페이코(PAYCO)고도몰같은 쇼핑몰도 동일한 제품을 사용하는데 이쪽은 SMS 전송이 중요해서 빠르고 안정적인 비즈망이라는 것을 사용한다고 하셨거든요. 그리고 비용도 SMS(9.9원/건), LMS(30원/건), MMS(100원/건) 모두 현재 SMS 제공업체보다 많이 저렴합니다 !

토스트 클라우드에 대해 직접 와서 설명해주신 NHN엔터테인먼트의 이주영 님, 송정석 님 덕분에 좋은 정보를 얻을 수 있었습니다. 다시 한 번 감사드립니다 ^^

참고정보

일본어글은… 번역기로 읽어요 ㅋㅋ 저도 일본어 1도 못하지만 모두 번역기로 봅니다 ~ 유용한 글이 많더라구요

Show your support

Clapping shows how much you appreciated seapy’s story.