불변성 패턴 — Object.freeze, Immer, Immutable.js 비교
불변성(Immutability)은 상태 관리의 핵심 원칙입니다. React의 상태 업데이트, Redux의 리듀서가 모두 불변성을 기반으로 동작합니다. 왜 불변성이 중요하고, 어떻게 유지하는지 정리해보겠습니다.
왜 불변성이 중요한가?
// 가변 상태의 문제
const state = { count: 0, items: ["a"] };
const copy = state; // 참조 복사 — 같은 객체!
copy.count = 1;
console.log(state.count); // 1 — 원본도 변경됨!
// React에서의 문제
const [user, setUser] = useState({ name: "정훈", age: 25 });
user.age = 26; // 직접 변경하면 리렌더링 안 됨!
setUser({ ...user, age: 26 }); // 새 객체를 만들어야 리렌더링됨
불변성을 지키면:
- 변경 감지가 쉬움 (
===로 비교 가능) - 예측 가능한 상태 관리
- 디버깅 용이 (시간 여행 디버깅)
- 동시성 문제 방지
방법 1: 스프레드 연산자
// 객체 업데이트
const user = { name: "정훈", age: 25, address: { city: "서울" } };
const updated = { ...user, age: 26 };
// 중첩 객체 업데이트 — 깊이가 깊어지면 장황해짐
const deepUpdated = {
...user,
address: { ...user.address, city: "부산" },
};
// 배열 업데이트
const items = [1, 2, 3];
const added = [...items, 4]; // 추가
const removed = items.filter((_, i) => i !== 1); // 삭제 (인덱스 1)
const replaced = items.map((item, i) => // 수정
i === 1 ? 999 : item
);
스프레드의 한계
// 깊이 3단계 업데이트 — 끔찍한 가독성
const state = {
user: {
profile: {
address: { city: "서울", zip: "06000" },
},
},
};
const newState = {
...state,
user: {
...state.user,
profile: {
...state.user.profile,
address: {
...state.user.profile.address,
city: "부산",
},
},
},
};
방법 2: Object.freeze
const config = Object.freeze({
apiUrl: "https://api.example.com",
timeout: 5000,
});
config.apiUrl = "changed"; // 무시됨 (strict mode에서 TypeError)
console.log(config.apiUrl); // "https://api.example.com"
// 얕은 동결 — 중첩 객체는 변경 가능
const shallow = Object.freeze({
nested: { value: 1 },
});
shallow.nested.value = 999; // 변경됨!
// 깊은 동결
function deepFreeze(obj) {
Object.freeze(obj);
Object.values(obj).forEach((val) => {
if (typeof val === "object" && val !== null) {
deepFreeze(val);
}
});
return obj;
}
방법 3: Immer — 가장 실용적
Immer는 "가변적으로 작성하면 불변 업데이트를 자동 생성"해주는 라이브러리입니다.
import { produce } from "immer";
const state = {
user: {
profile: {
address: { city: "서울", zip: "06000" },
},
},
items: [1, 2, 3],
};
// produce 안에서는 직접 수정해도 됨!
const newState = produce(state, (draft) => {
draft.user.profile.address.city = "부산";
draft.items.push(4);
});
console.log(state.user.profile.address.city); // "서울" — 원본 유지
console.log(newState.user.profile.address.city); // "부산"
console.log(state === newState); // false — 새 객체
console.log(state.items === newState.items); // false
React에서 Immer 사용
import { useImmer } from "use-immer";
function TodoApp() {
const [todos, updateTodos] = useImmer([
{ id: 1, text: "공부", done: false },
]);
const toggleTodo = (id) => {
updateTodos((draft) => {
const todo = draft.find((t) => t.id === id);
todo.done = !todo.done; // 직접 수정 가능!
});
};
const addTodo = (text) => {
updateTodos((draft) => {
draft.push({ id: Date.now(), text, done: false });
});
};
}
비교
| 기능 | 스프레드 | Object.freeze | Immer |
|---|---|---|---|
| 깊은 업데이트 | 장황함 | 방지만 함 | 간결함 |
| 런타임 보호 | X | O (쓰기 방지) | X |
| 번들 크기 | 0 | 0 | ~5KB |
| 학습 곡선 | 낮음 | 낮음 | 낮음 |
| 성능 | 중첩 시 비효율 | 초기 동결 비용 | 구조적 공유로 효율 |
| TypeScript | 기본 지원 | 기본 지원 | 우수한 지원 |
structuredClone과의 차이
// structuredClone은 깊은 복사 → 전체 트리를 복사
const clone = structuredClone(hugeState);
clone.deeply.nested.value = "changed";
// 전체 객체가 새로 만들어짐 — 비효율적
// Immer는 구조적 공유 — 변경된 부분만 새로 만듦
const newState = produce(hugeState, (draft) => {
draft.deeply.nested.value = "changed";
});
// 변경되지 않은 부분은 원래 객체 참조를 유지
**기억하기 **: 간단한 업데이트는 스프레드로 충분하고, 깊은 중첩 구조는 Immer가 최선입니다. Object.freeze는 "읽기 전용 상수"에 적합합니다. React 상태 관리에서 불변성은 리렌더링의 핵심이므로, 직접 수정(mutation)하지 않는 습관이 중요합니다.
댓글 로딩 중...