우리가 데이터를 변경하기 위해서 기존의 데이터를 직접 수정할 수도 있고, 기존의 데이터를 복사하여 새롭게 만들어진 데이터를 수정하는 방법을 사용할 수도 있습니다. 전자의 경우 인덱스를 통해 접근하여 수정하거나 splice 메서드를 이용할 수 있고 후자의 경우는 map 메서드를 이용할 수 있습니다. 리액트에서는 map, concat, filter 등의 새로운 배열을 반환하는 방법(불변성을 유지하는 방법)을 권장하고 있습니다. 왜 그럴까요?

 

🗒️: 만약 원시값과 참조값에 대해 잘 알지 못하시면 이해가 어려울 수도 있습니다.

불변성을 사용하는 것의 이점

사이드 이펙트를 방지할 수 있습니다.

앞선 글에서 리액트는 상태의 변경을 즉시 처리하지 않고 예약해두었다가 리렌더링한다고 하였습니다. 그래서 리액트는 상태가 변하는 시점이 리렌더링 직전인 것으로 생각하고 상태를 평가하는 등 자신의 기능을 수행합니다. 이때문에 불변성을 지키지 않을 때 문제가 발생할 수 있습니다. 리액트는 리렌더링을 위해 이전의 상태 값과 현재의 상태 값을 비교할 때 얇은 비교를 수행합니다. 관리하고 있는 상태가 참조값이라면 그 내용을 상세하게 모두 비교하지 않습니다. 참조값이 가지고 있는 주소를 비교할 뿐이죠. 따라서 컴포넌트가 중첩된 참조값을 상태로 가지고 있고, set함수 혹은 다른 상태변경을 통해 해당 상태를 변경하면 참조값이 가지는 주소는 변경되지 않으므로 리액트는 상태의 변화를 감지할 수 없습니다(이런 문제를 해결하기 위해 리액트가 깊은 비교를 수행한다면 성능이 크게 저하될 것입니다.).

 

다른 문제도 있습니다. 만약(함수형 업데이트를 사용하지 않는 경우) 참조값인 상태를 변경하면 해당 상태는 원래의 리액트 cycle을 따르지 않고 즉시 변경됩니다. 원래는 리렌더링 직전에 수행되어야할 상태가 그보다 한참 앞서서 변경되는 것이죠. 이 때문에 알수 없는 오류나 부작용을 마주칠 수도 있습니다.

값의 변경을 쉽게 파악할 수 있습니다.

앞서 리액트는 얇은 비교를 수행한다고 하였습니다. 만약 상태를 복사하여 변경한다면, 그래서 불변성을 유지한다면 해당 상태는 기존의 상태와 다른 주소를 값으로 가지고 있을 것입니다. 그래서 리액트는 상태의 변경을 얇은 비교로도 충분히 비교할 수 있게 되고 의도한 대로 값의 변화를 이끌어 낼 수 있을 것입니다.

몇몇 복잡한 기능을 쉽게 구현할 수 있습니다.

만약 과거의 상태를 다시 불러와야 한다면 어떻게 할 수 있을까요? 불변성을 유지하지 않고 상태의 값을 계속 변경하였다면 과거의 상태를 불러오기는 불가능하거나 아주 복잡할 것입니다. 하지만 상태가 변경될 때마다 모든 상태를 새로 만들었다면 어떨까요? 아주 단순한 방법으로 과거의 상태를 불러올 수 있습니다. 이전의 상태와 바뀌는 상태는 서로 독립적이니, 상태가 변화할 때마다 변화 전의 상태를 어딘가에 저장해 두기만 하면 아주 쉽게 과거의 상태를 저장하고 불러올 수 있습니다. 이에 대해서는 리액트 공식 문서에 있는 시간여행 추가하기를 참고하세요.

 

또한 불변성은 컴포넌트가 데이터의 변경 여부를 '저렴한 비용'으로 판단할 수 있게 합니다. memo API는 props가 변경되지 않았을 때, 리렌더링을 하지 않게 해주는 데요. 불변성을 유지한다면 해당 props의 변경여부를 (위의 '값의 변경을 쉽게 파악할 수 있습니다.' 항목과 같은 이유로)쉽게 파악할 수 있기 때문에 성능의 향상에도 도움이 됩니다. 만약 불변성을 유지하지 않았다면 props이 바뀌어도 (참조하는 주소가 같으면) 감지하지 못해 memo를 사용하는 자식 컴포넌트가 정상적으로 작동하지 않을 수도 있습니다.

불변성을 쉽게 유지하기

불변성을 쉽게 유지하는 라이브러리를 소개하는 것으로 글을 마치고자합니다.
use-immer는 내부적으로 불변성을 유지하면서 상태를 직접 변경하는 코드를 작성할 수 있도록 만들어주는 라이브러리 입니다.

import React from "react";
import { useImmer } from "use-immer";


function App() {
  const [person, updatePerson] = useImmer({
    name: "Michel",
    age: 33
  });

  function updateName(name) {
    updatePerson(draft => {
      draft.name = name;
    });
  }

  function becomeOlder() {
    updatePerson(draft => {
      draft.age++;
    });
  }

  return (
    <div className="App">
      <h1>
        Hello {person.name} ({person.age})
      </h1>
      <input
        onChange={e => {
          updateName(e.target.value);
        }}
        value={person.name}
      />
      <br />
      <button onClick={becomeOlder}>Older</button>
    </div>
  );
}

위의 코드는 상태를 복사하여 번경하지 않고 직접 변경하여 불변성을 지키지 않은 것같지만 useImmer로 내부 동작에 의해 자동으로 불변성을 유지할 수 있도록 도와줍니다. 즉 개발자가 map, concat, filter 혹은 스프레드 문법을 사용지 않고 원본 상태에 직접 접근하여 수정하여도 자동적으로 새로운 객체 혹은 배열을 만들어 변경합니다. 이는 immer의 draft 덕분인데 draft는 Proxy라고 하는 특별한 객체 타입으로 동작(변경)을 '기록'하여 해당 변경이 적용된 새로운 객체를 생성합니다.

참고자료

React, [자습서: 틱택토 게임, 불변성이 왜 중요할까요, Immer로 간결한 갱신 로직 작성하기]
use-immer

+ Recent posts