React의 숨겨진, Offscreen 컴포넌트

한상우·2025년 5월 15일

리액트

목록 보기
21/24
post-thumbnail

알고 계신가요? React의 숨겨진 보석, Offscreen 컴포넌트

안녕하세요! 오늘은 React의 숨겨진 보석과도 같은 기능인 Offscreen 컴포넌트에 대해 소개해 드리려고 합니다. React 18에서 Suspense의 내부 구현을 위해 사용되는 이 컴포넌트는 아직 공식적으로 공개되지 않았지만, React 팀은 미래에 개발자들이 직접 사용할 수 있게 할 계획이라고 밝혔습니다. 평소에 React 내부 구현에 관심이 있거나, Concurrent Mode와 Suspense의 작동 원리가 궁금하신 분들에게 유용한 정보가 될 것같아 공유합니다.

Offscreen 컴포넌트란 무엇인가요?

Offscreen 컴포넌트는 간단히 말해 화면에 보이지 않는 컴포넌트의 렌더링을 지연시키고 CSS로 숨기는 역할을 합니다. 이는 React의 Concurrent Mode에서 매우 중요한 개념으로, 사용자에게 당장 보이지 않는 UI 요소의 렌더링 우선순위를 낮춰 더 중요한 작업에 리소스를 집중할 수 있게 해줍니다.

현재는 실험적 기능이지만, 다음과 같이 사용할 수 있습니다

// 실험적 기능 사용 예시
const Offscreen = React.unstable_Offscreen;

function App() {
  const [hidden, setHidden] = React.useState(true);
  console.log("render App");
  
  return (
    <div>
      <button onClick={() => setHidden(prev => !prev)}>토글</button>
      <Offscreen mode={hidden ? "hidden" : "visible"}>
        <Component />
      </Offscreen>
    </div>
  );
}

Offscreen 컴포넌트의 흥미로운 특징

Offscreen 컴포넌트를 사용해 보면 몇 가지 흥미로운 동작을 발견할 수 있습니다:

  1. 컴포넌트는 숨겨져 있어도 여전히 렌더링됩니다 - React 내부 트리에서는 렌더링 과정이 진행됩니다.
  2. useEffect는 실행되지만 useLayoutEffect는 실행되지 않습니다 - 패시브 이펙트는 실행되어 데이터 페칭이 가능하지만, 레이아웃 이펙트는 실행되지 않아 DOM 관련 작업은 미뤄집니다.
  3. renderRootConcurrent가 두 번 호출됩니다 - 숨겨진 컴포넌트의 렌더링이 별도의 패스로 처리됩니다.
  4. DOM은 생성되지만 CSS로 숨겨집니다 - 실제 DOM에는 요소가 존재하지만 display: none으로 숨겨집니다.

이런 특징들은 브라우저 콘솔에서 직접 확인해볼 수 있습니다.

Offscreen 컴포넌트의 내부 구조

React 소스 코드를 살펴보면 Offscreen 컴포넌트는 다음과 같은 데이터 구조를 가지고 있습니다:

export type OffscreenProps = {|
  mode?: OffscreenMode | null | void,
  children?: ReactNodeList,
|};

export type OffscreenState = {|
  baseLanes: Lanes,
  cachePool: SpawnedCachePool | null,
|};

export type OffscreenInstance = {};

export type OffscreenMode =
  | "hidden"
  | "unstable-defer-without-hiding"
  | "visible";

특히 mode 속성은 컴포넌트의 가시성을 결정하며, OffscreenState는 컴포넌트가 숨겨져 있는지 여부를 나타냅니다.

Offscreen 컴포넌트의 내부 동작 원리

1. 렌더링 우선순위와 스케줄링

Offscreen 컴포넌트의 가장 중요한 특징은 우선순위 기반 렌더링입니다. React는 hidden 상태의 Offscreen 컴포넌트를 OffscreenLane이라는 매우 낮은 우선순위로 스케줄링합니다.

export const IdleLane: Lanes = /*                       */ 0b0100000000000000000000000000000;
export const OffscreenLane: Lane = /*                   */ 0b1000000000000000000000000000000;

이는 심지어 IdleLane보다도 낮은 우선순위로, 사용자에게 보이지 않는 요소이므로 마지막에 처리하는 것이 합리적입니다.

2. 렌더링 과정: 두 번의 패스

Offscreen 컴포넌트의 렌더링은 두 번의 패스로 이루어집니다:

첫 번째 패스 (hidden 상태):

// 첫 번째 패스에서는 렌더링을 지연시킵니다
if (!includesSomeLane(renderLanes, OffscreenLane)) {
  // 후속 렌더링을 위해 baseLanes를 준비합니다
  let nextBaseLanes;
  if (prevState !== null) {
    const prevBaseLanes = prevState.baseLanes;
    nextBaseLanes = mergeLanes(prevBaseLanes, renderLanes);
  } else {
    nextBaseLanes = renderLanes;
  }
  
  // OffscreenLane으로 재렌더링을 스케줄링합니다
  workInProgress.lanes = workInProgress.childLanes = laneToLanes(OffscreenLane);
  
  // 상태 설정 및 bailout
  const nextState = {
    baseLanes: nextBaseLanes,
    cachePool: spawnedCachePool,
  };
  workInProgress.memoizedState = nextState;
  workInProgress.updateQueue = null;
  
  // bailout을 위해 null 반환
  return null;
}

이 코드를 보면, React는 첫 번째 패스에서 Offscreen 컴포넌트를 만나면 바로 null을 반환하여 자식 컴포넌트의 렌더링을 중단하고, 대신 해당 컴포넌트를 OffscreenLane에서 나중에 처리하도록 스케줄링합니다.

두 번째 패스 (OffscreenLane에서 렌더링):

// 두 번째 렌더링 - 숨겨진 트리 렌더링
const nextState = {
  baseLanes: NoLanes,
  cachePool: null,
};
workInProgress.memoizedState = nextState;

// 첫 번째 패스에서 스킵된 레인 사용
const subtreeRenderLanes = prevState !== null ? prevState.baseLanes : renderLanes;
pushRenderLanes(workInProgress, subtreeRenderLanes);

두 번째 패스에서는 첫 번째 패스에서 저장해둔 baseLanes를 사용해 자식 컴포넌트를 렌더링합니다. 이때는 return null이 없으므로 자식 컴포넌트의 렌더링이 계속 진행됩니다.

3. DOM 조작 및 실제 숨김 처리

눈에 보이는 실제 숨김 처리는 커밋 단계에서 이루어집니다

// commitMutationEffectsOnFiber
if (flags & Visibility) {
  const newState = finishedWork.memoizedState;
  const isHidden = newState !== null;
  const offscreenBoundary = finishedWork;
  
  // 모든 자식 요소를 숨기거나 표시합니다
  hideOrUnhideAllChildren(offscreenBoundary, isHidden);
}

hideOrUnhideAllChildren 함수는 DOM 트리에서 첫 번째 호스트 컴포넌트(실제 DOM 노드)를 찾아 CSS로 숨기거나 표시합니다

export function hideInstance(instance: Instance): void {
  instance = ((instance: any): HTMLElement);
  const style = instance.style;
  if (typeof style.setProperty === "function") {
    style.setProperty("display", "none", "important");
  } else {
    style.display = "none";
  }
}

놀랍게도 이 모든 복잡한 프로세스의 결과는 단순히 display: none으로 DOM 요소를 숨기는 것입니다. 하지만 그 과정에서 React는 렌더링 우선순위를 효과적으로 관리하고 있습니다.

왜 이렇게 복잡한 방식을 사용할까요?

단순히 CSS로 요소를 숨기는 것만으로도 비슷한 효과를 낼 수 있는데, 왜 React는 이렇게 복잡한 메커니즘을 구현했을까요? 그 이유는 Concurrent Mode의 핵심 목표 때문입니다.

Concurrent Mode의 핵심은 렌더링 작업의 우선순위를 세밀하게 제어하는 것입니다. Offscreen 컴포넌트의 복잡한 구현을 통해 React는

  1. 사용자에게 보이는 UI 요소를 높은 우선순위로 처리
  2. 보이지 않는 요소는 낮은 우선순위로 미루기
  3. 시스템 리소스를 효율적으로 사용하기

이런 목표를 달성할 수 있습니다. 결국 이는 사용자 경험을 향상시키는 데 큰 도움이 됩니다.

Offscreen 컴포넌트의 활용 가능성

비록 아직 공식적으로 공개되지 않았지만, Offscreen 컴포넌트는 다음과 같은 상황에서 유용하게 활용될 수 있을 것입니다:

  1. 탭 인터페이스 - 현재 보이지 않는 탭의 내용을 낮은 우선순위로 렌더링하면서도 데이터는 미리 로드
  2. 모달 또는 팝업 - 열기 전에 미리 렌더링하되 우선순위는 낮게 설정
  3. 무한 스크롤 - 아직 화면에 보이지 않지만 곧 보여질 콘텐츠를 미리 준비
  4. 프리페칭과 프리렌더링 - 사용자 상호작용을 예측하여 미리 컨텐츠 준비

정리: Offscreen 컴포넌트의 핵심 작동 방식

Offscreen 컴포넌트의 작동 방식을 요약하면 다음과 같습니다

  1. 상태 관리 - 컴포넌트는 visible 또는 hidden 상태를 가집니다.
  2. 렌더링 지연 - hidden 상태일 때 첫 번째 패스에서 bailout하고 OffscreenLane으로 렌더링을 지연시킵니다.
  3. 두 번째 패스 - 지연된 렌더링이 OffscreenLane에서 진행됩니다.
  4. visibility 플래그 - 상태 변경 시 Visibility 플래그가 설정됩니다.
  5. DOM 처리 - 커밋 단계에서 display: none으로 DOM 요소를 숨깁니다.

이런 과정을 통해 React는 보이지 않는 컴포넌트의 렌더링 우선순위를 낮추고, 사용자에게 중요한 UI 요소에 리소스를 집중할 수 있습니다.

마무리

Offscreen 컴포넌트는 React 18의 Concurrent Mode와 함께 도입되었지만 아직은 숨겨진 기능입니다. 이 컴포넌트의 내부 동작 원리를 이해하면 Suspense와 Concurrent Mode가 어떻게 작동하는지, 그리고 React가 어떻게 렌더링 우선순위를 관리하는지에 대한 개념을 조금 더 이해 할 수 있을것입니다.

비록 현재는 직접 사용할 수는 없지만, React 팀이 미래에 이 컴포넌트를 공개한다면, 이미 그 원리를 이해하고 있는 개발자들은 큰 이점을 얻게 될 것입니다. 특히 프론트엔드 개발자로서 이런 내부 구현에 대한 이해는 더 효율적인 애플리케이션을 만드는 데 큰 도움이 될 것입니다.

profile
안녕하세요

0개의 댓글