lazy initial state

리액트 공식문서에 정리된 내용이다.

lazy initial state

보통 useState는 다음과 같이 사용한다.

1
const [state, setState] = useState(initialState);

리액트에게 initialState가 필요한 순간은 첫 렌더링시 뿐이다.

하지만 함수형 컴포넌트의 경우 리렌더링마다 함수 본문을 실행한다.
이는 값이 필요하지 않더라도 계속해서 계산한다는 이야기이다.

아래 코드와 같이 initialState 계산비용이 비싼 경우 성능에 영향을 끼칠 수 있다.

1
const [state, setState] = useState(someExpensiveComputation(props));

이런 불필요한 계산을 방지하기 위해 리액트에선 lazy initial state라는 기능을 제공한다.
useState 안에 콜백함수를 제공할 시 초기 렌더링 시에만 사용하고 그 이후의 렌더링에는 무시된다.

다음과 같은 코드 작성시 첫 렌더링 시에만 계산한다.

1
const [state, setState] = useState(() => someExpensiveComputation(props));

정리

  1. initial state의 계산비용이 클 경우에 useState에 콜백함수를 전달하여 첫 렌더링 시에만 값을 계산할 수 있다.

  2. 단순 원시값(number,string,etc..)의 경우 계산이 필요하지 않기 때문에 오히려 성능 저하로 연결될 수 있다.

React.memo 없이 성능개선하기

며칠 전 리액트의 렌더링 성능 최적화를 위한 diff 알고리즘과 재조정 알고리즘에 대해 공부했었다.

공부한 내용을 바탕으로 TOAST UI에 작성된 리액트 렌더러를 최적화하는 간단한 트릭을 읽고 정리해보았다.

리액트는 diff 알고리즘을 통해 state나 props가 변화할 때 마다 새로운 엘리먼트 트리를 만든다.

이 말은 즉 마지막 렌더링 떄와 동일한 리액트 엘리먼트를 넘긴다면 리액트는 그 엘리먼트를 리랜더링하지 않는다는 의미이다.

예제 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// codesandbox: https://codesandbox.io/s/react-codesandbox-g9mt5

import React from "react";
import ReactDOM from "react-dom";

function Logger(props) {
console.log(`${props.label} rendered`);
return null; // 여기서 반환되는 값은 부적절하다...
}

function Counter() {
const [count, setCount] = React.useState(0);
const increment = () => setCount((c) => c + 1);
return (
<div>
<button onClick={increment}>The count is {count}</button>
<Logger label="counter" />
</div>
);
}

ReactDOM.render(<Counter />, document.getElementById("root"));

위 예제 코드에선 첫 렌더링에 counter rendered가 콘솔에 기록되고,
Counter의 count 값이 변화할 때 마다 counter rendered가 콘솔에 기록될 것이다.
여기서 <Logger label="counter"/> 엘리먼트는 렌더링할 때 변하지 않는 정적인 엘리먼트이다.

UI 기술 객체

counter 함수를 호출시 다음과 같은 ui 기술 객체를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 몇 가지를 제거한 예제
const counterElement = {
type: "div",
props: {
children: [
{
type: "button",
props: {
onClick: increment, // 클릭 이벤트 핸들러 함수
children: "The count is 0",
},
},
{
type: Logger, // Logger 컴포넌트 함수
props: {
label: "counter",
},
},
],
},
};

이중 버튼을 눌러 count를 변경할 시 바뀌는 값은 button 엘리먼트의 props의 이벤트 핸들러 함수와 children이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const counterElement = {
type: "div", // 불변
props: {
children: [
{
type: "button", // 불변
props: {
onClick: increment, // 변경
children: "The count is 1", // 변경
},
},
{
type: Logger, // 불변
props: {
label: "counter", // 불변
},
},
],
},
};

하지만 리액트는 재조정 알고리즘에 따라 전체를 새로운 것으로 변경한다.(props를 갱신한다.)

모든 엘리먼트들의 type은 동일하고 Logger 엘리먼트의 label 속성도 변하지 않는다.
하지만 객체의 속성이 이전의 props 객체와 같더라도 props 객체 자체는 매 번 렌더링 할 떄마다 변경된다.

이렇게 Logger의 props 객체는 변경되기 떄문에 리액트는 새로운 props 객체에 기반한 JSX 객체를 얻기 위해 Logger 함수를 다시 실행한다.

다음과 같이 이런 점을 반영하여 리액트에 jsx 엘리먼트를 만들어 전달해주면 이러한 과정을 생략할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// codesandbox: https://codesandbox.io/s/react-codesandbox-o9e9f

import React from "react";
import ReactDOM from "react-dom";

function Logger(props) {
console.log(`${props.label} rendered`);
return null; // 여기서 반환되는 값은 부적절하다...
}

function Counter(props) {
const [count, setCount] = React.useState(0);
const increment = () => setCount((c) => c + 1);
return (
<div>
<button onClick={increment}>The count is {count}</button>
{props.logger}
</div>
);
}

ReactDOM.render(
<Counter logger={<Logger label="counter" />} />,
document.getElementById("root")
);

위 코드의 ui 기술 객체는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const counterElement = {
type: "div", // 불변
props: {
children: [
{
type: "button", // 불변
props: {
onClick: increment,
children: "The count is 1",
},
},
// Logger element 자체가 불변한다.
{
type: Logger,
props: {
label: "counter",
},
},
],
},
};

Logger 엘리먼트 자체가 변경되지 않았기 떄문에 리액트는 이러한 최적화를 제공할 수 있다.
떄문에 다시 렌더링 할 필요 없는 Logger 엘리먼트를 렌더링 하지 않는다.

때문에 위 코드는 처음 렌더링시에만 counter rendered를 콘솔에 기록한다.

이 방법은 리액트가 props객체 전체만 체크한다는 것을 제이하고 기본적으로 React.memo 동작 방식과 같다.

정리

게시글에서 제안하는 성능을 개선하기 위한 실용적인 방법은 다음과 같다.

  1. 렌더링 비용이 비싼 컴포넌트는 덜 렌더링 되는 상위요소로 끌어 올린다.

  2. 그리고 해당 컴포넌트를 props로 내려준다.

느낀점

신기한 접근인 것 같다.
마찬가지로 자바스크립트의 메모리 사용 방식으로 인해 가능한 방식인 것 같다.

React fiber

React Fiber?

  • 리액트 재조정 알고리즘(Reconciliation Algorithm)을 보완한 알고리즘이다.

  • React 16에서 도입되었다.

  • Fiber라는 용어는 React의 데이터 구조 혹은 아키텍처를 의미하며 DOM 트리의 Node를 나타내는 ‘fiber’에서 유래되었다.

React Fiber의 개선사항

재조정 알고리즘(Reconciliation Algorithm)의 경우 재귀적으로 동작하여 중간에 멈출 수 없다.

Fiber는 정해진 우선순위에 따라 작업을 일시중지, 재개, 중단할 수 있는 작은 단위로 나누어 더욱 유동적으로 동작할 수 있도록 한다.

특히 애니메이션의 경우 재조정 알고리즘(Reconciliation Algorithm)에서는 추가로 랜더링 알고리즘이 돌아가야하고 16ms 내에 랜더링 될 수 없었다.

React Fiber에선 애니메이션 랜더링 작업을 작은 단위로 나누어 브라우저가 쉬고 있을 때(idle 상태일 떄) 조금씩 랜더링을 지시하는 방식을 통해 더욱 유연하게 애니메이션을 랜더링 할 수 있게한다.

작동방식

  1. Reconciliation
  • UI에 랜더링할 변경사항을 계산한다.
  • 계산이 끝나면 React는 변경사항을 예약한다
  • 실제 변경이 반영되지는 않는 단계다.
  • 작업단위 마다의 우선순위를 설정하므로 하나의 작업이 여러개의 단위로 나뉘어 각각 다른 우선순위를 지닌다.
  1. Commit
  • React가 DOM에 랜더링을 지시한다.
  • Reconciliation은 중단될 수 있지만 Commit은 중단될 수 없다.

ref

리액트 fiber
리액트 fiber 소개

리액트 재조정(Reconciliation)

React에서 diff 알고리즘을 만들기 위해 고려한 재조정 알고리즘을 정리해보았다.

선행 개념

리액트 재조정에 대한 설명 전에 간단이 알아두어야 할 용어이다.

엘리먼트 (Element)

  • 실제 화면에 랜더링할 DOM 노드의 정보이다.
  • 즉 js의 불변객체이다.
  • 일반적으로 jsx로 적혀지고 아래와 같이 저장된다.
1
2
3
4
5
<div clasName="hi">
<div>
<div></div>
</div>
</div>

다음과 같이 type(문자열,컴포넌트 함수/클래스)과 props(객체)필드로 표현된다.

1
2
3
4
5
6
7
8
9
{
type:"div"
props:{
className:'user_info',
children:[
...
]
}
}
  • 엘리먼트들로 이뤄진 트리를 엘리먼트 트리라고 부르며, 이것이 메모리 상에만 존재하는 가상 DOM(virtual DOM)이다.
  • React가 실제로 화면에 랜더링하는 대상이다.

컴포넌트 엘리먼트 (Component Element)

  • type이 컴포넌트 클래스/함수(대문자로 시작)인 엘리먼트이다.

  • 사용자가 직접 정의한 컴포넌트이며 입력으로 props를 받으면 랜더링할 엘리먼트 트리를 반환한다.

  • 위 엘리먼트 트리의 노드는 다른 DOM 엘리먼트나 컴포넌트 엘리먼트들로 이루어진다.

  • React는 랜더링을 위해서 모든 컴포넌트 엘리먼트들에게 어떠한 엘리먼트를 반환해야 하는지 묻는다.

  • 클래스형 컴포넌트

    • 지역 상태를 가질 수 있다.
    • 인스턴스에 대응하는 DOM노드가 생성, 수정, 삭제될 때의 동작을 제어할 수 있다.(생명주기,life cycle)
  • 함수형 컴포넌트

    • render()함수만 가지는 클래스형 컴포넌트와 동일하다.

엘리먼트 관련 핵심 아이디어

  • 엘리먼트들끼리는 서로 섞이거나 중첩되는것이 가능하다.
  • 즉 다른 컴포넌트를 정의하는데에 있어서 재사용이 가능하다.
  • 컴포넌트들의 분리를 가능하게 하여 복잡한 UI를 쉽게 구성할 수 있다.

컴포넌트 인스턴스(Component Instance)

  • 클래스의 인스턴스이다.
  • 클래스 내부에서 this키워드를 통해 참조하는 대상이다.
  • 지역상태를 저장하고 생명주기(life cycle), 이벤트에 대한 반응을 제어할 때 유용하다.

DOM 엘리먼트(DOM Element)

  • 말 그대로 DOM에 존재하는 엘리먼트들이다.
  • type이 태그 이름에 해당하는 문자열(소문자로 시작)하는 엘리먼트이다.
  • props 정보를 통해 해당 노드의 attribute들을 표현한다.

동기

state나 props가 갱신되면 render() 함수는 새로운 React 엘리먼트 트리를 반환한다.
React는 만들어진 트리에 맞게 효과적으로 UI를 갱신할 필요가 있다.

하나의 트리를 가지고 다른 트리로 변환하기 위한 연산 수를 구하는 알고리즘 문제를 푸는 해결책은
주로 O(n3)의 복잡도를 가진다.

React는 더욱 효율적인 계산을 위해 두 가지 가정을 기반하여 O(n) 복잡도의 휴리스틱 알고리즘을 구현하였다.

  1. 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.

  2. 개발자가 key prop를 통해 여러 랜더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할 지 표시해 줄 수 있다

    • 이는 key element에 index를 쓰는것이 지양 되는 이유이다.

비교 알고리즘(Diffing Algorithm)

두 개의 트리를 비교할 때 React는 두 엘리먼트의 루트 엘리먼트부터 비교한다. 이후의 동작은 루트 엘리먼트의 타입에 따라 달라진다.

엘리먼트의 타입이 다른 경우

두 루트 엘리먼트의 타입이 다르면 React는 기존의 트리(루트 엘리먼트와 하위 컴포넌트들)를 버리고 새로운 트리를 구축한다.

ex) <a>에서 <img>로 바뀌는 경우

트리를 버릴 때 이전 DOM 노드들은 모두 파괴된다.

  1. 컴포넌트 인스턴스는 componentWillUnmount()가 실행된다.
  2. 새로운 트리가 만들어진다.
  3. 새로운 트리가 DOM에 삽입될 때 UNSAFE_componentWillMount()가 실행되고 이후 componentDidMount()가 실행된다.
  4. 이전 트리와 관련된 모든 state가 사라진다.

또한 루트 엘리먼트 아래의 모든 컴포넌트도 언마운트 되고 그 state도 사라진다.

아래와 같은 비교에선 이전 Counter는 사라지고 새로 다시 마운트가 된다.

1
2
3
4
5
6
7
<div>
<Counter/>
</div>

<span>
<Counter/>
</span>

DOM 엘리먼트의 타입이 같은 경우

같은 타입의 React DOM 엘리먼트를 비교할 때 React는 두 엘리먼트의 attribute를 확인하여 변경된 attribute만 갱신한다.

아래의 비교에선 React는 현재 DOM 노드상의 className만 수정한다.

1
2
3
<div className="before" title="stuff" />

<div className="after" title="stuff" />

style이 갱신될 떄 또한 React는 변경된 attribute만을 갱신한다.

아래의 비교에선 React는 현재 DOM 노드 상의 style에서 color만들 수정한다.

1
2
3
<div style={{color: 'red', fontWeight: 'bold'}} />

<div style={{color: 'green', fontWeight: 'bold'}} />

같은 타입의 컴포넌트 엘리먼트

컴포넌트가 갱신되면 인스턴스는 렌더링간 state를 유지한다.
새로운 엘리먼트의 내용을 반영하기 위해 현재 컴포넌트 인스턴스의 props를 갱신한다.

이 때 해당 인스턴스의 UNSAFE_componentWillReceiveProps(),UNSAFE_componentWillUpdate(),componentDidUpdate를 호출한다.

다음으로 render() 메서드가 호출되고 비교 알고리즘이 이전 결과와 새로운 결과를 재귀적으로 처리한다.

자식에 대한 재귀적 처리

DOM 노드들의 자식들을 재귀적으로 처리할 때 React는 기본적으로 동시에 두 리스트를 순회하고 차이점이 있으면 변경을 생성한다.

아래의 변경은 마지막 요소에 <li?>third</li>를 추가하였다.
first가 일치함을 확인하고 second가 일치하는 것을 확인 후 third를 트리에 추가한다.

1
2
3
4
5
6
7
8
9
10
<ul>
<li>first</li>
<li>second</li>
</ul>

<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>

하지만 이 경우 리스트의 맨 앞에 엘리먼트를 추가하는 경우 성능이 좋지 않다.
아래의 경우 처음과 마지막까지 모두 순회하여 변경하거나 자식 엘리먼트 모두를 변경해야한다.
이는 성능저하의 요인이 된다.

1
2
3
4
5
6
7
8
9
10
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>

<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>

keys

이러한 문제를 해결하기 위해 React에선 key 속성을 지원한다.
자식들이 key를 가지고 있다면 React는 key를 통해 기존 트리와 이후 트리의 자식들이 일치하는지 확인한다.

아래의 변경은 key를 추가하여 엘리먼트의 맨 앞에 자식을 추가하는 경우도 효율적으로 작동하도록 하였다.

1
2
3
4
5
6
7
8
9
10
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>

<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>

이것이 배열의 index가 key로 권장되지 않는 이유이다.
항목들이 재배열되지 않을 경우 유용하지만 삭제와 재배열이 필요할 경우 비효율적으로 동작한다.

컴포넌트 인스턴스는 key를 기반으로 갱신되고 재사용되기 떄문에 인덱스를 key로 사용할 경우 항목의 순서가 바뀌었을 때 key또한 바뀌어 state가 정상적으로 작동하지 않을 수 있다.

또한 key는 형제 사이에서만 유일하면 된다.

고려사항

위의 재조정 알고리즘(Reconciliation Algorithm)이 랜더링 전후에 변경된 부분만을 변경하는 알고리즘이다.

react는 휴리스틱에 의존하고 있기 때문에 다음과 같은 것을 고려해야한다.

  1. React는 다른 컴포넌트 타입의 경우 전부 버리고 교체해버린다.
    때문에 비슷한 결과물을 출력하는 두 컴포넌트를 작성해야 한다면 같은 타입으로 만드는 것이 더 나을 수도 있다.

  2. key는 변하지 않고 예상 가능하며 유일해야한다.
    그렇지 않을 경우 성능이 나빠지거나 자식 컴포넌트의 state가 유실될 수 있다.

느낀 점

불변성을 지키기에 용이하고 값을 직접 변경하지 않아 가능한 방식이라고 느꼈다.

또한 직접적인 DOM조작이 지양되는 이유라고 생각했다.

ref

리액트 엘리먼트
리액트 공식문서