디버깅을 하다 보면 분명히 건드리지 않았다고 생각한 객체가 어느 순간 바뀌어 있는 경우가 있습니다. 콘솔을 찍어가며 흐름을 따라가도 "도대체 어디서 바뀐 건지" 감이 잡히지 않는 그 답답함, 한 번쯤 겪어 보셨을 것입니다.
상태란 시간이 지남에 따라 변할 수 있는 데이터입니다. 사용자의 입력, 서버에서 받은 응답, 화면에 표시되는 값 등이 모두 상태에 해당합니다.
// 이 값들은 모두 상태입니다
let count = 0;
let user = { name: "홍길동", age: 30 };
let items = ["사과", "바나나"];
상태가 언제, 어디서, 어떻게 바뀌는지를 추적할 수 있어야 버그를 예측하고 고칠 수 있습니다.
불변성이란 값을 직접 수정하지 않고, 변경된 새 값을 만드는 원칙입니다. 기존 데이터는 그대로 두고, 변경 사항이 반영된 새 데이터를 반환합니다.
[기존 상태] ──변경 시도──▶ [새 상태] (기존 상태는 그대로)
불변성을 지키면 변경이 항상 명시적으로 드러납니다. "이 값이 언제 바뀌었는가"를 코드 흐름만으로 파악할 수 있게 됩니다.
자바스크립트에서 객체와 배열은 참조(reference)로 전달됩니다. 같은 객체를 여러 변수가 참조하고 있을 때, 한 곳에서 직접 수정하면 다른 곳에서도 바뀝니다.
const a = { name: "홍길동" };
const b = a; // 같은 객체를 참조
b.name = "김철수"; // b를 바꿨지만
console.log(a.name); // "김철수" ← a도 바뀌었다!
함수에 객체를 넘겨 내부에서 수정해도 마찬가지입니다.
function 이름변경(user) {
user.name = "이영희"; // 원본을 직접 수정
}
const user = { name: "홍길동" };
이름변경(user);
console.log(user.name); // "이영희" ← 함수 밖에서도 바뀜
이런 부수 효과(side effect)가 쌓이면 버그의 원인을 찾기가 매우 어려워집니다.
number, string, boolean 같은 원시값은 변수에 재할당해도 원래 값이 바뀌지 않습니다. 새로운 값이 생성될 뿐입니다.
let a = 10;
let b = a;
b = 20;
console.log(a); // 10 ← 바뀌지 않음
console.log(b); // 20
원시값은 기본적으로 불변이라 걱정할 필요가 없습니다. 문제는 객체와 배열입니다.
객체를 수정할 때는 기존 객체를 건드리지 않고, spread 연산자(...)로 새 객체를 만듭니다.
const user = { name: "홍길동", age: 30 };
// 직접 수정 (나쁜 예)
user.age = 31;
// 불변성 유지 (좋은 예)
const updatedUser = { ...user, age: 31 };
console.log(user); // { name: "홍길동", age: 30 } ← 원본 유지
console.log(updatedUser); // { name: "홍길동", age: 31 } ← 새 객체
배열을 수정하는 메서드 중 push, pop, splice는 원본을 직접 바꿉니다. 불변성을 지키려면 새 배열을 반환하는 메서드를 사용해야 합니다.
const items = ["사과", "바나나", "포도"];
// 추가 — push 대신 concat 또는 spread
const added = [...items, "딸기"];
// 제거 — splice 대신 filter
const removed = items.filter(item => item !== "바나나");
// 수정 — 직접 수정 대신 map
const updated = items.map(item => item === "포도" ? "망고" : item);
console.log(items); // ["사과", "바나나", "포도"] ← 원본 유지
console.log(added); // ["사과", "바나나", "포도", "딸기"]
console.log(removed); // ["사과", "포도"]
console.log(updated); // ["사과", "바나나", "망고"]
spread 연산자는 얕은 복사(shallow copy)입니다. 중첩된 객체까지는 새로 만들지 않습니다.
const state = {
user: { name: "홍길동", address: { city: "서울" } },
};
const next = { ...state };
next.user.address.city = "부산"; // 중첩 객체는 여전히 같은 참조
console.log(state.user.address.city); // "부산" ← 원본도 바뀜!
중첩 구조에서 불변성을 지키려면 바뀌는 경로의 모든 레벨을 새로 만들어야 합니다.
const next = {
...state,
user: {
...state.user,
address: {
...state.user.address,
city: "부산",
},
},
};
깊이가 깊어질수록 코드가 장황해집니다. 이 경우 structuredClone이나 Immer 같은 라이브러리를 고려할 수 있습니다.
React는 상태가 바뀌었는지를 Object.is로 이전 값과 현재 값을 비교해서 판단합니다. 객체를 직접 수정하면 참조가 같으므로 React는 변경이 없다고 판단하고 리렌더링을 건너뜁니다.
리렌더링(Re-render): React가 컴포넌트 함수를 다시 실행해 화면을 새로 그리는 과정입니다.
// 잘못된 예 — 리렌더링이 일어나지 않음
const [user, setUser] = useState({ name: "홍길동" });
// 직접 수정 후 같은 참조를 전달
user.name = "김철수";
setUser(user); // Object.is(이전, 현재) === true → 리렌더링 없음
// 올바른 예 — 새 객체를 전달해야 리렌더링 발생
setUser({ ...user, name: "김철수" }); // 새 참조 → 리렌더링 발생
참조 동등성(Reference Equality): 두 변수가 메모리의 같은 객체를 가리키는지 비교하는 것. 값이 같아도 다른 객체이면 동등하지 않습니다.
| 방식 | 깊은 복사 | 함수/순환 참조 지원 | 성능 | 사용 편의성 |
|---|---|---|---|---|
{ ...obj } spread | 아니오 (얕은 복사) | 지원 | 빠름 | 간단 |
JSON.parse(JSON.stringify(obj)) | 예 | 미지원 | 중간 | 보통 |
structuredClone(obj) | 예 | 부분 지원 | 중간 | 간단 |
const original = { a: 1, b: { c: 2 } };
// spread — 얕은 복사
const shallow = { ...original };
// JSON 방식 — 깊은 복사, 단 함수·Date·undefined 손실
const jsonDeep = JSON.parse(JSON.stringify(original));
// structuredClone — 깊은 복사, 함수 제외 대부분 지원
const deep = structuredClone(original);
structuredClone: 브라우저와 Node.js 18+에서 기본 제공하는 깊은 복사 함수. Date, Map, Set 등을 올바르게 복사합니다.
변경 전 값 보존 + 새 값 반환 = 예측 가능한 상태 관리
structuredClone을 사용합니다.불변성은 "변경했다"는 사실을 코드에 명시적으로 드러내는 약속이며, 이 약속 하나가 디버깅 시간을 크게 줄여 줍니다.