Next.js App Router 탭 네비게이션에서 RSC 요청이 두 번 가던 문제
요약
GNB(상단 내비게이션)에서 카테고리 탭을 눌러 이동하면, 같은 경로의 RSC 요청이 서버로 두 번 갔다. 요청마다 새 nonce를 만드는 proxy.ts 구조 때문에 두 요청의 CSP nonce가 달라 문제가 생겼고, 불필요한 서버 왕복으로 응답도 느려졌다. 원인은 Radix UI Tabs의 controlled value에 usePathname() 값을 바로 연결한 코드였다.
# 서버 로그
[proxy:rsc-nav] /admin/field-work
[proxy:rsc-nav] /admin/field-work ← 같은 경로 중복
원인 후보를 하나씩 제거하기
GNB를 구성하는 요소를 하나씩 빼거나 바꿔 가며 재현 여부를 확인했다.
| 가설 | 실험 | 결과 |
|---|---|---|
framer-motion AnimatePresence | 제거, motion.div → div | 여전히 2회 |
탭 인디케이터의 useLayoutEffect(의존성 배열 없음) | 기본 TabList로 교체 | 여전히 2회 |
| 서브 메뉴 표시에 따른 높이 변화 | 서브 메뉴 강제 숨김 | 여전히 2회 |
| GNB 전체 | layout.tsx에서 GNB 제거, 임시 버튼으로 이동 | 1회 |
Radix UI Tabs | Tabs·TabTrigger → div·button으로 교체 | 1회 |
Radix UI Tabs가 직접적인 원인으로 좁혀졌다.
원인 분석
App Router 내비게이션은 transition이다
router.push()와 <Link> 내비게이션은 내부적으로 transition으로 처리된다. React는 새 경로의 UI를 백그라운드에서 렌더링하고, 이 렌더 중에는 usePathname()이 이미 새 경로를 반환한다.
기존 코드
const pathname = usePathname();
const openCategory = findActiveCategoryLabel(pathname);
<Tabs value={openCategory ?? ''}>{/* ... */}</Tabs>
transition 렌더 중에 Tabs의 value가 바뀌고, Radix Tabs는 controlled 값이 바뀌면 내부 상태를 갱신한다. 이 내부 갱신이 진행 중인 내비게이션 transition과 맞물리면서 내비게이션이 다시 처리되었고, 그 결과 같은 경로의 RSC 요청이 한 번 더 나간 것으로 판단했다.
정확한 내부 경로(어떤 업데이트가 어떤 우선순위로 transition을 끊는지)는 React·Next.js·Radix 버전에 따라 다를 수 있다. 실험으로 확인한 사실은 "transition 렌더 중 Tabs의 value가 바뀌면 요청이 2회, 커밋 후에 바뀌면 1회" 라는 것이다.
useDeferredValue로는 해결되지 않는다
useDeferredValue(pathname)로 감싸 보는 것이 자연스러운 시도지만, React 문서에 이렇게 나와 있다.
When an update is inside a Transition,
useDeferredValuealways returns the new value and does not spawn a deferred render.
pathname 변경은 transition 안에서 일어나므로 지연되지 않는다.
해결: 커밋된 값만 Tabs에 전달
useEffect는 커밋 이후에만 실행된다. useState에 값을 복사해 두고 effect에서 동기화하면, Tabs는 실제로 커밋된 경로만 보게 된다.
const pathname = usePathname();
const openCategory = findActiveCategoryLabel(pathname);
// transition 렌더 중이 아니라 커밋 이후에만 Tabs 값을 갱신
const [tabValue, setTabValue] = useState(openCategory ?? '');
useEffect(() => {
setTabValue(openCategory ?? '');
}, [openCategory]);
<Tabs
value={tabValue}
onValueChange={(value) => {
setTabValue(value); // 클릭 즉시 탭 표시 변경
const category = NAV_CATEGORIES.find(({ label }) => label === value);
if (category) router.push(category.items[0].path);
}}
>
{/* ... */}
</Tabs>
onValueChange에서 바로 setTabValue를 호출해 클릭 피드백은 즉시 주고, 뒤로 가기처럼 탭 클릭 없이 경로가 바뀌는 경우는 effect가 맞춰 준다.
| 항목 | 수정 전 | 수정 후 |
|---|---|---|
Tabs value 출처 | usePathname() 파생값 | 커밋 후 동기화되는 state |
transition 렌더 중 value | 새 값으로 바뀜 | 이전 값 유지 |
| RSC 요청 | 2회 | 1회 |
교훈
- 내비게이션 transition 중에 렌더되는 값(
usePathname,useSearchParams)을 내부 상태를 가진 서드파티 controlled 컴포넌트에 바로 넘길 때는 주의한다. - 커밋된 값만 넘겨야 할 때는
useState+useEffect로 한 단계 격리할 수 있다. 일반적으로는 불필요한 패턴이므로, 문제를 확인한 경우에만 쓰고 이유를 주석으로 남긴다. - 원인 후보를 하나씩 제거하는 실험이 결국 예상하지 못한 지점을 찾아냈다.