리액트 렌더링 최적화


원문
Anton Lavrenov, https://lavrton.com/how-to-optimise-rendering-of-a-set-of-elements-in-react-ad01f5b161ae/

리액트의 성능 향상을 위한 가이드.

보통의 웹 어플리케이션에서는 한 페이지에 다수의 엘리먼트들을 렌더링한다. 이번 포스트에서 다수 엘리먼트들을 렌더링할 때 어떤 방식으로 성능 향상을 하는지 보여주고자 한다.

테스트 예시를 위해, canvas 엘리먼트에 여러 원을 그리는 어플리케이션을 만들 것이다. 그리고 데이터 저장소로 Redux를 사용할 것이지만, 여러 다른(state를 관리하는) 접근에도 적용 할 수 있다.

Store를 먼저 정의하자.

function generateTargets() {
    return _.times(1000, (i) => {
        return {
            id: i,
            x: Math.random() * window.innerWidth,
            y: Math.random() * window.innerHeight,
            radius: 2 + Math.random() * 5,
            color: Konva.Util.getRandomColor()
        };
    });
}

// 테스트케이스를 위해 로직은 최대한 간단히 한다.
// 단순히, UPDATE액션은 한번에 한개 원의 반지름만 수정한다.
function appReducer(state, action) {
   if (action.type === 'UPDATE') {
       const i = _.findIndex(state.targets, (t) => t.id === action.id);
       const updatedTarget = {
           ...state.targets[i],
           radius: action.radius
       };
       state = {
           targets: [
               ...state.targets.slice(0, i),
               updatedTarget,
               ...state.targets.slice(i + 1)
           ]
       }
   }
   return state;
}

const initialState = {
    targets: generateTargets()
};

const store = Redux.createStore(appReducer, initialState);

이제 어플리케이션의 렌더링을 정의한다. 캔버스 렌더링을 위해 react-konva를 사용한다.

function Target(props) {
    const {x, y, color, radius} = props.target;
    return (
        <Group x={x} y={y}>
            <Circle
                radius={radius}
                fill={color}
            />
            <Circle
                radius={radius * 1 / 2}
                fill="black"
            />
            <Circle
                radius={radius * 1 / 4}
                fill="white"
            />
        </Group>
    );
}

// 타겟 리스트를 가지고 있는 최상위 컴포넌트.
class App extends React.Component {
    constructor(...args) {
        super(...args);
        this.state = store.getState();
        // 모든 상태변화를 감지
        store.subscribe(() => {
            this.setState(store.getState());
        });
    }
    render() {
        const targets = this.state.targets.map((target) => {
            return <Target key={target.id} target={target}/>;
        });
        const width = window.innerWidth;
        const height = window.innerHeight;
        return (
            <Stage width={width} height={height}>
                <Layer hitGraphEnabled={false}>
                    {targets}
                </Layer>
            </Stage>
        );
    }
}

Example은 CodePen-1에서 확인할 수 있다.
이제 간단한 테스트를 만들것이다. 타겟의 변화(radius)에 따라 업데이트가 발생할 것이다.

const N_OF_RUNS = 500;
const start = performance.now();
_.times(N_OF_RUNS, () => {
    const id = 1;
    let oldRadius = store.getState().targets[id].radius;
    // Redux store 업데이트.
    store.dispatch({type: 'UPDATE', id, radius: oldRadius + 0.5});
});
const end = performance.now();

console.log('sum time', end - start);
console.log('average time', (end - start) / N_OF_RUNS);

최적화 없이 테스트를 수행해보자. 현재 개발 환경에서 각 업데이트마다 평균 약 21ms가 걸렸다.

without-optimization

측정된 시간은 캔버스의 드로잉을 포함하지 않았다. 아무튼 1000개의 엘리먼트가 한번 업데이트되는데 21ms는 꽤 괜찮은 성능이다. 만약 업데이트가 자주 발생하지 않는다면, 지금 코드를 그대로 사용해도 된다.

하지만 이런 업데이트가 빈번이 발생한다면(예를 들어, 마우스 드래그앤드롭에서 마우스 움직임에 따른 업데이트), 60 프레임의 애니메이션에서, 각 업데이트는 16ms보단 빨리 끝나야 한다. 때문에 이 경우에 21ms는 그렇게 괜찮은 성능은 아니다. (또한 캔버스 드로잉까지 고려해야 한다.)

어떻게 하면 렌더링을 최적화 할 수 있을까?

1. 바뀌지 않는 엘리먼트들은 업데이트하지 않는다.

React의 성능을 향상시키는 첫번째 규칙이다. 우리는 타겟 엘리먼트의 shouldComponentUpdate메서드를 구현해야 한다.

class Target extends React.Component {
    shouldComponentUpdate(newProps) {
        return this.props.target !== newProps.target;
    }
    render() {
        const {x, y, color, radius} = this.props.target;
        return (
            <Group x={x} y={y}>
                <Circle
                    radius={radius}
                    fill={color}
                />
                <Circle
                    radius={radius * 1 / 2}
                    fill="black"
                />
                <Circle
                    radius={radius * 1 / 4}
                    fill="white"
                />
            </Group>
        );
    }
}

결과는 CodePen-2에서 확인할 수 있다.

classic-optimization

21ms가 4ms까지 줄었다. 그렇지만 더 빠르게 할 수는 없을까? 실제 어플리케이션이라면 이정도의 성능은 그리 좋은 것은 아니다.

Advanced tuning

이제 App componentrender() 메서드를 보자. 마음에 안드는 부분은 바로 각 Update마다 전체 render()가 호출되는 것이다.

부분적인 업데이트만 있었는데, 왜 전체 리스트에 대한 렌더링을 수행해야 하는가? 직접적으로 부분적인 업데이트만 할 수 있지 않을까?

2. 자식 컴포넌트들을 스마트하게 만들어라. (Make child components smarter)

아이디어는 간단하다.

  1. AppComponent는 자식 엘리먼트들의 리스트의 크기가 같고, 순서가 바뀌지 않았다면 업데이트 하지 않는다.
  2. 자식 컴포넌트들은 자신의 데이터 변화를 직접 업데이트한다.

따라서 "Target" 컴포넌트는 스토어에서 직접 자신의 변화를 감지한다.

class Target extends React.Component {
    constructor(...args) {
        super(...args);
        this.state = {
            target: store.getState().targets[this.props.index]
        };
        // 모든 상태변화에서 자신의 데이터 변화를 감지
        this.unsubscribe = store.subscribe(() => {
            const newTarget = store.getState().targets[this.props.index];
            if (newTarget !== this.state.target) {
                this.setState({
                    target: newTarget
                });
            }
        });
    }
    shouldComponentUpdate(newProps, newState) {
         return this.state.target !== newState.target;
    }
    componentWillUnmount() {
      this.unsubscribe();
    }
    render() {
        const {x, y, color, radius} = this.state.target;
        return (
            <Group x={x} y={y}>
                <Circle
                    radius={radius}
                    fill={color}
                />
                <Circle
                    radius={radius * 1 / 2}
                    fill="black"
                />
                <Circle
                    radius={radius * 1 / 4}
                    fill="white"
                />
            </Group>
        );
    }
}

그리고 AppComponent의 shouldComponentUpdate메서드를 구현한다.

shouldComponentUpdate(newProps, newState) {
    // 순서와 길이에 대한 변화를 확인한다.
    // 만약 전부 같다면 업데이트 할 필요가 없다.
    const changed = newState.targets.find((target, i) => {
        return this.state.targets[i].id !== target.id;
    });
    return changed;
}

결과는 CodePen-3에서 확인할 수 있다.

advanced-optimization

각 업데이트에 0.25ms 시간이 걸렸다.

보너스 팁

mobxjs를 사용하면, subscribing코드를 제거할 수 있다.

같은 어플리케이션을 mobx를 사용하여 구현하였다: Codepen-mobx

mobx-state

약 1.5배정도 빨라졌다. 코드 역시 더 간단해졌다.

const {Stage, Layer, Circle, Group} = ReactKonva;
const {observable, computed} = mobx;
const {observer} = mobxReact;

class TargetModel {
    id = Math.random();
    @observable x = 0;
    @observable y = 0;
    @observable radius = 0;
    @observable color = null;
    constructor(attrs) {
        _.assign(this, attrs);
    }
}

class State {
    @observable targets = [];
}


function generateTargets() {
     _.times(1000, (i) => {
        state.targets.push(new TargetModel({
            id: i,
            x: Math.random() * window.innerWidth,
            y: Math.random() * window.innerHeight,
            radius: 2 + Math.random() * 5,
            color: Konva.Util.getRandomColor()
        }));
    });
}

const state = new State();
generateTargets();


@observer
class Target extends React.Component {
    render() {
        const {x, y, color, radius} = this.props.target;
        return (
            <Group x={x} y={y}>
                <Circle
                    radius={radius}
                    fill={color}
                />
                <Circle
                    radius={radius * 1 / 2}
                    fill="black"
                />
                <Circle
                    radius={radius * 1 / 4}
                    fill="white"
                />
            </Group>
        );
    }
}

@observer
class App extends React.Component {
    render() {
        const targets = state.targets.map((target) => {
            return <Target key={target.id} target={target}/>;
        });
        const width = window.innerWidth;
        const height = window.innerHeight;
        return (
            <Stage width={width} height={height}>
                <Layer hitGraphEnabled={false}>
                    {targets}
                </Layer>
            </Stage>
        );
    }
}

ReactDOM.render(
  <App/>,
  document.getElementById('container')
);


// update one target
state.targets[1].radius += 0.5