6.9 여러 함수를 클래스로 묶기
목표
as-is
function base(aReading) {...}
function taxableCharge(aReading) {...}
function calculateBaseCharge(aReading) {...}
to-be
class Reading {
base(){...}
taxableCharge() {...}
calculateBaseCharge() {...}
}
배경
- 클래스는 데이터와 함수를 하나의 공유 환경으로 묶은후, 다른 프로그램 요소와 어우러질 수 있도록 그중 일부를 외부에 제공한다.
- 클래스는 객체 지향 언어의 기본인 동시에 다른 패러다임 언어에도 유용하다.
여러 함수를 클래스로 묶기의 장점
- 클래스로 묶으면 이 함수들이 공유하는 공통 환경을 더 명확하게 표현할 수 있다.
- 각 함수에 전달되는 인수를 줄여서 객체 안에서의 함수 호출을 간결하게 만들 수 있다.
- 객체를 시스템의 다른 부분에 전달하기 위한 참조를 제공할 수 있다.
- 클라이언트가 객체의 핵심 데이터를 변경할 수 있고, 파생 객체들을 일관되게 관리할 수 있다.
함수를 객체처럼 패턴
- 클래스를 지원하지 않는 언어를 사용할때 사용되는 패턴
- 일급 함수와 클로저 지원 필요 (일급 함수와 클로저를 이용하여 구성) →
*(**Module Pattern 같다…)*** - https://martinfowler.com/bliki/FunctionAsObject.html
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();
절차
-
- 함수들이 공유하는 공통 데이터 레코드를 캡슐화 한다.
- 공통 데이터가 레코드 구조로 묶여 있지 않다면 사전 작업으로 매개변수 객체 만들기 진행(6.8절)
-
- 공통 레코드를 사용하는 함수 각각을 새 클래스로 옮긴다 (함수 옮기기 8.1절)
- 공통 레코드의 맴버는 함수 호출문의 인수 목록에서 제거
-
데이터를 조작하는 로직들은 함수로 추출해서(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절)과 비교
- 원본 데이터가 코드에서 갱신될 경우 클래스로 묶는 방법이 좋다.
변환 함수로 묶으면 가공한 데이터를 새로운 레코드에 저장한다. 이는 원본 데이터가 수정되면 일관성이 깨질 수 있다. - 여러 함수를 변환 함수로 묶는 이유는 도출 로직의 중복을 피하기 위함이다.
절차
-
- 변환할 레코드를 입력받아서 값을 그대로 반환하는 변환 함수를 만든다.
- 이 작업은 대체로 깊은 복사로 처리한다. 변환 함수가 원본 레코드를 바꾸지 않는지 검사하는 테스트를 마련해 두면 도움이 된다.
-
묶을 함수 중 함수 하나를 골라서 본문 코드를 변환 함수로 옮기고, 처리 결과를 레코드에 새 필드로 기록한다. 그런 다음 클라이언트 코드가 이 필드를 사용하도록 수정한다. (로직이 복잡하면 함수 추출하기(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));
중복 코드는 나중에 로직 수정에 골치 동반할 수 있다. 중복 코드라면 함수 추출하기 (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];
}
배경
서로 다른 두 대상을 한꺼번에 다루는 코드는 각각을 별개 모듈로 나누면 좋다.
- 코드를 수정해야 할 때 두 대상을 동시에 고민할 필요가 사라진다.
- 모듈이 잘 분리되어 있다면, 다른 모듈의 상세 내용은 몰라도 상관없다.
- 가장 대표적인 예는 컴파일러
- 컴파일러는 텍스트 토큰화, 토큰 파싱 등 여러 단계를 거친 다음 마지막으로 목적 코드 생성
절차
- 두 번째 단계에 해당하는 코드를 독립 함수로 추출한다.
- 테스트한다.
- 중간 데이터 구조를 만들어서 앞에서 추출한 함수의 임수로 추가한다.
- 테스트한다.
- 추출한 두 번째 단계 함수의 매개변수를 하나씩 검토한다. 그중 첫 번째 단계에서 사용되는 것은 중간 데이터 구조로 옮긴다. 하나씩 올길 때마다 테스트한다.
- 간혹 두 번째 단계에서 사용하면 안 되는 매개변수가 있다. 이럴 때는 각 매개변수를 사용한 결과를 중간 데이터 구조의 필드로 추출하고, 이 필드의 값을 설정하는 문장을 호출한 곳으로 옮긴다.(8.4절)
- 첫 번째 단계 코드를 함수로 추출(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;
}