• TOC {:toc}

이 글은 벨로퍼트와 함께하는 모던 React중 23. Immer 를 사용한 더 쉬운 불변성 관리의 내용을 복습하기위해 핵심 내용을 요약 정리한 글입니다.

  • 제가 필요한 부분 위주로 확인하면서 정리하고 있어 글에 덜 작성된 부분이 있을 수 있습니다.
  • 글 작성 후 원문의 내용이 수정되거나 내용을 이해하기 위한 개인적인 설명이나 해석이 있을 수 있습니다. 되도록 원문을 참고해주시길 바랍니다.
  • 잘못된 부분이 있다면 댓글이나 그 외 편하신 방법으로 알려주시면 감사하겠습니다.

리액트에서 배열이나 객체를 업데이트 해야 할 때는 불변성을 지켜주면서 업데이트를 해주어야 한다.

예를 들어 다음과 같이 객체를 직접 수정하면 안되고

// 기존의 객체
const object = {
  a: 1,
  b: 2
};

// object의 b에 직접 3을 대입.
object.b = 3;

다음과 같이 … 연산자를 사용해 새로운 객체를 만들어야 한다.

const object = {
  a: 1,
  b: 2
};

const nextObject = {
  // object 객체를 복사한 뒤
  ...object,
  // b의 값을 3으로 바꾼 '새로운' 객체를 생성
  b: 3
};

배열도 마찬가지로, push, splice 등의 함수를 사용하거나 n번째 항목을 직접 수정하면 안되고, concat, filter, map 등의 함수를 사용해야 한다.

// 기존의 배열
const todos = [
  {
    id: 1,
    text: '할 일 #1',
    done: true
  },
  {
    id: 2
    text: '할 일 #2',
    done: false
  }
];

// concat 으로 기존 배열에서 새로운 객체를 추가한 '새로운' 배열을 생성
const inserted = todos.concat({
  id: 3,
  text: '할 일 #3',
  done: false
});

// filter 로 기존 배열에서 특정 객체를 걸러낸 '새로운' 배열을 생성
const filtered = todos.filter(todo => todo.id !== 2);

// map 으로 기존 배열의 값을 변경한 '새로운' 배열을 생성
const toggled = todos.map(
  todo => todo.id === 2
    ? {
      ...todo,
      done: !todo.done,
    }
    : todo
);

대부분의 경우 … 연산자 또는 배열 내장함수를 사용하는건 어렵지 않지만 데이터의 구조가 까다로워지면 불변성을 지켜가면서 새로운 데이터를 생성해내는 코드가 조금 복잡해진다.

예를 들어 다음과 같은 객체가 있을 때

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

posts 배열 안의 id 가 1 인 post 객체를 찾아서, comments 에 새로운 댓글 객체를 추가해줘야 한다고 가정해보자.

그렇다면, 업데이트는 다음과 같이 해야한다.

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

구현 자체가 어려운 것은 아니지만 코드의 구조가 복잡해져 코드를 한 눈에 파악하기 어렵다.

이럴 때, immer 라는 라이브러리를 사용하면 코드를 더 깔끔하게 구현 할 수 있다.

const nextState = produce(state, draft => {
  const post = draft.posts.find(post => post.id === 1);
  post.comments.push({
    id: 3,
    text: '와 정말 쉽다!'
  });
});

Immer 를 사용하면 상태를 업데이트 할 때, 불변성을 신경쓰지 않고 업데이트를 해도 Immer 가 불변성 관리를 대신 해준다.

Immer 사용법

이번 섹션에서는 기존에 만들었던 사용자 관리 프로젝트에 Immer 를 적용해보면서 Immer 의 사용법을 알아보자.

우선 프로젝트에서 다음 명령어를 실행하여 Immer 를 설치한다.

yarn add immer

Immer 를 사용하기 위해서는 먼저 코드의 상단에서 immer 를 불러와야 한다. 보통 produce 라는 이름으로 불러온다.

import produce from 'immer';

produce 함수를 사용 할 때에는

  • 첫번째 파라미터에는 수정하고 싶은 상태,
  • 두번째 파라미터에는 상태를 어떻게 업데이트할지 정의하는 함수를 넣어준다.
    • 이 함수에서는 불변성을 고려하지 않은채 상태를 업데이트 하는 것이 가능하다.
const state = {
  number: 1,
  dontChangeMe: 2
};

const nextState = produce(state, draft => {
  // 불변성을 고려하지 않고 number state를 수정
  draft.number += 1;
});

console.log(nextState);
// { number: 2, dontChangeMe: 2 }

Reducer에서 Immer 사용하기

사용하기에 앞서, Immer 를 사용해서 업데이트 코드가 간단해지도 하지만 오히려 코드가 길어지기도 한다.

예를 들어 지금까지 만들었던 프로젝트의 상태는 users 배열이 객체의 깊은곳에 위치하지 않기 때문에 새 항목을 추가하거나 제거 할 때 Immer 보다 concatfilter 를 사용하는것이 더 간결하고 편하다.

하지만, 사용법을 배우기 위해 우선 모든 업데이트를 Immer 를 사용하여 처리해보자.

/* App.js */

//...
// immer 에서 produce를 import
import produce from 'immer';

function countActiveUsers(users) { /* ... */ }

const initialState = { /* ... */ };

function reducer(state, action) {
  switch (action.type) {
    case 'CREATE_USER':
      return produce(state, draft => {
        // concat 대신 push 사용
        draft.users.push(action.user);
      });
    case 'TOGGLE_USER':
      return produce(state, draft => {
        const user = draft.users.find(user => user.id === action.id);
        // user의 active 상태를 직접 수정
        user.active = !user.active;
      });
    case 'REMOVE_USER':
      return produce(state, draft => {
        const index = draft.users.findIndex(user => user.id === action.id);
        // filter 대신 splice 사용
        draft.users.splice(index, 1);
      });
    default:
      return state;
  }
}

export const UserDispatch = React.createContext(null);

function App() {
  
  // ...

  return ( /* ... */ );
}

export default App;

TOGGLE_USER 액션은 확실히 코드가 깔끔해졌지만 나머지의 경우에는 오히려 코드가 좀 복잡해진 것을 볼 수 있다. Immer 를 사용한다고 해서 모든 업데이트 로직에서 사용을 하실 필요는 없고 상황에 따라 잘 선택하여 사용면 된다.

Immer 와 함수형 업데이트

이전에 useState 를 사용 할 때 함수형 업데이트를 배웠다.

예를 들면, setTodo 의 파라미터로 상태를 업데이트를 해주는 ‘함수’를 넣어서, useCallback 을 사용하는 경우 두번째 파라미터인 deps 배열에 todo 를 넣지 않아도 되게 된다.

const [todo, setTodo] = useState({
  text: 'Hello',
  done: false
});

const onClick = useCallback(() => {
  setTodo(todo => ({
    ...todo,
    done: !todo.done
  }));
}, []);

이렇게 함수형 업데이트를 하는 경우에, Immer 를 사용하면 상황에 따라 더 편하게 코드를 작성 할 수 있다.

produce 함수에

  • 두 개의 파라미터를 넣어주면 첫번째 파라미터에 넣은 상태를 불변성을 유지하면서 새로운 상태를 만들어주지만
  • 첫 번째 파라미터를 생략하고 바로 업데이트 함수를 넣어주면, 반환 값은 상태를 업데이트 해주는 ‘함수’가 된다.

코드를 보면 조금 더 쉽게 이해할 수 있다.

const todo = {
  text: 'Hello',
  done: false
};

// updater: 상태를 업데이트 해주는 함수
const updater = produce(draft => {
  draft.done = !draft.done;
});

// todo를 인자로 전달해서 todo의 done을 업데이트
const nextTodo = updater(todo);

console.log(nextTodo);
// { text: 'Hello', done: true }

produce 가 ‘업데이트 함수’를 반환하기 때문에 useState 의 업데이트 함수를 다음과 같이 구현 할 수 있다.

const [todo, setTodo] = useState({
  text: 'Hello',
  done: false
});

const onClick = useCallback(() => {
  setTodo(
    produce(draft => {
      draft.done = !draft.done;
    })
  );
}, []);

Immer 은 분명히 편한 라이브러리 이지만 확실히 알아둘 점은, 성능적으로는 Immer 를 사용하지 않은 코드가 더 빠르다는 점 이다.

50,000개의 원소중에서 5,000 개의 원소를 업데이트 하는 코드를 비교 했을때

  • Immer 의 경우 31ms 걸리는 작업이 (map 을 사용하는) Native Reducer 에서는 6ms 걸린다.
  • 25ms 의 차이는, 사실 그렇게 큰 차이가 아니기 때문에 걱정할 필요는 없다. 심지어, 데이터가 50,000개 가량 있는게 아니라면 성능 차이는 거의 없을 것이기 때문에 더더욱 걱정하지 않아도 된다.
  • 단, Immer 는 JavaScript 엔진의 Proxy 라는 기능을 사용하는데 이를 지원하지 않는 구형 브라우저 및 react-native 같은 환경에서는 ES5 fallback 이라는 대안을 사용하기 때문에 191ms 정도로 꽤나 느려지게 된다. 물론, 데이터가 별로 없다면 여전히 크게 걱정 할 필요는 없다.

Immer 라이브러리는 확실히 편하기 때문에, 데이터의 구조가 복잡해지고 불변성을 유지하기위한 코드가 복잡해지는 상황이 온다면, 이를 사용하는 것이 좋다.

  • 다만, 무조건 사용하는 것보다 우선 가능하면 데이터의 구조가 복잡해지게 되는 것을 방지하고
  • 어쩔 수 없을 때 ‘필요한 곳’에만 Immer 를 사용하되
  • 간단히 처리 될 수 있는 곳에서는 그냥 일반 JavaScript 로 구현하는 것을 권장한다.