본문 바로가기
Programming/13. Book

리팩터링 - 10장

by @sangseophwang 2024. 3. 2.

Chapter 10. 조건부 로직 간소화

✏️ 조건문 분해하기

배경

  • 복잡한 조건부 로직은 프로그램을 복잡하게 만든다.
  • 다양한 조건, 그에 따라 동작도 다양한 코드를 작성하면 순식간에 꽤 긴 함수가 탄생한다.
  • 거대한 코드 블록이 주어지면 코드를 부위별로 분해한 다음 각각 의도를 살린 이름의 함수 호출로 바꿔주자.
  • 이렇게 하면 해당 조건이 무엇인지 강조하고, 그래서 무엇을 분기했는지 명확해진다.

절차

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

예시

Before

// 함수 내용 : 여름 기간 여부에 따라 할인율이 달라지는 함수
// 목표: 조건식을 별도 함수로 추출해 조건문 정리하기

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

After

function summer() { // 여름철 기간 조건
  return !date.isBefore(plan.summerStart) && !date.isAfter(plan.summerEnd)
}

function summerCharge() { // 여름철 비용
  return quantity * plan.summerRate;
}

function regularCharge() { // 일반 비용
  return quantity * plan.regularRate + plan.regularServiceCharge;
}

// 결과 1 ⭐️
if (summer()) {
  charge = summerCharge();
} else {
  charge = regularCharge();
}

// 결과 2 ⭐️ - 삼항 연산자로 변경
charge = summer() ? summerCharge() : regularCharge();

Deep dive

  • 이번 예시에서는 함수로 변경하는 방안에 대해 소개했는데, 이런 조건과 값의 조합에는 변수화해서 적용하는게 더 낫지 않을까?
  • 저도 조건식안

✏️ 조건식 통합하기

배경

  • 비교하는 조건은 다르지만 그 결과로 수행하는 동작이 똑같은 코드들이 있다.
  • 이럴 때 and 연산자와 or 연산자를 사용해 여러 개의 비교 로직을 하나로 합칠 수 있다.
  • 조건부 코드를 통합하는 게 중요한 이유는 두 가지다.
    • 여러 조건들을 하나로 통합해 하려는 일을 명확하게 만든다.
    • 이 작업이 함수 추출하기까지 이어질 수 있다. 함수로 추출해 의도를 명확하게 드러내게 할 수 있기 때문이다.

절차

  1. 해당 조건식들에 부수효과가 없는지 확인하고, 있다면 질의 함수와 변경 함수 분리하기를 먼저 적용한다.
  2. 조건문 두 개를 선택해 논리 연산자로 결합한다.
  3. 조건이 하나만 남을 때까지 반복한다.
  4. 하나로 합쳐진 조건식을 함수로 추출할지 고민해본다.

예시

Before

// 함수 내용 : 직원의 연차, 장애를 갖게 된 기간, 근무 형태를 확인해 장애 수당 계산
// 목표 : 순차적으로 진행되는 조건

function disabilityAmount(employee) {
  if (employee.seniority < 2) return 0;
  if (employee.monthsDisabled > 12) return 0;
  if (employee.isPartTime) return 0;
  return 1;
}

After

function disabilityAmount(employee) {
  if (isNotEligibleForDisability(employee))  {
    return 0;
  }
  return 1;
}

// 장애 수당 자격 조건 함수
function isNotEligibleForDisability(employee) { 
  return employee.seniority < 2 || employee.monthsDisabled > 12 || employee.isPartTime
}

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

배경

  • 조건문은 주로 두 가지 형태로 쓰인다.
    • 참인 경로, 거짓인 경로 모두 정상 동작으로 이어지는 형태
    • 정상인 형태
  • 두 형태는 의도하는 바가 서로 다르므로 그 의도가 코드에 드러나야 한다.
    • 한 쪽만 정상이라면 비정상 조건을 if에서 검사한다.
    • 조건이 참(또는 비정상)이면 함수에서 빠져나온다.
    • 이 검사 형태를 흔히 보호 구문(guard clause)이라고 한다.
    • “이건 이 함수의 핵심이 아니다. 이 일이 일어나면 뭔가 조치를 취한 후 함수에서 빠져나온다”
  • 코드에서는 명확함이 핵심이다. 반환점이 하나일 때 명확해진다면 그렇게 하고, 아니라면 하지 않는다.

절차

  1. 교체해야 할 조건 중 가장 바깥 것을 선택해 보호 구문으로 바꾼다.
  2. 이를 반복한다.
  3. 모든 보호 구문이 같은 결과를 반환한다면 보호 구문들의 조건식을 통합한다.

예시

Before

// 함수 내용 : 직원 급여를 계산하는 코드. 각 조건에 따라 result에 값을 넣어 return한다.
// 목표 : 중첩 조건문을 보호 구문으로 바꾸기

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;
}

After

  1. 최상위 조건부터 하나씩 보호 구문으로 변경한다.
  2. 다음 조건도 동일하게 작성한다.
  3. result 가변 변수를 제거하고 보호 구문을 통과한 값을 return 시켜준다.
export function payAmount(employee) {

  // early return ⭐️
  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();
}

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

배경

  • 복잡한 조건부 로직은 해석하기 가장 난해한 대상에 속한다.
    • 타입을 기준으로 분기하는 switch문이 포함 된 함수가 여러개 있는 경우
      • case별로 클래스를 만들어 공통 switch 로직 중복 제거
      • 기본 동작을 위한 case문과 그 변형 동작들로 구성된 로직 생성

절차

  1. 다형적 동작을 표현하는 클래스를 만들어준다. 또한 적합한 인스턴스를 알아서 만들어 반환하는 팩토리 함수도 함께 만든다.
  2. 호출하는 코드에서 팩토리 함수를 사용하게 한다.
  3. 조건부 로직 함수를 슈퍼클래스로 옮긴다.
  4. 서브클래스 중 하나를 선택해 슈퍼클래스의 조건부 로직 메서드를 오버라이드한다.
  5. 같은 방식으로 각 조건절을 해당 서브클래스에서 메서드로 구현한다.
  6. 슈퍼클래스 메서드에는 기본 동작만 남긴다.

예시

Before

Plumage: 깃털

Voltage: 전압

Nail: 못으로 고정하다, 못으로 박다

Velocity: 속도

// 새 정보로 새 이름, 깃털 상태 정보를 가진 맵 객체 생성
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;
  }

}

After

  1. airSpeedVelocity(), plumage()를 Bird 클래스로 묶는다.
class Bird {
  constructor(birdObject) {
    Object.assign(this, birdObject)
  }

  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;
    }
  }
}

2. 종별 서브클래스와 서브클래스의 인스턴스를 만들어줄 팩토리 함수를 만든다.

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);
  }
}

// 팩토리 함수
function plumage(bird) {
  return createBird(bird).plumage;
}

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

// 서브클래스
class EuropeanSwallow extends Bird { }
class AfricanSwallow extends Bird { }
class NorwegianBlueParrot extends Bird { }

3. switch문을 하나씩 해당 서브클래스로 오버라이드한다.

function createBird(bird) {
  get plumage() {
    return "알 수 없다";
  }

  get airSpeedVelocity() {
    return null;
  }
}

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;
  }
}

✏️ 특이 케이스 추가하기

배경

  • 데이터 구조의 특정 값을 확인한 후 똑같은 동작을 수행하는 코드가 곳곳에 등장하는 경우가 있다.
  • 이런 코드가 여러 곳에 있다면 그 반응들을 한 데로 모으는 게 효율적이다.
  • 특수한 경우의 공동 동작을 요소 하나에 모아서 사용하는 패턴을 특이 케이스 패턴(Special Case Pattern)이라 한다.
    • 이 패턴을 활용해 특이 케이스를 확인하는 코드 대부분을 단순 함수 호출로 바꿀 수 있다.
  • 특이 케이스는 여러 형태로 표현할 수 있다.
    • 특이 케이스 객체에서 단순히 데이터를 읽기만 한다면 반환값을 담은 리터럴 객체 형태로 준비하면 된다.
    • 그 이상의 동작을 한다면 필요한 메서드를 담은 객체를 생성한다.
  • 특히 null을 특이 케이스로 처리해야 할 때가 많아 이 패턴을 널 객체 패턴(Null Object Pattern)이라고도 한다.

절차

컨테이너 : 리팩터링 대상이 될 속성을 담은 데이터 구조(혹은 클래스)

  1. 컨테이너에 특이 케이스를 검사하는 속성을 추가하고, false를 반환하게 한다.
  2. 그 속성들을 모은 객체를 만들어 true를 반환하게 한다.
  3. 클라이언트에서 특이 케이스 검사 코드를 함수로 추출한다. 그리고 이 함수를 사용하도록 고친다.
  4. 코드에 새로운 특이 케이스 대상을 추가한다.
  5. 특이 케이스를 검사하는 함수 본문을 수정해 특이 케이스 객체의 속성을 사용하도록 한다.
  6. 여러 함수를 클래스로 묶거나 변환 함수로 묶어 특이 케이스를 처리하는 공통 동작을 새로운 요소로 옮긴다.

예시

Before

Delinquent: 채무를 이행하지 않은, 연체된

Acquire: 획득하다, 취득하다

Enrich: 강화하다

📍 현장 정보와 고객 정보가 담긴 기본 데이터 형태
{
  name: "애크미 보스턴",
  location: "Malden MA",
  // 더 많은 현장(site) 정보
  customer: {
    name: "애크미 산업",
    billingPlan: "plan-451",
    paymentHistory: {
      weekDelinquentInLastYear: 7
    }
  }
};

📍 미확인 고객인 경우
const UnknownCustomer = {
  name: "물류창고 15",
  location: "Malden MA",
  // 더 많은 현장(site) 정보
  customer: "미확인 고객", ⭐️
};

// 클라이언트1
// 고객 정보가 미확인이면 '거주자'로, 확인됐다면 고객명 표시
const site = acquireSiteData();
const aCustomer = site.customer;
...
let customerName;
if (aCustomer === "미확인 고객") customerName = "거주자";
else customerName = aCustomer.name;

// 클라이언트2
// 미확인 고객이면 기본 요금제를, 확인됐다면 해당 고객의 요금제를 반환
const plan = (aCustomer === "미확인 고객") ?
    registry.billingPlans.basic
    : aCustomer.billingPlan;

// 클라이언트3
// 고객의 연체 기간을 주로 환산하는데, 미확인 고객이면 0주, 
// 확인된 고객이면 체납 기록에서 작년 주당 연체 기록 반환
const weeksDelinquent = (aCustomer === "미확인 고객") 
    ? 0
    : aCustomer.paymentHistory.weekDelinquentInLastYear;

After

  1. 현장 데이터 구조를 변환하는 enrichSite 함수를 생성한다.
  2. 우선 깊은 복사만 수행하도록 한다.
    1. const result = _.cloneDeep(inputSite);
  3. 알려지지 않은 고객인지 검사하는 로직을 함수로 추출한다.
  4. enrichSite 함수에 고객 레코드에 isUnknown 속성을 추가하도록 보강한다.
    1. const UnknownCustomer = { isUnknown: true, }…
  5. isUnknown 함수에서 기존 데이터, 변환된 데이터 모두 사용할 수 있도록 조건을 추가한다.
  6. 특이 케이스들을 unknownCustomer로 몰아넣는다.
function isUnknown(aCustomer) { 📍 3번
  if (aCustomer === "미확인 고객") return true;
  else return aCustomer.isUnknown; 📍 5번
}

// 클라이언트1
const rawSite = acquireSiteData();
const site = enrichSite(rawSite);
const aCustomer = site.customer;
const customerName = aCustomer.name;

function enrichSite(inputSite) {
  const result = _.cloneDeep(inputSite); 📍 2번
  const unknownCustomer = {
    isUnknown: true,
    name: "거주자",
    billingPlan: registry.billingplans.basic,
    paymentHistory: {
      weeksDelinquentInLastYear: 0,
    }
  };
  
  // 미확인 고객이면 미확인 고객 데이터로 교체
  // 확인된 고객이면 isUnknown: false 속성 추가
  if (isUnknown(result.customer)) result.customer = unknownCustomer;
  else result.customer.isUnknown = false;
  return result;
}

// 클라이언트2
// 미확인 고객이면 기본 요금, 아니면 고객의 요금 정보
const plan = aCustomer.billingPlan;

// 클라이언트3
const weeksDelinquent = aCustomer.paymentHistory.weekDelinquentInLastYear;

✏️ 어서션 추가하기

배경

  • 어서션은 항상 참이라고 가정하는 조건부 문장을 뜻한다.
  • 어서션은 프로그램이 어떤 상태임을 가정한 채 실행되는지를 다른 개발자에게 알려주는 훌륭한 소통 도구이다.

절차

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

예시

Before

// 클래스 내용 : 할인율이 존재할 때 구매 개수만큼 할인율을 적용해 할인가를 도출
// 목표 : 할인율이 항상 양수라는 가정을 추가하기 

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

After

class Customer {
  ...
  
  applyDiscount(number) {
    if (!this.discountRate) return number;
    else {
      assert(this.discountRate >= 0); // 할인율이 양수라고 가정해보자 ⭐️
      return number - (this.discountRate * number);
    }
  }
}

Deep dive

  • 어서션을 남발하는 것은 위험하다.
  • ‘반드시 참이어야만 하는 것’만 검사한다.

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

배경

  • 제어 플래그란 코드의 동작을 변경하는 데 사용되는 변수를 말한다.
    • 어딘가에서 값을 계산해 제어 플래그에 설정한 후
    • 다른 어딘가의 조건문에서 검사하는 형태로 쓰인다.
  • 제어 플래그의 주 서식지는 반복문 안이다.
  • return 문이 여러 개 생기더라도 명확한 역할을 하는 것이 좋을 수 있다.

절차

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

예시

Before

// 함수 내용 : 위험한 사람을 발견하면 경고하고 찾았다고 알리는 코드

let found = false;

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

After

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

// 더 가다듬기
const villainList = ['조커', '사루만'];

function checkForVillain(people) {
  if (people.some(p => villainList.includes(p))) sendAlert();
}
반응형

'Programming > 13. Book' 카테고리의 다른 글

리팩터링 - 12장  (0) 2024.03.02
리팩터링 - 11장  (0) 2024.03.02
리팩터링 - 8, 9장  (0) 2024.03.02
리팩터링 - 5, 6장  (0) 2024.03.02
리팩터링 - 2장  (0) 2024.03.02

댓글