리액트에서 참조타입 상태(이하 객체 상태)를 다루다 보면 객체 속 값이 변경되었음에도 리렌더링이 이루어지지 않은 모습을 종종 볼 수 있다. 이 글에서는 이런 상황이 발생하는 이유와 객체 상태를 올바르게 다루는 법 그리고 그 방법 중 하나인 useImmer
와 immer
를 파헤처보도록 하겠다
상태는 변하지 않는 다이아몬드와 같아야 한다.
결론부터 말하자면 리액트는 상태 객체 자체의 메모리 주소를 기억하고 있기 때문에 객체 내부에 있는 값이 아무리 바뀌어도 변경을 인지하지 못한다. 그렇기에 객체 상태를 업데이트하기 위해서는 객체의 속성을 변경하는 것이 아닌 아예 새로운 객체를 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
}
위 코드를 해석해보면
다시 말해서 객체의 값을 변경하는 것은 currentState를 반환한다. 그리고 그 이후의 과정은 진행되지 않는다.
그렇기에 객체 상태를 다룰때는 메모리 주소가 다른 객체를 이용해야한다.
👀 setState 함수는 왜 연속해서 실행해도 마지막 함수만 적용될까? 실제 useState 함수는 state의 변경 여부를 한번만 실행하지 않는다. 이게 무슨 말인가 하면 setState 실행시 setState의 인자는 state queue에 푸쉬되며 리액트는 이를 일괄적으로 처리한다.
그렇기에 setState를 하나의 함수 블록에서 여러번 실행해도 마지막 인자가 새로운 상태로 할당되는 것이다.
상태가 object일때와 array일때 다루는 법이 조금씩 다른데 이는 새로운 배열을 반환하는 array 메서드가 있기 때문이다. 이번 주제는 실질적인 해결책을 제시함으로 코드들이 많이 포함되어 있다.
아래와 같은 코드에서 x만 변경하고 싶다면 어떻게 해야할까? 그 방법을 한번 알아보자
const [position, setPosition] = useState({
x: 0,
y: 0,
});
원본 객체가 아닌 완전히 새로운 객체를 만들 수 있는 대표적인 방법에는 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",
}
});
다음과 같은 상태가 있다고 가정해보자.
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는 Immer 라이브러리를 기반으로 만들어진 상태 관리 라이브러리로 다음과 같은 특징을 가진다.
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는 Immer 라이브러리를 사용해서 만들어졌고 Immer 라이브러리는 자바스크립트의 Proxy를 기반으로 작성됐다. 즉, Proxy의 이해가 선행돼야 Immer를 알 수 있다. 차근차근 하나씩 보자.
👀 Immer는 독일 말로 “항상, 영원히”이라는 뜻이다. 이는 Immutability(불변성)을 내포하고 지키자는 의미로서 지어졌다.
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에서 정의한 방식대로 작업을 실행했다.
지금은 뭐가 뭔지 이해가 되지 않겠지만 여기서 알아야할 것은 두 가지다.
Proxy는 생성자 함수로 총 두 개의 인자를 받는다.
첫번째 인자: 복사할 객체(원본)
두번째 인자: 어떤 명령을 가로채고 어떻게 재정의할지 명시하는 객체(handler)
첫번째 인자는 명시적이니 생략하고 두번째 인자를 한번 보자.
handler 객체는 어떤 상황에서 어떤 명령을 수행할 것인지 명시적으로 함수 프로퍼티를 선언해줘야한다. 이게 무슨 말이냐 하면 명령에 따라 해당 함수의 이름이 정해져 있다. (다 알아볼 필요는 없고 두 개만 살펴보자)
proxy.lastName // 이 문 자체가 handler의 get 함수를 실행시킨다.
const handler : {
get: function(target, prop, receiver) {
return target[prop]
}
}
proxy.lastName = choi // 이 문 자체가 handler의 set 함수를 실행시킨다.
const handler = {
set: function (obj, prop, value) {
obj[prop] = value;
console.log(`${value}가 지정됨!`);
}
}
이외에도 여러 함수가 있으니 MDN을 한번 살펴보면 좋다.
useImmer는 Immer를 리액트 훅으로 변경시킨 버전으로 Immer를 이해하는 것이 우선이라고 생각한다. Immer는 사용하기 편리할 뿐만 아니라 메모리 공간 사용 측면에서도 최적화되어 있다. 이는 deep copy를 통해서 객체의 속성을 새로 생성하는 것이 아닌 변경된 값에 한해서만 참조 메모리 주소만 변경하기 때문이다. 즉, 기존 객체에서 사용하는 메모리를 재사용하기 때문에 새로운 객체를 빠르고 메모리 효율적으로 생성한다.
그럼 이제 Immer가 어떤 방식으로 작동하는지 자세히 알아보자.
라이브러리 혹은 함수를 이해함에 있어 가장 좋은 방법 중 하나는 실제로 구현해보는 것이라고 생각한다. 이 글에서도 Immer를 간소화된 코드로 구현해보고자 한다. 구현한 코드는 3 단계로 나눌 수 있다.
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이 실행되면 다음과 같은 작업이 수행된다.
set이 실행되면 다음과 같은 작업이 수행된다.
const baseState = {
user: {
name: "John",
address: {
city: "New York",
zip: "10001",
},
},
};
const draft = CreateProxy(baseState);
baseState.user.name = Minsug
baseState.user.name = Minsug이라는 코드를 실행시켰다고 가정해보자.
정리하자면
여기서 중요한 점은 원본 객체는 영향을 전혀 받지 않는다는 점이다.
👀 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를 사용해서 새로운 객체를 만든다. 흐름은 다음과 같다.
이 과정은 전체 코드 예시로 더 자세히 알아보자
구현한 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 코드가 실행됐을 때 어떤 일이 일어나는지 차근 차근 뜯어보자.
draft.user.name = "Jane"
이 실행된다.draft.user
는 객체이기에 get이 실행되어 draft.user.name = "Jane"
은 set을 호출하고 changes에 얕은 복사된 객체의 name을 "Jane"으로 변경 한 후에 저장한다.draft.user.address.city = "Seoul"
이 실행된다.draft.user
가 프록시 객체이고 draft.user.address
가 객체이기에 get이 실행되어 draft.user.address.city = "Seoul"
는 set을 호출하고 changes에 얕은 복사된 객체의 city를 “Seoul”로 변경한 후에 저장한다.여기까지가 프록시 명령 작성 및 프록시 객체 생성에 해당된다. 이제 finalize(실행순서 3)가 실행될 차례이다.
finalize의 실행은 이렇게 정리할 수 있다.
최대한 읽기 쉽게 쓴다고 썼지만 여간 읽기 쉽지 않을 것 같다고 생각한다. 그래서 마지막으로 Immer의 작동 원리를 요약 및 정리해보고자 한다.
useState의 상태 객체를 다루는 법이 Immer의 구현까지 와버렸는데 이번 기회에 Proxy와 Immer의 동작 원리를 파볼 수 있어서 좋은 시간이었던 것 같다. 이와 별개로 중첩 객체를 다룸에 있어서 항상 Immer를 사용하는 것이 옳지는 않다. 앞서 소개한 다양한 방법들도 존재하지만 중첩 객체를 만드는 상황을 피하거나 정규화하는 편이 상황에 더 알맞을 수 있다.
https://github.com/immerjs/immer
https://react.vlpt.us/basic/23-immer.html