react - immer.js

김예찬·2021년 5월 4일
0

리액트의 불변성을 쉽게 지켜주는 라이브러리 immer를 알아봅시다😶


이 포스터는 경환님과 벨로퍼트님의 포스터를 참고하여 작성하였습니다.


immer

react에서 불변성을 유지하는 코드를 쉽게 작성하게 해주는 라이브러리입니다. 상태의 계층이 깊어진다면, 꼭 사용할 것을 권장합니다(velopert님, zerocho님 등 많은 분들이 추천하시더라구요!!)


불변성

상태를 변경하지 않는 것. 상태를 변경할 때, 최소한의 변경만 해줌을써, 데이터 낭비를 방지해줍니다. 또 어떤 변화가 있는지 이전의 상태현재 상태를 비교해 변화를 알아차리는 react의 특성의 이해가 필요


react의 렌더링 방식

  1. react는 기본적으로 부모 컴포넌트가 리렌더링을 하면 자식 컴포넌트도 리렌더링 됩니다. 즉, 얕은 비교를 통해 새로운 값인지 아닌지를 판단한 후 새로운 값인 경우 리렌더링을 하게 됩니다

  2. 얕은 비교란 간단히 말하자면 객체, 배열, 함수와 같은 참조 타입들을 실제 내부 값까지 비교하지 않고 동일 참조인지(동일한 메모리 값을 사용하는지)를 비교하는 것을 뜻합니다

  3. state가 얕은 비교를 통해 컴포넌트가 리렌더링 된다는 말은 굉장한 의미가 있습니다. 아래의 시나리오를 보면 왜 react에서 요소를 직접 변경하면 안되는지 알 수 있죠.

  4. 컴포넌트를 리렌더링 해야하는 상황이 있다고 가정하고, 타입이 배열인 state를 바꿉니다. state.push(1)을 통해 state 배열에 직접 접근하여 요소를 추가합니다.

  5. push 전과 다른 값이라고 생각하지만, 리엑트는 state라는 값은 새로운 참조값이 아니기 때문에 이전과 같은 값이라고 인식하고 리렌더링 하지 않습니다(문제 발생).

  6. 위 이유로 우리가 state를 바꾸고 돔을 다시 만들려면, 새로운 객체 or 배열을 만들어 새로운 참조값을 만들고, react에게 이 값은 이전과 다른 참조값임을 알려야하는 것입니다.

  7. 위 과정은 가상 dom에서만 이뤄지는 렌더링이며, 렌더링을 마치면 react의 알고리즘에 의해 변화가 일어난 컴포넌트만 실제로 업데이트되어 우리 눈에 보이게 되는 것입니다.


Immer 사용하지 않고 불변성 지키기

immer를 사용하지 않고 불변성을 지키는 예시를 보고 immer를 사용하면 얼마나 편리하게 불변성을 지킬 수 있는지 확인해봅시다!


// 객체 불변성 지켜주기
const object = {
  a: 1,
  b: 2
};

const nextObject = {
  ...object,
  b: 3
};


// 배열 불변성 지켜주기
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
};

경우 불변성을 지켜주기 쉽지 않은데, post 배열 안의 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.js 사용해보자

위의 코드를 Immer로 처리하게 된다면

const nextState = produce(state, draft => {
  const post = draft.posts.find(post => post.id === 1);
  post.comments.push({
    id: 3,
    text: '이머 얼마나 좋게~'
  });
});

처럼 코드를 따라 읽으면 이해하기 쉽고, 코드의 길이 또한 상당히 줄일 수 있습니다. 위의 immer가 제공하는 produce의 처음 파라미터로는 다루는 state가 들어가게 됩니다. 이는 두번째 함수 파라미터의 draft라는 인자 값으로 들어가게 되는데, draft의 모양은 state와 같음으로, 불변성을 생각하지 않고 배열 함수를 이용하거나, state를 마음대로 변화시켜주면, immer가 불변성을 지켜주어 새로운 state를 반환해줍니다

리듀서에서 Immer 사용하기

리액트뿐 만아니라, 리액트 상태관리 방법인 reducer(redux도 마찬가지 입니다)에서도 Immer를 사용할 수 있습니다. 하지만 깊이가 깊지 않은 상태에 관해서는 immer의 사용이 오히려 코드를 더 길고 복잡하게 할 수 있음으로, 상황에 따라 사용하는 것을 추천합니다.

// reducer에서 immer 사용예시
// 예시는 벨로퍼트님의 포스팅을 참고 했습니다.
import React, { useReducer, useMemo } from 'react';
import UserList from './UserList';
import CreateUser from './CreateUser';
import produce from 'immer';

function countActiveUsers(users) {
  console.log('활성 사용자 수를 세는중...');
  return users.filter(user => user.active).length;
}

const initialState = {
  users: [
    {
      id: 1,
      username: 'kim',
      email: 'kim@gmail.com',
      active: true
    },
    {
      id: 2,
      username: 'tester',
      email: 'tester@example.com',
      active: false
    },
    {
      id: 3,
      username: 'liz',
      email: 'liz@example.com',
      active: false
    }
  ]
};

function reducer(state, action) {
  switch (action.type) {
    case 'CREATE_USER':
      return produce(state, draft => {
        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;
      });
    case 'REMOVE_USER':
      return produce(state, draft => {
        const index = draft.users.findIndex(user => user.id === action.id);
        draft.users.splice(index, 1);
      });
    default:
      return state;
  }
}

// UserDispatch 라는 이름으로 내보내줍니다.
export const UserDispatch = React.createContext(null);

function App() {
  const [state, dispatch] = useReducer(reducer, initialState);

  const { users } = state;

  const count = useMemo(() => countActiveUsers(users), [users]);
  return (
    <UserDispatch.Provider value={dispatch}>
      <CreateUser />
      <UserList users={users} />
      <div>활성사용자 수 : {count}</div>
    </UserDispatch.Provider>
  );
}

export default App;

Immer 사용에 주의 할 점

벨로퍼트님의 글에 따르면 기본적인 불변성을 지켜주며 하는 상태관리에 비해 Immer의 사용이 약간 더 느리다고 합니다. 하지만 그렇게 크게 신경쓸 정도의 차이가 아니게 때문에 데이터의 양이 크지 않다면 별로 걱정할게 없다고 합니다. 하지만, 무조건 사용하지말고 데이터의 구조를 먼저 단순화 할 수 있는지 판단해보고 사용을 결정하면 좋을 것 같습니다.😚

profile
프론트엔드

0개의 댓글