next dev에서만 페이지가 잘린다 — 디버그 스택 정보가 부른 RSC 직렬화 오버플로

6

next dev에서만 페이지가 잘린다 — 디버그 스택 정보가 부른 RSC 직렬화 오버플로 이미지

dev에서만 페이지 하단이 사라진다

npm run dev로 블로그 글을 열면 본문 하단이 통째로 사라져 있습니다. DOM에도 없어요. 그런데 next build && next start로 띄운 프로덕션은 멀쩡합니다. 같은 코드, 같은 데이터인데 dev에서만. 이 글은 그 이유를 dev 서버 터미널 로그에서 찾아 범인까지 좁힌 기록입니다.

결론을 미리 말하면, 범인은 코드의 버그가 아니라 개발 모드가 붙이는 "디버그 스택 정보" 였습니다.

서버 터미널이 뱉은 에러

dev 서버를 로그로 리다이렉트해 띄우고 문제 페이지를 요청하니, 터미널에 이런 에러가 찍혔습니다.

text
⨯ [Error: failed to pipe response] {
  page: '/blog/gis-13-flatgeobuf',
  [cause]: [RangeError: Maximum call stack size exceeded]
}
 GET /blog/gis-13-flatgeobuf 500 in 27137ms

failed to pipe response + RangeError: Maximum call stack size exceeded. 응답을 스트리밍(pipe)하다가 스택 오버플로로 터진 겁니다. 이미 헤더(200)를 내보낸 뒤라 상태코드를 500으로 바꾸지도 못하고 소켓을 끊어버렸고, 그래서 dev에선 페이지 하단이 잘려 보였던 거예요.

무엇을 직렬화하다 넘쳤나 — 디버그 스택 정보

스택 오버플로는 어딘가에서 재귀가 너무 깊어졌다는 뜻입니다. 무엇을? 그 잘린 응답을 파일로 받아 끝부분을 열어 보니 힌트가 있었어요.

text
...page.mdx",3204,16,3190,1,false],["Object.apply","webpack-internal:///(rsc)/./src/.../page.mdx",3248,10,...
4e:[["_createMdxContent","webpack-internal:///(rsc)/./s   ← 여기서 뚝

끊긴 지점이 <body> HTML이 아니라 self.__next_f.push([...])RSC flight 페이로드(App Router가 본문 뒤에 붙이는, 하이드레이션용 직렬화 데이터) 한복판이었습니다. 그리고 그 안의 값이 결정적이었어요.

["_createMdxContent", "webpack-internal:///(rsc)/./...page.mdx", 3204, 16, ...] — 이건 owner stack입니다. React가 컴포넌트마다 "어디서 생성됐는지(file:line:col)" 를 기록해 두는 데이터예요. 경로의 (rsc) 표기는 React Server Components 레이어라는 표식이고요.

여기서 주제가 잡힙니다. 스트림이 죽은 건 본문(HTML)이 아니라, 그 뒤 flight에 실린 "디버그용 스택 정보(owner stack)"를 직렬화하던 중이었습니다. 즉 이 디버그 데이터가 너무 깊어 직렬화 재귀가 V8 스택을 넘긴 거예요.

💡 owner stack이 뭐냐면 — dev에서 "이 엘리먼트가 어느 파일 몇 번째 줄에서 만들어졌는지"를 추적해, 에러 오버레이·경고에 정확한 위치를 찍어주는 개발 편의용 데이터입니다. 편의를 위해 컴포넌트마다 스택을 잔뜩 들고 다녀요. 바로 이게 flight로 직렬화될 때 문제의 씨앗이 됐습니다.

왜 dev에서만 터지나

이 지점에서 "prod는 되는데 dev만 깨지는" 미스터리도 자연히 풀립니다. owner stack 같은 디버그 데이터는 개발 모드에서만 만들어지거든요.

developmentproduction
owner stack (생성 위치 file:line:col)생성함없음
flight 페이로드디버그로 무겁고 깊음실데이터만, 가벼움
결과직렬화 재귀가 스택 초과문제 없음

프로덕션 React는 이 디버그 데이터를 아예 안 찍습니다(minified). 그래서 next build가 통과하고 next start(프로덕션 서빙)도 페이지를 끝까지 렌더해요. 잘림의 "원료"가 dev 전용이라, 딱 dev에서만 터지는 겁니다.

💡 그래서 next build 성공은 "렌더가 된다"는 보증이 아니었어요. 이 블로그 페이지들은 루트 레이아웃의 cookies() 때문에 동적(ƒ) 이라, 빌드는 컴파일만 하고 실제 렌더는 요청 때 합니다. "빌드 통과 = 타입·문법 OK"였을 뿐이죠.

그 디버그 스택을 부풀린 범인 — 렌더 중 "패치된 fetch"

원인의 절반은 잡았습니다("dev 디버그 스택 직렬화가 스택을 넘긴다"). 남은 절반 — 왜 하필 블로그 경로에서만 이 디버그 스택이 한계까지 깊어졌나? 짧은 글, 이미지 없는 목록에서도 똑같이 터졌으니 특정 콘텐츠 문제는 아니었어요.

그래서 stub 이분탐색으로 공통 요소를 하나씩 제거했습니다.

용의자검증결과
getPosts (포스트 동적 import)정적 stub으로 교체❌ 그대로
sharp (blur 이미지 처리)fetch만 두고 sharp 제거❌ 그대로
원격 이미지 fetchgetPlaceholderImage를 통째로 stub완전 렌더

범인은 렌더 중 이미지마다 호출하던 fetch 였습니다. Next(App Router)는 캐싱·계측을 위해 전역 fetch를 패치하는데, 개발 모드에선 이 패치된 fetch가 호출마다 디버그 스택 정보를 붙입니다. 블로그 페이지는 카드·본문마다 이미지가 여러 개라 렌더 한 번에 fetch가 수 개~수십 개 → 그만큼 디버그 스택이 쌓이고 → flight 직렬화가 그 깊이를 못 버티고 오버플로한 거죠.

js
// 문제의 코드 — 렌더 중 이미지마다 "패치된 전역 fetch" 호출
const getFileBufferRemote = async (url) => {
  const response = await fetch(url, { cache: 'default' });
  return Buffer.from(await response.arrayBuffer());
};

해결 — 계측 안 타는 길로

원인이 "패치된 fetch가 붙이는 디버그 계측"이라면, 답은 명확합니다. 그 패치된 fetch를 안 쓰면 돼요. 이미지 버퍼 받는 부분을 Node 내장 https로 바꿨습니다.

ts
import https from 'node:https';

// 전역 fetch(패치됨) 대신 node:https로 직접 받아 dev 계측을 우회한다.
const getFileBufferRemote = (url: string) =>
  new Promise<Buffer>((resolve, reject) => {
    https
      .get(url, (res) => {
        if ((res.statusCode ?? 0) >= 400) {
          res.resume();
          reject(new Error(`Request failed: ${url}`));
          return;
        }
        const chunks: Uint8Array[] = [];
        res.on('data', (c: Uint8Array) => chunks.push(c));
        res.on('end', () => resolve(Buffer.concat(chunks)));
        res.on('error', reject);
      })
      .on('error', reject);
  });

node:https는 Next의 래핑을 타지 않으니 디버그 계측이 안 붙고, flight에 실릴 디버그 스택이 얕아져 오버플로가 사라집니다. 이 한 번의 교체로 dev에서 모든 페이지가 끝까지 렌더되고 스택 오버플로 로그도 0이 됐어요.

다만 전역 fetch를 버리면서 Next의 fetch 캐시도 잃었기 때문에, 결과를 모듈 메모리에 캐시해 이미지당 최초 1회만 받도록 얹었습니다.

ts
const cache = new Map<string, Promise<PlaceholderResult>>();

const getPlaceholderImage = async (filepath: string) => {
  const cached = cache.get(filepath);
  if (cached) return cached;
  const promise = compute(filepath).catch((e) => {
    console.error(e);
    cache.delete(filepath); // 실패는 캐시하지 않음
    return fallback(filepath);
  });
  cache.set(filepath, promise);
  return promise;
};

정리 — 한 문장으로

"dev에서만 페이지가 잘린다"의 정체는, 개발 모드가 컴포넌트마다 붙이는 "디버그 스택 정보(owner stack)"를 RSC로 직렬화하다 스택이 넘친 것이었다. 그 스택을 한계까지 부풀린 건 렌더 중 호출한 Next의 패치된 fetch였고, node:https로 우회하니 사라졌다.

이번 추적에서 남은 교훈:

참고