JavaScript의 Mutation vs Non-Mutation 심화

이 문서는 Mutation vs Non-Mutation 개념의 실무 활용 예시를 다룹니다.

Non-Mutation이 중요한 이유

1. 예측 가능성과 안전성

const arr = [1, 2, 3];
const doubled = arr.map(x => x * 2);
console.log(arr); // [1, 2, 3] → 원본 그대로 유지
  • 원본 데이터를 다른 곳에서 재사용할 때 안전
  • 대규모 코드베이스에서 여러 함수가 같은 배열을 공유할 때 중요

2. 디버깅 용이성

  • 데이터가 예상치 못하게 변경되는 버그 방지
  • 부작용(side effects) 감소로 데이터 변경 추적 용이

3. 함수형 프로그래밍과 불변성

  • React, Redux, Zustand 등 모던 JavaScript 패턴의 기반
  • 이전 상태와 새 상태를 직접 비교 가능

4. 시간 여행 디버깅

  • Redux DevTools처럼 “시간을 되돌리는” 기능 구현 가능
  • 모든 상태가 새로운 스냅샷이기 때문에 가능

5. 병렬 및 동시성 안전성

  • 멀티스레드나 비동기 환경에서 안전
  • 한 함수가 데이터를 변경하는 동안 다른 함수가 사용하는 것을 방지

Mutation이 필요한 경우

1. 성능이 중요한 시나리오

// 대용량 데이터 실시간 처리
const arr = new Array(1_000_000).fill(0);
// Mutation 방식 (더 빠름)
for (let i = 0; i < arr.length; i++) {
  arr[i] = i * 2;
}
// map() 사용 시 새 배열 생성으로 메모리 사용량 2배

2. Mutation을 기대하는 데이터 구조

const scores = [50, 90, 70];
scores.sort((a, b) => b - a); // 원본 변경
console.log(scores); // [90, 70, 50]

3. 저수준 제어 (게임, 시뮬레이션, 애니메이션)

// 게임 루프에서 플레이어 위치 업데이트
const players = [{ x: 0, y: 0 }, { x: 5, y: 5 }];
players.forEach(p => {
  p.x += 1;
  p.y += 1;
});

4. 히스토리 상태가 불필요한 경우

let cart = ["apple", "banana"];
cart.push("orange"); // 이전 장바구니 상태 보존 불필요

5. 외부 시스템과의 인터페이스

const button = document.querySelector("button");
button.textContent = "Clicked!"; // DOM은 본질적으로 mutable

예시

데이터셋

const numbers = [1, 2, 3, 4];

1. 요소 추가

Mutation:

numbers.push(5);
console.log(numbers); // [1, 2, 3, 4, 5]
// 원본 배열이 변경됨

Non-Mutation:

const newNumbers = numbers.concat(5);
console.log(newNumbers); // [1, 2, 3, 4, 5]
console.log(numbers);    // [1, 2, 3, 4] (변경되지 않음)

2. 요소 제거

Mutation:

numbers.splice(1, 2);
console.log(numbers); // [1, 4]
// 원본 데이터 손실

Non-Mutation:

const filtered = numbers.filter(n => n !== 2 && n !== 3);
console.log(filtered); // [1, 4]
console.log(numbers);  // [1, 2, 3, 4] (원본 보존)

3. 값 업데이트

Mutation:

for (let i = 0; i < numbers.length; i++) {
  numbers[i] = numbers[i] * 2;
}
console.log(numbers); // [2, 4, 6, 8]

Non-Mutation:

const doubled = numbers.map(n => n * 2);
console.log(doubled); // [2, 4, 6, 8]
console.log(numbers); // [1, 2, 3, 4] (원본 보존)

4. 정렬

Mutation:

numbers.sort((a, b) => b - a);
console.log(numbers); // [4, 3, 2, 1]

Non-Mutation (ES2023):

const sorted = numbers.toSorted((a, b) => b - a);
console.log(sorted);  // [4, 3, 2, 1]
console.log(numbers); // [1, 2, 3, 4] (원본 보존)

사용 시기

Mutation 사용 시기

  • 내부/로컬 로직 (반복문, 정렬, DOM 업데이트)
  • 성능이 중요한 상황
  • 히스토리 관리가 불필요한 경우

Non-Mutation 사용 시기

  • 상태 관리 시스템 (React, Redux, 협업 앱)
  • 여러 함수가 같은 데이터를 공유하는 경우
  • 디버깅과 예측 가능성이 중요한 경우

하이브리드 접근법

// 내부적으로는 mutation 사용, 외부에는 immutable하게 노출
class DataProcessor {
  constructor(data) {
    this._data = [...data]; // 복사본으로 시작
  }
  
  // 내부적으로 mutation 사용 (성능)
  _processInternal() {
    for (let i = 0; i < this._data.length; i++) {
      this._data[i] = this._data[i] * 2;
    }
  }
  
  // 외부에는 immutable하게 노출 (안전성)
  getProcessedData() {
    this._processInternal();
    return [...this._data]; // 복사본 반환
  }
}

실무 개발 관점

1. 팀 협업에서의 고려사항

코드 리뷰 시 체크포인트

// ❌ 리뷰에서 지적받을 코드
function processUserData(users) {
  users.sort((a, b) => a.name.localeCompare(b.name)); // 원본 변경!
  users.forEach(user => {
    user.isActive = checkUserStatus(user.id); // 원본 객체 변경!
  });
  return users;
}

// ✅ 리뷰 통과 코드
function processUserData(users) {
  return users
    .map(user => ({
      ...user,
      isActive: checkUserStatus(user.id)
    }))
    .sort((a, b) => a.name.localeCompare(b.name));
}

버그 추적의 어려움

// 실제 프로덕션에서 발생한 버그 사례
const sharedConfig = { apiUrl: 'https://api.example.com', timeout: 5000 };

function moduleA() {
  sharedConfig.timeout = 10000; // 다른 모듈에 영향!
  return fetchData(sharedConfig);
}

function moduleB() {
  // moduleA 호출 후 timeout이 예상과 다름
  return fetchData(sharedConfig); // timeout: 10000 (예상: 5000)
}

2. 성능 벤치마킹

메모리 사용량 비교

// 성능 테스트 코드
function benchmarkMemoryUsage() {
  const largeArray = new Array(1000000).fill(0).map((_, i) => ({ id: i, value: Math.random() }));
  
  console.time('Mutation');
  const mutationResult = largeArray;
  mutationResult.forEach(item => item.processed = true);
  console.timeEnd('Mutation'); // ~50ms
  
  console.time('Non-Mutation');
  const nonMutationResult = largeArray.map(item => ({ ...item, processed: true }));
  console.timeEnd('Non-Mutation'); // ~200ms
  
  // 메모리 사용량: Non-Mutation이 약 2배
}

실제 프로젝트 성능 임계점

// 경험상 성능 차이가 체감되는 기준점
const PERFORMANCE_THRESHOLD = {
  arraySize: 10000,      // 배열 크기
  objectDepth: 3,        // 객체 중첩 깊이
  updateFrequency: 60    // 초당 업데이트 횟수 (60fps)
};

function shouldUseMutation(data, context) {
  return (
    data.length > PERFORMANCE_THRESHOLD.arraySize ||
    context.isRealTime ||
    context.memoryConstrained
  );
}

3. 프레임워크별 패턴

React에서의 실무 패턴

// ❌ React에서 흔한 실수
function UserList({ users, onUserUpdate }) {
  const handleToggleActive = (userId) => {
    const user = users.find(u => u.id === userId);
    user.isActive = !user.isActive; // 직접 변경 - 리렌더링 안됨!
    onUserUpdate(users);
  };
  
  // ✅ 올바른 React 패턴
  const handleToggleActive = (userId) => {
    const updatedUsers = users.map(user =>
      user.id === userId
        ? { ...user, isActive: !user.isActive }
        : user
    );
    onUserUpdate(updatedUsers);
  };
}

Redux/Zustand 상태 관리

// Redux Toolkit의 Immer 활용
const userSlice = createSlice({
  name: 'users',
  initialState: { list: [] },
  reducers: {
    // Immer 덕분에 mutation 문법 사용 가능
    updateUser: (state, action) => {
      const user = state.list.find(u => u.id === action.payload.id);
      if (user) {
        user.name = action.payload.name; // 실제로는 immutable 업데이트
      }
    }
  }
});

// Zustand의 Immer 통합
const useStore = create(
  immer((set) => ({
    users: [],
    updateUser: (id, updates) => set((state) => {
      const user = state.users.find(u => u.id === id);
      if (user) Object.assign(user, updates);
    })
  }))
);

4. 디버깅 전략

개발 도구 활용

// 개발 환경에서 mutation 감지
if (process.env.NODE_ENV === 'development') {
  const originalPush = Array.prototype.push;
  Array.prototype.push = function(...args) {
    console.warn('Array mutation detected:', this);
    console.trace();
    return originalPush.apply(this, args);
  };
}

// Object.freeze를 활용한 불변성 강제
function deepFreeze(obj) {
  Object.getOwnPropertyNames(obj).forEach(prop => {
    if (obj[prop] !== null && typeof obj[prop] === 'object') {
      deepFreeze(obj[prop]);
    }
  });
  return Object.freeze(obj);
}

// 개발 환경에서만 적용
const safeData = process.env.NODE_ENV === 'development' 
  ? deepFreeze(data) 
  : data;

타입스크립트와의 조합

// readonly를 활용한 컴파일 타임 보호
interface User {
  readonly id: number;
  readonly name: string;
  readonly email: string;
}

type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

// 유틸리티 타입으로 불변성 강제
function processUsers(users: DeepReadonly<User[]>): User[] {
  // users.push() // 컴파일 에러!
  return users.map(user => ({ ...user, processed: true }));
}

5. 성능 최적화

얕은 복사 vs 깊은 복사 전략

// 성능을 고려한 선택적 복사
function updateNestedData(data, path, value) {
  if (path.length === 1) {
    // 얕은 복사로 충분
    return { ...data, [path[0]]: value };
  }
  
  // 깊은 복사가 필요한 경우만 재귀
  const [head, ...tail] = path;
  return {
    ...data,
    [head]: updateNestedData(data[head], tail, value)
  };
}

// 구조적 공유를 활용한 메모리 최적화
const structuralSharing = {
  updateUser(users, userId, updates) {
    return users.map(user => 
      user.id === userId 
        ? { ...user, ...updates }  // 변경된 객체만 새로 생성
        : user                     // 기존 객체 재사용
    );
  }
};

배치 업데이트 패턴

// 여러 변경사항을 한 번에 처리
class DataManager {
  constructor(data) {
    this._data = data;
    this._pendingChanges = [];
  }
  
  // 변경사항 누적
  queueUpdate(id, updates) {
    this._pendingChanges.push({ id, updates });
    return this; // 체이닝 지원
  }
  
  // 한 번에 적용 (성능 최적화)
  commit() {
    if (this._pendingChanges.length === 0) return this._data;
    
    const result = this._data.map(item => {
      const changes = this._pendingChanges.filter(c => c.id === item.id);
      return changes.length > 0
        ? { ...item, ...Object.assign({}, ...changes.map(c => c.updates)) }
        : item;
    });
    
    this._pendingChanges = [];
    return result;
  }
}

6. 라이브러리 생태계

실무에서 자주 사용하는 도구들 - Immer, Ramda, Lodash

// Immer - 가장 인기 있는 불변성 라이브러리
import produce from 'immer';

const nextState = produce(currentState, draft => {
  draft.users.push(newUser);           // mutation 문법
  draft.settings.theme = 'dark';       // 하지만 실제로는 immutable
});

// Ramda - 함수형 프로그래밍 유틸리티
import { assocPath, dissocPath } from 'ramda';

const updated = assocPath(['user', 'profile', 'name'], 'John', state);

// Lodash - 실무에서 가장 많이 사용
import { cloneDeep, merge } from 'lodash';

const deepCopy = cloneDeep(originalData);
const merged = merge({}, defaultConfig, userConfig);

성능 비교 및 선택 기준

// 라이브러리별 성능 특성 (경험적 데이터)
const LIBRARY_PERFORMANCE = {
  native: { speed: 'fastest', bundle: 0, learning: 'easy' },
  immer: { speed: 'good', bundle: '43kb', learning: 'easy' },
  ramda: { speed: 'good', bundle: '173kb', learning: 'hard' },
  lodash: { speed: 'fast', bundle: '69kb', learning: 'medium' }
};

// 프로젝트 규모별 권장사항
function recommendLibrary(projectSize, teamExperience) {
  if (projectSize === 'small' && teamExperience === 'junior') {
    return 'native + simple helpers';
  }
  if (projectSize === 'large' && teamExperience === 'senior') {
    return 'immer + custom utilities';
  }
  return 'lodash for general use';
}

댓글