Next.js 16 + Redis | AWS Self-hosting 캐시 불일치 해결하기 (4편 · 렌더링 전략 실측과 최선의 선택)

Next.js 16 + Redis | AWS Self-hosting 캐시 불일치 해결하기 (4편 · 렌더링 전략 실측과 최선의 선택)

Next.js 16 + Redis | AWS Self-hosting 캐시 불일치 해결하기 (4편 · 렌더링 전략 실측과 최선의 선택)

3편까지는 "일관성"을 이야기했다. 이번 편은 그 위에서 **"그래서 어떤 렌더링 전략을 골라야 하는가"**를 실제 수치로 답한다.

1편은 왜 멀티 인스턴스 환경에서 캐시가 갈라지는지 개념을 다뤘고, 2편은 Docker Redis와 데모 앱으로 그 문제를 로컬에서 재현했고, 3편은 그 구조를 AWS Self-hosting 환경으로 옮기며 운영 검증을 기록했다.

이번 4편에서는 숫자를 본다.

  • SSR / ISR / Cache Components / Hybrid / CSR / BFF를 같은 인프라에서 비교하고
  • 각 전략의 성능·비용·운영 복잡도를 실측 기반으로 정리하고
  • "CSR이 서버비 아끼고 유저한테 넘기면 되지 않나?"라는 흔한 주장을 실측으로 반박하고
  • revalidateTag가 실제로 멀티 태스크에 몇 ms 안에 전파되는지까지 재보고
  • 마지막에 내가 고른 "최선의 선택"과 그 이유를 남긴다.

모든 수치는 staging에 그대로 배포된 대시보드에서 실시간으로 확인할 수 있다 — 가정이 아니라 실측이라는 걸 독자가 직접 재현할 수 있게, 각 Verdict 옆에 원본 JSON 경로와 재현 명령까지 붙여 뒀다.

SSG는 이번 비교에서 일부러 뺐다. 이유는 뒤에서 설명한다.

그리고 마지막 장에는, 이 글을 쓰면서 staging에서 실제로 만난 사고 두 건을 넣었다. "공유 캐시가 잘 공유돼서 배포 경계까지 넘어버린" 사례와 /api/health endpoint가 Redis 상태를 오판하던 버그. 둘 다 원인·수정·배포 검증까지 남겼다. 이 시리즈가 푸는 문제의 반대면이라 같이 싣는다.

주소는 3편과 동일하다.

  • Staging: http://next-redis-cache-staging-alb-1315597713.ap-southeast-2.elb.amazonaws.com/
  • Dashboard (실측 결과 요약): http://next-redis-cache-staging-alb-1315597713.ap-southeast-2.elb.amazonaws.com/dashboard
  • GitHub: https://github.com/leejpsd/next-redis-cache

29. 실측 환경 (재확인)

앞에서 여러 번 언급했지만, 숫자를 해석하려면 조건을 다시 고정해둔다.

  • 리전: ap-southeast-2 (Sydney)
  • ECS Fargate 2 태스크, 각 0.5 vCPU · 1 GB
  • ALB 1개 (HTTP)
  • ElastiCache Redis cache.t4g.micro 1 노드 (Single-AZ)
  • Next.js 16.2.3, React 19.2, cacheComponents: true
  • 공유 캐시 핸들러: redis-handler.cjscacheHandlers.default, incremental-cache-handler.jscacheHandler
  • 측정 도구: k6 v1.7.1, Lighthouse 12, Playwright, redis-cli

측정일: 2026-04-19 (k6 · Lighthouse · SEO · 브라우저), 2026-04-20 추가 실측 2건 (CSR origin 호출 · revalidateTag 전파).

원본 데이터:

  • docs/load-test/2026-04-19/rendering-strategies.json — k6 baseline
  • docs/load-test/2026-04-19/spike.json — k6 스파이크
  • docs/load-test/2026-04-19/soak.json — k6 soak
  • docs/load-test/2026-04-19/web-vitals-mobile-local.json — Lighthouse 모바일 4G
  • docs/load-test/2026-04-19/web-vitals-slow3g.json — Lighthouse Slow 3G
  • docs/load-test/2026-04-19/seo-html-local.json — SEO HTML 길이 UA별
  • docs/load-test/2026-04-19/cache-debug-{pre,post-spike,post-soak}.json — Redis 상태 스냅샷
  • docs/load-test/browser-strategies.json (2026-04-14) — Playwright CSR/BFF
  • docs/load-test/2026-04-20/origin-fetch-per-session.json (신규) — CSR 세션당 origin 호출
  • docs/load-test/2026-04-20/revalidate-tag-propagation.json (신규) — 태스크 간 무효화 전파 시간

30. 비교 대상 전략 정의

이번 비교에 포함한 전략은 다음 여섯 개다.

코드 상 경로전략핵심 코드
/experiments/ssrSSR (no-store)fetch(url, { cache: "no-store" })
/experiments/isr-fetchISR (fetch revalidate)fetch(url, { next: { revalidate: 60, tags } })
/experiments/shared-cacheCache Components + Redis"use cache" + cacheLife("minutes") + cacheTag
(섹션 혼합)Hybrid섹션별로 cacheLife 다르게, revalidateTag 혼용
/experiments/csrCSR브라우저에서 직접 fetch
/experiments/bffBFF브라우저가 /api/bff/* 경유

SSG는 이번 표에 없다. 이유는 docs/experiments/ssg-why-not-here.md에 자세히 썼지만 요약하면:

  • 이 프로젝트 데이터가 빌드 시점에 고정되지 않는 동적 데이터(randomuser.me)다
  • SSG는 "멀티 인스턴스 캐시 일관성"이라는 문제가 원천적으로 발생하지 않는 구조
  • 같은 평면에서 비교하면 전제가 달라 독자 혼란을 만든다

SSG가 부적합한 것이 아니라, 이 시리즈가 푸는 문제와 교집합이 없다. 그래서 뺐다.

31. 기본 성능 비교 (부하 없는 상태)

각 전략 5 VU × 45초 constant-VUs로 측정한 결과다.

31-1. 결과

전략avgp50p90p95p99에러율
SSR481.4ms425.9ms597.7ms855.3ms측정 필요0.44%
ISR (fetch revalidate)222.5ms187.5ms245.2ms323.9ms측정 필요0%
Cache Components + Redis228.1ms183.9ms274.7ms353.9ms측정 필요0%

(원본: docs/load-test/2026-04-19/rendering-strategies.json)

31-2. 관찰

  1. SSR은 요청마다 원본 호출 + 렌더 비용을 낸다. 평균 481ms, p95 855ms. 에러율도 가장 높다.
  2. ISR(fetch revalidate)이 가장 안정적이다. 평균·p95 모두 가장 낮고, 60초 윈도우 안에서는 거의 모든 요청이 캐시 히트로 처리된다.
  3. Cache Components + Redis는 ISR(fetch)보다 약간 무겁다 (avg 228 vs 222, p95 354 vs 324). Redis 네트워크 왕복 비용이 들어가서다. 그러나 태그 기반 무효화와 cacheLife 제어권이라는 운영 이점을 감안하면 수십 ms 차이는 받아들일 수 있다.
  4. 서버 전략 안에서는 "렌더 방식"보다 "캐시 전략"이 성능을 지배한다. SSR → ISR로 넘어가는 변화가 ISR → Cache Components로 가는 변화보다 훨씬 크다.

32. 스파이크 상황 (30 VU × 3 시나리오, 5분간 peak)

실제 운영에서 중요한 건 부하 없는 상태가 아니라 트래픽이 튈 때다. 스파이크를 모사해 각 시나리오 30 VU, 피크 동시 90 VU, 5분 hold로 돌렸다.

32-1. 결과

전략avgp90p95피크 처리량에러율
SSR1120ms1.73s1.95s낮음0.31%
ISR (fetch revalidate)481.8ms825.5ms1.04s높음0.75%
Cache Components + Redis898.6ms1.52s1.79s중간0.38%

총 20,377 요청, 평균 68 req/s, 전체 에러율 0.53%.

(원본: docs/load-test/2026-04-19/spike.json)

32-2. 관찰

  1. ISR(fetch)이 스파이크에서도 가장 견고. p95가 1초를 약간 넘는 수준으로 서버 부하가 몰려도 캐시 히트로 흡수한다.
  2. Cache Components가 스파이크에서는 SSR과 비슷해진다. 평상시 228ms → 스파이크 898ms로 4배 가까이 증가. Redis로 몰린 요청들이 네트워크 왕복을 대기하는 것으로 보인다. 이 프로젝트의 Redis 인스턴스가 cache.t4g.micro(가장 작은 등급)라는 점이 같이 작용한다.
  3. SSR은 예상대로 가장 먼저 무너진다. avg가 1초를 넘기는 순간부터 ECS 태스크 CPU가 포화돼서 p95가 2초 근처로 간다.
  4. Redis 캐시는 "스파이크에서 원본을 막는 효과"가 확실: 20,377 요청 후에도 Redis의 entryKeys 수는 여전히 1개만 유지됐다 (cache-debug-post-spike.json). 즉 2만 요청 중 사실상 대부분이 같은 키에 hit했고, 원본 호출은 TTL 주기만큼만 일어났다.

이 4번째 관찰이 이 시리즈 전체의 핵심이다. "일관성을 얻었다"의 진짜 결과물은 "스파이크에서도 원본 API가 흔들리지 않는다"로 나타난다.

33. 소크 테스트 (장시간 안정성)

30분간 shared-cache 경로에 10 VU constant로 돌려 장시간 안정성, 태그 인덱스 누수, 메모리 증가를 관찰했다.

33-1. 결과

지표
총 요청 수18,164
처리량약 10 req/s
avg190.1ms
p90192.9ms
p95217.5ms
max1167ms
에러율0.00% (실패 1건)
시작 시점 Redis entryKeys1
종료 시점 Redis entryKeys1
종료 시점 tagKeys1

(원본: docs/load-test/2026-04-19/soak.json, cache-debug-post-soak.json)

33-2. 관찰

  1. 장시간 안정적이다. 30분간 18,164 요청에서 에러율 0%, p95 217ms. Baseline 측정(354ms)보다 오히려 더 낮다. 캐시가 warm 상태로 고정되면서 분산이 좁아졌다.
  2. 태그 인덱스 누수 없음. 시작과 종료 시점 Redis 키 수가 동일(entry 1, tag 1, tagExpiration 1). 재검증 주기마다 키가 쌓이지 않고 같은 키를 재사용하고 있다는 증거다. cacheLife와 태그 만료 정책이 실제로 작동한다.
  3. 메모리/네트워크 정상. 30분 동안 265 MB 다운스트림, ECS 태스크 재시작/헬스체크 실패 없음.
  4. 즉 Cache Components + Redis 조합은 장시간 트래픽에서도 안정적. Spike에서 일시적으로 p95가 뛰어오르는 구간이 있지만, 평상시 부하로 돌아오면 곧 안정화된다.

이 결과가 의미하는 것: "일관성을 위해 감수한 Redis 네트워크 왕복 비용"은 장시간 평균으로 보면 거의 드러나지 않는다. 스파이크 순간에만 추가 지연이 보이고, 평상시엔 ISR과 거의 구분이 안 되는 수준으로 동작한다.

34. 브라우저 전략 (CSR vs BFF)

이 절은 2026-04-14 측정 결과를 쓴다. 2026-04-19 재측정 때 staging 정적 청크(/_next/static/*)가 404로 막혀 Playwright가 돌지 않았다. 이 404 자체가 흥미로운 운영 사고였어서 별도 절(37-β)에서 원인까지 파고들었고 수정도 같이 했다.

34-1. 결과 (7회 iteration, Playwright)

지표CSRBFF
pageReadyMs avg1816.8ms2442.7ms
readyToPaintMs avg504.8ms1120.9ms
readyToPaintMs p95558.5ms3692.0ms

34-2. 관찰

  1. 평균만 보면 CSR이 빠르다. BFF는 한 번 더 홉을 타므로 당연히 더 무겁다.
  2. 그러나 BFF의 p95 3692ms outlier가 크다. 7회 중 2회가 크게 튀었다.
  3. 이 outlier의 원인은 이번 시리즈에서 완전히 특정하지는 못했지만, origin fetch 변동 + ECS 태스크 편차 + 콜드 패스가 겹친 것으로 보인다.
  4. 그럼에도 BFF는 "느려서 지는 전략"이 아니다. 보안(키 미노출), 응답 정규화, 레이트 리밋, 관측성, 캐시 삽입 지점 같은 운영 제어 포인트를 브라우저 밖으로 빼낼 수 있다. 속도만 보면 오답이지만, 운영을 포함하면 정답이 바뀐다.

34-α. 사용자 체감 성능 (Lighthouse + SEO)

여기까지는 서버 응답 시간을 봤다. 그런데 사용자가 "느리다"고 느끼는 건 서버 시간이 아니라 LCP다. 그래서 Lighthouse와 HTML 구조 분석으로 한 번 더 비교했다. 측정 데이터는 docs/load-test/2026-04-19/web-vitals-seo.md 전체 기록 참조.

34-α-1. SEO HTML — 크롤러/JS 실패 환경에서 무엇이 보이는가

같은 URL을 Browser / Googlebot / naked curl로 요청해서 HTML 본문 길이를 잰 결과다.

전략HTML 본문 텍스트 (browser)설명
SSR3,216 chars풀 본문
ISR3,239 chars풀 본문
Cache Components3,295 chars풀 본문
Hybrid (신규)751 charsShell + 3개 섹션 fallback (스트리밍 교체)
CSR165 chars로딩 메시지만
BFF143 chars로딩 메시지만

CSR/BFF는 HTML에 사실상 내용이 없다. JS가 꺼져 있거나 크롤러가 JS를 실행하지 않는 환경에서는 빈 껍데기가 된다. 공개 페이지에 CSR을 쓰면 안 되는 이유가 여기 있다.

34-α-2. Lighthouse — 모바일 4G 프로파일 (LCP 기준 정렬)

로컬 production build 기준, 5 runs × Moto G4 에뮬레이션.

ISR      ████████████████             1746ms
Hybrid   ████████████████             1743ms
SSR      █████████████████▌           1900ms
shared   █████████████████▌           1904ms
BFF      ██████████████████           1987ms
CSR      █████████████████████████████ 3200ms   ← +1454ms (+83%)
전략LCP avgPerformance Score
ISR1,746ms100
Hybrid1,743ms100
shared-cache1,904ms100
SSR1,900ms~100
BFF1,987ms99
CSR3,200ms93

34-α-3. Slow 3G (400kbps, RTT 400ms, CPU 4x)

네트워크 조건이 나빠질수록 CSR의 페널티는 더 커진다.

전략LCP avg
ISR / Hybrid5,693ms
SSR / shared-cache6,093~6,119ms
BFF6,275ms
CSR8,398ms

모바일 4G에서 +1.45초 차이였던 것이 Slow 3G에서 +2.7초까지 벌어진다. 저사양 폰·지하철·해외 사용자 비중이 클수록 CSR이 더 비싸진다.

34-α-4. "CSR로 유저에게 비용을 넘긴다"는 주장의 재검토

서버비만 본 시각은 한쪽 축만 보고 있다. 전체를 놓고 보면:

관점SSR/ISR/HybridCSR
서버 응답 시간 (p95)217~855ms서버 렌더 없음
체감 LCP (모바일 4G)1.7~1.9s3.2s
체감 LCP (Slow 3G)5.7~6.3s8.4s
SEO HTML 본문3,200+ chars143~165 chars
JS 실행 실패 시정상 노출빈 페이지
원본 API 장애 시stale cache 서빙 가능전 사용자 에러
원본 API 호출량TTL 주기당 1회사용자 수만큼

CSR이 실제로 아끼는 건 Next 서버의 렌더 CPU뿐이다. 이 프로젝트 인프라 기준으로 그 절감은 월 $22 수준(태스크 1개분). 대신 LCP를 1.5~2배 늦추고, SEO 본문을 20분의 1로 줄이고, 원본 API를 사용자 수만큼 때린다. 총비용으로 보면 CSR이 오히려 비싸다.

34-α-5. Hybrid — 한 페이지 안에서 성격이 다른 섹션을 다루는 방법

이번 편에서 새로 추가한 경로 /experiments/hybridStreaming SSR + 섹션별 Cache Components 조합이다.

export default function HybridExperimentPage() { return ( <main> <Shell /> {/* 즉시 내려감 */} <Suspense fallback={<BannerFallback />}> <BannerSection /> {/* use cache + cacheLife("hours") */} </Suspense> <Suspense fallback={<RankingFallback />}> <RankingSection /> {/* use cache + cacheLife("minutes") */} </Suspense> <Suspense fallback={<LiveFeedFallback />}> <LiveFeedSection /> {/* fetch no-store */} </Suspense> </main> ); }

브라우저는 순서대로 받는다.

  1. Shell HTML 즉시 — 페이지 구조와 제목이 먼저 보인다 (LCP 후보 확보)
  2. 3개 섹션이 Suspense fallback 상태로 HTML에 포함됨
  3. 각 섹션이 준비되는 대로 병렬 스트리밍으로 교체

측정 결과: LCP 1,743ms로 ISR(1,746ms)과 동률. 즉 "한 페이지 안에 서로 다른 갱신 주기가 섞여 있어도" 사용자 체감은 손해 보지 않는다. 동시에 섹션별 태그 무효화(revalidateTag("hybrid:ranking"))가 독립적으로 가능하다는 운영 이점이 그대로 유지된다.

이게 실제 메인 페이지/대시보드 설계의 현실적인 답이다.


34-β. 실측 두 건 — “가정치”를 제거하기 위해 더 재본 것

여기까지의 수치로도 결론은 충분히 선명하다. 다만 4편을 마감하는 시점에 "세 축 중 origin 비용 부분은 여전히 가정치"라는 걸 스스로 인정해야 했다. 월 100만 세션 · TTL 60s · 호출당 $0.0001 — 가정이 겹겹이 쌓여 있었다. 포트폴리오로 내걸려면 이 지점부터 실측으로 바꿔야 한다고 판단했다.

그래서 추가로 두 건을 더 쟀다.

34-β-1. CSR 세션당 origin 호출 수 (Playwright 실측)

Playwright로 fresh browser context를 5번 열어 각 전략 페이지에 접속, 브라우저가 randomuser.me/api로 나가는 요청을 카운트했다.

전략세션당 origin 호출월 100만 세션 환산
CSR1.0회 (min 1, max 1, 분산 0)1,000,000회
BFF0회0회 (서버가 프록시)
SSR0회0회
ISR0회0회
Cache Components0회0회
Hybrid0회0회

(원본: docs/load-test/2026-04-20/origin-fetch-per-session.json)

5번 모두 min=max=1, 분산 0이라는 건 “사용자 N명 = origin 호출 N회”가 구조적으로 확정됐다는 뜻. 이제 월 1M 가정은 가정이 아니라 “세션당 1.0회 × 사용자 수”라는 곱셈식이다.

OriginFetchChart 상단의 가정치 태그를{" "} <span class="eyebrow">실측</span>{" "} 배지로 바꿔 달았고, 대시보드 본문에서 CsrOriginLiveCard로 분산 0을 대형 숫자로 강조한 이유가 이것이다.

34-β-2. revalidateTag 전파 속도 (멀티 태스크 실측)

로컬에 Redis를 하나 띄우고 Next 앱을 두 포트(3000·3001)로 같은 Redis에 붙였다. 포트 3000에서 revalidateTag로 entry를 무효화한 뒤, 양쪽 태스크가 새 값을 반환하는 데 걸리는 시간을 5 라운드 반복 측정.

지표Task A (3000)Task B (3001)
avg6.4ms6.4ms
median6ms6ms
min / max5 / 10ms5 / 10ms
첫 polling 감지5/5 round5/5 round
A/B 편차0ms

(원본: docs/load-test/2026-04-20/revalidate-tag-propagation.json)

이게 이 시리즈가 1편부터 밀어온 주장 “멀티 인스턴스에서도 같은 캐시 상태를 본다”의 가장 직접적인 실측 증거다. 1편에서 지적한 “EC2 #1 최신 / EC2 #2 옛날” 문제가 6.4ms까지 좁혀졌고, 두 태스크 편차는 0ms. 대시보드의 PropagationCard에 5 라운드 바 차트로 그대로 실어 뒀다.

왜 이 두 실측이 중요했나

“공유 캐시는 좋은 겁니다”를 어떻게 증명하느냐. 말로는 한 줄이지만 실제로는 두 방향에서 공격 받을 수 있다.

  1. “CSR이 서버비 안 들고 유저에게 넘기면 되는데 왜 공유 캐시까지?” → CSR 1.0회 × 사용자 수 대비
  2. “공유 캐시라 해봐야 전파 지연 있지 않나?” → 6.4ms 편차 0ms 대비

두 실측이 각각의 반박을 수치로 막는다.


35. 비용 관점 — 같은 인프라에서 전략이 만드는 차이

이 프로젝트 인프라의 월 기본 비용은 약 $98 (약 13만 원)이다. 자세한 내역은 docs/cost-estimate.md.

항목월 비용
ECS Fargate (2 task × 0.5 vCPU × 1 GB)$44.98
ALB (시간 + LCU)$25.55
ElastiCache Redis cache.t4g.micro$16.06
CloudWatch Logs + Container Insights~$7.50
Data Transfer (out)~$4.22
합계$98.31/월

이 기본 비용은 전략이 바뀌어도 거의 안 바뀐다. 진짜 비용 차이는 두 군데서 생긴다.

35-1. Origin API 호출 비용

트래픽 100만 요청/월, TTL 60초를 가정한 원본 API 호출 수:

전략월 origin fetch호출당 $0.0001 가정 시 월 비용
SSR (no-store)1,000,000회$100.00
ISR / Cache Components43,200회$4.32
Hybrid (섹션별 TTL 혼합)30,000 ~ 60,000회$3.00 ~ $6.00

SSR만 쓰면 origin 호출 비용이 기본 인프라 비용을 넘어선다. 이 프로젝트처럼 외부 API가 무료인 경우에도, 자체 백엔드를 호출하면 그 백엔드의 CPU/DB 비용으로 거의 같은 구조로 돌아온다.

35-2. ECS 태스크 증설 시점

SSR은 요청마다 원본 호출 + 렌더를 하기 때문에 트래픽이 커지면 태스크를 더 빨리 늘려야 한다. 태스크 1개 추가는 월 $22.49. 그런데 cache.t4g.micro Redis는 월 $16.06. 즉:

  • 태스크 1개 줄이는 것만으로 Redis 비용이 회수된다.
  • 실제 절감 효과는 "origin 호출 $비용"보다 "ECS 증설 억제"에서 더 크게 나온다.

36. 성능 × 비용 × 운영 복잡도 트레이드오프 매트릭스

다섯 축을 같이 놓고 본다. ◎ 매우 좋음, ○ 좋음, △ 보통, ✗ 나쁨. Web Vitals 축은 LCP 기준.

전략서버 성능스파이크 견고성체감 LCPSEOOrigin 비용 절감일관성 보장운영 복잡도한 줄 요약
SSR (no-store)단순·정확하지만 비싸고 느리다
ISR (fetch revalidate)가장 무난한 "기본값"
Cache Components + Redis운영 제어권이 필요할 때
Hybrid (스트리밍 + 섹션별)메인 페이지의 현실적 답
CSR◎ (서버부하X)각자내부 어드민/인증 뒤 영역 한정
BFF속도보다 보안·관측성이 우선일 때

해석:

  • "한 전략이 모든 축에서 이기는 경우는 없다." 이게 이 표에서 얻은 가장 큰 결론이다.
  • **ISR(fetch revalidate)**은 평균적인 화면에서 가장 균형 잡힌 선택이다.
  • Cache Components는 ISR 대비 약간의 런타임 비용을 내고 태그 기반 무효화와 운영 제어권을 산다.
  • Hybrid가 메인 페이지/대시보드 같은 "섹션별 성격이 다른 화면"에선 거의 유일한 현실적 답이다.
  • CSR/BFF는 서버 렌더 축이 아니라 "데이터 소유권" 축의 이야기다. 성능 표만 보고 고르면 안 된다.

37. 그래서, 최선의 선택

도메인과 화면에 따라 다르다. 이 문장을 피하고 싶었지만 실측 결과가 그렇게 말한다.

하지만 도움이 안 되는 말이기도 해서, 화면 성격에 따라 내가 실제로 고른 기준을 정리했다.

37-1. 메인 페이지 (배너 · 랭킹 · 큐레이션 혼재)

Hybrid (Streaming SSR + 섹션별 Cache Components)/experiments/hybrid에서 실측 검증됨

  • Shell: 즉시 SSR로 내려가 페이지 구조/제목 확보 (LCP 후보)
  • 배너: cacheLife("hours") + cacheTag("home:banner")
  • 랭킹: cacheLife("minutes") + cacheTag("home:ranking")
  • 피드: cacheLife("seconds") 또는 fetch no-store
  • 각 섹션을 <Suspense> 경계로 감싸 병렬 스트리밍
  • 운영 이벤트 시 해당 태그만 revalidateTag

측정 결과: LCP 1,743ms — ISR 단독 전략(1,746ms)과 동률이면서 섹션별 독립 캐시·무효화를 확보. 메인 페이지처럼 한 화면에 성격이 다른 여러 데이터가 섞이는 경우의 가장 현실적인 답이다.

근거: 섹션마다 갱신 주기와 중요도가 달라서 전체를 하나의 TTL로 묶으면 불필요한 무효화가 많아지고, 원본 API 지연이 한 섹션에서 생겨도 전체 페이지가 막히지 않아야 한다.

37-2. 상세/리스트 페이지 (단일 성격의 데이터)

ISR (fetch revalidate)

  • fetch(url, { next: { revalidate: 60, tags: [...] } }) 한 줄로 시작해서
  • 운영에서 더 세밀한 제어가 필요하면 Cache Components로 올린다

근거: 과잉 설계가 가장 큰 위험인 영역. 기본값이 ISR이다.

37-3. 완전 개인화 페이지 (마이페이지, 장바구니)

SSR (no-store) + 필요 시 BFF

  • 캐시 자체가 독이 되는 영역
  • 개인화 데이터가 다른 사용자에게 노출될 위험이 SSR의 비용보다 크다
  • 외부 API 키가 관여하면 BFF로 숨긴다

근거: "일관성"의 정의가 다르다. 공유 캐시 일관성이 아니라 세션 경계 일관성을 지켜야 한다.

37-4. 행정·정책·회사 소개

SSG (이번 비교표 밖)

  • 이 시리즈가 다루는 문제와 교집합이 없다
  • 배포 시점에 결정되는 콘텐츠면 그냥 SSG가 이긴다

37-5. 브라우저 쪽에서 실시간 업데이트가 중요한 위젯

CSR + BFF, 단 공개 페이지 본문에는 쓰지 않는다

  • 서버 렌더에 끌어들이지 말고 클라이언트에서 갱신
  • 키·레이트 리밋·로깅은 BFF에서 잡는다
  • 초기 HTML 본문이 SEO·접근성에 중요한 경우엔 절대 안 된다 — 34-α-1에서 본 것처럼 CSR/BFF HTML은 150자 수준 로딩 메시지가 전부다
  • 실제 쓰는 곳: 인증 뒤 대시보드·어드민·실시간 채팅·장바구니 위젯 등 이미 서버 렌더 이점이 없는 영역

37-β. 운영 중에 배운 것: 공유 캐시가 "배포 경계"에서 저주로 바뀔 수 있다

글을 마무리하는 시점에 staging에서 예상치 못한 현상을 만났다. 4편용 새 라우트(/dashboard, /experiments/hybrid)는 정상 배포된 반면, 기존 CSR/BFF/SSR/shared-cache 라우트는 계속 이전 빌드 시점의 HTML을 서빙하고 있었다. 심지어 그 HTML이 참조하는 정적 청크(_next/static/chunks/*.wizo.js)는 새 이미지에 존재하지 않아서 404가 났다.

처음엔 "Docker 빌드가 Turbopack으로 섞여 들어갔나?" 싶어 Dockerfile에 --webpack 플래그 명시·Turbopack 산출물 assertion까지 넣어봤지만 재현이 안 됐다. 결정적 증거는 응답 헤더였다.

/experiments/csr 응답:
  x-nextjs-cache: HIT
  x-nextjs-prerender: 1
  Cache-Control: s-maxage=31536000    ← 1년

/experiments/hybrid (신규 라우트) 응답:
  x-nextjs-postponed: 1
  Cache-Control: private, no-cache, no-store

즉 CSR/BFF/SSR/shared-cache는 Redis에 저장된 ISR prerender HTML을 hit하고 있었다. 문제는 그 HTML이 몇 주 전 Turbopack으로 빌드되었을 때 생성되어 Redis에 저장됐고, TTL이 1년이라 배포를 몇 번 해도 여전히 hit된다는 것.

내가 쓴 incremental-cache-handler.js를 다시 보니 cacheKey는 이렇게 생겼었다.

// Before function entryKey(key) { return `next-incremental:entry:${key}`; }

배포 버전이 cacheKey에 포함되어 있지 않았다. 멀티 인스턴스 간 공유는 잘 되고 있었지만, 배포 간에도 공유된 것이다.

수정

// After const BUILD_NAMESPACE = process.env.DEPLOYMENT_VERSION || process.env.GIT_HASH || "unversioned"; function entryKey(key) { return `next-incremental:entry:${BUILD_NAMESPACE}:${key}`; } function tagMetaKey(tag) { // 태그는 배포 간에도 공유 유지 — revalidateTag가 이전 배포의 stale 플래그도 // 덮어쓸 수 있어야 한다. return `next-incremental:tag:${tag}`; }

엔트리 키에만 git SHA를 붙이고, 태그 메타 키는 공유 상태를 유지했다. 이 구분이 중요하다:

  • 엔트리: 빌드 산출물과 1:1 연결되어 있어 배포가 바뀌면 무효화되어야 한다
  • 태그: "콘텐츠가 stale이다"라는 운영자 신호는 배포 경계를 넘어 유지되어야 한다. 그렇지 않으면 CMS에서 누른 revalidateTag가 바로 다음 배포에서 사라진다

이 경험이 의미하는 것

처음 2~3편까지는 "멀티 인스턴스에서 캐시가 갈라지면 안 된다"는 관점이었다. 이번 4편을 쓰면서 그 반대쪽 위험도 있다는 걸 확인했다.

공유 캐시는 너무 잘 공유되면 배포 경계도 넘어선다. 이건 "캐시 충돌"보다 더 교묘한 실패 방식이다. 전통적인 "인스턴스마다 캐시가 다르다" 문제는 눈에 잘 띄고 무효화로 바로 해결되지만, "배포가 바뀌었는데 옛 HTML이 여전히 hit한다"는 문제는 응답이 200이고, 본문도 정상처럼 보이며, 그저 참조하는 청크만 404라 훨씬 늦게 드러난다.

교훈

  1. 공유 캐시 cacheKey 설계 시 멀티 인스턴스 축배포 버전 축을 분리해서 본다
  2. 엔트리는 배포 네임스페이스를 붙여 격리, 태그는 공유 유지
  3. ISR prerender HTML처럼 빌드 산출물과 강결합된 캐시는 TTL이 길수록 배포 격리가 중요하다
  4. 응답 헤더(Cache-Control, x-nextjs-cache, x-nextjs-prerender)는 공유 캐시 경로를 진단할 때 가장 먼저 봐야 한다

수정 후 배포 검증

위 수정(BUILD_NAMESPACE 도입)을 staging에 올린 뒤 직접 확인했다.

  • CSR/BFF/SSR/shared-cache 라우트 HTML이 전부 새 4bd1b696-*.js 같은 webpack 청크로 바뀜
  • 모든 청크 HTTP 200
  • CSR 응답 헤더가 x-nextjs-cache: HIT, s-maxage=31536000에서 x-nextjs-postponed: 1, no-store로 바뀜 — 이전 엔트리를 조회하지 않는다는 증거

즉 이번 편 쓰는 도중에 생긴 버그를 이번 편에서 원인·수정·검증까지 다 마무리했다. 일부러 극적인 결말을 만든 게 아니라, 공유 캐시가 실제 운영에서 이런 식으로 드러난다는 걸 있는 그대로 남겼다.


38. 이 시리즈 전체를 관통하는 한 문장

"어떤 렌더링 전략이 제일 빠른가"는 틀린 질문이고, "어떤 전략이 이 화면의 일관성·운영·비용 제약을 가장 낮은 비용으로 만족시키는가"가 맞는 질문이다.

1편에서 캐시 불일치를 개념으로 다뤘고, 2편에서 로컬로 재현했고, 3편에서 운영 환경으로 옮겼고, 이번 4편에서 숫자로 확인했다.

그 과정에서 얻은 실질적 결론은 네 개다.

  1. Redis 공유 캐시는 기술 선택이라기보다 "멀티 인스턴스 운영의 기본값"에 가깝다. 이걸 안 쓰면 다른 모든 최적화가 모래 위에 얹힌다. 태스크 간 전파가 6.4ms(편차 0ms)까지 좁혀진다는 실측이 이를 뒷받침한다.
  2. 렌더링 전략은 "한 개 고르기"가 아니라 "화면별로 나눠 고르기"다. Hybrid가 현실이다.
  3. CSR이 아끼는 건 서버 CPU뿐이고, 그 대가는 origin API 호출로 1:1 옮겨간다. 사용자 N명 = origin 호출 N회(분산 0). 내부 API라도 CPU·DB로 동일한 구조로 되돌아온다.
  4. 공유 캐시를 설계할 때는 "인스턴스 축"과 "배포 축"을 분리해서 본다. 엔트리는 배포 버전을 네임스페이스로 묶고, 태그는 배포 경계를 넘어 공유한다. 그래야 멀티 인스턴스 일관성을 지키면서도 배포 간에 옛 HTML이 갇히지 않는다.

39. 이번 편에서 남긴 산출물

측정 결과

  • docs/load-test/2026-04-19/rendering-strategies.json — k6 baseline
  • docs/load-test/2026-04-19/spike.json — k6 스파이크
  • docs/load-test/2026-04-19/soak.json — k6 30분 soak
  • docs/load-test/2026-04-19/cache-debug-{pre,post-spike,post-soak}.json — Redis 키 상태 스냅샷
  • docs/load-test/2026-04-19/seo-html-local.json — UA별 HTML 본문 길이
  • docs/load-test/2026-04-19/web-vitals-mobile-local.json — Lighthouse 모바일 4G
  • docs/load-test/2026-04-19/web-vitals-slow3g.json — Lighthouse Slow 3G
  • docs/load-test/browser-strategies.json (2026-04-14) — Playwright CSR/BFF
  • docs/load-test/2026-04-20/origin-fetch-per-session.json — CSR 세션당 origin 호출 (Phase 1)
  • docs/load-test/2026-04-20/revalidate-tag-propagation.json — revalidateTag 전파 실측 (Phase 1)
  • docs/load-test/2026-04-19/web-vitals-seo.md — Web Vitals + SEO 종합 해석

문서

  • docs/cost-estimate.md — 실제 Terraform 스펙 기반 월 비용 계산
  • docs/experiments/ssg-why-not-here.md — SSG를 비교에서 뺀 이유
  • docs/incident/health-endpoint-redis-ping-mismatch.md — health endpoint 오판 (Resolved)
  • docs/incident/static-chunk-404-turbopack-mismatch.md — staging CSR/BFF 재측정 차단 이슈 (Resolved)

코드 · 실험 라우트

  • app/experiments/hybrid/page.tsx — Streaming SSR + 섹션별 Cache Components
  • app/dashboard/page.tsx — 실측 결과 스토리라인 대시보드 (Act 1~5 + Appendix)
  • incremental-cache-handler.js — 엔트리 키에 배포 버전 네임스페이스 추가 (37-β)
  • lib/redis-client.ts pingRedis()/api/health 전용 static export로 오판 제거

대시보드 컴포넌트 (시각화 · 스토리라인)

  • ArchitectureDiagram — ALB → ECS 2 task → ElastiCache 순수 SVG
  • CsrOriginLiveCard · PropagationCard — Phase 1 실측 2건 시각화
  • SpikeRedisProof · LcpBarChart · OriginFetchChart — 3축 임팩트 시각화
  • ServerTimingTable · LighthouseFullTable · BrowserTimingTable · SeoTable — 전체 지표 풀 테이블
  • GlossaryCard · ReproducibleBlock · SourceLink — 비전문가/심사자 친화적 문맥
  • StoryTransition · Verdict · ClimaxBanner · FinalCta · StickyToc — 스토리라인 구조

측정 스크립트

  • scripts/k6-spike.js, scripts/k6-soak.js — 신규 k6 시나리오
  • scripts/measure-seo-html.mjs — UA별 HTML 본문 분석
  • scripts/measure-web-vitals.mjs, scripts/measure-web-vitals-slow3g.mjs — Lighthouse 자동화
  • scripts/measure-origin-fetch-per-session.mjs — Phase 1 CSR origin 카운트
  • scripts/measure-revalidate-tag-propagation.mjs — Phase 1 태스크 간 전파 시간

40. 대시보드에서 확인하기 (글보다 짧은 길)

이 편의 모든 수치는 staging에 배포된 대시보드(/dashboard)에서 실시간으로 확인할 수 있다. 대시보드 자체가 이 글의 스토리라인을 그대로 따라간다.

  • Hero — “반만 맞는 이야기다” 결론 먼저 + 핵심 지표 3개
  • Architecture — ALB → ECS 2 task → ElastiCache 공유 캐시 구성도 (SVG)
  • Act 1 — 서버 시간만 보면 CSR이 이긴 것 같음 (ServerTimingTable · TTFB · baseline/spike/soak)
  • Act 2-1 — 사용자 체감 (LcpBarChart · Lighthouse 4G + Slow 3G)
  • Act 2-2 — SEO (SeoTable · Browser / Googlebot / naked curl)
  • Act 2-3 — Origin API (CsrOriginLiveCard · OriginFetchChart, 실측 1.0회)
  • Act 3 — 공유 캐시 효과 (SpikeRedisProof · PropagationCard, 6.4ms / 편차 0ms)
  • Climax — 전략은 하나가 아니다. 화면마다 다르다.
  • Act 4 — Recommendation 5카드
  • Act 5 — Operations Incidents 2건 (배포 경계 캐시 · health endpoint)
  • Appendix — LighthouseFullTable · BrowserTimingTable · Trade-off Matrix · Cost · Glossary
  • Reproducible — 모든 수치 재현하는 3가지 명령
  • Next Actions — Experiments · 블로그 · GitHub 3갈래

각 Verdict 아래엔 [출처 · 재현] 펼침이 있어, 원본 JSON 경로와 실행 명령을 바로 볼 수 있다. 심사자·독자가 “진짜인가?”를 git clone → 3 명령으로 직접 확인할 수 있도록 만드는 게 목표였다.


참고 링크

  • 1편 · 개념: https://www.eddy-dev.xyz/blog/Next.js-16-Redis-AWS-Self-hosting-%EC%BA%90%EC%8B%9C-%EB%B6%88%EC%9D%BC%EC%B9%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-1%ED%8E%B8-%EA%B0%9C%EB%85%90
  • 2편 · 구현: https://www.eddy-dev.xyz/blog/Next.js-16-Redis-AWS-Self-hosting-%EC%BA%90%EC%8B%9C-%EB%B6%88%EC%9D%BC%EC%B9%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-2%ED%8E%B8-%EA%B5%AC%ED%98%84
  • 3편 · 운영 검증: https://www.eddy-dev.xyz/blog/next-js-16-redis-aws-self-hosting-%EC%BA%90%EC%8B%9C-%EB%B6%88%EC%9D%BC%EC%B9%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-3%ED%8E%B8-%EC%9A%B4%EC%98%81-%EA%B2%80%EC%A6%9D
  • Staging: http://next-redis-cache-staging-alb-1315597713.ap-southeast-2.elb.amazonaws.com/
  • Dashboard (실측 요약): http://next-redis-cache-staging-alb-1315597713.ap-southeast-2.elb.amazonaws.com/dashboard
  • GitHub: https://github.com/leejpsd/next-redis-cache
JP
이중표Frontend Engineer

3년차 프론트엔드 개발자. Next.js, React, TypeScript 기반 웹 애플리케이션 개발 전문. 대규모 트래픽 환경에서 SSR·ISR 렌더링 전략 설계 경험.

이력서 보기