지나가던 개발(zigae)

프론트엔드 클린코드 - 객체 변이(mutation) 지양

2022년 3월 6일 • ☕️☕️ 12 min read

clean code

새로운 변수를 생성하거나 기존 변수를 재할당하지 않고 JavaScript의 객체 또는 배열을 변경할 때 예상치 못한 mutation(변이) 일어난다.

const puppy = {
  name: 'Dessi',
  age: 9
};
puppy.age = 10;

현재 원본 puppy 객체의 age 속성을 변경하여 객체를 변화 시키고 있다. 이러한 mutation은 문제가 발생할 수 있다. 다음을 살펴보자.

function printSortedArray(array) {
  array.sort();
  for (const item of array) {
    console.log(item);
  }
}

여기서 문제는 sort() 메서드가 printSortedArray 함수에 전달된 배열을 변경한다는 것이다.

mutation이 가지는 문제:

  • mutation은 디버그하기 어려운 코드를 만든다. 데이터가 값을 보존하지 않고, 어디서 변경 되었는지 알기 어렵다.
  • mutation은 코드를 이해하기 어렵게 만든다. 언제든지 배열이나 객체가 다른 값을 가질 수 있기 때문에 가독성이 떨어진다.
  • 함수 인자의 mutation는 함수의 동작을 예측할 수 없게 만든다.

immutable한 데이터 구조를 가지기 위해선 배열이나 객체를 새로 만들어야 한다. 그러나 Javascriptimmutable을 기본적으로 지원하지 않으며 완벽하지 않다. 그러나 코드에서 mutation이 생기는 것을 피하는 것만으로도 충분히 나아 질 수 있다.

또한 JavaScriptconst는 **mutation(이하 변이)**이 아니라 재할당만을 방지 한다는 점을 명심하자. 필자는 이미 변수 재할당에 관련된 글을 작성했다.

Mutation 연산 회피

객체를 업데이트할 때 일어나는 변이의 일반적인 예시코드이다.

function parseExample(content, lang, modifiers) {
  const example = {
    content,
    lang
  };

  if (modifiers) {
    if (hasStringModifiers(modifiers)) {
      example.settings = modifiers
        .split(' ')
        .reduce((obj, modifier) => {
          obj[modifier] = true;
          return obj;
        }, {});
    } else {
      try {
        example.settings = JSON.parse(modifiers);
      } catch (err) {
        return {
          error: `modifiers를 parse 할 수 없습니다.`
        };
      }
    }
  }

  return example;
}

3개의(content, lang, settings) 프로퍼티가 있는 example 객체를 생성하는 코드이다.(settings는 optional) settings가 존재하는 경우 초기화 된 객체를 변경한다.

필자는 객체의 shape을 알기 위해 전체 코드를 읽을 필요 없이 shape을 한 곳에서 볼 수 있는 것을 선호한다. 이는 대부분의 개발자도 마찬가지일 것이다.

또한 에러 속성으로 완전히 다른 객체를 리턴하는 케이스도 존재한다. 그러나 두 객체의 프로퍼티를 겹치지 않고 병합하는 것은 아주 드문 경우이다. 또 일반적으로 optional한 프로퍼티의 값이 undefined 인 것은 크게 중요하지 않다.

간단한 경우에는 삼항연산자를 사용하거나, 조금 복잡한 조건에는 함수로 코드를 분리하는 방법도 있지만 여기서는 try/catch 구문을 이용한 리팩토링을 소개한다.

function getSettings(modifiers) {
  if (!modifiers) {
    return undefined;
  }

  if (hasStringModifiers(modifiers)) {
    return modifiers.split(' ').reduce((obj, modifier) => {
      obj[modifier] = true;
      return obj;
    }, {});
  }

  return JSON.parse(modifiers);
}

function parseExample(content, lang, modifiers) {
  try {
    return {
      content,
      lang,
      settings: getSettings(modifiers)
    };
  } catch (err) {
    return {
      error: `modifiers를 parse할 수 없습니다.`
    };
  }
}

우리는 코드가 수행하는 작업을 더 쉽게 파악할 수 있게 되었고, 객체의 shape도 명확해졌다. 또한 변이 가능성을 제거하고 하나의 중첩을 줄였다.

Mutating array methods 주의

JavaScript의 모든 메소드가 새로운 레퍼런스의 배열이나 객체를 반환하는 것은 아니다. 일부 메소드는 기존 레퍼런스를 변경한다. 예로 Array.prototype.push가 많이 사용 되는 메소드 중 하나이다.

반복문과 조건문으로 가득한 명령형 코드를 선언형 코드로 바꾸는 것은 좋은 리팩터링 중 하나이다. 그리고 필자가 코드 리뷰에서 가이드하는 실제 사례를 가져왔다.

다음 코드를 살펴보자.

const generateOptionalRows = () => {
  const rows = [];

  if (product1.colors.length + product2.colors.length > 0) {
    rows.push({
      row: 'Colors',
      product1: <ProductOptions options={product1.colors} />,
      product2: <ProductOptions options={product2.colors} />
    });
  }

  if (product1.sizes.length + product2.sizes.length > 0) {
    rows.push({
      row: 'Sizes',
      product1: <ProductOptions options={product1.sizes} />,
      product2: <ProductOptions options={product2.sizes} />
    });
  }

  return rows;
};

const rows = [
  {
    row: 'Name',
    product1: <Text>{product1.name}</Text>,
    product2: <Text>{product2.name}</Text>
  },
  // More 
  ...generateOptionalRows()
];

rows 테이블을 정의 하는 두가지 방법이 있다. 하나는 항상 존재하는 행이 있는 일반 배열과 선택적으로 행을 반환하는 함수이다. 후자는 push() 메소드를 사용하여 참조 된 배열의 원본을 변경한다.

이 코드에선 변이가 일어나는 배열 자체는 중요한 문제가 아니다. 그러나 변이가 존재하는 코드는 다른 문제를 숨길 가능성이 높다. 여기서 주요 문제는 명령형 배열 작성과 require 및 optional한 행을 처리하는 방법이 다양하다는 것이다. 명령형 코드를 선언형 코드로 대체하고 조건을 제거하면 코드의 가독성을 높히고 유지보수하기 쉽게 만들 수 있다.

가능한 모든 행을 단일 배열으로 합쳐보자.

const rows = [
  {
    row: 'Name',
    product1: <Text>{product1.name}</Text>,
    product2: <Text>{product2.name}</Text>
  },
  // More rows...
  {
    row: 'Colors',
    product1: <ProductOptions options={product1.colors} />,
    product2: <ProductOptions options={product2.colors} />,
    isVisible: (product1, product2) =>
      (product1.colors.length > 0 || product2.colors.length) > 0
  },
  {
    row: 'Sizes',
    product1: <ProductOptions options={product1.sizes} />,
    product2: <ProductOptions options={product2.sizes} />,
    isVisible: (product1, product2) =>
      (product1.sizes.length > 0 || product2.sizes.length) > 0
  }
];

const visibleRows = rows.filter(row => {
  if (typeof row.isVisible === 'function') {
    return row.isVisible(product1, product2);
  }
  return true;
});

이제 단일 배열에서 모든 행을 정의하고 있다. isVisible에서 false 반환하는 함수가 없는 한 모든 행은 기본적으로 표시된다. 선언적인 코드로 변경함으로써 코드 가독성과 유지보수에 용이하도록 개선했다.

종종 볼 수 있는 또 다른 예를 보자.

const defaults = { ...options };
const prompts = [];
const parameters = Object.entries(task.parameters);

for (const [name, prompt] of parameters) {
  const hasInitial = typeof prompt.initial !== 'undefined';
  const hasDefault = typeof defaults[name] !== 'undefined';

  if (hasInitial && !hasDefault) {
    defaults[name] = prompt.initial;
  }

  prompts.push({ ...prompt, name, initial: defaults[name] });
}

새 객체를 prompts 배열에 push 하고, 객체를 배열으로 변환한다. 언뜻 보기에 이 코드는 괜찮아 보인다. 하지만 더 자세히 살펴보면 defaults 객체를 변형시키는 조건 내부에 mutable이 존재한다. 그리고 이것은 독자로 하여금 놓치기 쉽기 때문에 더 큰 문제이다.

코드는 실제로 task.parameters 객체를 prompts 배열로 변환 및 task.parameters의 값으로 기본값을 업데이트 총 두 가지 반복문이 동작한다.

const parameters = Object.entries(task.parameters);

const defaults = parameters.reduce(
  (acc, [name, prompt]) => ({
    ...acc,
    [name]:
      prompt.initial !== undefined ? prompt.initial : options[name]
  }),
  {}
);

const prompts = parameters.map(([name, prompt]) => ({
  ...prompt,
  name,
  initial: defaults[name]
}));

코드를 선언형으로 리팩토링 하였다.

배열의 변이를 발생 시키는 메소드는 주의하여 사용하자

함수의 인수 mutation 회피

함수에 인수로 전달 되는 객체는 내부에서 변이될 수 있으며, 이는 원본 객체에 영향을 준다.

const mutate = object => {
  object.secret = 'Loves pizza';
};

const person = { name: 'Sam Noah' };
mutate(person);
// -> { name: 'Sam Noah', secret: 'Loves pizza' }

person객체는 mutate 함수 내에서 변이가 발생한다.

함수의 인수의 변이는 의도적이거나 실수일 수 있지만 둘 다 문제가 된다.

  • 함수가 리턴을 이용하지 않고 들어오는 인수를 변이 되면 함수의 작동 방식과 사용법이 어려워진다.
  • 의도치 않은 인수의 변이는 개발자가 의도하지 않은 방식대로 동작하기 때문에 더욱 심각하다. 그리고 함수 내부에서 변경된 값이 다른 곳에서 사용될 때 찾기 힘든 버그로 나타난다.

예:

const addIfGreaterThanZero = (list, count, message) => {
  if (count > 0) {
    list.push({
      id: message,
      count
    });
  }
};

const getMessageProps = (
  adults,
  children,
  youths,
  seniors
) => {
  const messageProps = [];
  addIfGreaterThanZero(messageProps, adults, 'ADULTS');
  addIfGreaterThanZero(messageProps, children, 'CHILDREN');
  addIfGreaterThanZero(messageProps, youths, 'YOUTHS');
  addIfGreaterThanZero(messageProps, seniors, 'SENIORS');
  return messageProps;
};
/*
[
  {
    id: 'ADULTS',
    count: 7
  },
  {
    id: 'SENIORS',
    count: 2
  },
  ...,
  ...,
];
*/

여러 개의 숫자 변수를 서로 다른 연령대의 사람들을 개수에 따라 그룹화하는 messageProps 배열로 변환하는 코드이다.

이 코드의 문제는 addIfGreaterThanZero함수로 전달 되는 배열을 변이 시키고 있다는 것이다. 이것은 의도적인 변이이기 때문에 의도한대로 동작하려면 변이가 필수적이다. 그러나 이 함수는 가장 적합한 API는 아니다.

addIfGreaterThanZero 함수가 새로운 배열을 리턴하도록 리팩토링할 수 있다.

const addIfGreaterThanZero = (list, count, message) => {
  if (count > 0) {
    return [
      ...list,
      {
        id: message,
        count
      }
    ];
  }
  return list;
};

하지만 필자는 이 함수가 필요하지 않다고 생각한다.

const MESSAGE_IDS = [
  'ADULTS',
  'CHILDREN',
  'INFANTS',
  'YOUTHS',
  'SENIORS'
];
const getMessageProps = (
  adults,
  children,
  youths,
  seniors
) => {
  return [adults, children, youths, seniors]
    .map((count, index) => ({
      id: MESSAGE_IDS[index],
      count
    }))
    .filter(({ count }) => count > 0);
};

이제 코드를 더 쉽게 이해할 수 있습니다. 반복은 없고 의도는 명확해졌다. getMessageProps함수는 인수 목록을 배열으로 변환하고 “빈” 요소를 제거합니다.

더 단순화할 수도 있다.

const MESSAGE_IDS = [
  'ADULTS',
  'CHILDREN',
  'YOUTHS',
  'SENIORS'
];
const getMessageProps = (...counts) => {
  return counts
    .map((count, index) => ({
      id: MESSAGE_IDS[index],
      count
    }))
    .filter(({ count }) => count > 0);
};

리팩토링 결과 함수 API가 복잡해지고 에디터의 자동완성 기능을 사용하지 못하게 됐다. 또한 함수가 임의의 인수를 허용하고 인수의 순서가 명확하지 않아졌다. 인수와 순서는 이전 리팩토링이 더 명확했다.

.map() / .filter() 체인 대신 .reduce() 메소드를 사용할 수도 있다:

const MESSAGE_IDS = [
  'ADULTS',
  'CHILDREN',
  'YOUTHS',
  'SENIORS'
];
const getMessageProps = (...counts) => {
  return counts.reduce((acc, count, index) => {
    if (count > 0) {
      acc.push({
        id: MESSAGE_IDS[index],
        count
      });
    }
    return acc;
  }, []);
};

다른 글에서 설명 한적 있지만 필자는 .reduce()는 종종 코드를 읽기 어렵게 만들고 의도를 명확하지 않게 만들기 때문에 항상 옳다고 생각하지 않는다. .map(), .filter() 체이닝을 이용하면 배열에 동일한 수의 요소가 있는 다른 배열로 변환하고, 다음 필요하지 않은 배열 항목을 제거한다는 것이 분명해진다.

그래서 필자는 이 리팩토링 과정을 2단계 전에 멈추는 것이 좋다고 생각한다.

함수 인수를 변경하는 유효한 이유는 성능 최적화일 것이라 생각한다. 엄청난 양의 데이터로 작업할 때 매번 새 객체를 만들어 느려진 경우이다. 그러나 다른 성능 최적화와 마찬가지로 실제로 성능에 문제가 있는지 먼저 측정을 하여 섣부른 최적화는 피하는 것이 좋다.

Mutation이 불가피한 경우 명시적으로 작성

변이가 일어나는 API 때문에 변이를 피할 수 없는 경우가 있을 수 있다.

Array .sort() 메소드는 대표적인 메소드이다.

const counts = [4, 3, 2];
const puppies = counts.sort().map(n => `${n} puppies`);

counts 배열이 변경되지 않고 정렬된 puppies 배열을 생성하고 있다고 생각할 수 있다. 그러나 .sort() 메소드는 정렬된 배열을 반환함과 동시에 원본 배열을 변이시킨다. 이러한 코드는 위험이 있고, 마찬가지로 찾기 어려운 버그로 이어질 수 있다. 예시 코드가 제대로 동작하는 것처럼 보이기 때문에 많은 개발자는 원본 배열이 변이 된다는 사실을 인지하지 못한다.

변이를 명시적으로 만들 수 있다.

const counts = [6, 3, 2];
const sortedCounts = [...counts].sort();
const puppies = sortedCounts.map(n => `${n} puppies`);

counts 배열을 스프레드 문법을 이용해 얕은 복사를 통해 복사본을 만든 후 정렬하여 원본 배열이 변이 되지 않도록 한다.

.sort() API를 래핑하여 원본 배열을 변경하지 않도록 새로운 API를 작성하는 방법도 있다. (sortBy, Lodash 같은 라이브러리를 설치해도 좋다)

function sort(array) {
  return [...counts].sort();
}

const counts = [6, 3, 2];
const puppies = sort(counts).map(n => `${n} puppies`);

객체 업데이트

최신 Javascript에는 스프레드 문법 덕분에 불변성을 유지하며 데이터 업데이트를 쉽게 할 수 있다. 스프레드 문법이 있기 전엔 다음과 같이 작성해야했다.

const prev = { coffee: 1 };
const next = Object.assign({}, prev, { pizza: 42 });

첫 번째 인수로 빈객체를 준다. 그렇지 않으면 Object.assign은 원본 객체를 변이 시킨다. 첫 번째 인수를 변이 대상으로 삼고, 첫 번째 인수를 변경하고 반환한다. 참으로 불편한 API 이다.

이젠 다음과 같이 작성 가능하다.

const prev = { coffee: 1 };
const next = { ...prev, pizza: 42 };

동일한 동작을 수행하고 코드가 복잡하지 않으며, Object.assign을 더 이상 기억할 필요가 없다.

사실 ECMA Script 2015 이전에는 변이를 피하려고 하지 않았다. 정말 고통스러운 코드이다. 하지만 그 시대엔 나름의 변명이 있다. 자세한 얘기는 다른 포스팅에서 다루어보도록 하겠다.

Redux에는 불변성하게 업데이트를 관리하는 패턴에 대한 훌륭한 문서가 존재한다. 변이 없이 배열과 객체를 업데이트하는 패턴이 설명되어 있으며 Redux를 사용하지 않더라도 꽤 유용하다.

그러나 중첩된 객체에의 스프레드 문법은 여전히 복잡하다.

function addDrink(meals, drink) {
  return {
    ...meals,
    lunch: {
      ...meals.lunch,
      drinks: [...meals.lunch.drinks, drink]
    }
  };
}

중첩된 값을 변경하려면 객체의 중첩을 분산해야 한다. 그렇지 않으면 초기 객체를 새로운 객체로 덮어쓰도록 하자

function addDrink(meals, drink) {
  return {
    ...meals,
    lunch: {
      drinks: [drink]
    }
  };
}

여기서 초기 객체의 첫 번째 중첩만 유지하고, drinks는 새로운 값을 가지게 된다.

Object.assign, 스프레드 문법 모두 얕은 복사만 지원한다. 첫 번째 중첩만 복사본이고 외에 중첩된 속성은 원본 객체를 여전히 참조한다.

객체가 자주 업데이트되어야 한다면 최대한 얕은 깊이로 유지하는 것이 좋다.

최근 Javascript에서 깊은 복사하기 위한 API를 제공하기 시작했다. structuredClone() API는 객체를 깊은 복사하는데 사용할 수 있으며, 아래와 같이 순환 참조도 지원한다.

const original = { name: "MDN" };
original.itself = original;

const clone = structuredClone(original);

console.assert(clone !== original); // 서로 다른 참조 값을 가지고 있다.
console.assert(clone.name === "MDN"); // 서로 같은 값을 가지고 있다.
console.assert(clone.itself === clone); // 원본 객체가 보존 된다.

다만 serialize 할 수 없는 값이라면 DataCloneError 이 발생한다.

const validation = {
    value: "hello world"
    condition: (value) => value && value.trim() !== ''
}

const clone = structuredClone(validation); // DataCloneError DOMException

코드 리뷰 과정을 거쳐도 변이를 놓치기 쉽기 때문에 변이를 방지할 수 있는 한 가지 방법은 Lint를 이용하는 것이다. ESLint에는 변이를 방지하기 위한 플러그인이 있다. 플러그인에서는 다음과 같이 설명하고 있다.

eslint-plugin-better-mutation disallows any mutations, except for local variables in functions. This is a great idea because it prevents bugs caused by the mutation of shared objects but allows you to use mutations locally. Unfortunately, it breaks even in simple cases, such as a mutation occurring inside .forEach().

변이를 방지하는 또 다른 방법으로는 Typescript에서 모든 객체와 배열을 읽기 전용으로 표시 하는 것이다.

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

type Person = Readonly<{
  readonly name: string;
  readonly age: number;
}>;

function sort(array: readonly any[]) {
  return [...counts].sort();
}

그러나 readonly 키워드 및 유틸을 사용해도 중첩된 객체는 변이가 가능하기 때문에 모든 중첩된 객체에 추가해 주어야 한다. 타입 정의가 복잡해지긴 하지만 런타임 비용이 없기 때문에 필자는 좋은 방법이라 생각한다.

interface 를 readonly로 만들 수 있길 바라지만 현재는 불가능하다. 대신 Readonly 유틸을 이용하고 있다.

type Mutable<T> = T extends Readonly<infer U> ? U : never;

Typescript에서 읽기 전용으로 만드는 것과 비슷하게 Object.freeze를 이용해 런타임에서 읽기 전용으로 만들 수 있다. 그러나 마찬가지로 중첩된 객체는 변이가 가능하다. 대신 deep-freeze 라이브러리를 사용할 수 있다.

객체 업데이트를 단순화 하는 것은 변이를 방지하는 방법이라 생각한다.

객체 업데이트를 단순화하는 일반적인 방법은 Immutable.js 또는 Immer를 사용하는 것이다. 두 라이브러리 모두 인기 있지만 작업을 intercept 하여 새로운 객체를 만들기 때문에 코드 변화가 적어 필자는 Immer를 조금 더 선호한다.

Mutation은 항상 좋지 않을까?

드문 경우이지만 명령형 코드에서 변이는 크게 나쁘지 않을 수 있다. 변이가 없는 코드로 다시 작성한다고 해서 크게 바뀌지 않는다.

const getDateRange = (startDate, endDate) => {
  const dateArray = [];
  let currentDate = startDate;
  while (currentDate <= endDate) {
    dateArray.push(currentDate);
    currentDate = addDays(currentDate, 1);
  }
  return dateArray;
};

주어진 날짜 범위를 채우기 위해 날짜 배열을 만들고 있다. 명령형 루프, 재할당 및 변이 없이 더 좋은 코드로 리팩토링할 아이디어가 당장 떠오르진 않는다. 대신 다음을 감수할 수 있다.

  • 변이를 작은 function 으로 격리
  • 의미 있는 함수 네이밍
  • 명확한 내부 로직
  • 순수 함수

변이가 없는 복잡하고 지저분한 코드보다 변이가 있는 간단명료한 코드가 더 낫다. 그러니 변이를 사용해야 한다면 의미 있는 네이밍과 명확한 API를 가진 작은 함수로 분리하도록 하자.