immutable data patterns & Referential equality

hong·2026년 5월 21일

javascript

목록 보기
13/13
post-thumbnail

immutable data patterns

immutable data patterns는 데이터를 직접 바꾸지 않고, 바뀐 것처럼 “새 데이터”를 만들어서 쓰는 방식이다.

immutable은 “변하지 않는”이라는 뜻이고, 프론트엔드에서는 보통 상태(state)를 수정할 때 기존 객체/배열을 직접 건드리지 말자는 의미로 많이 쓴다.

예를 들어 이런건 mutable, 직접 변경이다 :

const user = { name: ‘뽀야미’, age: 1 };

user.age = 2;

immutable data pattern 경우 이렇게 쓴다. :

const user = { name: ‘뽀야미’, age: 1 };

const nextUser = {
...user,
age: 2,
};

기존 user는 그대로 두고, age만 바뀐 새 객체를 만든 것이다.


왜 이렇게 하냐면, React 같은 프론트엔드 라이브러리에서는 상태가 바뀌었는지 감지하는 것이 중요하다. 특히 리액트에서는 객체 안의 값이 바뀌었는지 깊게 다 보는게 아니라 대체로 참조가 바뀌었는지를 보고 렌더링 판단을 한다.

const prev = { count: 1 };
const next = prev;

next.count = 2;

console.log(prev === next); // true

이 경우, 값은 바뀌었지만 객체 참조는 그대로여서 프레임워크나 상태 관리 도구가 변경을 못 알아채는 상황이 생길 수 있다.

반면 immutable 방식을 쓴다면 참조가 바뀌어서 상태가 변경되었음을 감지하기 쉬워진다.

const prev = { count: 1 };
const next = { ...prev, count: 2 };

console.log(prev === next); // false

실무에서 자주 쓰는 패턴은 다음과 같다.

const state = {
  user: {
    name: ‘뽀야미’,
    profile: {
      nickname: ‘햄스터’,
    },
  },
};

const nextState = {
  ...state,
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      nickname: ‘공주’,
    },
  },
};

중첩 객체는 바꾸려는 깊이까지 …로 새로 만들어줘야한다. Redux Toolkit 같은 데서는 내부적으로 Immer를 써서 아래와 같이 쓸수도있다 :

state.user.profile.nickname = ‘공주’;

겉보기엔 직접 바꾸는 것 같지만, Immer가 내부에서 안전하게 새 상태를 만들어준다. 그래서 Redux Toolkit에서는 이런 스타일이 허용된다.


Q.

let bool = true;
bool = false; 

도 immutable인가?

A. 맞다. boolean, number, string, null, undefined, symbol, bigint 같은 원시값(primitive) 은 객체처럼 “안쪽 내용을 바꾸는” 개념이 없다.
bool = false는 true라는 값 자체를 고친 게 아니라, bool이라는 변수 이름표가 이제 false 값을 가리키게 바뀐 것에 가깝다.


Q. React는 왜 참조 변경을 중요하게 볼까?
A. React는 상태 변경 여부를 판단할 때 주로 얕은 비교(shallow comparison)를 사용한다.

얕은 비교는 객체 안쪽의 모든 값을 비교하는 방식이 아니라, 최상위 값이나 참조가 바뀌었는지 확인하는 방식이다.

원시값인 boolean, number, string은 값 자체를 비교하면 되지만, 객체나 배열은 내부 내용이 아니라 참조값이 바뀌었는지가 중요하다.

const prev = { count: 1 };
const next = prev;

next.count = 2;

console.log(prev === next); // true

위 코드에서는 count 값은 바뀌었지만 prev와 next가 같은 객체를 가리키고 있으므로 참조는 그대로다. 이런 경우 React가 상태 변경을 제대로 감지하지 못할 수도 있다. 그래서 React에서는 객체나 배열 상태를 수정할 때 기존 데이터를 직접 바꾸기 보다는, 새 객체나 새 배열을 만들어 상태를 교체하는 방식이 권장된다.


TypeScript에서 불변성을 표현하는 방법

TypeScript에서는 readonly 키워드를 사용해서 특정 프로퍼티를 수정할 수 없도록 표현할 수 있다.

interface User {
  readonly name: string;
  readonly age: number;
}

const user: User = {
  name: '뽀야미',
  age: 1,
};

user.age = 2; // 오류

중첩 객체라면 내부 객체에도 readonly 처리를 해줘야 한다.

interface Profile {
  readonly nickname: string;
}

interface User {
  readonly name: string;
  readonly profile: Readonly<Profile>;
}

단, readonly는 TypeScript 컴파일 단계에서 수정 시도를 막아주는 장치다. JavaScript 런타임에서 객체 자체를 완전히 얼리는 것은 아니므로, 런타임 불변성이 필요하다면 Object.freeze 같은 방법을 별도로 고려해야 한다. 중첩 객체까지 막으려면 재귀적으로 freeze하는 deepFreeze 같은걸 써야한다.

Immutable Data Pattern의 장점

Immutable Data Pattern을 사용하면 상태 변경 흐름이 더 예측 가능해진다.

기존 데이터를 직접 바꾸지 않기 때문에, 특정 데이터가 어디서 몰래 바뀌었는지 추적해야 하는 상황이 줄어든다. 또한 이전 상태와 다음 상태를 비교하기 쉬워져서 React의 렌더링 최적화, 디버깅, undo/redo, 상태 변경 추적에도 유리하다.

특히 React.memo, useMemo, useCallback 같은 최적화 도구들은 참조 비교와 관련이 깊기 때문에, immutable update를 잘 지키는 것이 중요하다.

javascript에서 immutable data structure를 다루는 방법

* 객체에서 삭제하는 패턴

const state = {
  theme: 'dark',
  language: 'ko',
};

// theme 제거
const { theme, ...stateWithoutTheme } = state;

console.log(stateWithoutTheme);
// { language: 'ko' }

delete state.theme 처럼 기존 객체를 직접 바꾸는게 아니라, theme을 제외한 새 객체를 만든다.

* 배열에서 추가/삭제/수정 패턴

const items = [1, 2, 3];

// 추가
const added = [...items, 4];

// 삭제
const removed = items.filter(item => item !== 2);

// 수정
const updated = items.map(item =>
  item === 2 ? 20 : item
);

*피해야 하는 것 

items.push(4);
items.splice(1, 1);
items[0] = 100;

Push, slice, 인덱스 직접 대입은 기존 배열을 직접 수정한다.

* Redux reducer 예시

function todoReducer(state = [], action) {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, action.payload];

    case 'DELETE_TODO':
      return state.filter(todo => todo.id !== action.payload.id);

    default:
      return state;
  }
}

핵심은 reducer 안에서 state.push(…)하고 return state 하는게 아니라, 새 배열을 return 한다는 것이다.

얕은 복사의 한계

…spread를 썼다고 항상 안전한건 아니다. spread는 기본적으로 얕은 복사라서, 중첩 객체는 여전히 같은 참조를 공유할 수 있다.

const user = {
  name: '뽀야미',
  address: {
    city: 'Seoul',
  },
};

const updated = { ...user };

updated.address.city = 'Busan';

console.log(user.address.city); // 'Busan'

겉의 user 객체는 복사됐지만, 안쪽 address 객체는 같은 참조라서 원본까지 바뀌어버린다. 깊게 중첩된 구조에서는 단순 spread만으로 충분하지 않고, 바꾸려는 depth까지 복사해야 한다.

올바른 방식 예시 :

const updated = {
  ...user,
  address: {
    ...user.address,
    city: 'Busan',
  },
};

불변성을 도와주는 도구

중첩 객체를 매번 spread로 복사하면 코드가 길어질 수 있다. 이럴 때 Immer를 사용하면 직접 수정하는 것처럼 작성하면서도 내부적으로는 immutable update를 만들 수 있다.

Immutable.js처럼 불변 자료구조 자체를 제공하는 라이브러리도 있지만, 일반 JavaScript 객체와 사용법이 달라질 수 있으므로 프로젝트 상황에 따라 선택해야 한다.

cloneDeep를 쓰는건?

const state = {
  user: {
    name: '뽀야미',
    profile: {
      nickname: '햄스터',
    },
  },
  movies: [
    { id: 1, title: '센과 치히로' },
    { id: 2, title: '하울의 움직이는 성' },
  ],
  theme: {
    mode: 'dark',
  },
};

이런 상태가 있다고 했을 때, 바꾸고 싶은건 아래와 같이 딱 하나라고 해보자.

state.user.profile.nickname

cloneDeep를 쓰면 이런 느낌이다 :

const nextState = cloneDeep(state);

nextState.user.profile.nickname = '공주';

겉보기엔 편해보인다. 일단 전체를 깊은 복사를 해두고, 복사본을 마음대로 수정하면 되기 때문이다. 근데 문제는 movies, theme처럼 전혀 안바뀐 데이터까지 전부 새로 복사한다는 점이다. 영화 목록이 천 개, 만 개가 되면 매번 전체를 깊게 복사하는 것은 비용이 커진다.

그래서 보통은 바뀐 경로만 복사하거나 Immer를 사용하는 방식이 더 실용적이다.

const nextState = {
  ...state,
  user: {
    ...state.user,
    profile: {
      ...state.user.profile,
      nickname: '공주',
    },
  },
};

아래는 Immer를 사용한 경우다.

const nextState = produce(state, draft => {
  draft.user.profile.nickname = '공주';
});

참고

https://hwanheejung.tistory.com/22
https://medium.com/@Adekola_Olawale/handling-immutable-data-structures-in-javascript-for-better-state-management-860282998f7f

Referential equality

Referential equality는 ‘두 값이 같은 대상을 가리키고 있나?’를 보는 것이다.

js에서는 보통 === 비교할 때 이 개념이 중요해진다.

원시값은 값 자체를 비교한다 :

const a = true;
const b = true;

console.log(a === b); // true

const x = 1;
const y = 1;

console.log(x === y); // true

true랑 true, 1이랑 1은 값이 같으니까 true다.

근데 객체/배열/함수는 다르게 봐야한다. 내용이 같아 보여도, 같은 메모리의 같은 객체를 가리키는지 봐야한다.

const user1 = { name: ‘뽀야미’ };
const user2 = { name: '뽀야미' };

console.log(user1 === user2); // false

둘다 { name: ‘뽀야미’ } 처럼 생겼지만, 각각 따로 만든 객체이기 때문에 다른 참조값을 가지고있다.

const user1 = { name: '뽀야미' };
const user2 = user1;

console.log(user1 === user2); // true

반대로 이 경우에는 true이다. user2가 새 객체를 만든 게 아니라, user1이 가리키던 그 객체를 같이 가리키게 된 것이다.
이게 중요한 이유는 immutable data patterns 와 마찬가지로 리액트에서는 상태나 props가 바뀌었는지 볼 때 ‘참조가 바뀌었는지’ 기준으로 빠르게 판단하는 경우가 많기 때문이다.

정리하면, referential equality는 내용이 같은지가 아니라 같은 객체를 가리키는지 비교하는 개념이다.

{} === {} // false
[] === [] // false

const a = {};
const b = a;

a === b // true

확인문제

📙 기억하기 : 객체/배열/함수는 “내용이 같은지”가 아니라 같은 객체를 가리키는지가 중요하다. Spread 얇은 복사는 내가 spread한 그 한 겹만 새로 만들고, 안쪽 객체는 따로 복사하지 않으면 기존 참조를 그대로 공유한다.


Q1.

const a = { name: '뽀야미' };
const b = { name: '뽀야미' };

console.log(a === b);

정답 : false
해설 : a와 b는 내용은 같지만 각각 따로 만든 객체이다. 객체는 === 로 비교할 때 내부 값이 같은지를 비교하지 않고, 같은 객체를 가리키는지 본다.


Q2.

const a = { name: '뽀야미' };
const b = a;

console.log(a === b);

정답 : true
해설 : a가 가리키던 객체를 b도 같이 가리키게 만들었다. 즉, a와 b는 같은 객체를 바라보고 있다.


Q3.

const prev = { count: 1 };

const next = prev;
next.count = 2;

console.log(prev === next);
console.log(prev.count);

정답:

true
2

해설 :

const next = prev;

이 코드는 next가 prev와 같은 객체를 바라보게 했다.

next.count = 2;

그 다음, 같은 객체 안의 count 값을 직접 바꾸었다.

prev === next // true 

둘은 여전히 같은 객체를 보고있으니 true.

prev.count // 2

next.count를 바꿨지만, prev도 같은 객체를 보고 있으니 2.


Q4.

const prev = { count: 1 };

const next = {
  ...prev,
  count: 2,
};

console.log(prev === next);
console.log(prev.count);
console.log(next.count);

정답 :

false
1
2

해설 : 여기서는 spread를 사용해서 새 객체를 만들었다.

prev === next // false

그래서 false. 그리고 기존 객체 prev는 직접 수정하지 않았기 때문에 아래와 같이 된다.

prev.count // 1
next.count // 2

Q5.

const user = {
  name: '뽀야미',
  address: {
    city: 'Seoul',
  },
};

const updated = { ...user };

updated.address.city = 'Busan';

console.log(user.address.city);
console.log(user === updated);
console.log(user.address === updated.address);

정답 :

Busan
false
true

해설 :

const updated = { ...user };

이 코드는 user의 겉 객체만 새로 만든다.
그래서 아래와 같이 된다 :

user === updated // false

하지만 안쪽 객체인 address는 새로 만든 게 아니다.

address: {
  city: 'Seoul'
}

이 객체는 user.address와 updated.address가 같이 공유하고 있다.

그래서 :

user.address === updated.address // true

그런 상태에서

updated.address.city = 'Busan';

이렇게 하면, updated의 address만 바꾸는 것처럼 보이지만, 사실은 둘이 공유하는 같은 address 객체를 바꾸는 것이다.

그래서 원본도 같이 바뀐다.

user.address.city // 'Busan'

Q6.

const user = {
  name: '뽀야미',
  address: {
    city: 'Seoul',
  },
};

const updated = {
  ...user,
  address: {
    ...user.address,
    city: 'Busan',
  },
};

console.log(user.address.city);
console.log(updated.address.city);
console.log(user === updated);
console.log(user.address === updated.address);

정답 :

Seoul
Busan
false
false

해설 :

이번에는 바꾸려는 깊이까지 복사했다.

const updated = {
  ...user,
  address: {
    ...user.address,
    city: 'Busan',
  },
};

여기서 새로 만든 객체는 두 개다.

updated // 새 객체
updated.address // 새 객체

그래서 :

user === updated // false
user.address === updated.address // false

그리고 address도 새로 만들었기 때문에, updated.address.city를 Busan으로 바꿔도 원본 user.address.city는 그대로다.

user.address.city // 'Seoul'
updated.address.city // 'Busan'

*중첩 객체는 바꾸려는 depth까지 …로 새로 만들어줘야한다.


Q7.

아래 코드는 React 상태 업데이트로 좋은 방식일까, 안 좋은 방식일까? 이유도 간단히 말해보세요.

user.age = 2;
setUser(user);

정답 : 안좋은 방식. 기존 객체를 직접 수정한 뒤 같은 참조를 다시 넣는 방식이라 React가 상태 변경을 감지하기 어려울 수 있다. 객체 상태는 새 객체를 만들어 교체하는 방식이 좋다.


Q8.
아래 코드는 왜 React에서 더 권장될까?

setUser({
  ...user,
  age: 2,
});

A. 기존 객체를 직접 바꾸기 때문에
B. 새 객체를 만들어 참조가 바뀌기 때문에
C. 깊은 복사를 하기 때문에

정답 : B. 새 객체를 만들어 참조가 바뀌기 때문에


Q9.

const prevState = {
  user: {
    name: '뽀야미',
    profile: {
      nickname: '햄스터',
    },
  },
  theme: {
    mode: 'dark',
  },
};

const nextState = {
  ...prevState,
  user: {
    ...prevState.user,
    profile: {
      ...prevState.user.profile,
      nickname: '공주',
    },
  },
};

console.log(prevState === nextState);
console.log(prevState.user === nextState.user);
console.log(prevState.user.profile === nextState.user.profile);
console.log(prevState.theme === nextState.theme);

정답 :

false
false
false
true

해설 :

여기서 만든 것은 총 3개다. :

nextState           // 새 객체
nextState.user      // 새 객체
nextState.user.profile // 새 객체

하지만 theme은 건드린 적 없기 때문에, 기존 참조를 그대로 공유한다.

즉,

바꾼 경로: state → user → profile
안 바꾼 theme : 이건 그대로 재사용했으니까 true.

profile
프론트엔드 개발을 하고 있습니다 ⌨️

0개의 댓글