01 소개 02 경력 03 작업 04 블로그 05 이력서 PDF
← 작업 목록
실무 Initiative 2023.01 — 2023.02 · 설계 · 단독 구현

런타임 이미지 변환 서비스

오브젝트 스토리지의 원본 이미지를 런타임에 동적으로 변환(리사이즈·포맷·품질·블러)해 제공하는 서비스. WebP 미지원 상용 솔루션을 대체해 LCP를 82% 줄였습니다.

런타임 이미지 변환 서비스 스크린샷
분류
실무
기간
2023.01 — 2023.02
역할
설계 · 단독 구현
핵심 지표
LCP 82%↓
NestJSTypeScriptsharpNginxNCloud StorageDockerVitest
// 01
문제
the problem

라이브커머스 화면은 상품·방송 썸네일이 많고 원본이 고용량이라 첫 화면 렌더링(LCP)이 느렸다. NHN 클라우드가 제공하는 SaaS 이미지 변환 서비스를 검토했지만 WebP 출력을 지원하지 않아, 최신 포맷으로 용량을 줄여 렌더링을 개선하려던 목표 자체가 막혔다.


// 02
접근
the approach

오브젝트 스토리지(S3 호환)에서 원본을 스트림으로 가져와 sharp(libvips)로 런타임에 변환하는 서비스를 NestJS로 직접 구현했다. URL 쿼리(w·h·f·q·b)로 리사이즈·포맷(webp/avif 등)·품질·블러를 동적으로 지정한다. 변환은 비용이 크므로 앞단에 Nginx를 두어 변환 결과를 캐싱했는데, 쿼리스트링을 그대로 캐시 키로 쓰면 `?w=300&f=webp`와 `?f=webp&w=300`이 서로 다른 키로 잡혀 같은 이미지가 중복 캐싱된다 — 그래서 캐시 키 생성 시 쿼리 파라미터 순서를 고정(정규화)해 중복 캐싱을 차단했다. 또한 변환을 허용할 크기·포맷을 YAML 설정(`allowedSizes`·`allowedTypes`)으로 화이트리스트화해, 목록에 없는 요청은 변환을 시도하지 않고 404로 막았다 — 임의의 크기를 무한정 요청해 변환·캐시를 고갈시키는 DoS성 공격의 표면을 줄였다.


// 03
결과
the result

WebP·AVIF 변환으로 이미지 용량을 크게 줄여 LCP를 82% 감소시켰다. Nginx 캐싱으로 동일 변환 요청은 변환 비용 없이 즉시 응답하고, 쿼리 순서 정규화로 캐시 적중률을 높여 중복 변환·중복 캐싱을 제거했다. MinIO(S3 호환) SDK로 추상화해 NCloud/NHN 등 스토리지 벤더에 독립적으로 동작한다.


// 04
배운 것
takeaways

상용 SaaS의 단 하나의 미지원 기능(WebP)이 전체 목표를 막을 때는, 직접 만드는 편이 더 빠르고 유연했다. 그리고 캐싱은 '무엇을 캐싱하느냐'만큼 '키를 어떻게 정규화하느냐'가 중요하다 — 쿼리 순서 하나로 캐시 효율이 갈렸다. 사용자가 파라미터를 자유롭게 넣는 엔드포인트는 '무엇을 허용할지'를 좁히는 것만으로 공격 표면을 크게 줄일 수 있었다.

배경

라이브커머스 화면의 렌더링 속도를 끌어올리려면 이미지 용량을 줄여야 했고, 가장 효과가 큰 수단은 WebP·AVIF 같은 차세대 포맷이었습니다. 그런데 당시 사용하던 NHN 클라우드의 이미지 변환 SaaS가 WebP를 지원하지 않았습니다. 외부 솔루션에 발이 묶이는 대신, 필요한 변환만 하는 가벼운 서비스를 직접 만들기로 했습니다.

동작

GET /:bucket/:objectKey 한 엔드포인트로 동작합니다. 스토리지에서 원본을 스트림으로 받아 sharp로 변환한 뒤 StreamableFile로 흘려보내, 큰 이미지도 메모리에 통째로 올리지 않습니다.

요청 처리 흐름

GET /devimages/lc-image/brc/1099/main.jpg?f=webp&w=300&q=80
→ 200 OK · image/webp (너비 300px WebP)
쿼리의미
w / h리사이즈 (0 = 안 함)
f포맷 (webp·avif·jpg·png·gif)
q품질 (0–100)
b블러

캐싱 전략 — 쿼리 순서 정규화

변환은 CPU 비용이 크기 때문에 Nginx를 앞단에 두어 변환 결과를 캐싱했습니다. 핵심은 캐시 키였습니다. 쿼리스트링을 받은 그대로 키로 쓰면 의미가 같은 ?w=300&f=webp?f=webp&w=300이 별개의 캐시 엔트리로 잡혀, 동일한 이미지가 중복 저장되고 적중률이 떨어집니다. 캐시 키를 만들 때 쿼리 파라미터의 순서를 고정해, 순서만 다른 요청이 하나의 키로 모이도록 했습니다.

비정상 요청 차단

런타임 변환 서비스는 쿼리로 크기를 자유롭게 받는 만큼 악용 표면도 함께 생깁니다. 누군가 w=1, 2, 3 …처럼 매번 다른 크기를 무한정 요청하면, 그때마다 sharp 변환이 돌고 캐시에는 쓸모없는 엔트리가 끝없이 쌓여 CPU와 캐시를 고갈시킬 수 있습니다(캐시 버스팅 + DoS).

그래서 변환을 허용할 크기·포맷을 YAML 설정으로 화이트리스트화했습니다.

image:
  allowedTypes:
    - "webp"
    - "avif"
    - "png"
    - "gif"
    - "jpg"
    - "jpeg"
  allowedSizes:
    - {"width":0,"height":0}
    - {"width":75,"height":75} # profile image thumbnail
    - {"width":120,"height":120} # prev image thumbnail
    - {"width":200,"height":0} # main image thumbnail

목록에 없는 크기·포맷 요청은 변환을 시도하지 않고 404로 막습니다. 실제 서비스에서 쓰는 몇 개의 사이즈만 열어두면 변환 비용과 캐시 키의 가짓수가 유한해져, 악의적 요청으로 시스템을 흔드는 표면이 크게 줄어듭니다.

스토리지 추상화

스토리지는 MinIO(S3 호환) SDK로 추상화하고 factory 패턴으로 벤더(minio / none)를 갈아끼울 수 있게 했습니다. NCloud·NHN Object Storage처럼 S3 호환 스토리지라면 설정만 바꿔 그대로 동작합니다.