누구든지 하는 리액트: 9편 불변성을 지키는 이유와 업데이트 최적화


이 튜토리얼은 10편으로 이뤄진 시리즈입니다. 이전 / 다음 편을 확인하시려면 목차를 확인하세요.

우리는 지난 섹션에서 배열을 어떻게 다뤄야 하는지에 대해서 알아보았습니다. 데이터를 업데이트하는 과정에서 불변성을 지켜야한다는것을 강조했었는데요, 왜 그렇게 해야하는지 알아보겠습니다.

데이터 필터링 구현하기

우선, 불변성의 중요성을 알아보는 과정에서 이름으로 전화번호를 찾는 데이터 필터링 기능을 구현해보겠습니다.

먼저 App 컴포너트에서 input 하나를 렌더링하고 해당 input 의 값을 state 의 keyword 라는 값에 담겠습니다. 이를 위해서 이벤트 핸들러도 만들어줘야겠지요?

// file: src/App.js
import React, { Component } from 'react';
import PhoneForm from './components/PhoneForm';
import PhoneInfoList from './components/PhoneInfoList';

class App extends Component {
  id = 2
  state = {
    information: [
      {
        id: 0,
        name: '김민준',
        phone: '010-0000-0000'
      },
      {
        id: 1,
        name: '홍길동',
        phone: '010-0000-0001'
      }
    ],
    keyword: ''
  }
  handleChange = (e) => {
    this.setState({
      keyword: e.target.value,
    });
  }
  handleCreate = (data) => {
    const { information } = this.state;
    this.setState({
      information: information.concat({ id: this.id++, ...data })
    })
  }
  handleRemove = (id) => {
    const { information } = this.state;
    this.setState({
      information: information.filter(info => info.id !== id)
    })
  }
  handleUpdate = (id, data) => {
    const { information } = this.state;
    this.setState({
      information: information.map(
        info => id === info.id
          ? { ...info, ...data } // 새 객체를 만들어서 기존의 값과 전달받은 data 을 덮어씀
          : info // 기존의 값을 그대로 렌더링
      )
    })
  }
  render() {
    const { information, keyword } = this.state;

    return (
      <div>
        <PhoneForm
          onCreate={this.handleCreate}
        />
        <p>
          <input 
            placeholder="검색 할 이름을 입력하세요.." 
            onChange={this.handleChange}
            value={keyword}
          />
        </p>
        <hr />
        <PhoneInfoList 
          data={information}
          onRemove={this.handleRemove}
          onUpdate={this.handleUpdate}
        />
      </div>
    );
  }
}

export default App;

검색어를 입력했을 때 필터링을 하는것은 나중에 구현하도록 하겠습니다. 지금의 상황에선, input 에 입력을 했을때 업데이트가 필요한것은 오직 input 뿐입니다.

하지만, App 컴포넌트의 상태가 업데이트 되면, 컴포넌트의 리렌더링이 발생하게 되고, 컴포넌트가 리렌더링되면 그 컴포넌트의 자식 컴포넌트도 리렌더링됩니다.

한번 확인을 해볼까요? PhoneInfoList 컴포넌트에서 render 함수의 상단에 다음 코드를 넣어보세요.

// file: src/components/PhoneInfoList.js
...
  render() {
    console.log('render PhoneInfoList');
    const { data, onRemove, onUpdate } = this.props;
    const list = data.map(
      info => (
        <PhoneInfo
          key={info.id}
          info={info}
          onRemove={onRemove}
          onUpdate={onUpdate}
        />)
    );

    return (
      <div>
        {list}    
      </div>
    );
  }
...

이렇게 하고 검색어 input 을 수정한다음에 콘솔을 확인해봅시다.

App 이 리렌더링됨에 따라 PhoneInfoList 도 리렌더링이 되고 있죠. 물론, 실제로 변화가 일어나진 않으니 지금은 Virtual DOM 에만 리렌더링 합니다. 지금의 상황에는 별로 큰 문제가 되지 않는데, 리스트 내부의 아이템이 몇백개, 몇천개가 된다면 이렇게 Virtual DOM 에 렌더링 하는 자원은 아낄 수 있으면 아끼는게 좋습니다.

이러한 낭비되는 자원을 아끼기 위해선 우리가 이전에 배웠던 shouldComponentUpdate LifeCycle API 를 사용하면 됩니다.

자, PhoneInfoList 에서 shouldComponentUpdate 를 구현해보세요.

그냥 단순히 다음 받아올 data 가 현재 data 랑 다른 배열일 때 true 로 설정하게 하면 됩니다.

import React, { Component } from 'react';
import PhoneInfo from './PhoneInfo';

class PhoneInfoList extends Component {
  static defaultProps = {
    data: [],
    onRemove: () => console.warn('onRemove not defined'),
    onUpdate: () => console.warn('onUpdate not defined'),
  }

  shouldComponentUpdate(nextProps, nextState) {
    return nextProps.data !== this.props.data;
  }
  

  render() {
    console.log('render PhoneInfoList');
    const { data, onRemove, onUpdate } = this.props;
    const list = data.map(
      info => (
        <PhoneInfo
          key={info.id}
          info={info}
          onRemove={onRemove}
          onUpdate={onUpdate}
        />)
    );

    return (
      <div>
        {list}    
      </div>
    );
  }
}

export default PhoneInfoList;

그러면 이제 변화가 필요하지 않을 때는 render 함수가 호출되지 않게 됩니다.

우리는 shouldComponentUpdate 로직을 굉장히 간단하게 작성해주었는데 어떻게 이런게 가능 한 것일까요?

불변성에 대해 알아보자.

그 이유는, 우리가 불변성을 지켜줬기 때문입니다.

만약에 우리가 배열을 직접 건들여서 수정해줬다고 가정해봅시다..
그럴때는 이렇게 !== 하나로 비교를 끝낼수가 없습니다.

const array = [1,2,3,4];
const sameArray = array;
sameArray.push(5);

console.log(array !== sameArray); // false

우리가 sameArray = array 를 했다고 해서 기존에 있던 배열이 복사되는것이 아니라 똑같은 배열을 가르키고 있는 레퍼런스가 하나 만들어진 것이기 때문에, 우리가 sameArray 에 push 를 하게 된다고 해서 array 와 sameArray 가 달라지지 않습니다.

하지만, 우리가 불변성을 유지하면

const array = [1,2,3,4];
const differentArray = [...array, 5];
  // 혹은 = array.concat(5)
console.log(array === differentArray); // true

위 코드와 같이 바로바로 비교가 가능하다는 것이죠.

이는 객체를 다룰때도 마찬가지입니다.

// NO
const object = {
  foo: 'hello',
  bar: 'world'
};
const sameObject = object;
sameObject.baz = 'bye';
console.log(sameObject !== object); // false
// YES
const object = {
  foo: 'hello',
  bar: 'world'
};
const differentObject = {
  ...object,
  baz: 'bye'
};
console.log(differentObject !== object); // true

기능 마저 구현하기

그러면, 구현하던 기능을 마저 끝내보겠습니다.

App 컴포넌트에서 keyword 값에 따라서 information 배열을 필터링 해주는 로직을 작성하고, 필터링된 결과를 PhoneInfoList 에 전달해주겠습니다.

// file: src/App.js
import React, { Component } from 'react';
import PhoneForm from './components/PhoneForm';
import PhoneInfoList from './components/PhoneInfoList';

class App extends Component {
  id = 2
  state = {
    information: [
      {
        id: 0,
        name: '김민준',
        phone: '010-0000-0000'
      },
      {
        id: 1,
        name: '홍길동',
        phone: '010-0000-0001'
      }
    ],
    keyword: ''
  }
  handleChange = (e) => {
    this.setState({
      keyword: e.target.value,
    });
  }
  handleCreate = (data) => {
    const { information } = this.state;
    this.setState({
      information: information.concat({ id: this.id++, ...data })
    })
  }
  handleRemove = (id) => {
    const { information } = this.state;
    this.setState({
      information: information.filter(info => info.id !== id)
    })
  }
  handleUpdate = (id, data) => {
    const { information } = this.state;
    this.setState({
      information: information.map(
        info => id === info.id
          ? { ...info, ...data } // 새 객체를 만들어서 기존의 값과 전달받은 data 을 덮어씀
          : info // 기존의 값을 그대로 렌더링
      )
    })
  }
  render() {
    const { information, keyword } = this.state;
    const filteredList = information.filter(
      info => info.name.indexOf(keyword) !== -1
    );
    return (
      <div>
        <PhoneForm
          onCreate={this.handleCreate}
        />
        <p>
          <input 
            placeholder="검색 할 이름을 입력하세요.." 
            onChange={this.handleChange}
            value={keyword}
          />
        </p>
        <hr />
        <PhoneInfoList 
          data={filteredList}
          onRemove={this.handleRemove}
          onUpdate={this.handleUpdate}
        />
      </div>
    );
  }
}

export default App;

필터링이 잘 돼었나요? 참고로, 지금 상황에서는 키워드 값에 따라 PhoneInfoList 가 전달받는 data 가 다르므로, 키워드 값이 바뀌면 shouldComponentUpdate 도 true 를 반환하게 됩니다.

계속해서 최적화

자, 이번에는 PhoneInfo 컴포넌트도 최적화해주겠습니다.

PhoneInfo 컴포넌트의 render 함수 상단에 다음 코드를 넣어보세요.

  render() {
    console.log('render PhoneInfo ' + this.props.info.id);

그 다음에, 새 데이터를 등록하고나서 개발자 콘솔을 확인해보세요.

보면 처음 렌더링이 됐을 때 0과 1이 렌더링됐습니다. 그 다음에, 새 데이터가 나타났을때 사실상 맨마지막 데이터만 새로 렌더링해주면 되는데, 그 위에 있는 컴포넌트도 렌더링되었는데요, 이것도 아까전에 다뤘던것과 마찬가지로 실제로 바뀌지 않는 컴포넌트들은 DOM 변화가 일어나지는 않겠지만, Virtual DOM 에 그리는 자원도 아껴주기 위해서 우리는 shouldComponentUpdate 를 통하여 최적화 해줄 수 있습니다.


// file: src/components/PhoneInfo.js
  shouldComponentUpdate(nextProps, nextState) {
    // 수정 상태가 아니고, info 값이 같다면 리렌더링 안함
    if (!this.state.editing  
        && !nextState.editing
        && nextProps.info === this.props.info) {
      return false;
    }
    // 나머지 경우엔 리렌더링함
    return true;
  }
...

낭비 렌더링이 사라졌지요?

정리

축하합니다! 여러분은 리액트의 기본 사용법부터 활용법까지 모두 배웠습니다. 다음 섹션에서는, 앞으로 여러분들이 무엇을 더 배워야 할 지에 대해서 다뤄보겠습니다.

  • 김구연

    const array = [1,2,3,4];
    const differentArray = […array, 5];
    // 혹은 = array.concat(5)
    console.log(array === differentArray); // true

    이거 돼있는 부분 마지막줄의 주석부분 false인데 잘못된거 같아요!
    항상 강의 잘보고 있습니다!