리액트 톺아보기 - Hooks
goidle 님의 리액트 톺아보기, 보아즈 님의 리액트 까보기 시리즈를 참고하여 공부하며 정리한 글입니다. 이 글은 그중에서도 goidle 님의 React 톺아보기 - 03. Hooks_1를 주제로 공부한 내용입니다. 리액트를 더 잘 이해할 수 있도록 자세히 설명해주신 두분께 리액트 개발자로서 진심으로! 감사드립니다. 이 글은 위 두 리소스에 맞춰 리액트 버전 16을 기준으로 하고 있으며, 18 코드와 비교해 볼 수 있는 부분도 중간중간 언급됩니다.
VDOM
Virtual DOM은 일종의 programming concept다. 메모리 안에 저장되어있으며 ReactDOM 등의 라이브러리에 의해 실제 DOM과 동기화된다.

VDOM은 fiber 노드들로 구성된 트리 형태로 구현되어있고, 더블 버퍼링 구조로 DOM에 마운트된 fiber를 의미하는 current 트리와 render phase에서 작업중인 fiber인 workInProgress를 가진다. workInProgress에 있는 fiber는 commit phase를 지나서 current 트리에 속하게 된다. 각 노드는 하나의 child를 가지고, 부모는 return으로 참조한다.
위 이미지에서 보듯 workInProgress 트리의 노드는 current 트리에서 자기복제를 해서 만들어지고, 이 노드들은 서로를 alternate으로 참조한다.
fiber
fiber란 VDOM의 노드 객체인데, react element의 내용이 DOM에 반영되기 위해서는 먼저 VDOM에 추가되어야한다. 이를 위해 확장한 객체가 바로 fiber다. fiber는 컴포넌트의 상태, 라이프사이클, 훅 등을 관리한다.
리액트 라이프사이클
render phase
VDOM을 재조정(reconciliation)하는 단계로, element(Fiber)가 추가/수정/삭제되면 reconciler가 work를 스케줄러에 등록한다. 이때 말하는 work란 reconciler가 컴포넌트의 수정사항을 DOM에 적용하기 위해 수행하는 일을 말한다. 스케줄러는 등록된 work를 타이밍에 맞춰 수행한다.
이때 reconciler의 설계가 리액트 V16부터는 stack에서 fiber architecture로 바뀌었다. Stack 아키텍처를 사용할 경우 스택의 LIFO 특성상 렌더링 순서를 조정할 수 없는데, Fiber 아키텍처를 도입하면서, 순서 조정이 가능해져 순서가 유연한 렌더링을 할 수 있게 되었다.
commit phase
재조정한 VDOM을 동기적으로 DOM에 적용(동기화)하고 라이프사이클을 실행하는 단계다. DOM에 적용을 일괄 처리한 후 리액트가 콜 스택을 비우고 브라우저가 paint한다. 브라우저가 paint를 하기 위해선 리액트 DOM 조작이 완전히 끝난 상태여야한다.
컴포넌트를 호출하면 일어나는 일
JSX로 만들어진 컴포넌트를 호출하면 babel이 React.createElement()
를 해 리액트 엘리먼트를 반환한다.
이 React element는 type, key, props, ref 등을 가진다. 이후 변화가 있는 부분을 VDOM에 새로 적용(reconcile)하는 과정까지를 렌더링이라고 한다.
이후 renderer가 컴포넌트 정보를 DOM에 삽입하는데, 이것을 mount된다고 한다.
이후 브라우저가 DOM을 페인트한다.
Hook을 호출하면 일어나는 일
Hook을 호출하면 Scheduler와도 상호작용을 한 후 reconciler와 상호작용을 한다.
리액트는 Task를 비동기적으로 실행한다. 스케줄러(Scheduler)가 그 Task들의 실행 타이밍을 아는 패키지이다.
Reconciler는 Fiber architecture에서 VDOM 재조정을 담당한다.
React hook은 어디서 오는가
코드에서 hook 호출 -> react/React
-> react/ReactHooks
->
react/ReactCurrentDispatcher
-> react/ReactSharedInternals
->
shared/ReactSharedInternals
-> reconciler
우리가 useState
, useEffect
를 사용할때 react 패키지에서 가져온다. 그 코어 패키지 안으로 들어가면
ReactHooks.js
에서 hook들을 import하고 있다. 이 파일 안에선 ReactCurrentDispatcher.current
를 반환하는 resolveDispatcher()
를 갖고 있다.
그래서 또 ReactCurrentDispatcher.current
를 따라가보면 훅 관련해 구현되어있는 내용이 없다.
즉, react 코어 패키지는 React element 관련 정보만 알고, hook에 대한 정보를 갖고 있지 않는다.
훅은 react element라는 클래스가 인스턴스화된 후, 그 객체의 상태를 관리하는 것이기에 hook과 관련된 세부 내용은 react 패키지에선 알지 못하는 것이 맞다.
훅은 ReactCurrentDispatcher.current
을 통해 외부로부터 주입(DI) 받으며, 리액트 엘리먼트가 reconciler에 의해 fiber로 확장되어야 hook을 포함한다는 걸 알 수 있다.
훅은 shared 패키지에서 import 해오는데, shared 패키지는 모든 패키지가 공유하는 공유 패키지이다.
reconciler의 ReactFiberHooks.js에서 renderWithHooks 함수를 통해 hook을 주입한다.
훅은 react-reconciler
패키지의 ReactFiberHooks.js
이 주입하는데, 이 파일의 renderWithHooks
함수가 훅을 주입하며 컴포넌트를 호출하는 역할을 한다.
위 함수가 하는 일들은 아주 많지만 이런 것들이 있다. 아래의 VDOM 구조 이미지와 함께 보면 좋다.

currentlyRenderingFiber = workInProgress;
: 현재 작업중인 fiber를 전역으로 잡아둠current
가 존재하면current.memoizedState
을nextCurrentHook
에 할당하고, 없으면null
을 할당한다. 위 VDOM 그림에서 Current 노드는 current 트리 안에 존재한다. 즉 이미 마운트되어 dom에 정보가 반영된 파이버(current
)가 있으면nextCurrentHook
에current.memoizedState
을 할당한다. 기존에 들어있던current.memoizedState
가 hook일 거라고도 예상할 수 있다.nextCurrentHook
이 있으면ReactCurrentDispatcher.current
에HooksDispatcherOnMount
를 할당하고, 없으면HooksDispatcherOnUpdate
를 할당한다.nextCurrentHook
,firstWorkInProgressHook
과 같은 전역변수를 현재 작업중인 컴포넌트에서만 사용할 수 있도록 작업이 끝날 경우 초기화시킨다let children = Component(props, refOrContext);
: 컴포넌트 호출. 이때 이 컴포넌트가 마운트되어야하면 전역변수firstWorkInProgressHook
에 훅 리스트가 생성되어 저장된다. 그 후 이 변수를 fiber의memoizedState
에 저장해 훅을 컴포넌트와 매핑시킨다.
5번을 통해 2번을 이해할 수 있다. current.memoizedState
가 존재하면 그 컴포넌트는 첫 생성(mount)가 아니라
update되는 것이고, 훅 리스트 또한 이미 존재한다는 거다. 그렇게 3번에서도 mount/update 여부를 구분해
그에 맞는 훅 구현체(HooksDispatcherOnMount
, HooksDispatcherOnUpdate
, etc)를 사용한다.
Hook 생성과 관련된 구현체들

위 3번에서 등장한 HooksDispatcherOnMount
는 useState 속성으로 mountState
함수를 가진다.
즉, useState를 호출한다는 것은 mountState를 호출하는 것이다.
useState를 호출하면 Hook 객체를 생성한다. 그 Hook 객체는 하나의 큐 객체를 가지고, 그 큐 객체는 하나의 update 객체를 가진다.
mountState

mount 구현체인 mountState
를 타고 들어가서, hook 객체를 생성하는 함수인 mountWorkInProgressHook
으로 들어가본다.
(리액트 버전 18에서부턴 mountStateImpl
이 hook을 반환하고, mountWorkInProgressHook
으로 타고 들어갈 수 있다.)
mountWorkInProgressHook
에서 생성하는 hook은 memoizedState
, queue
(큐 객체), next
(다음 hook을 가리키는 포인터) 등의 속성을
가진다. (버전 16과 18의 속성이 살짝 다르다)
그 다음엔 작업중인 훅(workInProgressHook
)이 없으면 위에서 생성한 hook을 workInProgressHook
에 할당한다.
만약 현재 작업중인 훅이 있으면 그 훅을 workInProgressHook
의 next
로 넣어놓는다.
다시 mountState
로 돌아와서 보면 queue
객체(hook.queue
)를 생성하고,
dispatchAction
에 currentlyRenderingFiber
와 queue
를 바인딩해 dispatch
함수를 만든다.
그리고 [hook.memoizedState, dispatch]
배열을 반환한다. 이 리턴값은 useState
의 반환값이 된다.
훅은 어떻게 상태를 변경하고 컴포넌트를 리렌더링 시키는가
바로 위 문단에서 알아본 대로 setState
는 사실상 dispatch
, 즉 dispatchAction
함수이다.
이 함수는 상태를 변경하고 컴포넌트를 리렌더링하기 위해 크게 4가지 역할을 한다.
update 객체 생성 -> update를 큐에 저장 -> 불필요한 렌더링이 없도록 최적화 -> update를 적용하고자 Work를 scheduler에 예약한다.
위에서 VDOM을 설명할 때 current 트리와 workInProcess(이하 WIP) 트리가 있다고 했다.
dispatchAction
함수에서는 fiber가 currentlyRenderingFiber
(현재 WIP에 있는 fiber)인지 확인하고 그 alternate까지 더블체크한다.
WIP의 fiber가 current에 반영될 때 기존 current와의 연결을 끊고 자기자신을 복제하여 새로운 workInProgress 노드를 만들기 때문이다.
체크해서 만약 현재 렌더링하고 있는 fiber가 없을 경우는 idle 상태로, render phase에 진입하지 않은 상태이다.
주어진 update가 없는 상태의 idle update의 과정을 먼저 알아본다.
idle update
- update 객체 생성: update 객체는
expirationTime
,action
(setState
인자로 넣을 값),next
(다음 객체를 가리키는 포인터) 등을 가진다. - update를 queue에 저장: 그다음 update 객체를 list의 마지막에 넣는다. 그리고 2번째 업데이트부터는 첫번째 update 객체를 마지막 update의 next로 지정해 원형 링크드 리스트로 만든다.
- 불필요 렌더링이 없도록 최적화: fiber와 alternate의
expirationTime===NoWork
이면(스케줄러에 등록된 work가 없으면)return;
을 통해dispatchAction
을 실행 중단한다. - update를 적용하고자 Work를 스케줄링: 3에서 실행 중지하지 않았을 경우
scheduleWork
함수를 통해 Work를 스케줄링한다.
4번에서 Work를 스케줄링하면 idle 상태를 벗어나 render phase에 진입한다.
render phase update
render phase update란 이미 다른 스케줄링된 업데이트가 있는 상황에 추가로 업데이트가 발생한 것이다. 리액트 톺아보기의 예시를 들수 있다.
function FC() {
const [a, setA] = useState(0)
if (a === 1)
setA(2)
return <button onClick={() => setA(1)}></button>
}
위 코드에서 button
을 클릭하면 setA(1)
이 실행된다.
setState
함수가 실행되었으므로 컴포넌트가 다시 렌더링되는데, if(a===1)
문에 걸려 setA(2)
에 닿는다.
render phase update 상태에서는 크게 3가지의 일을 처리한다.
- 해야할 update를 임시 저장
- update가 추가적으로 발생하지 않을때까지 컴포넌트를 호출해 update를 소비
- 소비 중에 render phase update가 끊임없이 발생하면 에러 띄우기
render phase update의 경우 idle update와 달리, Work가 이미 스케줄링 되었으므로 scheduleWork를 하거나 성능 최적화를 할 필요가 없다. 버전 16 리액트 코드와 함께 이 3가지를 살펴보자.
update 임시 저장

render phase에 진입했으므로 전역변수 didScheduleRenderPhaseUpdate
를 true
로 설정한다.
update 객체를 만들고, update들을 담을 전역변수 Map(renderPhaseUpdates
)을 만든다.
이 임시 저장소가 있어야 다음 컴포넌트 호출 때 update를 꺼내 소비할 수 있다.
update를 queue 객체를 키로 삼아 이 renderPhaseUpdates
에 임시 저장해둔다.
renderPhaseUpdates 소비

위 단계에서 저장한 update는 renderWithHooks 함수가 소비한다.
렌더링 횟수를 카운트하며 nextCurrentHook
, currentHook
, workInProgressHook
등
update를 소비하는데 필요한 값들을 초기화한다. 그러다 466번째 줄에서 Component를 한번 더 실행했을 때
컴포넌트를 리턴하기 전에 setState가 또 호출될 경우(update가 또 있을 경우) dispatchAction 안의
구문이 재실행되며 위 과정이 반복된다.
반복되는 리렌더링 방지

반복하는 횟수(numberOfReRenders
)가 제한(RE_RENDER_LIMIT
)을 초과하면 Too many rerenders.
메시지를 띄운다.
결론
위에서 알아본 것과 같이 리액트는 훅을 통해 컴포넌트 상태를 업데이트하고, 업데이트를 반영할 Work를 Scheduler에 전달하고, Scheduler는 스케줄링된 task를 적절한 시기에 실행한다. 다음 장에서는 reconciler가 Work를 스케줄링하기 위해 하는 사전작업과 스케줄러가 행하는 일들을 더 자세히 알아본다.