지나가던 개발(zigae)

프론트엔드 클린코드 - 조건문 지양

2021년 8월 30일 • ☕️☕️ 9 min read

clean code

조건(condition)은 코드를 읽고 복잡하게 만든다. 중첩된 조건은 행을 더 길게 만들기도 해서 여러 행으로 분할 하도록 해야하고, 각 조건은 특정 모듈 또는 기능에 대해 작성 해야하는 테스트 케이스의 수도 증가시키기도 한다.

불필요한 조건

많은 조건들은 불필요하거나 더 읽기 쉬운 방식으로 다시 작성할 수 있다.

예를 들어 Boolean 값을 반환하는 다음과 유사한 코드를 종종 볼 수 있다.

const hasValue = value !== NONE ? true : false;
const hasProducts = products.length > 0 ? true : false;

value !== NONE그리고 products.length > 0 우리는 Boolean을 리턴하도록 해 삼항연산자를 피할 수 있다.

const hasValue = value !== NONE;
const hasProducts = products.length > 0;

또한 초기 값이 Boolean이 아닐지라도

const hasValue = value ? true : false;
const hasProducts = products.length ? true : false;

명시적으로 값을 Boolean으로 변환하여 조건을 피할 수 있다.

const hasValue = Boolean(value);

삼항연산자가 있는 코드보다 더 짧고 읽기 쉬워졌다.

조건이 불필요한 경우는 더 있다.

- const hasProducts = products && Array.isArray(products);
+ const hasProducts = Array.isArray(products);

Array.isArray는 falsy 한 값에 대해 false를 반환하므로 별도로 확인할 필요가 없다.

그리고 불필요한 조건의 다른 예(실제 사용 코드 발췌)는 또 있다.

function IsNetscapeSolaris() {
  const agent = window.navigator.userAgent;
  if (
    agent.indexOf('Mozilla') != -1 &&
    agent.indexOf('compatible') == -1
  ) {
    if (agent.indexOf('SunOS') != -1) return true;
    else return false;
  } else {
    return false;
  }
}

조건문은 표현식으로 대체 가능하다.

function IsNetscapeSolaris() {
  const { userAgent } = window.navigator;
  return (
    userAgent.includes('Mozilla') &&
    userAgent.includes('SunOS') &&
    !userAgent.includes('compatible')
  );
}

두 단계의 중첩된 조건을 제거함으로써 반복되는 코드도 상당히 많이 제거 했고, 코드를 더 쉽게 파악할 수 있게 됐다.

배열 처리

배열을 순회하기 전에 배열의 길이를 확인하는 것은 일반적이다.

return getProducts().then(response => {
  const products = response.products;
  if (products.length > 0) {
    return products.map(product => ({
      label: product.name,
      value: product.id
    }));
  }
  return [];
});

모든 루프와 배열의 메소드는 빈 배열에 동작함으로 길이 검사를 제거해도 된다.

return getProducts().then(({ products }) =>
  products.map(product => ({
    label: product.name,
    value: product.id
  }))
);

때로는 특정 케이스에만 배열을 반환하는 API를 사용할 때도 있다. 이 경우엔 길이를 확인하면 실패하고 타입을 먼저 확인해야 한다.

return getProducts().then(response => {
  const products = response.products;
  if (Array.isArray(products) && products.length > 0) {
    return products.map(product => ({
      label: product.name,
      value: product.id
    }));
  }
  return [];
});

이경우 조건을 피할 수는 없지만 가능한 데이터 유형에 따라 여러 가지 방법이 있다.

데이터가 배열 또는 undefined 값을 가지면 매개변수에 default value를 사용할 수 있다.

return getProducts().then((products = []) =>
  products.map(product => ({
    label: product.name,
    value: product.id
  }))
);

또는 객체의 비구조화할당(destructuring) 대한 기본값:

- return getProducts().then((products = []) =>
+ return getProducts().then(({ products = [] }) =>

데이터가 배열 또는 null이 될 수 있다면 더 복잡하다. 이유는 default value는 값이 strict undefined인 경우에만 동작하기 때문이다. 이 경우엔 || 연산자를 사용할 수 있다.

return getProducts().then(products =>
  (products || []).map(product => ({
    label: product.name,
    value: product.id
  }))
);

여전히 조건은 남아 있지만 전체 코드 구조는 더 간결해졌다.

위의 예에서 필자는 가능한 입력을 미리 배열로 변환하고, 변환된 데이터에 대해 원하는 로직을 실행하여 별도의 분기를 제거하고 데이터의 부재를 방어 처리했다.

종종 단일 데이터 또는 배열을 반환하는 API도 종종 있다.

여기서는 단일 데이터를 배열로 래핑하므로 동일한 코드를 사용하여 작업할 수 있다.

return getProducts().then(({ products }) =>
  (Array.isArray(products) ? products : [products]).map(product => ({
    label: product.name,
    value: product.id
  }))
);

Early return

방어 코드 또는 Early return을 적용 하는 것은 중첩 조건을 피하는 좋은 방법이다. Arrow anti patttern 이라고도 하는 일련의 중첩 조건은 종종 오류 처리를 위해 사용된다.

function postOrderStatus(orderId) {
  var idsArrayObj = getOrderIds();

  if (idsArrayObj != undefined) {
    if (idsArrayObj.length == undefined) {
      var tmpBottle = idsArrayObj;
      idsArrayObj = new Array(tmpBottle);
    }

    var fullRecordsArray = new Array();
    // 70 lines

    if (fullRecordsArray.length != 0) {
      // 40 lines
      return sendOrderStatus(fullRecordsArray);
    } else {
      return false;
    }
  } else {
    return false;
  }
}

첫 번째 조건과 해당 else 블록 사이에는 120줄의 코드가 있다. 그리고 주요 반환 값은 조건의 3번째 블록 내부 어딘가에 존재한다.

우선 이 복잡한 코드를 풀어 보자.

function postOrderStatus(orderId) {
  let idsArrayObj = getOrderIds();

  if (idsArrayObj === undefined) {
    return false;
  }

  if (!Array.isArray(idsArrayObj)) {
    idsArrayObj = [idsArrayObj];
  }

  const fullRecordsArray = [];

  // 70 lines
  if (fullRecordsArray.length === 0) {
    return false;
  }

  // 40 lines
  return sendOrderStatus(fullRecordsArray);
}

위 함수는 아직 길다. 하지만 코드 구조가 더 간결하기 때문에 훨씬 더 쉽게 이해할 수 있다.

이제 함수 내부 중첩이 줄었으며, 주요 반환 값은 중첩 없이 마지막에 있고, 처리할 데이터가 없을 때 일찍 함수를 종료하기 위해 두 개의 방어코드를 추가하였다.

두 번째 조건(condition) 안의 코드가 무엇을 하는지 잘 모르겠지만 이전 섹션처럼 단일 값을 배열으로 래핑 하고 있다.

여기에서 다음 단계는 getOrderIds()함수의 API를 개선하는 것일 수 있다. undefined, 단일 값 또는 배열 총 세가지 타입을 반환할 수 있고, 각각을 개별적으로 처리해야 하므로 조건이 증가한다.

getOrderIds()함수가 항상 배열을 반환하면, 우리는 두 조건을 제거 할 수 있다.

function postOrderStatus(orderId) {
  const orderIds = getOrderIds(); // Always an array

  const fullRecordsArray = [];

  // 70 lines
  if (fullRecordsArray.length === 0) {
    return false;
  }

  // 40 lines
  return sendOrderStatus(fullRecordsArray);
}

이제 초기 작성된 코드에 비해 크게 개선이 되었다. idsArrayObj에서 “array object”는 더이상 의미가 없기에 변수명도 변경했다.

Tables & maps

조건을 개선(회피) 할 때 필자가 가장 좋아하는 방법 중 하나는 상황을 tabels 또는 maps로 바꾸는 것이다. JavaScript를 사용하면 오브젝트를 활용해서 tables또는 maps를 만들 수 있다.

간단한 예를 보자. 이 예제는 다소 극단적일 수 있지만 실제로 과거에 필자 역시 이렇게 작성하였다.

if (month == 'jan') month = 1;
if (month == 'feb') month = 2;
if (month == 'mar') month = 3;
if (month == 'apr') month = 4;
if (month == 'may') month = 5;
if (month == 'jun') month = 6;
if (month == 'jul') month = 7;
if (month == 'aug') month = 8;
if (month == 'sep') month = 9;
if (month == 'oct') month = 10;
if (month == 'nov') month = 11;
if (month == 'dec') month = 12;

조건을 테이블로 교체해 보자.

const MONTH_NAME_TO_NUMBER = {
  jan: 1,
  feb: 2,
  mar: 3,
  apr: 4,
  may: 5,
  jun: 6,
  jul: 7,
  aug: 8,
  sep: 9,
  oct: 10,
  nov: 11,
  dec: 12
};
const month = MONTH_NAME_TO_NUMBER[monthName];

반복 되는 코드가 거의 없고, 가독성이 높으며 테이블처럼 보여진다. 또한 예시 코드에는 중괄호가 없는데 대부분의 현대식 코드 스타일 가이드에서는 조건문에 중괄호가 있는 반면 본문은 한 줄에 있어야 하므로 사실은 더 길고 가독성도 더욱 떨어진다.

이제부터는 Javascript가 아닌 우리에게 와닿을 수 있는 React에서 코드를 어떻게 개선하는지 살펴보자.

const DECISION_YES = 0;
const DECISION_NO = 1;
const DECISION_MAYBE = 2;

const getButtonLabel = decisionButton => {
  switch (decisionButton) {
    case DECISION_YES:
      return (
        <FormattedMessage
          id="decisionButtonYes"
          defaultMessage="Yes"
        />
      );
    case DECISION_NO:
      return (
        <FormattedMessage id="decisionButtonNo" defaultMessage="No" />
      );
    case DECISION_MAYBE:
      return (
        <FormattedMessage
          id="decisionButtonMaybe"
          defaultMessage="Maybe"
        />
      );
  }
};

// 사용 부
<Button>{getButtonLabel(decision.id)}</Button>;

세 개의 버튼 중에 하나를 반환하는 switch문이 있다.

switch은 장황하며 복잡하기에 먼저 switch문을 tables로 교체 해보자.

const DECISION_YES = 0;
const DECISION_NO = 1;
const DECISION_MAYBE = 2;

const getButtonLabel = decisionButton =>
  ({
    [DECISION_YES]: (
      <FormattedMessage id="decisionButtonYes" defaultMessage="Yes" />
    ),
    [DECISION_NO]: (
      <FormattedMessage id="decisionButtonNo" defaultMessage="No" />
    ),
    [DECISION_MAYBE]: (
      <FormattedMessage
        id="decisionButtonMaybe"
        defaultMessage="Maybe"
      />
    )
  }[decisionButton]);

// 사용 부
<Button>{getButtonLabel(decision.id)}</Button>;

오브젝트 구문은 switch문 보다 좀 더 짧으며 읽기 쉽다.

getButtonLabel함수를 React 컴포넌트로 변환하면 이 코드를 React 스럽게 만들 수 있다.

const DECISION_YES = 0;
const DECISION_NO = 1;
const DECISION_MAYBE = 2;

const ButtonLabel = ({ decision }) =>
  ({
    [DECISION_YES]: (
      <FormattedMessage id="decisionButtonYes" defaultMessage="Yes" />
    ),
    [DECISION_NO]: (
      <FormattedMessage id="decisionButtonNo" defaultMessage="No" />
    ),
    [DECISION_MAYBE]: (
      <FormattedMessage
        id="decisionButtonMaybe"
        defaultMessage="Maybe"
      />
    )
  }[decision]);

// 사용 부
<Button>
  <ButtonLabel decision={decision.id} />
</Button>;

초기 코드보다 훨씬 React 답고 간결해고, 사용법 또한 직관적으로 바뀌었다.

다음은 유효성(validation) 검사를 살펴보자.

function validate(values) {
  const errors = {};

  if (!values.name || (values.name && values.name.trim() === '')) {
    errors.name = (
      <FormattedMessage
        id="errorNameRequired"
        defaultMessage="이름은 필수 값입니다."
      />
    );
  }

  if (values.name && values.name.length > 80) {
    errors.name = (
      <FormattedMessage
        id="errorMaxLength80"
        defaultMessage="이름은 80글자를 초과할 수 없습니다."
      />
    );
  }

  if (!values.address1) {
    errors.address1 = (
      <FormattedMessage
        id="errorAddressRequired"
        defaultMessage="주소는 필수 값입니다."
      />
    );
  }

  if (!values.email) {
    errors.mainContactEmail = (
      <FormattedMessage
        id="errorEmailRequired"
        defaultMessage="이메일은 필수 값입니다."
      />
    );
  }

  if (!values.login || (values.login && values.login.trim() === '')) {
    errors.login = (
      <FormattedMessage
        id="errorLoginRequired"
        defaultMessage="로그인은 필수 입니다."
      />
    );
  }

  if (values.login && values.login.indexOf(' ') > 0) {
    errors.login = (
      <FormattedMessage
        id="errorLoginWithoutSpaces"
        defaultMessage="공백을 허용하지 않습니다."
      />
    );
  }

  if (values.address1 && values.address1.length > 80) {
    errors.address1 = (
      <FormattedMessage
        id="errorMaxLength80"
        defaultMessage="주소는 80글자를 초과할 수 없습니다."
      />
    );
  }

  // 100 lines

  return errors;
}

위 함수는 매우 길고 반복적인 boilerplate 코드가 많다. 유지보수 뿐만 아니라 코드를 이해하기 역시 어렵다. 동일한 필드에 대한 유효성 검사가 함께 그룹화되지 않는 경우도 있다.

그러나 자세히 살펴보면 다음 세 가지 고유한 유효성 검사가 있다.

  • 필수 필드(어떤 경우에는 선행 및 후행 공백이 무시되고 일부에서는 무시된다. 의도한 것인지 여부를 구분하기 어렵다.)
  • 최대 길이(80)
  • 공백이 허용되지 않는다.

먼저, 나중에 재사용할 수 있도록 모든 유효성 검사를 함수로 분리하자.

const hasStringValue = (value) => value && value.trim() !== '';

const hasLengthLessThanOrEqual = (max) => (value) => !hasStringValue(value) || (value && value.length <= max);

const hasNoSpaces = (value) => !hasStringValue(value) || (value && value.includes(' '));

또한 필자의 의견으로는 더 읽기 쉬운 코드를 위해 잘못된 값이 아닌 올바른 값을 검증하기 위해 모든 조건을 반전 시켰다.

이제 유효성 검사를 테이블에 정의할 수 있으며, 두가지 방법이 있다.

  • 키가 양식 필드를 나타내는 객체 사용
  • 배열을 사용하여

필드 마다 서로 다른 에러 메시지가 있고, 여러 유효성 검사를 해야하기 때문에 두 번째 옵션을 사용할 것이다. 예를 들어 어떤 필드는 필수이면서 최대 길이를 가질 수 있다.

const validations = [
  {
    field: 'name',
    validation: hasStringValue,
    message: (
      <FormattedMessage
        id="errorNameRequired"
        defaultMessage="Name is required"
      />
    )
  },
  {
    field: 'name',
    validation: hasLengthLessThanOrEqual(80),
    message: (
      <FormattedMessage
        id="errorMaxLength80"
        defaultMessage="Maximum 80 characters allowed"
      />
    )
  }
  // 다른 필드들...
];

이제 이 배열을 반복하고 모든 필드에 대해 유효성 검사를 실행 할 수 있게 되었다.

초기에 작성 했던 validate함수를 고쳐보자.

function validate(values, validations) {
  return validations.reduce((errors, ({field, validation, message}) => {
    if (!validation(values[field])) {
      errors[field] = message;
    }
    return errors;
  }, {})
}

우리는 한번 더 “무엇(what)“을 “어떻게(how)“에서 분리했다. 가독성이 좋고 유지 보수하기 용이한 유효성 검사 목록(“무엇”), 재사용 가능한 유효성 검사 함수 및 validate 양식 값을 유효성 검사 하는 함수(“방법”)로 분리 되었고, 또한 재사용할 수 있다.


결론

  • boolean을 반환할 필요 없는 조건 제거
  • 데이터가 없는 데이터를 조기에 배열로 변환하여 데이터 타입에 따른 분기를 방지하고 데이터가 없는 경우 별도로 처리
  • 긴 조건 및 switch를 tables 또는 maps로 교체