React에서 Hook 객체를 관리하는 방법

우혁·2025년 1월 20일
41

React

목록 보기
20/20

React에서 Hook 객체를 관리하는 방법

React는 useState, useEffect, useRef 등 React Hook 객체들을 연결 리스트(Linked List) 구조로 관리한다.

💡 연결 리스트(Linked List)
각 데이터(노드)가 다음 데이터를 가리키는 포인터로 연결된 자료구조이다.

  • 기차처럼 각 칸(노드)이 다음 칸을 가리키는 방식으로 연결되어 있으며, 마지막 칸은 null을 가리켜 끝을 표시한다.
  • 데이터의 삽입과 삭제가 쉽고 크기를 자유롭게 변경할 수 있다는 장점이 있지만 중간 노드에 접근하려면 첫 노드부터 순회해야 한다는 단점이 있다.

아래 사진과 같은 코드가 있을 때 useState의 Hook 객체 next 속성에 useRef Hook 객체가 연결 되어 있고, useRef Hook 객체의 next 속성에 useEffect Hook 객체가 연결되는 방식이다.
(useState Hook next ➔ useRef Hook next ➔ useEffect Hook)

useEffect가 마지막 Hook이기 때문에 해당 Hook 객체 next 속성에는 아무 값도 들어가지 않아 null이 할당된다.

연결 리스트로 Hook 객체를 관리하는 방식은 크게 두 가지(초기 마운트, 업데이트)로 나누어진다.
이 두 가지 방식을 React ver19.0.0의 내부 소스 코드를 통해 알아보려고 한다.


초기 마운트 시 훅 관리

초기 마운트에는 mountWorkInProgressHook라는 함수를 통해 훅 객체를 생성하고 연결하는 과정이 이루어진다.

mountWorkInProgressHook 함수 전체 코드 보기

용어 설명

currentlyRenderingFiber: 현재 렌더링 중인 컴포넌트의 파이버 노드를 의미한다.
workInProgressHook: 현재 렌더링에서 작업 중인 훅을 가리킨다.

function mountWorkInProgressHook(): Hook {
  // 1. 훅 객체 생성
  const hook: Hook = {
 	memoizedState: null, // 가장 최근에 커밋된 업데이트 이후의 상태 값
  	baseState: null, // 업데이트 큐를 처리하기 전의 초기 상태(함수형 업데이트에도 사용)
  	baseQueue: null, // 이전 렌더링에서 처리되지 않은 업데이트들의 큐
  	queue: null, // 상태 업데이트를 위한 디스패치 함수들과 업데이트 정보를 담고 있는 큐
  	next: null, // 연결 리스트에서 다음 훅을 가리키는 포인터
  };

  // 2. 훅 연결 리스트 구성
  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    workInProgressHook = workInProgressHook.next = hook;
  }
  
  return workInProgressHook;
}

1. 훅 객체 생성

  • 새로운 훅을 생성하는 과정으로 훅 객체의 모든 속성 값을 null로 초기화하여 생성한다.

2. 훅 연결 리스트 구성

  • 연결 리스트의 첫 번째 Hook인 경우(mountWorkInProgressHook 함수가 처음 호출된 경우)
    • currentlyRenderingFiber.memoizedState에 새로운 Hook을 할당하여 연결 리스트의 시작점으로 설정
    • workInProgressHook을 새로 추가된 Hook으로 업데이트

🤷‍♂️ 파이버의 memoizedState에 훅 객체를 저장하는 이유
컴포넌트의 모든 훅들을 연결 리스트 형태로 저장하여 다음 렌더링에서 동일한 순서로 훅을 실행하고 상태를 유지하기 위함이다.

  • 연결 리스트의 첫 번째 Hook이 아닌 경우(이미 mountWorkInProgressHook 함수가 호출된 경우)
    • 현재 workInProgressHook의 next에 새로운 Hook을 연결
    • workInProgressHook을 새로 추가된 Hook으로 업데이트

💡 mountWorkInProgressHook 함수는 새로운 Hook 객체를 생성하고 이를 연결 리스트 형태로 구성하여 컴포넌트의 Hook들을 관리한다. 이 연결 리스트는 파이버의 memoizedState에 저장되어 렌더링 간에 Hook의 순서와 상태를 보장한다.


업데이트 시 훅 관리

업데이트 시에는 updateWorkInProgressHook 함수를 사용하여 Hook 객체를 관리한다.

updateWorkInProgressHook 함수 전체 코드 보기

💡 React는 현재 렌더링이 초기 마운트인지, 업데이트인지 어떻게 구분할까?
컴포넌트를 Hook과 함께 실행시키는 renderWithHooks이라는 함수에서 어떤 Hook을 사용할 것인지 결정한다.

ReactSharedInternals.H =
  // 이전 렌더링 결과가 없거나 이전 렌더링의 상태(훅 객체)가 존재하지 않다면 마운트로 처리
  current === null || current.memoizedState === null
    ? HooksDispatcherOnMount // 초기 마운트 관련 훅(mountState, mountRef 등)
    : HooksDispatcherOnUpdate; // 업데이트 관련 훅(updateState, updateRef 등)

용어 설명

  • alternate: 현재 트리의 파이버 노드가 가리키는 다른 트리(current ↔ workInProgress)의 대응되는 파이버 노드
  • currentHook: current 트리에서 현재 처리 중인 Hook을 가리키는 포인터
  • nextCurrentHook: current 트리에서 다음에 처리할 Hook을 가리키는 포인터
  • workInProgressHook: workInProgress 트리에서 현재 작업 중인 Hook을 가리키는 포인터
  • nextWorkInProgressHook: workInProgress 트리에서 다음에 처리할 Hook을 가리키는 포인터

💡 currentHook, nextCurrentHook은 이전 렌더링의 훅 상태를 재사용하기 위해 사용되고,
workInProgressHook, nextWorkInProgressHook 현재 렌더링의 훅 상태를 관리하기 위해 사용된다.

function updateWorkInProgressHook(): Hook {
  // 1. 다음 Hook 포인터 설정
  let nextCurrentHook: null | Hook;
  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate;
    if (current !== null) {
      nextCurrentHook = current.memoizedState;
    } else {
      nextCurrentHook = null;
    }
  } else {
    nextCurrentHook = currentHook.next;
  }

  // 2. 작업 중인 Hook 확인
  let nextWorkInProgressHook: null | Hook;
  if (workInProgressHook === null) {
    nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
  } else {
    nextWorkInProgressHook = workInProgressHook.next;
  }

  // 3. Hook 재사용 또는 생성
  if (nextWorkInProgressHook !== null) {
    workInProgressHook = nextWorkInProgressHook;
    nextWorkInProgressHook = workInProgressHook.next;
    currentHook = nextCurrentHook;
  } else {
    if (nextCurrentHook === null) {
      const currentFiber = currentlyRenderingFiber.alternate;
      if (currentFiber === null) {
        throw new Error(
          "초기 렌더에서 업데이트 훅이 호출되었습니다. 이것은 React의 버그일 가능성이 큽니다."
        );
      } else {
        throw new Error("이전 렌더링 중보다 더 많은 훅이 렌더링되었습니다.");
      }
    }

    currentHook = nextCurrentHook;

    const newHook: Hook = {
      memoizedState: currentHook.memoizedState,
      baseState: currentHook.baseState,
      baseQueue: currentHook.baseQueue,
      queue: currentHook.queue,
      next: null,
    };

    if (workInProgressHook === null) {
      currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
    } else {
      workInProgressHook = workInProgressHook.next = newHook;
    }
  }
  return workInProgressHook;
}

1. 다음 Hook 포인터 설정(current 트리의 Hook 체인)

  • 첫 번째 Hook을 처리하는 경우(updateWorkInProgressHook 함수가 처음 호출된 경우)

    • 현재 파이버의 alternate가 존재한다면 해당 파이버의 memoizedState 에서 시작점을 가져온다.
    • 현재 파이버의 alternate가 존재하지 않는다면 초기 렌더링임을 의미하므로 null로 초기화 해준다.
  • 첫 번째 Hook이 아닌 경우(이전에 updateWorkInProgressHook 함수가 호출된 경우)

    • next 속성을 통해 다음 훅으로 포인터를 이동한다.

2. 작업 중인 Hook 확인(workInProgress 트리의 Hook 체인)

  • 첫 번째 Hook을 처리하는 경우 현재 렌더링 중인 파이버의 memoizedState에서 시작점을 가져온다.

  • 첫 번째 Hook이 아닌 경우 현재 진행 중인 Hook의 next를 사용한다.

3. Hook 재사용 또는 생성

  • Hook 재사용

    • 이미 작업 중인 Hook이 있다면 그대로 재사용
    • 포인터들을 다음 Hook으로 이동
    • 컴포넌트 내부에서 setState를 호출하여 리렌더링이 발생한 경우 이 경로를 통해 처리
  • 새로운 Hook 생성

    • 이전 Hook의 상태를 복사하여 새로운 Hook 객체 생성
    • 연결 리스트에 새로운 Hook 추가
    • 컴포넌트가 외부 요인(부모 리렌더링, Context 변경 등)으로 인해 리렌더링되는 경우 이 경로를 통해 처리
  • 에러 처리
    • 초기 렌더링인데 updateWorkInProgressHook 함수가 호출된 경우 ➔ 에러 발생
    • 이전 렌더링보다 더 많은 훅을 렌더링한 경우 ➔ 에러 발생

💡 updateWorkInProgressHook 함수는 이전 렌더링의 Hook 상태를 기반으로 작업 중인 Hook 연결 리스트를 구성하며, 이 과정에서 훅의 순서를 보장하고 상태를 유지한다.


정리하기

초기 마운트

컴포넌트가 처음 렌더링될 때는 mountWorkInProgressHook 함수를 통해 훅을 관리한다.

  1. 새로운 Hook 객체를 생성하고 모든 속성을 null로 초기화
  2. 생성된 Hook을 연결 리스트에 추가
    • 첫 번째 Hook인 경우 파이버의 memoizedState에 저장
    • 첫 번째 Hook이 아닌 경우 Hook 객체의 next에 연결

업데이트

컴포넌트가 리렌더링될 때는 updateWorkInProgressHook 함수를 통해 훅을 관리한다.

  1. 내부 상태 변경으로 인한 리렌더링
    • 기존 Hook을 재사용
  2. 외부 요인으로 인한 리렌더링
    • 이전 Hook의 상태를 복사하여 Hook 생성

React가 내부/외부 요인에 따라 Hook을 다르게 처리하는 이유

내부 상태 변경(Hook 재사용)

컴포넌트가 setState를 호출하는 경우

  • 동일한 컴포넌트 인스턴스 내에서 상태가 변경되는 것
  • 이미 생성된 Hook이 최신 상태를 가지고 있음
  • 기존 Hook을 재사용하는 것이 효율적
function Counter() {
  const [count, setCount] = useState(0);
  // setCount 호출 시 같은 Hook 인스턴스를 재사용
  return <button onClick={() => setCount((prev) => prev += 1)}>{count}</button>;
}

외부 요인(새로운 Hook 생성)

부모 컴포넌트의 리렌더링이나 Context 변경 등의 경우

  • 컴포넌트가 새로운 인스턴스로 실행됨
  • 이전 렌더링과 현재 렌더링이 독립적
  • 이전 상태를 복사한 새로운 Hook을 생성하여 격리된 환경 보장
function Parent() {
  const [theme, setTheme] = useState('light');
  return (
    <Child theme={theme} /> // theme이 변경되면 Child는 새로운 Hook 인스턴스 생성
  );
}

조건부 훅 호출이 제한되는 이유

위에서 알아본 것 처럼 React에서 Hook은 연결 리스트 구조로 관리하기 때문에 호출하는 순서에 의존하여 Hook의 상태를 식별한다.

만약 조건부로 Hook을 호출하면 실행 순서가 변경될 수 있어 React가 올바른 상태를 추적할 수 없기 때문에 조건부 Hook 호출을 제한하여 항상 동일한 순서로 실행되어야 한다는 규칙을 강제한다.

function Component() {
  const [count, setCount] = useState(0); // Hook 1
  const [age, setAge] = useState(20); // Hook 2
  if (count > 0) {
    const [name, setName] = useState(""); // Hook 3
  }

  return (
    <button type="button" onClick={() => setCount((prev) => (prev += 1))}>
      {count} - 버튼
    </button>
  );
}

버튼을 클릭하여 카운트를 증가시키면 조건부 Hook 호출 규칙을 위반하였기 때문에 Hook 객체의 숫자가 늘어나 아래와 같은 에러가 발생한다.

profile
🏁

0개의 댓글

관련 채용 정보