불변성 유지와 immer

nasagong·2023년 2월 23일
0

React

목록 보기
12/15
post-thumbnail

📚 들어가며

불변성 유지의 중요성과 immer를 사용해 간단하게 불변성을 유지하는 방법을 알아보자

불변성의 중요성

상태관리에 있어서 불변성은 중요한 개념이다. 참조타입 변수들(ex. 배열,객체)에 push등을 사용해 통째로 상태를 근본적으로 변경해버리면 이전 상태를 참조할 수 없게 된다. 즉, 상태가 바뀌어도 바뀐 걸 최적화 과정에서 인지할 수 없다. 그래서 보통 spread syntax나 concat등을 사용해 간접적으로 값만 가져오는 방법을 사용한다. 예시를 좀 보자.

const array = [1,2,3,4,5];
const nextArrayBad = array;
nextArrayBad[0] = 100;
console.log(array===nextArrayBad);

위 코드를 콘솔에 찍어보면 true가 나온다. 배열이나 객체는 reference type이기 때문에 이 다른 변수에 할당해주면 값의 복사가 아닌 그냥 한 객체에 두 변수가 연동돼버린다.

const nextArrayGood = [...array];
nextArrayGood[0] = 100;
console.log(nextArrayGood===array);

이번엔 전개구문을 사용해 값을 복사해줬다. 값만 가져왔기에 둘은 아예 다른 배열이다. 그렇기에 콘솔엔 false가 찍힌다.

const obj = {
   foo : 'bar',
   value : 1
};
const newObj = {
    ...obj,
    value: 2
}
console.log(obj===newObj) // false

객체도 똑같다.

얕은 복사

const todos = [{ id:1, checked:true}, {id:2, checked:true}];
const nextTodos = [...todos];

nextTodos[0].checked = false;
console.log(todos[0] === nextTodos[0]);
// true

코드를 읽어보자. 분명 전개구문으로 배열을 복사해 왔는데 왜 콘솔은 여전히 두 배열이 같다고 말하는 걸까?

전개 연산자를 사용해 객체나 배열의 값을 복사할 때 내부의 값은 복사되지 않는다. 요컨대 nextTodos의 배열 내부값인 두 객체는 여전히 todos와 연결돼 있는 것이다. 그렇기에 nextTodos의 값을 수정한 것 같지만 실제로는 todos의 내부값을 수정한 것이다. 내부값도 확실히 복사하고 싶다면 아래처럼 작성해야 한다.

nextTodos[0] = {
   ...nextTodos[0],
   chekced:false
};
console.log(todos[0]===nextTodos[0]) // false

객체 안에 있는 객체를 수정하고 싶다면 아래처럼 해준다.

const nextComplexObject = {
   ...complexObject,
   objectInside: {
       ...complexObject.objectInside,
       enable : false
  }
);
console.log(complexObject === nextComplexObject);
// false
console.log(complexObject.objectInside === 
            nextComplexObject.objectInside);
// false

다소 복잡하다.. 하지만 매번 이렇게 할 필요는 없다. immer를 비롯해 불변성 유지를 도와주는 라이브러리들이 있기 때문이다.

immer 사용해 불변성 유지하기

포스팅 하는 걸 구경하던 독일어 전공자 동기가 immer는 독어로 ‘항상’ 이란 뜻이라고 설명해줬다..

import {useRef,useCallback,useState} from 'react';

const App = () =>{
  const nextId = useRef(1);
  const [form,setForm] = useState({name:'',username:''});
  const [data,setData] = useState({
    array : [],
    uselessValue: null
  });
  const onChange = useCallback(
    e =>{
      const {name,value} = e.target;
      setForm({
        ...form,
        [name]:[value]
      });
    },
    [form]
  );
  const onSubmit = useCallback(
    e=>{
      e.preventDefault();
      const info={
        id:nextId.current,
        name:form.name,
        username:form.username
      };
      setData({
        ...data,
        array:data.array.concat(info)
      });
      setForm({
        name:'',
        username:''
      });
      nextId.current+=1;
    },
    [data, form.name ,form.username]
  );
  const onRemove = useCallback(
    id=>{
      setData({
        ...data,
        array: data.array.filter(info=>info.id!==id)
      });
    },
    [data]
  );
  return(
    <div>
      <form onSubmit={onSubmit}>
        <input
        name="username"
        placeholder="아이디"
        value={form.username}
        onChange={onChange}
        />
        <input
        name="name"
        placeholder="이름"
        value={form.name}
        onChange={onChange}
        />
        <button type="submit">등록</button>
      </form>
      <div>
        <ul>
          {data.array.map(info=>(
            <li key={info.id} onClick={()=>onRemove(info.id)}>
              {info.username} ({info.name})
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
};
export default App;

아이디와 이름을 입력하면 아래에 보여주는 페이지다. 코드 중간중간에 부변성 유지르 위한 노력들이 보이는데, 이 작업을 immer로 해보자.

바뀐 부분만 확인해보겠다.

const onChange = useCallback(
    e =>{
      const {name,value} = e.target;
      setForm(
        produce(form,draft=>{
          draft[name] = value;
        })
      );
    },
    [form]
  )
const onSubmit = useCallback(
    e=>{
      e.preventDefault();
      const info={
        id:nextId.current,
        name:form.name,
        username:form.username
      };
      setData(
        produce(data,draft=>{
          draft.array.push(info);
        })
      );
      setForm({
        name:'',
        username:''
      });
      nextId.current+=1;
    },
    [data, form.name ,form.username]
  );
const onRemove = useCallback(
    id=>{
      setData(
        produce(data,draft=>{
          draft.array.splice(draft.array.findIndex(info=>info.id===id),1);
        })
      );
    },
    [data]
  );

상태 설정 함수들에 전부 immer의 produce함수를 사용해줬다. produce함수의 첫 번째 파라미터는 수정하고 싶은 값, 두 번째는 상태를 업데이트 할지 정의하는 함수다.

const onChange = useCallback(
    e =>{
      const {name,value} = e.target;
      setForm(
        produce(form,draft=>{
          draft[name] = value;
        })
      );
    },
    [form]
  )

setForm내부를 잘 보자. produce함수를 통해 form을 수정하고 있으며, 새로 반환할 상태(객체)의 내부값을 선언해주고 있다. 덮어씌워주는 방식을 사용하지 않아도 produce함수가 알아서 불변성을 유지한 채로 업데이트 된 새로운 상태를 반환해준다. 그 반환값이 setForm에 들어가 form의 상태를 업데이트한다.

immer의 핵심은 불변성은 신경 쓰지 않으며 코딩해도 불변성 관리는 제대로 이루어진다는 것이다.

다만 위 코드에서 onRemove함수는 splice를 사용해 특정값을 잘라내고 있는데, 사실 filter먹인 배열을 한번 덮어씌워주는 편이 더 간단하다.

항상 immer를 사용할 필요는 없고 필요에 따라, 복잡해지면 사용하면 된다.

useState의 함수형 업데이트 + immer

컴포넌트 최적화에서 배운 useState의 함수형 업데이트를 immer와 함께 사용할 수 있다. 예시를 먼저 보자.

const update = produce(draft=>{
  draft.value=2;
});
const originalState = {
  value:1,
  foo:'bar',
};
const nextState = update(originalState);
console.log(nextState);
// {value:2, foo:'bar'}

수정하고 싶은 state를 파라미터로 넣는 대신 바로 draft함수를 넘겨줬다. 이런 경우 produce는 함수를 반환한다. 반환한 함수는 update에 할당돼 nextState의 상태를 수정하는 데 사용됐다.

상태 업데이트 함수 내부에 명시적인 상태 대신 업데이트가 되는 과정이 담긴 함수를 넣어줬던 것처럼, produce를 그대로 넣어줘도 된다. 다 보기엔 너무 많으니 함수 하나만 바꿔보자.

const onChange = useCallback(
    e =>{
      const {name,value} = e.target;
      setForm(
        produce(draft=>{
          draft[name] = value;
        })
      );
    },
    [form]
  )

그냥 state값만 지워주면 된다. 이렇게 쓰는 편이 더 보기 좋다.

📝 마치며

불변성 유지, 얕은 복사.. 굵직한 개념들이 나왔다.
언젠가 더 깊은 이해가 필요해지면 구글링해보자

profile
잘쫌해

0개의 댓글