들어가며

대시보드에 붙는 이미지 하나가 배지(24px)부터 풀와이드 배너(1200px)까지 아주 다양한 크기로 노출되고 있었습니다. 그런데 지금까지는 어느 자리에 들어가든 500KB짜리 원본을 그대로 내려보내고 있었습니다.

24px 배지 하나를 그리기 위해 500KB를 받아오는 셈입니다.

CloudFront 요금에서 가장 큰 덩어리가 DataTransfer-Out이라는 건 알고 있었기 때문에, 요청 시점에 필요한 크기로 줄여서 내려보내는 온디맨드 리사이징을 도입하기로 했습니다.

이 글은 설계부터 구현, 배포, 그리고 “왜 캐싱이 안 되는가”를 찾아 헤맨 트러블슈팅까지 하루 동안의 기록입니다.


최종 아키텍처

먼저 완성된 그림부터 보겠습니다.

브라우저
  │  GET https://cdn.example.com/review/.../image.jpg?w=240
  ▼
CloudFront ──(캐시 HIT)──► 즉시 응답 (Lambda 실행 안 됨)
  │ (캐시 MISS)
  ▼
API Gateway (HTTP API, $default 라우트)
  ▼
Lambda (Node.js 20 / arm64 / sharp)
  1. S3에서 원본 GetObject
  2. sharp로 리사이즈 + webp 변환
  3. base64 응답 (isBase64Encoded: true)
  ▼
CloudFront가 쿼리스트링(w, h, fit, q, f)을 포함한 캐시 키로 저장
→ 이후 동일 요청은 전부 엣지에서 처리

핵심은 한 문장으로 정리됩니다.

리사이징 Lambda는 캐시 MISS일 때 딱 한 번만 실행되고, 그 다음부터는 CloudFront가 다 받아냅니다.

그래서 응답 헤더에 캐시 수명을 최대한 길게 실어줍니다.

Cache-Control: public, max-age=86400, s-maxage=31536000, immutable

max-age는 브라우저 캐시, s-maxage는 CDN 캐시를 가리킵니다. 브라우저는 하루, 엣지는 최대 1년입니다. 브라우저 쪽만 짧게 잡은 이유는 뒤의 캐시 무효화 이야기에서 다시 다룹니다.


설계에서 고민한 것들

고정 변형 vs 쿼리 파라미터

두 가지 방식을 놓고 고민했습니다.

  1. 미리 정해둔 변형만 제공 (예: 원본의 50% 축소본)
  2. ?w=240&h=240 처럼 쿼리 파라미터로 크기를 받는 방식

결론은 2번 + 프리셋 스냅 하이브리드였습니다.

캐시 효율은 어차피 둘이 같습니다. CloudFront가 쿼리스트링을 캐시 키에 포함하면 ?w=240도 고정 변형과 똑같이 하나의 캐시 엔트리로 저장됩니다. 반면 위젯은 필요한 사이즈가 계속 늘어나는 구조라, 1번을 택하면 사이즈가 하나 추가될 때마다 배포를 해야 합니다.

2번의 리스크는 임의 사이즈 폭격으로 인한 캐시 파편화입니다. 누군가 ?w=241, ?w=242… 를 순서대로 호출하면 캐시 엔트리가 무한히 늘어나고 Lambda도 그만큼 돕니다. 이건 프리셋 스냅으로 막았습니다.

사이즈 프리셋

실제 UI에서 쓰고 있는 이미지 슬롯 크기를 전수조사해서 프리셋을 뽑았습니다.

프리셋 실사용 값 용도
24 24 배지 / 아이콘
40 28, 40 작은 배지, xs 썸네일
64 60, 72 컴팩트 그리드
96 80, 100 기본 그리드 썸네일
160 152, 160, 162 메인 티커, m 썸네일
240 252, 280 l 썸네일
360 323, 390, 407, 431 큰 배너 / 프리뷰 카드
600 596, 600 공통 배너, 다이얼로그
800 788 안내 다이얼로그
1200 1176, 1200 풀와이드 배너

전수조사를 먼저 한 게 도움이 됐습니다. 프리셋을 감으로 정하면 실제로 쓰는 크기는 빠져 있고 안 쓰는 크기만 들어간 목록이 나옵니다.

스냅은 반올림이 아니라 올림

스냅 방향은 올림입니다. 431px 슬롯에 360px 이미지를 내려보내면 브라우저가 업스케일해서 흐릿해지지만, 600px로 올려 스냅하면 다운스케일이라 화질 손실이 없습니다.

function snapToPreset(value) {
  const n = parseInt(value, 10)
  if (Number.isNaN(n) || n <= 0) return null
  return PRESETS.find(p => p >= n) ?? PRESETS[PRESETS.length - 1]
}

PRESETS가 오름차순이라는 전제 위에서 find가 곧 “n 이상인 첫 프리셋”이 됩니다. 요청이 최대 프리셋보다 크면 마지막 값으로 잘립니다.

fit 기본값 자동 분기

sharp의 resize()는 w/h 중 하나만 주면 비율에 맞춰 나머지를 계산합니다. 둘 다 주면 fit 옵션이 결과를 결정하는데, 기본값이 cover(크롭) 라서 명시하지 않으면 이미지가 잘릴 수 있습니다.

그래서 이렇게 분기했습니다.

  • w, h 둘 다 있으면 → cover (정사각 썸네일 크롭)
  • 하나만 있으면 → inside (비율 유지)

위젯 쪽에서 대부분 fit을 생략해도 의도한 대로 나오게 하려는 목적입니다. 호출하는 쪽이 옵션을 주지 않았을 때 나오는 결과가, 그 상황에서 보통 원하는 결과여야 합니다.

캐시 무효화는 URL 버저닝으로

immutable + 1년 캐시는 “같은 URL이면 같은 콘텐츠” 라는 전제 위에 서 있습니다. 같은 키에 원본을 덮어쓰면 CloudFront는 그 사실을 알 방법이 없습니다.

그래서 기본 전략은 파일이 바뀌면 URL도 바뀌는 버저닝입니다. 키에 해시를 넣거나 ?v=타임스탬프를 붙입니다.

CloudFront Invalidation은 실수 복구용 비상 수단으로만 남겨뒀습니다. 다만 비상 수단을 쓸 때 주의할 점이 있습니다.

# 이렇게만 지우면 리사이즈 변형이 살아남습니다
/review/.../logo.svg

# 쿼리스트링 캐시 키까지 함께 지워야 합니다
/review/.../logo.svg*

캐시 키에 쿼리스트링이 들어가기 때문에, 원본 경로 하나를 지우는 것으로는 ?w=160, ?w=600 같은 변형 엔트리가 지워지지 않습니다. 와일드카드로 함께 날려야 합니다.

브라우저 캐시를 하루로 잡아둔 이유도 여기에 있습니다. 엣지 캐시는 Invalidation으로 지울 수 있지만, 이미 사용자 브라우저에 들어간 캐시는 지울 방법이 없습니다.


Lambda 핸들러

핵심 구조만 옮기면 이렇습니다.

import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
import sharp from 'sharp'

const s3 = new S3Client({})
const BUCKET = process.env.ORIGIN_BUCKET
const RESIZED_PREFIX = process.env.RESIZED_PREFIX || ''
const PRESETS = [24, 40, 64, 96, 160, 240, 360, 600, 800, 1200]

export const handler = async event => {
  try {
    const key = decodeURIComponent((event.rawPath || '/').replace(/^\//, ''))
    const qs = event.queryStringParameters ?? {}

    const width = snapToPreset(qs.w)
    const height = snapToPreset(qs.h)
    if (!width && !height) return textResponse(400, 'w or h required')

    const fit = ALLOWED_FITS.has(qs.fit)
      ? qs.fit
      : width && height
      ? 'cover'
      : 'inside'
    const quality = clampInt(qs.q, 1, 100) ?? 80
    const format = ALLOWED_FORMATS.has(qs.f) ? qs.f : 'webp'

    // 스냅된 값 기준으로 S3 리사이즈본을 재사용합니다 (w=152/160/162 → 같은 객체)
    const resizedKey = RESIZED_PREFIX
      ? `${RESIZED_PREFIX}${key}/${width ?? 'auto'}x${height ??
          'auto'}-${fit}-q${quality}.${format}`
      : null

    if (resizedKey) {
      const cached = await getObjectSafe(BUCKET, resizedKey)
      if (cached) return imageResponse(cached.body, cached.contentType)
    }

    const origin = await getObjectSafe(BUCKET, key)
    if (!origin) return textResponse(404, 'original not found')

    const outBuffer = await sharp(origin.body, { failOn: 'none' })
      .rotate() // EXIF 회전 보정
      .resize({ width, height, fit, withoutEnlargement: true })
      .toFormat(format, { quality })
      .toBuffer()

    if (resizedKey) {
      await s3
        .send(
          new PutObjectCommand({
            Bucket: BUCKET,
            Key: resizedKey,
            Body: outBuffer,
            ContentType: `image/${format}`,
            CacheControl: 'public, max-age=31536000, immutable',
          })
        )
        .catch(() => {})
    }

    return imageResponse(outBuffer, `image/${format}`)
  } catch (err) {
    console.error(err)
    return textResponse(500, 'resize failed')
  }
}

.rotate()를 인자 없이 호출한 것은 EXIF 회전 정보를 반영하라는 뜻입니다. 스마트폰으로 찍은 사진은 픽셀은 가로로 저장되고 “세로로 돌려서 보여줘”라는 메타데이터가 따로 붙어 있는 경우가 많은데, 리사이즈 과정에서 메타데이터가 날아가면 이미지가 눕습니다.

PutObject.catch(() => {})를 붙인 것도 의도한 부분입니다. S3 저장은 다음 요청을 빠르게 하기 위한 최적화일 뿐이지 응답의 전제 조건이 아닙니다. 쓰기 권한 문제로 실패하더라도 이미지는 내려가야 합니다.

응답은 base64로

API Gateway를 거쳐 바이너리를 내려보내려면 base64로 인코딩하고 isBase64Encoded: true를 붙여야 합니다.

function imageResponse(buffer, contentType) {
  return {
    statusCode: 200,
    headers: {
      'Content-Type': contentType,
      'Cache-Control': 'public, max-age=86400, s-maxage=31536000, immutable',
    },
    body: buffer.toString('base64'),
    isBase64Encoded: true,
  }
}

여기에 제약 하나가 따라옵니다. Lambda의 동기 호출 응답 페이로드 상한이 6MB이고 base64는 원본보다 약 1.33배로 부풀기 때문에, 실질적인 이미지 상한은 4.5MB 정도가 됩니다. 리사이즈 결과가 수십 KB인 이 파이프라인에서는 문제가 되지 않지만, 나중에 “원본을 그대로 통과시키는” 옵션을 추가한다면 걸릴 수 있는 선입니다.

캐시가 두 계층인 이유

RESIZED_PREFIX 환경변수가 있으면 리사이즈 결과를 S3에도 저장합니다. 엣지 캐시가 이미 있는데 왜 한 계층을 더 두었는가 하면, 두 계층의 캐시 키가 서로 다르기 때문입니다.

계층 캐시 키 기준 w=152, w=160, w=162 요청 시
CloudFront 엣지 스냅 쿼리스트링 엔트리 3개
S3 리사이즈본 스냅 객체 1개

세 요청 모두 프리셋 160으로 스냅되므로 S3에서는 같은 객체를 가리킵니다. 결과적으로 sharp 실행은 한 번뿐입니다. 엣지에서 발생한 캐시 파편화를 S3 계층이 흡수하는 구조입니다.

엣지 캐시가 영구적이지 않다는 점도 고려했습니다. s-maxage를 1년으로 잡아도 엣지 캐시는 LRU라 요청이 뜸한 이미지는 밀려나고, 엣지 로케이션별로 따로 캐싱됩니다. 서울에서 만들어진 변형이 도쿄 엣지에는 없습니다. 그때 sharp를 다시 돌리는 대신 S3에서 꺼내오면 됩니다.


비용 분석

월 100만 이미지 요청, 원본 500KB → 리사이즈 8KB, 캐시 히트율 95%를 가정했습니다. 서울 리전 기준입니다.

항목 도입 전 도입 후
DataTransfer-Out 500GB ≈ $60.00 8GB ≈ $0.96
HTTPS 요청 $1.25 $1.25
Lambda (MISS 5%) $0.33
API Gateway $0.05
S3 GET / PUT $0.03
합계 ~$61 ~$2.6

약 96% 절감입니다.

흥미로운 건 손익분기점입니다. 캐시 히트율이 0% 라는 최악의 조건을 가정해보겠습니다. 추가되는 비용은 Lambda + API Gateway + S3이고, 위 표를 100% MISS로 환산하면 100만 요청당 약 8.2,요청당약8.2, 요청당 약 0.0000082입니다. 서울 리전 DataTransfer-Out 단가가 GB당 $0.12이니 요청당 약 68KB 이상만 줄이면 이득입니다.

500KB → 8KB는 492KB를 줄이는 것이니 손익분기의 7배가 넘습니다. 캐시가 아예 동작하지 않아도 남는 장사라는 뜻입니다. 덕분에 “캐싱 설정이 아직 완벽하지 않은데 배포해도 되나”를 고민하지 않아도 됐습니다.

여기에 리전 내 S3 → Lambda 전송은 무료라, 원본을 읽어오는 구간에는 전송비가 붙지 않습니다.

부가 효과로 페이지 성능도 함께 개선됩니다. 위젯이 심긴 페이지가 이미지당 500KB 대신 8KB를 받으니, 비용을 줄이려고 시작한 일이 로딩 속도까지 같이 가져다줬습니다.


배포에서 막힌 지점들

sharp가 linux-arm64 바이너리를 못 찾습니다

Could not load the "sharp" module using the linux-arm64 runtime

원인은 단순합니다. M시리즈 맥에서 그냥 npm install sharp를 하면 macOS(darwin-arm64)용 바이너리가 설치됩니다. sharp는 플랫폼별 네이티브 바이너리를 선택적 의존성으로 받아오기 때문에, 로컬에서 만든 zip을 Lambda(linux-arm64)에 올리면 맞는 바이너리가 없습니다.

플래그를 줘도 기존 node_modules가 남아 있으면 갈리지 않는 경우가 있어서, 완전히 지우고 다시 설치했습니다.

rm -rf node_modules package-lock.json
npm install --force --os=linux --cpu=arm64 --libc=glibc sharp

# zip 전 검증 — 여기에 sharp-linux-arm64 가 보여야 합니다
ls node_modules/@img/

마지막 ls 한 줄이 중요합니다. 이걸 확인하지 않으면 zip을 올리고 호출해본 다음에야 같은 에러를 다시 봅니다.

iam:CreateRole 권한이 없습니다

Lambda를 만들 때 “새 역할 자동 생성”을 골랐다가 계정에 iam:CreateRole이 없어서 막혔습니다. 선택지는 세 가지였습니다.

  1. 기존 Lambda 실행 역할을 재사용한다
  2. 관리자에게 iam:CreateRole + iam:PassRole 권한을 요청한다
  3. 관리자에게 역할 스펙을 전달해서 만들어달라고 한다

2번을 요청할 때 iam:PassRole을 자주 빼먹습니다. 역할을 만들 권한만 있고 그 역할을 Lambda에 넘길 권한이 없으면 똑같이 막힙니다.

조직에서는 보통 3번이 가장 빠릅니다. “권한 주세요”보다 “이 스펙으로 역할 하나 만들어주세요”가 검토 범위가 명확해서 승인이 쉽습니다.


캐싱이 안 되던 이유들

Lambda와 API Gateway가 정상 동작한 뒤에도 캐싱이 되지 않는 문제가 이어졌습니다. 이 구간이 가장 길었습니다.

?w=240을 붙였는데 “w or h required”

Lambda 입장에서 queryStringParametersnull로 들어오고 있었습니다.

원인은 CloudFront 기본 캐시 정책인 CachingOptimized였습니다. 이 정책은 쿼리스트링을 캐시 키에서 빼는 것에 그치지 않고, 오리진으로 전달조차 하지 않습니다.

“캐시 키에서 뺀다”와 “오리진에 전달하지 않는다”는 다른 이야기인데, 관리형 정책에서는 한 덩어리로 묶여 있습니다. 쿼리를 오리진에 넘기려면 캐시 정책이나 origin request policy에서 명시해야 합니다.

GET인데도 계속 Miss — 범인은 CachingDisabled

메서드부터 확인했습니다. CloudFront는 GET/HEAD 응답만 캐싱하기 때문에 여기서 걸리는 경우가 있는데, 요청은 GET이었습니다.

behavior 설정을 열어보니 이렇게 되어 있었습니다.

캐시 정책    : CachingDisabled   ← "Recommended for API Gateway"
원본 요청 정책 : AllViewerExceptHostHeader

API Gateway 오리진을 추가할 때 콘솔이 추천하는 조합을 그대로 두고 있었습니다.

CachingDisabled는 응답 헤더가 뭐라고 하든 무조건 저장하지 않습니다. 쿼리는 origin request policy가 전달해줘서 리사이징은 정상 동작했지만, 캐시 히트율은 0%였습니다. Cache-Control을 1년으로 실어 보내고 있었는데 아무 의미가 없었던 것입니다.

이 “추천”이 틀린 건 아닙니다. 매 요청마다 다른 응답을 주는 일반 REST API라면 캐싱하지 않는 게 맞습니다. 다만 캐싱이 존재 이유인 이미지 오리진에는 정확히 반대되는 설정입니다.

커스텀 캐시 정책이 “Business plan”에 잠겨 있습니다

그래서 커스텀 캐시 정책을 만들려고 했는데 “Available with the Business plan”이 떴습니다.

CloudFront에 flat-rate 요금제(Free / Pro / Business / Premium)가 생겼고, 이 배포가 Free 플랜에 가입된 상태여서 커스텀 캐싱 규칙이 잠겨 있었습니다. Business는 월 $200입니다.

선택지는 둘이었습니다.

  • Business 플랜으로 업그레이드한다 (월 $200)
  • 종량제(pay-as-you-go)로 전환한다 (이 파이프라인 예상 비용 월 $2~3)

답이 명확했습니다. Free 플랜을 취소하고 종량제로 전환한 뒤 정책을 만들었습니다.

커스텀 캐시 정책
  TTL           : Min 1 / Default 86400 / Max 31536000
  Headers       : None          ← Host 미포함 (API Gateway 호환의 핵심)
  Query strings : 지정 포함 → w, h, fit, q, f
  Cookies       : None
  압축           : Gzip + Brotli

두 줄이 특히 중요합니다.

Min TTL을 1 이상으로 둬야 오리진의 Cache-Control을 존중하는 모드로 동작합니다. 0이면 매번 오리진에 확인하러 갑니다.

Headers를 None으로 둬서 Host를 캐시 키에서 빼야 합니다. API Gateway는 Host 헤더를 보고 라우팅하는데, 뷰어의 Host(cdn.example.com)가 그대로 전달되면 게이트웨이가 요청을 인식하지 못합니다.

마침내

1회차: x-cache: Miss from cloudfront
2회차: x-cache: Hit from cloudfront
       age: 2

Hit from cloudfront 한 줄을 보기까지 반나절이 걸렸습니다. 🎉


정체불명의 1ms 실행

캐싱이 되기 시작했는데도 로그에 1~2ms짜리 Lambda 실행이 계속 찍혔습니다. sharp가 돌 수 있는 시간이 아니니 어딘가에서 즉시 튕기는 요청인데, 정체를 알 수 없었습니다.

추측을 접고 핸들러 첫 줄에 로그를 한 줄 넣었습니다.

console.log(
  JSON.stringify({
    method: event.requestContext?.http?.method,
    path: event.rawPath,
    qs: event.queryStringParameters,
    ua: event.headers?.['user-agent'],
  })
)

찍힌 범인은 이랬습니다.

{
  "method": "GET",
  "path": "/favicon.ico",
  "qs": null,
  "ua": "Mozilla/5.0 (Macintosh..."
}

favicon.ico였습니다.

브라우저 주소창에 이미지 URL을 직접 쳐서 테스트하면, 브라우저가 그 도메인의 favicon을 자동으로 요청합니다. 이게 $default 라우트를 타고 Lambda까지 와서 400을 받습니다.

문제는 그 다음입니다. 400 응답은 no-store라 캐싱되지 않으니 매번 Lambda까지 옵니다. 에러 응답이 캐싱되지 않는 성질이 오히려 반복 과금 루프를 만드는 셈입니다.

방어 코드를 두 개 넣었습니다.

// favicon은 캐싱 가능한 204로 받아넘깁니다
if (key === 'favicon.ico') {
  return {
    statusCode: 204,
    headers: { 'Cache-Control': 'public, max-age=86400, s-maxage=604800' },
    body: '',
  }
}

// 4xx는 5분 캐싱으로 반복 타격을 막고, 5xx는 일시 장애일 수 있으니 no-store를 유지합니다
function textResponse(statusCode, message) {
  return {
    statusCode,
    headers: {
      'Content-Type': 'text/plain',
      'Cache-Control': statusCode >= 500 ? 'no-store' : 'public, s-maxage=300',
    },
    body: message,
  }
}

4xx와 5xx를 갈라놓은 게 핵심입니다.

4xx는 요청 자체가 잘못된 것이니 같은 요청이 다시 와도 결과가 같습니다. 짧게라도 캐싱해서 반복 유입을 막는 게 맞습니다. 반면 5xx는 일시적인 장애일 수 있으니 캐싱하면 오리진이 복구된 뒤에도 엣지가 계속 에러를 내려보냅니다.

CORS도 함께 정리했습니다. API Gateway HTTP API의 CORS 설정(Allow-Origin *, Methods GET, Max-Age 86400)을 넣으면 OPTIONS preflight를 게이트웨이가 직접 응답하고 Lambda까지 오지 않습니다. 위젯이 다른 도메인에 심기는 구조라 어차피 필수 설정이었습니다.


운영 모니터링

비용이 새는 경로는 사실상 두 가지입니다. 전송량이 안 줄었거나, Lambda가 필요 이상으로 도는 것. 감시 우선순위도 그 순서로 잡았습니다.

지표 보는 이유 기준
CloudFront Cache hit rate 이 아키텍처의 생명선 90% 이상 정상 / 70% 이하 이상 신호
Lambda Invocations 캐시 미작동의 가장 빠른 경보 평시 3~5배 초과 시 알람
Lambda Errors 에러 루프 = 비용 루프 급증 감시
APIGW Count vs CloudFront MISS CDN 우회 호출 탐지 APIGW가 유의미하게 크면 의심
AWS Budgets 총액 안전망 예상 월비용의 2배

마지막에서 두 번째 항목만 설명이 조금 필요합니다.

API Gateway 호출 수가 CloudFront MISS 수보다 유의미하게 크다면, 누군가 CDN을 거치지 않고 Invoke URL을 직접 호출하고 있다는 뜻입니다. 이 경우 캐시 이득이 전부 사라집니다. CloudFront가 붙여주는 커스텀 헤더를 Lambda에서 검증해 막을 수 있습니다.


정리

하루 동안 배운 것을 정리하면 이렇습니다.

  1. sharp는 반드시 타깃 아키텍처로 크로스 설치합니다. npm install --force --os=linux --cpu=arm64 --libc=glibc sharp, 그리고 zip 전에 ls node_modules/@img/로 검증합니다.
  2. “Recommended” 설정을 의심합니다. API Gateway 오리진에 붙는 CachingDisabled 추천은 이미지 캐싱 용도에는 정반대 설정입니다. 추천은 일반적인 경우를 가정하고, 내 경우는 일반적이지 않을 수 있습니다.
  3. 관리형 정책이 헤더를 어떻게 다루는지 확인합니다. API Gateway 오리진에서는 Host를 캐시 키에서 빼야 합니다.
  4. 에러 응답의 캐싱 정책도 설계 대상입니다. no-store 400은 반복 과금 루프가 됩니다. 4xx는 짧게 캐싱합니다.
  5. flat-rate 플랜의 기능 게이트를 확인합니다. Free 플랜에서는 커스텀 캐시 정책을 만들 수 없습니다. 트래픽이 작다면 종량제가 답입니다.
  6. 정체불명의 요청은 추측하지 말고 로그를 한 줄 넣습니다. method / path / qs / ua 네 개를 찍었더니 favicon 범인이 5분 만에 잡혔습니다.

가장 오래 걸린 구간이 코드가 아니라 CloudFront 설정이었다는 게 인상적이었습니다.

리사이징 로직은 sharp가 대신 해주니 실제로 짠 코드는 100줄이 안 됩니다. 반나절을 쓴 곳은 “왜 캐싱이 안 되는가”였고, 원인은 전부 콘솔의 체크박스 몇 개였습니다. Cache-Control을 1년으로 실어 보내면서도 정작 엣지는 아무것도 저장하지 않고 있었는데, 응답 헤더만 보고 있으면 절대 알 수 없는 문제였습니다.

이번 작업에서 그래서 가장 도움이 된 건 x-cache 헤더와 로그 한 줄이었습니다. 설정이 의도대로 동작하는지는 설정 화면이 아니라 응답에서 확인해야 한다는 걸 다시 배웠습니다.


남은 과제

  • 도입 전후 Cost Explorer의 DataTransfer-Out 추이 비교 — 추정치가 아니라 실제 청구서로 확인하기
  • Cache hit rate와 Lambda Invocations에 CloudWatch 알람 실제 연결
  • 리사이즈 결과가 원본보다 큰 경우(이미 충분히 작은 이미지) 원본을 그대로 내려보내는 분기 추가
  • CloudFront 커스텀 헤더 검증으로 Invoke URL 직접 호출 차단