App Router에서 '서버가 부르는 API'가 Network 탭에 안 보여서, DevTools 확장을 만들었다

Shin Jinseop·2026년 6월 24일
post-thumbnail

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

🟥 발단: Network 탭이 비어 있다

어떤 목록 페이지를 SSR(서버에서 데이터 패칭) 로 바꿔보던 중이었다. 잘 동작하는데, 디버깅하려고 DevTools Network 탭을 열어보니 — 정작 그 데이터를 가져오는 API 호출이 하나도 안 보인다.

처음엔 내 코드가 잘못된 줄 알았다. 그런데 응답은 멀쩡히 화면에 그려진다. 호출은 분명히 일어났는데 Network 탭만 모른다. 배포 환경에선 콘솔 로그도 없어서 더 깜깜했다.

🤔 왜 App Router 서버 fetch는 Network 탭에 안 보일까

App Router에선 데이터 패칭이 서버에서 일어난다.

  • RSC(서버 컴포넌트) 렌더 중의 fetch
  • 라우트 핸들러(app/**/route.ts) 안의 호출
  • 서버 액션 안의 호출

전부 서버에서 실행되니 브라우저는 그 요청의 존재 자체를 모른다. Network 탭은 브라우저가 직접 보낸 요청(클라 fetch/XHR)만 보여준다. 그래서 SSR로 옮길수록 "데이터는 보이는데 그 호출은 안 보이는" 사각지대가 커진다.

💡 한 줄 요약 — App Router의 서버 패칭은 Network 탭의 사각지대다. 클라가 보낸 게 아니니까.


🟩 그래서 만들었다: Next API Capture

서버의 fetch와 Node http/https를 계측해서, 페이지 이동(navigation) 단위로 묶어 전용 DevTools 패널로 스트리밍한다.


실제 패널 — 왼쪽은 navigation 단위 호출 목록, 오른쪽은 선택한 호출의 헤더·바디. authorization은 자동 마스킹(***).

캡처되는 호출은 출처별로 배지가 붙는다:

배지어디서 온 호출
server:rsc서버 컴포넌트 렌더 중의 fetch
server:route-handler라우트 핸들러(route.ts) 안
server:action서버 액션 안
client:fetch / client:xhr브라우저

⚙️ 어떻게 동작하나

1. 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'가 안 난다.

2. navigationId로 서버·클라를 묶는다

미들웨어가 요청마다 x-nac-navigation-id 헤더와 쿠키를 심는다. 서버는 next/headers로, 클라 fetch 패치는 쿠키로 같은 id를 읽는다. 덕분에 한 번의 페이지 이동에서 일어난 서버·클라 호출이 패널에서 한 그룹으로 묶인다.

3. 응답 스트림은 '소비'가 아니라 '관찰'

응답 바디를 캡처하려고 스트림을 읽으면, 실제 소비자(axios)가 읽을 데이터를 굶길 위험이 있다. 캡처의 제1원칙은 "절대 앱 요청을 깨지 않는다"인데.

해결책은 Node readable 스트림이 EventEmitter라는 점이다. data 리스너가 여러 개면 각자 같은 청크의 복사본을 받는다. 우리 리스너는 복사본만 누적하고, 원본은 소비자에게 그대로 흘러간다. gzip/deflate/brnode: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://extensionshttps://chromewebstore.google.com/detail/next-api-capture/jhbgbomlmdnjiahdinlndejedgnepbdk?hl=ko&gl=CZ


🪤 삽질 기록 (이게 본편)

① 런타임은 되는데 타입이 깨진다
self-contained라 자신했는데 TS 사용자가 Cannot find module로 깨졌다. tsup의 noExternalJS 번들만 인라인한다. .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 publishworkspace:*를 그대로 박아 깨진 package.json이 올라간다. pnpm publish가 실버전으로 치환. scoped 패키지는 기본 private이라 --access public 필수.

next dev가 안 죽는다
포트를 문 워커만 kill하면 부모가 다시 살린다. 트리 전체(루트~워커) + 포트 기준 리스너, 두 각도로 잡아야 깨끗이 종료.


🔚 마무리

"안 보이던 걸 보이게" 만드는 도구라 만들면서도 재밌었다. App Router로 넘어오며 서버 패칭이 늘었다면 한 번 써보면 좋겠다.

도움이 됐다면 좋아요 / 이슈 / 피드백 환영합니다 🙏

2개의 댓글

comment-user-thumbnail
2026년 6월 29일

어쩐지 안 보이더군요..

1개의 답글