개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

상태와 불변성 — 예측 가능한 변경을 만드는 방법

2025년 9월 8일
JavaScript상태불변성React깊은 복사

디버깅을 하다 보면 분명히 건드리지 않았다고 생각한 객체가 어느 순간 바뀌어 있는 경우가 있습니다. 콘솔을 찍어가며 흐름을 따라가도 "도대체 어디서 바뀐 건지" 감이 잡히지 않는 그 답답함, 한 번쯤 겪어 보셨을 것입니다.

상태(State)란 무엇인가

상태란 시간이 지남에 따라 변할 수 있는 데이터입니다. 사용자의 입력, 서버에서 받은 응답, 화면에 표시되는 값 등이 모두 상태에 해당합니다.

// 이 값들은 모두 상태입니다
let count = 0;
let user = { name: "홍길동", age: 30 };
let items = ["사과", "바나나"];

상태가 언제, 어디서, 어떻게 바뀌는지를 추적할 수 있어야 버그를 예측하고 고칠 수 있습니다.

불변성(Immutability)이란

불변성이란 값을 직접 수정하지 않고, 변경된 새 값을 만드는 원칙입니다. 기존 데이터는 그대로 두고, 변경 사항이 반영된 새 데이터를 반환합니다.

[기존 상태] ──변경 시도──▶ [새 상태]  (기존 상태는 그대로)

불변성을 지키면 변경이 항상 명시적으로 드러납니다. "이 값이 언제 바뀌었는가"를 코드 흐름만으로 파악할 수 있게 됩니다.

직접 수정의 문제

자바스크립트에서 객체와 배열은 참조(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 연산자

객체를 수정할 때는 기존 객체를 건드리지 않고, 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에서 불변성이 중요한가

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 등을 올바르게 복사합니다.

불변성 원칙 요약

변경 전 값 보존  +  새 값 반환  =  예측 가능한 상태 관리
  • 원시값은 재할당해도 원본이 바뀌지 않으므로 안전합니다.
  • 객체·배열은 spread, map, filter 등으로 항상 새 값을 만들어 반환합니다.
  • 중첩이 깊으면 structuredClone을 사용합니다.
  • React에서 같은 참조를 그대로 넘기면 변경으로 인식되지 않습니다.

불변성은 "변경했다"는 사실을 코드에 명시적으로 드러내는 약속이며, 이 약속 하나가 디버깅 시간을 크게 줄여 줍니다.

← 이전 글에러 처리 — 예외가 발생했을 때 무너지지 않는 코드
다음 글 →기본 타입 — TypeScript가 다루는 값의 종류