본문 바로가기

Frontend/React

리액트의 불변성, 왜? 그리고 어떻게?

리액트에서는 useState() 훅을 이용하여 상태를 정의하고 반환된 setter함수를 이용해서만 데이터를 변경할 수 있다.

배열이나 객체처럼 단일 값이 아닌 여러 값을 저장하고 있는 데이터 타입의 경우 아래와 같이 값을 변경해야 한다.

 

//items 배열에 새로운 요소를 추가하는 예제
const [items, setItems] = useState(['Item 1', 'Item 2', 'Item3']);

const addItem = () => {
  setItems([...items, 'New Item']); // 기존 배열을 복사하고 새로운 아이템 추가
};

 

자바스크립트에는 배열에 요소를 추가하는 push()와 같은 메소드가 있는데, 왜 setter 함수를 이용해서만 값을 변경해야 할까? 그리고 setter 함수는 왜 변경할 부분의 값만 받아 기존 데이터를 수정하는 것이 아니라, 전체 값을 새로 복사하여 재할당하도록 설계된걸까?

 

이는 리액트에서 불변성이 지켜져야 하기 때문이다.

불변성이란 값이나 상태를 변경할 수 없는 것인데, 자바스크립트에서의 불변성은 메모리 영역의 값을 변경할 수 없는 것이다. 

 

그렇다면 리액트에서는 왜 불변성이 지켜져야 하는 것이고, 어떻게 불변성을 지킬 수 있는 것일까?

 


 

리액트의 불변성을 이해하기 위해서는 먼저 자바스크립트의 메모리 구조와 데이터를 재할당하는 원리를 이해해야 한다. 

자바스크립트는 call stackmemory heap, 2가지 메모리 구조를 사용한다.

 

이러한 메모리 구조에서 원시 타입 데이터와 참조 타입 데이터는 저장되는 방식이 다르다.

원시 타입의 경우 콜 스택에 데이터가 저장되는 반면, 참조 타입의 경우 실제 데이터는 메모리 힙에 저장되고 콜스택에는 메모리 힙의 주소가 저장된다. 

 

원시 타입은 기본적으로 변경할 수 없는 데이터이다. 변수에 새로운 값을 할당할 수 없다는 뜻이 아니라, 콜스택 영역에 저장된 데이터가 변경되지 않는다는 것이다.

원시 타입 변수에 새로운 값을 할당하면, 새로운 주소를 추가해 값을 저장하고 변수가 해당 주소를 바라보게 한다. 기존에 콜스택 영역에 저장된 데이터를 그대로 둔 채로(불변성을 유지) 새로운 값을 할당하는 것이다. (이 기존 값은 더 이상 참조되지 않으므로 가비지 컬렉터에 의해 자동으로 사라진다.) 

 

 

 

참조 타입 변수에 값을 재할당하면 콜스택 영역의 주소 값은 변경되지 않은 채 메모리 힙 영역의 데이터가 변경된다. 

즉, 참조 타입의 값을 변경하는 경우에는 메모리 영역의 값을 변경할 수 없다는 불변성이 지켜지지 않는다.

 

 

리액트에서 불변성이 지켜져야 하는 이유

리액트는 state의 변경을 감지하여 컴포넌트를 리렌더링하는데, 이때 state가 변경되는 기준이 콜스택의 값이기 때문이다. 

리액트에서는 효율성을 위해 상태 값을 변경할 때 얕은 비교를 한다. 얕은 비교를 한다는 것은 배열이나 객체의 속성 값들이 변화되었는지 비교하는 것이 아니라, 참조값, 즉 콜스택에 저장된 메모리 힙 영역을 가리키는 주소 값을 비교하여 상태 변화를 감지한다는 것이다. (원시 타입의 경우 콜 스택에 데이터 값이 저장되므로 값을 비교한다.)

 

참조 타입의 값을 변경하면 메모리 힙 영역의 데이터는 변경되지만, 콜 스택 영역의 주소 값은 그대로 이기 때문에 리액트에서는 상태 변경을 감지하지 못한다. 이로 인해 값이 바뀌어도 리렌더링이 되지 않는다. 

 

 

불변성을 지키기 위한 방법

따라서 배열이나 객체와 같은 참조 타입의 데이터를 변경할 때에 원본 데이터를 직접 수정하는 것이 아니라, 새로운 참조 값을 가진 새로운 배열이나 객체를 생성해야 한다. 그렇기 때문에 참조 타입 상태를 변경할 때 setter 함수에 변경되지 않는 값을 포함한 전체 배열 또는 객체를 넘겨주어야 하는 것이다. 

 

변경할 값을 포함한 새로운 배열이나 객체를 생성하기 위해 스프레드 연산자(...) 등을 활용할 수 있다.

//배열 상태 업데이트
const [items, setItems] = useState([1, 2, 3]);

setItems((prevItems) => [...prevItems, 4]); // 기존 배열 복사 후 새 항목 추가


//객체 상태 없데이트
const [user, setUser] = useState({
  name: "John",
  age: 25,
});

setUser((prevUser) => ({
...prevUser, // 기존 객체 복사
name: "Jane", // 변경할 필드만 업데이트
}));

 

이 외에도 map(), filter(), slice(), reduce(), concat() 등 새로운 배열을 반환하는 메소드들을 활용할 수 있다. 

 

Immer

Immer는 불변성을 유지하며 상태를 쉽게 변경할 수 있도록 돕는 라이브러리이다.

Immer를 활용하면 구조가 복잡한 원시 타입의 상태 업데이트를 더욱 간결하게 처리할 수 있다.

 

Immer는 produce 라는 함수를 기반으로 동작한다. produce 함수는 기존 상태를 전달받아 변경 작업을 거친 후 새로운 생태를 생성하여 반환한다. 

produce 함수 내에서는 원본 데이터의 Proxy인 draft 객체를 사용하여 마치 원본 데이터를 직접 수정하는 것처럼 변경 사항을 기록할 수 있다. 그러나 기록한 변경 사항은 원본 데이터에는 아무런 영향을 미치지 않으며, 최종적으로 모든 변경 사항을 반영한 새로운 상태를 생성한다. 

 

 

사용법은 다음과 같다.

 

먼저 라이브러리를 설치해줘야 한다.

npm install immer

 

 

 기본적인 사용 방법으로 produce는 상태를 변경할 데이터와, 상태를 변경하는 함수를 파라미터로 받는다. 

import {produce} from "immer"

const baseState = [
    {
        title: "Learn TypeScript",
        done: true
    },
    {
        title: "Try Immer",
        done: false
    }
]

const nextState = produce(baseState, draftState => {
    draftState.push({title: "Tweet about it"})
    draftState[1].done = true
})

 

useState()와 사용하면 setter 함수 안에서 사용하면 된다.

import { useState } from "react";
import produce from "immer";

function App() {
  const [state, setState] = useState({
    user: { name: "John", age: 25 },
  });

  const updateAge = () => {
    setState(
      produce((draft) => {
        draft.user.age += 1; 
      })
    );
  };

 

 

 


[정리]

  • 자바스크립트에서 원시 타입 값을 변경하면 새로운 메모리 영역에 변경 값을 할당하여 불변성이 유지되지만, 참조 타입은 메모리에 저장된 기존 데이터를 직접 변경하여 불변성이 유지되지 않는다.
  • 리액트에서는 효율성을 위해 객체나 배열의 값이 아닌 참조 값을 비교하는 얕은 비교를 하여 상태 업데이트를 하기 때문에 불변성이 요구된다.
  • 불변성을 유지한 채 객체나 배열의 상태를 업데이트 하기 위해서는 새로운 배열이나 객체를 생성하여 새로운 참조 값을 가지도록 해주어야 한다.