#상태 업데이트 동작에 대한 의문
React에 대한 정보를 찾아볼 때, 우리는 배열이나 객체 state를 업데이트하는 아래와 같은 유형의 비슷한 예제들을 많이 봤을 것이다.
// React에서 자주 보는 익숙한 코드 패턴
const [items, setItems] = useState(["item1", "item2"]);
const [user, setUser] = useState({ name: "jack", age: 30 });
// 배열에 아이템 추가할 때
setItems([...items, "item3"]);
// 객체 속성 업데이트할 때
setUser({ ...user, age: 29 });
마치 공식처럼 스프레드 연산자(...)를 사용해 새로운 배열이나 객체를 만들어 setState에 전달하는 것이다. 대부분의 개발자들은 이 패턴을 자연스럽게 받아들이고 사용한다.
하지만 '왜?'라는 질문을 던져본 적 있는가? 왜 원본 배열에 그냥 push()하면 안 되는지, 왜 객체의 값을 직접 바꾸면 안 되는지에 대한 근본적인 의문 말이다.
만약 이런 의문을 한 번이라도 가져봤거나, 혹은 그 이유를 모른 채 패턴만 사용하고 있었다면 이 글이 도움이 될 것이다. 이 글에서는 바로 그 '왜'에 집중하여, React의 얕은 비교와 불변성이라는 두 가지 중요한 개념을 통해 그 의문을 해소해보자.
#1. 얕은 비교(Shallow Comparison)란 무엇인가?
정의: 얕은 비교(Shallow Comparison)란, 두 데이터의 내부 값 전체를 비교하는 것이 아니라, 두 값이 동일한 메모리 주소를 가리키고 있는지, 즉 참조값의 동일성만 확인하는 비교 방식이다. React에서는 Object.is를 통해 이 비교를 수행한다.
React는 컴포넌트의 state가 변경되었는지 판단할 때, 바로 이 얕은 비교를 사용한다.
#동작 방식을 시각적으로 이해하기
[원본 배열] → push() → [같은 참조값] → React: "변화 없음" → 리렌더링 X
[원본 배열] → [...] → [새 참조값] → React: "변화 감지!" → 리렌더링 O
#2. 왜 React는 깊은 비교 대신 얕은 비교를 선택했나?
#성능
- 얕은 비교: 참조값만 확인하므로 데이터 크기와 무관하게 연산 속도가 매우 빠르다.
- 깊은 비교: 객체나 배열의 모든 요소를 재귀적으로 확인해야 하므로 데이터가 복잡할수록 연산 비용이 크게 증가한다.
다양한 환경에서 React 애플리케이션에서는 수많은 상태 업데이트가 발생할 수 있다. 매번 깊은 비교를 한다면, 사용자는 버벅거리는 UI를 경험하게 될 것이다.
#예측 가능성: 개발자가 제어할 수 있는 명확한 규칙
얕은 비교는 단순하고 명확한 규칙을 제공한다.
- 새로운 참조 발생 = 리렌더링
- 같은 참조 유지 = 리렌더링 하지 않음
이 규칙만 알면 렌더링 동작을 완벽하게 예측하고 제어할 수 있다. 반면 깊은 비교는 복잡한 데이터 구조에서 예상치 못한 동작을 일으킬 수 있다.
#순환 참조 문제 회피
깊은 비교의 또 다른 문제는 순환 참조다
const obj1 = { name: "parent" };
const obj2 = { name: "child", parent: obj1 };
obj1.child = obj2; // 순환 참조 발생
// 깊은 비교 시도 시 무한 루프에 빠짐
// 얕은 비교는 이런 문제가 없음
#불변성과의 시너지
이러한 얕은 비교 방식은, 데이터가 변경될 때마다 새로운 복사본을 만드는 불변성 원칙과 결합되었을 때 가장 효율적으로 변화를 감지할 수 있다. 불변성을 통해 참조값이 달라진 데이터는 얕은 비교만으로도 충분히 그 변화를 감지할 수 있게 되는 것이다.
#React 생태계 전체의 일관성
React 생태계 전체가 얕은 비교를 기반으로 동작한다. 대표적인 예로 React.memo는 props에 대한 얕은 비교를 통해 컴포넌트의 불필요한 리렌더링을 방지하는 최적화 도구다. 이처럼 얕은 비교는 React의 상태 관리뿐만 아니라 렌더링 최적화의 핵심 원리이기도 하다.
#트레이드오프: 개발자에게 책임 전가
물론 얕은 비교에도 단점이 있다. 개발자가 불변성을 지켜야 한다는 책임이 생긴다. 하지만 React 팀은 이것이 충분히 가치 있는 트레이드오프라고 판단했다.
React가 얻는 것: 빠른 성능, 단순한 구현 개발자가 해야 하는 것: 불변성 유지 모두가 얻는 것: 예측 가능하고 빠른 UI
사용자 인터페이스(UI)의 빠른 반응성을 유지해야 하는 React 입장에서, 얕은 비교는 성능과 단순성을 모두 잡을 수 있는 최적의 선택이었다.
#3. 불변성(Immutability): 얕은 비교를 위한 원칙
정의: 불변성(Immutability)이란, 데이터가 한번 생성된 후에는 상태를 바꿀 수 없는 데이터의 특성을 의미한다. 데이터를 수정해야 할 경우, 원본을 직접 바꾸는 대신 변경 사항을 적용한 새로운 복사본을 만드는 방식을 말한다.
React가 얕은 비교로 변화를 감지하려면, 반드시 이 '불변성' 원칙을 따라야 한다. 원본 데이터를 직접 수정하면 참조값이 바뀌지 않아 React가 변화를 인지하지 못하기 때문이다.
// 잘못된 방식: 원본 배열을 직접 수정 (Mutation)
const handleAddTodoWrong = () => {
todos.push("불변성 공부하기"); // 참조값이 변하지 않는다.
setTodos(todos);
};
// 올바른 방식: 함수형 업데이트로 새로운 배열을 생성 (Immutability)
const handleAddTodoRight = () => {
setTodos((prevTodos) => [...prevTodos, "불변성 공부하기"]); // 새 참조값이 생성된다.
};
#4. 불변성을 지키는 다양한 방법들
스프레드 연산자가 가장 널리 사용되지만, 이외에도 다양한 방법이 있다.
#배열을 불변하게 다루는 방법
const [items, setItems] = useState(["item1", "item2", "item3"]);
// 1. 스프레드 연산자 - 가장 직관적
const addItem = (newItem) => {
setItems((prevItems) => [...prevItems, newItem]);
};
// 2. concat() - 새 배열 반환
const addItemWithConcat = (newItem) => {
setItems((prevItems) => prevItems.concat(newItem));
};
// 3. slice() + push() 조합
const addItemWithSlice = (newItem) => {
setItems((prevItems) => {
const newItems = prevItems.slice(); // 전체 복사
newItems.push(newItem);
return newItems;
});
};
// 4. Array.from() - 복사 후 수정
const removeItem = (index) => {
setItems((prevItems) => {
const newItems = Array.from(prevItems);
newItems.splice(index, 1);
return newItems;
});
};
// 5. filter() - 조건에 맞는 요소만 남기기
const removeItemById = (id) => {
setItems((prevItems) => prevItems.filter((item) => item.id !== id));
};
// 6. map() - 요소 변환
const updateItem = (index, newValue) => {
setItems((prevItems) =>
prevItems.map((item, i) => (i === index ? newValue : item))
);
};
// 7. reduce() - 복잡한 변환
const groupItems = () => {
setItems((prevItems) =>
prevItems.reduce((acc, item) => {
// 복잡한 로직 처리
return [...acc, processItem(item)];
}, [])
);
};
#객체를 불변하게 다루는 방법
const [user, setUser] = useState({
name: "jack",
age: 30,
skills: ["React"],
});
// 1. 스프레드 연산자 - 단순하고 명확
const updateAge = (newAge) => {
setUser((prevUser) => ({ ...prevUser, age: newAge }));
};
// 2. Object.assign()
const updateName = (newName) => {
setUser((prevUser) => Object.assign({}, prevUser, { name: newName }));
};
// 3. 구조 분해 할당과 조합
const updateMultipleFields = ({ name, age }) => {
setUser((prevUser) => ({
...prevUser,
...(name && { name }),
...(age && { age }),
}));
};
// 4. JSON 방식 (깊은 복사가 필요할 때, 단 함수나 undefined는 복사 안됨)
const deepCloneUpdate = (updates) => {
setUser((prevUser) => {
const cloned = JSON.parse(JSON.stringify(prevUser));
Object.assign(cloned, updates);
return cloned;
});
};
// 주의: 이 방식은 내부적으로 문자열 변환과 파싱을 거치므로 성능적으로 비용이 가장 크다.
// 함수나 undefined 등 JSON으로 표현할 수 없는 속성은 유실되므로, 정말 깊은 복사가 필요한 제한적인 경우에만 사용하는 것이 좋다.
#5. 간단한 문제 상황과 해결 과정
간단하게 아래의 버그 상황을 살펴보자. Todo 리스트에서 특정 항목의 완료 상태를 토글하려고 할 때 발생하는 문제다.
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "React 공부", done: false },
{ id: 2, text: "불변성 이해하기", done: false },
]);
// 문제가 있는 코드: 화면이 업데이트되지 않는다
const toggleTodoWrong = (id) => {
const todo = todos.find((t) => t.id === id);
todo.done = !todo.done; // 객체를 직접 수정
setTodos(todos); // 같은 참조값을 전달
};
// 해결 방법 1: map을 활용한 새 배열 생성
const toggleTodoCorrect = (id) => {
setTodos((prevTodos) =>
prevTodos.map(
(todo) =>
todo.id === id
? { ...todo, done: !todo.done } // 해당 객체만 새로 생성
: todo // 나머지 객체는 그대로 유지
)
);
};
// 해결 방법 2: 인덱스를 활용한 방법
const toggleTodoWithIndex = (id) => {
setTodos((prevTodos) => {
const index = prevTodos.findIndex((t) => t.id === id);
return [
...prevTodos.slice(0, index),
{ ...prevTodos[index], done: !prevTodos[index].done }, // 해당 객체만 새로 생성
...prevTodos.slice(index + 1),
];
});
};
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} onClick={() => toggleTodoCorrect(todo.id)}>
{todo.text} - {todo.done ? "완료" : "미완료"}
</li>
))}
</ul>
);
}
위 예제에서 toggleTodoWrong은 객체를 직접 수정하기 때문에 React가 변화를 감지하지 못한다. 반면 toggleTodoCorrect는 새로운 배열과 객체를 생성하여 참조값을 바꿔주므로 정상적으로 리렌더링이 발생한다.
#6. 중첩된 객체 상태 관리
실제 프로젝트에서는 더 복잡한 중첩 구조를 다루게 된다. 이런 경우 불변성을 유지하는 방법을 알아보자.
const [user, setUser] = useState({
name: "jack",
profile: {
age: 30,
skills: ["React", "TypeScript"],
},
});
// 잘못된 방식: 중첩된 객체를 직접 수정
const addSkillWrong = (skill) => {
user.profile.skills.push(skill);
setUser(user);
};
// 올바른 방식: 모든 계층에서 새 객체 생성
const addSkillCorrect = (skill) => {
setUser((prevUser) => ({
...prevUser,
profile: {
...prevUser.profile,
skills: [...prevUser.profile.skills, skill],
},
}));
};
// Immer 라이브러리를 사용한 간편한 방법
import { produce } from "immer";
const addSkillWithImmer = (skill) => {
setUser((prevUser) =>
produce(prevUser, (draft) => {
// 직접 수정하는 것처럼 보이지만 Immer가 불변성 처리
draft.profile.skills.push(skill);
})
);
};
#마무리
React의 상태 관리와 렌더링은 얕은 비교와 불변성이라는 두 가지 개념을 기반으로 동작한다. 이 둘의 관계를 한 문장으로 정리하면 다음과 같다.
"React는 성능을 위해 얕은 비교를 하며, 이 비교가 올바르게 동작하도록 불변성 원칙에 따라 새로운 참조값을 전달해야 한다."
이 관계를 이해하고 코드에 적용하는 것만으로도 예측하기 어려운 렌더링 관련 버그를 상당수 줄일 수 있으며, React의 동작 방식을 더 깊이 있게 활용하는 개발자가 될 수 있다.