
TL;DR
Next.js App Router는 데이터를 서버에서 패칭한다. 그래서 그 API 호출은 브라우저 Network 탭에 안 보인다. 그걸 DevTools 패널에서 보려고 직접 만들었다.
📦@shinjinseop/next-api-capture· 💻 GitHub · ▶️ 데모

어떤 목록 페이지를 SSR(서버에서 데이터 패칭) 로 바꿔보던 중이었다. 잘 동작하는데, 디버깅하려고 DevTools Network 탭을 열어보니 — 정작 그 데이터를 가져오는 API 호출이 하나도 안 보인다.
처음엔 내 코드가 잘못된 줄 알았다. 그런데 응답은 멀쩡히 화면에 그려진다. 호출은 분명히 일어났는데 Network 탭만 모른다. 배포 환경에선 콘솔 로그도 없어서 더 깜깜했다.
App Router에선 데이터 패칭이 서버에서 일어난다.
fetchapp/**/route.ts) 안의 호출전부 서버에서 실행되니 브라우저는 그 요청의 존재 자체를 모른다. Network 탭은 브라우저가 직접 보낸 요청(클라 fetch/XHR)만 보여준다. 그래서 SSR로 옮길수록 "데이터는 보이는데 그 호출은 안 보이는" 사각지대가 커진다.
💡 한 줄 요약 — App Router의 서버 패칭은 Network 탭의 사각지대다. 클라가 보낸 게 아니니까.
서버의 fetch와 Node http/https를 계측해서, 페이지 이동(navigation) 단위로 묶어 전용 DevTools 패널로 스트리밍한다.

실제 패널 — 왼쪽은 navigation 단위 호출 목록, 오른쪽은 선택한 호출의 헤더·바디. authorization은 자동 마스킹(***).
캡처되는 호출은 출처별로 배지가 붙는다:
| 배지 | 어디서 온 호출 |
|---|---|
server:rsc | 서버 컴포넌트 렌더 중의 fetch |
server:route-handler | 라우트 핸들러(route.ts) 안 |
server:action | 서버 액션 안 |
client:fetch / client:xhr | 브라우저 |
fetch만 후킹하면 절반만 보인다처음엔 globalThis.fetch만 monkey-patch 했다. 그런데 서버에서 axios(그리고 got, node-fetch)는 node:http/https의 .request()로 나간다. fetch 패치를 그대로 우회한다.
// 두 경로를 모두 패치해야 한다 — 서로 겹치지 않아 중복 기록도 없다
patchFetch(config); // globalThis.fetch (undici)
patchHttp(config); // node:http / https .request → axios·got·node-fetch 커버
빌트인은
import/require대신process.getBuiltinModule("node:http")로 로드한다. 번들러(webpack/esbuild) 정적 분석에 안 걸려서 Edge/instrumentation 번들에서Can't resolve 'http'가 안 난다.
navigationId로 서버·클라를 묶는다미들웨어가 요청마다 x-nac-navigation-id 헤더와 쿠키를 심는다. 서버는 next/headers로, 클라 fetch 패치는 쿠키로 같은 id를 읽는다. 덕분에 한 번의 페이지 이동에서 일어난 서버·클라 호출이 패널에서 한 그룹으로 묶인다.
응답 바디를 캡처하려고 스트림을 읽으면, 실제 소비자(axios)가 읽을 데이터를 굶길 위험이 있다. 캡처의 제1원칙은 "절대 앱 요청을 깨지 않는다"인데.
해결책은 Node readable 스트림이 EventEmitter라는 점이다. data 리스너가 여러 개면 각자 같은 청크의 복사본을 받는다. 우리 리스너는 복사본만 누적하고, 원본은 소비자에게 그대로 흘러간다. gzip/deflate/br은 node:zlib로 투명 해제한다.
npm i @shinjinseop/next-api-capture
# peer: next >= 13.4
// instrumentation.ts
import { registerCapture } from "@shinjinseop/next-api-capture";
export async function register() {
await registerCapture(); // dev 기본, 프로덕션 자동 OFF
}
// middleware.ts
import { createCaptureMiddleware } from "@shinjinseop/next-api-capture/middleware";
export const middleware = createCaptureMiddleware();
// next.config.js — Node 전용 ws 패키지를 server-only로
module.exports = { serverExternalPackages: ["ws"] };
확장은 현재 크롬 웹스토어 심사 중이라 그동안은 unpacked로 로드한다:
chrome://extensions→ https://chromewebstore.google.com/detail/next-api-capture/jhbgbomlmdnjiahdinlndejedgnepbdk?hl=ko&gl=CZ
① 런타임은 되는데 타입이 깨진다
self-contained라 자신했는데 TS 사용자가Cannot find module로 깨졌다. tsup의noExternal은 JS 번들만 인라인한다..d.ts는 별도 패스(rollup-plugin-dts)라 내부 패키지를 그대로import로 남긴다. →dts: { resolve: [...] }로 타입까지 인라인. "런타임 import 없음"만 보지 말고.d.ts까지 grep하자.
② publish 직후
npm view가 404
방금 올렸는데 안 보여서 식겁. CDN(CloudFlare)이 게시 직전의 빈 응답을 부정 캐시한 것. 진실은 tarball URL HEAD가 200이면 게시된 것이고, 다음 publish가 캐시를 무효화한다.
③ 워크스페이스는
pnpm publish
npm publish는workspace:*를 그대로 박아 깨진 package.json이 올라간다.pnpm publish가 실버전으로 치환. scoped 패키지는 기본 private이라--access public필수.
④
next dev가 안 죽는다
포트를 문 워커만 kill하면 부모가 다시 살린다. 트리 전체(루트~워커) + 포트 기준 리스너, 두 각도로 잡아야 깨끗이 종료.
"안 보이던 걸 보이게" 만드는 도구라 만들면서도 재밌었다. App Router로 넘어오며 서버 패칭이 늘었다면 한 번 써보면 좋겠다.
npm i @shinjinseop/next-api-capture도움이 됐다면 좋아요 / 이슈 / 피드백 환영합니다 🙏
어쩐지 안 보이더군요..