누구든지 하는 리액트: 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인데 잘못된거 같아요!
    항상 강의 잘보고 있습니다!

  • 행인

    윗 분 말씀처럼 true가 아니라 false인 것 같습니다.
    좋은 강의 감사드립니다 🙂

  • donggyu

    handleChage 함수 같은거에 console.log(keyword); 넣어서 확인해보면 제가 ‘a’를 딱 쳤을때 현재 keyword는 a 가 아니라 ” 이고, 그 다음에 ‘b’를 한번 더 눌르면 현재 스테이트의 키워드는 ‘ab’가 아니라 ‘a’ 가 되는 식으로 한박자씩 늦게 state가 변경되는데 어떻게 해결 못할까요?

    • e.target.value 를 확인하셔야 현재의 값이 나타납니다.

  • Kyrie

    PhoneInfoList의 shouldComponentUpdate에서 ( nextProps.data !== this.props.data ) 는 서로 다른 빈 배열을 참조하기 때문에 무조건 true로 나옵니다. 각각을 toString() 메소드에 넣은 후 비교해주니 올바른 결과가 나오더군요. 더 좋은 방법이 있다면 공유 부탁드립니다

    • KoRoGhOsT

      PhoneInfoList의. SCU는 data를 비교하신게 의도일겁니다.
      form에서 키보드 입력을 할 때마다, App의 keyword state가 변경되는데
      이 때는, PhoneInfoList를 새로 render하지 않겠다!
      라는 의도일거라서요.

      • Heedae Lee

        키보드 입력하면 무조건 true가 나올수 밖에 없는 조건식이라 조건식을 잘못짠것 같네요. 저도 이상하다 생각이 드네요. false가 나올수 있는 조건이 없어서 무조건 랜더가 되네요. 무조건 true가 되는 조건식은 잘못된것 같습니다.

        • 안녕하세요 ^^
          우선 헷갈리게 해서 죄송합니다.

          “참고로, 지금 상황에서는 키워드 값에 따라 PhoneInfoList 가 전달받는 data 가 다르므로, 키워드 값이 바뀌면 shouldComponentUpdate 도 true 를 반환하게 됩니다.”

          Filter를 구현하고 나면 배열이 바뀌기 때문에 결국 언제나 true가 나타납니다. 이건 의도했던거였어요. 그 전에 SCU를 구현한건 이런 용도로 사용된다는걸 강조하고 싶었던거고..

          굳이 toString을 하지 않아도, 이 PhoneInfo 컴포넌트쪽에서 SCU 구현하면 충분한 최적화가 됩니다. 비록 List 쪽 컴포넌트는 계속 리렌더링 되긴 하지만, 내부 컴포넌트가 리렌더링되지 않기 때문에 는 문제는 안돼요.

          이 강의 자료도 조만간 업데이트를 할 필요가 있겠군요…ㅋㅋ 이제보니 벌써 1년이 넘게 지나서 오래된느낌이 나네요..!

          • Heedae Lee

            헤맸던 부분인데 친절하게 답변달아주셔서 감사합니다!! ^^

  • Harry S. Hur

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

    • KoRoGhOsT

      아… 포스트를 지적하신거군요.
      오타가 나신 것 같습니다.
      false가 맞으니까요.

      • 뒤늦게 업데이트 하였습니다.
        죄송합니다

  • 손지호

    잘보고 있습니다. 좋은글 감사합니다.