React 의 성능을 조금 이라도 올려보자 (Performance Optimize)

박성룡 ( Andrew park )
15 min readJun 4, 2020

React 는 UI (User Interface) 라이브러리 로써 매우 가볍고 빠르다.

또한 React 는 개발자에게 매우 자유롭다.

자유롭다는 뜻은 만드는 사람이 얼마나 이해하고, 어떻게 사용 하느냐에 따라, 제품의 성능이 극명하게 갈린다는 뜻이기도 하다.

아직은 React 을 정확히 이해하지 못하는 것 같아 최근 성능을 올리기 위해 배운 내용들을 정리 해보고자 한다.

브라우저 렌더링 과 React Virtual DOM

브라우저가 htmlcss 를 가지고 화면을 그리기 위해서는 먼저 DOM TreeCSSOM TreeRender Tree 를 만들어야 한다.

그 후 Layer 위치를 잡는 flow 단계와 색을 칠하는 paint 단계를 거쳐 최종적으로 화면에 출력하게 된다.

첫 렌더링이 끝나고, 사용자가 화면을 조작하기 시작하면, 무수히 많은 DOM Tree 의 변화와 ReflowRepaint 가 일어나게 되는데, 이는 이전 과정의 반복으로 많은 리소스가 필요하다.

많은 리소스가 필요한 작업들은 화면에 반영 되기까지 시간이 걸리기 때문에, 화면이 뚝뚝 끊기는 느낌을 줘 유저 경험에 좋지 않다.

Virtual DOM 과 Reconciliation

React 는 화면이 그려지는 성능을 빠르게 하기 위해, 브라우저 Memory 에 Tree 구조로 이루어진 Virtual DOM 을 저장하고 있다.

이 Virtual DOM 으로 렌더링 여부를 결정하는데, 브라우저의 DOM Tree 를 변경하기 전에 이전 Virtual DOM변경될 Virtual DOM비교 (Diff) 하고, 바뀐 최소 부분만을 실제 DOM 에 반영하여, 브라우저 렌더링 과정을 최소화한다.

이 Virtual DOM 은 Reconciliation (재조정) 과정을 통해 재생성하게 된다.

변경이 있어 났을때 각 Virtual DOM 의 Element 와 Component 를 비교하며 재렌더링을 하는데, 이를 자식 노드까지 반복하게 된다.

모든 비교가 끝나면, DOM Tree 에 반영하게 된다.

비교하는 Element 가 다른경우 div -> section

  1. 이전 Virtual DOM 을 제거한다. (componentWillUnmount, useEffect clean-up)
  2. 이때 모든 자식들도 모두 제거되고 재생성 된다.

비교하는 Element 와 Component 가 같은 경우 div -> div

  1. Virtual DOM 의 property 를 수정한 뒤, 자식들의 노드를 비교한다. (componentWillUpdate, useEffect diff)
  2. 이때 자식들은 key 를 가질 수 있는데, 이전과 변경되는 key 를 비교한다.
  3. key 가 없다면, 이전 자식 node 를 삭제하고, key 가 추가 됬다면, 새로 자식 node 를 생성한다.
  4. key 가 같다면, 비교를 진행한다.

React 는 Virtual DOM 으로 실제 DOM 에 반영하는 빈도를 줄여, React 의 성능을 올리고 있다.

하지만 개발자가 어떻게 Component 를 적용하고 배치 하느냐에 따라 성능 차이가 극명하게 난다.

필요 없는 렌더링 줄이기 shouldComponentUpdate

React 의 Virtual DOM 의 ReRendering 은 4 가지 상황에서 발생된다.

  1. 부모 Component 가 Rendering 되서, 자식을 비교할때
  2. props 가 변경 되었을 때
  3. state 가 변경 되었을 때
  4. forceUpdate 가 호출되었을 때

이중 부모 Component 가 Rendering 되서, 자식을 비교할때, 자식의 props 가 변경되지 않았지만, 재렌더링되는 것은, 불필요한 작업일 수 있다.

특히 거대한 웹을 만들다보면 Tree 가 깊어지고, 자식들이 많아지면, 비교 해야하는 코드도 많아진다.

Component 의 props 정보를 저장해두고, 동일한 props 가 입력되 었을 때 재랜더링을 막는다면 성능을 올릴 수 있을 것이다.

이러한 기법을 Memoization 라고 부르며, Memoization 이란 입력한 결과를 저장하고 동일한 입력이 다시 발생했을때, 동일한 결과를 빠르게 반환하는 기법이다.

이를 기법을 적용 하기 위해 Class Component 에서는 shouldComponentUpdate 를 제공한다.

shouldComponentUpdate이전 props, state다음 props, state 를 비교하여, 렌더링 여부를 결정할 수 있다.

React 의 Lifecycle 은 create 와 update, delete 로 나눌 수 있는데,

shouldComponentUpdate 는 update 시점의 componentWillUpdate 이전에 발생한다.

이를 이용하면, 불필요한 Rendering 과 자식들의 비교를 막아 성능을 올릴 수 있다.

// jsx
class Class extends React.Component {
shouldComponentUpdate(nextProps, nextState) {
console.log('shouldComponentUpdate', nextProps, nextState);
return (JSON.stringify(nextProps) != JSON.stringify(this.props))
|| (JSON.stringify(nextState) != JSON.stringify(this.state));
}
render() {
//…
}
}

필요 없는 렌더링 줄이기 React.memo

Class Component 에서 shouldComponentUpdate 가 있다면, Functional Component 에는 React.memo 가 있다.

React.memoComponent 함수비교 함수(propsAreEqual)를 받아 Component 를 반환하는 HOC 이다.

props 가 변경 되었을때, 얕은 비교 로, Rerendering 여부를 결정할 수 있다.

// jsxconst Component = () => (<div>text</div>)
const Memo = React.memo(Component,
(prevProps, nextProps) => {
console.log(‘propsAreEqual’, prevProps, nextProps)
return JSON.stringify(prevProps) === JSON.stringify(nextProps);
}
);

shouldComponentUpdateReact.memo 의 차이점은, shouldComponentUpdatetrue 일때, Rendering 를 하고,

React.memo 의 propsAreEqual 는 false 일때 Rendering 을 한다.

Memoization 으로 성능을 올리더 라도 props 를 자주 변경하는 Component 는 오히려 비교하는데 많은 성능이 들어, Memoization 기법의 이점을 얻기 힘들 수도 있다.

생각지도 못한 실수 Inline Function

위에서 Memoization 기법으로 Rerendering 을 막는데는 성공했다.

하지만 우리가 React 에 대해 고민하지 않고 쓰다보면, 놓치는 부분이 있다.

props 로 전달 하는 ObjectFunction참조 값을 전달 한다. 때문에 참조 값이 변경되면, Rerendering 이 일어난다.

// jsx
const [bool, toggle] = useState(false);
return (
<MyButton onClick={ (e) => setState(!bool) }>
button
</MyButton>
);

위 코드에서는 onClick 에게 전달하는 함수는 Rendering 시점에 생성하고 있다.

만약 state 변경으로 Rerendering 이 일어나게 되면, onClick 에 새로운 function 을 생성해서 props 로 전달하게 된다.

새로운 props 는 shouldComponentUpdateReact.memoMemoization 했던 자식 컴포넌트도 Rerendering 이 일어나게 때문에, Memoization 을 한 의미가 없어진다.

때문에, 이를 특정 값이 변경 되었을 때만 생성하는 방법이 필요하다.

Hooks Memoization

useCallbackuseMemoMemoization 된 값을 반환하는 Hook 이다.

이를 이용해서 같은 props 를 자식에게 전달하여, Rerendering 을 막을 수 있다.

useCallback

useCallback 은 함수와 비교 배열을 전달받아 함수를 반환 하는 Hook 이다.

이때 비교 배열의 값이 변경 되었을때에만, 새로운 함수를 반환하게 된다.

// jsx
const [bool, toggle] = useState(false);
const onClick = useCallback((e) => {
setState(!bool)
}, [bool])
return (<>
<MyButton onClick={(e) => setState(!bool)}>
button
</MyButton>
<MyButton onClick={onClick}>
button
</MyButton>
</>);

때문에 Inline Funtion 으로 작성 했을때 발생하던, Rerendering 시의 새로운 함수 생성을 막아 동일한 props 를 전달하므로써, 성능을 올릴 수 있다.

useMemo

useMemo 는 함수와 비교 배열을 전달 받아 값을 반환 하는 Hook 이다.

이때 비교 배열의 값이 변경 되었을때에만, 새로운 값을 반환하게 된다.

// jsx
const {
a, b
} = props;
const sum = useMemo(() => {
return a + b;
}, [a, b]);
return (<>
<div>
{a + b}
</div>
<div>
{sum}
</div>
</>);

props 값은 동일하지만, a + b 연산은 리렌더링 시점마다 일어나게 된다.

때문에, useMemo 를 이용해서 불필요한 연산을 제거하면 성능을 올릴 수 있다.

useContext

useContext 는 React.createContext 로 생성한 context 의 Provider value 를 구독하는 Hook 이다.

React.memoshouldComponentUpdate 를 사용하더라도 useContext 를 사용하는 Component 는 Rerendering 이 일어난다.

// jsx
const MyContext = React.createContext('false');
const ChildComponent = () => {
const value = useContext(MyContext);
return (<div>{value}</div>)
}
const Memo = React.memo( ChildComponent )const ParentComponent = () => {
const [state, setState] = useState('false');
useEffect(() => {
setTimeout(() => {
setState('true');
}, 1000);
}, [true]);
return <MyContext.Provider value={state}>
<Memo />
</MyContext.Provider>
}

Redux 같은 상태관리 도구들은 Context API 로 구현되어 있기 때문에, useSelector 를 쓸 경우 React.memo 를 쓰더라도 Rerendering 이 일어날 수 있다.

immutable-js

React 에서 props 로 전달 받는 Object 값은 참조 값이다.

때문에, Object 의 property 가 변경 되더라도 React 는 이를 인식하지 못하기 때문에, 리렌더링이 일어나지 않는다.

React 사용자들은, 보통 강제로 Rerendering 을 발생 시키기위해, forceUpdate 를 호출하거나, 내부 state 를 변경 하거나,

아니면 새로운 참조 값을 전달하기 위해, Object.assign 이나 object-rest-spread (…obj) 를 이용해서 새로운 객체를 생성해서 전달한다.

// javascript
Object.assign(
{},
state,
nextState
)
///
{
...state,
...nextState
}

매번 새로운 Object 참조 값을 전달하는 방식의 경우, 내부 값이 바뀌지 않더라도, 재 생성해서 전달하기 때문에, 이 방법은 Memoization 을 했더라도 불필요한 Rerendering을 발생시키게 된다.

특히 이러한 현상은 Redux 같은 상태관리 도구에서 많이 볼 수 있다.

immutable-js 같은 도구를 사용하면, 같은 값으로 업데이트 하더라도, 새로운 객체가 아니라 현재 객체를 반환하게 되어, 불필요한 Rerendering 을 막을 수 있다.

// jsx
import { Map, is } from 'immutable';
var map1 = Map({ a: 1, b: 2, c: 3 });
var map2 = map1.set('c', 3);
const Memo = React.memo(Component, (prev, next) => is(prev, next));export default function Render() {
const [state, setState] = useState(map1);
useEffect(() => {
setTimeout(() => {
setState(map2);
}, 1000);
}, [true]);
return (<Memo data={state} />);
};

성능 측정하기 React Developer Tools

성능을 개선하기 위해 노력하는것도 좋지만, 어느 부분에서 병목이 일어나는지 확인할 수 있는 도구를 이용하면, 더 쉽게 개선 작업을 진행 할 수 있다.

Chrome 의 확장도구 중 React Developer Tools for Chrome 은 Chrome 개발자 도구에서 React 요소들의 병목 지점 측정을 도와준다.

성능 측정하기 Profile API

React Profiler 를 사용하면, mount update시점, 렌더링 된 시간 등을 측정할 수 있다.

// jsx
import React, { Profiler } from "react"
import { unstable_trace as trace } from "scheduler/tracing";
function Render() {
const [value, setValue] = React.useState('')
const onChange = useCallback((e => {
trace('updated', performance.now(), () => {
setValue(event.target.value)
})
}, []);
return (<div>
<React.StrictMode>
<Profiler id="Render" onRender={onRenderCallback}>
<input onChange={onChange} value={value} />
{value}
</Profiler>
</React.StrictMode>
</div>);
function onRenderCallback(
id,
phase, // mount | update
actualDuration, // 렌더링 시간
baseDuration, // 전체 서브트리를 렌더링하는데 걸린 시간
startTime, // 렌더링을 시작한 시간
commitTime, // 업데이트요청한 시간
interactions // trace set
) {
//...
}
};

https://gist.github.com/bvaughn/25e6233aeb1b4f0cdb8d8366e54a3977

--

--