DEV ℧ Developer Diary

[Refactoring] 냄새 7. 뒤엉킨 변경

해당 포스트는 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를 제거해 변수명을 바꿔주도록 하자. officeAreaCodeareaCode, officeNumbernumber의 필드로 필드명을 바꿔주었다. 그에 맞춰 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;
    }
}