page owner's profile image
minsug
참조타입 관리하기(feat. immer)immer를 구현해보자
2024년 6월 3일
28분

개요

리액트에서 참조타입 상태(이하 객체 상태)를 다루다 보면 객체 속 값이 변경되었음에도 리렌더링이 이루어지지 않은 모습을 종종 볼 수 있다. 이 글에서는 이런 상황이 발생하는 이유와 객체 상태를 올바르게 다루는 법 그리고 그 방법 중 하나인 useImmerimmer를 파헤처보도록 하겠다

상태는 변하지 않는 다이아몬드와 같아야 한다.상태는 변하지 않는 다이아몬드와 같아야 한다.

왜 불변성을 유지해야할까?

결론부터 말하자면 리액트는 상태 객체 자체의 메모리 주소를 기억하고 있기 때문에 객체 내부에 있는 값이 아무리 바뀌어도 변경을 인지하지 못한다. 그렇기에 객체 상태를 업데이트하기 위해서는 객체의 속성을 변경하는 것이 아닌 아예 새로운 객체를 setter 함수에게 전달해줘야한다

리액트가 렌더링하기까지

리액트가 리렌더링 하는 과정은 실제로 복잡하지만 이 글에서는 간단히 다루고자 한다. 먼저, 버튼을 누르면 화면에 있는 숫자가 1 증가하는 컴포넌트가 있다고 가정해보자. 이때 유저가 버튼을 누르게 되면 리액트에게 리렌더링하라는 신호가 가게되고 리액트는 기존에 저장된 화면(스냅샷)과 새로이 변경된 화면을 비교해서 가상 DOM을 업데이트 한다.

여기서 주목하고 싶은 것은 리액트가 어떻게 리렌더링 여부를 아는 지이다. 리액트는 가상 DOM 비교 전에 상태 실제로 변경됐는지 확인한다. (이 글에서는 가상 DOM 비교는 다루지 않을 예정이다)

👀 리액트에서의 리렌더링 리액트의 리렌더링은 브라우저의 리렌더링을 의미하지 않는다. 대신 리액트가 상태 변경 전후의 가상 DOM을 비교하고 만약 달라졌다면 업데이트된 가상 DOM을 브라우저에게 전달하는 일련의 과정을 말한다.

이때 리액트는 실제 변경을 확인하기 위해 Object.is 를 사용한다. useState의 간략화된 일부 코드를 보면 다음과 같다.

function basicStateReducer(currentState, action) {
  // Check if new state is a function
  const newState = action === "function" ? action(currentState) : action;
  // Check if state has changed
  if (Object.is(currentState, newState)) {
    return currentState; // No change, return current state
  }
  return newState; // State has changed, return new state
}

위 코드를 해석해보면

  1. action이 함수일 경우 currentState를 매개변수로 실행시킨 값을 newState에 할당하며 아닐 경우 action을 newState에 할당한다.
  2. 만약 currentState와 newState가 동일하다면 currentState를 반환하고 동일하지 않다면 newState를 반환한다.

다시 말해서 객체의 값을 변경하는 것은 currentState를 반환한다. 그리고 그 이후의 과정은 진행되지 않는다.

그렇기에 객체 상태를 다룰때는 메모리 주소가 다른 객체를 이용해야한다.

👀 setState 함수는 왜 연속해서 실행해도 마지막 함수만 적용될까? 실제 useState 함수는 state의 변경 여부를 한번만 실행하지 않는다. 이게 무슨 말인가 하면 setState 실행시 setState의 인자는 state queue에 푸쉬되며 리액트는 이를 일괄적으로 처리한다.

그렇기에 setState를 하나의 함수 블록에서 여러번 실행해도 마지막 인자가 새로운 상태로 할당되는 것이다.

불변성을 유지하고 객체를 다루는 법

상태가 object일때와 array일때 다루는 법이 조금씩 다른데 이는 새로운 배열을 반환하는 array 메서드가 있기 때문이다. 이번 주제는 실질적인 해결책을 제시함으로 코드들이 많이 포함되어 있다.

Object 타입의 요소 다루기(Array 포함)

아래와 같은 코드에서 x만 변경하고 싶다면 어떻게 해야할까? 그 방법을 한번 알아보자

  const [position, setPosition] = useState({
    x: 0,
    y: 0,
  });

Spread Operator 사용하기

원본 객체가 아닌 완전히 새로운 객체를 만들 수 있는 대표적인 방법에는 spread operator가 있다. 만약 x를 0에서 1로 만들고 싶다면 다음과 같이 쓸 수 있다.

  setPosition({
    ...position,
    x: 1,
  });

이는 속성을 덮어쓸 수 있는 object의 특성을 이용한 것이다.

spread operator를 사용할 때 주의할 점은 깊은 복사가 아닌 얕은 복사를 실행한다는 것이다. 그래서 중첩된 객체를 복사할때는 다음과 같이 조금 복잡하게 사용해야한다.

const [person, setPerson] = useState({
  name: "Niki de Saint Phalle",
  artwork: {
    title: "Blue Nana",
    city: "Hamburg",
    image: "<https://i.imgur.com/Sd1AgUOm.jpg>",
  }
});
 
setPerson({
 ...person,
 arkwork: {
  ...person.artwork,
  city: "StateCollege",
 }
});

Array 타입의 요소 다루기

다음과 같은 상태가 있다고 가정해보자.

const [artists, setArtists] = useState([
  {id: 0, name: "피카소"}
]);

artists에 새로운 화가를 넣으려면 push가 아닌 setArtist를 활용해야하는데 객체와 마찬가지로 spread operator를 사용할 수 있다.

setArtists([
  ...artists,
  {id: 1, name: "레오나르도 다 빈치"}
]);

만약 배열 안에 있는 값을 제거하고 싶으면 filter를 활용할 수 있다.

setArtists(artists.filter(artist => artists.id !== 0)); // id가 0인 artist 삭제

만약 배열 안에 있는 값을 수정하고 싶으면 map을 활용할 수 있다.

setArtists(artists.filter(
  artist => artists.id === 0 ? artists.name = "minsug" : artist.name = artist.name
)); // id가 0인 화가 이름을 "minsug"으로 변경

객체와 마찬가지로 배열도 중첩된 형태를 다루려면 상당히 품이 많이든다. 이를 해결하기 위해 나온 것이 useImmer라는 라이브러리다. 한번 살펴보자.

useImmer로 객체 상태를 쉽게 다루는 법

useImmer는 Immer 라이브러리를 기반으로 만들어진 상태 관리 라이브러리로 다음과 같은 특징을 가진다.

  • 복잡한 상태 업데이트: 복잡하게 얽힌 상태나 중첩된 객체를 쉽게 업데이트할 수 있게 한다.
  • 불변성 유지: 원래 상태였던 객체를 변경하지 않고 새로운 객체를 할당함으로 불변성을 유지한다.
  • 컴포넌트 상태 최적화: Immer는 내부적으로 변경 사항을 추적하고 필요한 경우에만 실제로 새로운 상태를 생성한다. 이는 React 컴포넌트의 불필요한 리렌더링을 방지하고 상태 업데이트의 성능을 최적화할 수 있다.

useImmer 사용법

useImmer는 사용하기 정말 간편하다. 좀 전에 다뤘던 객체를 useImmer로 변경해보자.

const [person, updatePerson] = useImmer({
  name: "Niki de Saint Phalle",
  artwork: {
    title: "Blue Nana",
    city: "Hamburg",
    image: "<https://i.imgur.com/Sd1AgUOm.jpg>",
   }
 });

title는 중첩 객체의 값이기 때문에 spread operator 두 번 써야하지만 useImmer를 사용하면 spread operator를 사용하지 않고도 변경이 가능하다.

// useImmer를 사용하지 않은 경우
setPerson({
  ...person,
  artwork: {
    ...person.artwork,
    title: "typescript"
  }
});
 
    // useImmer를 사용한 경우
updatePerson(draft => {
  draft.artwork.time = "typescript"
});

더 깊게 중첩된 객체를 다뤄보자.

const state = {
  posts: [
    {
      id: 1,
      title: "제목입니다.",
      body: "내용입니다.",
      comments: [
        {
          id: 1,
          text: "와 정말 잘 읽었습니다."
        }
      ]
    },
    {
      id: 2,
      title: "제목입니다.",
      body: "내용입니다.",
      comments: [
        {
          id: 2,
          text: "또 다른 댓글 어쩌고 저쩌고"
        }
      ]
    }
  ],
  selectedId: 1
};

여기서 posts 배열 안에 id : 1 인 객체를 찾아서, comments 에 새로운 댓글 객체를 추가해줘야 한다고 가정해보자. 만약 setState를 사용하면 다음과 같이 구현할 수 있다.

setState({
  ...state,
  posts: state.posts.map(post =>
    post.id === 1
      ? {
          ...post,
          comments: post.comments.concat({
            id: 3,
            text: "새로운 댓글"
          })
        }
    : post
  )
});

코드의 구조가 복잡해져서 코드가 한 눈에 들어오지 않는다. 만약 useImmer를 사용하면 어떨까?

// updateState를 setState라고 생각하면 이해하기 수월하다.
updateState(draft => {
  const post = draft.posts.find(post => post.id === 1);
  post.comments.push({
    id: 3,
    text: "와 정말 쉽다!"
  });
});

코드를 이해하기 훨씬 수월해졌다. 근데 아까 객체 상태는 불변성을 유지해야한다고 했는데 useImmer의 콜백에 push가 있다. 이래도 될까? 만약 된다면 어떤 작동원리로 이것이 가능한지 한번 들여다보자.

👀 useImmer의 컨벤션
useImmer의 setter 함수는 useState처럼 setState가 아닌 updateState로 > 명명된다. useImmer의 매개변수의 이름은 draft로 명명된다.

useImmer의 작동원리

useImmer는 Immer 라이브러리를 사용해서 만들어졌고 Immer 라이브러리는 자바스크립트의 Proxy를 기반으로 작성됐다. 즉, Proxy의 이해가 선행돼야 Immer를 알 수 있다. 차근차근 하나씩 보자.

👀 Immer는 독일 말로 “항상, 영원히”이라는 뜻이다. 이는 Immutability(불변성)을 내포하고 지키자는 의미로서 지어졌다.

Proxy가 뭐야?

Proxy는 여기저기 많이 불리는 단어이지만 뜻이 명확히 알려져 있지는 않다. proxy를 한글로 번역하면 “대리” 혹은 “대리자”로 어떤 역할을 대신해서 수행하는 기능을 의미한다. 그렇다면 자바스크립트에서 Proxy는 어떤 의미일까?

Proxy는 객체를 복사한 또 다른 객체로 같은 메모리 주소를 가지고 있다. 한 가지 다른 점은 Proxy 객체에 대한 작업을 가로채고 재정의할 수 있다.

이걸 쉬운 예시로 설명하자면, 프록시 객체 프로퍼티의 값을 변경하려고 할때 이 변경을 프록시가 정의한 방식대로 한다는 것이다. 한번 코드로 보자

// 오리지널 객체
const minsug = {
  lastName: "chae",
  age: 29,
};
 
// 프록시 객체에 할당할 명령
const handler = {
  set: function (obj, prop, value) {
    obj[prop] = value;
    console.log(`${value}가 지정됨!`);
  }
}
// 오리지널 객체를 카피한 프록시 객체
const proxy = new Proxy(minsug, handler);
proxy.lastName = "choi" // "choi가 지정됨!"가 출력됨
console.log(proxy) // "{ lastName: "choi", age: 29 }"가 출력됨
console.log(minsug) // "{ lastName: "choi", age: 29 }"가 출력됨

minsug과 handler로 생성된 proxy의 lastName을 재할당하니 콘솔이 찍혔다. 그 이유는 handler의 set에서 콘솔을 실행시켰기 때문이다.

만약 set 함수 안 obj[prop] = value 표현식이 없다면 어떻게 될까?

proxy.lastName = "choi" // choi가 지정됨!
console.log(proxy) // { lastName: "chae", age: 29 }
console.log(minsug) // { lastName: "chae", age: 29 }

콘솔만 찍힐 뿐 값이 재할당되지 않았다.

다시 말해서 재할당 명령을 내렸는데 proxy가 명령을 가로채서 handler에서 정의한 방식대로 작업을 실행했다.

지금은 뭐가 뭔지 이해가 되지 않겠지만 여기서 알아야할 것은 두 가지다.

  1. 프록시 객체는 handler 함수가 정의된 대로 작업을 수행한다.
  2. 오리저널 객체와 프록시 객체는 같은 메모리 주소를 참조하고 있다.

Proxy 사용법

Proxy는 생성자 함수로 총 두 개의 인자를 받는다.

첫번째 인자: 복사할 객체(원본)
두번째 인자: 어떤 명령을 가로채고 어떻게 재정의할지 명시하는 객체(handler)
첫번째 인자는 명시적이니 생략하고 두번째 인자를 한번 보자.

handler 객체는 어떤 상황에서 어떤 명령을 수행할 것인지 명시적으로 함수 프로퍼티를 선언해줘야한다. 이게 무슨 말이냐 하면 명령에 따라 해당 함수의 이름이 정해져 있다. (다 알아볼 필요는 없고 두 개만 살펴보자)

  1. 가장 먼저 get 함수가 있다. 말 그대로 값을 불러올때 가로챈다. 더 구체적으로 말하면 object의 [[Get]] 함수가 실행되면 발동된다.
proxy.lastName // 이 문 자체가 handler의 get 함수를 실행시킨다.
 
const handler : {
 get: function(target, prop, receiver) {
  return target[prop]
 }
}
  1. 두번째로 값을 할당할때 가로채는 set 함수가 있다. 더 구체적으로 말하면 object의 [[Set]] 함수가 실행되면 발동된다.
proxy.lastName = choi // 이 문 자체가 handler의 set 함수를 실행시킨다.
 
const handler = {
  set: function (obj, prop, value) {
    obj[prop] = value;
    console.log(`${value}가 지정됨!`);
  }
}

이외에도 여러 함수가 있으니 MDN을 한번 살펴보면 좋다.

Immer의 작동원리 파헤치기

useImmer는 Immer를 리액트 훅으로 변경시킨 버전으로 Immer를 이해하는 것이 우선이라고 생각한다. Immer는 사용하기 편리할 뿐만 아니라 메모리 공간 사용 측면에서도 최적화되어 있다. 이는 deep copy를 통해서 객체의 속성을 새로 생성하는 것이 아닌 변경된 값에 한해서만 참조 메모리 주소만 변경하기 때문이다. 즉, 기존 객체에서 사용하는 메모리를 재사용하기 때문에 새로운 객체를 빠르고 메모리 효율적으로 생성한다.

그럼 이제 Immer가 어떤 방식으로 작동하는지 자세히 알아보자.

Immer 구현하기

라이브러리 혹은 함수를 이해함에 있어 가장 좋은 방법 중 하나는 실제로 구현해보는 것이라고 생각한다. 이 글에서도 Immer를 간소화된 코드로 구현해보고자 한다. 구현한 코드는 3 단계로 나눌 수 있다.

  1. 프록시 명령 작성 및 프록시 객체 생성
  2. 변경 사항 적용 및 새로운 객체 생성
  3. 1번과 2번 객체를 합성

프록시 명령 작성 및 프록시 객체 생성

const proxies = new WeakMap();
const changes = new WeakMap();
 
const handler = {
    get: function (target, prop) {
      const value = target[prop];
      if (typeof value === "object" && value !== null) {
        if (!proxies.has(value)) {
          proxies.set(value, createProxy(value));
        }
      }
 
      return proxies.get(value);
    },
    set: function (target, prop, value) {
      if (!changes.has(target)) {
        changes.set(target, { ...target });
      }
      changes.get(target)[prop] = value;
 
      return true;
    }
  }
 
function createProxy(state) {
  return new Proxy(state, handler);
}

get이 실행되면 다음과 같은 작업이 수행된다.

  1. 값이 proxies에 저장되어 있지않은 객체라면 오리지널 객체를 key로, 프록시 객체를 value로 proxies에 집어넣는다.
  2. 값이 무엇이든 proxies에서 값을 key로 value를 반환한다.

set이 실행되면 다음과 같은 작업이 수행된다.

  1. changes에 target이 없다면 changes에 target을 key로, target의 얕은 복사본을 value로 저장한다.
  2. change에 담겨 있는 얕은 복사본의 값를 value으로 변경한다.
    위 같이 handler를 설계한 이유를 예시를 통해 알아보자.
const baseState = {
  user: {
    name: "John",
    address: {
      city: "New York",
      zip: "10001",
    },
  },
};
 
const draft = CreateProxy(baseState);
 
baseState.user.name = Minsug

baseState.user.name = Minsug이라는 코드를 실행시켰다고 가정해보자.

  1. baseState.user.name을 읽기 위해서 baseState.user가 먼저 읽혀야하는데 이때 baseState.user의 원본과 프록시 객체가 proxies에 저장되고 프록시 객체를 반환.
  2. 이때 baseState.user.name은 원본 객체가 아닌 프록시 객체의 값이기 때문에 Minsug으로 재할당할때 set 함수가 발동되어 changes에 프록시 user 객체와 얕은 복사된 객체가 key와 value로 저장된다. 그리고 얕게 복사된 객체의 값을 “John"에서 “Minsug”으로 변경.

정리하자면

  • 객체의 값을 바꿀때 원본의 불변성을 유지하기 위해 weakMap에 얕은 복사된 객체의 값을 변경한다.
  • 중첩된 객체도 다루기 위해 값을 읽을 때 객체라면 프록시 객체를 반환한다.

여기서 중요한 점은 원본 객체는 영향을 전혀 받지 않는다는 점이다.

👀 WeakMap이란 일반적인 Map과 같지만 키의 타입이 object이라는 것이 다르다. 이름에 weak가 붙은 이유는 키로 들어간 object가 참조되지 않으면 GC가 바로 수거해가기 때문이다.

변경 사항 적용 및 새로운 객체 생성

function finalize(state) {
    if (!state || typeof state !== "object") return state;
    const modified = changes.get(state) || state;
    const copy = Array.isArray(modified) ? modified.slice() : { ...modified };
 
    for (const key in modified) {
      copy[key] = finalize(modified[key]);
    }
 
    return copy;
  }

finalize 함수는 변경사항이 저장된 changes를 사용해서 새로운 객체를 만든다. 흐름은 다음과 같다.

  1. 만약 state가 비어있거나 객체가 아니라면 state를 반환
  2. 만약 changes에 state가 등록되어있다면 changes에 저장돼 있는 객체를 할당하고 아니라면 state를 할당
  3. modified를 얕은 복사 후 copy에 할당
  4. modified의 모든 프로퍼티에 대해서 위 절차를 반복하고 반환값을 copy[key]에 할당
  5. copy를 반환

이 과정은 전체 코드 예시로 더 자세히 알아보자

실제 코드 흐름

구현한 Immer 전체 코드는 아래와 같다.

function produce(baseState, producer) {
  const proxies = new WeakMap();
  const changes = new WeakMap();
 
  const handler = {
    get: function (target, prop) {
      const value = target[prop];
 
      if (typeof value === "object" && value !== null) {
        if (!proxies.has(value)) {
          proxies.set(value, createProxy(value));
        }
      }
 
      return proxies.get(value);
    },
    set: function (target, prop, value) {
      console.log(target);
      if (!changes.has(target)) {
        changes.set(target, { ...target });
      }
 
      changes.get(target)[prop] = value;
 
      return true;
    }
  }
 
  function createProxy(state) {
    return new Proxy(state, handler);
  }
 
 // 실행순서 1
  const draft = createProxy(baseState);
  
  // 실행순서 2
  producer(draft); // draft를 매개변수로 하는 익명함수 실행
 
  function finalize(state) {
    if (!state || typeof state !== "object") return state;
    const modified = changes.get(state) || state;
    const copy = Array.isArray(modified) ? modified.slice() : { ...modified };
    
    for (const key in modified) {
      copy[key] = finalize(modified[key]);
    }
 
    return copy;
  }
 
 // 실행순서 3
 const updatedState = finalize(baseState);
 
  return updateState;
}
 
const baseState = {
  user: {
    name: "John",
    address: {
      city: "New York",
      zip: "10001",
    },
  },
};
 
const nextState = produce(baseState, draft => {
  draft.user.name = "Jane";
  draft.user.address.city = "Seoul";
});

먼저 draft의 정체를 알아보자.

실행순서 1에서 createProxy를 실행시키고 그 반환값을 draft로 준다. 즉, draft는 baseState의 프록시 객체이다.

자 이제, nextState 코드가 실행됐을 때 어떤 일이 일어나는지 차근 차근 뜯어보자.

  1. produce의 첫 번째 인자로 baseState를 넣고 두 번째 인자에 익명 함수를 넣는다.
  2. baseState의 프록시 객체가 draft에 할당된다.
  3. draft(프록시 객체)를 매개변수로 하는 draft.user.name = "Jane"이 실행된다.
  4. draft가 프록시 객체이고 draft.user는 객체이기에 get이 실행되어 draft.user.name = "Jane"은 set을 호출하고 changes에 얕은 복사된 객체의 name을 "Jane"으로 변경 한 후에 저장한다.
  5. draft(프록시 객체)를 매개변수로 하는 draft.user.address.city = "Seoul"이 실행된다.
  6. draft.user가 프록시 객체이고 draft.user.address가 객체이기에 get이 실행되어 draft.user.address.city = "Seoul"는 set을 호출하고 changes에 얕은 복사된 객체의 city를 “Seoul”로 변경한 후에 저장한다.

여기까지가 프록시 명령 작성 및 프록시 객체 생성에 해당된다. 이제 finalize(실행순서 3)가 실행될 차례이다.

  1. baseState를 인자로 finalize를 실행시킨다.
  2. baseState의 속성은 변경된 적이 없어 그대로 modified에 할당한다.
  3. modified는 객체이기 때문에 얕은 복사되어 copy에 할당된다.
  4. copy의 속성을 순회하면서 finalize를 실행한다.
  5. user를 인자로 finalize를 실행시킨다.
  6. user의 속성(name)은 변경된 적이 있어 changes.get(user)는 user를 얕은 복사한하고 name에 ‘Jane’을 재할당한 객체를 modified에 할당한다.
  7. modified는 객체이기 때문에 얕은 복사되어 copy에 할당된다.
  8. 전달된 인자가 객체가 아닐때까지 순회한다.

finalize의 실행은 이렇게 정리할 수 있다.

  • 객체의 속성을 순회하면서 변경된 적이 있는지 확인한다.
  • 변경된 적이 있다면 변경된 속성을 얕은 복사하고 객체의 속성에 할당한다.

Wrap Up!

최대한 읽기 쉽게 쓴다고 썼지만 여간 읽기 쉽지 않을 것 같다고 생각한다. 그래서 마지막으로 Immer의 작동 원리를 요약 및 정리해보고자 한다.

  • produce의 두 번째 인자에서 사용되는 익명 함수의 draft는 프록시 객체이다.
  • draft는 깊이와 상관없이 값이 변경되면 새로운 객체를 만들어 기존 객체에 이어 붙인다.
  • 최상위 객체의 메모리 주소를 바꿔 새 객체로 만들면 변경된 값이 저장된 복사본이 생성된다.

useState의 상태 객체를 다루는 법이 Immer의 구현까지 와버렸는데 이번 기회에 Proxy와 Immer의 동작 원리를 파볼 수 있어서 좋은 시간이었던 것 같다. 이와 별개로 중첩 객체를 다룸에 있어서 항상 Immer를 사용하는 것이 옳지는 않다. 앞서 소개한 다양한 방법들도 존재하지만 중첩 객체를 만드는 상황을 피하거나 정규화하는 편이 상황에 더 알맞을 수 있다.

참고자료

https://github.com/immerjs/immer
https://react.vlpt.us/basic/23-immer.html