[리팩터링 2판] - 조건부 로직 간소화

Lee Jeong Min·2022년 9월 10일
0

리팩터링 2판

목록 보기
10/12
post-thumbnail

리팩터링 2판의 Chatper 10를 보고 정리한 글입니다.

조건부 로직은 프로그램의 힘을 강화하는 데 기여하지만, 프로그램을 복잡하게 만드는 주요 원흉중 하나이다.
→ 이러한 조건부 로직과 관련한 리팩터링 기법들을 알아보자.

조건문 분해하기

배경

조건문 코드는 무슨 일이 일어나는지 이야기해주지만 '왜' 일어나는지는 제대로 말해주지 않을 때가 많은 것이 문제다.

거대한 코드 블록이 주어지면 코드를 나누고 해체된 덩어리의 의도를 살린 이름의 함수 호출로 바꾸어 조건문안의 의도를 더 확실히 드러내자.

절차

  1. 조건식과 그 조건식에 딸리 조건절 각각을 함수로 추출한다.

예시

여름철이면 할인율이 달라지는 어떤 서비스의 요금을 계산하는 코드

리팩터링 전

if (!aDate.isBefore(plan.summaerStart) && !aDate.isAfter(plan.summerEnd))
  charge = quantity * plan.summerRate;
else
  charge = quantity * plan.regularRate + plan.regularServiceCharge;

리팩터링 후

조건식을 별도 함수로 추출하고, 조건이 만족하거나 만족하지 않았을 때의 로직을 모두 함수로 추출한다.

if (summer()) charge = summerCharge();
else charge = regularCharge();

function summer() {
  return !aDate.isBefore(plan.summaerStart) && !aDate.isAfter(plan.summerEnd);
}

function summerCharge() {
  return quantity * plan.summerRate;
}

function regularCharge() {
  return quantity * plan.regularRate + plan.regularServiceCharge;
}


// 취향에 따라 3항 연산자로 바꿔줄 수 있다.
charge = summer() ? summerCharge() : regularCharge();

조건식 통합하기

배경

비교하는 조건은 다르지만 그 결과로 수행하는 동작은 똑같은 코드들이 있다. 이러한 경우라면 하나로 통합하는게 낫다.

통합하는 이유
1. 여러 조각으로 나뉜 조건들을 하나로 통합함으로써 내가 하려는 일이 더 명확해진다.
2. 이 작업이 함수 추출하기까지 이어질 가능성이 높기 때문이다.

함수로 추출하게 되면 코드의 의도가 훨씬 분명하게 드러나는 경우가 많다.

절차

  1. 해당 조건식들 모두에 부수효과가 없는지 확인한다.
  2. 조건문 두 개를 선택하여 두 조건문의 조건식들을 논리 연산자로 결합한다.
  3. 테스트한다.
  4. 조건이 하나만 남을 때까지 2~3 과정을 반복한다.
  5. 하나로 합쳐진 조건식을 함수로 추출할지 고려해본다.

예시

리팩터링 전

function disabilityAmount(anEmployee) {
  if (anEmployee.seniority < 2) return 0;
  if (anEmployee.monthsDisabled > 12) return 0;
  if (anEmployee.isPartTime) return 0;
  // 장애 수당 계산
}

리팩터링 후

조건들을 하나의 식으로 통합하고, 함수로 추출하자.

function disabilityAmount(anEmployee) {
  if (isNotEligibleForDisability()) return 0;

  function isNotEligibleForDisability() {
    // 장애 수당 적용 여부 확인
    return anEmployee.seniority < 2 || anEmployee.monthsDisabled > 12 || anEmployee.isPartTime;
  }
}

중첩 조건문을 보호 구문으로 바꾸기

배경

조건문의 두 경로 모두 정상 동작이라면 if else를 사용하는 것이 좋지만, 한쪽만 정상인 경우 그 조건만 if문으로 검사를 하고 그 조건문에서 빠져나와 작성하자. 후자의 경우를 보통 보호 구문이라고 한다.

이 리팩터링의 핵심은 의도를 부각하는데에 있다. 즉, 동일한 if절과 else는 양 갈래가 똑같은 무게를 가지고 중요하다는 의미를 전달하는데, 보호 구문은 이건 이 함수의 핵심이 아니라는 것을 이야기할 수 있다.

절차

  1. 교체해야 할 조건 중 가장 바깥 것을 선택하여 보호 구문으로 바꾼다.
  2. 테스트한다.
  3. 1~2 과정을 필요한 만큼 반복한다.
  4. 모든 보호 구문이 같은 결과를 반환한다면 보호 구문들의 조건식을 통합한다.

예시

직원 급여를 계산하는 코드 예시

리팩터링 전

function payAmount(employee) {
  let result;
  if (employee.isSeparated) {
    // 퇴사한 직원인가?
    result = { amount: 0, reasonCode: 'SEP' };
  } else if (employee.isRetired) {
    // 은퇴한 직원인가?
    result = { amount: 0, reasonCode: 'RET' };
  } else {
    // 급여 계산 로직
    lorem.ipsum(dolor.sitAmet);
    consectetur(adipiscing).elit();
    sed.do.eiusmod = tempor.incididunt.ut(labore) && dolore(magna.aliqua);
    ut.enim.ad(minim.veniam);
    result = someFinalComputation();
  }
  return result;
}

리팩터링 후

function payAmount(employee) {
  if (employee.isSeparated) {
    return { amount: 0, reasonCode: 'SEP' };
  }
  if (employee.isRetired) {
    return { amount: 0, reasonCode: 'RET' };
  }

  // 급여 계산 로직
  lorem.ipsum(dolor.sitAmet);
  consectetur(adipiscing).elit();
  sed.do.eiusmod = tempor.incididunt.ut(labore) && dolore(magna.aliqua);
  ut.enim.ad(minim.veniam);
  return someFinalComputation();
}

뒤의 예제에서 기존 조건식을 반대로 만들어서 리팩터링하는 예시도 나오는데 이러한 상황이 필요한 경우 적용해보면 좋을것 같다.

조건부 로직을 다형성으로 바꾸기

배경

조건문 구조를 클래스와 다형성을 이용하면 더 확실하게 분리할 수 있다. 이를 활용해보자.

절차

  1. 다형적 동작을 표현하는 클래스들이 아직 없다면 만들어준다. 이왕이면 적합한 인스턴스를 알아서 만들어 반환하는 팩터리 함수도 함께 만든다.
  2. 호출하는 코드에서 팩터리 함수를 사용하게 한다.
  3. 조건부 로직 함수를 슈퍼클래스로 옮긴다.
  4. 서브클래스 중 하나를 선택한다. 서브클래스에서 슈퍼클래스의 조건부 로직 메서드를 오버라이드한다. 조건부 문장 중 선택된 서브클래스에 해당하는 조건절을 서브클래스 메서드로 복사한 다음 적절히 수정한다.
  5. 같은 방식으로 각 조건절을 해당 서브클래스에서 메서드로 구현한다.
  6. 슈퍼클래스 메서드에는 기본 동작 부분만 남긴다. 혹은 슈퍼클래스가 추상 클래스여야 한다면, 이 메서드를 추상으로 선언하거나 서브클래스에서 처리해야 함을 알리는 에러를 던진다.

예시

새의 종에 따른 비행 속도와 깃털 상태 관련 예시

리팩터링 전

function plumages(birds) {
  return new Map(birds.map((b) => [b.name, plumage(b)]));
}

function speeds(birds) {
  return new Map(birds.map((b) => [b.name, airSpeedVelocity(b)]));
}

function plumage(bird) {
  switch (bird.type) {
    case '유럽 제비':
      return '보통이다';
    case '아프리카 제비':
      return bird.numberOfCoconuts > 2 ? '지쳤다' : '보통이다';
    case '노르웨이 파랑 앵무':
      return bird.voltage > 100 ? '그을렸다' : '예쁘다';
    default:
      return '알 수 없다';
  }
}

function airSpeedVelocity(bird) {
  switch (bird.type) {
    case '유럽 제비':
      return 35;
    case '아프리카 제비':
      return 40 - 2 * bird.numberOfCoconuts;
    case '노르웨이 파랑 앵무':
      return bird.isNailed ? 0 : 10 + bird.voltage / 10;
    default:
      return null;
  }
}

리팩터링 후

가장 먼저 Bird라는 클래스로 묶어보자.

class Bird {
  constructor(birdObject) {
    Object.assign(this, birdObject)l
  }

  get plumage() {
    switch (this.type) {
      case '유럽 제비':
        return '보통이다';
      case '아프리카 제비':
        return this.numberOfCoconuts > 2 ? '지쳤다' : '보통이다';
      case '노르웨이 파랑 앵무':
        return this.voltage > 100 ? '그을렸다' : '예쁘다';
      default:
        return '알 수 없다';
    }
  }

  get airSpeedVelocity() {
    switch (this.type) {
      case '유럽 제비':
        return 35;
      case '아프리카 제비':
        return 40 - 2 * this.numberOfCoconuts;
      case '노르웨이 파랑 앵무':
        return this.isNailed ? 0 : 10 + this.voltage / 10;
      default:
        return null;
    }
  }
}

이후 종별 서브클래스를 만든다. 적합한 서브클래스의 인스턴스를 만들어줄 팩터리 함수를 만들고, 객체를 얻을 때 팩터리 함수를 사용하도록 수정한다.

function plumage(bird) {
  return createBird(bird).plumage;
}

function airSpeedVelocity(bird) {
  return createBird(bird).airSpeedVelocity;
}

function createBird(bird) {
  switch (bird.type) {
    case '유럽 제비':
      return new EuropeanSwallow(bird);
    case '아프리카 제비':
      return new AfricanSwallow(bird);
    case '노르웨이 파랑 앵무':
      return new NorwegianBlueParrot(bird);
    default:
      return new Bird(bird);
  }
}

class EuropeanSwallow extends Bird {}

class AfricanSwallow extends Bird {}

class NorwegianBlueParrot extends Bird {}

이후 서브클래스들에 조건부 메서드를 처리하자.

function plumages(birds) {
  return new Map(birds.map((b) => [b.name, plumage(b)]));
}

function speeds(birds) {
  return new Map(birds.map((b) => [b.name, airSpeedVelocity(b)]));
}

class Bird {
  constructor(birdObject) {
    Object.assign(this, birdObject);
  }

  get plumage() {
    return '알 수 없다';
  }

  get airSpeedVelocity() {
    return null;
  }
}

function plumage(bird) {
  return createBird(bird).plumage;
}

function airSpeedVelocity(bird) {
  return createBird(bird).airSpeedVelocity;
}

function createBird(bird) {
  switch (bird.type) {
    case '유럽 제비':
      return new EuropeanSwallow(bird);
    case '아프리카 제비':
      return new AfricanSwallow(bird);
    case '노르웨이 파랑 앵무':
      return new NorwegianBlueParrot(bird);
    default:
      return new Bird(bird);
  }
}

class EuropeanSwallow extends Bird {
  get plumage() {
    return '보통이다';
  }

  get airSpeedVelocity() {
    return 35;
  }
}

class AfricanSwallow extends Bird {
  get plumage() {
    return this.numberOfCoconuts > 2 ? '지쳤다' : '보통이다';
  }

  get airSpeedVelocity() {
    return 40 - 2 * this.numberOfCoconuts;
  }
}

class NorwegianBlueParrot extends Bird {
  get plumage() {
    return this.voltage > 100 ? '그을렸다' : '예쁘다';
  }

  get airSpeedVelocity() {
    return this.isNailed ? 0 : 10 + this.voltage / 10;
  }
}

위와 같은 계층 구조뿐만아니라 변형동작과 관련하여도 상속과 다형성을 이용하여 조건부 로직을 분리할 수 있다.(예제 책 참고)

특이 케이스 추가하기

배경

코드베이스에서 특정 값에 대해 똑같이 반응하는 코드가 여러 곳이라면 그 반응들을 한 데 모으는게 효율적이다. 특수한 경우의 공통 동작을 요소 하나에 모아서 사용하는 특이 케이스 패턴이라는 것이 있고, 널은 특이 케이스로 처리해야 할 때가 많다. 그래서 이 패턴을 널 객체 패턴이라고도 한다.

절차

  1. 컨테이너에 특이 케이스인지를 검사하는 속성을 추가하고, false를 반환하게 한다.
  2. 특이 케이스 객체를 만든다. 이 객체는 특이 케이스인지를 검사하는 속성만 포함하며, 이 속성은 true를 반환하게 한다.
  3. 클라이언트에서 특이 케이스인지를 검사하는 코드를 함수로 추출한다. 모든 클라이언트가 값을 직접 비교하는 대신 방금 추출한 함수를 사용하도록 고친다.
  4. 코드에 새로운 특이 케이스 대상을 추가한다. 함수의 반환 값으로 받거나 변환 함수를 적용하면 된다.
  5. 특이 케이스를 검사하는 함수 본문을 수정하여 특이 케이스 객체의 속성을 사용하도록 한다.
  6. 테스트한다.
  7. 여러 함수를 클래스로 묶기나 여러 함수를 변환 함수로 묶기를 적용하여 특이 케이스를 처리하는 공통 동작을 새로운 요소로 옮긴다.
  8. 아직도 특이 케이스 검사 함수를 이용하는 곳이 남아 있다면 검사 함수를 인라인한다.

예시

전력 회사의 예시로 전력이 필요한 현장에 인프라를 설치해 서비스를 제공하는 코드를 보자.

리팩터링 전

class Site {
  get customer() { return this._customer; }
}

class Customer {
  get name() {...} // 고객이름
  get billingPlan() {...} // 요금제
  set billingPlan(arg) {...}
  get paymentHistory() {...} // 납부 이력
}

일반적으로 현장에 고객이 거주하는 게 보통이지만 그렇지 않은 경우가 있다. 즉 '미확인 고객'이란 문자열로 대체되는 상황이 존재하고, 이런 상황을 감안하여 Site 클래스를 사용하는 코드들은 이 경우도 처리할 수 있어야 한다.

클라이언트 측 코드

// 클라이언트 1
const aCustomer = site.customer;
// ... 수많은 코드 ...
let customerName;
if (aCustomer === '미확인 고객') customerName = "거주자";
else customerName = aCustomer.name;

// 클라이언트 2
const plan = aCustomer === "미확인 고객" ? registry.billingPlans.basic : aCustomer.billingPlan;

// 클라이언트 3
if (aCustomer !== '미확인 고객') aCustomer.billingPlan = newPlan;

// 클라이언트 4
const weeksDelinquent = aCustomer === '미확인 고객' ? 0 : aCustomer.paymentHistory.weeksDelinquentInLastYear;

코드베이스에서 미확인 고객을 처리해야 하는 클라이언트 코드가 여러 개 발견되었다. 이러한 경우가 우리에게 특이 케이스 객체를 도입할 때임을 말해준다.

리팩터링 후

isUnknown이라는 함수를 만들어 클라이언트측에서 미확인 고객을 확인하는 부분을 바꿔보자.

class Site {
  get customer() { this._customer === '미확인 고객' ? new UnknownCustomer() : this._customer }
}

function isUnknown(arg) {
  if(!((arg instanceof Customer) || (arg instanceof UnknownCustomer))) throw new Error(`잘못된 값과 비교: <${arg}>`);
  return arg.isUnknown
}

class Customer {
  get name() {...} // 고객이름
  get billingPlan() {...} // 요금제
  set billingPlan(arg) {...}
  get paymentHistory() {...} // 납부 이력

  get isUnknown() {return true;}
}

class UnknownCustomer {...}

// 클라이언트 1
const aCustomer = site.customer;
// ... 수많은 코드 ...
let customerName;
if (isUnknown(aCustomer)) customerName = "거주자";
else customerName = aCustomer.name;

// 클라이언트 2
const plan = isUnknown(aCustomer) ? registry.billingPlans.basic : aCustomer.billingPlan;

// 클라이언트 3
if (!isUnknown(aCustomer)) aCustomer.billingPlan = newPlan;

// 클라이언트 4
const weeksDelinquent = isUnknown(aCustomer) ? 0 : aCustomer.paymentHistory.weeksDelinquentInLastYear;

여기서부터 특이 케이스 검사를 일반적인 기본값으로 대체하여 검사 코드에 여러 함수를 클래스로 묶기를 적용할 수 있다.

class UnknownCustomer {
  get name() {return '거주자';}
  get billingPlan() {return registry.billingPlans.basic;}
  set billingPlan(arg) {/* */}
  get paymentHistory() {return new NullPaymentHistory();}
}

// 특이 케이스 객체가 다른 객체를 반환해야 하는경우, 그 객체 역시 특이 케이스여야하므로 새로운 클래스를 만듦
class NullPaymentHistory {
  get weeksDelinquentInLastYear() {return 0;}
}

책에선 위 예제말고 다른 두가지 예제도 소개한다.
1. 데이터 구조를 읽기만 한다면 간단하게 객체 리터럴 생성함수를 이용하여 간단하게 해결하는 방법
2. 변환 함수를 이용하여 해결하는 법

어서션 추가하기

배경

어서션을 추가하여 프로그램이 어떤 상태임을 가정한 채 실행되는지를 다른 개발자에게 알려주는 소통 도구로 사용할 수 있다.

어서션: 항상 참이라고 가정하는 조건부 문장
→ 이것이 실패하면 프로그래머가 잘못했다는 뜻

절차

  1. 참이라고 가정하는 조건이 보이면 그 조건을 명시하는 어서션을 추가한다.

예시

할인과 관련한 간단한 예

리팩터링 전

class Customer {
  applyDiscount(aNumber) {
    return this.discountRate ? aNumber - this.discountRate * aNumber : aNumber;
  }
}

이 코드에는 할인율이 항상 양수라는 가정이 전제

리팩터링 후

// 다음과 같이 간단하게 어서션을 추가할 수 있지만
class Customer {
  applyDiscount(aNumber) {
    if (!this.discountRate) return aNumber;
    else {
      assert(this.discountRate >= 0);
      return aNumber - this.discountRate * aNumber;
    }
  }
}

// 이번 예에서는 세터 메서드에 추가함으로써, 이 문제가 언제 처음 발생했는지를 알 수 있다.
// Customer 클래스
set discountRate(aNumber) {
  assert(aNumber === null || aNumber >= 0);
  this._discountRate = aNumber;
}

제어 플래그를 탈출문으로 바꾸기

배경

제어 플래그란 코드의 동작을 변경하는 데 사용되는 변수를 말하며, 어딘가에서 값을 계산해 제어 플래그를 설정한 후 다른 어딘가의 조건문에서 검사하는 형태로 쓰인다.

이러한 코드는 충분히 리팩터링을 진행할 수 있다.

절차

  1. 제어 플래그를 사용하는 코드를 함수로 추출할지 고려한다.
  2. 제어 플래그를 갱신하는 코드 각각을 적절한 제어문으로 바꾼다. 하나 바꿀 때마다 테스트한다.
  3. 모두 수정했다면 제어 플래그를 제거한다.

예시

사람 목록을 훑으면서 악당을 찾는 코드

리팩터링 전

let found = false;
for (const p of people) {
  if (!found) {
    if (p === '조커') {
      sendAlert();
      found = true;
    }
    if (p === '사루만') {
      sendAlert();
      found = true;
    }
  }
}

리팩터링 후

function checkForMiscreants(people) {
  for (const p of people) {
    if (p === '조커') {
      sendAlert();
      return;
    }
    if (p === '사루만') {
      sendAlert();
      return;
    }
  }
}


// 더 가다듬기
function checkForMiscreants(people) {
  if (people.some((p) => ['조커', '사루만'].includes(p))) sendAlert();
}
profile
It is possible for ordinary people to choose to be extraordinary.

0개의 댓글