blog

Next.js App Router 탭 네비게이션에서 RSC 요청이 두 번 가던 문제

Front-end / Next.js#Next.js#App Router#RSC#Radix UI#Transition#CSP

요약

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 TabsTabs·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, useDeferredValue always 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회

교훈

  1. 내비게이션 transition 중에 렌더되는 값(usePathname, useSearchParams)을 내부 상태를 가진 서드파티 controlled 컴포넌트에 바로 넘길 때는 주의한다.
  2. 커밋된 값만 넘겨야 할 때는 useState + useEffect로 한 단계 격리할 수 있다. 일반적으로는 불필요한 패턴이므로, 문제를 확인한 경우에만 쓰고 이유를 주석으로 남긴다.
  3. 원인 후보를 하나씩 제거하는 실험이 결국 예상하지 못한 지점을 찾아냈다.

참고