본문으로 건너뛰기
← Development Log

Development Log

캐시는 빨랐는데 첫 로딩은 왜 느렸을까: Notion 커버를 R2 CDN으로 옮긴 회고

Notion 커버의 첫 로딩 병목을 요청 경로에서 추적하고, Webhook 시점에 WebP로 변환해 Cloudflare R2 CDN으로 옮긴 설계와 운영 검증 과정을 정리했습니다.

  • #Next.js
  • #Cloudflare
  • #Performance
  • #Security
  • #Kubernetes
  • #Operations

한 줄 결론 이미지가 한 번 캐시된 뒤 빠른 것과 첫 방문이 빠른 것은 다른 문제였다. 브라우저의 첫 요청마다 Next.js가 Notion S3 원본을 가져와 Sharp로 변환하던 경로를 없애고, 커버를 등록하는 시점에 한 번만 WebP로 만들어 Cloudflare R2에 저장하도록 바꿨다. 핵심은 캐시 시간을 늘리는 것이 아니라 반복되던 이미지 가공을 읽기 경로에서 쓰기 경로로 옮긴 것이었다.

1. 문제를 발견한 계기

/log 화면의 이미지는 재방문하면 빨랐지만 첫 로딩은 여전히 느렸다. Network 탭에서 확인한 WebP 요청은 대략 0.84초에서 2.30초가 걸렸고, 대표 Notion 원본 PNG는 1672×941, 약 1.52MB였다.

처음에는 “캐시가 재사용되는데 왜 느리지?”라는 질문에서 시작했다. 여기서 놓치고 있던 것은 캐시는 이미 만들어진 결과를 재사용할 뿐, 최초 요청의 비용까지 없애주지는 않는다는 점이었다. 당시 첫 요청의 실제 경로는 다음과 같았다.

plain
Browser
→ Next.js /_next/image
→ Notion signed S3 URL
→ 약 1.5MB 원본 다운로드
→ Pod에서 Sharp 변환
→ WebP 응답
→ 그제야 cache 저장

두 번째 요청부터 빨라지는 것은 정상이었다. 문제는 새 이미지, 만료된 캐시, 다른 크기의 이미지 후보처럼 캐시가 없는 순간마다 이 전체 경로를 다시 지나야 한다는 것이었다.

2. 왜 기존 Next.js 이미지 캐시만으로는 부족했나

next/image 자체는 잘못된 선택이 아니었다. 반응형 크기 선택, lazy loading, WebP 변환으로 원본을 그대로 전달하던 때보다 전송량을 크게 줄여줬다. 하지만 현재 운영 환경에서는 이미지 최적화기가 K3s의 Next.js Pod 안에 있었다. 결국 첫 방문의 응답 시간은 다음 요소에 동시에 의존했다.

  • Notion signed URL이 유효한가
  • Notion S3 원본 응답이 빠른가
  • 원본 파일이 얼마나 큰가
  • Pod에 이미지 결과가 이미 캐시되어 있는가
  • Sharp 변환 순간에 CPU와 메모리 여유가 있는가 즉 캐시는 증상을 완화했지만, 공개 페이지가 외부 원본과 실시간 이미지 변환에 의존하는 구조는 그대로였다. 이번 문제의 질문은 “캐시를 더 오래 유지할까?”가 아니라 다음으로 바뀌었다.

방문자가 이미지를 요청할 때마다 원본을 가져와 변환해야 할 이유가 있는가? 커버는 자주 바뀌지 않는다. 그렇다면 변경될 때 한 번만 처리하고, 읽을 때는 완성된 파일을 바로 전달하는 편이 자연스러웠다.

3. 왜 Cloudflare R2를 선택했나

별도의 이미지 서버를 만드는 방법도 있었지만 개인 사이트 규모에서 서버 하나를 더 운영하는 것은 과했다. 필요한 것은 복잡한 이미지 API가 아니라 다음 네 가지였다.

  • 완성된 WebP 파일을 저장할 object storage
  • 브라우저가 바로 접근할 공개 URL
  • 긴 cache header를 활용할 CDN
  • 기존 Node.js 코드에서 쉽게 업로드할 S3-compatible API Cloudflare R2와 cdn.ddongmy.com 조합은 이 조건에 맞았다. 이미 Cloudflare에서 ddongmy.com zone을 운영하고 있어 custom domain도 같은 경계에서 관리할 수 있었다. 최종 사용자 요청 경로는 다음처럼 단순해졌다.
plain
Browser
→ cdn.ddongmy.com
→ Cloudflare Edge Cache
   ├─ HIT  → 약 50KB WebP 반환
   └─ MISS → R2 → Edge Cache → 약 50KB WebP 반환

Edge cache가 비어 있어 R2까지 요청하더라도, 방문자 요청 경로에서는 더 이상 Notion 원본 다운로드와 Next.js Pod의 Sharp 변환을 거치지 않는다. 개인 사이트 트래픽에서는 무료 제공 범위 안에서 시작할 수 있다는 점도 선택에 도움이 됐지만, 핵심 이유는 비용보다 첫 요청에서 Next.js Pod와 Notion 원본을 제외할 수 있다는 것이었다.

4. 왜 별도 thumbnailUrl 속성을 만들지 않았나

처음 생각할 수 있는 방법은 Notion 데이터베이스에 thumbnailUrl 같은 속성을 추가하고 사이트가 그 값을 읽도록 바꾸는 것이었다. 하지만 이 방식은 두 개의 원본을 만든다.

plain
Notion page cover
R2 thumbnailUrl property

둘이 어긋났을 때 어떤 값이 진짜인지 다시 판단해야 하고, 기존 parser와 화면 모델도 함께 바꿔야 한다. 대신 변환이 끝난 뒤 Notion page cover 자체를 아래 external URL로 교체했다.

plain
https://cdn.ddongmy.com/covers/{pageId}/{contentHash}.webp

이렇게 하면 사이트는 여전히 “Notion의 cover를 읽는다”는 기존 계약을 유지한다. 저장 위치만 Notion file에서 R2 external URL로 바뀐다. 이 선택은 성능 개선 범위를 이미지 파이프라인 안에 가두고, 화면과 데이터 모델의 변경을 최소화했다.

5. 왜 공개 요청 경로가 아니라 Webhook에서 처리했나

Sharp 변환과 R2 업로드를 /log 요청 중에 실행하면 첫 방문의 병목을 다른 코드로 옮기는 것뿐이다. 방문자는 이미지 변환이 끝날 때까지 기다려야 하고, 동시에 여러 요청이 들어오면 같은 이미지를 중복 처리할 수도 있다. 그래서 처리 시점을 커버 변경 시점으로 옮겼다.

plain
Notion에서 cover 업로드 또는 변경
→ Webhook
→ page ID로 최신 상태 재조회
→ Notion 원본 다운로드
→ SHA-256(original bytes)
→ rotate + 최대 1200px + WebP quality 65
→ R2 업로드
→ 최신 cover가 처음 원본과 같은지 재확인
→ Notion cover를 CDN external URL로 교체

사이트 방문과 이미지 생성이 분리되면서 사용자는 기존처럼 Notion에서 글과 커버만 관리하면 된다.

6. 왜 URL이 아니라 이미지 bytes를 hash했나

Notion file URL에는 만료되는 서명 query가 붙는다. 같은 이미지도 URL이 다시 발급되면 query string이 달라질 수 있다. URL 전체를 hash하면 내용은 같은데 key만 계속 달라지는 문제가 생긴다. 그래서 원본 bytes의 SHA-256을 사용했다.

plain
covers/{pageId}/{SHA-256(original bytes)}.webp

같은 페이지의 같은 이미지 처리는 같은 key로 수렴한다. 이미지 내용이 바뀌면 hash와 URL도 함께 바뀌므로 긴 캐시 TTL을 적용하기에도 유리한 구조다. 다만 실제 Cache-Control 정책은 응답 헤더를 확인한 뒤 별도로 검증하고, 적용 여부를 추정해서 성능 개선 근거로 사용하지 않는다. 반면 변환 도중 사용자가 커버 A를 B로 바꾸는 경쟁 조건은 hostname + pathname을 다시 비교해 A가 B를 덮어쓰지 못하게 막았다. R2 업로드 뒤 cover가 바뀐 경우 작은 orphan 파일이 남을 수 있지만, 약 50KB 파일 하나를 즉시 삭제하는 보상 로직까지 넣으면 오히려 실패 경로가 늘어난다고 판단해 초기 구현에서는 허용했다.

7. 왜 보안과 제한을 먼저 넣었나

Webhook 서버가 외부 URL을 대신 fetch하는 순간 SSRF 경계가 생긴다. “Notion이 보낸 URL이겠지”라고 믿고 아무 주소나 요청하면 안 됐다. 그래서 다음 제한을 함께 적용했다.

  • Webhook의 raw body로 X-Notion-Signature HMAC-SHA256 검증
  • HTTPS만 허용
  • Notion에서 실제 확인한 S3 hostname만 exact allowlist로 허용
  • redirect 거절
  • 15초 timeout
  • Content-Type: image/* 확인
  • Content-Length가 있으면 사전 차단
  • header가 없거나 틀려도 실제 읽은 bytes를 10MB로 제한
  • R2 권한은 특정 bucket의 Object Read & Write로 제한 *.amazonaws.com처럼 넓은 wildcard를 허용하지 않은 이유는 편의보다 fetch 가능한 범위를 작게 유지하는 것이 중요했기 때문이다. 새로운 정상 host가 등장하면 로그를 보고 명시적으로 추가하는 fail-closed 방식을 택했다.

8. 구현하면서 실제로 막힌 지점

R2 토큰이 읽기 전용이었다

처음 만든 Account API 토큰으로 조회는 가능했지만 업로드에는 Object Read & Write가 필요했다. 권한을 다시 확인해 특정 bucket의 읽기·쓰기로 수정하고, 테스트 WebP를 올려 cdn.ddongmy.com/test/hello.webp에서 직접 표시되는 것까지 확인했다. 여기서 배운 점은 “토큰이 존재한다”와 “현재 작업에 필요한 최소 권한을 가진다”는 별개의 검증이라는 것이다.

Verification 요청이 503을 반환했다

Webhook subscription 생성 직후 Notion은 verification_token을 보내는데, 초기 구현은 서버 환경변수에 검증 토큰이 없으면 먼저 503을 반환했다. 하지만 그 환경변수에 넣을 값이 바로 이 최초 요청에서 오는 토큰이었다. 검증을 위한 bootstrap 요청과 일반 이벤트의 순서가 뒤집혀 있었다. 수정한 흐름은 다음과 같다.

plain
strict { verification_token } payload인가?
→ 아직 token이 없으면 bootstrap 값으로 수신
→ 일반 event라면 저장된 token으로 HMAC 검증

운영에서 bootstrap 요청에도 signature가 포함될 수 있다는 사실도 확인해 회귀 테스트에 반영했다. 검증 과정에서 토큰이 운영 로그나 작업 대화에 노출될 가능성도 확인했고, subscription을 다시 만들어 토큰을 회전한 뒤 Kubernetes Secret에 저장했다. 비밀값은 확인 과정에서도 출력 범위를 최소화해야 한다는 교훈이 남았다.

커버 변경 이벤트 종류를 잘못 추정했다

커버가 page의 최상위 필드라서 처음에는 page.content_updated가 필요할 것으로 추정했다. 서버 처리 목록과 subscription에 이 이벤트를 추가했지만, 실제 커버 교체 테스트에서 들어온 이벤트는 다음이었다.

plain
eventType: 'page.properties_updated'
status: 'updated'

문서 구조만 보고 이벤트 mapping을 확정하면 안 됐다. 최종적으로는 page.created, page.content_updated, page.properties_updated를 모두 처리하되, 최신 page를 다시 조회해 실제 cover 상태로 동작을 결정했다.

Webhook이 왔는데 로그가 한 줄만 보였다

다음 명령으로 로그를 필터링했다.

bash
kubectl logs ... | grep notion-webhook

로그 객체는 여러 줄이었고 첫 줄에만 notion-webhook 문자열이 있었다. 그래서 event type과 status 줄이 보이지 않았다. grep -A를 사용하거나 필터 없이 확인하자 status: 'updated'까지 볼 수 있었다. 이 문제는 Webhook 실패가 아니라 관측 명령이 정보를 잘라낸 문제였다.

9. 최종 검증

실제 Notion 커버를 다른 이미지로 변경한 뒤 운영 로그에서 다음 결과를 확인했다.

plain
[notion-webhook] cover sync {
  eventType: 'page.properties_updated',
  status: 'updated'
}

이 결과 하나로 다음 경로가 모두 통과했음을 확인했다.

  1. Notion Webhook 전송
  2. 운영 endpoint 도달
  3. raw body signature 검증
  4. Development Log page 재조회
  5. 원본 이미지 다운로드와 WebP 변환
  6. R2 업로드
  7. race check
  8. Notion external cover 갱신
  9. 사이트의 cdn.ddongmy.com 직접 사용 브라우저에서도 최종 cover가 CDN URL로 표시되는 것을 확인했다. 기존 구조의 1.5MB Notion PNG 실시간 최적화 경로는 새로 변경한 커버부터 제거됐다. 여기까지 확인된 것은 요청 경로가 단순해졌다는 것이지, 최종 사용자 체감 성능이 몇 % 개선됐다는 것까지는 아니다. CDN URL로 바뀐 사실만으로 성능 개선이 완료됐다고 보지 않기로 했다. 다음 검증에서는 같은 이미지 기준으로 cold cachewarm cache를 분리해 응답 시간, transferred size, TTFB를 다시 기록하고 이전 구조의 0.84~2.30초와 비교할 예정이다. 개선 폭은 이 측정 결과가 나온 뒤에만 수치로 적는다.

10. 왜 기존 이미지 일괄 자동화는 하지 않기로 했나

기존 페이지는 과거 변경에 대한 Webhook이 오지 않으므로 별도의 backfill이 필요했다. 전체 Development Log를 조회해 Notion-hosted cover만 순차 변환하는 dry-run/apply 스크립트까지 구현하고 검증했다. 하지만 여기서 다시 질문했다.

기술적으로 자동화할 수 있다는 이유만으로 운영 경로를 하나 더 유지해야 하는가? 기존 커버 수가 많지 않다면 각 페이지를 한 번씩 변경해 Webhook을 발생시키는 편이 더 단순하다. 일회성 작업을 위해 Docker 산출물과 관리 명령을 계속 유지하는 것은 현재 규모에 비해 과했다. 그래서 스크립트는 실행하지 않고 기존 이미지는 수동으로 처리하기로 했다. 이번 결정은 자동화의 실패가 아니라, 자동화의 유지 비용까지 포함해 다시 범위를 줄인 선택이었다.

11. 결과적으로 바뀐 구조

구분이전개선 후
원본약 1.5MB Notion PNG최대 1200px, WebP quality 65
첫 이미지 경로Browser → Next.js → Notion S3 → SharpBrowser → Cloudflare CDN
변환 시점첫 읽기 요청Notion cover 변경 시 한 번
저장 위치Pod의 Next.js 이미지 cacheCloudflare R2
사이트 데이터 계약Notion cover 읽기동일하게 Notion cover 읽기

12. 배운 점

캐시 적중률과 첫 요청 비용을 분리해서 본다

“재사용은 빠르다”는 사실은 “처음도 빠르다”는 뜻이 아니다. 성능 문제를 볼 때 warm cache 결과만 보지 않고 cold path가 어떤 네트워크와 연산을 거치는지 먼저 그려야 한다.

읽기보다 쓰기가 드물다면 계산을 쓰기 시점으로 옮긴다

커버는 여러 번 읽히지만 드물게 바뀐다. 매번 읽을 때 변환하는 것보다 바뀔 때 한 번 가공하는 것이 현재 도메인에 맞았다.

기존 계약을 유지하면 변경 범위가 작아진다

별도 thumbnail 필드를 추가하지 않고 Notion cover 자체를 external URL로 교체하면서 사이트 parser와 작성 흐름을 유지할 수 있었다.

외부 이벤트는 추정보다 실제 payload로 확인한다

커버가 최상위 필드라는 사실만으로 Webhook event type을 확정할 수 없었다. 실제 운영 이벤트는 page.properties_updated였다. 외부 시스템 연동에서는 문서, 코드, 실제 payload를 함께 봐야 한다.

관측 도구도 결과를 왜곡할 수 있다

grep 한 줄 때문에 정상 이벤트의 세부 상태를 보지 못했다. 로그가 없다는 결론을 내리기 전에 필터가 무엇을 숨기는지도 확인해야 한다.

자동화의 끝은 더 많은 자동화가 아니다

backfill을 자동화할 수 있었지만 현재 데이터 규모에서는 수동 처리가 더 싸고 이해하기 쉬웠다. 반복 빈도와 유지 비용이 낮지 않다면 자동화 자체가 새로운 운영 부채가 된다.

13. 이후 확인할 것

  • 기존 Notion-hosted cover는 페이지별로 한 번씩 변경해 CDN URL로 전환
  • DevTools에서 첫 CDN 요청과 재요청 시간을 각각 측정
  • R2 object 크기와 Cloudflare cache 상태 확인
  • Webhook의 updated, already-synced, 실패 상태를 운영 로그에서 관찰
  • Notion file delivery hostname이 달라져 fail-closed될 때만 allowlist 추가
  • 사용하지 않을 backfill 코드의 유지 여부를 다음 정리 커밋에서 결정

회고

이번 작업은 처음에는 “이미지 캐시가 있는데 왜 첫 화면이 느리지?”라는 작은 질문이었다. 하지만 요청 하나를 따라가 보니 브라우저, Next.js image optimizer, Notion signed URL, Sharp, Docker, Kubernetes Secret, Webhook 검증, R2와 Cloudflare CDN까지 이어져 있었다. 중요했던 것은 도구를 많이 붙인 것이 아니라 병목이 발생하는 시점을 바꾼 것이었다. 캐시 설정을 더 조정하는 대신 방문자가 기다리는 경로에서 외부 원본 다운로드와 이미지 변환을 제거했다. 동시에 구현 과정에서는 “정확해 보이는 설계”와 “운영에서 실제로 일어난 일”이 여러 번 달랐다. verification 요청의 signature, 커버 변경의 event type, 한 줄만 보인 로그 모두 실제 요청을 확인한 뒤에야 바로잡을 수 있었다. 마지막으로 기존 커버까지 자동화하려다 멈춘 판단도 남길 가치가 있다. 개인 프로젝트에서는 완전한 자동화보다 지금 필요한 만큼만 운영하는 것이 더 좋은 설계일 수 있다. 이번 개선의 최종 기준은 기능의 수가 아니라, 앞으로도 Notion에서 평소처럼 글과 커버를 올리기만 하면 공개 사이트는 가벼운 CDN 이미지를 읽는다는 단순한 사용 흐름이다.