# 03-07. 뒤엉킨 변경
# ✋ Intro
- 소프트웨어의 구조를 변경하기 쉬운 형태로 조직해야한다.
- 코드를 수정할 때는 시스템에서 고쳐야 할 딱 한 군데를 찾아서 그 부분만 수정할 수 있기를 바란다.
- 이렇게 할 수 없다면 (서로 밀접한 악취인) '뒤엉킨 변경'과 '산탄총 수술' 중 하나가 풍긴다.
- '뒤엉킨 변경'은 **단일 책임 원칙(Single Responsibility Principle)**이 제대로 지켜지지 않을 때 나타난다.
- 즉, 하나의 모듈이 서로 다른 이유들로 인해 여러 가지 방식으로 변경되는 일이 많을 때 발생한다.
- 단일 책임 원칙은 '단일 모듈은 변경의 이유가 하나, 오직 하나여야만 한다'는 설계 원칙이다.
- 여기서 '변경의 이유'는 '단일 모듈은 오직 하나의 액터(actor)에 대해서만 책임져야 한다'는 의미로 이해해도 좋다.
- 액터란 시스템이 동일한 방식으로 변경되기를 원하는 사용자 집단을 말합니다. 즉 액터란 하나의 사용자가 될 수도 있고 여러 사용자가 모여서 하나의 액터가 될 수도 있다.
- 단일 책임 원칙의 자세한 내용은 여기 (opens new window)를 참고하자.
- 순차적으로 실행되는 게 자연스러운 맥락이라면, 다음 맥락에 필요한 데이터를 특정한 데이터 구조에 담아 전달하게 하는 식으로 단계를 분리하는 '단계 쪼개기' 기법을 적용하면 된다.
- 전체 처리 과정 곳곳에서 각기 다른 맥락의 함수를 호출하는 빈도가 높다면, 각 맥락에 해당하는 적당한 모듈들을 만들어서 관련 함수들을 모으는 '함수 옮기기' 기법을 적용한다.
- 이 때 여러 맥락의 일에 관여하는 함수가 있다면 옮기기 전에 '함수 추출하기'부터 수행한다.
- 모듈이 클래스라면 '클래스 추출하기' 기법으로 맥락별 분리 방법을 잘 안내해 줄 것이다.
# (1) 단계 쪼개기
- 주로 이 리팩토링 기법은 덩치가 큰 소프트웨어에 적용된다.
- 다른 단계로 볼 수 있는 코드 영역들이 마침 서로 다른 데이터와 함수를 사용한다면 단계 쪼개기에 적합하다.
- 이 코드 영역들을 별도 모듈로 분리하면 그 차이를 코드에서 훨씬 분명하게 드러낼 수 있다.
- 예시) 상품의 결재 금액을 계산하는 함수
// before
function priceOrder(product, quantity, shippingMethod) {
const basePrice = product.basePrice * quantity;
const discount = Math.max(quantity - product.discountThreshold, 0) * product.basePrice * product.discountRate;
const shippingPerCase = (basePrice > shippingMethod.discountThreshold) ? shippingMethod.discountedFee : shippingMethod.feePerCase;
const shippingCost = quantity * shippingPerCase;
const price = basePrice - discount + shippingCost;
return price;
}
// after
function priceOrder(product, quantity, shippingMethod) {
const priceData = calculatePricingData(product, quantity);
return applyShipping(priceDate, shippingMethod);
}
function calculatePricingData(product, quantity) {
const basePrice = product.basePrice * quantity;
const discount = Math.max(quantity - product.discountThreshold, 0) * product.basePrice * product.discountRate;
return { basePrice, quantity, discount };
}
function applyShipping(priceData, shippingMethod) {
const shippingPerCase = (priceData.basePrice > shippingMethod.discountThreshold) ? shippingMethod.discountedFee : shippingMethod.feePerCase;
const shippingCost = priceData.quantity * shippingPerCase;
return priceData.basePrice - priceData.discount + shippingCost;
}
// 직접 리팩토링한 코드
function calcPrice({ basePrice, discount, shippingCost }) {
return basePrice - discount + shippingCost;
}
function calcShippingCost({ basePrice, quantity, shippingMethod }) {
const shippingPerCase = (basePrice > shippingMethod.discountThreshold) ? shippingMethod.discountedFee : shippingMethod.feePerCase;
return quantity * shippingPerCase;
}
function basePriceData({ product, quantity }) {
return {
basePrice: product.basePrice * quantity,
discount: Math.max(quantity - product.discountThreshold, 0) * product.basePrice * product.discountRate,
}
}
function priceOrder({ product, quantity, shippingMethod }) {
const { basePrice, discount } = basePriceData({ product, quantity });
const shippingCost = calcShippingCost({ basePrice, quantity, shippingMethod });
return calcPrice({ basePrice, discount, shippingCost });
}
# ➕ 험블 객체 패턴(Humble Object Pattern)
- 디자인 패턴 중의 하나로 테스트 하기 어려운 행위와 쉬운 행위를 분리하는 방법이다.
- 두 개의 모듈 또는 클래스로 분리 하되 테스트하기 어려운 행위를 한 곳으로 옮기는데 이것이 험블 객체가 된다.
- 험블 객체에 속하지 않은 테스트하기 쉬운 나머지 행위는 다른 하나의 객체로 옮긴다.
- 아키텍처 경계마다 험블 객체 패턴을 발견할 수 있는데 그 경계를 테스트하기 어려운 것과 쉬운 것으로 분리하는 험블 객체 패턴을 적용하면 테스트 용이성도 크게 높아질 수 있다.
- react hooks에서 험블 객체 패턴을 적용한 예시는 여기 (opens new window)를 참고하면 좋다.
# (2) 함수 옮기기
- **모듈성(modularity)**이란 프로그램의 어딘가를 수정하려 할 때 해당 기능과 깊이 관련된 작은 일부만 이해해도 가능하게 해주는 능력을 말한다.
- 모듈성을 높이려면 서로 연관된 요소들을 함께 묶고, 요소 사이의 연결 관계를 쉽게 찾고 이해할 수 있도록 해야 한다.
- 어떤 함수가 자신이 속한 모듈 A의 요소들보다 다른 모듈 B의 요소들을 더 많이 참조한다면 모듈 B로 옮겨줘야 마땅하다.
- 이렇게 하면 캡슐화가 좋아져서, 이 소프트웨어의 나머지 부분은 모듈 B의 세부사항에 덜 의존하게 된다.
- 함수를 옮길지 말지를 정하기란 쉽지 않다. 그럴 땐 대상 함수의 현재 컨텍스트와 후보 컨텍스트를 둘러보면 도움이 된다.
- 대상 함수를 호출하는 함수들은 무엇인지, 대상 함수가 호출하는 함수들은 또 무엇이 있는지, 대상 함수가 사용하는 데이터는 무엇인지 살펴봐야 한다.
- 예시1) 중첩 함수를 최상위로 옮기기
// before
function trackSummary(points) {
const totalTime = calculateTime();
const totalDistance = calculateDistance();
const pace = totalTime / 60 / totalDistance;
return {
time: totalTime,
distance: totalDistance,
pace,
};
// 총 거리 계산
function calculateDistance() {
let result = 0;
for (let i = 1; i < points.length; i++) {
result += distance(points[i - 1], points[i]);
}
return result;
}
// 두 지점의 거리 계산
function distance(p1, p2) {
const EARTH_RADIUS = 3959;
const dLat = radians(p2.lat) - radians(p1.lat);
const dLon = radians(p2.lon) - radians(p1.lon);
const a = Math.pow(Math.sin(dLat / 2), 2) + Math.cos(radians(p2.lat)) * Math.cos(radians(p1.lat)) * Math.pow(Math.sin(dLon / 2), 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS * c;
}
// 라디안 값으로 변환
function radians(degrees) {
return degrees * Math.PI / 100;
}
function calculateTime() { ... } // 총 시간 계산
}
// after
function trackSummary(points) {
const totalTime = calculateTime();
const pace = totalTime / 60 / totalDistance(points);
return {
time: totalTime,
distance: totalDistance(points),
pace,
};
function calculateTime() { ... } // 총 시간 계산
}
// 총 거리 계산
function totalDistance(points) {
let result = 0;
for (let i = 1; i < points.length; i++) {
result += distance(points[i - 1], points[i]);
}
return result;
}
// 두 지점의 거리 계산
function distance(p1, p2) {
const EARTH_RADIUS = 3959;
const dLat = radians(p2.lat) - radians(p1.lat);
const dLon = radians(p2.lon) - radians(p1.lon);
const a = Math.pow(Math.sin(dLat / 2), 2) + Math.cos(radians(p2.lat)) * Math.cos(radians(p1.lat)) * Math.pow(Math.sin(dLon / 2), 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS * c;
}
// 라디안 값으로 변환
function radians(degrees) {
return degrees * Math.PI / 100;
}
- 예시2) 다른 클래스로 옮기기
// before
class Account {
get bankCharge() { // 은행 이자 계산
let result = 4.5;
if (this._daysOverdrawn > 0) {
result += this.overdraftCharge;
}
return result;
}
get overdraftCharge() { // 초과 인출 이자 계산
if (this.type.isPremium) {
const baseCharge = 10;
if (this.daysOverdrawn <= 7) {
return baseCharge;
} else {
return baseCharge + (this.daysOverdrawn - 7) * 0.85;
}
} else {
return this.daysOverdrawn * 1.75;
}
}
}
// after
// 계좌 종류에 따라 이자 책정 알고리즘이 달라지도록 AccountType이라는 다른 클래스를 두는 방식으로 수정
// cf) 만약 소스 컨텍스트에서 가져와야 할 데이터가 많다면 매개변수로 this 자체를 넘길 수도 있다.
class Account {
get bankCharge() {
let result = 4.5;
if (this._daysOverdrawn > 0) {
result += this.type.overdraftCharge(this.daysOverdrawn);
}
return result;
}
get overdraftCharge() { // 위임 메서드
return this.type.overdraftCharge(this.daysOverdrawn);
}
}
class AccountType {
overdraftCharge(daysOverdrawn) {
if (this.isPremium) {
const baseCharge = 10;
if (daysOverdrawn <= 7) {
return baseCharge;
} else {
return baseCharge + (daysOverdrawn - 7) * 0.85;
}
} else {
return daysOverdrawn * 1.75;
}
}
}
# (3) 클래스 추출하기
- 메서드와 데이터가 너무 많은 클래스는 이해하기가 쉽지 않으니 잘 살펴보고 적절히 분리하는 것이 좋다.
- 특히 일부 데이터와 메서드를 따로 묶을 수 있다면 어서 분리하라는 신호다.
- 함께 변경되는 일이 많거나 서로 의존하는 데이터들도 분리한다.
- 예시)
Person
클래스에서 전화번호 관련 동작을 별도 클래스로 추출하기
// before
class Person {
get name() {
return this._name;
}
set name(arg) {
this._name = arg;
}
get telephoneNumber() {
return `${this._officeAreaCode} ${this.officeNumber}`;
}
get officeAreaCode() {
return this._officeAreaCode;
}
set officeAreaCode(arg) {
this._officeAreaCode = arg;
}
get officeNumber() {
return this._officeNumber;
}
set officeNumber(arg) {
this._officeNumber = arg;
}
}
// after
class Person {
constructor() {
this._telephoneNumber = new TelephoneNumber();
}
get officeAreaCode() {
return this._telephoneNumber.areaCode;
}
set officeAreaCode(arg) {
this._telephoneNumber.areaCode = arg;
}
get officeNumber() {
return this._telephoneNumber.number;
}
set officeNumber(arg) {
this._telephoneNumber.number = arg;
}
get telephoneNumber() {
return this._telephoneNumber.toString();
}
}
class TelephoneNumber {
get areaCode() {
return this._areaCode;
}
set areaCode(arg) {
this._areaCode = arg;
}
get number() {
return this._number;
}
set number(arg) {
this._number = arg;
}
toString() {
return `${this.areaCode} ${this.number}`;
}
}
- 전화번호는 여러모로 쓸모가 많으니 이 클래스는 클라이언트에게 공개하는 것이 좋다.
- 그러면 'office'로 시작하는 메서드들을 없애고
TelephoneNumber
의 접근자를 바로 사용하도록 바꿀 수 있다. - 그런데 기왕 이렇게 쓸 거라면 전화번호를 값 객체(VO)로 만드는 게 나으니 참조를 값으로 바꾸기 기법을 적용하면 된다.
- 그러면 'office'로 시작하는 메서드들을 없애고