Immer 를 사용한 더 쉬운 불변성 관리

Hyun·2021년 10월 2일
0

리액트 기초

목록 보기
15/18
post-custom-banner

리액트에서 배열이나 객체를 업데이트 해야 할 때에는 직접 수정하면 안되고 반드시 불변성을 지켜주면서 업데이트를 해야한다.

이렇게 하면 안되고

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

object.b = 3;

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

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

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

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

const todos = [
  {
    id: 1,
    text: '할 일 #1',
    done: true
  },
  {
    id: 2
    text: '할 일 #2',
    done: false
  }
];

const inserted = todos.concat({
  id: 3,
  text: '할 일 #3',
  done: false
});

const filtered = todos.filter(todo => todo.id !== 2);

const toggled = todos.map(
  todo => todo.id === 2
    ? {
      ...todo,
      done: !todo.done,
    }
    : todo
);

concat: 인자로 주어진 배열이나 값들을 기존 배열에 합쳐서 새 배열을 반환
filter: 주어진 함수의 테스트를 통과하는 모든 요소를 모아 새로운 배열로 반환
map: 배열 내의 모든 요소 각각에 대하여 주어진 함수를 호출한 결과를 모아 새로운 배열을 반환

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

만약 다음과 같은 객체가 있다고 가정해보자.

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 사용법

npm 이나 yarn 등을 사용하여 immer 를 설치해준다

$ yarn add immer

이후 코드 상단에서 immer 를 불러온다. 보통 produce 라는 이름으로 불러온다.

import produce from 'immer';

produce 함수를 사용할 때 첫 번째 파라미터에는 수정하고 싶은 상태를, 두 번째 파라미터에는 어떻게 업데이트하고 싶은지 정의하는 함수를 넣어준다.

두 번째 파라미터에 넣는 함수에서는 불변성에 대해 신경쓰지 말고 그냥 업데이트를 해주면 다 알아서 해준다!!

const state = {
  number: 1,
  dontChangeMe: 2
};

const nextState = produce(state, draft => {
  draft.number += 1;
});

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

Reducer 에서 Immer 사용하기

Immer 를 사용해서 간단해지는 업데이트가 있고, 오히려 코드가 길어지는 업데이트가 있다. 예를 들어 우리가 만든 프로젝트의 상태의 경우 users 배열이 객체의 깊은 곳에 위치하지 않기 때문에 새 항목을 추가하거나 제거할 때는 Immer 를 사용하는 것보다 concatfilter 를 사용하는게 코드가 더 짧고 편하다.

하지만 사용법을 배워보기 위해 모든 업데이트들을 Immer 를 사용해서 처리해주었다.

App.js

import React, { useRef, useState, useMemo, useCallback, useReducer } from 'react';
import UserList from './Components/UserList';
import CreateUser from './Components/CreateUser';
import produce from 'immer';

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

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

function reducer(state, action){
  switch (action.type){
    case 'CHANGE_INPUT':
      return produce(state, draft => {
        const input = draft.input;
        input[action.name] = action.value;
      })
    case 'CREATE_USER':
      return produce(state, draft => {
        const input = draft.input;

        draft.users.push(action.user);
        input.username = '';
        input.email = '';
      })
    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);
      })
    case 'MODIFY_USER':
      return produce(state, draft => {
        const input = draft.input;
        input.username = action.username;
        input.email = action.email;
        input.id = action.id; 
      })
    case 'UPDATE_USER':
      return produce(state, draft => {
        const user = draft.users.find(user => user.id === draft.input.id);
        const input = draft.input;

        user.username = draft.input.username;
        user.email = draft.input.email;

        input.username = '';
        input.email = '';
      })
      default:
        return state;
  }
}

export const UserDispatch = React.createContext(null);

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

  const { users } = state;
  const { username, email } = state.input;

  const count = useMemo(() => countActiveUsers(users), [users]);

  return (
    <UserDispatch.Provider value={dispatch}>
      <CreateUser username={username} email={email}/>
      <UserList users={users} />
      <div>활성사용자 수 : {count}</div>
    </UserDispatch.Provider>
  );
}

export default App;

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

Immer 와 함수형 업데이트

useState 를 사용할 때 함수형 업데이트를 사용하는 법을 배웠다.

예시

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

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

이렇게 setTodo 함수에 업데이트를 해주는 함수를 넣음으로써, 만약 useCallback 을 사용하는 경우, 두 번째 파라미터인 deps 배열에 todo 를 넣지 않아도 되게 되었다.

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

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

예시

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

const updater = produce(draft => {
  draft.done = !draft.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 를 사용하는것이 좋다. Immer 를 사용한다고 해도, 필요한 곳에서만 사용하고, 간단히 처리될 수 있는 곳에서는 그냥 일반 JavaScript로 구현하는 것이 좋다.

profile
better than yesterday
post-custom-banner

0개의 댓글