방치된 이미지 리사이즈 Lambda@Edge 현대화
수년간 방치된 CloudFront Lambda@Edge 이미지 리사이즈 함수를 인수받아 런타임·의존성을 최신화하고, SAM 기반 배포 파이프라인을 구축했습니다. 버그·DoS 취약점을 잡고 변환 성능을 4.7배 끌어올렸습니다.
문제
CloudFront 앞단에서 상품 이미지를 리사이즈하는 Lambda@Edge 함수가 수년간 손대지 않은 채 돌고 있었다. sharp 0.29(2021), Node 20, aws-sdk v2에 200줄짜리 단일 핸들러였고, 코드 안에는 `message` 미정의로 403 응답이 통째로 깨지는 버그, `%` 인코딩 오류로 CloudFront가 502를 뱉는 경로, base64 크기를 잘못 재 1MB 한도를 넘겨 터지는 케이스가 숨어 있었다. width/height를 무제한으로 받아 `?s=20000x20000` 한 방에 메모리·CPU를 고갈시킬 수 있는 DoS 표면도 열려 있었다. 게다가 배포는 문서화되지 않은 수작업이었고, prod 버킷·리전이 코드에 하드코딩돼 있었다. 의존성 취약점은 19건이 쌓여 있었다.
접근
먼저 함수 전체를 읽어 이슈를 버그(정확성) / 보안·DoS / 코드 스멜로 분류하고 우선순위를 매겼다. 1차로 동작 변화가 최소인 정확성·보안 수정을 묶었다 — 미정의 참조 제거, `decodeURIComponent`를 try/catch로 감싸 502 차단, base64 길이를 `ceil(raw/3)*4`로 정확히 계산, 치수 정수·양수 검증 후 `MAX_WIDTH/HEIGHT` 상한 clamp, fit 옵션을 sharp 허용값 화이트리스트로 제한. 2차로 순수 로직을 `lib.js`로 떼어내 단위테스트 28개를 붙이고, 매 루프마다 리사이즈를 통째로 재실행하던 `while` 재인코딩 루프를 단일 패스로 없앴다. 런타임은 Node 24로, sharp는 0.35로 올렸는데 — 0.33부터 네이티브 바이너리가 optionalDependency로 분리돼, mac(arm64)에서 Lambda(linux x64)용으로 크로스빌드하려면 구식 `npm_config_*` env 대신 `npm ci --os=linux --cpu=x64 --libc=glibc` 플래그를 써야 했다. aws-sdk는 v2 통짜에서 v3 모듈로 교체했다. 배포는 Serverless Framework 수작업을 걷어내고 AWS SAM으로 옮겨, `aws cloudformation`/`sam` CLI로 멀티환경(dev·prod) 빌드·배포가 재현되게 만들었다. Lambda@Edge는 환경변수를 못 쓰므로 stage별 bucket·region은 빌드타임 config 주입(`gen-config.mjs` + `config/<stage>.json`)으로 풀었다.
결과
동일 이벤트·메모리로 구·신 함수를 각각 100회씩 3회 직접 호출해 벤치마크했다. warm 변환이 1085ms → 233ms로 약 4.7배 빨라졌고 3회 모두 233~234ms로 재현됐다(p90−min ≈ 23ms, 지터도 크게 줄었다). cold init은 aws-sdk v3 패키지 경량화로 559ms → 453ms(약 19%) 단축됐다. warm GB-초가 약 4.7배 줄어 동일 트래픽 기준 함수 compute 비용을 약 78% 절감했다. 의존성 취약점은 19건에서 3건으로 떨어졌고, 502를 유발하던 두 경로와 403 처리 버그가 사라졌다. 배포는 CLI 한 번으로 환경별 재현이 가능해졌다.
배운 것
방치된 함수는 '돌고 있으니 괜찮다'가 아니라, 아무도 안 보는 사이 버그와 취약점이 조용히 쌓인 상태였다 — 먼저 전체를 읽고 이슈를 분류·우선순위화한 뒤 동작 변화가 작은 것부터 PR로 쪼갠 게 안전했다. 성능은 라이브러리 버전을 올린 것만으로 4.7배가 나왔는데, sharp 0.35·Node 24·aws-sdk v3 같은 '최신화' 자체가 가장 값싼 성능 개선이었다. 대신 sharp의 네이티브 바이너리 크로스빌드처럼 버전이 바뀌며 배포 방식까지 달라지는 지점이 실제 함정이었다. 그리고 절대 수치가 아니라 상대 비교임을 분명히 하려면, 측정 조건(동일 이벤트·메모리·리전, S3 fetch 포함)을 문서에 남기는 게 중요했다.
배경
CloudFront 이미지 파이프라인은 viewer-request(URI rewrite) + origin-response(sharp resize) 2단으로 동작합니다. 이 중 origin-response 리사이즈 함수는 수년째 손대지 않은 채 운영되고 있었습니다. sharp 0.29.2(2021년), Node 20, aws-sdk v2에 200줄짜리 단일 핸들러 — “돌고는 있지만 아무도 안 보는” 전형적인 방치 함수였습니다. 이걸 인수받아 최신화하는 것이 목표였습니다.
무엇이 문제였나
함수를 처음부터 끝까지 읽으며 이슈를 세 갈래로 분류했습니다.
정확성 버그
response.body = message—message가 정의되지 않아 403 응답 의도가 통째로 깨지고 fallback으로 샘decodeURIComponent가 try 밖 → 잘못된%인코딩 입력 하나로 CloudFront 502 (의도적 공격 가능)Buffer.byteLength(img, "base64")가 Buffer라 encoding 무시 → base64 실제 크기(raw×1.33)가 1MB 한도를 몰래 초과해 502
보안 / DoS
- width/height 사용자 입력 무제한,
MAX_WIDTH/HEIGHT상수는 정의만 하고 미사용(dead) →?s=20000x20000로 메모리·CPU 고갈 - fit 옵션 미검증 → sharp에 임의 문자열 전달
- dev 버킷·리전 하드코딩 → prod 배포 시 코드 수정 필요 (Lambda@Edge는 환경변수 불가)
의존성 취약점 19건 누적, sharp·aws-sdk 모두 수년 묵음.
접근 — 작은 PR로 쪼개기
동작 변화가 작은 것부터 순서대로 갔습니다.
- 정확성 + 보안 (1차 PR) — 미정의 참조 제거,
decodeURIComponent를 try/catch로 감싸 실패 시 원본 통과, base64 길이를Math.ceil(raw/3)*4로 정확히 계산, 치수 정수·양수 검증 후 상한 clamp, fit을 sharp 허용값(cover/contain/fill/inside/outside)으로 화이트리스트. - 구조 (2차 PR) — 순수 로직을
lib.js로 분리하고 단위테스트 28개 추가. quality만 바꾸면 되는데 매 루프마다.rotate()/.metadata()/resize를 통째로 재실행하던while재인코딩 루프를 단일 패스로 제거.
런타임·의존성 최신화
| 항목 | 전 | 후 |
|---|---|---|
| sharp | 0.29.2 (2021) | 0.35.1 |
| Node | 20 | 24 |
| AWS SDK | aws-sdk v2 (통짜) | @aws-sdk/client-s3 v3 (모듈) |
| 취약점 (prod) | 19 | 3 |
sharp는 0.33부터 네이티브 바이너리가 @img/sharp-* optionalDependency로 분리됩니다. mac(arm64)에서 Lambda(linux x64)용으로 크로스빌드하려면 구식 env 방식이 아니라 npm 10+ 플래그를 써야 했습니다.
# 전 — "Unknown env config" 경고, 구식
npm_config_platform=linux npm_config_arch=x64 npm_config_libc=glibc npm ci --omit=dev
# 후 — @img/sharp-linux-x64 + libvips 정확히 설치
npm ci --omit=dev --os=linux --cpu=x64 --libc=glibc
사용 중인 sharp API(resize/toFormat/toBuffer 등)는 버전 간 breaking change에 걸리지 않아 애플리케이션 코드는 무수정, 빌드 방식만 바뀌었습니다.
배포 프로세스 구축 (SAM + CLI)
문서화되지 않은 수작업 배포를 걷어내고 AWS SAM으로 옮겨, sam/aws cloudformation CLI로 멀티환경(dev·prod) 빌드·배포가 재현되게 했습니다. Lambda@Edge는 환경변수를 지원하지 않기 때문에, stage별 bucket·region은 런타임이 아니라 빌드타임 config 주입(scripts/gen-config.mjs + config/<stage>.json + SAM Parameters)으로 해결했습니다.
제약: Lambda@Edge는 리전 us-east-1 고정, 생성 응답 body 1MB 한도. 이 두 제약이 base64 크기 검사와 배포 구조를 결정했습니다.
성능 — 4.7배
동일 이벤트(332×332 리사이즈)·동일 메모리(1536MB)로 구·신 함수를 각각 aws lambda invoke 100회씩 3회 직접 호출해 측정했습니다. CloudFront/캐시를 우회한 순수 핸들러 실행(S3 getObject + sharp 변환)입니다.
| 지표 | 구버전 | 신버전 | 개선 |
|---|---|---|---|
| warm mean | ~1085 ms | ~233 ms | 4.7배 |
| warm p50 | ~1142 ms | ~230 ms | ~5배 |
| p90−min (지터) | ~284 ms | ~23 ms | 안정 |
| cold Init | 559 ms | 453 ms | ~19%↓ |
3회 모두 신버전 233~234ms로 재현됐습니다. 850ms 차이는 S3 fetch가 양쪽 동일하게 포함되므로 compute/SDK에서 발생 — sharp 0.29→0.35(libvips/SIMD), Node 20→24(V8), aws-sdk v2→v3(오버헤드 경량화)의 합입니다.
비용
warm GB-초가 약 4.7배 줄어, 동일 트래픽 기준 함수 compute 비용을 약 78% 절감했습니다.
측정은 us-east-1 마스터에서 수행했고(실제 엣지 리플리카 아님), 크로스리전 S3 fetch를 포함한 상대 비교값입니다. 절대값은 엣지 환경과 다를 수 있습니다.