그림 커뮤니티가 이미지를 다루는 방법

JongHun, Lim·2025년 12월 14일

Grimity

목록 보기
2/2
post-thumbnail

사이드 프로젝트로 시작한 그림 커뮤니티 그리미티가 어느덧 MAU 2000명과 함께 회원가입자수 760명을 돌파했다.

서비스 링크: https://www.grimity.com
트위터 링크: https://x.com/grimity_offcl

서비스 운영의 주 목적 자체가 개인의 성장이다보니 따로 돈을 써서 광고를 돌린다던지 마케팅을 하지 않았음에도 이정도면 개인적으론 엄청난 성과라고 생각한다.

서비스의 주 컨텐츠가 그림이기에 많은 이미지가 업로드되고 조회되며 그림의 종류또한 다양해 이 이미지들을 어떻게 다뤄야할지 팀 내부에서 굉장히 많은 고민과 논의가 있었다.

Flutter 앱 개발자

  • 이미지 몇장만 렌더링해도 메모리가 차서 앱이 죽어버려요.
  • 이미지를 최적화 한 상태로 렌더링하기 위해 원본 이미지의 가로세로 비율을 알아야해요.

NextJS 웹 개발자

  • 브라우저의 메모리 사용량을 보면 500MB라고 나오는데 다 이미지에요. 최적화가 필요해요.
  • 피드 이미지, 자유게시판 이미지, 프로필 이미지, 커버 이미지가 다 다른 api를 쓰며 form-data로 들어가는데 통일된 방식이면 좋겠어요.
  • Next의 Image 태그를 사용해서 Vercel에 올리게 되면 며칠만에 프리티어 제한에 도달해버려요.

UI/UX 디자이너

  • 핀터레스트처럼 원본 이미지 비율을 유지한 상태로 무한스크롤 UI를 만들고 싶은데 구현이 힘들까요?

NestJS 백엔드 개발자

  • 이미지를 저장하고 있는 S3 용량이 너무 빠르게 차요.
  • CPU Intensive한 작업이 어울리지 않는 NodeJS API 서버에서 이미지를 리사이징하는 건 아닌 것 같아요.
  • 유저가 업로드 했을때 동기식으로 리사이징을 하기엔 응답을 기다리는 유저의 UX가 안좋을 것 같고 비동기식으로 한다면 업로드 직후엔 리사이징 된 이미지가 없기 때문에 프론트 개발자들의 분기처리가 복잡해져요.
  • 여러명의 유저가 한 번에 여러장의 이미지를 업로드하게 된다면 인스턴스의 메모리 사용량이 높아지고 요청 단건에 갑작스럽게 높아지다보니 스케일링되기도 전에 인스턴스가 죽어버릴 수 있어요.

오늘은 그림러들을 위한 그림 커뮤니티, Grimity에서 이미지를 어떻게 업로드하고, 어떻게 저장하고, 어떻게 용량을 줄이며, 어떻게 보여주는지 그 모든 내용을 공유하려 한다.

1. 이미지 업로드

이미지를 업로드하는 방식에서부터 고민이 많았다.
최초의 방식과 비교하여 총 3가지의 개선점이 있고 하나씩 소개하겠다.

이미지 용량 줄이기

Next + Vercel 환경에서 Image 태그를 쓰게 되면 조회할때 png나 jpeg 포맷을 webp로 바꿔주는 걸 보고 이 방식을 처음 떠올렸다.

이미지의 포맷은 대표적으로 png, jpg가 있고 조금 덜 알려진 포맷으로는 webp가 있다.
webp 포맷은 구글이 개발한 차세대 포맷으로 기존의 jpeg, png보다 더 뛰어난 압축률을 자랑한다.

구글링해보면 jpeg -> webp는 약 25%, png -> webp도 25% 정도로 용량이 살짝 감소한다고 나와있지만 실제 고용량 일러스트(2MB이상) 이미지로 10장 정도 테스트해보니 jpeg -> webp는 약 20% 감소, png -> webp는 무려 80%나 용량이 줄어들었고 몇장은 90%도 있었다.

유저가 업로드하는 이미지의 99%가 jpg 혹은 png이기 때문에 이걸 webp로 변환해서 업로드하기로 결정했으며 이 변환작업을 api 서버가 아닌, 프론트에서 하기로 했다.

webp 변환을 프론트에서 하는 이유

  • 이미지 포맷 변환은 메모리뿐만 아니라 CPU도 사용하는 작업이다. 여러 명의 유저가 동시에 여러 장의 이미지를 업로드할 때 API 서버에 부하가 집중되는 것을 방지하기 위해 프론트엔드에서 처리했다.

  • 브라우저에서 파일을 선택하는 시점에 이미 클라이언트 메모리에 이미지가 로드되므로, 서버로 전송하기 전에 변환하는 것이 더 효율적이다. 또한 변환된 이미지를 업로드하면 네트워크 대역폭도 절약할 수 있다.

S3 PresignedURL 사용

S3 PresignedURL을 사용한건 사실 API 서버의 메모리에 이미지가 올라가는 걸 막기위함이었다.

AWS 서버비용을 줄이기 위해 1GiB 메모리를 가진 t4g.micro를 쓰고있기에 이미지 한장 한장이 서버 입장에선 부담이 컸다.

PresignedURL을 도입하면서 얻은 부가적인 이점은 프론트엔드 개발자가 일관된 방법으로 이미지를 업로드할 수 있는 DX개선이 있다.

피드 이미지, 프로필 이미지, 커버 이미지, 자유게시판 이미지 등등 이미지의 용도에 따라 서로 다른 api들을 호출하며 request body에 form-data로 업로드하던 방식에서 용도에 관계없이 선 업로드 후 저장이라는 일관된 방식으로 프론트엔드 개발자들의 혼란을 효과적으로 줄일 수 있었다.

또한, 백엔드 입장에선 api상으로 단순히 업로드 된 이미지 url만 입력받아 저장하면 되기에 비즈니스 로직에서 이미지 업로드를 분리시킬 수 있다.

이미지 파일명에 width, height 정보 추가하기

이미지명에 width, height 정보를 추가한건 비교적 최근의 일이었다.
팀 내의 Flutter 앱 개발자가 이미지를 최적화하여 렌더링하기 위해선 원본 이미지의 비율정보가 필요하다는 요청이 있었다.
사실 이 부분은 백엔드 개발자인 내 입장에선 왜 비율정보가 필요한지 기술적으로 잘 모르긴하지만 뭐 어쩌겠는가 Flutter를 잘 모르기에 반박을 못하는데 해줘야지..

장기적으로 봤을때도 비율정보는 필요하긴하다. 현재의 그리미티는 정사각형 형식으로 이미지를 렌더링하고 있지만 핀터레스트나 다른 그림 사이트로 유명한 해외서비스 픽시브처럼 이미지의 원본 비율을 유지한 상태로 무한스크롤 페이지를 구현하기 위해선 원본 이미지의 비율 정보가 필수적이다.

그렇다고 이 이미지의 메타데이터를 저장하기 위해 이미지ID를 FK로 가진 다른 테이블에 저장하여 매번 join해서 반환하기도 애매하기에 업로드 시점에 프론트에게 이미지의 width, height값을 받아 feed/${UUID}_widthxheight.webp형식으로 이미지를 S3에 저장하여 조회시 파일명을 그대로 반환하는 것으로 해결했다.

2. 이미지 조회

그리미티는 이미지를 업로드하는 시점에 webp로 변환하여 용량을 효과적으로 줄일 수 있었지만 이것만으론 부족했다.

가장 처음 문제의 심각성을 깨달은 건 개발된 앱을 QA할때였다.
처음 앱을 받아 로그인 후 무한스크롤 페이지를 쭉 내리는데, 얼마 가지않아 앱이 스스로 꺼지는 현상이 발생했다.
물론 앱단에서도 메모리 최적화를 하지 않았기에 발생한 현상이지만 그럼에도 이 경험은 클라이언트에서 이미지가 차지하는 메모리 용량도 생각해야한다는 신선한 충격이 있었다.

개선하기 전의 브라우저 환경에서도 그리미티는 다른 웹사이트들에 비해 메모리를 과하게 사용하고 있기도 했고 생각해보면 당연한 이야기였다.
렌더링 사이즈가 36x36인데 크기가 1MB가 넘어가는 10000x10000 사이즈의 원본 이미지를 그대로 보여주고 있는게 얼마나 큰 리소스 낭비인지 여태 생각하지 않았을 뿐..

그럼에도 이미 webp로 변환을 했기에 그정도는 그냥 보여줘도 되지 않냐라고 생각할 수 있지만 그건 틀린 생각이다.

webp로 이미지를 변환하여 얻은 이점은 저장 크기 감소와 로딩 속도 향상, 즉 저장서빙의 측면에선 확실히 이점이 있지만 클라이언트에서 압축이 풀리며 이미지가 보여질땐 pngwebp나 메모리 관점에선 아무런 차이가 없다.

이미지 리사이징의 필요성은 알겠는데 그럼 언제 어디서 해야 좋을지 고민이 많았다.

업로드 하는 시점에 리사이징을 하기엔 결국 백엔드에서 이미지를 직접 받아야하는데 처음 설계의도였던 백엔드에서 이미지에 메모리와 CPU를 사용하지말자부터 뜯어고쳐야한다.

기존 설계를 유지한 상태로 문제를 해결하기 위해선 Lambda가 필요했다.
이미 그리미티는 S3 앞단에 CDN 서비스인 Cloudfront를 통해 이미지를 조회하고 있었고, CloudfrontLambda@Edge를 붙일 수 있다.
그리미티는 다음 프로세스와 같이 Origin Request 시점에 리사이징된 상태의 이미지를 캐싱하는 방법을 채택했다.

이에 우리는 리사이징의 크기를 정할 수 있는 s 쿼리스트링을 추가했고 이미지를 조회할때 다음 4가지 사항에 맞춰 요청하기로 약속했다.

  1. s=300 쿼리스트링은 이미지의 가장 짧은 변 기준으로 300px을 맞춰 리사이징
  2. s=600
  3. s=1200
  4. 위 리스트에 없는 수치이거나 s 쿼리스트링이 없을경우 원본 이미지를 조회

캐싱 및 리사이징 로직은 다음과 같다.

한 번 리사이징 된 이미지는 /resized/300 경로에 그대로 저장되며 다음번 Cache Miss때는 리사이징을 다시 할 필요없이 기존에 리사이징되어 있는 이미지를 그대로 반환한다.

사실 리사이징된 이미지를 300, 600, 1200 총 3가지 버전으로 다시 저장하는게 S3 비용증가로 이어지기때문에 고민이 많았지만 Cache Miss때마다 매번 리사이징 하는것이 Lambda 실행비용 및 UX 측면에서 종합적으로 더 안좋을 것이라 생각했다.
저장용량 문제도 /resized 경로에 있는 객체들에 한해 S3 생명주기 규칙을 걸어 일정 기간 이상 조회되지 않은 객체는 삭제시킬 수도 있어 그렇게 큰 단점이라고 생각되진 않았다.

개선 전 메모리 사용량(481MB)

개선 후 메모리 사용량(204MB)

정확한 비교가 되진 않겠지만 두 스크린샷 모두 처음 웹에 진입한 순간 메모리 사용량 기준이다. 거의 60%의 메모리 사용량이 감소했음을 알 수 있다.

15MB를 그대로 렌더링하고 있는 벨로그의 썸네일을 보라.

위 이미지에 그리미티에서 사용하는 최적화 기법들을 전부 사용한다면 png -> webp로 503KB, 1200px로 리사이징 한다면 57KB까지 줄어들어 총 15.2MB -> 57KB로 99.6%의 용량감소효과를 볼 수 있다.

마무리

서비스를 2월부터 운영했으니 거의 1년을 함께한 서비스다.
그동안 팀장으로써 책임지고 서비스를 관리하다 보니 놀고싶어도 못놀고 힘들때도 있고 그만하고 싶을때도 있었지만 올 한해를 마무리하며 그동안의 고생을 돌아보면 이 서비스 덕분에 취업도 하고, 많이 성장한 것 같아 참 애정이 많이 가는 서비스인 것 같다.

사이드 프로젝트를 진행하는 동안 이미지와 관련해 수없이 많은 고민과 수정이 있었는데 이걸 블로그에 적어야지 적어야지 하며 계속 미루다가 올해가 다 지날것같아 급하게 쓰다보니 정리가 깔끔하진 않은 것 같다..

아마 다음 포스팅으론 그리미티의 주력 기능은 아니지만 개인적인 고민이 많이 녹아들어져 있는 스케일링 된 인스턴스에서 웹소켓을 다루는 방법, FCM과 알림등을 비동기적으로 다루는 방법에 대해 설명할 것 같다.

1개의 댓글

comment-user-thumbnail
2025년 12월 15일

너무 유용해요 붐업 ^ㅁ^b

답글 달기