refactoring 6: Basic refactoring way 2

가장 기본적이고 많이 사용해서 제일 먼저 배워야 하는 리팩터링 2
refactoring

6.9 여러 함수를 클래스로 묶기


목표


as-is

function base(aReading) {...}
function taxableCharge(aReading) {...}
function calculateBaseCharge(aReading) {...}

to-be

class Reading {
  base(){...}
  taxableCharge() {...}
  calculateBaseCharge() {...}
}

배경


여러 함수를 클래스로 묶기의 장점

함수를 객체처럼 패턴

function createPerson(name) {
  let birthday;
  return {
    name: () => name,
    setName: (aString) => (name = aString),
    birthday: () => birthday,
    setBirthday: (aLocalDate) => (birthday = aLocalDate),
    age: age,
    canTrust: canTrust,
  };
  function age() {
    return birthday.until(clock.today(), ChronoUnit.YEARS);
  }
  function canTrust() {
    return age() <= 30;
  }
}

// 사용
const kent = createPerson("kent");
kent.setBirthday(LocalDate.parse("1961-03-31"));
const youngEnoughToTrust = kent.canTrust();

절차


  1. 함수들이 공유하는 공통 데이터 레코드를 캡슐화 한다.
    공통 데이터가 레코드 구조로 묶여 있지 않다면 사전 작업으로 매개변수 객체 만들기 진행(6.8절)
  2. 공통 레코드를 사용하는 함수 각각을 새 클래스로 옮긴다 (함수 옮기기 8.1절)
    공통 레코드의 맴버는 함수 호출문의 인수 목록에서 제거
  3. 데이터를 조작하는 로직들은 함수로 추출해서(6.1절) 새 클래스로 옮긴다.

예시


수돗물처럼 차 계량기를 읽어서 측정값을 기록하는 로직

기본 코드의 1번 클라이언트

1번 클라이언트

const aReading = acquireReading();
const baseCharge = baseRate(aReading.month, aReading.year) * aReading.quantity;

세금 부과를 위한 코드가 추가된 2번 클라이언트

2번 클라이언트

const aReading = acquireReading();
const base = baseRate(aReading.month, aReading.year) * aReading.quantity;
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));

1번 클라이언트와 2번 클라이언트의 공동된 계산식을 함수로 추출(6.1절)한 것과 같은 3번 클라이언트의 calculateBaseCharge() 가 존재한다.

3번 클라이언트

const aReading = acquireReading();
const basicChargeAmount = calculateBaseCharge(aReading);

/**
 * [기본 요금 계산 함수]
 * 1번 클라이언트, 2번 클라이언트에 공통된 계산식 존재
 */
function calculateBaseCharge(aReading) {
  return baseRate(aReading.month, aReading.year) * aReading.quantity;
}

1번 클라이언트와 2번 클라이언트도 해당 함수를 사용할 수 있지만, 최상위 함수로 두면 못 보고 지나치기 쉬운 문제가 발생한다.

→ 함수를 데이터 처리 코드 가까이에 두기 위한 데이터를 클래스로 변환 한다.

1. 함수들이 공유하는 공통 데이터 레코드를 캡슐화 한다.

레코드를 클래스로 변환하기 위해 레코드를 캡슐화(7.1절) 한다.

class Reading {
  constructor(data) {
    this._customer = data.customer;
    this._quantity = data.quantity;
    this._month = data.month;
    this._year = data.year;
  }

  get customer() {
    return this._customer;
  }

  get customer() {
    return this._quantity;
  }

  get month() {
    return this._month;
  }

  get year() {
    return this._year;
  }
}

2. 공통 레코드를 사용하는 함수 각각을 새 클래스로 옮긴다 (함수 옮기기 8.1절)

새 클래스를 사용하려면 데이터를 얻자마자 객체로 만들어야 한다.

3번 클라이언트

const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const basicChargeAmount = calculateBaseCharge(aReading);

이미 만들어져 있는 calculateBaseCharge()를 새로 만든 클래스로 옮긴다.(함수 옮기긱 8.1절)

Reading 클래스

class Reading {
  constructor(data) {...}

  //사전에 만든 getter 들...

  get calculateBaseCharge() {
    return baseRate(this._month, this._year) * this._quantity;
  }
}

3번 클라이언트

const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const basicChargeAmount = aReading.calculateBaseCharge;

calculateBaseCharge를 조금 더 명확한 이름으로 변경한다. (함수 이름 바꾸기 6.5절)

Reading 클래스

class Reading {
  constructor(data) {...}

  //사전에 만든 getter 들...

  get baseCharge() {
    return baseRate(this.month, this.year) * this.quantity;
  }
}

3번 클라이언트

const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const basicChargeAmount = aReading.baseCharge;

baseCharge로 이름을 변경하면 Reading 클래스를 사용하는 클라이언트는 baseCharge가 필드인지, 계산된 값을 return하는 함수 호출인지 구분 할 수 없으며, 이는 **단일 접근 원칙**을 따르므로 권장하는 방식이다.

단일 접근 원칙이란? (Uniform Access Principle)

이 원칙의 핵심은 person 객체가 있고, 그 객체의 나이값을 알고 싶을 때 나이가 객체 내의 필드에 저장되어 있든 계산된 값이든 상관없이 같은 방식으로 접근해야 한다는 것이다. 이는 person 객체를 사용하는 클라이언트가 나이가 저장된 것인지 계산된 것인지 신경 쓸 필요도, 알 필요도 없어야 한다는 것을 의미한다.

1번, 2번 클라이언트도 Reading 클래스를 사용할 수 있도록 변경해준다.

2번 클라이언트

const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const taxableCharge = Math.max(
  0,
  aReading.baseCharge - taxThreshold(aReading.year)
);

3. 데이터를 조작하는 로직들은 함수로 추출해서(6.1절) 새 클래스로 옮긴다.

세금을 부과할 소비량을 계산하는 코드를 함수로 추출(6.1절)한다.

function taxableChargeFn(aReading) {
  return Math.max(0, aReading.baseCharge - taxThreshold(aReading.year));
}

추출한 함수를 클라이언트에 적용한다.

3번 클라이언트

const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const taxableCharge = taxableChargeFn(aReading);

완벽히 작동한다면, 추출한 함수를 Reading 클래스로 옮긴다 (함수 옮기기 8.1절)

Reading 클래스

class Reading {
  constructor(data) {...}

  //사전에 만든 getter 들...

  get taxableCharge() {
    return Math.max(0, this.baseCharge - taxThreshold(this.year))
  }
}

3번 클라이언트

const rawReading = acquireReading();
const aReading = new Reading(rawReading);
const taxableCharge = aReading.taxableCharge;

완성



6.10 여러 함수를 변환 함수로 묶기


목표


as-is

function base(aReading) {...}
function taxableCharge(aReading) {...}

to-be

function enrichReading(argReading) {
  const aReading = _.cloneDeep(argReading);

  aReading.baseCharge = base(aReading);
  aReading.taxableCharge = taxableCharge(aReading);

  return aReading;
}

배경


변환 함수

변환 함수는 도출을 위한 로직을 한곳에 처리하기 위한 방법으로,

원본 데이터를 입력받아서 필요한 정보를 모두 도출한 뒤, 각각을 출력 데이터의 필드에 넣어 반환한다.

함수를 클래스로 묶기 (6.9절)과 비교

절차


  1. 변환할 레코드를 입력받아서 값을 그대로 반환하는 변환 함수를 만든다.
    이 작업은 대체로 깊은 복사로 처리한다. 변환 함수가 원본 레코드를 바꾸지 않는지 검사하는 테스트를 마련해 두면 도움이 된다.
  2. 묶을 함수 중 함수 하나를 골라서 본문 코드를 변환 함수로 옮기고, 처리 결과를 레코드에 새 필드로 기록한다. 그런 다음 클라이언트 코드가 이 필드를 사용하도록 수정한다. (로직이 복잡하면 함수 추출하기(6.1절) 진행)

  3. 테스트 진행

  4. 나머지 관련 함수도 위 과정에 따라 처리한다.

예시


차를 제공하는 서비스에서 매달 사용자가 마신 차의 양을 측정 하는 로직

기본 코드의 1번 클라이언트

1번 클라이언트

const aReading = acquireReading();
const baseCharge = baseRate(aReading.month, aReading.year) * aReading.quantity;

세금을 부과할 소비량을 계산하는 코드 추가된 2번 클라이언트

2번 클라이언트

const aReading = acquireReading();
const base = baseRate(aReading.month, aReading.year) * aReading.quantity;
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));

중복 코드는 나중에 로직 수정에 골치 동반할 수 있다. 중복 코드라면 함수 추출하기 (6.1절)로 처리 가능하지만, 추출한 함수들이 프로그램 곳곳에 흩어진다면 그것도 문제를 야기한다.

3번 클라이언트

const aReading = acquireReading();
const basicChargeAmount = calculateBaseCharge(aReading);

/**
 * [기본 요금 계산 함수]
 * 1번 클라이언트, 2번 클라이언트에 공통된 계산식 존재
 */
function calculateBaseCharge(aReading) {
  return baseRate(aReading.month, aReading.year) * aReading.quantity;
}

이를 해결하는 방법으로, 다양한 파생 정보 계산 로직을 모두 하나의 변환 단계로 모을 수 있다.

변환 단계에서 미가공 측정값을 입력받아서 다양한 가공 정보를 덧붙여 반환하는 것이다.

1. 변환할 레코드를 입력받아서 값을 그대로 반환하는 변환 함수를 만든다.

입력 객체를 그대로 복사해 반환하는 변환 함수로 만든다.

function enrichReading(original) {
  const result = _.cloneDeep(original); // lodash 사용

  return result;
}

2. 묶을 함수 중 함수 하나를 골라서 본문 코드를 변환 함수로 옮기고, 처리 결과를 레코드에 새 필드로 기록한다. 그런 다음 클라이언트 코드가 이 필드를 사용하도록 수정한다.

변경하려는 계산 로직중 하나를 고른다. 이 계산 로직에 측정값을 전달하기 전에 부가 정보를 덧붙인다.

3번 클라이언트

const rawReading = acquireReading(); // 미가공 측정값
const aReading = enrichReading(rawReading);
const basicChargeAmount = calculateBaseCharge(aReading);

calculateBaseCharge()를 부가 정보를 덧붙이는 코드 근처로 옮긴다 (함수 옮기기 (8.1절))

enrichReading()

function enrichReading(original) {
  const result = _.cloneDeep(original);
  result.baseCharge = calculateBaseCharge(result); // 미가공 측정값에 부가 정보를 덧붙임

  return result;
}

변환 함수 안에서는 결과 객체를 매번 복제할 필요 없이 마음껏 변경해도 된다.

불변 데이터가 안정적이긴 하지만 유효범위가 좁을 때는 큰 상관이 없다.

클라이언트가 부가 정보를 담은 필드를 사용하도록 수정한다.

3번 클라이언트

const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const basicChargeAmount = aReading.baseCharge;

calculateBaseCharge()를 호출하는 코드를 모두 수정했다면, 이 함수를 enrichReading()안에 중첩 시킬수 있습니다. -> (무슨말이야???)

이렇게 변환하면 ‘기본요금을 이용하는 클라이언트는 변환된 레코드를 사용해야 한다.’ 라는 의도를 명확히 표현할 수 있습니다.

3. 테스트

enrichReading()처럼 정보를 추가해 반환할 때 원본 측정값 레코드는 변경하지 않아야 한다.

it("check reading unchanged", function () {
  const baseReading = {customer: "ivan", quantity: 15, month: 5, year: 2017};
  const oracle = _.cloneDeep(baseReading);

  enrichReading(baseReading);

  assert.deepEqual(baseReading, oracle);
});

4. 나머지 관련 함수도 위 과정에 따라 처리한다.

기본요금을 옮겼으니 이제 세금을 부과할 소비량 계산도 옮기도록 한다.

const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const base = baseRate(aReading.month, aReading.year) * aReading.quantity;
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));

base 를 enrichReading()값인 baseCharge로 변경한다.

const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const base = aReading.baseCharge;
const taxableCharge = Math.max(0, base - taxThreshold(aReading.year));

base변수를 인라인한다.(6.4절)

const rawReading = acquireReading();
const aReading = enrichReading(rawReading);
const taxableCharge = Math.max(
  0,
  aReading.baseCharge - taxThreshold(aReading.year)
);

taxableCharge 계산 코드를 변환 함수로 옮긴다.

function enrichReading(original) {
  const result = _.cloneDeep(original);
  result.baseCharge = calculateBaseCharge(result);
  result.taxableCharge = Math.max(
    0,
    result.baseCharge - taxThreshold(result.year)
  );

  return result;
}

6.11 단계 쪼개기


목표


as-is

const orderData = orderString.split(/\\s+/);
const productPrice = priceList[orderData[0].split("-")[1]];
const orderPrice = parseInt(orderData[1]) * productPrice;

to-be

const orderRecord = parseOrder(order);
const orderPrice = price(orderRecord, priceList);

function parseOrder(aString) {
  const values = aString.split(/\\s+/);

  return {
    productId: values[0].split("-")[1],
    quantity: parseInt(values[1]),
  };
}

function price(order, priceList) {
  return order.quantity * priceList[order.productId];
}

배경


서로 다른 두 대상을 한꺼번에 다루는 코드는 각각을 별개 모듈로 나누면 좋다.

절차


  1. 두 번째 단계에 해당하는 코드를 독립 함수로 추출한다.
  2. 테스트한다.
  3. 중간 데이터 구조를 만들어서 앞에서 추출한 함수의 임수로 추가한다.
  4. 테스트한다.
  5. 추출한 두 번째 단계 함수의 매개변수를 하나씩 검토한다. 그중 첫 번째 단계에서 사용되는 것은 중간 데이터 구조로 옮긴다. 하나씩 올길 때마다 테스트한다.
  1. 첫 번째 단계 코드를 함수로 추출(6.1절) 하면서 중간 데이터 구조를 반환하도록 만든다.

예시


상품의 결제 금액을 계산하는 코드

function priceOrder(product, quantity, shippingMethod) {
  const basePrice = product.basePrice * quantity;
  const discount =
    Math.max(quantity - product.discountThreshold) *
    product.basePrice *
    product.discountRate;
  const shippingPerCase =
    basePrice > shippingMethod.discountThreshold
      ? shippingMethod.discountFee
      : shippingMethod.feePerCase;
  const shippingCost = quantity * shippingPerCase;
  const price = basePrice - discount + shippingCost;

  return price;
}

앞 부분 코드는 상품 정보를 이용해서 결제 금액 중 상품 가격을 계산한다.

뒷 부분 코드는 배송 정보를 이용하여 결제 금액중 배송비를 계산한다.

1. 두 번째 단계에 해당하는 코드를 독립 함수로 추출한다.

배송비 계산 부분을 함수로 추출한다.(6.1절)

function priceOrder(product, quantity, shippingMethod) {
  const basePrice = product.basePrice * quantity;
  const discount =
    Math.max(quantity - product.discountThreshold) *
    product.basePrice *
    product.discountRate;
  const price = applyShipping(basePrice, shippingMethod, discount);

  return price;
}

// 두 번째 단계를 처리하는 함수
function applyShipping(basePrice, shippingMethod, discount) {
  const shippingPerCase =
    basePrice > shippingMethod.discountThreshold
      ? shippingMethod.discountFee
      : shippingMethod.feePerCase;
  const shippingCost = quantity * shippingPerCase;
  const price = basePrice - discount + shippingCost;

  return price;
}

두 번째 단계에 필요한 데이터를 모두 개별 매개변수로 전달했다.

2. 테스트한다.

3. 중간 데이터 구조를 만들어서 앞에서 추출한 함수의 임수로 추가한다.

첫 번째 단계와 두 번째 단계가 주고받을 중간 데이터 구조를 만든다.

function priceOrder(product, quantity, shippingMethod) {
  const basePrice = product.basePrice * quantity;
  const discount =
    Math.max(quantity - product.discountThreshold) *
    product.basePrice *
    product.discountRate;
  const priceData = {}; // 중간 데이터 구조
  const price = applyShipping(priceData, basePrice, shippingMethod, discount);

  return price;
}

function applyShipping(priceData, basePrice, shippingMethod, discount) {
  const shippingPerCase =
    basePrice > shippingMethod.discountThreshold
      ? shippingMethod.discountFee
      : shippingMethod.feePerCase;
  const shippingCost = quantity * shippingPerCase;
  const price = basePrice - discount + shippingCost;

  return price;
}

4. 테스트 한다.

5. 추출한 두 번째 단계 함수의 매개변수를 하나씩 검토한다. 그중 첫 번째 단계에서 사용되는 것은 중간 데이터 구조로 옮긴다. 하나씩 올길 때마다 테스트한다.

applyShipping()에 전달 되는 다양한 매개변수 중에서 basePrice는 첫 번째 단계를 수행하는 코드에서 생성된다.

basePrice를 중간 데이터 구조로 옮기고 매개변수 목록에서 제거한다.

function priceOrder(product, quantity, shippingMethod) {
  const basePrice = product.basePrice * quantity;
  const discount =
    Math.max(quantity - product.discountThreshold) *
    product.basePrice *
    product.discountRate;
  const priceData = {basePrice};
  // basePrice 매개변수에서 제거
  const price = applyShipping(priceData, shippingMethod, quantity, discount);

  return price;
}

// basePrice 매개변수에서 제거
function applyShipping(priceData, shippingMethod, quantity, discount) {
  const shippingPerCase =
    priceData.basePrice > shippingMethod.discountThreshold
      ? shippingMethod.discountFee
      : shippingMethod.feePerCase;
  const shippingCost = quantity * shippingPerCase;
  const price = priceData.basePrice - discount + shippingCost;

  return price;
}

shippingMethod 매개변수는 첫 번째 단계에서는 사용하지 않으니 그대로 둔다.

quantity와 discount는 basePrice와 같은 방식으로 처리한다.

quantity는 첫 번째 단계에서 사용되지만 첫 번째 단계에서 생성된 것은 아니나, 최대한 중간 데이터 구조로 담는 걸 추천한다. (마틴파울러님 선호)

function priceOrder(product, quantity, shippingMethod) {
  const basePrice = product.basePrice * quantity;
  const discount =
    Math.max(quantity - product.discountThreshold) *
    product.basePrice *
    product.discountRate;
  const priceData = {basePrice, quantity, discount};
  const price = applyShipping(priceData, shippingMethod);

  return price;
}

function applyShipping(priceData, shippingMethod) {
  const shippingPerCase =
    priceData.basePrice > shippingMethod.discountThreshold
      ? shippingMethod.discountFee
      : shippingMethod.feePerCase;
  const shippingCost = priceData.quantity * shippingPerCase;
  const price = priceData.basePrice - priceData.discount + shippingCost;

  return price;
}

6. 첫 번째 단계 코드를 함수로 추출(6.1절) 하면서 중간 데이터 구조를 반환하도록 만든다.

function priceOrder(product, quantity, shippingMethod) {
  const priceData = calculatePricingData(product, quantity);
  const price = applyShipping(priceData, shippingMethod);

  return price;
}

// 첫 번째 단계를 처리 하는 함수
function calculatePricingData(product, quantity) {
  const basePrice = product.basePrice * quantity;
  const discount =
    Math.max(quantity - product.discountThreshold) *
    product.basePrice *
    product.discountRate;

  return {basePrice, quantity, discount};
}

// 두 번째 단계를 처리 하는 함수
function applyShipping(priceData, shippingMethod) {
  const shippingPerCase =
    priceData.basePrice > shippingMethod.discountThreshold
      ? shippingMethod.discountFee
      : shippingMethod.feePerCase;
  const shippingCost = priceData.quantity * shippingPerCase;
  const price = priceData.basePrice - priceData.discount + shippingCost;

  return price;
}

최종 결과를 담은 상수 price도 정리하면 완료.

function priceOrder(product, quantity, shippingMethod) {
  const priceData = calculatePricingData(product, quantity);

  return applyShipping(priceData, shippingMethod);
}

// 첫 번째 단계를 처리 하는 함수
function calculatePricingData(product, quantity) {
  const basePrice = product.basePrice * quantity;
  const discount =
    Math.max(quantity - product.discountThreshold) *
    product.basePrice *
    product.discountRate;

  return {basePrice, quantity, discount};
}

// 두 번째 단계를 처리 하는 함수
function applyShipping(priceData, shippingMethod) {
  const shippingPerCase =
    priceData.basePrice > shippingMethod.discountThreshold
      ? shippingMethod.discountFee
      : shippingMethod.feePerCase;
  const shippingCost = priceData.quantity * shippingPerCase;

  return priceData.basePrice - priceData.discount + shippingCost;
}