[Refactoring] 냄새 7. 뒤엉킨 변경
29 Oct 2022해당 포스트는 inflearn의 백기선님의 강의인 리팩토링 을 듣고 정리한 글입니다.
냄새 7. 뒤엉킨 변경
- 소프트웨어는 변경에 유연하게(soft) 대처할 수 있어야 한다. 응집도를 높히고 결합도를 낮춰야 한다.
- 어떤 한 모듈이 (함수 또는 클래스가) 여러가지 이유로 다양하게 변경되어야 하는 상황.
- 예) 새로운 결제 방식을 도입하거나, DB를 변경할 때 동일한 클래스에 여러 메소드를 수정해야 하는 경우.
- 서로 다른 문제는 서로 다른 모듈에서 해결해야 한다.
- 모듈이 책임이 분리되어 있을 수록 해당 문맥을더 잘 이해할 수 있으며 다른 문제는 신경쓰지 않아도 된다.
- 관련 리팩토링 기술
- “단계 쪼개기 (Split phase)“를 사용해 서로 다른 문맥의 코드를 분리할 수 있다.
- “함수 옮기기 (Move Function)“를 사용해 적절한 모듈로 함수를 옮길 수 있다.
- 여러가지 일이 하나의 함수에 모여 있다면 “함수 추출하기 (Extrat Function)”를 사용할 수 있다.
- 모듈이 클래스 단위라면 “클래스 추출하기 (Extract Class)“를 사용해 별도의 클래스로 분리할 수 있다.
리팩토링 24. 단계 쪼개기
- 서로 다른 일을 하는 코드를 각기 다른 모듈로 분리한다.
- 그래야 어떤 것을 변경해야 할 때, 그거과 관련있는 것만 신경쓸 수 있다.
- 여러 일을 하는 함수의 처리 과정을 각기 다른 단계로 구분할 수 있다.
- 예) 전처리 -> 주요 작업 -> 후처리
- 예) 컴파일러 : 텍스트 읽어오기 -> 실행 가능한 형태로 변경
- 서로 다른 데이터를 사용한다면 단계를 나누는데 있어 중요한 단서가 될 수 있다.
- 중간 데이터(intermediate Data)를 만들어 단계를 구분하고 매개변수를 줄이는데 활용할 수 있다.
예시를 살펴보자
- PriceOrder
public class PriceOrder {
public double priceOrder(Product product, int quantity, ShippingMethod shippingMethod) {
final double basePrice = product.basePrice() * quantity;
final double discount = Math.max(quantity - product.discountThreshold(), 0)
* product.basePrice() * product.discountRate();
final double shippingPerCase = (basePrice > shippingMethod.discountThreshold()) ?
shippingMethod.discountedFee() : shippingMethod.feePerCase();
final double shippingCost = quantity * shippingPerCase;
final double price = basePrice - discount + shippingCost;
return price;
}
}
- Product
public record Product(double basePrice, double discountThreshold, double discountRate) {
}
- ShippingMethod
public record ShippingMethod(double discountThreshold, double discountedFee, double feePerCase) {
}
PriceOrder
에서 주문의 상품수와, 상품 할인율을 받아 구매비용을 계산해서 반환하는 클래스이다.
해당 할인 및 주문의 경우의 수에 대한 테스트는 아래에서 진행해 볼수 있다.
class PriceOrderTest {
@Test
void priceOrder_discountedFee() {
PriceOrder priceOrder = new PriceOrder();
double price = priceOrder.priceOrder(new Product(10, 2, 0.5),
4,
new ShippingMethod(20, 1, 5));
// basePrice = 10 * 4 = 40
// discount = 2 * 10 * 0.5 = 10
// shippingPerCase = 1 (40 > 20)
// shippingCost = 4 * 1 = 4
// price = 40 - 10 + 4 = 34
assertEquals(34, price);
}
@Test
void priceOrder_feePerCase() {
PriceOrder priceOrder = new PriceOrder();
double price = priceOrder.priceOrder(new Product(10, 2, 0.5),
2,
new ShippingMethod(20, 1, 5));
// basePrice = 10 * 2 = 20
// discount = 0 * 10 * 0.5 = 0
// shippingPerCase = 5
// shippingCost = 2 * 5 = 10
// price = 20 - 0 + 10 = 30
assertEquals(30, price);
}
}
PriceOrder
에서는 두가지의 작업이 진행중인것을 확인할 수 있다. 할인율 계산과, 배송비 계산에 대한 것
먼저 배송비 계산에 대한 수식을 함수 추출하기를 이용해서 applySipping
라는 메소드로 분리를 해주자.
public class PriceOrder {
public double priceOrder(Product product, int quantity, ShippingMethod shippingMethod) {
final double basePrice = product.basePrice() * quantity;
final double discount = Math.max(quantity - product.discountThreshold(), 0)
* product.basePrice() * product.discountRate();
final double price = applySipping(quantity, shippingMethod, basePrice, discount);
return price;
}
private double applySipping(int quantity, ShippingMethod shippingMethod, double basePrice, double discount) {
final double shippingPerCase = (basePrice > shippingMethod.discountThreshold()) ?
shippingMethod.discountedFee() : shippingMethod.feePerCase();
final double shippingCost = quantity * shippingPerCase;
final double price = basePrice - discount + shippingCost;
return price;
}
}
여기서 applySipping
메소드로 들어가는 매개변수가 4가지나 되는것을 알 수있다. 이 경우에 위에서 언급된 중간 데이터 클래스를 만들어서 매개변수를 묶어 줄 수 있다.
자바 14버전이므로 record를 사용해서 중간 데이터 전달자를 만들어 보자.
전달해줄 데이터는 basePrice
, discount
, quantity
과 같이 금액 계산에 관련된 데이터를 묶어보자.
shippingMethod
데이터는 가격과는 성격이 다른 데이터로 판단했기 때문에 제외하기로 하였다.
그러면 applySipping
메소드에는 PriceData
레코드로 인해 3개의 매개변수를 줄여줄 수 있다.
public record PriceData(double basePrice, double discount, int quantity) {
}
public double priceOrder(Product product, int quantity, ShippingMethod shippingMethod) {
final double basePrice = product.basePrice() * quantity;
final double discount = Math.max(quantity - product.discountThreshold(), 0)
* product.basePrice() * product.discountRate();
final PriceData priceData = new PriceData(basePrice, discount, quantity);
final double price = applySipping(priceData, shippingMethod);
return price;
}
이제 배송비를 구하는 전단계인, 가격정보데이터인 PriceData
를 구하는 부분을 함수로 추출해주면, 두단계 모두를 가독성있게 정리 할 수 있을 것이다.
여기서 priceData를 매개변수로 넘겨줄때 인라인 리팩토링을 써서 코드량을 줄일 수 있겠지만, PriceData의 코드를 빼서 표기하는 것도 좋은 방법중 하나라고 볼 수 있기 때문에 따로 분리해서 코드를 작성 해줄 수 있다.
public class PriceOrder {
public double priceOrder(Product product, int quantity, ShippingMethod shippingMethod) {
final PriceData priceData = calculatePriceData(product, quantity);
return applySipping(priceData, shippingMethod);
}
private PriceData calculatePriceData(Product product, int quantity) {
final double basePrice = product.basePrice() * quantity;
final double discount = Math.max(quantity - product.discountThreshold(), 0)
* product.basePrice() * product.discountRate();
final PriceData priceData = new PriceData(basePrice, discount, quantity);
return priceData;
}
private double applySipping(PriceData priceData, ShippingMethod shippingMethod) {
final double shippingPerCase = (priceData.basePrice() > shippingMethod.discountThreshold()) ?
shippingMethod.discountedFee() : shippingMethod.feePerCase();
final double shippingCost = priceData.quantity() * shippingPerCase;
final double price = priceData.basePrice() - priceData.discount() + shippingCost;
return price;
}
}
리팩토링 25. 함수 옮기기
- 모듈화가 잘 된 소프트웨어는 최소한의 지식으로 프로그램을 변경할 수 있다.
- 관련있는 함수나 필드가 모여있어야 더 쉽게 찾고 이해할 수 있다.
- 하지만 관련있는 함수나 필드가 항상 고정적인 것은 아니기 때문에 때에 따라 옮겨야 할 필요가 있다.
- 함수를 옮겨야 하는 경우
- 해당 함수가 다른 문맥 (클래스)에 있는 데이터 (필드)를 더 많이 참조하는 경우.
- 해당 함수를 다른 클라이언트 (클래스)에서도 필요로 하는 경우. 공통적으로 사용하는 클래스로 빼낸 뒤 사용 할 수 있을 것이다.
- 함수를 옮겨갈 새로운 문맥 (클래스)이 필요한 경우에는 “여러 함수를 클래스로 묶기 (Combine Functions info CLass)” 또는 “클래스 추출하기 (Extract Class)”를 사용한다.
- 함수를 옮길 적당한 위치를 찾기가 어렵다면, 그대로 두어도 괜찮다. 언제든 나중에 옮길 수 있다. 추후에 해당 함수를 더 사용하는 곳이 생기면 그때 옮겨줘도 늦지 않다.
이제 example을 comfirm 해보십니다.
- Account
public class Account {
private int daysOverdrawn;
private AccountType type;
public Account(int daysOverdrawn, AccountType type) {
this.daysOverdrawn = daysOverdrawn;
this.type = type;
}
public double getBankCharge() {
double result = 4.5;
if (this.daysOverdrawn() > 0) {
result += this.overdraftCharge();
}
return result;
}
private int daysOverdrawn() {
return this.daysOverdrawn;
}
private double overdraftCharge() {
if (this.type.isPremium()) {
final int baseCharge = 10;
if (this.daysOverdrawn <= 7) {
return baseCharge;
} else {
return baseCharge + (this.daysOverdrawn - 7) * 0.85;
}
} else {
return this.daysOverdrawn * 1.75;
}
}
}
- AccountType
public class AccountType {
private boolean premium;
public AccountType(boolean premium) {
this.premium = premium;
}
public boolean isPremium() {
return this.premium;
}
}
Account
클래스에서는 계정의 납기일이 지날경우 AccountType
에서 프리미엄 유무를 확인하고, 추가요금 얼마나 부과해줄지 계산해서 반환한다.
Account
클래스에서 사용하는 메소드들을 구분지어, 어느 위치에 옮겨줄지는 크게 정답이 없다. 더 많이 사용하는 곳이 있으면 그곳으로 옮겨주는것이 합당 할 수 있다.
여기서는 this.type
변수를 사용하는 overdraftCharge
메소드를 AccountType
으로 옮겨주도록 하자. 실질적으로는 Account
에 있어도 이상하지 않을 메소드이다.
하지만 해당 메소드를 옮기게된다면 생기는 문제도 있다. overdraftCharge
메소드에서 사용하는 this.daysOverdrawn
의 값을 받아야한다.
this.daysOverdrawn
값은 Account
클래스에 존재하는데, 만약 해당 클래스에서 필요한 값이 많다면 Account
클래스 자체를 받아올 수 있을 것이다. 이경우에 주의할 점은 해당 메소드는 Account
클래스에 의존성이 생긴다는 것이다.
그러므로 메소드에서 사용하는 값은 this.daysOverdrawn
하나이기 때문에, int 타입의 매개변수만 추가해주도록 하자.
- Account
public class Account {
private int daysOverdrawn;
private AccountType type;
public Account(int daysOverdrawn, AccountType type) {
this.daysOverdrawn = daysOverdrawn;
this.type = type;
}
public double getBankCharge() {
double result = 4.5;
if (this.daysOverdrawn() > 0) {
result += this.type.overdraftCharge(this.daysOverdrawn());
}
return result;
}
private int daysOverdrawn() {
return this.daysOverdrawn;
}
}
- AccountType
public class AccountType {
private boolean premium;
public AccountType(boolean premium) {
this.premium = premium;
}
public boolean isPremium() {
return this.premium;
}
double overdraftCharge(int daysOverdrawn) {
if (isPremium()) {
final int baseCharge = 10;
if (daysOverdrawn <= 7) {
return baseCharge;
} else {
return baseCharge + (daysOverdrawn - 7) * 0.85;
}
} else {
return daysOverdrawn * 1.75;
}
}
}
이렇게 적절한 위치에 메소드를 옮겨준다면, 응집도를 높히고, 결합도를 낮추면서 프로그램의 모듈화를 잘 진행 할 수있게될 것이다.
리팩토링 26. 클래스 추출하기
- 클래스가 다루는 책임(Responsibility)이 많아질수록 클래스가 점차 커진다.
- 클래스를 쪼개는 기준
- 데이터가 메소드 중 일부가 매우 밀저한 관련이 있는 경우
- 일부 데이터가 대부분 같이 바뀌는 경우
- 데이터 또는 메소드 중 일부를 삭제한다면 어떻게 될 것인가?
- 하위 클래스를 만들어 책임을 분산 시킬 수도 있다.
예시
public class Person {
private String name;
private String officeAreaCode;
private String officeNumber;
public String telephoneNumber() {
return this.officeAreaCode + " " + this.officeNumber;
}
public String name() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String officeAreaCode() {
return officeAreaCode;
}
public void setOfficeAreaCode(String officeAreaCode) {
this.officeAreaCode = officeAreaCode;
}
public String officeNumber() {
return officeNumber;
}
public void setOfficeNumber(String officeNumber) {
this.officeNumber = officeNumber;
}
}
Person
클래스에는 사람의 클래스에 사람에 대한 정보들이 담겨져 있다.
이중 officeAreaCode
, officeNumber
정보는 Person
클래스에서 다루는것에서 확장해서 사무실 전용의 데이터로 추출할 수 있을 것이다.
해당 예제는 작은 케이스지만, 실무 단계에서는 더크고, 더 복잡한 변수와 메소드들이 얽혀 있을 수 있다. 그중 하나를 빼기 시작하다면 그와 연관되어 추출할 수 있는 변수들이 보이기 시작 할 것이다.
이제 다시 예제로 돌아가서 리팩토링을 진행해보도록 하자.
officeAreaCode
, officeNumber
정보를 추출해서 TelephoneNumber
라는 클래스로 추출해 주도록 하자.
public class TelephoneNumber {
String officeAreaCode;
String officeNumber;
public TelephoneNumber() {
}
public String officeAreaCode() {
return officeAreaCode;
}
public void setOfficeAreaCode(String officeAreaCode) {
this.officeAreaCode = officeAreaCode;
}
public String officeNumber() {
return officeNumber;
}
public void setOfficeNumber(String officeNumber) {
this.officeNumber = officeNumber;
}
}
Person
클래스에서는 officeAreaCode
, officeNumber
필드 대신에 TelephoneNumber
의 생성자를 받아 정보를 가져올 수 있다.
private TelephoneNumber telephoneNumber;
public Person(TelephoneNumber telephoneNumber, String name) {
this.telephoneNumber = telephoneNumber;
this.name = name;
}
여기서 두가지의 방법으로 TelephonePhone
데이터를 사용할 수 있다. 해당 클래스를 final로 선언해서 getter 및 setter를 TelephoneNumber
에서 관리하는 방법과, private로 선언 하여 Person
에서 getter, setter의 권한을 위임하는 방법 두가지로 설정 해 줄 수 있다.
private TelephoneNumber telephoneNumber;
private final TelephoneNumber telephoneNumber;
이중 final로 선언하여 getter, setter를 TelephoneNumber
클래스로 빼주기로 하자.
그리고 Person
클래스에 TelephoneNumber
의 getter를 만듬으로써 해당 객체를 사용할 수 있게 해주자.
public class Person {
private final TelephoneNumber telephoneNumber;
private String name;
public Person(TelephoneNumber telephoneNumber, String name) {
this.telephoneNumber = telephoneNumber;
this.name = name;
}
public String telephoneNumber() {
return this.telephoneNumber.officeAreaCode + " " + this.telephoneNumber.officeNumber;
}
public String name() {
return name;
}
public void setName(String name) {
this.name = name;
}
public TelephoneNumber getTelephoneNumber() {
return telephoneNumber;
}
}
여기서 클래스로 변수들을 추출해서 옮겨 줬다고 끝이 아니다.
officeAreaCode
, officeNumber
필드는 Person
의 클래스에 있었기 때문에 office 라는 prefix를 붙여 구분해줄 필요가 있었지만, TelephoneNumber
클래스에서는 office 통해 구분해줄 필요가 없을것으로 보인다.
따라서 prefix를 제거해 변수명을 바꿔주도록 하자.
officeAreaCode
는 areaCode
, officeNumber
는 number
의 필드로 필드명을 바꿔주었다. 그에 맞춰 getter 메소드도 변경을 해주어야 한다.
public class TelephoneNumber {
private String areaCode;
private String number;
public TelephoneNumber(String areaCode, String number) {
this.areaCode = areaCode;
this.number = number;
}
public String getAreaCode() {
return areaCode;
}
public void setAreaCode(String areaCode) {
this.areaCode = areaCode;
}
public String getNumber() {
return number;
}
public void setNumber(String number) {
this.number = number;
}
}
필드 및 메소드명을 모두 변경했다면, Person
에서 사용하는 부분도 고쳐주면 클래스로 추출이 완료된다.
public class Person {
private final TelephoneNumber telephoneNumber;
private String name;
public Person(TelephoneNumber telephoneNumber, String name) {
this.telephoneNumber = telephoneNumber;
this.name = name;
}
public String telephoneNumber() {
return this.telephoneNumber.getAreaCode() + " " + this.telephoneNumber.getNumber();
}
public String name() {
return name;
}
public void setName(String name) {
this.name = name;
}
public TelephoneNumber getTelephoneNumber() {
return telephoneNumber;
}
}