logo

DowanKim

최종 렌더링, 캐싱 흐름

2026년 10월 6일40
PLick

image.png

지난 세개의 글들은 문제를 하나씩 고친 기록이었습니다.

처음엔 루트 레이아웃의 쿠키 읽기를 브라우저로 옮겨 홈과 FAQ를 정적, ISR로 옯겼고 그 다음엔 기사,릴스,투표 도 익명으로 렌더하고 각자의 상태는 따로 업데이트하게 바꾸었고, 마지막엔 게스트 발급을 페이지 응답에서 떼어내 CDN 캐시를 켰습니다.

원칙은 하나였습니다.

페이지 HTML에는 특정 사람의 내용을 넣지 않는다. (쿠키든 헤더든) 그래야 한번 만든 HTML을 저장해 두고 다음 사람에게 줄 수있다. 좋아요 여부, 로그인 여부, 내 투표 처럼 사람마다 다른 것은 HTML이 뜬 뒤 브라우저가 따로 요청하고 업데이트 한다

전체 흐름

처음 온 사람이 /reels 를 열때

브라우저 → CloudFront → Next 서버(proxy.ts) → BE
① 브라우저   GET /reels (쿠키 없음)
② CloudFront 저장된 HTML이 있으면 바로 준다(Hit). 없으면 Next로
③ proxy.ts   토큰 없음 → 익명으로 통과. 페이지 응답에 쿠키는 심지 않는다
④ Next       /reels는 ISR(60초). BE를 익명으로 불러 HTML을 만들고 저장
             응답에 s-maxage=60을 붙여 CloudFront도 저장하게 한다
⑤ 브라우저   HTML을 그린다. 로그인 여부 모름, 하트는 전부 빈 하트
⑥ 브라우저   sessionKind 쿠키가 없다 → POST /api/session/guest로 게스트를 받는다
⑦ 브라우저   보이는 릴스 ID를 모아 GET /users/me/article-states?ids=… 한 번으로 내 상태를 받아 하트를 채운다
⑧ 브라우저   좋아요를 누르면 /be로 보내고, proxy.ts가 쿠키의 토큰을 Bearer 헤더로 바꿔 BE에 전달한다

1번부터 4번까지는 캐싱될 모두에게 같은 페이지를 만드는 과정이고, 그 이후 단계는 내 데이터를 추가하는 과정입니다. 두 과정의 중간에는 HTML이 브라우저에 도착한 순간이 있습니다.

각 층이 지키는 것은 다음과 같습니다

클라우드 프론트 : 응답을 바꾸는 입력(경로, 쿼리, rsc 헤더) 만 캐시 키로 쓰고 쿠키는 키에서 제외

proxy : 페이지 응답에 쿠키 심기 금지. 만약 쿠키가 심어져 있으면 캐시 불가로 변경

NExt : 렌더 중 cookies, headers 금지. ISR 유지, 서버 fetch는 익명으로

브라우저 : 세션 판단, 게스트 발급, 내 상태 불러오기를 HTML 도착 뒤 스스로 진행


1. CloudFront

plick.co.kr의 DNS는 EC2가 아니라 CloudFront를 가리킵니다. 그래서 모든 요청은 CloudFront를 먼저 지납니다. CloudFront가 하는 일은 둘 중 하나입니다.

  • 저장해 둔 응답이 있으면 그걸 돌려주고 끝냅니다. (x-cache: Hit from cloudfront)
  • 없으면 뒤의 Next 서버에 넘기고, 받은 응답을 브라우저에 주면서 저장할지 결정합니다. (Miss)

저희 웹사이트에선 원본은 경로에 따라 둘입니다. /_next/static/*(JS, CSS, 이미지)는 S3가 원본이고 1년 캐싱되고 있었습니다. 나머지 전부는 Next 서버가 원본인데, 세 번째 글 전까지는 캐시 정책이 CachingDisabled였습니다. HTML은 한 번도 저장되지 않고 매번 Next까지 갔다는 뜻입니다.

저장 여부는 두 곳에서 같이 정합니다

CloudFront 쪽 설정은 캐시 정책입니다. 새로 만든 정책은 TTL이 최소 0, 기본 0, 최대 1년입니다. 여기서 기본 0이 중요합니다. 원본이 Cache-Control로 아무 말도 안 하면 저장하지 않는다는 뜻입니다. AWS 관리형 CachingOptimized는 기본이 하루라서, 헤더를 빠뜨린 응답이 하루 동안 남에게 나갈 수 있어 사용하면 큰일날 수있습니다.

원본 에서 정하는 것은 Next가 응답마다 붙이는 Cache-Control 헤더입니다. 세 종류가 나갑니다.

화면헤더CloudFront 동작
FAQs-maxage=315360001년 저장
홈s-maxage=20, stale-while-revalidate=3153598020초 신선, 그 뒤엔 옛 것을 주면서 갱신
MYprivate, no-store저장 안 함

s-maxage의 s는 shared입니다. 공유 캐시(CDN)에게 주는 수명이고 브라우저용 max-age와는 다릅니다. 브라우저에는 저장하라고 하지 않고 CDN에만 저장하라고 하는 겁니다.

stale-while-revalidate

s-maxage=20, stale-while-revalidate=31535980은 이렇게 읽습니다.

  • 처음 20초는 신선하니 원본에 묻지 않고 바로 줍니다.
  • 20초가 지나면 묵은(stale) 상태입니다. 그 뒤 31535980초 동안은 묵은 것을 일단 먼저 주고, 뒤에서 원본에 새 것을 받아 갈아 끼웁니다.

방문자는 기다리지 않습니다. 대신 주기가 지난 뒤 첫 방문자는 한 번 옛 문서를 받습니다.

두 번째 값이 1년인 건 제가 정한 게 아니라 Next의 기본값입니다. Next 16에는 expireTime 설정이 있고 기본이 31536000초입니다. 헤더는 expireTime - revalidate로 만들어져서 홈은 31536000 - 20 = 31535980이 나옵니다.

prod처럼 트래픽이 있으면 20초마다 누군가 와서 갱신을 일으키니 낡은 정도가 수십 초를 넘기 어렵습니다. 반대로 트래픽이 거의 없는 dev에서는 마지막 방문 뒤 며칠이 지나도 첫 방문자가 그 며칠 낡 홈을 받습니다. dev에서 "방금 올린 기사가 홈에 안 보인다"가 나오면 이것 때문입니다.

캐시 키

CloudFront가 "이 요청은 전에 저장한 것과 같다"를 판단하는 기준이 캐시 키입니다. 키에 넣은 것만 비교합니다. 우리 정책의 키는 셋입니다.

  • 경로 : /reels와 /faq는 다른 것
  • 쿼리스트링 : /reels와 /reels?_rsc=abc는 다른 것
  • rsc 헤더 : 이 헤더가 있는 요청과 없는 요청은 다른 것

쿠키는 키에 없습니다. 쿠키를 키에 넣으면 사람마다 토큰 값이 달라서 모든 요청이 처음 보는 요청이 되고, 아무것도 재사용되지 않습니다. 쿠키를 빼야 "모두에게 같은 HTML"을 한 번 저장해 모두에게 줄 수 있습니다.

다만 키에서 뺐다고 원본에 안 보내는 건 아닙니다. 그건 별도 설정인 오리진 요청 정책(AllViewer)이 정하고, 헤더·쿠키·쿼리를 전부 원본에 넘깁니다. Miss일 때 Next가 쿠키를 봐야 MY 화면을 그릴 수 있기 때문입니다. 쿠키는 비교에는 안 쓰지만 원본에는 전달됩니다.

키에 넣을 것의 기준은 "응답을 바꾸는 입력은 전부 넣는다"입니다. 응답을 바꾸는 입력이 키에 빠져 있으면, 먼저 들어온 응답이 뒤에 오는 모두에게 나갑니다. 이를 캐시 포이즈닝이라 합니다.

페이지 응답에 토큰 쿠키가 실려 있는데 키에는 쿠키가 없으면, 캐시를 켰다면 한 사람의 토큰이 다음 사람에게 나갔을 겁니다. 이전에 CachingDisabled라 사고가 없었지만, 이번 전환과정에서는 토큰을 응답에서 뺀 뒤에 이 설정을 켤 수 있었습니다.

rsc 헤더와 _rsc 쿼리

Next App Router는 같은 주소에 두 종류로 답합니다.

  • 주소창 입력이나 새로고침: 완성된 HTML (text/html)
  • 앱 안에서 <Link>를 눌러 이동: 바뀐 부분의 React 데이터만 (text/x-component, RSC 페이로드)

라우터가 후자를 원할 때 요청에 RSC: 1 헤더를 붙입니다.

문제는 캐시입니다. 캐시가 주소만 보고 저장하면 새로고침한 사람이 HTML 대신 페이로드 텍스트를 받습니다. 서버는 Vary: RSC로 "이 헤더에 따라 응답이 다르다"고 알리지만, CloudFront는 Vary를 캐시 키로 쓰지 않습니다. 그래서 Next 라우터는 헤더에만 의존하지 않고 페이로드 요청의 주소 자체를 다르게 만듭니다. 쿼리에 _rsc=<해시>를 붙입니다. 주소가 다르니 어떤 캐시든 둘을 따로 저장합니다.

우리 캐시 키에 쿼리스트링 전체를 넣은 건 이 _rs=해시를 인식하게 하고자 한 것입니다. rsc 헤더까지 넣은 건 사실 이중 장치 입니다. _rsc가 붙은 주소로 헤더 없는 요청이 올 수 있습니다. 크롤러가 그 주소를 따라오거나 누가 주소창에 복사해 넣는 경우입니다. 그 요청은 HTML로 답하고 /reels?_rsc=abc 키에 HTML이 저장됩니다. 그 뒤 진짜 라우터가 같은 해시로 페이로드를 요청하면 저장된 HTML을 받아 화면이 깨집니다. 헤더가 키에 있으면 둘이 따로 저장돼 이 사고가 없습니다. 평소엔 중복이고, 어긋난 요청이 들어올 때 한 번 일합니다.

나머지 설정

압축(Gzip, Brotli)은 켰습니다. 에러 응답(500, 502, 503, 504)의 캐시 시간은 기본 10초에서 0으로 낮췼습니다. 배포 중 Next가 잠깐 죽은 응답이 엣지에 10초 남아 그 사이 방문자 전부에게 에러가 나가는 걸 막기 위해서입니다.

요청 과정

  1. 브라우저가 GET /reels를 쿠키와 함께 보냅니다.
  2. CloudFront가 키(경로 + 쿼리 + rsc 헤더 유무)로 저장분을 찾습니다. 쿠키는 보지 않습니다.
  3. 있고 60초 안이면 바로 줍니다. 있지만 지났으면 옛 것을 주고 뒤에서 Next에 새로 받습니다. 없으면 Next에 넘깁니다.
  4. Next 응답에 s-maxage=60이 있으면 저장합니다. private, no-store면 저장하지 않습니다.
  5. 저장된 응답에는 age 헤더(저장된 뒤 지난 초)가 붙어 나갑니다.

이 구조가 성립하는 전제가 하나 있습니다. Next가 저장해도 된다고 말한 응답에는 정말로 개인 정보가 없어야 합니다. 그 전제를 지키는 게 뒤 두 층의 일입니다.


2. Next 서버

CloudFront가 Miss를 내면 요청이 Next 서버에 닿습니다. Next 서버 안에서는 proxy.ts가 먼저 돌고, 그 다음 페이지가 렌더됩니다.

proxy.ts

모든 요청이 페이지에 닿기 전에 지나는 자리입니다. 하는 일은 셋입니다.

  • 토큰 상태를 봅니다. accessToken이 있으면 통과, 없고 refreshToken만 있으면 재발급해서 새 토큰 쿠키를 심고 통과, 둘 다 없으면 익명으로 통과시킵니다.
  • 분석용 헤더(X-Plick-Device, X-Plick-Path 등)를 요청 헤더에 적습니다. 이 사람의 기기와 유입 경로입니다.
  • 브라우저가 보내는 /be/* 요청에는 쿠키의 accessToken을 꺼내 Authorization: Bearer 헤더로 바꿔 BE에 넘깁니다.

이전에는 여기서 게스트 발급도 했습니다. 토큰이 없는 페이지 요청이 오면 BE에 게스트를 만들고 그 페이지 응답에 토큰 쿠키를 심었습니다. 지난 게시글들에서 이 과정을 전환했습니다. 페이지 응답은 CloudFront에 저장되고, 거기에 토큰이 실려 있으면 다음 사람에게 나가기 때문입니다. 발급은 브라우저가하도록 합니다.

페이지를 만드는 세 방식

Next는 라우트마다 세 방식 중 하나로 HTML을 만듭니다.

방식빌드 표HTML을 만드는 때우리 라우트
정적○next build 때 한 번FAQ, 약관, 개인정보, 가입
ISR●첫 요청 때, 그 뒤 주기마다 뒤에서홈, 기사, 릴스, 투표, 팀, 선수, 인물
동적ƒ요청마다MY, 로그인, 라이브

정적과 ISR은 만든 HTML을 서버 안에 저장해 두고 재사용합니다. 동적은 저장하지 않고 매번 만듭니다.

어느 방식이 될지는 Next가 정합니다

우리가 직접 고르는 게 아니라 Next가 빌드 때 코드를 한 번 실행해 보고 판정합니다. 기준은 하나입니다.

렌더하는 동안 요청에 따라 달라지는 값을 읽었는가.

cookies(), headers(), searchParams, cache: "no-store" fetch가 그 값입니다. 하나라도 읽으면 "이 페이지는 요청마다 다를 수 있다"로 보고 동적(ƒ)으로 만듭니다. 하나도 안 읽으면 정적이나 ISR입니다.

판정은 레이아웃까지 합쳐서 합니다. app/layout.tsx는 모든 페이지의 부모라서, 레이아웃이 cookies()를 읽으면 그 아래 FAQ까지 전부 동적이 됩니다.

첫 번째 글의 기존 문제가 이거였습니다. 레이아웃이 로그인 여부를 쿠키로 판단해 내려주고 있었고, 그 한 줄이 모든 페이지를 ƒ로 만들었습니다. 쿠키 읽기를 브라우저로 옮기자 페이지들이 각자 성격대로 판정됐습니다.

정적과 ISR을 가르는 것

쿠키를 안 읽는 페이지 중에서 정적과 ISR을 가르는 건 그 페이지가 부르는 fetch입니다.

  • fetch가 없으면 정적입니다. FAQ는 데이터를 안 받습니다.
  • fetch에 next: { revalidate: 60 }이 있으면 "60초마다 데이터가 바뀔 수 있다"는 뜻이라 ISR이 됩니다.

페이지의 주기는 그 페이지가 부르는 fetch 중 가장 짧은 값입니다. 홈이 20초인 건 라이브 스코어 띠 fetch가 20초라서입니다. 기사, 릴스, 투표는 페이지 파일에 export const revalidate = 60을 직접 써서 60초로 고정했습니다.

ISR이 돌아가는 방식

릴스로 보면 이렇습니다.

  1. 첫 요청. 저장된 HTML이 없으니 BE를 익명으로 불러 HTML을 만들고 저장한 뒤 돌려줍니다.
  2. 60초 안의 요청. 저장된 HTML을 그대로 줍니다. 렌더도 BE 호출도 없습니다.
  3. 60초 지난 뒤 첫 요청. 저장된 옛 HTML을 먼저 주고, 뒤에서 BE를 불러 새 HTML로 갈아 끼웁니다. 이 사람은 옛 것을 봅니다.
  4. 그 뒤 요청. 새 HTML을 받습니다.

CloudFront의 s-maxage + stale-while-revalidate와 같은 형태입니다. Next가 자기 안에서 먼저 하고, CloudFront가 그 바깥에서 한 번 더 하는 겁니다. 응답 헤더 s-maxage=60, stale-while-revalidate=…도 이 revalidate 값에서 Next가 자동으로 만들어 붙입니다. 헤더를 직접 쓴 곳은 없습니다.

ISR의 전제

저장한 HTML을 다음 사람에게 주는 구조라서, HTML 안에 특정 사람의 것이 있으면 안 됩니다. 두 번째 글 전의 기사·릴스는 서버 fetch가 쿠키의 토큰을 BE에 보내고 likedByMe를 받아 HTML에 그렸습니다. 토큰을 쓰려면 cookies()를 읽어야 하니 동적이 됐고, 억지로 ISR로 만들었대도 한 사람의 하트가 모두에게 보였을 겁니다.

그래서 서버 fetch에서 토큰을 뺐습니다. 지금 서버는 BE를 익명으로만 부르고, 좋아요 여부 같은 건 HTML에 없습니다.

[postId] 같은 동적 세그먼트

/articles/[postId]는 ID마다 다른 페이지입니다. 빌드 때 전부 만들 수 없으니 "어떤 ID를 미리 만들까"를 알려주는 generateStaticParams가 있습니다. 여기에 함정이 하나 있었습니다.

  • 함수가 없으면: Next는 "미리 만들 생각이 없구나"로 보고 동적(ƒ)으로 처리합니다. 쿠키를 안 읽어도 요청마다 렌더합니다.
  • 함수가 빈 배열을 돌려주면: "미리 만들 목록은 없지만 ISR 대상이다"로 봅니다. 기사마다 첫 요청에 만들어 60초 저장합니다.

그래서 기사·릴스 상세에 빈 generateStaticParams를 넣었습니다. 팀처럼 목록이 고정된 라우트는 전부 넣어 빌드 때 만듭니다.

분석 헤더와 ISR

BE에 요청을 보낼 때 "누가 보낸 요청인지"를 알리는 분석 헤더를 같이 보냅니다. 그 값은 proxy.ts가 요청 헤더에 적어 둔 것을 headers()로 읽어야 알 수 있습니다. 그런데 렌더 중에 headers()를 읽으면 그 페이지는 동적이 됩니다.

그래서 익명 조회에는 분석 헤더를 붙이지 않습니다. 익명 조회 결과는 캐시돼 여러 사람에게 재사용되니 "누가 보냈는지"가 의미가 없고, 안 붙이면 headers()도 안 읽어 ISR이 삽니다. 사람이 한 행동(좋아요, 투표)은 토큰이 실린 호출이라 분석 헤더를 붙입니다.

요청 경로

  1. GET /reels가 Next에 닿습니다.
  2. proxy.ts가 토큰 없음을 보고 익명으로 통과시킵니다. 쿠키는 심지 않습니다.
  3. Next는 /reels가 ISR(60초)이라는 걸 빌드 때 이미 알고 있습니다.
  4. 저장된 HTML이 있고 60초 안이면 그대로 줍니다. 지났으면 옛 것을 주고 뒤에서 BE를 익명으로 불러 다시 만듭니다. 없으면 만들어 저장하고 줍니다.
  5. 응답에 Cache-Control: s-maxage=60, stale-while-revalidate=31535940을 붙입니다. CloudFront가 이걸 보고 저장합니다.
  6. GET /me였다면 페이지가 cookies()를 읽으니 동적입니다. 매번 렌더하고 private, no-store를 붙입니다.

3. 브라우저

HTML이 브라우저에 도착했습니다. 이 HTML은 누구에게나 같은 것이라 세 가지가 비어 있습니다.

  • 로그인했는지, 게스트인지 모릅니다.
  • 좋아요 하트가 전부 빈 하트입니다.
  • 내가 투표했는지 표시가 없습니다.

브라우저가 이 셋을 순서대로 채웁니다.

1. 내가 누군지 알아내기

로그인 정보는 accessToken, refreshToken 두 쿠키에 있습니다. 둘 다 HttpOnly입니다. HttpOnly는 "서버로 자동 전송만 되고, 페이지의 JS는 읽을 수 없다"는 옵션입니다. 토큰이 JS에 노출되면 악성 스크립트가 훔칠 수 있어서 이렇게 둡니다.

그러면 JS는 지금 로그인 상태인지 어떻게 알까요. 토큰은 못 읽어도 되는 표식 쿠키 하나를 따로 두었습니다

sessionKind=guest   또는   sessionKind=user

이 쿠키는 HttpOnly가 아니라 JS가 읽을 수 있습니다. 값이 토큰이 아니라 "종류"뿐이라 노출돼도 위험하지 않습니다. AuthProvider가 화면이 그려지기 직전(useLayoutEffect)에 이걸 읽어 "게스트다 / 로그인 유저다 / 아무것도 없다"를 정하고, 그 아래 컴포넌트들이 그 값으로 그립니다.

이 쿠키는 토큰 쿠키를 심는 자리마다 같이 심습니다. 게스트 발급이면 guest, 소셜 로그인이나 계정 연동이면 user, 로그아웃하면 새 게스트로 전환되니 다시 guest, 토큰 재발급 때는 BE 응답에 게스트 마감 시각이 있는지 보고 다시 정합니다. 수명은 refresh 토큰과 같은 14일이라, refresh가 사라지면 표식도 같이 사라집니다.

전에는 서버가 쿠키를 읽어 이 값을 HTML에 넣어 줬습니다. 그게 레이아웃을 동적으로 만든 원인이라 읽는 자리를 브라우저로 옮겼습니다.

2. 처음 온 사람이면 게스트 받기

sessionKind 쿠키가 없으면 처음 온 사람입니다. 화면이 뜬 뒤 브라우저가 한가해지면(약 0.25초 뒤) 이 요청을 보냅니다.

POST /api/session/guest

Next의 이 라우트가 BE에 게스트 계정을 만들어 달라고 하고, 받은 토큰을 쿠키로 심어 돌려줍니다. accessToken, refreshToken(HttpOnly), sessionKind=guest(JS 읽기 가능), 게스트 마감 시각입니다. 응답이 오면 AuthProvider가 sessionKind를 다시 읽어 게스트로 바뀌고, 첫 방문 안내 토스트를 띄웁니다.

페이지 응답이 아니라 따로 받는 이유는 앞서 말한바와 같습니다.

발급이 끝나기 전에 좋아요를 누르면 어떻게 될까요. 누르는 쪽이 ensureSession()을 먼저 부릅니다. 발급 중이면 그 요청이 끝나길 기다렸다가 좋아요를 보냅니다. 세션이 이미 있으면 바로 통과합니다. 하트는 누르는 즉시 차고, 요청만 뒤에서 기다립니다.

3. 내 상태 얹기

이제 사용자가 누군지 알았으니 하트를 채웁니다. 화면에 보이는 기사 ID를 모아 한 번에 묻습니다.

GET /be/api/v1/users/me/article-states?ids=101,102,103,...
→ { "101": { liked: true, voted: "A" }, "102": { liked: false }, ... }

기사마다 따로 묻지 않고 모아서 보내는 건 요청 수 때문입니다. 릴스 첫 화면 열 장이면 열 번 대신 한 번만 요청합니다. 같은 순간에 들어온 질문을 setTimeout(0)으로 아주 잠깐 묶어서 보냅니다.

응답이 올 때까지 하트가 비어 있다가 차면 번쩍입니다. 이걸 줄이려고 "내가 눌렀던 기사 목록"을 localStorage에 적어 두고, 응답 전에는 그 기록으로 먼저 채웁니다. 응답이 오면 진짜 값으로 바꿉니다. 대부분 같아서 번쩍임이 보이지 않습니다. 기록에는 세션 종류를 같이 적어 두고, 종류가 바뀌면(로그아웃 등) 지웁니다.

좋아요를 누르면 서버 응답을 받아 그 기사의 상태만 바로 갱신하고 다시 묻지 않습니다. 다시 묻는 건 두 경우뿐입니다. 뒤로가기로 돌아왔을 때(bfcache 복원), 로그인이나 로그아웃으로 사람이 바뀌었을 때.

방금 받은 게스트는 누른 게 하나도 없으니 묻지 않습니다. 빈 칸을 전부 "안 누름"으로 채우고 끝냅니다.

4. 브라우저가 BE를 부를 때 토큰은 어떻게 실리나

3단계의 요청이나 좋아요 요청은 /be/... 주소로 나갑니다. 이건 BE가 아니라 Next 서버의 주소입니다. 브라우저는 토큰을 못 읽으니 Authorization 헤더를 스스로 만들 수 없습니다. 대신 같은 도메인이라 쿠키는 자동으로 실려 갑니다.

proxy.ts가 /be/* 요청을 받으면 쿠키에서 accessToken을 꺼내 헤더로 바꿔 BE에 넘깁니다.

브라우저 → Next:  GET /be/api/v1/users/me/article-states   Cookie: accessToken=xxx
Next → BE:        GET /api/v1/users/me/article-states       Authorization: Bearer xxx

브라우저는 토큰을 본 적이 없고, BE는 쿠키를 본 적이 없습니다. 중간의 프록시가 둘을 잇습니다.

요청 경로

  1. HTML 도착. 로그인 모름, 하트 전부 빔.
  2. AuthProvider가 sessionKind 쿠키를 읽습니다.
  3. 없으면 POST /api/session/guest로 게스트를 받고, 쿠키가 생기면 다시 읽습니다.
  4. 보이는 기사 ID를 모아 내 상태를 한 번 묻고 하트를 채웁니다. 그 전엔 localStorage 기록으로 임시로 채웁니다.
  5. 이후 좋아요·투표는 /be로 보내고, 프록시가 쿠키를 Bearer로 바꿔 BE에 전달합니다.

4. 안전장치

지금 구조의 전제는 "저장해도 된다고 말한 응답에는 개인 정보가 없다"입니다. 이 전제는 코드를 고치다 깨지기 쉽습니다. 누가 나중에 페이지 응답에 쿠키를 하나 심거나, 토큰 실은 fetch를 캐시에 넣으면 그 순간 남의 것이 남에게 나갑니다. 그런 실수가 사고로 이어지지 않게 두 번째 벽을 둘 두었습니다.

쿠키를 심은 응답은 무조건 저장 금지

proxy.ts 맨 끝에 이 코드가 있습니다.

if (response.cookies.getAll().length > 0) { response.headers.set("Cache-Control", "private, no-store"); }

어느 경로로 왔든 응답에 Set-Cookie가 하나라도 붙어 있으면 Cache-Control을 private, no-store로 덮어씁니다. Next가 s-maxage=60을 붙였어도 여기서 지워집니다. CloudFront도 브라우저도 저장하지 않습니다.

평범한 페이지 응답은 쿠키가 없으니 여기 걸리지 않습니다. 걸리는 건 드문 경우들입니다. 55분마다 한 번 있는 토큰 재발급, 게스트 마감으로 세션을 끝낼 때, 진입 화면 일회용 쿠키를 지울 때. 이 응답들은 그 사람 것이니 저장되면 안 되는 게 맞습니다.

핵심은 쿠키를 심는 코드를 쓰는 사람이 캐시를 신경 안 써도 된다는 점입니다. 심기만 하면 자동으로 저장 금지가 됩니다.

토큰을 실은 fetch는 서버 캐시에 넣지 않는다

apiFetch에 이 줄이 있습니다.

...(authorized ? { cache: "no-store" } : {}),

Authorization 헤더가 있는 호출은 Next 데이터 캐시를 끕니다. 그 캐시가 URL 단위로 공유되기 때문입니다. /api/v1/articles/101을 A가 토큰 실어 불러 likedByMe: true를 받았는데 그 응답이 캐시에 들어가면, 60초 안에 같은 주소를 부른 B도 A의 하트를 받습니다. 호출부가 깜빡해도 토큰만 있으면 자동으로 캐시 밖입니다.

ex막는 것
페이지 응답에 실린 쿠키가 CDN에 저장됨첫 번째
토큰 실은 BE 응답이 서버 캐시에 저장돼 남의 렌더에 쓰임두 번째

둘 다 "이 응답이 특정 사람 것인가"를 코드가 스스로 판단해 저장을 막습니다. 사람이 매번 기억하지 않아도 되게 한 겁니다.

결과

같은 주소를 두 번씩 받아 본 결과입니다.

경로Cache-Controlx-cacheSet-Cookie
/faqs-maxage=31536000Hit없음
/s-maxage=20, stale-while-revalidate=…Hit없음
/reelss-maxage=60, stale-while-revalidate=…Hit없음
/meprivate, no-storeMiss없음

정적·ISR 문서는 엣지에서 나가고, 세션 기반 화면만 원본까지 갑니다. 페이지 응답에는 쿠키가 하나도 없습니다.

감수한 것

  • 투표하고 60초 안에 새로고침하면 내 표 체크는 맞지만 표 수는 저장된 HTML 값이라 하나 적게 보입니다. 다음 재생성 때 맞습니다.
  • 릴스 첫 페이지는 익명 HTML이라 "안 본 릴 우선" 개인화가 빠집니다. 두 번째 페이지부터 붙습니다.
  • 첫 방문의 첫 /be 호출(릴 조회 기록 등)이 게스트 발급보다 먼저 나가면 익명으로 기록됩니다.
  • 첫 방문자가 탭 둘을 거의 동시에 열면 게스트가 둘 생길 수 있습니다. 나중 쿠키가 남고 먼저 생긴 계정은 14일 뒤 정리됩니다.
  • 첫 방문자가 곧장 MY에 들어가면 서버는 세션 없이 그리고, 게스트가 생긴 뒤 router.refresh()로 한 번 다시 받습니다.
  • dev처럼 트래픽이 없으면 stale-while-revalidate가 1년이라 첫 방문자가 며칠 묵은 홈을 받을 수 있습니다.

남은 것

  • MY, 로그인, 라이브 같은 동적 페이지는 여전히 캐시되지 않습니다. 세션을 봐야 그릴 수 있는 화면이라 당장 바꿀 계획은 없습니다.

마치며

세 편에 걸쳐 고친 것을 한 줄로 줄이면 "HTML은 모두에게 같게 만들어 저장하고, 나에 관한 것은 브라우저가 나중에 얹는다"입니다. CloudFront는 쿠키를 키에서 빼고, proxy는 페이지 응답에 쿠키를 심지 않고, Next는 렌더 중 쿠키와 헤더를 읽지 않고, 브라우저는 세션과 내 상태를 스스로 챕니다. 넷 중 하나만 어긋나도 캐시가 꺼지거나 남의 것이 새는 구조라, 어느 층을 고칠 때든 나머지 셋이 전제하는 것을 깨지 않는지 보게 됩니다. image.png