본문 바로가기
FE

어색했던 Hook의 규칙

by owonie 2023. 11. 13.

\Hook을 사용할 때 지켜야할 두가지 규칙이 있다

Hook의 규칙

옛 버전의 리액트 공식문서를 보고있을 때 였다. 요즘 들어 커스텀 훅을 자체적으로 구현 할 일 이 많아져서 Hook 부분을 보게됐는데, Hook을 사용할 때 지켜야 할 규칙이 있다는 내용을 보게됐다. 처음 보는 내용이라 참신했었는데, 그 내용은 아래와 같다:

  1. 최상위(at the Top Level)에서만 Hook을 호출해야 한다.
  -> 반복문, 조건문 혹은 중첩된 함수 내에서 Hook을 호출하지 말자.
  2. 오직 React 함수 내에서 Hook을 호출해야 한다.
  -> Hook을 일반적인 Javascript 함수에서 호출하지 말자.

규칙에 대한 의문점

최상위가 아닌 조건문에서 Hook을 호출하면, 왜 안될까? Hook의 실행 여부는 리액트 스케줄러에 의존한다고만 알고 있었지, 실제로 어떻게 동작하는진 모르고있었다. 컴포넌트 내부에서 Hook을 사용하면 스케줄러에 첫 렌더링 시 등록을 해주고, 두번째 렌더링 때 부턴 의존성 배열을 참조하여 실행할지에 대한 여부가 결정된다. 이는 리액트의 소스코드를 확인해보면 알 수 있는데, 자세한 내용은 아래에서 다루도록 하겠다.

 

예전에 어떤 블로그에서 이런 내용을 보게되었다:

  useState / useMemo => 의존성 배열을 넣어주면, 스케줄러에 조건부로
  빠지게 됩니다. 스케줄러에 등록은 되지만 리렌더링만 하지 않는다는 뜻입니다.

나의 의구심은 여기서 시작이 되었다. 내가 생각한 스케줄러의 목록은 배열 구조를 기반으로 동작했기 때문이다.

스케줄러 구조에 대한 추측

  1. 의존성 배열의 변수는 스케줄러 목록에 등록된다.
  2. 새로운 렌더링을 할 때 의존성 배열의 변수가 변했을지를 확인하기 위해, 해당 변수를 스케줄러가 구독(subscribe)한다.
  3. 스케줄러는 목록에 구독되어있는 변수들을 등록한 순서대로 조회하며, Hook의 실행 여부를 결정한다.

만약 내 추측대로, 스케줄러의 목록이 리스트 형식으로 저장되어 있다면, 조건부로 Hook을 사용했을 때, 리액트는 해당 Hook을 스킵할 수 도 있다. 모든 Hook들의 의존성 배열 값이 하나의 리스트에 저장된다면, 특정 렌더링 시도에 기존에 잘 작동하던 Hook이 동작을 안하더라도, 스케줄러는 다음 Hook을 진행하면 된다. 그렇다면 Hook의 첫번째 규칙과 모순 된다.

스케줄러 목록의 실체!

스케줄러의 목록은 간단한 리스트가 아니었다. 만약 배열의 자료구조를 가졌다면, 매번 렌더링 시 상태변동에 따른 스케줄링에 과도한 메모리를 사용하게 되며, 수정/삭제에 대한 작업도 시간복잡도 또한 O(n)을 넘어선다. 좀 더 자세히 알아보기 위해 리액트의 소스코드를 일부 가져와봤다.

// packages/react/src/ReactHooks.js

import ReactCurrentDispatcher from './ReactCurrentDispatcher';

function resolveDispatcher() {
  // 중간 코드 생략
  const dispatcher = ReactCurrentDispatcher.current;
  return ((dispatcher: any): Dispatcher);
}

export function useEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  const dispatcher = resolveDispatcher();
  return dispatcher.useEffect(create, deps);
}

export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

Hook 코드를 자세히 살펴보면, dispatcher 객체가 이용되는 걸 볼 수 있는데, 여기서 dispathcer는 리덕스의 그 dispathcer와 같은 역할이라 보면된다. (오히려 리덕스가 따라했을 수 도 있다)
dispatcher의 내부를 확인하기 위해 계속 추적해보았다.

//Breadcrumbsreact/packages/react-reconciler/src/ReactInternalTypes.js

type BaseFiberRootProperties = {
  // The type of root (legacy, batched, concurrent, etc.)
  tag: RootTag;
  // 중간 코드 생략
  // Used to create a linked list that represent all the roots that have
  // pending work scheduled on them.
  next: FiberRoot | null;
  onRecoverableError: (
    error: mixed,
    errorInfo: { digest?: ?string; componentStack?: ?string }
  ) => void;
};

해당 타입은 제일 기본적인 스케줄러의 등록되는 task 단위, 즉 Fiber의 속성을 정의했다. 타입 정의의 마지막 부분을 살펴보면, 'next'라는 attribute가 있는데, 해당 FiberRoot는 다음 FiberRoot을 가르키는 것을 알 수 있다! 유사 리스트 형식을 갖고있지만, 하나의 노드가 다음 노드를 가르키는 방식은 명백히 Linked List의 자료구조를 갖고 있다는 것을 확인할 수 있다.

다시 Hook의 규칙

리액트의 스케줄러의 목록이 Linked List 자료구조를 갖고 있다면, Hook의 법칙을 모두 이해하기 쉽다. 하나의 훅을 호출한다면, 스케줄러 목록에 등록이 된다. 다음 Hook(노드)을 차례대로 실행하려면, 스케줄러 목록에 있는 모든 Hook을 거쳐야하기에, 조건부로 특정 구간을 생략해버리면, 시스템적으로 코드를 정상 실행 할 수 없다.


꼬리말

리액트는 진입장벽이 낮은대신 제대로 다루기 위해선 더 많은 노력이 필요한 것 같다. 가까이 들여다 보면 심연과 같은 무서운 라이브러리다.(어떤 관점에선 프레임워크)