원문
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가 걸렸다.

측정된 시간은 캔버스의 드로잉을 포함하지 않았다. 아무튼 1000개의 엘리먼트가 한번 업데이트되는데 21ms는 꽤 괜찮은 성능이다. 만약 업데이트가 자주 발생하지 않는다면, 지금 코드를 그대로 사용해도 된다.
하지만 이런 업데이트가 빈번이 발생한다면(예를 들어, 마우스 드래그앤드롭에서 마우스 움직임에 따른 업데이트), 60 프레임의 애니메이션에서, 각 업데이트는 16ms보단 빨리 끝나야 한다. 때문에 이 경우에 21ms는 그렇게 괜찮은 성능은 아니다. (또한 캔버스 드로잉까지 고려해야 한다.)
어떻게 하면 렌더링을 최적화 할 수 있을까?
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에서 확인할 수 있다.

21ms가 4ms까지 줄었다. 그렇지만 더 빠르게 할 수는 없을까? 실제 어플리케이션이라면 이정도의 성능은 그리 좋은 것은 아니다.
이제 App component 의 render() 메서드를 보자. 마음에 안드는 부분은 바로 각 Update마다 전체 render()가 호출되는 것이다.
부분적인 업데이트만 있었는데, 왜 전체 리스트에 대한 렌더링을 수행해야 하는가? 직접적으로 부분적인 업데이트만 할 수 있지 않을까?
아이디어는 간단하다.
따라서 "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에서 확인할 수 있다.

각 업데이트에 0.25ms 시간이 걸렸다.
mobxjs를 사용하면, subscribing코드를 제거할 수 있다.
같은 어플리케이션을 mobx를 사용하여 구현하였다: Codepen-mobx

약 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