useState의 Lazy Initialization 완전 정복 — 왜 써야 하고 무엇이 다른가?

iberis2·약 5시간 전

React 리액트

목록 보기
21/22

리액트에서 useState를 사용할 때, 우리는 종종 다음과 같은 코드를 작성하곤 합니다.

const Example = () => {
  const initVal = [1,2,3,4,5].reduce((acc, cur) => acc + cur, 0);
  const [state, setState] = useState(initVal);
  return <div>{state}</div>;
};

문제는 이 코드가 리렌더링마다 불필요한 계산을 반복한다는 것입니다.
이러한 낭비를 막기 위해 React가 제공하는 기능이 바로 Lazy Initialization(지연 초기화) 입니다.

이번 글에서는

  • 왜 Lazy Initialization이 필요한지
  • React 내부에서 어떻게 동작하는지
  • 컴포넌트 밖에서 초기값을 선언하는 방법과는 무엇이 다른지
  • 언제 어떤 방식을 써야 하는지
  • 잘 못 사용했을 때 생길 수 있는 성능 문제 시나리오
  • (참고) 공식문서에서 설명하는 Lazy Initialization
  • (참고) 리액트의 리렌더링과 Lazy Initialization

를 실전 코드와 함께 명확하게 정리해봅니다.


1. 문제 상황: 초기값 계산이 매 렌더링마다 실행된다

다음 코드를 보면, reduce() 계산은 컴포넌트가 렌더링될 때마다 다시 실행됩니다.

const Example = () => {
  const initVal = [1,2,3,4,5].reduce((acc, cur) => acc + cur, 0);
  const [state, setState] = useState(initVal);
  return <div>{state}</div>;
};

이 계산이 무거운 연산이라면(예: JSON 파싱, 복잡한 계산, localStorage 읽기, API 유사 연산 등)
컴포넌트가 리렌더링될 때마다 성능을 갉아먹게 됩니다.

사실 이 값은 초기 렌더링 시 한 번만 계산하면 충분한 값입니다.
그 후에는 React가 state를 보관해 주기 때문에 다시 계산할 필요가 없죠.


2. 해결: useState Lazy Initialization

React는 이러한 불필요한 재계산을 막기 위해
초기값을 값(value) 이 아닌 함수(function) 형태로 전달할 수 있게 제공합니다.

const Example = () => {
  const [state, setState] = useState(
    () => [1,2,3,4,5].reduce((acc, cur) => acc + cur, 0)
  );
  return <div>{state}</div>;
};

이제 reduce()는 다음 상황에서만 실행됩니다.

  • 이 컴포넌트가 mount될 때 단 한 번만 실행
  • 이후에는 절대 다시 계산되지 않음 (리렌더링과 무관)

3. Lazy Initialization은 어떻게 동작할까? (React 내부 코드)

React의 실제 동작을 간단히 요약해 보면 다음과 같습니다.

initialState // type -> () => S | S

if (typeof initialState === 'function') {
  const initialStateInitializer = initialState;
  initialState = initialStateInitializer();
}

즉,

  1. 초기값이 함수면, React는 그 함수를 실행해 그 반환값을 초기 state로 저장한다.
  2. 이후 업데이트 시에는 이 함수를 다시 호출하지 않는다.
  3. 초기값 함수는 오직 mount 시에만 호출된다.

이것이 Lazy Initialization이 “지연”이라는 이름을 가진 이유입니다.


🤔 4. 그렇다면 컴포넌트 밖에서 초기값을 계산하면 Lazy Init과 동일할까?

예를 들어 다음과 같이 작성하는 경우입니다:

const initVal = [1,2,3,4,5].reduce((acc, cur) => acc + cur, 0);

const Example = () => {
  const [state, setState] = useState(initVal);
  return <div>{state}</div>;
};

겉보기에는 한 번만 계산되는 것처럼 보일 수 있습니다.
하지만 이 방식은 Lazy Init과 완전히 동일하지 않습니다.


5. Lazy Initialization과의 차이점

다음 표로 비교해보겠습니다.

항목Lazy Initialization컴포넌트 밖 계산
계산 시점컴포넌트 mount 시앱 로드 시 즉시 실행
불필요한 계산 지연가능불가능
props 기반 초기화 가능가능불가능
컴포넌트가 실제로 렌더링될 때만 계산OX
컴포넌트가 여러 번 mount될 때mount마다 새 계산전역 상수 재사용

🔍 핵심 차이 요약

✔ 1) 계산 시점이 다르다

컴포넌트 밖 초기화는 앱이 시작될 때 이미 계산을 실행해버린다.

Lazy Init은 해당 컴포넌트가 실제로 렌더링되기 전까지 계산을 미룸.

✔ 2) props 기반 초기화는 Lazy Init만 가능

예:

const Example = ({ numbers }) => {
  const [sum] = useState(() => numbers.reduce(...));
};

컴포넌트 밖에서는 numbers를 사용할 수 없다.

✔ 3) 컴포넌트별 초기값이 필요할 때 Lazy Init이 더 적합

컴포넌트가 여러 번 mount되면 Lazy Init은 mount마다 독립적으로 계산을 실행하지만,
전역 초기값은 항상 동일한 값을 재사용한다.


6. 언제 어떤 방식을 사용할까?

🟢 Lazy Initialization을 사용해야 하는 상황

  • 초기값 계산 비용이 큰 경우
  • props 또는 외부 상태에 따라 초기값이 달라지는 경우
  • 컴포넌트 mount 시점까지 계산을 미루고 싶은 경우
  • SSR 또는 조건부 렌더링 환경에서 안전하게 초기화가 필요할 때

🟡 컴포넌트 밖 초기값이 적합한 경우

  • 초기값이 단순 상수일 때
  • 앱 전체에서 공통 상수로 재사용하는 경우
  • props나 다른 동적 값과 무관할 때

7. 잘못 사용했을 때 생길 수 있는 성능 문제 시나리오

Lazy Initialization을 이해하는 가장 좋은 방법 중 하나는,
“이걸 안 썼을 때 어떤 문제가 생기는지”를 보는 것입니다.

7-1. 무거운 계산을 매 렌더마다 실행하는 경우

const Example = ({ data }) => {
  // ❌ 데이터가 클수록 렌더링 비용이 점점 커진다
  const parsed = JSON.parse(JSON.stringify(data)); // deep copy 같은 무거운 작업이라고 가정
  const [state, setState] = useState(parsed);

  // state 업데이트 → 리렌더 → 또 JSON.parse 실행…
  // 실제로는 초기값으로 한 번만 필요했는데 계속 반복됨
  return <div>{state.length}</div>;
};
  • setState가 호출될 때마다 이 컴포넌트는 리렌더링되고,
  • 리렌더링마다 JSON.parse 같은 무거운 연산을 매번 다시 수행합니다.
  • 하지만 우리가 정말로 원했던 건 “처음에 한 번만 deep copy해서 초기값으로 쓰기”였죠.

이럴 때 Lazy Init을 사용하면:

const Example = ({ data }) => {
  const [state, setState] = useState(() => {
    return JSON.parse(JSON.stringify(data));
  });

  return <div>{state.length}</div>;
};

이제 JSON.parse초기 mount 시에 단 한 번만 호출되고,
그 이후에는 state 값만 업데이트/재사용됩니다.


7-2. localStorage, sessionStorage 접근 시

const Example = () => {
  // ❌ 매 렌더링마다 localStorage를 읽음
  const saved = localStorage.getItem('theme') ?? 'light';
  const [theme, setTheme] = useState(saved);

  return <div>{theme}</div>;
};

브라우저 스토리지 접근은 생각보다 비용이 크고, I/O 성격을 띄기 때문에
매 렌더마다 호출하는 건 좋지 않은 패턴입니다.

const Example = () => {
  const [theme, setTheme] = useState(() => {
    return localStorage.getItem('theme') ?? 'light';
  });

  return <div>{theme}</div>;
};

이렇게 Lazy Init을 쓰면,

  • 처음 mount될 때만 localStorage에 접근하고
  • 이후 리렌더링에서는 스토리지를 다시 건드리지 않으므로 불필요한 I/O를 줄일 수 있습니다.

8. (참고) React 공식 문서와 Lazy Initialization

🔗 React 공식 문서에서도 useState의 초기값으로 함수를 넘길 수 있다는 점을 명시하고 있습니다.

If the initial state is the result of an expensive computation, you may provide a function instead, which will be executed only on the initial render.
useState에 함수를 넘기면, React는 그 함수를 초기 렌더링 시에만 호출하고
그 반환값을 초기 state로 사용합니다. (의역)

이를 사용하면 비용이 큰 초기화 로직의 실행을 지연시킬 수 있습니다.

정리하면 공식 문서도 다음을 권장하고 있다고 볼 수 있습니다.

  • 비용이 큰 초기화 로직을 가지고 있다면
    useState(() => heavyWork()) 형태로 Lazy Initialization을 사용하는 것이 좋다.

즉, 이 패턴은 단순 트릭이나 내부 구현 디테일이 아니라,
React가 공식적으로 지원하고 있는 성능 최적화 패턴입니다.


9. (참고) “Why does React re-render?” 와 Lazy Initialization

Lazy Initialization을 이해할 때
“React는 왜 리렌더링을 할까?”를 같이 생각해보면 더 직관적으로 와닿습니다.

기본적으로 React는 다음과 같은 이유로 컴포넌트를 리렌더링합니다.

  1. props가 변경되었을 때
  2. state가 변경되었을 때
  3. 부모 컴포넌트가 리렌더링되면서 자식도 다시 렌더링될 때
  4. Context 값이 변경되었을 때

이때 중요한 포인트는:

리렌더링이 될 때마다, 함수형 컴포넌트는 다시 호출된다는 점입니다.

즉,

const Example = () => {
  const initVal = heavyWork();
  const [state, setState] = useState(initVal);
  ...
}

이 컴포넌트는 리렌더링될 때마다 다시 호출되므로
heavyWork()매 리렌더마다 다시 호출됩니다.

반면 Lazy Initialization은, 리렌더링이 되어도
heavyWork()가 다시 호출되지 않도록 막아줍니다.

const Example = () => {
  const [state, setState] = useState(() => heavyWork());
  ...
}

여기서 heavyWork()

  • 초기 렌더에서 useState가 실행될 때 한 번
  • 이후에는 절대 다시 실행되지 않음

React가 리렌더링을 자주 하더라도, 불필요한 초기 계산이 반복되지 않도록 보호해 주는 것입니다.


정리: Lazy Initialization은 “초기값 계산을 최적화하는” React의 핵심 기능

useState의 Lazy Initialization은 단순히 “초기값을 함수로 넘기는 문법적 선택”이 아닙니다.

React의 렌더링 방식과 매우 깊게 연결된 구조적인 최적화 기법입니다.
컴포넌트가 리렌더될 때 함수형 컴포넌트가 다시 호출된다는 특성 때문에 초기값 계산을 그대로 두면 불필요한 연산이 반복됩니다. 이 문제를 해결하기 위해 React는 초기값 계산을 컴포넌트가 mount되는 시점으로 명확히 고정해 줄 수 있는 Lazy Initialization을 제공합니다.

정리하면 Lazy Initialization은 다음과 같은 역할을 수행하는 기능입니다:

  1. 비용이 큰 초기값 연산을 컴포넌트 mount 시 딱 한 번만 실행한다.
    → 리렌더링이 발생해도 초기값 계산은 다시 실행되지 않는다.

  2. 불필요한 계산을 줄여 렌더링 성능을 크게 개선할 수 있다.

  3. 컴포넌트 밖에서 초기값을 계산하는 방식과 비슷해 보이지만,
    초기화 시점·props 의존성·컴포넌트별 독립성 등에서 중요한 차이가 존재한다.
    → Lazy Init은 더 유연하고 정확한 초기화 방식을 제공한다.

  4. React 공식 문서에서도 명확하게 권장하는 성능 최적화 기법이다.

즉, Lazy Initialization은 초기 state 계산을 제어함으로써
불필요한 렌더링 비용을 줄이고 컴포넌트의 예측 가능성과 안정성을 높여주는 React의 정교한 기능이다.

복잡한 연산, 스토리지 접근, 동적 초기값 계산 등 초기화 비용이 큰 상황에서 Lazy Initialization을 적극 활용하면 더 효율적이고 성능 친화적인 React 컴포넌트를 작성할 수 있습니다.

profile
자동화와 기록으로 더 효율적으로 일하는 (게)으른 개발자가 되려고 합니다.

0개의 댓글