logo

DowanKim

모든 페이지가 Dynamic 인 문제

2026년 9월 28일66
PLick

1. 문제 상황

Ligjthouse 측정

bfcache (back/forward cache)는 브라우저가 떠나는 페이지를 메모리에 통째로 얼려 두는 기능입니다.

DOM, JS 힙, 스크롤 위치, 입력 중이던 폼까지 그대로 보관했다가 뒤로가기나 앞으로가기로 돌아오면 네트워크 요청 없이 그 순간으로 되돌립니다.

다만 응답에 Cache-Control: no-store 가 있거나, unload 리스너가 달려 있거나, 열린 웹소켓이 있으면 들어가지 못합니다.

image.png

예시 사진의 릴스 뿐 아니라, 저희 앱은 전체적으로 문서 자체가 no-store 인 상태입니다.

홈 측정 기준 사유 세가지도 전부 No-store 였습니다. 문서 자체, 메인 리소스, 그리고 JS가 받은 네트워크 응답 중에 no-store가 있다는 것. 세번째는 Next 의 RSC 페이로드와 /be api 응답이 같은 헤더를 달고 오기 때문입니다.

왜 전부 no-store 인거지

응답의 헤더를 보면, 모든 화면이 현재 똑같습니다.

<FAQ 페이지는 단순 고정 설명글 페이지임에도 no-store> image.png

FAQ와 약관은 유저별 데이터가 한 줄도 없는 화면입니다. FAQ 페이지 파일은 공용 FaqBody 컴포넌트와 JSON-LD를 그리는게 전부고, fetch 도 cookies() 도 없습니다. 그럼에도 불구하고 이 응답은 이 사람 전용이니 어디에도 저장하지 말라는 헤더를 갖고 있습니다.

빌드 출력에서도 보입니다. develop을 로컬에서 next build 해보았고, 페이지 라우트는 전부 Dynamic이고 Static은 robots.txt, manifest.webmanifest, icon.png 같은 파일 라우트 뿐입니다.

Route (app)
┌ ƒ /
├ ○ /ads.txt
├ ƒ /articles/[postId]
├ ƒ /debates
├ ƒ /faq
├ ○ /icon.png
├ ƒ /live
├ ƒ /login
├ ○ /manifest.webmanifest
├ ƒ /me
├ ƒ /privacy
├ ƒ /reels
├ ○ /robots.txt
├ ƒ /signup
├ ƒ /teams/[slug]
└ ƒ /terms

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

Next 앱 라우터는, 빌드 때 라우트마다 요청 없이도 그릴 수 있는지를 판단합니다. 렌더 중에 cookies() 나, headers(), searchParams 처럼 요청이 있어야 값이 정해지는 API를 부르면, 그 라우트는 동적이 됩니다. 요청마다 Node 가 React 트리를 새로 돌려 HTML을 만들고, 그 HTML은 요청의 쿠키에 따라 달라질 수 있으니 Next는 no-store를 통해 CDN 이나 브라우저가 다른 사람에게 돌려주지 못하게 막습니다.

모든 페이지가 그렇다는 것은, 루트레이아웃이 영향을 미치고 있을 가능성이 높습니다.

루트레이아웃 코드가 다음과 같았습니다.

// apps/mobile/app/layout.tsx const loggedIn = await isLoggedIn(); // cookies().has(accessToken) const guestExpiresAt = await getGuestExpiresAt(); // cookies().get(guestExpiresAt) const profile = loggedIn ? getProfileSeed() : Promise.resolve(null); const adsMode = resolveAdsMode( ADS_BASE_MODE, (await headers()).get(ANALYTICS_HEADERS.browser), );

레이아웃은 모든 페이지의 부모입니다. 레이아웃이 요청 데잍터를 읽으면, 그 아래 페이지가 아무리 정적이어도 트리 전체가 동적으로 판정됩니다.

즉 FAQ가 동적이었던 이유는 그 레이아웃이 쿠키를 읽기 때문인것입니다.

레이아웃이 쿠키를 읽게 한 것에는 이유가 있긴 했습니다.

access 토큰은 HttpOnly 쿠키라 브라우저 JS가 읽지 못합니다. 좋아요 버튼이나, 댓글 입력창 같은 클라이언트 아일랜드가 지금 로그인 중인지, 게스티인지를 알려면 서버가 판단해서 내려줘야 하고, 그 값을 화면마다 prop으로 내리지 않으려고 (편하려고) 루트에서 한번 AuthProvider에 적용합니다.

뒤로가기에 문제가 생기는 건가요? 아닙니다

bfcache 가 막히면 기사에서 뒤로가기 할 때 홈을 처음 부터 다시 그리는 문제가 생긴다고 오해할 수 있습니다.

그러나 bfcache는 문서 간 이동(cross-document navigation) 에만 관여합니다. 앱 안에서 로 오가는 동안은 애초에 bfache가 끼어들 자리가 없습니다.

Link로 클릭한 페이지는 브라우저가 새 문서를 받는게 아니라 클라이언트 라우터가 pushState로 주소만 바꾸고 RSC 페이로드를 받아 트리를 갈아 끼웁니다. 뒤로가기도 popstate라 같은 문서 안에서 일어납니다. 그리고 NExt 라오터는 뒤로가기와 앞으로가기에는 자기 캐시에 둔 홈 트리를 그대로 되살립니다.(Router Cache). 서버에 홈을 다시 묻지 않고 스크롤 위치도 저장됩니다.

그럼 언제 문제인것인가

문서가 바뀐다는건 사이트 밖으로 나갔다 들어올 때입니다.

원문 보기로 x 게시물에 간다거나, 소셜 로그인으로 카카오로그인 창 같은곳으로 나갔다 오는 케이스입니다.

그치만 사실 데스크톱 브라우저에서는 이것도 문제가 아닙니다. 외부 링크가 전부 target="_blank" 로 해두어서 새 탭에서 열리기 때문입니다.

문제가 생기는 것은 탭이 없는 브라우저 입니다. 인스타그램 인앱 브라우저는 탭이 없어서 같은 뷰에서 엽니다.

(개발자 도구/애플리케이션/백그라운드서비스 / back/forward cache 를 보면 자세히 볼 수 있다) image.png

문제 총 정리

1.인스타그램 인앱 브라우저처럼 탭이 없는 환경에서 원문이나 로그인으로 나갔다 들어오면 페이지를 통째로 다시 받음.

2.FAQ, 약관, 개인정보, 로그인, 회원가입처럼 유저별 데이터가 없는 화면까지 유저별 응답으로 취급됨. 클라우드프론트도 브라우저도 저장하지 못해서 매 요청이 ec2 렌더. 넥스트 서버 과부하

3.그 근본 이유는 루트 레이아웃이 cookies() 와 headers()를 읽는 것.

4.라이트하우스 bf-cache 18/18 실패

2. 해결 방안들

방법 1. 레이아웃을 쪼개서 정적 화면을 떼어내기

방법 2. 부분 프리렌더로 쿠키 읽기를 Suspense 안으로 옮기기

방법 3. 헤더만 낮추기

방법 4. bfcache 포기, 증상만 고침

방법 5. 로그인 확인을 서버에서 브라우저로 옮김

image.png

먼저 저희가 고칠 목표 세가지는 다음과 같습니다

1.문서 밖으로 나갔다 들어올 때 페이지가 통째로 다시 로드되지 않게 한다(bfcache진입)

2.유저별 데이터가 없는 화면은 CDN과 브라우저가 저장할 수 있게 한다

3.bfcache로 복원된 화면은 떠날 때 모습 그대로라, 떠나 있던 사이 바뀐 좋아요 수·로그인 상태·만료된 토큰을 돌아온 뒤에 다시 맞춰 줘야 한다

이 세가지를 동시에 고칠 방법을 클로드와 함께 고민했을 때, 한번에 만족하는 방법은 없고 앞선 방법 4가지로 갈렸습니다.

방법1. 레이아웃을 쪼개서 정적 화면을 떼어내기

루트 레이아웃이 cookies를 읽는게 문제니, 쿠키를 읽는 레이아웃과 안 읽는 레이아웃 둘로 나누는 방법입니다. Next 앱 라우터의 라우트 그룹(괄호 폴더)를 쓰면 URL은 그대로고 레이아웃만 다르게 갈 수 있습니다

app/
  layout.tsx            ← html, body, 폰트, GA만. 쿠키 안 읽음
  (app)/
    layout.tsx          ← isLoggedIn, AuthProvider, 광고 모드 (동적)
    page.tsx            ← 홈
    articles/ reels/ debates/ live/ me/ ...
  (static)/
    layout.tsx          ← AuthProvider를 고정값(비로그인)으로만 감쌈
    faq/ terms/ privacy/

장점은 Next 가 원래 추천하는 방식이라 특별한 설정이 없습니다. static 아래는 빌드 표에서 o로 바뀌고 no store가 사라지며, cdn 도 탈 수 있습니다.

단점은 대상이 좁습니다. 유저별 데이터가 아예 없는 화면은 faq, 약관, 개이정보 셋 분입니다. 로그인과 회원가입은 방금 로그아웃한 게스트인가를 쿠키로 읽어 문구를 바꾸고 있으므로, 동적이어야합니다. 홈 기사 릴스처럼 트래픽이 몰리는 화면은 대상이 아닙니다. 그리고 AppShell 이나 TabBar가 AuthContext를 쓰고 있ㅇ면 정적 레이아웃에서도 provider를 고정값으로 감싸줘야 해서, 그 화면에서 로그인 여부를 읽는 UI가 있는지 하나씩 확인해야 합니다.

방법2. 부분프리렌더로 쿠키 읽기를 Suspense 로 이전

Next16 에는 cacheComponents (이전 이름 PPR) 가 있습니다.

화면을 정적 셸과 동적 구멍으로 나눠서 셸은 빌드 때 만들어 두고, cookies를 읽는 부분만 Suspense 경계안에서 요청 시점에 스트리밍 합니다. 루트 레이아웃의 isLoggedIn을 Suspense 안의 작은 컴포넌트로 옮기면 전 화면의 셸이 정적이 됩니다.

장점은 홈 기사 릴스 까지 전 화면에 적용된다는 겁니다

단점이 클 수 있는게, 첫째, 응답 헤더가 바뀌는지 확실하지 않습니다. 셸이 정적이어도 응답 전체는 동적 구멍 때문에 스트리밍되는 하나의 문서라 넥스트가 여전히 no-store를 달 가능성이 있습니다. 둘째, 앱 전체의 렌더 구조를 바꾸는 일이라 이전에 만든 로딩 컴포넌트와 섹션 SUspense 구조와 겹치는 부분을 다시 봐야 합니다. 사이즈가 커집니다.. 셋째는 아직 실험 플래그라 넥스트가 버전을 올릴 때마다 동작이 바뀔 수 있습니다.

방법 3. 헤더만 낮춤(no-store-> private, no-cache)

동적 렌더는 그대로 두고 응답 헤더만 바꾸는 방법입니다

no-store는 아무데도 저장하지 마라 이고

private, no-cache는 공용 캐시(cdn)에는 저장하지 말고, 브라우저는 저장하되 쓰기 전에 서버에 확인해라 입니다.

bfcache가 보는 건 no-store 유무뿐이라 이것만으로 어느정도 해결은됩니다. 유저별 HTML 이 다른 유저에게 새지도 않습니다.

장점은 변경이 작고 전 화면에 적용이 됩니다.

단점은 확인할게 많습니다.

넥스트는 동적 응답에 no-store를 렌더 파이프라인에서 직접 답니다. 프록시에서 덮어쓴 헤더가 살아남는지, 혹은 넥스트가 다시 덮는지 확인해야 합니다.

또 크롬은 문서 헤더만 보는 게 아닙니다. 페이지가 떠 있는 동안 JS가 fetch로 받아 온 응답 중에 no-store가 하나라도 있으면 그 페이지도 bfcache에 안 넣습니다. 떠난 사이 바뀌면 안 되는 민감한 데이터를 들고 있을 수 있다고 보는 겁니다. 우리 화면이 JS로 받는 응답은 두 종류입니다. Next가 다음 화면을 미리 받아 두는 RSC 페이로드(?_rsc=)와, 좋아요·조회수·피드 같은 /be API 응답입니다. 첫 번째는 Next가 다는 헤더라 1번과 같은 문제고, 두 번째는 Spring 백엔드가 다는 헤더라 BE 저장소에서 고쳐야 합니다. 즉 문서 헤더 하나 바꿔서 끝나는 게 아니라 세 군데를 다 맞춰야 크롬이 열어 줍니다. 사진 2를 그 자리에 둔 이유입니다.

또 문서가 CloudFront를 지나니(x-cache: Miss) private를 CloudFront가 존중해서 저장하지 않는지 확인해야 합니다.

마지막으로 bfcache로 돌아온 화면은 떠날 때 상태 그대로입니다. 그 사이 누른 좋아요, 바뀐 조회수, 만료된 게스트 토큰은 반영이 안 됩니다. pageshow 이벤트의 persisted가 true일 때 TanStack Query를 다시 묻는 코드가 필요하고, 릴스 피드는 캐시를 비우면 서버 씨앗이 다시 심기는 함정(useInfiniteQuery)이 있어 그 경로도 봐야 합니다.

방법4. bfcache 포기, 증상만 개선

재로드를 막는 대신 재로드돼도 티가 안나게 할 수 있긴 합니다. 릴스의 현재 위치와 기사 스크롤 위치를 sessionStorage 에 저장해두고 새 문서가 생겼을때 그 위치를 반영하면 됩니다.

장점은 편하지만, 단점은 비용이 그대로인것입니다.

방법 5. 로그인 확인을 서버에서 브라우저로 옮김

사실 앞선 방법들은 레이아웃이 쿠키를 읽는다는 사실은 그대로 두고 주변에서 우회하는 방식입니다.

레이아웃이 쿠키로 아는건 세션이 있는지, 게스트인지, 게스트 마감이 언제인지 세가지이고 헤더로 아는건 앱 셸인지 하나입니다. 전부 비밀이 아닙니다.

토큰은 지금처럼 httponly로 두되, 프록시가 토큰을 심을 때 plick_session=guest 또는 user 처럼 JS가 읽을 수 있는 표식 쿠키를 옆에 하나 더 심습니다. 그러면 AuthProvider 가 서버에서 값을 받지 않고 브라우저에서 그 쿠키를 읽어 스스로 초기화할 수 있습니다.

이렇게 되면 faq,약관, 개인정보, 로그인, 회원가입은 폴더를 옮기지 않아도 자동으로 정적이 됩니다. 인물, 선수, 팀 프로필처럼 유저별 값이 없는 데이터 화면ㅇ은 isr로 돌릴 수 있습니다.

다만 홈 , 기사, 릴스, 투표는 아직 풀리지 않습니다. 레이아웃이 아니라 페이지 자체가 토큰을 실어 likeByMe 가 섞인 목록을 받기 때문입니다. 해결하려면 be가 익명 목록과 내 상태를 따로 줘야 하고, 일이 커집니다.

또한 대가가 있습니다. 브라우저가 쿠키를 읽기전 순간입니다. 로그인 여부는 head 동기 스크립트에서 쿠키를 읽어 첫 페인트 전에 확정할 수 있어 티가 나지 않습니다. 그 외 useAuth를 쓰는 댓글 작성 목록, 로그인 유도, 좋아요, 투표, 채팅 이 서버 시드 대신 클라이언트 초기값을 받게 되는 문제가 있습니다.

선택

5번 방법을 선택하고자 합니다. 레이아웃이 cookies를 쓰게 만든 구조가 잘못되었다는걸 뒤늦게 알았지만, 지금이라도 고쳐야 합니다.

페이지는 누구에게나 같게 만들어 캐시하고, 그 사람만의 정보는 브라우저가 나중에 덧붙입니다. 유튜브 좋아요, 네이버 뉴스 공감, 쿠팡 찜이 전부 이런 방식으로 동작합니다. 사용자 한명의 하트 한두개를 채워 보내려고 모든 사람에게 다른 페이지를 만드는 지금 구조가 오히려 드뭅니다.

시스템을 고치는 일과 함께 백엔드도 고쳐야 해서 사이즈가 커지긴 하지만 그래도 지금 고치는게 나을 것 같습니다.

이후 계획

image.png

티켓을 다음과 같이 잡았습니다. 각 티켓을 만들고, 백엔드와 회의하고 반영하기로 했습니다.

image.png

구현

레이아웃에서 cookies 만 빼면 되는게 아니라,

서버에서 BE를 부르는 apiFetch가 분석헤더를 BE에 옮겨 싣느라 headers를 부르고 있었고, 레이아웃을 고쳐도 서버에서 데이터를 받는 페이지(홈 같은) 는 동적으로 남게 됩니다.

생각해 보면 익명 get은 어차피 넥스트 캐시로 모든 사용자가 한 응답을 나눠 쓰고, 첫 요청자의 헤더가 캐시에 박히기 때문에 사람별 헤더를 실을 의미가 없습니다. 그래서 익명 get에는 헤더 제공자를 안 부르도록 변경했습니다.

1단계 : 표식 쿠키

sessionKind =guest|user 를 같이 심습니다.

첫 진입 게스트 발급, 토큰 재발급, 소셜 로그인 , 연동 , 로그아웃 다섯 자리에서 사용됩니다.

2단계 AuthProvider 가 브라우저에서 스스로 시작

서버 prop을 전부 제거합니다.

서버 HTML은 세션을 모르는 상태로 그리고, useLayoutEffect에서 쿠키륵읽습니다.

const [sessionKind, setSessionKind] = useState(undefined); // 모름 useLayoutEffect(() => { setSessionKind(readSessionKind()); // 쿠키를 읽어 올린다 }, [pathname]);

서버가 쿠키를 안 읽으니 서버 HTML은 "모름"이어야 하고, 하이드레이션 첫 패스도 같아야 불일치가 안 납니다. 레이아웃 이펙트는 브라우저가 첫 프레임을 그리기 전에 돌고 그 안의 setState는 동기 재렌더라, 사용자는 "모름"을 한 프레임도 보지 않습니다.

처음엔 동기 스크립트로 에 값을 박아 둘 생각이었는데 쓸모가 없었습니다. 하이드레이션 첫 패스는 어차피 서버 스냅샷으로 그려야 하니까요. pathname이 바뀔 때마다 다시 읽어 로그인·로그아웃의 redirect 뒤를 따라가고, bfcache 복원(pageshow)에도 다시 읽습니다.

닉네임은 /users/me를 브라우저에서 TanStack Query로 받습니다. 이건 원래도 뒤에 채워지던 값이라 달라지는 게 없습니다.

useAuth를 쓰는 7곳 중 렌더 시점에 로그인 여부로 모양이 갈리는 건 라이브 채팅 패널 하나였습니다. 나머지는 클릭할 때만 봅니다.

3단계 레이아웃 정리

isLoggedIn(), getGuestExpiresAt(), headers(), getProfileSeed() 네 호출을 뺐습니다. RootLayout이 async도 아니게 됐습니다.

4단계 빌드 결과

Route (app)                   Revalidate
┌ ○ /                                20s      ← 홈이 ISR
├ ƒ /articles/[postId]
├ ○ /faq
├ ● /figures/[figureId]
├ ● /players/[playerId]
├ ○ /privacy
├ ƒ /reels
├ ○ /signup
├ ● /teams/[slug]                    20s
├ ● /teams/[slug]/profile             1m
└ ○ /terms

5단계 standalone 확인

빌드 산출물을 로컬에서 그대로 띄어 라우트마다 두번씩 실행해봤고, 첫 실행에 에러로그가 발생했습니다

Error: Page changed from static to dynamic at runtime /teams/arsenal/profile, reason: headers

헤더 제공자 건너뛰기 조건을 기본 60초 캐시를 붙일 대상으로만 잡아서, revalidate : 20 처럼 캐시 수명을 직접 준 fetch는 여전히 제공자를 부르고, 그안의 headers가 ISR 재 생성 중 에러가 발생한 것입니다.

조건을 익명 get이고 no-store, revalidate:0 이 아니면 으로 넓혔습니다

image.png

정적 응답에도 프록시의 게스트 set-cookie가 실려 나갑니다. cdn 이 이걸 캐시하면 토큰이 남에게 갈 수 있어 확인했고, cloudfront 기본 비헤이비어가 CachingDisabled라 cdn에 안남았습니다. 추후 수정 예정.

이후 백엔드가 수정되고, 남은 티켓을 처리하고자 합니다.

이후 글에서 기사,릴스,투표 화면을 익명 조회 + 내 상태 오버레이 조합으로 변경해 ISR로 변경하고

게스트 발급도 페이지 응답에서 떼어 cloudfront에 정적, isr 문서가 캐싱되게 하고자 합니다.