불변성(Immutability)

contability·2025년 9월 3일

불변성을 "React의 diffing 알고리즘을 위한 React만의 개념"이라고 잘못 이해하고 있었다. "vanilla JavaScript에서는 불변성을 사용하지 않나요?"라는 질문을 받고 뇌정지가 왔던 경험을 바탕으로, 불변성의 본질적 목적에 대해서 정리해봤다.

불변성(Immutability)이란?

불변성은 데이터가 생성된 후 그 상태를 변경할 수 없다는 개념이다. 즉, 기존 데이터를 직접 수정하지 않고, 변경이 필요할 때는 새로운 데이터를 생성하는 방식이다.

불변성의 목적

  1. 예측 가능한 코드: 데이터가 언제 어떻게 변경되는지 추적하기 쉽다
  2. 사이드 이펙트 방지: 의도하지 않은 데이터 변경을 막는다
  3. 디버깅 용이성: 데이터 변화 흐름을 명확하게 파악할 수 있다
  4. 메모이제이션 최적화: 참조 비교만으로 변경 여부를 확인할 수 있다
  5. 동시성 안정성: 멀티스레드 환경에서 안전하다

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

가변적 방식 (Mutable)

// 배열 직접 변경
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 }

불변적 방식 (Immutable)

// 배열 불변적 변경
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 }
  }
};

JavaScript 불변성 메서드들

// 배열
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에서의 불변성

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에서 불변성이 중요한 이유

  1. 리렌더링 최적화: React.memo, useMemo, useCallback이 참조 비교로 변경을 감지한다
  2. 상태 관리: useState, useReducer가 이전 상태와 새 상태를 비교한다
  3. 개발자 도구: React DevTools에서 상태 변화를 추적할 수 있다
// React.memo 최적화 예시
const TodoItem = React.memo(({ todo, onToggle }) => {
  console.log('TodoItem 렌더링:', todo.text);
  
  return (
    <div onClick={() => onToggle(todo.id)}>
      {todo.text} - {todo.completed ? '완료' : '미완료'}
    </div>
  );
});

// 불변성을 지키면 변경된 항목만 리렌더링된다
// 가변성을 사용하면 모든 항목이 리렌더링된다

정리

  • JavaScript 자체는 가변적이지만, 개발자가 불변성 패턴을 선택할 수 있다
  • React에서는 상태 관리와 최적화를 위해 불변성이 필수적이다
  • 불변성은 특정 라이브러리의 개념이 아니라 프로그래밍 패러다임이다
  • Vanilla JS에서도 불변성을 지키면 더 안전하고 예측 가능한 코드를 작성할 수 있다

정리해놓고 나니 또 궁금해진 부분: 메모리 사용량 문제

불변성을 사용하면 oldValue와 newValue가 모두 메모리에 존재하게 되어 메모리 사용량이 증가한다. 하지만 실제로는 다음과 같은 특성들이 있다:

JavaScript의 가비지 컬렉션

const oldArray = [1, 2];
const newArray = [...old, 3]; // newArray: [1, 2, 3]

// new가 어딘가에서 참조되고 있다면:
// - oldArray 배열 [1, 2]는 더 이상 참조되지 않으므로 가비지 컬렉션 대상
// - newArray 배열 [1, 2, 3]은 참조되고 있으므로 메모리에 유지

구조적 공유 (Structural Sharing)

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

메모리 vs 안정성 트레이드오프

// 메모리 효율적이지만 위험한 방식
function dangerousUpdate(largeObject) {
  largeObject.someProperty = 'new value'; // 원본 변경
  return largeObject;
}

// 메모리를 더 쓰지만 안전한 방식  
function safeUpdate(largeObject) {
  return { ...largeObject, someProperty: 'new value' };
}

실제 대응 방안:

  • Immer 라이브러리: 내부적으로 구조적 공유 최적화
  • 필요한 부분만 불변성 적용: 모든 데이터에 반드시 적용할 필요는 없음
  • 적절한 데이터 구조 선택: 큰 데이터는 다른 패턴 고려

결론: 메모리 사용량 증가는 맞지만, 대부분의 경우 코드 안정성이 더 중요하다.

0개의 댓글