
불변성을 "React의 diffing 알고리즘을 위한 React만의 개념"이라고 잘못 이해하고 있었다. "vanilla JavaScript에서는 불변성을 사용하지 않나요?"라는 질문을 받고 뇌정지가 왔던 경험을 바탕으로, 불변성의 본질적 목적에 대해서 정리해봤다.
불변성은 데이터가 생성된 후 그 상태를 변경할 수 없다는 개념이다. 즉, 기존 데이터를 직접 수정하지 않고, 변경이 필요할 때는 새로운 데이터를 생성하는 방식이다.
JavaScript에서 불변성을 지키면 예상치 못한 사이드 이펙트를 방지할 수 있다. 여러 함수가 같은 객체를 참조할 때, 한 함수에서 원본을 변경하면 다른 곳에서 예상과 다른 결과가 나올 수 있기 때문이다.
// 문제가 되는 상황
function processUser(user) {
user.age += 1; // 원본 객체 직접 수정
user.lastLoginDate = new Date();
return user;
}
function sendEmail(user) {
console.log(`이메일 발송: ${user.name}, 나이: ${user.age}`);
}
const originalUser = { name: 'Kim', age: 25 };
const processedUser = processUser(originalUser);
sendEmail(originalUser); // 어? 나이가 26으로 바뀌어 있다!
// 불변성으로 해결
function safeProcessUser(user) {
return { ...user, age: user.age + 1, lastLoginDate: new Date() };
}
const safeProcessedUser = safeProcessUser(originalUser);
sendEmail(originalUser); // 원본 그대로! 나이는 25
// 배열 직접 변경
const numbers = [1, 2, 3];
numbers.push(4); // 원본 배열이 변경됨
console.log(numbers); // [1, 2, 3, 4]
// 객체 직접 변경
const user = { name: 'Kim', age: 25 };
user.age = 26; // 원본 객체가 변경됨
console.log(user); // { name: 'Kim', age: 26 }
// 배열 불변적 변경
const numbers = [1, 2, 3];
const newNumbers = [...numbers, 4]; // 새로운 배열 생성
console.log(numbers); // [1, 2, 3] (원본 유지)
console.log(newNumbers); // [1, 2, 3, 4]
// 객체 불변적 변경
const user = { name: 'Kim', age: 25 };
const updatedUser = { ...user, age: 26 }; // 새로운 객체 생성
console.log(user); // { name: 'Kim', age: 25 } (원본 유지)
console.log(updatedUser); // { name: 'Kim', age: 26 }
// 중첩 객체 불변적 변경
const state = {
user: { name: 'Kim', profile: { age: 25 } },
todos: [{ id: 1, text: '할일1' }]
};
const newState = {
...state,
user: {
...state.user,
profile: { ...state.user.profile, age: 26 }
}
};
// 배열
const arr = [1, 2, 3];
const newArr1 = arr.concat(4); // [1, 2, 3, 4]
const newArr2 = arr.slice(0, 2); // [1, 2]
const newArr3 = arr.map(x => x * 2); // [2, 4, 6]
const newArr4 = arr.filter(x => x > 1); // [2, 3]
// 문자열 (기본적으로 불변)
const str = 'hello';
const newStr = str.toUpperCase(); // 'HELLO'
console.log(str); // 'hello' (원본 유지)
React에서 불변성이 특히 중요한 이유는 상태 변경 감지 메커니즘 때문이다.
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: '할일1', completed: false }
]);
const toggleTodo = (id) => {
// 잘못된 방식: 기존 배열/객체 직접 수정
const todo = todos.find(t => t.id === id);
todo.completed = !todo.completed; // 원본 객체 변경
setTodos(todos); // React가 변경을 감지하지 못함
};
const addTodo = (text) => {
// 잘못된 방식: 기존 배열 직접 변경
todos.push({ id: Date.now(), text, completed: false });
setTodos(todos); // React가 변경을 감지하지 못함
};
return (
<div>
{todos.map(todo => (
<div key={todo.id}>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}>
{todo.text}
</span>
<button onClick={() => toggleTodo(todo.id)}>
완료 토글
</button>
</div>
))}
</div>
);
}
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: '할일1', completed: false }
]);
const toggleTodo = (id) => {
// 올바른 방식: 새로운 배열과 객체 생성
setTodos(todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
));
};
const addTodo = (text) => {
// 올바른 방식: 새로운 배열 생성
setTodos([
...todos,
{ id: Date.now(), text, completed: false }
]);
};
const removeTodo = (id) => {
// 올바른 방식: 필터링으로 새로운 배열 생성
setTodos(todos.filter(todo => todo.id !== id));
};
return (
<div>
{todos.map(todo => (
<div key={todo.id}>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}>
{todo.text}
</span>
<button onClick={() => toggleTodo(todo.id)}>
완료 토글
</button>
<button onClick={() => removeTodo(todo.id)}>
삭제
</button>
</div>
))}
</div>
);
}
// React.memo 최적화 예시
const TodoItem = React.memo(({ todo, onToggle }) => {
console.log('TodoItem 렌더링:', todo.text);
return (
<div onClick={() => onToggle(todo.id)}>
{todo.text} - {todo.completed ? '완료' : '미완료'}
</div>
);
});
// 불변성을 지키면 변경된 항목만 리렌더링된다
// 가변성을 사용하면 모든 항목이 리렌더링된다
불변성을 사용하면 oldValue와 newValue가 모두 메모리에 존재하게 되어 메모리 사용량이 증가한다. 하지만 실제로는 다음과 같은 특성들이 있다:
const oldArray = [1, 2];
const newArray = [...old, 3]; // newArray: [1, 2, 3]
// new가 어딘가에서 참조되고 있다면:
// - oldArray 배열 [1, 2]는 더 이상 참조되지 않으므로 가비지 컬렉션 대상
// - newArray 배열 [1, 2, 3]은 참조되고 있으므로 메모리에 유지
const bigObject = {
name: 'Kim',
hobbies: ['독서', '영화', '게임'], // 큰 배열
settings: { theme: 'dark', lang: 'ko' }
};
// 스프레드 연산자는 얕은 복사
const updated = {
...bigObject,
name: 'Lee' // name만 새로 만들고 나머지는 참조 공유
};
// hobbies 배열과 settings 객체는 메모리를 공유한다
console.log(bigObject.hobbies === updated.hobbies); // true
console.log(bigObject.settings === updated.settings); // true
// 메모리 효율적이지만 위험한 방식
function dangerousUpdate(largeObject) {
largeObject.someProperty = 'new value'; // 원본 변경
return largeObject;
}
// 메모리를 더 쓰지만 안전한 방식
function safeUpdate(largeObject) {
return { ...largeObject, someProperty: 'new value' };
}
실제 대응 방안:
결론: 메모리 사용량 증가는 맞지만, 대부분의 경우 코드 안정성이 더 중요하다.