리액트 훑어보기 - 리액트 QnA, 근데 이제 내부동작을 곁들인
4 days ago
글의 내용은 React v18.3.1, 함수형 컴포넌트 기준이며, 작성된 리액트 코드는 설명에 필요한 부분만 사용하였습니다. 전체 코드는 링크를 통해 직접 확인해 볼 수 있습니다.
Q. 리액트를 개발하다 보면 다음의 여러 타입을 접하게 됩니다.
ReactElement, ExoticComponent, ReactNode가 정확히 무엇이고 차이점은 어떻게 되나요?
리액트를 개발하면 대게 가장 먼저 접하게 되는 것은 컴포넌트입니다. 컴포넌트는 JSX 형태로 사용되며, Babel을 통해 다음과 같이 변환됩니다.
const Foo = ...;
const Bar = ({name}) => ...;
const App = () => <Foo><Bar name="bar" /></Foo>
// 바벨 빌드 이후
const App = () => React.createElement(
Foo,
null,
React.createElement(
Bar,
{name: "bar"}
)
);
우리가 JSX로 작성하게 되는 컴포넌트는 React.createElement()
를 통해서 React Element로 생성됩니다. 이것은 컴포넌트의 정보를 객체로 모델링하는 것이며, 자식 참조도 포함하기 때문에 App
은 리액트 앱의 전체 구조를 들고 있는 것과 같습니다.
function createElement(type, config, children) {
const props = {};
let key = null;
let ref = null;
if (config != null) {
ref = config.ref;
key = config.key;
for (const propName in config) {
// key, ref는 제외하고 할당한다.
props[propName] = config[propName];
}
}
const childrenLength = arguments.length - 2;
// 단일 작식이면 children을,여러개이면 배열을 할당한다.
props.children = ...;
return ReactElement(
type,
key,
ref,
...,
props,
);
}
ReactElement.js link
function ReactElement(type, key, ref, props) {
const element = {
$$typeof: REACT_ELEMENT_TYPE, // Symbol.for('react.element')
type: type,
key: key,
ref: ref,
props: props,
};
return element
}
ReactElement.js link
React Element은 추후에 ReactDOM을 통해 흔히 말하는, 하지만 DOM과는 전혀 다른 Virtual DOM(이하 VDOM)을 만들 때 사용됩니다.
또한, 리액트를 개발하다 보면 직접 작성한 컴포넌트 외에도 리액트에서 제공하는 memo, lazy 등을 사용하여 컴포넌트를 랩핑할 때가 있습니다. 이것들은 컴포넌트를 렌더링하기 전에 특수한 역할을 하도록 내부적으로 구현된 또 다른 객체이며 이를 Exotic Component라고 합니다.
function memo(type, compare) {
// 타입명은 Exotic Component 이지만, 쓰이는 곳은 React Element의 type에 쓰이기 때문에 변수명은 elementType이다.
const elementType = {
$$typeof: REACT_MEMO_TYPE, // Symbol.for('react.memo')
type,
compare: compare === undefined ? null : compare,
};
return elementType;
}
ReactMemo.js link
이 Exotic Component 객체는 다시 JSX로 작성되면서 React Element로 생성됩니다. React Element와 Memo Exotic Component의 생김새를 비교해보면 공통적으로 $$typeof
와 type
를 가지고 있습니다. 이 속성들은 추후에 렌더링 과정에서 Element의 종류를 구분하는데 사용됩니다.
React Element가 무엇인지 알아보았으니 React.isValidElement()
가 무엇을 의미하는지 알 수 있습니다.
function isValidElement(object) {
return (
typeof object === 'object' &&
object !== null &&
object.$$typeof === REACT_ELEMENT_TYPE
);
}
ReactElement.js link
isValidElement()
는createElement()
가 반환하는 React Element을 확인하는 유틸 함수입니다.
추가로, 컴포넌트 전달 방식의 차이점도 알아볼 수 있습니다.
const Foo = ...;
const Bar = memo(...);
isValidElement(Foo) // false
isValidElement(Bar) // false, Exotic Component는 React Element가 아니다.
isValidElement(<Foo /> or <Bar />) // true
const LoggingElementType = ...;
<LoggingElementType element={<Foo />} /> // Foo function
<LoggingElementType><Foo /></LoggingElementType> // Foo function
// 이것은 그냥 함수를 전달하는 것과 같습니다.
<LoggingElementType element={Foo} /> // undefined
그렇다면 React Node는 무엇일까요? React Element가 컴포넌트만을 표현하는 것과 다르게 React Node는 리액트가 렌더링할 수 있는 모든 것을 표현하는 타입입니다. 예를 들어, 요소를 미노출 하기 위한 null
이나 컴포넌트가 아닌 문자열 등을 JSX로 작성할 수 있음을 의미합니다.
type ReactNode =
| ReactElement
| string
| number
| Iterable<ReactNode> // 배열
| ReactPortal
| boolean
| null
| undefined
항상 자식으로 단일 컴포넌트만 작성되길 원한다면 컴포넌트 children
타입을 ReactElement
로 정의하면 됩니다.
const Foo = ({children}: {children: ReactElement}) => <div>{children}</div>;
<Foo>bar</Foo> // error
<Foo><Bar /><Baz /></Foo> // error
마지막으로 사소한 부분이지만, 개발자가 정의하는 컴포넌트나 리액트가 제공하는 것 외에도 JSX로 HTML 요소들을 작성할 수 있습니다. 이를 내부 코드에서는 Host Component라고 부르며, React Element 생성시 type
이 문자열로 작성되는 점만 다릅니다.
const Foo = <div />
// 바벨 빌드 이후
const Foo = React.createElement("div", null)
Q. React DOM의 createRoot(..).render(…)의 역할은 무엇이고 뒤에서는 어떠한 일들이 일어나고 있나요?
React Element는 JSX로 작성된 컴포넌트 타입, 속성, 하위 자식의 정보를 가진 단순한 객체입니다. 이러한 객체를 가지고 리액트는 UI를 효율적으로 관리할 수 있어야 하며, 이를 위한 아키텍처가 필요했습니다. 이 아키텍처에는 중요한 요구사항이 있었습니다. 바로 렌더링을 중단, 정지, 재시작 할 수 있어야 한다는 것이였습니다. 이 요구사항은 사용자의 UX와 밀접한 관련이 있으며 좀 더 세분화하면 다음과 같습니다.
- 사용자의 물리적인 이벤트에 대한 피드백은 즉각 UI에 반영되길 사용자는 기대한다.
- UI 전환은 느릴 수 있다고 사용자는 인지하고 있으므로 해당 업데이트는 우선순위를 낮게 설정하여 렌더링할 수 있어야 한다.
- 2번이 이미 진행중이라도 우선순위가 더 높은 업데이트가 발생한다면 즉각 중단하고 이를 먼저 렌더링할 수 있어야 한다.
- 3번을 위해서 2번 렌더링 과정은 사용자의 이벤트를 블록킹 하면 안된다. 다시 말하면, 해당 렌더링은 콜스택을 지속해서 점유하지 않아야 한다.
- 여러 업데이트가 대기중이라면 우선순위가 더 높은 업데이트를 먼저 렌더링할 수 있어야 한다. 이는 1번을 만족시킬 수 있는 조건이다.
- 우선순위에서 밀린 업데이트에 대해서 다시 렌더링을 진행할 수 있어야 한다.
위 내용은 우선순위 기반 렌더링을 설명한 것입니다. 이것을 만족하기 위해서는 렌더링 작업을 아주 잘게 쪼개고 이들 사이의 연결이 끊어지지 않도록 해야하며, 작업을 최소한의 단위로 쪼개다 보면 결국 컴포넌트 단위가 됩니다. 이 작업 단위가 Fiber이며 트리 구조로 연결을 해 놓습니다. 이것이 바로 Fiber Architecture 구조입니다. Fiber에는 연결 정보 말고도 컴포넌트 단위의 렌더링 작업도 수행해야 하므로 컴포넌트와 관련된 타입, 상태와 같은 정보도 포함되어 있습니다.
다음의 React Element가 React DOM의 render()를 통해 어떻게 Fiber 트리로 구성되는지 확인해보면서 좀 더 자세한 내용을 다뤄보겠습니다.
const Foo = ({children}) => children;
const Bar = () => ...;
const App = () => (
<Foo>
<Bar />
<div />
</Foo>
);
...render(<App />);
// 바벨 빌드 이후
const App = () => React.createElement(
Foo, // type
null, // props
React.createElement( // ...children
Bar,
null
),
React.createElement(
'div',
null
)
);
...render(React.createElement(App, null));
Fiber는 트리 구조를 위한 다음과 같은 속성을 가지고 있습니다.
type Fiber = {
return: Fiber | null, // 부모 경로
child: Fiber | null, // 첫번째 자식 경로
sibling: Fiber | null, // 오른쪽 형제 경로
alternate: Fiber | null, // 반대쪽 트리
...
}
ReactInternalTypes.js link
<App />은 다음과 같이 구성됩니다.
이 트리를 실제 DOM에 삽입하기 위한 컨테이너 HTML Element 가 필요합니다. 이것은 createRoot()
를 통해서 개발자가 원하는 대상을 리액트에게 전달할 수 있습니다.
import {createRoot} from "react-dom/client";
const rootElement = document.getElementById("root"); // <div id="root" />
const root = createRoot(rootElement);
createRoot()
는 Fiber 트리의 최상단 노드인 Fiber Root Node를 생성하며 트리 전반에 필요한 정보를 이 root에서 관리합니다.
function FiberRootNode(
containerInfo,// createRoot()에 넘겨준 rootElement
...
) {
this.containerInfo = containerInfo;
this.current = null; // 추후 설명
...
}
ReactFiberRoot.js link
이제 <App /> 트리를 Fiber Root Node에 연결하면 완성입니다. 하지만 이에 앞서 위에서 언급한 아키텍처 요구사항인 “렌더링을 중단 할 수 있어야 한다.”를 좀 더 깊이 생각해봐야 합니다. 이 요구사항은 렌더링 과정에서 생성된 결과물을 DOM에 즉각적으로 반영하면 안됨을 의미합니다. Fiber 하나의 작업을 완료했다고 바로 DOM에 반영해버리면 우선순위가 더 높은 업데이트가 발생하여 현재 렌더링을 중단해야 될 때 UI의 일관성이 깨지게 됩니다.
리액트의 렌더링은 상태의 스냅샷을 찍는 것과 같습니다. A 상태를 업데이트하여 렌더링을 진행하면 A 상태만 반영된 결과물이 만들어지고 이를 DOM에 반영합니다. 하지만 잘게 쪼갠 Fiber의 작업이 완료됐다고 바로 DOM에 반영해버리면 렌더링이 완료되지 않은 A 상태의 스냅샷과 우선순위가 더 높은 B 상태의 스냅샷이 찢겨진 상태로 함께 DOM에 반영되면서 사용자 입장에서는 UI의 일관성이 깨져 보이는 현상이 발생할 수 있게됨을 의미합니다.
그래서 리액트는 렌더링이 모두 완료된 후에만 DOM에 반영할 수 있도록 작업용 트리(트리의 렌더링이 완료되기 전까지는 언제든지 수정할 수 있는)를 하나 더 둡니다. 이 결과 Fiber는 두 가지 버전이 존재하게 됩니다. DOM에 반영된 current와 현재 작업중인 workInProgress가 이에 해당합니다.
리액트 내부에서는 렌더링 작업 진행중인 Fiber을 workInProgress, 반대로 렌더링이 끝나 DOM에 반영된 Fiber을
current
로 명칭합니다.
두 가지 버전의 트리가 존재하기 때문에 이들 사이에도 연결을 해주어야 합니다. Fiber는 alternate
속성으로 서로 참조하며, 각 트리의 루트를 판단할 수 있도록 Host Root Fiber라는 Fiber을 최상단에 하나 더 둡니다.
다음은 createRoot()
가 실행된 직후의 그림입니다. 이때의 current
가 가리키는 Host Root Fiber 속성의 대부분은 null
입니다. 왜냐하면 처음에는 DOM에 무엇도 렌더링된 것이 없기 때문입니다.
root
를 만들고 render(<App />)
를 호출하면 이제 본격적으로 렌더링을 시작하며, Host Root Fiber부터 시작해서 App
~ div
까지 순회하며 React Element를 Fiber로 확장하며 트리를 만들어 나갑니다. 이렇게 렌더링을 완료하면 우리가 처음 봤던 <App />의 트리가 Host Root Fiber에 붙게 됩니다.
이 과정을 Render phase라고 합니다. 이 단계의 작업용 트리는 아직 DOM에 반영된 것이 아니므로 위 그림에서와 같이 아직 current
가 아님을 유의해야 합니다.
후반부에 확인하게 될 작업용 트리를 DOM에 반영하는 Commit phase가 완료되어야 다음과 같이 Host Root Fiber을 current
로 변경합니다.
Q. 컴포넌트의 상태를 업데이트 했을 때 어떻게 리-렌더링되나요? 상태가 변경된 컴포넌트만 리-렌더링 되나요?
Fiber에는 트리와 관련된 정보 외에도 컴포넌트와 관련된 정보들이 포함되어 있습니다.
type Fiber = {
...,
tag: WorkTag, // Fiber 종류
type: any, // React Element의 type. 함수형 컴포넌트는 함수, Host Component는 문자열
lanes: Lanes, // 컴포넌트에서 발생한 업데이트 정보
childLanes: Lanes, // 하위 트리에서 발생한 업데이트 정보
pendingProps: any, // 렌더링 전 props
memoizedProps: any, // 렌더링 후 props
memoizedState: any, // 컴포넌트 상태, 함수형 컴포넌트라면 훅 리스트
}
ReactInternalTypes.js link
Lane은 여러 종류의 Lane이 존재하며(link), 이는 업데이트의 종류와 우선순위를 모델링한 것입니다. 이 글에서는 깊게 다루지 않으므로 자세한 설명은 생략합니다.
컴포넌트의 상태를 업데이트하면 해당 컴포넌트부터 Host Root Fiber까지 업데이트가 발생했음을 표식으로 남깁니다. 이것은 추후에 렌더링 과정에서 업데이트가 발생한 컴포넌트를 찾아갈 때 이정표로 사용됩니다.
function markUpdateLaneFromFiberToRoot(
sourceFiber: Fiber,
lane: Lane,
...
): void {
// 업데이트가 발생한 컴포넌트에 표식 남기기
sourceFiber.lanes = mergeLanes(sourceFiber.lanes, lane);
...
let parent = sourceFiber.return;
// host까지 올라가면서 하위 요소에 업데이트가 있음을 표시
while (parent !== null) {
parent.childLanes = mergeLanes(parent.childLanes, lane);
...
parent = parent.return;
}
...
}
ReactFiberConcurrentUpdate.js link
만약 <Foo/>의 상태를 수정했다면 표식은 다음과 같습니다.
렌더링은 항상 Host Root Fiber에서부터 시작하며, 기본적으로 Lane 표식이 있는 컴포넌트를 찾아 순회합니다. 이때 거쳐가는 모든 Fiber을 대상으로 렌더링 작업을 진행하진 않고 다음 기준 중 하나라도 해당 해야 작업을 진행합니다. 이 기준을 이해하고 기억해두는 것이 리액트 개발에 많은 도움이 될 수 있습니다.
- current가 없다. 즉, 이번에 새롭게 생성된 Fiber이다.
- current와 workInProgress의 props가 다르다.
- 현재 렌더링 대상인 lane이 기록되어 있다.
위 기준은 결국 UI의 달라짐을 의미합니다. Props가 다르면 해당 값을 반영한, 업데이트 Lane이 기록되어 있다면 해당 업데이트를 반영한 UI가 다를 수 있으며, current가 없다면 하위 UI 전체가 새롭게 추가됨을 의미입니다.
function beginWork(
current: Fiber | null,// 첫 렌더링(마운트)이라면 null일 수 있다.
workInProgress: Fiber,
renderLanes: Lanes,// 렌더링을 발생시킨 업데이트의 lane을 의미한다.
): Fiber | null {
// current가 있다면 DOM에 반영된 요소가 있음을 의미한다.
if (current !== null) {
const oldProps = current.memoizedProps;
const newProps = workInProgress.pendingProps;
// 2. current와 workInProgress의 Props가 다르다.
if (oldProps !== newProps) {
didReceiveUpdate = true;
} else {
// 3. 현재 렌더링 대상인 lane이 기록되어 있다.
const hasScheduledUpdateOrContext = checkScheduledUpdateOrContext(
current,
renderLanes,
);
// 1~3에 해당하지 않는다면 렌더링 작업을 진행하지 않는다.
if (!hasScheduledUpdateOrContext) {
didReceiveUpdate = false;
return attemptEarlyBailoutIfNoScheduledUpdate(
current,
workInProgress,
renderLanes,
);
}
didReceiveUpdate = false;
}
// 1. current가 없다.
} else {
didReceiveUpdate = false;
}
// 이하 Fiber 타입에 맞게 렌더링 작업 진행한다.
// 1 ~ 3 기준에 해당하지 않는다면 여기까지 도달할 수 없다.
switch (workInProgress.tag) {
case LazyComponent: {
...
}
case FunctionComponent: {
...
}
case HostRoot:
...
case HostComponent:
...
...
}
ReactFiberBeginWork.js link
업데이트 Lane을 확인하는 코드는 다음과 같습니다.
function checkScheduledUpdateOrContext(
current: Fiber,
renderLanes: Lanes, // 렌더링을 발생시킨 업데이트의 lane을 의미한다.
): boolean {
const updateLanes = current.lanes;
if (includesSomeLane(updateLanes, renderLanes)) {
return true;
}
return false;
}
ReactFiberBeginWork.js link
이제 beginWork()
에서 1 ~ 3번에 해당하지 않았을 때, 현재 Fiber의 렌더링 작업을 중단하는 방법을 알아야합니다. 이때 중요한 점은 해당 Fiber에 대해 렌더링 작업을 하지 않는다고 단순히 그냥 끝내면 안되고 렌더링 대상까지 작업용 트리를 이어 갈 수 있도록 해주어야 합니다.
예를 들어 위 이미지에서 <Foo/>가 업데이트되어 현재 <App/>이 렌더링 중이라면, <App/>은 1~3번에 해당하지 않기 때문에 렌더링 작업을 진행하지 않고 다음 작업을 이어가기 위한 <Foo/>의 작업용 Fiber(workInProgress)를 만들어 반환해야 합니다. 그렇지 않고 null
을 반환하면 다음 렌더링 대상이 없어 <App/> 하위 트리로의 경로가 끊겨 렌더링 작업을 이어갈 수 없게 됩니다.
이를 위한 함수가 위에서 확인한 beginWork()
의 attemptEarlyBailoutIfNoScheduledUpdate()
입니다. 이 함수는 곧 다음의 bailoutOnAlreadyFinishedWork()
로 이어집니다.
function bailoutOnAlreadyFinishedWork(
current: Fiber | null, // <App/>의 current Fiber
workInProgress: Fiber, // <App/>의 렌더링 작업중인 workInProgress Fiber
renderLanes: Lanes,
): Fiber | null {
// <Foo/>의 current가 있다면 복제하고, 없다면 새로 생성된 Fiber을 반환한다. 다른말로 workInProgress를 반환한다.
cloneChildFibers(current, workInProgress);
return workInProgress.child;
}
ReactFiberBeginWork.js link
cloneChildFibers()
(link)는 <App />은 더 이상 렌더링을 진행할 필요가 없지만 하위 트리로 렌더링 경로가 끊기지 않도록 작업용 트리를 만들기 위한 함수입니다. <App /> 하위 자식들이 이미 DOM에 반영되어 있다면 current를 복제하고 그게 아니라면 Fiber를 새로 생성하여 workInProgress를 만듭니다.
cloneChildFibers()
는 다음의 createWorkInProgress()
로 이어집니다.
function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
let workInProgress = current.alternate;
// 한번의 렌더링만 완료된 경우, current만 존재함.
if (workInProgress === null) {
workInProgress = createFiber(
current.tag,
pendingProps,
current.key,
current.mode,
);
workInProgress.type = current.type;
// 상호 참조
workInProgress.alternate = current;
current.alternate = workInProgress;
// 이전에 만들어 둔 Fiber가 있다면, 이번 렌더링에 초기화해서 WIP로 사용한다.
} else {
workInProgress.pendingProps = pendingProps;
workInProgress.type = current.type;
}
workInProgress.childLanes = current.childLanes;
workInProgress.lanes = current.lanes;
workInProgress.child = current.child;
// 이하 대충 current 속성을 WIP로 복사함
// ...
return workInProgress;
}
ReactFiber.js link
<App />은 1 ~ 3번 기준에 해당하지 않으면서 childLanes
를 가지고 있기 때문에(“내 하위 트리에서 업데이트 발생했고, 나는 렌더링 할 필요 없어!”), 자신의 하위 트리로 렌더링이 진행될 수 있도록 <Foo/>을 workInProgress로 만들어 반환합니다. 이때 <Foo />의 current을 복제했기 때문에 트리 정보도 그대로 가져옵니다. 그래서 <Foo/>의 workInProgress child
는 current와 같은 <Bar />의 Fiber을 참조하고 있습니다.
이렇게 <Foo/>의 workInProgress가 반환되면 이를 대상으로 본격적인 렌더링 작업이 진행됩니다(이는 다음 섹션에서 다룹니다.).
여기서 우리는 Props 비교가 렌더링 기준이 된다는 정보를 가지고 몇 가지 최적화 기법을 짚고 넘어갈 수 있습니다. 다음은 이해를 돕기 위한 결과물은 같지만 구조가 다른 코드입니다.
A
const Bar = ...
const Foo = () => <Bar />
<Foo />
B
const Foo = ({children}) => children
const Bar = ...
<Foo><Bar /></Foo>
A는 <Bar />을 <Foo /> 내부에 작성하였고, B는 <Foo /> 외부에서 전달받고 있습니다. 우리는 일반적으로 컴포넌트의 형태가 고정되어 있다면 A와 같이, 확장을 위해서는 B와 같이 작성합니다. 이것외에도 최적화 이유로 B와 같은 구조를 가져갈 수도 있습니다.
A의 경우, <Foo />의 상태가 변경되면, <Foo />는 렌더링을 위해 호출되며 <Bar />을 반환합니다. 여기서 <Bar />는 바벨을 통해 React.createElement()
로 변환되면서 React Element을 생성합니다. createElement()을 확인해보면 props
는 리터럴 객체로 정의됩니다. 이것은 실질적으로 <Bar />에 props
를 아무것도 넘겨주지 않아도 <Foo/>의 매 호출마다 props
가 달라져 렌더링 작업 대상이 됨을 나타냅니다. 이는 대게 문제가 되지 않지만 다음과 같은 경우에는 최적화를 고려해야 할 수 있습니다.
- <Bar /> 하위 트리의 크기가 크거나 <Foo /> 내부에 리스트와 같은 요소의 컴포넌트가 많이 정의되어 있다.
- <Foo />의 특성상 상태가 자주 변경된다.
- 2의 상태 변경과 무관하거나 너무 다른 빈도의 1에 해당하는 컴포넌트가 존재한다.
이런 경우, B와 같이 컴포넌트를 <Foo />와 분리하여 부모로 올리는 방법을 선택할 수 있습니다. B의 <Bar />는 이미 <Foo />의 외부에서 React Element로 생성되어children
로 전달되기 때문에 아무리 <Foo />가 호출되어도 Props가 달라지는 일은 없으므로 <Bar />에 대해서는 렌더링 작업을 진행하지 않습니다.
하지만 여러 가지 이유로 이런 구조적 최적화를 할 수 없다면 어떻게 해야 할까요? 이때 사용할 수 있는 것이 바로 리액트가 제공하는 memo입니다. memo는 Props를 얕은 비교하여 변경 여부에 따라 저장된 Fiber을 재사용합니다. <Bar />을 memo을 통해 정의해서 사용한다면 <Foo />의 상태가 아무리 변경되어도 memo를 통해 <Bar />의 React Element를 재사용하기 때문에 렌더링 작업을 진행하지 않습니다.
memo는 얕은 비교를 사용하기에 개발자가 놓칠 수 있는 것이 하나 있습니다. 바로 JSX에서 Props 작성시 직접 정의하는 것입니다.
const Bar = memo(({onClick}) => ...);
<Bar onClick={() => {}} />
위 코드에서는 onClick
핸들러 함수가 매번 생성되기 때문에 memo가 전혀 동작하지 못합니다. 그렇다며 이를 어떻게 해결할 수 있을까요? 바로 이때 사용할 수 있는 것이 리액트의 useMemo(), useCallback()과 같은 Memoization 훅입니다.
const handleClick = useMemo(() => {});
<Bar onClick={handleClick} />
이 유의사항은 Context를 사용할 때 특히 중요합니다. Context를 소비하는 컴포넌트들은 Context의 값이 변경되면 memo 사용 여부와 상관없이 무조건적으로 리-렌더링됩니다. 그래서 Provider의 value
속성 값을 다음과 같이 정의하지 않도록 주의해야 합니다.
const FooContext = React.createContext(defaultValue);
// Bad
<FooContext.Provider value={{name: 'foo'}}>...</FooContext.Provider>
// Good
const value = useMemo(() => ({name: 'foo'}), []);
<FooContext.Provider value={value}>...</FooContext.Provider>
결과적으로 컴포넌트의 상태가 업데이트 되었을때 리-렌더링 대상은 Props 관련 최적화를 하지 않았다면 해당 컴포넌트를 기준으로 하위 트리는 모두 리-렌더링(렌더링 작업 대상)될 것입니다.
Q. 배열에 컴포넌트를 작성하면 key를 설정해야 한다고 합니다. key를 설정하지 않으면 어떠한 문제가 발생하는 것인가요?
리액트 렌더링 과정에는 재조정이라는 작업이 있습니다. 이는 이전 렌더링 결과와 현재 렌더링 결과를 비교하여 추가, 삭제 및 이동을 처리하는 작업입니다. 이 작업은 beginWork()
에서 컴포넌트가 호출되어 자식 React Element를 반환한 직후, 해당 React Element를 기준으로 진행됩니다.
이 재조정을 확인하기 위해 이전 섹션에서 멈추었던 렌더링 과정을 이어가 보겠습니다. <Foo />가 반환되어 다시 beginWork()
가 시작되면 다음의 로직이 실행됩니다.
function beginWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
): Fiber | null {
...
// 이하 Fiber 타입에 맞게 렌더링 작업 진행한다.
// 1 ~ 3 기준에 해당하지 않는다면 여기까지 도달할 수 없다.
switch (workInProgress.tag) {
case LazyComponent: {
...
}
case FunctionComponent: {
...
const Component = workInProgress.type; // 함수형 컴포넌트 type은 개발자가 정의한 함수이다.
return updateFunctionComponent(
current,
workInProgress,
Component,
resolvedProps,
renderLanes,
);
}
case HostRoot:
...
case HostComponent:
...
...
}
ReactFiberBeginWork.js link
function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps: any,
renderLanes,
) {
...
// Component(props, secondArg)와 같이 컴포넌트를 호출한다.
nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes,
);
...
// current.child(fiber)와 nextChildren(React Element)을 비교하여 재조정을 진행한다.
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
}
ReactFiberBeginWork.js link
재조정 작업에서 리액트는 이전 요소와 현재의 요소가 본질적으로 다른 요소인지 판단해야 하는데, 이때 사용되는게 컴포넌트의 type
과 key
입니다. type
이 다르다는 것은 컴포넌트 자체가 다르다는 것이고, key
가 다르다는 것은 type
과 상관없이 개발자 정의에 의해 컴포넌트를 다르게 인식하는 것입니다. type
이나 key
가 다르면 다른 컴포넌트이기 때문에 Fiber을 새로 만들고 그렇지 않으면 props
만 교체하여 current의 workInProgress을 만들어 반환합니다. 이때 사용되는 함수는 위에서 확인한 createWorkInProgress()
입니다.
function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) {
if (current === null) {
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}
function reconcileSingleElement(
returnFiber: Fiber, // parent Fiber
child: Fiber | null, // current Fiber
element: ReactElement, // next child React Element
lanes: Lanes,
): Fiber {
// 이하 if문은 current Fiber와 next React Element의 정보를 가지고 서로 본질적으로 다른지 확인한다.
const key = element.key;
if (child.key === key) {
const elementType = element.type;
if (child.elementType === elementType) {
// props만 갈아끼워 current의 workInProgress을 반환한다.
const existing = useFiber(child, element.props); // createWorkInProgress
existing.return = returnFiber;
return existing;
}
...
}
// type, key가 다르면 삭제 처리를 한다.
...
// React Element을 Fiber만들어 반환한다.
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.return = returnFiber;
return created;
}
ReactChildFiber.js link
리액트 팀은 ”type이 달라지면 하위 구조도 다를 것이다“라고 설명합니다. 이에 맞게 createFiberFromElement()
를 통해 Fiber가 새로 만들어지면 해당 Fiber을 기준으로 하위 트리도 마찬가지로 모두 새것으로 교체됩니다. 왜냐하면 Fiber가 새로 만들어지면 트리 정보도 모두 null
이 되면서 하위트리의 모든 요소의 current도 null
이 됨을 의미하기 때문입니다. 새로 만들어져 반환된 Fiber는 beginWork()
의 1번에 해당하여 렌더링 작업 대상이 되고, 이후 재조정 작업시 마찬가지로 하위 자식들도 이전 요소가 없기 때문에 Fiber을 재사용하는 게 아니라 새로 만들게 되는 상황이 계속 반복됩니다.
예를 들어 <Foo/>가 <Baz> 로 변경된다면 다음과 같은 그림이 됩니다.
여기서 <Baz /> 자식의 <Bar />는 <Foo/> 자식의 <Bar/>와 alternative
로 서로 참조하고 있는게 아닌 별개의 Fiber임을 이해하면 됩니다.
재조정 작업에서 이처럼 컴포넌트 변경을 인식하면 삭제된 <Foo/>를 부모인 <App/> 기록해두고 이후 삭제를 처리할 때 <App/>과 <Foo/>의 연결을 끊는 등에 사용합니다.
여기까지 이해하면 key
로 인한 문제점이 발생할 수 있는 포인트를 설명할 수 있습니다. 배열은 기본적으로 요소들의 위치가 변경될 수 있음을 내포하고 있습니다. 문제는 같은 타입의 여러 컴포넌트를 key
없이 배열로 작성했다가 요소들의 위치를 변경 했을 때 발생합니다.
key
가 없으면 기본적으로 null
이 됩니다. 그리고 리액트는 해당 배열을 기준으로 재조정 작업을 진행할 때 key
가 같으므로 이전 요소와 다음 요소가 실질적으로 다르지만 같다고 판단하여 이전 요소의 Fiber를 기준으로 workInProgress를 만듭니다. 이때 바로 문제가 발생합니다. props
만 다음 요소의 props
로 교체되기 때문에, 그 값을 반영한 UI는 정상적으로 보이지만 Fiber에 저장된 값(예: 컴포넌트의 상태)은 여전히 이전 요소의 값이므로 이를 기반으로 한 UI로 인해 사용자는 기대와는 다른 깨진 화면을 보게 됩니다.
const Counter = ({ name }) => {
const [count, setCount] = useState(0);
return (
<>
{name}
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
</>
);
};
function App() {
const [names, setNames] = useState(["foo", "bar", "baz"]);
return (
<>
<button onClick={() => setNames(names.slice(1))}>shift</button>
{names.map((name) => (
<Counter name={name} />
))}
</>
);
}
Q. 함수형 컴포넌트에서 라이프사이클과 같은 useEffect()와 useLayoutEffect()는 언제 실행되고 차이점은 무엇인가요?
지금까지 살펴본 렌더링 과정은 Render Phase라고 불리며, 업데이트가 반영된 트리가 완성되면 종료됩니다. 트리의 Fiber에는 이 과정에서 발생한 여러 Effect의 정보가 기록되어 있습니다. 이러한 Effect를 실행하여 DOM과 같은 외부 시스템과 동기화하는 부수 효과를 유발하는 과정을 Commit Phase라고 합니다. 이 단계는 중단, 재시작을 할 수 있는 Render Phase와는 다르게 항상 동기적으로 동작해야 합니다. 왜냐하면 순수하지 않은 단계이기 때문입니다.
리액트의 렌더링은 순수해야 합니다. 렌더링은 언제든지 중지되었다가 재실행되거나 중단하고 다시 처음부터 실행될 수 있으므로 몇 번을 실행해도 결과는 같아야 합니다. 하지만 애플리케이션은 독립적으로 존재할 수 없습니다. 앱이 실행될 호스트 환경(예. DOM)이 있고 백엔드와 통신을 주고 받기도 합니다. 외부 시스템과의 상호작용은 순수하지 않지 않습니다. 하지만 이런 로직이 컴포넌트 내부에 작성될 수 밖에 없으며 이는 곧 렌더링 과정에서 해당 로직이 실행될 수 있음을 의미합니다.
리액트는 렌더링을 순수하게 유지하기 위해 개발자에게 이러한 Effect을 작성할 수 있는 훅을 제공합니다. 대표적인 것이 바로 useEffect()
입니다. 참고로 사용자와의 상호작용과 같은 특정 이벤트에 의해 발생하는 부수 효과는 이벤트 핸들러에 작성하면 됩니다. Effect는 렌더링 주도하에 발생할 수 있는 외부 시스템과의 동기화와 관련된 부수 효과를 작성한다고 이해하면 됩니다. 이와 관련한 예시는 아래에서 다룹니다.
리액트는 Commit Phase를 다음과 같이 세 단계로 분류하여 각 단계에 적합한 Effect를 처리합니다.
- commitBeforeMutationEffects: DOM에 변형을 가하기 전 단계입니다.
getSnapshotBeforeUpdate()
와 같이 변형을 가하기 전 상태을 읽어 들이기 위한 단계입니다. - commitMutationEffects: DOM에 변형을 가하는 단계입니다. 컴포넌트는 필요에 따라 마운트, 언마운트를 하고 Host Component는 적절히 DOM 작업을 하는 단계입니다.
- commitLayoutEffects: DOM에 변형이 가해진 이후의 단계입니다. 브라우저에 의해 페인트 되기 전 DOM의 레이아웃을 참조, 조작할 수 있는 단계입니다.
이 단계가 완료되면 트리와 DOM의 동기화가 완료된 것으로 간주하여 Fiber Root Node의 current
를 해당 트리의 Host Root Fiber로 교체합니다.
이전 렌더링에서 제거된 <Foo/> 트리는 commitMutationEffects 단계를 통해 연결 정보(Fiber와 DOM)가 모두 정리되면서 최종적으로 자연스레 GC됩니다.
Effect가 Commit Phase에서 처리 된다는 것은 확인했습니다. 그렇다면 useEffect()
와 useLayoutEffect()
의 차이점은 정확히 무엇일까요? 우선, 두 훅의 사용 목적을 이해하는 것이 중요합니다.
- useLayoutEffect: DOM에 반영되었기 때문에 실행되어야 하는 Effect을 작성합니다.
- useEffect: 화면이 사용자에게 보였기 때문에 실행되어야 하는 Effect을 작성합니다.
미세한 차이가 있지만 결국 시점의 차이입니다. useLayoutEffect()
는 DOM 조작 및 레이아웃 측정과 관련된 작업을 위해 사용하는데, 중요한 점은 DOM 변경으로 인한 페인트 이전에 실행된다는 것입니다. useEffect()
는 일반적으로 페인트 이후에 실행됩니다. UI가 사용자에게 노출되었기 때문에 발생해야 하는 Effect을 위해 사용되는데, 예를 들어 채팅방이 노출되었기 때문에 소켓 연결을 진행하는 것과 같은 부수 효과를 의미합니다. 이를 잘 표현한 것이 다음의 다이어그램입니다.
useEffect()는 항상 페인트 이후에 실행되는 것은 아닙니다. 우선순위가 높은 사용자의 클릭과 같은 이벤트에서 발생한 업데이트는 즉각 사용자가 UI적으로 인식할 수 있어야 하기 때문에 useLayoutEffect()와 마찬가지로 페인트 이전에 실행됩니다. 이는 이번 글의 주제에서 벗어나기도 하고 특수하기 때문에 짧게 언급만 하고 넘어갑니다.
다음의 코드는 useEffect()
를 통해 레이아웃을 조작했을 때의 UI 플리커링을 재현합니다. 이를 통해 사용자는 어떤 경험을 하는지 확인해보면 둘의 차이를 좀 더 이해하기 쉽습니다.
function App() {
const [names, setNames] = useState([]);
const ref = useRef();
useEffect(() => { // or useLayoutEffect()
ref.current.style.paddingTop = `${names.length * 10}px`;
}, [names]);
return (
<>
<button
onClick={() => {
setTimeout(() => setNames(['Foo', 'Bar']), 200);
}}
>
load
</button>
<ul ref={ref}>
{names.map(name => (
<li>{name}</li>
))}
</ul>
</>
);
}
useLayout
useLayoutEffect
마무리
여기까지 리액트의 기본 동작 방식을 확인해보았습니다. 이제 마지막으로 QnA을 작성하면서 마무리 하도록 하겠습니다.
Q. 리액트를 개발하다 보면 다음의 여러 타입을 접하게 됩니다. ReactElement, ExoticComponent, ReactNode가 정확히 무엇이고 차이점은 무엇인가요?
A.
- ReactElement: JSX로 작성된 컴포넌트의 정보를 객체로 나타내며, 컴포넌트 타입, props, key, ref 등을 포함하여 리액트 앱의 구조를 모델링한다.
- ExoticComponent: 리액트의
memo
,lazy
등 내부적을 정의된 특수한 기능을 가진 컴포넌트로, 일반 컴포넌트를 감싸서 렌더링 과정에서 특별한 역할을 한다. - ReactNode: 리액트가 렌더링할 수 있는 모든 것을 포함하는 타입이다.
Q. React DOM의 createRoot(..).render(…)의 역할은 무엇이고 뒤에서는 어떠한 일들이 일어나고 있나요?
A.
- createRoot: 최상단 Fiber Root Node를 생성하여 트리 전반의 정보를 관리하며, 실제 컨테이너 HTML 엘리먼트를 저장한다.
- render(): Host Root Fiber에서 시작하는 렌더링 과정이 시작되고 React Element을 Fiber 트리로 구성한다.
Q. 컴포넌트의 상태를 업데이트 했을 때 어떻게 리-렌더링되나요? 상태가 변경된 컴포넌트만 리-렌더링 되나요?
A.
- 컴포넌트의 상태가 업데이트되면 해당 컴포넌트부터 Host Root Fiber까지 업데이트 표식을 남기고 다시 렌더링이 시작된다.
- 리액트는 current의 존재 여부,
props
의 변경과 업데이트 표시를 기반으로 실제 렌더링 작업을 수행할 대상을 선택한다.
Q. 배열에 컴포넌트를 작성하면 key를 설정해야 한다고 합니다. key를 설정하지 않으면 어떠한 문제가 발생하는 것인가요?
A. 같은 컴포넌트를 key 없이 배열로 렌더링하면 위치 이동시 type과 key(null)가 같기 때문에 props만 교체하고 Fiber는 그대로 사용된다. 이로 인해 사용자는 props를 반영한 UI는 정상적으로 보이지만, Fiber 값(예: 컴포넌트 상태 등)을 사용하는 UI는 위치가 바뀌지 않은 것처럼 보이게 된다.
Q. 함수형 컴포넌트에서 라이프사이클과 같은 useEffect()와 useLayoutEffect()는 언제 실행되고 차이점은 무엇인가요?
A. Effect는 렌더링 완료 후 DOM에 반영하는 Commit Phase 또는 그 이후에 실행된다.
- useLayoutEffect: DOM 변형 후, 브라우저가 다시 페인트하기 전에 동기적으로 실행되어 DOM 측정 및 조작을 위해 주로 사용되며, UI의 플리커링을 방지할 수 있는 효과를 가진다.
- useEffect: 브라우저가 화면에 변경 사항을 랜더링하고 난 후 실행되는데, 주로 외부 시스템과의 동기화를 위해 사용한다.