DEV ℧ Developer Diary

[Refactoring] 냄새 16. 임시 필드

해당 포스트는 inflearn의 백기선님의 강의인 리팩토링 을 듣고 정리한 글입니다.

냄새 16. 임시 필드

  • 클래스에 있는 어떤 필드가 특정한 경우에만 값을 갖는 경우
  • 어떤 객체의 필드가 “특정한 경우에만” 값을 가진다는 것을 이해하는 것은 일반적으로 예상하지 못하기 때문에 이해하기 어렵다.
  • 관련 리팩토링
    • “클래스 추출하기 (Extract Class)”를 사용해 해당 변수들을 옮길 수 있다.
    • “함수 옮기기 (Move Function)”을 사용해서 해당 변수를 사용하는 함수를 특정 클래스로 옮길 수 있다.
    • “특이 케이스 추가하기 (Introduce Special Case)”를 적용해 “특정한 경우”에 해당하는 클래스를 만들어 해당 조건을 제거할 수 있다.

리팩토링 36. 특이 케이스 추가하기

  • 어떤 필드의 특정한 값에 따라 동일하게 동작하는 코드가 반복적으로 나타난다면, 해당 필드를 감싸는 “특별한 케이스”를 만들어 해당 조건을 표현할 수 있다.
  • 이러한 메커니즘을 “특이 케이스 패턴” 이라고 부르고 “Null Object 패턴”을 이러한 패턴의 특수한 형태라고 볼 수 있다.

아래의 코드 CustomerService 는 고객의 정보를 받아, 고객의 이름을 반환하거나, 결제 정보, 또는 연체 정보를 반환하는 클래스이다.

  • CustomerService
public class CustomerService {

    public String customerName(Site site) {
        Customer customer = site.getCustomer();

        String customerName;
        if (customer.getName().equals("unknown")) {
            customerName = "occupant";
        } else {
            customerName = customer.getName();
        }

        return customerName;
    }

    public BillingPlan billingPlan(Site site) {
        Customer customer = site.getCustomer();
        return customer.getName().equals("unknown") ? new BasicBillingPlan() : customer.getBillingPlan();
    }

    public int weeksDelinquent(Site site) {
        Customer customer = site.getCustomer();
        return customer.getName().equals("unknown") ? 0 : customer.getPaymentHistory().getWeeksDelinquentInLastYear();
    }
}

먼저 테스트 코드를 돌려보도록 하자.

리팩토링1

정상적으로 작동하는 것을 확인했다면 리팩토링을 시작해보도록 하자.

특이한 케이스의 경우를 따로 추출해 리팩토링을 진행해 보려고 한다.

여기서 특이한 케이스란 고객의 이름이 “unknown”으로 들어오는 경우이다. 세개의 메소드를 보면 “unknown”에 해당하는 조건이 있는것을 볼 수 있다.

if (customer.getName().equals("unknown")) {
    customerName = "occupant";
} else {
    customerName = customer.getName();
}

...

return customer.getName().equals("unknown") ? new BasicBillingPlan() : customer.getBillingPlan();

...

return customer.getName().equals("unknown") ? 0 : customer.getPaymentHistory().getWeeksDelinquentInLastYear();

먼저 UnknownCustomer를 위한 클래스를 따로 생성하고, 알수없는 고객에 대한 정보를 만들기 위해 Customer의 객체를 상속받는다.

  • Customer
public class Customer {

    private String name;

    private BillingPlan billingPlan;

    private PaymentHistory paymentHistory;

    public Customer(String name, BillingPlan billingPlan, PaymentHistory paymentHistory) {
        this.name = name;
        this.billingPlan = billingPlan;
        this.paymentHistory = paymentHistory;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public BillingPlan getBillingPlan() {
        return billingPlan;
    }

    public void setBillingPlan(BillingPlan billingPlan) {
        this.billingPlan = billingPlan;
    }

    public PaymentHistory getPaymentHistory() {
        return paymentHistory;
    }

    public void setPaymentHistory(PaymentHistory paymentHistory) {
        this.paymentHistory = paymentHistory;
    }
}

상속을 받았지만, UnknownCustomer의 정보는 이름을 unknown으로 구분한다 외에는 알 수 있는게 없으므로, 생성자를 따로 받지 않아도 된다.

아래와 같이 Customer에 unknown이라는 정보만 넘겨주도록 하자.

public class UnknownCustomer extends Customer{
    public UnknownCustomer() {
        super("unknown", null, null);
    }
}

이후 위의 CustomerService 클래스에서 중복되는 부분을 찾아 따로 메소드로 추출해준다. 여기서는 세번이나 반복된 unknown 조건문이 중복되는것을 알 수 있다.

private boolean isUnknown(Customer customer) {
    return customer.getName().equals("unknown");
}

이후 추출한 함수를 보면 Unknown인지 확인하는 기능 대한 책임이 어떤 클래스에 있는지 생각해 볼 수 있다.

로직을 보면 이름을 반환하거나, 결제 계획등을 반환하는 CustomerService 클래스보단, Customer 클래스에서 접속한 회원이 Unknown 판변하는 것이 더 맞는 것처럼 보인다.

이제 isUnknown 메소드를 Customer 클래스로 옮겨주도록 하자.

public class Customer {
  ...
  boolean isUnknown() {
    return getName().equals("unknown");
  }
}

하지만, 우리는 여기서 간과한 정보가 있다. Unknown 회원을 위한 클래스를 따로 분리했다는 것이다. 그렇다면 이제 Customer에는 무조건 회원 정보가 있는 객체만 들어오게 될 것이다.

Customer의 클래스에서는 무조건 name 정보가 있으므로, false를 반환한다.

그리고, UnknownCustomer 클래스는 Unknown에 대한 정보만 가지고 있으니 isUnknown 메소드를 @Override 하여 true를 반환하도록 한다.

public class Customer {
  ...
  boolean isUnknown() {
    return false;
  }
}

public class UnknownCustomer extends Customer{
  ...
  @Override
  boolean isUnknown() {
    return true;
  }
}

이제 Customer와 UnknownCustomer의 구분을 추가해줄 차례이다. 이부분은 Site 클래스에서 진행하고 있다. 아래의 클래스를 변경하여 UnknownCustomer의 구분을 추가해주도록 하자.

public class Site {
    private Customer customer;

    public Site(Customer customer) {
        this.customer = customer;
    }

    public Customer getCustomer() {
        return customer;
    }
}

위의 코드를 아래와 같이 변경하여, unknwon에 대한 구분을 추가해 주었다.

public class Site {

    private Customer customer;

    public Site(Customer customer) {
        this.customer = customer.getName().equals("unknown") ? new UnknownCustomer() : customer;
    }

    public Customer getCustomer() {
        return customer;
    }
}

이제 테스트코드를 작동하여, 정상적으로 실행하는지 확인해 보자.

정상 동작하는 것을 확인할 수 있다.

리팩토링2

이것으로 끝이 아니다.

UnknownCustomer에 대한 특이 케이스를 추출했으니, CustomerService의 로직을 간추릴 수 있다.

다시 한번 CustomerService 해당 로직을 정리해보자.

  1. 이름은 Customer의 name이 unknown일 경우 occupant를 반환한다.
  2. 결제정보는 Customer의 name이 unknown일 경우 new BasicBillingPlan()을 반환한다.
  3. 연체정보는 Customer의 name이 unknown일 경우 0을 반환한다.

이렇게 unknown에 대한 세가지의 로직을 UnknownCustomer로 이동하면, CustomerService의 unknown에 대한 조건식을 전부 간소화 할 수 있다.

먼저 이름과 결제정보를 UnknownCustomer이동시켜 보도록 하자.

이름의 경우 getName을 @Override하여 occupant을 반환하도록 하였고, 결제정보의 경우 new BasicBillingPlan() 부여하여, unknown일 경우 해당 객체를 반환하도록 하였다.

public class UnknownCustomer extends Customer{
    public UnknownCustomer() {
        super("unknown", new BasicBillingPlan(), null);
    }

    @Override
    boolean isUnknown() {
        return true;
    }

    @Override
    public String getName() {
        return "occupant";
    }
}

여기서 추가로 연체정보인 PaymentHistory에 대해서는 Null Object 패턴을 적용할 수 있는데,

말그대로 Null과 같은 정보를 가지고 있는 객체를 생성하는 것을 말한다.

연체 정보는 PaymentHistory 클래스의 weeksDelinquentInLastYear 변수를 반환한다.

그렇다면 UnknownCustomer일 경우는 0의 정보를 가지고 있는 PaymentHistory를 가지고 있으면 된다는 이야기다.

UnknownCustomer를 위한 NullPaymentHistory 클래스를 만들어준다.

NullPaymentHistory 클래스는 PaymentHistory를 상속받고, 0의 정보를 가지고 있도록 만들어 준다.

public class NullPaymentHistory extends PaymentHistory {
    public NullPaymentHistory(int weeksDelinquentInLastYear) {
        super(0);
    }
}

이후 UnknownCustomer에 해당 객체를 가지고있도록 생성자에 추가한다.

public class UnknownCustomer extends Customer{
    public UnknownCustomer() {
        super("unknown", new BasicBillingPlan(), new NullPaymentHistory());
    }
    ...
}

이렇게 unknown에 특수 케이스를 전부 UnknownCustomer로 옮겨준다면, CustomerService에서는 Customer의 정보만 조회하면 된다.

public class CustomerService {
    public String customerName(Site site) {
        return site.getCustomer().getName();
    }

    public BillingPlan billingPlan(Site site) {
        return site.getCustomer().getBillingPlan();
    }

    public int weeksDelinquent(Site site) {
        return site.getCustomer().getPaymentHistory().getWeeksDelinquentInLastYear();
    }
}

다시 한번 테스트 코드를 돌려 정상작동을 확인해 보자.

리팩토링3