전체 글 보기

React 게으른 초기화 (lazy initialization)와 원리 알아보기

profile icon

React useState의 게으른 초기화(lazy initialization)로 성능을 최적화하는 방법을 알아보고 그 동작 원리를 React 소스 코드 분석을 통해 이해해보자.

#React
마지막 수정일:

React에서 게으른 초기화는 상태(state) 초기화 시점에서 초기화 작업이 비용이 많이 들거나 시간이 오래 걸릴 때 유용하게 사용할 수 있는 기술이다.

예를 들어 다음과 같은 코드를 보자. 아래 코드는 React 컴포넌트가 리렌더링될 때마다 useState의 초기값으로 전달된 함수가 매번 호출되는 문제가 있다.

tsx
const veryHeavyWork = () => {
  console.log('very heavy work called');

  // localStorage 접근, 복잡한 계산, API 호출 등
  // 시간이 오래 걸리는 작업들...

  return { data: 'expensive data' };
};

export const ProblemComponent = () => {
  // 문제: 리렌더링마다 veryHeavyWork가 호출됨
  const [expensiveState, setExpensiveState] = useState(veryHeavyWork());
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        증가 (리렌더링 발생)
      </button>
    </div>
  );
};

위 코드에서 "증가" 버튼을 클릭할 때마다 veryHeavyWork 함수가 호출된다. 하지만 expensiveState는 이미 초기화되어 react-reconciler에 의해 memoized 되었기 때문에 이 함수의 반환값은 무시되고, 오직 성능 낭비만 발생한다.

console1.webp

이런 경우 게으른 초기화를 사용하면, 리렌더링이 되더라도 함수를 다시 실행시키지 않을 수 있다.

방법은 간단하다. 함수를 실행한 결과값을 전달하는 것이 아닌, 함수 자체를 전달하면 된다.

tsx
export const OptimizedComponent = () => {
    // 해결책: 함수 자체를 전달
  const [expensiveState, setExpensiveState] = useState(() => veryHeavyWork());
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        증가 (최적화됨)
      </button>
    </div>
  );
};
console2.webp

즉, 함수의 실행 시점을 개발자가 직접 정하는 것이 아닌 React에게 위임함으로써 해결할 수 있다. 이와 같이 필요할 때까지 실행을 미루는 기법 때문에 "Lazy Initialization"라는 이름이 붙었다.

useState에 인자를 전달하기 전, JavaScript 코드 평가 시점에 veryHeavyWork 함수가 먼저 실행된다. 함수에서 반환된 값은 initialState에 담기고, react-reconciler가 그 값을 memoizedState에 저장한다.

ts
const [expensiveState, setexpensiveState] = useState(veryHeavyWork());

리렌더링은 컴포넌트 함수를 다시 실행하는 것이므로, useState에 인자를 전달하기 전에 veryHeavyWork 함수가 매번 호출된다. react-reconciler가 hook의 memoizedState에 저장된 기존 값을 사용하기 때문에 이 반환값은 무시된다. 즉, 의미 없는 실행이 일어난 것이다.

아래와 같이 작성하면 veryHeavyWork 함수를 실행하기 전에 useState에 함수가 값으로서 전달된다. JavaScript가 코드를 평가하는 시점에는 실행되지 않는다.

ts
const [expensiveState, setexpensiveState] = useState(() => veryHeavyWork());

React가 이 판단을 어떻게 하는지 React 소스 코드를 통해 살펴보자.

초깃값이 들어오면 mountState가 실행된다. return 값으로 memoizedState와 dispatch를 반환하는걸 볼 수 있다. 이 값들이 우리가 사용하게 되는 const [state, setState] = useState() 이다.

ts
function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  // 생략...
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

구현체인 mountStateImpl을 살펴보면, 초기값이 함수로 들어올 경우 그 함수를 실행시킨 결과값을 initialState에 담고, 그 값을 memoizedState에 저장하는 것을 확인할 수 있다.

ts
function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    const initialStateInitializer = initialState;
    initialState = initialStateInitializer();
    // 생략...
  }
  hook.memoizedState = hook.baseState = initialState;
  // 생략..
  return hook;
}

리렌더링 시에는 rerenderStatererenderReducer 순으로 동작한다. 기존에 저장된 memoizedState를 가져와 반환할 뿐, 초기화 함수는 다시 실행하지 않는다.

ts
function rerenderReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook();
  let newState = hook.memoizedState;

  // 이전 렌더링 단계에서 ⁠dispatch가 호출된 경우 실행
  if (lastRenderPhaseUpdate !== null) {
    // 생략
  }
  return [newState, dispatch];
}

위에서 계속 설명한 것과 같이 초깃값을 생성하는데 연산이 오래 걸릴 경우에 쓰면 좋다. 예를 들어, localStoragesessionStorage에 접근할 때, 배열에 관한 메서드를 사용할 때 등이다.

  1. localStorage/sessionStorage 접근
    js
    const [user, setUser] = useState(() => {
      const savedUser = localStorage.getItem('user');
      return savedUser ? JSON.parse(savedUser) : null;
    });
  2. 복잡한 초기 데이터 생성
    js
    const [chartData, setChartData] = useState(() => {
      return generateComplexChartData(rawData);
    });
  3. 큰 배열이나 객체 처리
    js
    const [processedItems, setProcessedItems] = useState(() => {
      return largeDataSet.map(item => ({
        ...item,
        processed: true,
        timestamp: Date.now()
      }));
    });

useState의 게으른 초기화와 useEffect를 통해 컴포넌트가 렌더링 될 때 state를 초기화 하는 것 사이에는 몇 가지 차이가 있다.

전체 글 보기