Next.js Link Prefetch 무한 요청을 내부 캐시까지 추적해 해결한 과정

2026.09.15
대표 이미지

Next.js App Router 기반 블로그를 개발하던 중 이상한 현상을 발견했다.


`<Link>`가 화면에 나타나거나 `router.prefetch()`가 실행되면 prefetch 요청이 발생하는 것 자체는 정상이다.


그런데 production 환경에서 특정 페이지에 대한 prefetch 요청이 **끝없이 반복되고 있었다.**


처음에는 내가 작성한 코드의 문제라고 생각했다.


하지만 문제를 추적하다 보니 결국 Next.js 내부의 Prefetch Scheduler, Route Cache, Segment Cache까지 내려가게 되었고, 최종적으로는 **Head metadata를 읽을 때와 저장할 때 서로 다른 FetchStrategy를 사용하는 문제**까지 찾아냈다.


그리고 더 재미있는 점은, 원인을 다 찾은 뒤 Next.js GitHub를 확인해 보니 내가 찾은 것과 사실상 동일한 원인의 버그가 이미 upstream에서 수정된 상태였다.


현재 프로젝트에서 사용하고 있던 버전은 다음과 같았다.


npm ls next


git-blog@0.1.0
├─┬ next-auth@5.0.0-beta.32
│ └── next@16.3.3 deduped
└── next@16.3.3


즉 문제를 재현한 환경은 **Next.js 16.3.3**이었다.


이 글에서는 단순히 패치 방법만 적기보다, 디버깅하면서 내가 이해하기 어려웠던 Next.js 내부 개념부터 실제 원인 추적 과정까지 같이 정리하려고 한다.




문제 상황


개발 환경에서는 문제가 잘 보이지 않았다.


Next.js의 Link viewport prefetch는 development 환경에서는 production과 동작이 다르기 때문이다.


그래서 실제 확인은 production build로 진행했다.


npm run build
npm run start


그리고 브라우저 Network 탭을 확인하면 동일한 RSC 관련 요청이 계속 발생했다.


정상적인 prefetch라면 한 번 데이터를 받아 캐시에 기록하고 종료되어야 한다.


하지만 실제로는:


prefetch
→ 응답
→ prefetch
→ 응답
→ prefetch
→ 응답
→ ...


가 끝없이 반복됐다.


처음에는 다음과 같은 가능성을 생각했다.


* Link가 계속 mount/unmount 되는가?

* IntersectionObserver가 반복 호출되는가?

* Route Cache가 계속 만료되는가?

* Prefetch task가 잘못 reschedule 되는가?

* Segment Cache가 응답을 저장하지 못하는가?


결론부터 말하면 마지막에 가까웠다.


그 전에 Next.js 내부에서 나오는 용어들을 먼저 이해해야 했다.




먼저 이해해야 했던 Next.js 내부 개념


Router와 Route


처음에는 `Router`와 `Route`부터 헷갈렸다.


둘은 비슷해 보이지만 의미가 다르다.


Router는 **이동을 관리하는 시스템**이다.


예를 들어 현재 URL이:


/posts/10


이고 사용자가:


/posts/20


으로 이동하려 한다고 하자.


Router는 단순히 URL 문자열만 바꾸는 것이 아니다.


현재 화면
↓
목표 화면
↓
둘이 공유하는 Layout
↓
새로 필요한 Segment
↓
미리 받아둘 수 있는 데이터


를 판단한다.


반면 Route는 특정 URL에 대응하는 **페이지 구조 자체**다.


Router
= 이동을 관리하는 주체

Route
= 이동 대상이 되는 경로 구조


정도로 생각하면 된다.




Route는 왜 Tree인가


App Router에서 하나의 페이지는 하나의 파일만으로 구성되지 않는다.


예를 들어:


app/
├── layout.tsx
└── blog/
    ├── layout.tsx
    └── [id]/
        └── page.tsx


에서 `/blog/10`에 접근하면 실제 화면은:


Root Layout
└── Blog Layout
    └── Blog Page


처럼 여러 계층이 조합되어 만들어진다.


그래서 Next.js 내부에서는 Route를 하나의 Tree 형태로 표현한다.


Route Tree

Root Layout
└── Blog Layout
    └── Page


코드에서 계속 등장했던 `route.tree`, `RouteTree`가 이것이다.




Segment란 무엇인가


Route가 전체 페이지 구조라면 Segment는 그 구조를 이루는 각 조각이다.


Route
= 전체 Tree

Segment
= Tree의 각 Node


예를 들어:


Root Layout
Blog Layout
Blog Page


각각이 하나의 Segment라고 볼 수 있다.


이 구분은 Next.js가 부분 navigation을 수행하는 방식과 직접 연결된다.




왜 Segment 단위로 캐시하는가


현재 페이지가:


/blog/10


이고 다음 페이지가:


/blog/20


이라면 대부분의 구조는 동일하다.


Root Layout   → 동일
Blog Layout   → 동일
Blog Page     → 변경


Root Layout과 Blog Layout까지 다시 서버에서 받을 필요가 없다.


그래서 Next.js는 페이지 전체를 하나의 데이터로만 취급하지 않고 Segment 단위로 관리한다.


Segment Cache

Root Layout     cached
Blog Layout     cached
Blog Page 10    cached
Blog Page 20    missing


그러면 `/blog/20`으로 이동할 때 필요한 부분만 새로 받을 수 있다.




Route Cache와 Segment Cache


이번 디버깅에서 매우 중요했던 구분이다.


Route Cache는 대략:


이 URL은 어떤 Segment 구조로 이루어져 있는가?


를 저장한다.


예를 들어:


/blog/10

Root Layout
→ Blog Layout
→ Blog Page


같은 구조 정보다.


반면 Segment Cache는:


실제 각 Segment가 렌더링할 데이터는 무엇인가?


를 저장한다.


즉 단순화하면:


Route Cache
= 어떤 조각들이 필요한지

Segment Cache
= 각 조각의 실제 내용


이다.


이번 문제에서는 Route Cache가 정상적으로 `Fulfilled` 상태였다.


따라서 Route Tree를 계속 다시 받는 문제가 아니었다.


문제는 Segment 쪽이었다.




Cache Entry의 상태


Next.js 내부 Cache Entry는 다음과 같은 상태를 가진다.


Empty      = 0
Pending    = 1
Fulfilled  = 2
Rejected   = 3


정상 흐름은:


Empty
→ Pending
→ Fulfilled


이다.


아직 데이터가 없으면 `Empty`.


요청을 시작하면 `Pending`.


응답을 정상적으로 저장하면 `Fulfilled`.


그러면 다음 lookup에서는 네트워크 요청을 할 필요가 없다.


이번 버그에서는 이상하게도:


Empty
→ Pending
→ 응답
→ 다시 Empty
→ Pending
→ 응답
→ 다시 Empty


가 반복되고 있었다.


즉 응답은 오고 있는데 다음번 cache lookup에서는 저장된 값을 찾지 못하고 있었다.




Metadata와 Head


Next.js 페이지에는 화면 본문뿐 아니라 브라우저 `<head>`에 들어가는 정보도 있다.


예를 들어:


<title>My Blog</title>
<meta name="description" content="..." />


App Router에서는 다음처럼 정의할 수 있다.


export const metadata = {
  title: 'My Blog',
}


또는:


export async function generateMetadata() {
  return {
    title: 'My Blog',
  }
}


이런 `title`, `description`, `openGraph` 등의 선언 정보가 Metadata다.


그리고 Next.js가 이를 처리해 실제 `<head>`에 넣을 결과를 만든다.


대략:


Metadata
↓
Next.js 처리
↓
Head 데이터
↓
<title>, <meta> ...


로 생각할 수 있다.




Head는 일반 Segment와 조금 다르다


일반적인 Layout이나 Page는 Route Tree 내부에 존재한다.


하지만 Head는 일반적인 Route Segment와는 조금 다른 특수한 데이터다.


Next.js 내부에서도 Head를:


진짜 Route Segment는 아니지만 Segment처럼 Cache에 저장한다.


는 방식으로 다룬다.


그래서 코드에도 별도의:


pingRuntimeHead
route.metadata
HEAD_REQUEST_KEY


같은 처리가 존재한다.


그리고 바로 이 특수 처리가 이번 버그의 핵심이었다.




Head와 HTTP Header는 다르다


이름 때문에 처음 보면 헷갈릴 수 있다.


`Head`는 HTML의:


<head>


와 관련된 페이지 데이터다.


반면:


headers[NEXT_ROUTER_PREFETCH_HEADER] = '1'


에서 Header는 HTTP Header다.


즉 서버에게:


이 요청이 prefetch인가?
어떤 Router State를 가지고 있는가?
어떤 종류의 prefetch인가?


등을 전달하기 위한 네트워크 정보다.


Head
= 페이지의 <head>

HTTP Header
= HTTP 요청/응답 제어 정보


둘은 완전히 다른 개념이다.




Prefetch Task와 Scheduler


Next.js는 prefetch를 단순한 `fetch()` 한 번으로 처리하지 않는다.


각 prefetch를 Task 단위로 만들어 Scheduler가 관리한다.


대략 다음과 같은 상태를 가진다.


{
  key,
  treeAtTimeOfPrefetch,
  routeCacheVersion,
  segmentCacheVersion,
  priority,
  phase,
  hasBackgroundWork,
  hasPendingResponses,
  fetchStrategy,
}


Scheduler는 이 Task를 실행하면서:


네트워크 여유가 있는가?
↓
Route 정보가 준비됐는가?
↓
필요한 Segment가 있는가?
↓
Pending 응답이 있는가?
↓
다음 phase로 진행 가능한가?


를 판단한다.


Pending Segment가 있다면 Task를 queue에서 잠시 제거했다가, 응답이 도착하면 다시 Scheduler에 넣는다.


Task 실행
↓
Pending 발견
↓
Block
↓
응답 도착
↓
Task 재실행


이 구조 자체는 정상이다.


이번 문제는 Task가 다시 실행될 때마다 **똑같은 데이터를 또 Missing으로 판단한다는 것**이었다.




FetchStrategy


Next.js는 prefetch의 목적에 따라 서로 다른 전략을 사용한다.


내가 확인한 코드에서는 다음과 같았다.


LoadingBoundary = 0
StaticShell     = 1
RuntimeShell    = 2
PPR             = 3
PPRRuntime      = 4
Full            = 5


이번 문제에서 중요한 것은 `LoadingBoundary`와 `Full`이다.


`LoadingBoundary`는 비교적 보수적인 prefetch다.


필요한 Loading Boundary까지만
미리 데이터를 받아둔다.


반면 `Full`은 실제 Navigation에서 필요할 데이터를 최대한 완전하게 미리 받으려는 전략이다.


LoadingBoundary
= 필요한 경계까지만

Full
= 최대한 완전하게


정도로 이해할 수 있다.




varyPath


이번 디버깅에서 가장 중요한 개념이었다.


처음에는 Cache Key가 단순히 URL일 거라고 생각했다.


하지만 Next.js는 Segment를 여러 Route에서 재사용해야 한다.


예를 들어:


/search?q=apple
/search?q=banana


가 있다고 하자.


Root Layout은 `q`와 아무 관계가 없을 수 있다.


그러면 두 URL이 같은 Root Layout Cache를 공유해도 된다.


반면 Page나 Metadata가 `q`에 의존한다면 서로 다른 값을 캐시해야 한다.


Next.js는 이런 차이를 표현하기 위해 `varyPath`를 사용한다.


쉽게 말하면:


이 Segment 데이터를 Segment Cache의 어느 위치에서 찾고 저장할 것인가?


를 표현하는 Key 구조다.


특히 FetchStrategy에 따라 동일 Segment라도 다른 varyPath를 사용할 수 있다.


이번에 실제 로그에서도:


LoadingBoundary varyPath
!=
Full varyPath


가 확인됐다.


이 사실이 Root Cause를 찾는 핵심이었다.




RSC와 Flight


Next.js App Router 내부 코드를 보면 다음 용어들도 많이 나온다.


RSC
Flight
FlightRouterState
NavigationFlightResponse


RSC는 React Server Components다.


App Router는 페이지 이동 때마다 완성된 HTML 문서 전체를 새로 받는 방식만 사용하는 것이 아니다.


서버에서 필요한 Server Component 결과를 만들고 React가 이해할 수 있는 형태로 클라이언트에 보낸다.


이 데이터 전송 방식을 흔히 React Flight라고 부른다.


대략:


Server Component
↓
서버에서 실행
↓
Flight 데이터 생성
↓
클라이언트 Router가 수신
↓
기존 React Tree와 병합


이라는 흐름이다.


그래서 Segment Cache가 중요하다.


이미 가지고 있는 Layout까지 다시 받을 필요 없이 필요한 Segment만 업데이트할 수 있기 때문이다.




Prefetch 내부 흐름 추적


개념을 어느 정도 이해한 뒤 실제 코드를 추적했다.


Link에서 Scheduler까지


대략적인 흐름은 다음과 같았다.


<Link>
↓
mountLinkInstance
↓
IntersectionObserver
↓
onLinkVisibilityChanged
↓
rescheduleLinkPrefetch
↓
schedulePrefetchTask
↓
Prefetch Scheduler
↓
pingRoute


`<Link>`가 화면에 들어오면 Prefetch Task가 생성된다.


그리고 Scheduler가 이를 처리한다.




Scheduler 상태 확인


`pingRoute()`가 반환된 직후 로그를 넣었다.


console.log('[after pingRoute]', {
  pathname: task.key.pathname,
  phase: task.phase,
  exitStatus,
  hasBackgroundWork: task.hasBackgroundWork,
  hasPendingResponses: task.hasPendingResponses,
});


무한 요청 상태에서는 계속:


phase: 0
exitStatus: 1
hasBackgroundWork: false
hasPendingResponses: true


가 나왔다.


`exitStatus: 1`은 Task가 어떤 Pending 응답을 기다리고 있다는 의미였다.


즉 Scheduler 자체가 아무 이유 없이 loop를 돌고 있는 것은 아니었다.


어떤 Segment를 기다림
↓
응답 도착
↓
Task 재실행
↓
또 Pending 발생


이 계속 반복되고 있었다.




Route Cache는 문제가 아니었다


처음에는 Cache expiration을 의심했다.


Next.js 내부에서 Cache가 만료되는 조건은 대략 다음과 같았다.


value.staleAt <= now ||
value.version < currentCacheVersion


그래서 로그를 추가했다.


결과는:


expiredByTime: false
expiredByVersion: false


였다.


Route Cache status도 계속:


Fulfilled


였다.


즉:


Route 구조는 정상적으로 캐시되고 있었다.


따라서 문제 범위를 Segment Cache 쪽으로 좁혔다.




계속 Pending 되는 Segment 확인


`blockTaskOnPendingResponse()`를 확인했다.


console.log('[BLOCK]', {
  pathname: task.key.pathname,
  status: segment.status,
  fetchStrategy: segment.fetchStrategy,
  isPartial: segment.isPartial,
  staleAt: segment.staleAt,
});


반복되는 Segment의 상태는:


status: 1
fetchStrategy: 5
isPartial: false


였다.


즉:


Pending
+
Full


인 Segment가 계속 생성되고 있었다.


그런데 전체 Task의 FetchStrategy는:


0


즉 `LoadingBoundary`였다.


여기서 의문이 생겼다.


왜 LoadingBoundary Task 안에서 Full Segment가 계속 만들어지는가?




Head에서 전략이 바뀌고 있었다


LoadingBoundary 처리 경로를 따라가다 다음 부분을 확인했다.


const spawnedEntries = new Map();

pingRuntimeHead(
  now,
  task,
  route,
  spawnedEntries,
  fetchStrategy
);

const dynamicRequestTree =
  diffRouteTreeAgainstCurrent(...);


그래서 `spawnedEntries`가 어디서 추가되는지 확인했다.


pingRuntimeHead(...);

console.log(
  '[AFTER HEAD]',
  spawnedEntries.size
);

diffRouteTreeAgainstCurrent(...);

console.log(
  '[AFTER DIFF]',
  spawnedEntries.size
);


결과:


AFTER HEAD 1
AFTER DIFF 1


이 반복됐다.


즉 일반 Page나 Layout Segment를 처리하기도 전에 `pingRuntimeHead()`가 이미 요청해야 할 Entry 하나를 추가하고 있었다.


문제는 Head였다.




왜 Head는 Full이 되는가


`pingRuntimeHead()`를 보면 이유가 나온다.


개념적으로 다음과 같다.


function pingRuntimeHead(
  now,
  task,
  route,
  spawnedEntries,
  fetchStrategy
) {
  pingRouteTreeAndIncludeDynamicData(
    now,
    task,
    route,
    route.metadata,
    false,
    spawnedEntries,
    fetchStrategy === FetchStrategy.LoadingBoundary
      ? FetchStrategy.Full
      : fetchStrategy
  );
}


즉 전체 Task는 LoadingBoundary더라도 Head만큼은:


LoadingBoundary(0)
→ Full(5)


로 처리한다.


Head에는 Loading Boundary가 존재하지 않기 때문이다.


그래서 로그에서 계속 `Full(5)` Segment가 보였던 것이었다.




Head Cache 상태


Head Segment를 읽은 직후 상태를 찍었다.


반복적으로:


status: 0
isPartial: true
fetchStrategy: 3


같은 Empty Entry가 보였다.


그리고 요청을 시작하면:


status: 1
isPartial: false
fetchStrategy: 5


가 됐다.


즉 실제 흐름은:


Head lookup
↓
Empty
↓
Full Pending 생성
↓
서버 요청
↓
응답
↓
Task 재실행
↓
Head lookup
↓
또 Empty


였다.


정상이라면 응답 이후에는 `Fulfilled`가 보여야 한다.


하지만 그렇지 않았다.




응답이 다른 Entry에 기록되는 것은 아니었다


다음으로 의심한 것은 서버 응답이 원래 만들어 두었던 Pending Entry가 아닌 다른 Entry에 기록되는 경우였다.


`fulfillEntrySpawnedByRuntimePrefetch()`에서:


const ownedEntry =
  entriesOwnedByCurrentTask !== null
    ? entriesOwnedByCurrentTask.get(tree.requestKey)
    : undefined;


를 확인했다.


로그:


owned: true
ownedStatus: 1


즉 응답을 처리할 때 **자신이 만든 Pending Entry를 정확히 찾고 있었다.**


그렇다면 문제는 응답을 저장한 뒤였다.




varyPath 비교


다음으로 다음 값들을 비교했다.


LoadingBoundary 기준 varyPath
Full 기준 varyPath
tree.varyPath
응답 저장 시 canonicalVaryPath


결과:


loadingEqFull: false
loadingEqTree: false
fullEqTree: true
canonicalEqTree: false


여기서 거의 답이 나왔다.


Full이 읽는 위치
= tree.varyPath

LoadingBoundary가 읽는 위치
!= tree.varyPath

응답이 저장되는 canonicalVaryPath
!= tree.varyPath


즉 **읽는 위치와 저장되는 위치가 달랐다.**




Root Cause


전체 흐름을 다시 보면 이렇다.


Head 요청


전체 Prefetch Task는:


LoadingBoundary(0)


다.


하지만 `pingRuntimeHead()`에서:


LoadingBoundary(0)
→ Full(5)


로 바꾼다.


따라서 Head는 Full 기준의 `tree.varyPath`에서 Cache를 읽는다.


없으면 그 위치에 Pending Entry를 만든다.


Full varyPath
└── Head Pending


Head 응답


서버 응답이 도착한다.


그런데 응답을 Cache에 쓰는 코드에는 Head에서 변환한 `Full(5)`이 아니라 **바깥 Dynamic Request의 원래 FetchStrategy인 `LoadingBoundary(0)`**가 전달된다.


그러면 Response Write 과정에서 LoadingBoundary 기준의 Canonical Vary Path를 계산한다.


즉:


Full varyPath
└── Pending

응답 처리

LoadingBoundary varyPath
└── Fulfilled


같은 상태가 된다.


Scheduler 재실행


Pending 응답이 해결됐으니 Scheduler가 Task를 다시 실행한다.


Head는 다시 Full 전략으로 읽는다.


Full varyPath


를 확인한다.


그런데 Fulfilled Entry는 다른 위치로 이동해 있다.


그래서 다시:


Cache Miss
→ Empty
→ Pending
→ Request


가 된다.


결국:


Full로 조회
→ 없음
→ 요청

LoadingBoundary 기준으로 저장

Full로 다시 조회
→ 없음
→ 요청

LoadingBoundary 기준으로 저장

...


이 무한히 반복됐다.




처음 만든 수정


Root Cause를 찾은 직후에는 `fulfillEntrySpawnedByRuntimePrefetch()`에서 실제 Pending Entry가 가지고 있는 FetchStrategy를 사용하도록 수정했다.


const effectiveFetchStrategy =
  ownedEntry !== undefined
    ? ownedEntry.fetchStrategy
    : fetchStrategy;


Head의 Pending Entry는 이미 Full 전략으로 생성되었기 때문에:


ownedEntry.fetchStrategy
= Full(5)


이었다.


따라서 이를 Response Write에 사용하면:


Full로 생성
→ Full로 Fulfill
→ 같은 Cache 위치 유지
→ 다음 Lookup에서 Hit


가 된다.


실제로 이 수정만으로 무한 Prefetch 요청이 멈췄다.


이 시점에서 원인은 사실상 확정됐다.




Next.js GitHub에서 동일한 Bug Fix 발견


원인을 찾은 뒤 Next.js GitHub를 확인했다.


그러자 실제로 거의 동일한 문제를 수정하는 PR이 존재했다.


PR #97729
Fix metadata prefetch cache key for search params


PR의 설명 역시 내가 추적한 것과 동일했다.


핵심 내용은:


pingRuntimeHead는
LoadingBoundary Head를 Full 전략으로 생성한다.

하지만 Response Write에서는
raw LoadingBoundary semantics를 사용한다.

그 결과 Metadata Cache Key가 달라진다.


였다.


즉 독립적으로 추적해서 찾은 원인과 Next.js upstream에서 확인한 Root Cause가 동일했다.


다만 해당 수정은 당시 내가 사용 중이던 **Next.js 16.3.3에는 포함되어 있지 않았다.**


그래서 Canary 전체를 올리는 대신 공식 Fix만 현재 버전에 Backport하기로 했다.




Next.js 공식 수정 방식


내가 처음 만든 `ownedEntry.fetchStrategy` 기반 패치도 문제를 해결했지만, 공식 수정은 더 제한적인 범위에서 처리한다.


Head를 Cache에 기록할 때만:


LoadingBoundary
→ Full


로 정규화한다.


개념적으로:


const headFetchStrategy =
  fetchStrategy === FetchStrategy.LoadingBoundary
    ? FetchStrategy.Full
    : fetchStrategy;


그리고 Head Response를 저장할 때만 `headFetchStrategy`를 사용한다.


일반 Page/Layout Segment의 FetchStrategy는 건드리지 않는다.


이쪽이 영향 범위가 더 작기 때문에 공식 방식으로 Backport하기로 했다.




Next.js 16.3.3에 공식 Fix Backport


수정 파일


npm으로 설치한 Next.js 기준:


node_modules/next/dist/client/components/segment-cache/cache.js


파일 안에서 `isHeadPartial`을 찾는다.


16.3.3에서는 다음과 비슷한 코드가 있다.


const isHeadPartial =
  process.env.__NEXT_CACHE_COMPONENTS
    ? isResponsePartial
    : flightDataEntry.isHeadPartial;


그 아래에는 Head 데이터를 저장하는 코드가 있다.


fulfillEntrySpawnedByRuntimePrefetch(
  now,
  map,
  fetchStrategy,
  head,
  isHeadPartial,
  staleAt,
  headVaryParams,
  metadataTree,
  spawnedEntries
);


여기에 다음 변수를 추가한다.


const headFetchStrategy =
  fetchStrategy ===
  _types.FetchStrategy.LoadingBoundary
    ? _types.FetchStrategy.Full
    : fetchStrategy;


그리고:


fetchStrategy


대신:


headFetchStrategy


를 전달한다.


최종 형태는:


const isHeadPartial =
  process.env.__NEXT_CACHE_COMPONENTS
    ? isResponsePartial
    : flightDataEntry.isHeadPartial;

const headFetchStrategy =
  fetchStrategy ===
  _types.FetchStrategy.LoadingBoundary
    ? _types.FetchStrategy.Full
    : fetchStrategy;

fulfillEntrySpawnedByRuntimePrefetch(
  now,
  map,
  headFetchStrategy,
  head,
  isHeadPartial,
  staleAt,
  headVaryParams,
  metadataTree,
  spawnedEntries
);


가 된다.




수정 검증


수정 후 다시 Production Build를 진행했다.


npm run build
npm run start


그리고 Network 탭에서 확인했다.


기존에는:


prefetch
prefetch
prefetch
prefetch
...


가 끝없이 반복됐지만, 수정 후 정상적으로 한 번 수행된 뒤 종료됐다.


즉 Next.js 16.3.3에도 공식 수정 방식을 Backport했을 때 문제가 해결됐다.




그런데 node_modules를 직접 고칠 수는 없다


로컬에서는 `node_modules`를 직접 수정하면 동작한다.


하지만 Vercel에 배포하면 의미가 없다.


배포 환경에서는 Dependency를 다시 설치하기 때문이다.


Git Repository
↓
Vercel Build
↓
npm install / npm ci
↓
새 node_modules 생성


따라서 로컬의 `node_modules` 수정본은 사라진다.


이를 해결하기 위해 `patch-package`를 사용했다.




patch-package로 수정 유지하기


patch-package 설치


npm install -D patch-package


`-D`는:


--save-dev


의 축약형이다.


즉 `package.json`의 `devDependencies`에 추가된다.


"devDependencies": {
  "patch-package": "^8.0.1"
}




Patch 생성


현재 `node_modules/next`에는 수정된 코드가 존재한다.


이 상태에서:


npx patch-package next


를 실행한다.


`patch-package`는:


원본 next@16.3.3
vs
현재 수정된 node_modules/next


를 비교한다.


그리고 차이만 Patch 파일로 만든다.


결과:


patches/next+16.3.3.patch


실제로 생성된 Patch는 다음과 같았다.


diff --git a/node_modules/next/dist/client/components/segment-cache/cache.js b/node_modules/next/dist/client/components/segment-cache/cache.js
index 4152071..d2653b9 100644
--- a/node_modules/next/dist/client/components/segment-cache/cache.js
+++ b/node_modules/next/dist/client/components/segment-cache/cache.js
@@ -2300,9 +2300,13 @@ map) {
             //
             // Without Cache Components, the server sends the correct isHeadPartial.
             const isHeadPartial = process.env.__NEXT_CACHE_COMPONENTS ? isResponsePartial : flightDataEntry.isHeadPartial;
+            const headFetchStrategy =
+                fetchStrategy === _types.FetchStrategy.LoadingBoundary
+                    ? _types.FetchStrategy.Full
+                    : fetchStrategy;
             fulfillEntrySpawnedByRuntimePrefetch(now, // A response write is bound to the map its entries live in (the
             // spawning task's `PrefetchTask.segmentCacheMap`).
-            map, fetchStrategy, head, isHeadPartial, staleAt, // For head entries, use the head-specific vary params passed as
+            map, headFetchStrategy, head, isHeadPartial, staleAt, // For head entries, use the head-specific vary params passed as
             // parameter.
             headVaryParams, metadataTree, spawnedEntries);
         }


디버깅 과정에서 추가했던 `console.log`는 모두 제거한 뒤 Patch를 생성했기 때문에 실제 수정만 남았다.




postinstall 설정


Dependency가 다시 설치될 때마다 Patch도 다시 적용되어야 한다.


이를 위해 `package.json`의 `scripts` 안에 다음을 추가한다.


"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "test": "vitest",
  "prepare": "husky",
  "postinstall": "patch-package"
}


여기서 중요한 점이 있다.


처음에는 실수로:


{
  "scripts": {
    ...
  },

  "postinstall": "patch-package"
}


처럼 `postinstall`을 `scripts` 밖에 넣었다.


이렇게 하면 npm lifecycle script로 동작하지 않는다.


Vercel에서도 Patch가 적용되지 않았고, 배포 버전에서는 동일한 무한 요청이 다시 발생했다.


반드시:


"scripts": {
  "postinstall": "patch-package"
}


처럼 `scripts` 내부에 들어가야 한다.




Vercel에서 Patch가 적용되는 과정


설정이 정상이라면 Vercel의 Dependency 설치 과정은 대략 다음과 같다.


Next.js 16.3.3 설치
↓
npm postinstall
↓
patch-package 실행
↓
patches/next+16.3.3.patch 적용
↓
Next.js Build


즉 `node_modules` 자체를 Git에 올리는 것이 아니다.


Git에는:


package.json
package-lock.json
patches/next+16.3.3.patch


만 포함하면 된다.




로컬에서도 Patch 적용 확인하기


완전히 검증하려면 기존 `node_modules`를 삭제하고 다시 설치한다.


rm -rf node_modules
npm ci


정상이라면 설치 로그에:


> patch-package

Applying patches...
next@16.3.3 ✔


와 비슷한 메시지가 나타난다.


그리고:


grep -n "headFetchStrategy" \
node_modules/next/dist/client/components/segment-cache/cache.js


를 실행했을 때 수정된 코드가 나온다면 자동 적용이 정상적으로 된 것이다.




Next 버전은 가능하면 고정


기존 `package.json`은:


"next": "^16.3.3"


이었다.


하지만 Patch는 정확히 `next@16.3.3`의 파일 구조를 기준으로 만들어졌다.


그래서 Patch를 사용하는 동안에는:


"next": "16.3.3"


처럼 Exact Version으로 고정하는 편이 안전하다.


Next.js 버전이 업데이트되면 내부 파일 위치나 코드가 달라져 Patch가 실패할 수 있기 때문이다.


향후 이 공식 Fix가 포함된 Stable 버전으로 업데이트하면 Patch를 제거하면 된다.




Node 버전도 맞추기


프로젝트에서는:


"engines": {
  "node": "22.x"
}


를 사용하고 있었다.


그런데 로컬에서는 Node 23이 사용되고 있어서 `npm ci` 시 `EBADENGINE` 경고가 발생했다.


그래서:


nvm use 22


로 Node 22를 사용했다.


Dependency 문제를 디버깅할 때는 이런 Runtime Version 차이도 최대한 제거하는 편이 좋다.




npm audit fix는 별개의 문제


`npm ci` 후 여러 Vulnerability 경고가 나왔고 중간에:


npm audit fix


를 실행하기도 했다.


하지만 이 명령은 이번 Next.js Bug와 전혀 관계없는 Dependency까지 대량으로 수정했다.


특히:


npm audit fix --force


는 Breaking Change까지 적용할 수 있다.


이번 작업의 목적은:


Next 16.3.3 유지
+
문제가 되는 공식 Fix 하나만 Backport


였기 때문에 관련 없는 Dependency 변경은 다시 되돌렸다.


Patch 작업에서는 가능한 한 변경 범위를 최소화하는 것이 좋다.




최종 구조


최종적으로 프로젝트는 다음 상태가 됐다.


Next.js 16.3.3
+
공식 Head Prefetch Fix Backport
+
patch-package
+
Vercel postinstall 자동 적용


실제 Patch 내용도 단 몇 줄이다.


const headFetchStrategy =
  fetchStrategy ===
  _types.FetchStrategy.LoadingBoundary
    ? _types.FetchStrategy.Full
    : fetchStrategy;


하지만 이 몇 줄의 의미를 이해하기 위해서는 꽤 깊은 곳까지 내려가야 했다.




전체 문제를 다시 한 번 요약하면


정상적인 경우


Head Cache Miss
↓
Pending 생성
↓
Server Request
↓
Response
↓
Fulfilled
↓
다음 Lookup에서 Cache Hit
↓
Prefetch 종료


버그가 있는 경우


Task Strategy
LoadingBoundary

↓

Head는 특수 처리
LoadingBoundary → Full

↓

Full varyPath에서 Pending 생성

↓

서버 응답

↓

응답 Write에서는 다시 LoadingBoundary 사용

↓

다른 varyPath로 Fulfilled Entry 이동

↓

Scheduler 재실행

↓

Head는 다시 Full varyPath 조회

↓

없음

↓

다시 Request


즉 문제의 본질은:


Head를 읽을 때와 저장할 때 서로 다른 FetchStrategy를 사용하면서 Segment Cache Key가 달라진 것


이었다.




이번 디버깅에서 가장 크게 배운 점


처음에는 단순한:


<Link>가 왜 계속 요청하지?


라는 문제였다.


하지만 실제로는:


Link
↓
Prefetch Task
↓
Scheduler
↓
Route Cache
↓
Segment Cache
↓
Head
↓
FetchStrategy
↓
varyPath


까지 내려가야 했다.


중간에 여러 가능성을 하나씩 제거했다.


Link mount 문제?
→ 아님

Route Cache expiration?
→ 아님

Scheduler 자체 loop?
→ 아님

일반 Segment 문제?
→ 아님

Head 문제?
→ 맞음

Head 응답 자체가 안 오는가?
→ 아님

잘못된 Entry에 저장하는가?
→ 아님

읽고 쓰는 Cache Key가 다른가?
→ 맞음


결국 문제의 핵심은 네트워크 요청 자체가 아니었다.


**응답은 정상적으로 오고 있었지만, 저장한 데이터를 다음번 조회에서 찾지 못하고 있었다.**


캐시 버그였다.




마무리


처음부터 Next.js GitHub에서 관련 Issue를 발견했다면 단순히 버전을 올리거나 Patch를 가져오는 것으로 끝났을 수도 있다.


하지만 이번에는 먼저 내부 동작을 직접 따라가면서 Root Cause에 도달했고, 이후 upstream을 확인했을 때 동일한 원인이 공식적으로 수정되어 있다는 것을 확인했다.


그래서 결과적으로 단순히:


Next 버그였음.
패치함.


으로 끝난 것이 아니라,


왜 요청이 반복되는가
→ 어떤 Task가 반복되는가
→ 무엇을 기다리고 있는가
→ Route인가 Segment인가
→ 어느 Segment인가
→ 왜 Full 전략인가
→ 응답은 어디에 저장되는가
→ 왜 다음 Lookup에서 못 찾는가


까지 구조적으로 이해할 수 있었다.


최종 수정 자체는 매우 작았다.


하지만 이 몇 줄이 왜 필요한지를 이해하는 과정이 이번 디버깅의 핵심이었다.


(자세한건... 이제 잠좀 자고 나서. )