하루만에 서버리스 온디맨드 이미지 리사이징 서비스 구축하기
최근 투데잇 개발팀은 투데잇 모바일 어플리케이션을 위한 이미지 리사이징 서비스를 구축했습니다. Flask와 Wand(ImageMagick)를 사용해 서버 어플리케이션을 구현했으며 Zappa를 사용해 Lambda로 배포했습니다. CloudFront를 사용해 CDN까지 구성하여 모바일 어플리케이션에서 이미지 리소스를 로드하는 속도와 효율을 기존에 비해 크게 향상 시켰습니다. Flask 어플리케이션 구현부터 모바일 어플리케이션에서 사용 가능한 프로덕션 배포까지 모든 과정을 완료하기까지 하루라는 시간이 걸렸습니다. 이번 포스팅에서는 기존 투데잇의 이미지 로드 방식의 문제점과 그 문제를 어떻게 하루만에 개선했는지 공유하려고 합니다.
기존 투데잇의 문제점
초창기 투데잇 모바일 어플리케이션에서는 이미지의 사용 빈도가 낮았습니다. 애초에 이미지가 들어가는 UI 요소가 프로필 이미지, 메인 화면의 배경 이미지, 스톱워치 화면의 배경 이미지로 많지 않았고 그 마저도 다른 사용자와 공유되는 되지 않고 자신만 볼 수 있는 UI 요소였습니다. 따라서 초기 투데잇을 개발할 당시에는 이미지 파일을 최대한 간단하게 처리했습니다.
사용자가 모바일 어플리케이션에서 프로필 이미지, 메인 화면의 배경 이미지, 스톱워치 화면의 배경 이미지를 설정할 때 로컬에 원본 이미지 파일을 따로 저장해 두고, 도쿄 리전에 있는 S3 버킷에 직접 이미지를 업로드하도록 처리했습니다. 대부분의 경우에는 로컬에 캐싱된 이미지를 직접 로드해서 쓰기 때문에 이미지 로드 속도에 크게 문제가 되지 않았고, 로컬에 저장되는 이미지 개수도 많지 않았기 때문에 용량 문제도 없었습니다. 새로운 디바이스에 로그인을 하거나 어플리케이션을 재설치했을 때는 도쿄 리전에 있는 S3 버킷으로 부터 원본 이미지를 직접 다운로드해야하기 때문에 이미지 로드 속도가 느릴 수 밖에 없습니다. 하지만 한 번 다운로드한 후에는 로컬에 이미지 파일을 캐싱해두고 캐싱한 파일을 사용하기 때문에 사용자가 불편함을 인지할 만큼 큰 문제는 없었습니다.
문제는 유료 상품으로 그룹 기능이 출시되고 그룹 기능을 이용하는 사용자들이 늘어난 후에 생겼습니다. 다른 사람과 함께 공부하는 그룹 기능의 특성상 이미지가 들어가는 UI 요소가 많아졌습니다. 그룹 상태 화면과 그룹 피드에 들어가는 프로필 이미지, 기상인증 이미지, 공지 및 게시글에 첨부된 이미지 등 다양한 이미지를 반복적으로 로드하는 경우가 늘어났습니다. 하지만 그룹 기능 내에서 보여지는 이미지도 기존과 같이 원본 이미지를 도쿄 리전의 S3 버킷에서 직접 다운로드해 보여주는 방식으로 처리했기 때문에 다음과 같은 주요한 문제가 발생했습니다.
- 사용자가 답답함을 느낄 정도로 이미지 로드 속도가 느리다.
- 종종 이미지를 정상적으로 다운로드 받지 못해 이미지가 잘려 보인다.
- 모바일 디바이스에 이미지 파일을 캐싱한 후 해당 파일을 삭제하지 않아 어플리케이션 용량이 계속해서 증가한다.
- 모바일 디바이스에 용량이 부족한 경우 이미지 다운로드에 실패하고 이미지가 뜨지 않는다.
다수의 이미지를 실제 필요한 사이즈와 상관없이 불필요하게 큰 사이즈로 다운로드하고, 이미지 파일을 로컬에 저장한 후 더 이상 사용하지 않는 파일을 삭제하지 않았던 것이 문제의 가장 큰 원인이었습니다. 도쿄 리전의 S3에서 이미지 파일을 다운로드 받는 것도 다운로드 속도에 영향을 주었습니다.
스펙
기존 투데잇의 이미지 로드 문제를 근본적으로 해결하기 위해 이미지 리사이징 서비스가 필요하다고 판단하였습니다. 이미지 리사이징 서비스의 기능으로 크게 3가지가 필요했습니다.
- 모바일 어플리케이션에서 원하는 사이즈의 이미지를 URL 기반으로 자유롭게 요청할 수 있어야한다.
- 한 번 리사이즈한 이미지는 S3에 저장하고, 이후 요청에서는 S3에 저장된 이미지 파일을 반환해 같은 사이즈에 대해 중복해서 리사이즈하는 경우가 없어야한다.
- CDN에 이미지를 캐싱해야한다.
이미지가 나타나는 뷰의 크기와 모바일 디바이스의 해상도에 따라 최적의 이미지 크기가 달라지기 때문에 필요한 이미지 크기를 파라미터 설정을 통해 요청하는 기능은 필수입니다. 또한 URL을 기반으로 이미지를 로드할 수 있다면 모바일 어플리케이션에서 Glide (Android), AlamofireImage (iOS)와 같은 이미지 처리 라이브러리를 사용해 이미지 다운로드부터 파일 캐싱, 메모리 캐싱 등의 처리를 손쉽게 구현할 수 있습니다. 다음과 같은 형태로 이미지를 요청할 수 있습니다.
https://domain.com/images/image_file_name.jpg?criterion=width&size=200
이미지 리사이징 서비스에서 가장 오래 걸리는 작업은 아마도 이미지 크기를 바꾸는 리사이즈 작업일 것입니다. 따라서 한 번 리사이즈한 이미지는 S3에 저장해 같은 사이즈에 대해서 반복해서 리사이즈하지 않도록 처리합니다. 그러면 이후 같은 사이즈에 대한 요청의 지연 시간(latency)을 낮출 수 있습니다.
투데잇은 AWS 도쿄 리전을 사용하고 있습니다. 이미지를 저장하고 있는 S3 버킷 또한 도쿄 리전에 존재하고 Lambda 또한 도쿄 리전에 구성해야합니다. 따라서 사용자에게 조금이라도 더 빠르게 이미지를 제공하기 위해 CDN도 구성해야합니다.
구현
Flask
Python과 같은 경우 이미 서비스 데이터 분석에 사용하고 있었기 때문에 익숙한 언어였고, 회사에서 다른 프로젝트를 진행하면서 Flask 기반으로 백앤드를 구현한 경험도 있었습니다. 또 아래 코드와 같이 필요한 기능 이외에 불필요한 설정을 할 필요가 없다는 점에서 Flask는 당장 다양한 기능이 굳이 필요없는 이미지 리사이징 서비스 구현에 적합했습니다.
app = FlaskAPI(__name__, instance_relative_config=True)
app.config.from_object(app_config)
CORS(app)
@app.route('/images/<string:image_name>', methods=['GET'])
def get_resized_image(image_name):
# ...
return {'success': True}, status.HTTP_200_OK
if __name__ == '__main__':
app.run()
무엇보다 Python 기반의 Flask를 선택한 가장 큰 이유는 Zappa를 사용할 수 있다는 점이었습니다. Zappa를 사용하면 배포를 위한 아주 간단한 설정만으로 로컬에서 구현한 Flask 어플리케이션을 AWS Lambda로 빠르게 배포할 수 있습니다. Zappa에 대한 자세한 내용은 아래에서 다루도록 하겠습니다.
Wand (ImageMagick)
이미지 처리 라이브러리로는 Wand를 선택했습니다. 처음에는 비트윈의 사례와 모씨의 사례를 보고 ImageMagick 보다 성능이 좋은 Skia를 사용하고자 했습니다. 하지만 Skia와 같은 경우 최근까지 활발하게 유지보수되고 쉽게 가져와서 사용할만한 Python 바인딩 라이브러리가 마땅히 없었기에 Skia를 사용하기 위해서는 저희가 직접 Python 바인딩을 구현해야하는 상황이었습니다. 저희는 이미지 리사이징 서비스를 구축하기 위해 최대 2일 정도의 시간을 쓸 수 밖에 없는 상황이었고, 모씨에서 직접 개발하는데 일주일이 걸렸다고 하였기에 성능은 빠르게 포기하고 가장 쉽고 빠르게 연동할 수 있는 Wand (ImageMagick)를 선택하게 되었습니다. 실제로 Pipenv를 통해 Wand를 Flask 프로젝트에 설치하고, 로컬 환경에 ImageMagick을 설치하기만 하면 아래 코드와 같이 바로 이미지를 리사이즈할 수 있었습니다.
with Image(file=origin_image_file) as image:
width, height = measure_image_size(image.size, criterion, size)
image.resize(width, height)
# ...
Wand와 같은 경우 처음 로컬 환경에 설치할 때 조금 애를 먹었습니다.
Wand에서 아직 최신 버전의 ImageMagick 7을 지원하지 않는데 이 사실을 몰라 Wand의 가이드 문서를 따라 brew install imagemagick
명령으로 ImageMagick 7을 설치했고, 아래와 같은 에러와 함께 Flask 어플리케이션을 실행할 수 없었습니다.
ImportError: MagickWand shared library not found.
You probably had not installed ImageMagick library.
Try to install:
brew install freetype imagemagick
Wand가 현재(2018년 5월) ImageMagick 6까지만 지원하기 때문에 아래 명령어로 ImageMagick 6 버전을 설치해주어야 했습니다.
brew install imagemagick@6
brew link imagemagick@6 --force
앞서 이야기했듯이 전체 서비스에서 이미지 크기를 바꾸는 리사이즈 작업이 가장 성능을 잡아먹고, 오래 걸리는 작업인 만큼 추후 Wand (ImageMagick) 보다 빠른 라이브러리로 대체할 계획입니다. 대체할 라이브러리로는 위에서 언급한 Skia나 Pillow-SIMD를 고려하고 있습니다.
Zappa (Server-less Python on AWS Lambda)
이제 Flask 어플리케이션을 로컬 환경에서 개발 및 테스트를 완료했으니 모바일 어플리케이션에서 사용 가능하도록 배포해야 합니다. 저희는 아래의 기준에 따라 Zappa를 사용해 AWS Lambda로 배포하기로 결정했습니다.
- 최대 2일 이라는 한정된 시간 안에 프로덕션 배포 작업을 마무리할 수 있어야한다.
- 기존 AWS EC2에서 돌아가고 있는 API 서버에 영향을 주지 않아야 한다.
- 가능하면 최대한 저렴한 비용(경제적인 측면, 관리의 측면)으로 운영할 수 있어야한다.
Zappa는 배포 작업을 정말 간단하게 만들어 주고, AWS Lambda는 기존 서버와 완전히 독립적으로 구성되고 저렴한 비용으로 서비스를 운영할 수 있게 해줍니다.
Zappa는 기존 Django나 Flask와 같은 Python 웹 어플리케이션을 매우 간단하게 AWS Lambda 위에서 동작하는 서버리스 Python 어플리케이션으로 배포해주는 도구입니다.
실제로 Zappa 패키지를 설치하고, zappa_settings.json
파일을 설정하고, 배포 명령어를 치는 것만으로 프로덕션 환경으로 배포할 수 있습니다.
Lambda, API Gateway, CloudFormation, IAM Role, Cloudwatch 설정까지 Zappa에서 모두 자동으로 해줍니다.
물론 zappa_settings.json
파일을 통해 직접 설정할 수도 있습니다.
pipenv install zappa
명령어로 Flask 프로젝트 환경에 Zappa 패키지를 설치합니다.
그리고 난 다음 zappa init
명령어를 실행하거나 다음과 같이 zappa_settings.json
파일을 직접 추가해 배포에 필요한 설정을 합니다.
{
"production": {
"project_name": "image-resizing-flask",
"aws_region": "ap-northeast-1",
"s3_bucket": "production-bucket",
"runtime": "python3.6",
"aws_environment_variables" : {
"CONFIG_NAME": "production",
"FLASK_APP": "run.py"
},
"memory_size": 512,
"timeout_seconds": 30,
"vpc_config": {
"SubnetIds": ["subnet-12345678"],
"SecurityGroupIds": ["sg-12345678"]
},
"app_function": "run.app",
"exclude": ["*.gz", "*.rar", ".git", ".gitignore"],
"keep_warm": true
}
}
zappa_settings.json
파일에 배포에 필요한 설정을 끝내고 나면 zappa deploy production
명령을 통해 Flask 어플리케이션을 Lambda로 배포할 수 있습니다.
처음 배포한 이후의 수정 사항은 zappa update production
명령을 통해 다운 타임 없이 배포할 수 있습니다.
한 가지 더 편했던 점은 배포할 때 precompiled C-extension을 Zappa가 자동으로 가져와 주기 때문에 저희가 따로 설정할 필요가 없었다는 점입니다. 대표적으로 Wand 라이브러리의 ImageMagick에 대한 의존성을 가지고 있습니다. 로컬 개발 환경에서는 ImageMagick를 직접 설치했지만 배포 시에는 따로 설치하거나 하지 않았습니다.
AWS Lambda는 메모리 크기를 설정할 수 있습니다. AWS의 개발자 안내서에는 설정한 메모리 크기 비례해 CPU 성능과 요금이 달라진다 라고만 나와 있어 처음에는 메모리 값을 어느 정도로 설정해야 적정 성능과 비용이 나올지 감이 잘 안잡혔습니다. 관련해서 조사를 하던 중 메모리 값에 따라 CPU 성능과 요금이 어떻게 변하는지에 대해 실험한 글을 찾았습니다. 이 글의 실험에 따르면 메모리 설정값에 따라 실제 연산 속도는 지수적 감쇠 관계를 보이고, 비용과 같은 경우는 메모리 설정값과 크게 상관없이 비용이 거의 같다고 합니다. 실제 이미지 리사이즈 로직을 테스트해본 결과 메모리 설정값이 대략 1500MB 이상으로 가게 되면 연산 속도에 극적인 변화는 느껴지지 않아 메모리 크기를 2048MB 결정했습니다.
AWS Lamda에서 VPC의 Private Subnet에 있는 RDS 인스턴스에 접속하기 위해서는 AWS Lamda가 같은 VPC와 Private Subnet에 존재해야 합니다. 여기서 주의할 점은 Lamda에 VPC를 설정하게 되면 인터넷 엑세스 권한을 상실하기 때문에 꼭 Lambda에 설정된 Security Group의 아웃바운드 연결을 허용하고, Private Subnet이 외부 인터넷에 엑세스할 수 있도록 VPC에 NAT Gateway를 설정해야 한다는 점입니다.
CloudFront
S3 버킷과 Lambda 모두 도쿄 리전에 존재합니다.
따라서 CloudFront를 통해 한국에 존재하는 엣지 로케이션에 이미지를 캐싱하게 되면 추가적인 성능 개선을 기대할 수 있습니다.
Origin은 https://abcdefghij.execute-api.ap-northeast-1.amazonaws.com/production
과 같은 API Gateway의 주소로 설정합니다.
원본이 같은 이미지라도 URL 쿼리에 이미지 사이즈를 어떻게 설정하는지에 따라서 각각 다른 리소스가 되고, 각각 다른 파일로 엣지 로케이션에 캐싱되어야 합니다.
따라서 아래와 같이 Query String Forwarding and Caching
설정을 Forward all, cache based on whitelist
또는 Forward all, cache based on all
로 해야합니다.
결과
새로 구현한 이미지 리사이징 서비스를 실제 모바일에 적용한 결과 기대한 만큼의 이미지 로드 속도 개선 효과를 볼 수 있었고, 사용자가 체감할 만큼의 속도 차이를 보여줬습니다.
위 차트는 1600 * 1200 사이즈의 이미지를 160 * 120 사이즈로 리사이즈 하는 경우에 대해서 테스트한 결과입니다. 각각 경우에 대해서 50번 요청한 지연 시간의 평균입니다. S3에서 바로 반환하는 경우는 그래도 양호한 편이지만 리사이즈한 후에 반환하는 경우는 때에 따라서 600~700ms까지 느려질 때도 있었습니다.
정리
투데잇 개발팀은 Flask, Wand, Zappa, AWS Lambda, CloudFront를 사용해 하루만에 이미지 리사이징 서비스를 개발, 배포까지 마무리했습니다. 짧은 시간 안에 작업을 마무리해야 했기에 앞으로 이미지 리사이즈 성능, 원본 이미지 저장 효율, CDN Cache Hit Ratio 등 개선할 사항이 많이 있습니다. 하지만 적은 시간을 투자해 빠르게 기존의 문제점을 개선하고 안정적으로 서비스를 배포했다는 점에서 서버리스 아키텍처의 장점을 실감할 수 있었습니다. 서버리스 아키텍처가 모든 곳에 적합하거나 이점을 가져다 주지는 않겠지만 적재적소에 잘 활용하면 경제적인 비용 뿐만 아니라 서비스를 개발하고 운영하는 비용도 획기적으로 줄일 수 있을 것입니다. 특히 시간이 생명인 스타트업에서 서버리스 아키텍처는 더욱 매력적으로 다가올 것입니다.