노트

Activity와 숨겨진 라우트 보존

Activity

프런트엔드#react#nextjs · 연결된 개념 4개

쉽게 말하면

Activity는 화면을 치워 버리지 않고 커튼만 쳐서 가려 두는 거예요. 커튼을 다시 걷으면 쓰던 입력과 스크롤이 그대로 남아 있어서 처음부터 다시 할 필요가 없죠.

비유가 깨지는 곳 커튼 뒤 화면도 문서에서 사라진 게 아니에요. DOM이 남아 같은 id가 여러 개 생기고, 열려 있던 다이얼로그도 열린 채로 돌아오니 숨겨질 때 정리 함수로 닫아 줘야 해요.

Activity는 React 19.2의 컴포넌트로, UI를 언마운트하지 않고 display: none으로 숨기면서 state와 DOM(Document Object Model)을 보존한다. 숨기는 동안 Effect는 정리(cleanup)된다. 예전 이름은 Offscreen이다.

<Activity mode={isVisible ? 'visible' : 'hidden'}>
  <Sidebar />
</Activity>

Next.js 16에서의 쓰임

Cache Components(cacheComponents: true)를 켜면, 클라이언트 이동(<Link>, router.push) 때 이전 페이지를 언마운트하지 않고 Activity로 숨긴다. 뒤로 가면 입력하던 폼, 스크롤, 펼친 details가 그대로 돌아온다. 문서 기준으로 최근 3개 라우트까지 보존하고, 그보다 오래된 것은 지운다. 새로고침 같은 하드 내비게이션(Hard Navigation)은 평소처럼 새로 마운트한다.

함정: 같은 id가 여러 개 생긴다

숨겨진 이전 페이지의 DOM이 문서에 남아 있으니, 페이지 안에서 렌더하는 같은 id가 동시에 여러 개 존재할 수 있다. 레이아웃은 공유되므로 하나뿐이다.

사용처동작위험
getElementById·querySelector('#x')첫 번째 매칭만 반환숨겨진 이전 페이지 요소를 잡을 수 있음
CSS(Cascading Style Sheets) #x전부에 적용대체로 무해
href="#x" 앵커첫 번째로 스크롤엉뚱한 위치

DOM 순서는 보장되지 않는다. 숨겨진 요소를 잡으면 렌더 박스가 없어서 scrollIntoView가 조용히 아무 일도 하지 않는다. 전역 id 조회 대신 ref나 현재 컴포넌트의 컨테이너 기준으로 찾는다. React의 useId는 인스턴스마다 다른 id를 준다.

무엇을 보존할지 고르기

보존이 늘 좋은 것은 아니다. 다시 열 때 닫혀 있어야 하는 드롭다운처럼 숨겨질 때 초기화할 상태는 정리 함수에서 되돌린다. 숨기기 직전 낡은 화면이 한 프레임 보이지 않게 하려면 useLayoutEffect의 정리 함수를 쓴다. 다시 마운트하지 않으니 마운트 시점 초기화 Effect가 다시 돌지 않는다는 점도 기억한다.

예전에는 "라우트가 바뀌면 언마운트되니 로컬 state가 초기화된다"에 기대던 코드가 많다. 장바구니에 담은 뒤 열린 시트, 확인 다이얼로그처럼 일시적인 상호작용이 그렇다. Cache Components를 켜면 이런 UI가 열린 채로 돌아와 E2E(End-to-End) 테스트가 간헐적으로 막히는 식으로 드러난다. 공식 문서가 권하는 처리는 다음과 같다.

  • 숨겨질 때 닫기: useLayoutEffect(() => () => setOpen(false), []). 다시 보일 때 닫는 것보다 낫다. 보이는 순간 한 프레임이라도 열린 화면이 나오지 않는다
  • URL에서 파생하기: 열림 여부를 ?edit=true 같은 검색 파라미터로 둔다. 다른 페이지에 갔다 오면 파라미터가 없으니 자연히 닫히고, 열 때마다 값이 바뀌어 초기화 Effect도 다시 돈다
  • 링크를 누를 때 닫기: <Link onNavigate> 콜백에서 바로 닫는다
  • <video>·<audio>·<iframe>처럼 DOM 자체에 부수 효과가 있는 요소는 숨겨져도 재생이 이어질 수 있으니 정리 함수에서 멈춘다
  • 숨긴 동안에도 새 props가 오면 낮은 우선순위로 다시 렌더된다. Effect만 정리될 뿐 렌더는 계속된다
  • Effect는 숨김 → 표시 때마다 다시 실행된다. 첫 마운트와 재노출을 구분하려면 정리되지 않는 ref에 "이미 마운트됨"을 기록한다

테스트

숨겨진 페이지도 display: none으로 문서에 남으니, E2E 도구에서 같은 요소가 둘 잡히거나 숨겨진 요소를 기다리다 시간 초과가 난다. Playwright라면 접근성 트리 기준이라 숨겨진 요소를 거르는 getByRole·getByLabel을 쓰고, CSS 선택자를 써야 하면 .filter({ visible: true })로 거른다. 점진적 이전 중이라 보존이 곤란하면 useRouter().bfcacheId를 <Fragment key={bfcacheId}>로 감싼 서브트리의 key로 쓴다. push·replace 이동(<Link> 클릭 포함)에서는 새로 마운트되고, 뒤로·앞으로 가기에서는 상태가 복원된다. 문서는 이를 주로 이전용 도구로 보고, 새 코드에는 위의 패턴별 초기화를 권한다(Next.js 16.3 기준).

관련: 부분 사전 렌더링(PPR), 재조정(Reconciliation)(state가 유지되는 조건), Next.js 16 변경점.

출처: Next.js - Preserving UI state across navigations · Next.js - useRouter: bfcacheId · React - Activity

연결된 개념

이 노트를 가리키는 문서

뜻이 가까운 노트

  • loading 경계와 레이아웃 끌어올리기

    Next.js App Router에서 loading.tsx는 같은 세그먼트의 page와 그 아래(하위 layout 포함)를 suspense로 감싸지만, **같은 세그먼트의 layout은 감싸지 않는다**. 그래서 데이터를 기다릴 필요가 없는 공통 UI(탭바, 필터 헤더)를 page에서 layout으로 끌어올리면 스켈레톤에 덮이지 않고 처음부터 실제 모습으로 보인다.

  • Pages Router에서 App Router로

    App Router는 Next.js 13에서 도입된 app/ 디렉터리 기반 라우터로, 서버 컴포넌트·중첩 레이아웃(Nested Layouts)·스트리밍을 기본으로 한다. pages/ 기반의 Pages Router에서 옮길 때 바뀌는 생각을 정리한다.

  • bfcache(뒤로·앞으로 가기 캐시)

    페이지를 떠날 때 DOM과 JS 힙까지 통째로 메모리에 얼려 두었다가, 뒤로·앞으로 가기에서 그대로 되살리는 브라우저 캐시.

  • 내부 가변성과 메모이제이션

    내부 가변성(Interior Mutability)은 객체나 함수의 참조는 그대로인데 안에 숨은 상태가 바뀌는 성질이다. React의 메모이제이션은 "입력이 다른 참조인가"만 보기 때문에, 참조가 같으면 내용이 바뀌어도 바뀌지 않았다고 판단한다. React 공식 문서는 이를 "겉모습은 같은데 몰래 내용물을 바꾸는 상자"에 비유한다.

  • 버전 스큐와 정적 자산 오프로드

    버전 스큐(Version Skew)는 새 버전을 배포한 뒤에도 브라우저에 떠 있는 이전 빌드의 클라이언트가 새 서버와 통신하면서 생기는 불일치다. 대표 증상은 이전 HTML이 참조하던 JS 청크를 늦게 불러오려다 404가 나는 ChunkLoadError다.

보기 옵션