화면에서 컴포넌트를 잠시 숨겨야 할 때, React에서 어떤 방법을 사용했는지 생각해보자.
가장 먼저 생각나는 방식은 조건부 렌더링이다.
{activeTab === "video" ? <VideoPanel /> : null}
{activeTab === "video" && <VideoPanel />}
조건이 false가 되면 컴포넌트의 언마운트가 실행된다. effect 클린업이 실행되며 컴포넌트의 로컬 상태와 DOM이 사라진다. 다시 조건이 true가 되어도 해도 인풋 값, 접혀 있던 영역, 스크롤 위치 같은 상태를 복구하려면 외부에 상태를 저장한다든지 하는 식으로 복구방법을 마련해야 한다.
만약 상태를 남기고 싶다면 컴포넌트를 언마운트하지 않은채로 CSS나 HTML의 속성들로 화면에서 감춰야 한다.
<section hidden={activeTab !== "video"}>
<VideoPanel />
</section>
이렇게 하면 돌아왔을 때 이전 상태 그대로 화면을 바로 보여줄 수 있다. 하지만 컴포넌트가 계속 마운트되어 있기 때문에 별도의 처리를 하지 않으면 클린업이 실행되지 않는다.
Activity의 동작 방식
<Activity>를 사용하면 자식 컴포넌트의 UI와 내부 상태를 숨기고 복원할 수 있습니다.
<Activity mode={isActive ? "visible" : "hidden"}>
<VideoPanel />
</Activity>
Activity는 다음과 같이 동작한다.
- 화면에서는
display: none으로 숨는다. - DOM과 React 상태는 보존된다.
- Effect의 클린업이 실행되고 구독이 끊긴다.
- 숨겨진 동안 새 props를 받으면 낮은 우선순위로 렌더링한다.
- 다시
visible이 되면 이전 상태를 사용하고 Effect를 다시 설정한다.
즉, 다시 표시될 가능성이 있는 콘텐츠를 완전히 삭제하는 대신 UI와 내부 상태를 유지하고 복원하며, Effect는 클린업해 숨겨진 콘텐츠가 부작용을 일으키지 않도록 보장한다.
Chakra Tabs에 Activity를 적용할 수 있을까?
현재 프로젝트에 적용해 볼 수 있을만한 유즈케이스로 가장 먼저 생각이 든 것은 Tabs였다. Chakra Tabs는 각 탭을 숨겼다가 보여주고, lazyMount나 unmountOnExit 같은 옵션으로 대부분의 상황에 대응할 수 있었다. (있다고 생각했다)
Chakra Tabs는 다음과 같이 구현된다.
export const TabsContent = withContext(
ArkTabs.Content,
"content",
{ forwardAsChild: true },
)
Chakra는 Ark UI를 사용하기 때문에 사실 그냥 Ark UI의 Tabs를 보면 된다. 그리고 Ark UI의 Tabs에서는 usePresence라는 훅을 통해 해당 탭의 표시 여부를 제어한다.
usePresence에서는 lazyMount, 방문 이력, unmountOnExit을 한 조건식에 모아 두는데, 논지에 필요한 부분만 옮기면 다음과 같다.
const unmounted =
(!api.present && !wasEverPresent.current && lazyMount) ||
(unmountOnExit && !api.present && wasEverPresent.current)
const getPresenceProps = () => ({
hidden: !api.present,
})
present가 된 적이 있는지 기록한 뒤, unmounted가 참이면 패널 대신 null을 반환한다. 그렇지 않으면 div를 유지하고 비활성 패널에 HTML hidden 속성을 붙인다. 옵션 조합에 따라 결과는 세 갈래로 나뉜다.
아무 옵션도 주지 않았을 때
모든 패널을 처음부터 마운트한다. 비활성 패널에는 hidden이 붙는다. 상태와 DOM은 남지만 Effect도 계속 연결된 상태다.
lazyMount만 주었을 때
아직 한 번도 방문하지 않은 패널은 null이다. 처음 활성화될 때 마운트되고, 그 뒤 다른 탭으로 이동해도 기본값인 unmountOnExit={false} 덕분에 DOM을 유지한 채 hidden 상태가 된다. 참고로 HTML의 hidden은 사실상 display: none과 동일하게 동작한다고 한다.
lazyMount와 unmountOnExit를 함께 주었을 때
처음 열 때까지 마운트를 미루고, 탭을 떠나면 null을 반환한다. 이펙트는 정리되지만 상태와 DOM도 함께 사라진다.
Activity의 구현
Activity도 최종적으로 호스트 요소에 display: none !important를 설정한다. 차이가 있다면 Activity는 React 내부에서 특별한 경계로 처리된다는 것이다.
case REACT_ACTIVITY_TYPE:
return createFiberFromActivity(pendingProps, mode, lanes, key)
React에는 Reconciler라는 엔진이 있고 해당 엔진이 리액트의 가장 핵심 중 하나인 Reconciliation을 진행한다. 그리고 Fiber는 그 조정 작업의 단위라고 생각하면 된다. 리액트의 Reconciliation과 Fiber에 대해서는 다음에 더 알아보기로 하고, 일단 여기서는 fiber에 컴포넌트의 타입, props, 상태, 자식, 처리할 이펙트, 작업 우선순위 등이 기록된다고 알고 넘어가자.
JSX로 <Activity>를 작성하면 reconciler는 Activity 전용 ActivityFiber를 만들고, 그 자식에는 다시 내부 OffscreenFiber가 만들어진다.
const offscreenChildProps = {
mode: nextMode,
children: nextChildren,
}
const child = mountWorkInProgressOffscreenFiber(
offscreenChildProps,
mode,
renderLanes,
)
숨겨진 트리의 갱신은 OffscreenLane으로 보내 나중에 낮은 우선순위로 처리하며, mode가 visible에서 hidden으로 바뀌면 React는 이펙트를 연결 해제하고, 그 다음 실제 DOM을 숨긴다. 반대로 hidden에서 visible이 되면 DOM을 다시 보이게 하고 이펙트를 재연결한다.
이 부분이 차이를 만드는 핵심이다. 단순 언마운트라면 이펙트를 연결 해제하고 DOM을 삭제할것이다. 그리고 단순 hidden이라면 이펙트는 냅두고 DOM만 숨길것이다. Activity는 이펙트를 연결 해제하고 DOM을 숨긴다.
근데 Effect 관리만 잘 해주면 똑같은거 아님?
이런 의문이 들 수 있다. 그럼 그냥 hidden으로 처리하고 이펙트 관리만 해주면 되는거 아닌가?
예를 들어서 동영상 탭에서 다른 탭으로 옮기는 순간 재생을 멈추고 싶다고 해 보자.
Chakra Tabs에서 lazyMount를 사용하면 패널은 탭을 떠난 뒤에도 마운트돼 있다. 그러므로 VideoPanel이 현재 활성 탭인지 알아야 한다.
useLayoutEffect(() => {
if (!isActive) return
const video = videoRef.current
return () => {
video?.pause()
}
}, [isActive])
isActive가 false로 바뀌면 이전 Effect의 클린업이 실행되어 동영상을 멈춘다. 동작에는 문제가 없다. 다만 패널 내부까지 Tabs의 활성 상태가 흘러 들어온다. 동영상 외에도 타이머, 에디터, 구독이 있다면 각 로직이 isActive를 기준으로 연결과 해제 시점을 관리해야 한다.
Activity를 경계로 두면 자식은 활성 상태를 몰라도 된다.
useLayoutEffect(() => {
const video = videoRef.current
return () => {
video?.pause()
}
}, [])
부모의 Activity가 hidden으로 바뀌면 React가 이 클린업을 실행한다. 다시 visible이 되면 의존성 배열이 비어 있어도 Effect를 다시 설정한다. VideoPanel은 “언제 비활성화되는가” 대신 “비활성화될 때 무엇을 정리해야 하는가”만 적는다.
여기서 달라지는 건 책임의 위치다.
- Tabs를 소유한 부모는 어떤 패널이 보이는지 안다.
VideoPanel은 자신이 정리해야 할 리소스를 안다.- Activity는 가시성 변화와 Effect 생명주기를 연결한다.
프리렌더링과 하이드레이션
Activity에는 한 가지 용도가 더 있다. 아직 보이지 않는 다음 화면을 낮은 우선순위로 준비하는 일이다. 공식 문서는 탭과 페이지 전환 전에 숨겨진 콘텐츠를 프리렌더링하는 예제를 제공한다. 이렇게 하면 사용자 이동시에 미리 만든 DOM과 상태를 보여 줄 수 있다.
서버 렌더링에서는 mode="hidden"인 Activity의 자식이 초기 HTML에 포함되지 않고, 나머지 화면이 하이드레이션되는 동안 클라이언트 렌더링이 예약된다. 또한 Activity 경계 자체는 Suspense처럼 선택적 하이드레이션 단위가 된다. 보이는 콘텐츠 안에서도 상호작용이 필요한 부분을 먼저 하이드레이션할 수 있다는 뜻이다. 자세한 동작은 공식 문서를 참고하면 된다.
Chakra Tabs + Activity
현재 Chakra UI에는 아직 Tabs가 Activity를 쓰도록 만드는 옵션은 없다. 다만 Ark UI에 usePresence, Presence 및 사용처에 hideMode="activity"가 추가되어서, Ark UI를 사용하는 차크라에도 해당 옵션이 제공될 날이 멀지 않았다.
다만 그 전에 Activity를 적용해 보고 싶어서 Tabs 안에 Activity를 사용하는 방식으로 먼저 사용해봤다.
<Tabs.Root
value={activeTab}
onValueChange={({ value }) => setActiveTab(value)}
>
<Tabs.Content value="video">
<Activity mode={activeTab === "video" ? "visible" : "hidden"}>
<VideoPanel />
</Activity>
</Tabs.Content>
</Tabs.Root>
| 설정 | 첫 방문 lazy | state 보존 | Effect cleanup |
|---|---|---|---|
| Chakra Tabs 기본 | ❌ | ✅ | ❌ |
| Chakra Tabs 기본 + Activity | ❌ | ✅ | ✅ |
| lazyMount | ✅ | ✅ | ❌ |
| lazyMount unmountOnExit | ✅ | ❌ | ✅ |
| lazyMount + Activity | ✅ | ✅ | ✅ |
기존에는 lazyMount + unmountOnExit이 적용되어 있어서 컨텐츠가 프리로딩되지 않아 유저가 바로 컨텐츠를 볼 수 없고 스크롤 위치 및 인풋 상태 유지가 불가능했는데, Chakra Tabs 기본옵션 + Activity의 적용으로 프리로딩 및 상태유지가 적용되어 훨씬 UX가 좋아졌다!
기존에도 상태를 전달하면 비슷한 동작을 만들 수 있었지만, 결국 Activity의 핵심은 리액트 팀이 활성/비활성 상태를 컴포넌트 생명주기의 한 형태로 추상화하여 표현했다는데 있다고 생각한다.