리액트의 상태가 불변성을 유지하는 이유는 무엇일까요?
immer 같은 라이브러리를 이용해 대신 불변성을 유지할 수 있도록 위임하는 일도 가끔 있습니다. 오히려 리덕스 툴킷에는 내장되어 있는 기능이기도 하니 불변성을 유지하다보니 코드가 어지러워진다고 생각하면 도입을 적극 고려해야 한다고 생각합니다.'유지하는 이유'라는 말이, 마치 리액트에서 자동으로 상태의 불변성을 유지한다는 뜻으로 읽히기도 해서 찾아보았다.
리액트의 상태가 불변성을 유지하는 것은 리액트가 자동으로 불변성을 강제하는 것이 아니라, 개발자가 불변성을 유지하도록 코드를 작성해야 하기 때문이다.
리액트의 업데이트 메커니즘과 최적화를 위해 불변성을 지키는 것을 권장.
1. 리렌더링 최적화
리액트는 상태나 프롭이 변경되었을 때 컴포넌트를 리렌더링한다. 이를 위해 상태가 이전 값과 새 값을 비교하는데, 리액트는 객체를 얕은 비교(shallow comparison)한다.
2. 얕은 비교의 효율성
객체가 불변성을 유지하면, 객체의 참조(메모리 주소)만 비교해도 변경 여부를 알 수 있다.
3. 불변성 유지 실패의 경우
불변성을 유지하지 못하면, 객체 내부를 깊이 비교해야 하거나, 변경 사항을 정확히 파악하지 못할 위험이 있다.
4. 디버깅과 추적의 용이성
불변성을 유지하면 상태의 변경 기록을 쉽게 추적할 수 있다. 이를 통해 디버깅이 용이해지고, 상태 관리 라이브러리(예: Redux)에서 타임 트래블 디버깅 같은 기능을 구현 가능하다.
상태를 직접 변경하면 새로운 객체가 생성되지 않고 기존 상태가 덮어써지게 된다.
예를 들어, 다음과 같은 코드는 잘못된 상태 변경이다.
🖥️ jsx
const [state, setState] = useState({ count: 0 });
function increment() {
state.count += 1; // 기존 객체를 직접 변경
setState(state); // 상태 변경이 제대로 감지되지 않을 수 있음
}
위 코드와 달리, 아래처럼 새로운 객체를 생성해야 한다.
🖥️ jsx
function increment() {
setState(prevState => ({ ...prevState, count: prevState.count + 1 }));
}
immer는 불변성을 유지하면서 더 간결한 코드를 작성할 수 있도록 돕는 라이브러리. 내부적으로는 프록시(proxy)를 사용해 상태를 임시로 "가변적으로" 다룬 뒤, 최종적으로 새로운 불변 객체를 생성한다. ❓프록시(proxy)?
🖥️ javascript
import produce from "immer";
const [state, setState] = useState({ count: 0 });
function increment() {
setState(
produce(state, draft => {
draft.count += 1; // 마치 가변적인 것처럼 작성
})
);
}
➡️ 리액트는 자동으로 상태의 불변성을 유지하지 않고,
개발자가 상태를 변경할 때 불변성을 지켜야 하며, 이를 통해 리액트의 최적화 메커니즘과 디버깅 도구를 최대한 활용할 수 있다.
불변성을 유지에 immer 같은 라이브러리가 유용하다.
리액트는 기본적으로 얕은 비교를 기반으로 설계되어 있다. 이는 성능 최적화와 상태 관리의 효율성을 위해 선택된 방식이다.
1. React.memo
React.memo는 컴포넌트의 props를 얕은 비교하여 props가 변경되지 않았다면 컴포넌트를 리렌더링하지 않는다.
🖥️ javascript
import React, { memo } from "react";
const MyComponent = ({ value }) => {
console.log("Rendered");
return <div>{value}</div>;
};
export default memo(MyComponent);
memo는 props의 참조만 비교한다. 즉, 새로운 객체나 배열이 전달되면 내부 값이 같더라도 참조가 달라졌기 때문에 리렌더링한다.
2. PureComponent
클래스형 컴포넌트에서 React.PureComponent는 shouldComponentUpdate 메서드를 기본적으로 구현하여 props와 state를 얕은 비교로 최적화한다.
🖥️ javascript
import React, { PureComponent } from "react";
class MyComponent extends PureComponent {
render() {
console.log("Rendered");
return <div>{this.props.value}</div>;
}
}
PureComponent는 부모 컴포넌트에서 동일한 객체를 참조하는 props를 전달받았을 때 리렌더링을 방지한다.
3. useState와 useEffect
상태가 변경될 때 리렌더링 여부를 판단하거나, useEffect의 의존성 배열에서 값을 비교할 때도 얕은 비교가 사용된다.
🖥️ javascript
const [state, setState] = useState({ count: 0 });
useEffect(() => {
console.log("Effect runs");
}, [state]); // 얕은 비교로 상태 변경 감지
위 코드에서 state 객체의 속성이 변경되었더라도 새로운 객체를 생성하지 않으면 useEffect는 실행되지 않는다.
1. 성능 최적화
2. 불변성 유지와 시너지
➡️ 리액트는 기본적으로 얕은 비교를 사용하며, 이는 컴포넌트 리렌더링과 상태 변경 감지를 효율적으로 처리하기 위한 설계다. 다만, 개발자가 불변성을 지키지 않으면 얕은 비교의 효과가 떨어지므로, 불변성을 유지하는 방식으로 상태를 관리하는 것이 중요하다.
얕은 비교는 두 객체나 배열이 동일한 참조(메모리 주소)를 가지고 있는지만 비교하는 방법. 객체나 배열 내부의 속성 값까지는 확인하지 않는다.
🖥️ javascript
const obj1 = { a: 1 };
const obj2 = { a: 1 };
const obj3 = obj1;
console.log(obj1 === obj2); // false (참조가 다름)
console.log(obj1 === obj3); // true (참조가 같음)
obj1과 obj2는 내용이 같더라도 참조가 다르므로 얕은 비교 결과 false.
obj1과 obj3은 같은 참조를 가지므로 얕은 비교 결과는 true.
프록시는 자바스크립트 객체를 감싸는 래퍼(wrapper)로,
객체에 대한 접근이나 조작을 가로채 특정 동작을 수행할 수 있게 해주는 기능이다.
ES6에 도입된 Proxy 객체를 사용한다.
🖥️ javascript
const target = { a: 1, b: 2 };
const handler = {
get: (obj, prop) => {
console.log(`Property ${prop} was accessed.`);
return obj[prop];
},
set: (obj, prop, value) => {
console.log(`Property ${prop} was set to ${value}.`);
obj[prop] = value;
return true;
},
};
const proxy = new Proxy(target, handler);
console.log(proxy.a); // "Property a was accessed." -> 1
proxy.b = 3; // "Property b was set to 3."
console.log(target.b); // 3
immer는 프록시를 사용하여 상태 객체를 임시로 "가변적"으로 다룰 수 있게 만든다. 개발자가 객체를 직접 수정하는 것처럼 작성해도, 내부적으로는 새로운 불변 객체를 생성한다.
🖥️ javascript
import produce from "immer";
const state = { count: 0 };
const nextState = produce(state, draft => {
draft.count += 1; // 가변 객체처럼 작성
});
console.log(state); // { count: 0 }
console.log(nextState); // { count: 1 }
draft는 프록시 객체이다.
produce는 프록시를 통해 상태 변경을 기록하고, 최종적으로 새로운 객체를 생성한다.
➡️ 얕은 비교 : 객체의 참조만 비교하는 간단한 방식으로, 리액트에서 효율적인 리렌더링을 지원.
➡️ 프록시 : 객체에 대한 동작을 가로채는 기능으로, immer 같은 라이브러리에서 불변성을 유지하면서 직관적인 코드 작성을 돕는 데 사용된다.