특이 케이스 패턴(Special Case Pattern)

Jeonghwa·2024년 1월 21일
0

특이 케이스 패턴은 클린코드 7장에 소개된 패턴입니다. Null을 반환하는 대신, 예외를 던지거나 특수 사례 객체를 반환하라는 문구가 나오는데 리팩토링책에 이 특수 사례 객체가 자세히 소개되어있다하여 이를 정리해보려합니다.

리팩토링 책의 예시는 javascript로 되어있지만 이를 java로 변환하여 작성하였습니다. 코드는 github에 올려놨습니다.

배경

어플리케이션 개발 시 데이터 구조(클래스)의 특정 값을 확인 후 똑같은 동작을 수행하는 코드가 곳곳에 등장하는 경우가 많습니다. 이는 중복이기 때문에 동작을 한곳에 모으는게 효율적입니다.

특이 케이스 패턴 또한 공통 동작을 요소 하나에 모아서 사용하는 패턴입니다. 특이 케이스 클래스를 만들거나 객체를 조작하여 예외적인 상황을 하나에 모아 캡슐화하고, 특이 케이스인지 확인하는 코드 대부분을 단순한 함수 호출로 바꿀 수 있습니다.

주로 참조 변수가 Null인 경우 특이 케이스로 처리해야할 때가 많기 때문에 이 패턴을 널 객체 패턴(Null Object Pattern)이라고도 합니다.

절차

여기서 컨테이너는 속성을 담은 데이터 구조 (혹은 클래스)를 말합니다.

  1. 컨테이너에 특이 케이스인지 검사하는 속성을 추가하고, false를 반환하게 한다.

  2. 특이 케이스 객체를 만든다. 이 객체는 특이 케이스인지를 검사하는 속성만 포함하며, 이 속성은 true를 반환하게한다.

  3. 클라이언트에서 특이 케이스인지를 검사하는 코드를 함수로 추출한다. 모든 클라이언트가 값을 직접 비교하는 대신 방금 추출한 함수를 사용하도록 고친다.

  4. 코드에 새로운 특이 케이스 대상을 추가한다. 함수의 반환 값으로 받거나 변환 함수를 적용하면 된다.

  5. 특이 케이스를 검사하는 함수 본문을 수정하여 특이 케이스 객체의 속성을 사용하도록 한다.

  6. 테스트 한다.

  7. 여러 함수를 클래스로 묶거나 변환 함수로 묶어서 특이케이스를 처리하는 공통 동작을 새로운 요소로 옮긴다.

  8. 아직도 특이 케이스 검사 함수를 이용하는 곳이 남아 있다면 검사 함수를 인라인 한다.

예시

Customer class

@NoArgsConstructor
@AllArgsConstructor
public class Customer {

    private String name; // 이름
    private BillingPlans billingPlans; // 청구계획
    private PaymentHistory paymentHistory; // 납부내역
    
    public String getName() {
        return name;
    }

    public BillingPlans getBillingPlans() {
        return billingPlans;
    }

	// 청구계획 변경 가능
    public void setBillingPlans(BillingPlans billingPlans) {
        this.billingPlans = billingPlans;
    }

    public PaymentHistory getPaymentHistory() {
        return paymentHistory;
    }
}

BillingPlans enum

public enum BillingPlans {

    FREE, LITE, BASIC, PREMIUM
}

PaymentHistory class

@NoArgsConstructor
@AllArgsConstructor
public class PaymentHistory {

    private int weekDelinquentInLastYear; // 연체된주(작년)
    
    public int getWeekDelinquentInLastYear() {
        return weekDelinquentInLastYear;
    }
}

Site class

@NoArgsConstructor
@AllArgsConstructor
public class Site {

    private Customer customer;
    
    public Customer getCustomer() {
        return this.customer;
    }
}

Client calss

public class Client {

    public static void main(String[] args) {
        Site site = new Site();
        Customer aCustomer = site.getCustomer();

        // 클라이언트 1
        String customerName;
        if (aCustomer == null) {
            customerName = "거주자";
        } else {
            customerName = aCustomer.getName();
        }

        // 클라이언트 2
        BillingPlans plan = aCustomer == null ?
            BillingPlans.BASIC
            : aCustomer.getBillingPlans();

        // 클라이언트 3
        BillingPlans newPlan = BillingPlans.BASIC;
        if (aCustomer == null) {
            aCustomer.setBillingPlans(newPlan);
        }

        // 클라이언트4
        int weeksDelinquent = aCustomer == null ?
            0
            : aCustomer.getPaymentHistory().getWeekDelinquentInLastYear();
    }
}

클라이언트 코드를 보면, 미확인 고객일 경우 대부분 비슷하게 처리한걸 볼 수 있습니다.

  • 이름 : "거주자"
  • 청구계획 : BASIC
  • 연체된주 : 0주

많은 곳에서 이루어지는 특이 케이스 검사와 공통된 처리가 특이 케이스 객체를 도입할 때임을 알려줍니다.

1. 컨테이너에 특이 케이스인지 검사하는 속성을 추가하고, false를 반환하게 한다.

Customer 클래스에 특이 케이스 체크 메서드(isUnKnown)를 추가하고, false를 반환하게 합니다.

@NoArgsConstructor
@AllArgsConstructor
public class Customer {
    ...

    public boolean isUnKnown() {
        return false;
    }
}

2. 특이 케이스 객체를 만든다. 이 객체는 특이 케이스인지를 검사하는 속성만 포함하며, 이 속성은 true를 반환하게한다.

특이 케이스 객체(UnknownCustomer) 클래스를 만들고 Customer 클래스를 상속하게 합니다. 그리고 isUnKnown함수를 오버라이드하여 true를 반환하게 합니다. ➡️ 다형성 사용

public class UnknownCustomer extends Customer {

    @Override
    public boolean isUnKnown() {
        return true;
    }
}

3. 클라이언트에서 특이 케이스인지를 검사하는 코드를 함수로 추출한다. 모든 클라이언트가 값을 직접 비교하는 대신 방금 추출한 함수를 사용하도록 고친다.

한번에 기존코드를 새로운 함수 및 새로운 객체를 사용하도록 고치는 것은 아주 비효율적입니다. 이럴 때 저자는 여러 곳에서 똑같이 수정해야만 하는 코드를 별도 함수로 추출하는 기법을 사용합니다.

클라이언트 코드에서 특이 케이스 체크 로직을 별도의 공통 함수(isUnKnown)로 추출하고 해당 함수를 사용하도록 고칩니다,

public class Client {
	...
    
    public static boolean isUnknown(Customer customer){
		return customer == null;
	}
}

isUnknown 함수를 사용해 변경을 하나씩 적용해봅니다.

// 클라이언트 1
String customerName;
if (isUnknown(aCustomer)) {
    customerName = "거주자";
} else {
	customerName = aCustomer.getName();
}

// 클라이언트 2
BillingPlans plan = isUnknown(aCustomer) ?
	BillingPlans.BASIC
	: aCustomer.getBillingPlans();
    
// 클라이언트 3
BillingPlans newPlan = BillingPlans.BASIC;
if (isUnknown(aCustomer)) {
	aCustomer.setBillingPlans(newPlan);
}

// 클라이언트4
int weeksDelinquent = isUnknown(aCustomer) ?
	0
	: aCustomer.getPaymentHistory().getWeekDelinquentInLastYear();

4. 코드에 새로운 특이 케이스 대상을 추가한다. 함수의 반환 값으로 받거나 변환 함수를 적용하면 된다.

Site의 속성인 Customer가 특이 케이스일 경우, Site클래스가 특이 케이스 객체(UnknownCustomer)를 반환하도록 수정합니다.

@NoArgsConstructor
@AllArgsConstructor
public class Site {

    private Customer customer;

    public Customer getCustomer() {
        return (this.customer == null) ? new UnknownCustomer() : this.customer;
    }
}

5. 특이 케이스를 검사하는 함수 본문을 수정하여 특이 케이스 객체의 속성을 사용하도록 한다.

공통 함수를 수정하여, Customer 객체의 특수 케이스 체크 메서드(isUnKnown)를 사용하도록 변경합니다.
이 때 null을 체크하는 코드는 완전히 사라집니다.

public class Client {
	...
    
    public static boolean isUnknown(Customer customer){
		return customer.isUnKnown();
	}
}

6. 테스트한다.

모든 기능이 잘 동작하는지 테스트합니다.

스크린샷 2024-01-07 오후 5 35 53

7. 여러 함수를 클래스로 묶거나 변환 함수로 묶어서 특이케이스를 처리하는 공통 동작을 새로운 요소로 옮긴다.

이제 특이 케이스일 경우 체크하는 대신, 공통적인 기본값으로 대체하도록 리팩토링합니다.

이름 : "거주자"

미확인 객체는 이름 속성에 "거주자" 문자열을 기본값으로 반환하도록 설정합니다.

public class UnknownCustomer extends Customer {
	...
    
    @Override
    public String getName() {
        return "거주자";
    }
}

그러면 조건부 코드는 삭제가 가능합니다.

// 클라이언트 1
String customerName = aCustomer.getName();

청구계획 : BASIC

미확인 객체는 청구계획 속성에 BillingPlans.BASIC을 기본값으로 반환하도록 설정합니다. 이 때 겉보기 동작을 똑같게 만들어야 하기 때문에 setter호출 시 아무런 동작도 하지 않게 수정합니다.

public class UnknownCustomer extends Customer {
	...
  
    @Override
    public BillingPlans getBillingPlans() {
        return BillingPlans.BASIC;
    }
    
    @Override
    public void setBillingPlans(BillingPlans billingPlans) {
	    // 무시한다.
    }
}

그러면 클라이언트 코드의 조건부 코드 삭제가 가능합니다.

// 클라이언트 2
BillingPlans plan = aCustomer.getBillingPlans();

// 클라이언트 3
BillingPlans newPlan = BillingPlans.BASIC;
aCustomer.setBillingPlans(newPlan);

연체된주 : 0주

이는 값이 아닌 자신만의 속성을 갖는 객체를 반환하고 있습니다.

// 클라이언트4
int weeksDelinquent = isUnknown(aCustomer) ?
	0
	: aCustomer.getPaymentHistory().getWeekDelinquentInLastYear();

만약 특이 케이스 객체가 객체를 반환한다면, 그 역시 특이케이스여야 하는게 일반적입니다. 따라서 NullPaymentHistory 객체를 만들어주고 기본값인 0을 받환하도록합니다.

public class NullPaymentHistory extends PaymentHistory{

    @Override
    public int getWeekDelinquentInLastYear() {
        return 0;
    }
}
public class UnknownCustomer extends Customer {
	...

    @Override
    public PaymentHistory getPaymentHistory() {
        return new NullPaymentHistory();
    }
}
// 클라이언트4
int weeksDelinquent = aCustomer.getPaymentHistory().getWeekDelinquentInLastYear();

8. 아직도 특이 케이스 검사 함수를 이용하는 곳이 남아 있다면 검사 함수를 인라인 한다.

모든 특이 케이스 체크 로직을 제거했다면 클라이언트 코드에서 공통함수를 제거합니다.

public class Client {

    public static void main(String[] args) {
        Site site = new Site();
        Customer aCustomer = site.getCustomer();

        // 클라이언트 1
        String customerName = aCustomer.getName();

        // 클라이언트 2
        BillingPlans plan = aCustomer.getBillingPlans();

        // 클라이언트 3
        BillingPlans newPlan = BillingPlans.BASIC;
        aCustomer.setBillingPlans(newPlan);

        // 클라이언트4
        int weeksDelinquent = aCustomer.getPaymentHistory().getWeekDelinquentInLastYear();
    }
}

하지만, 특이 케이스로부터 다른 클라이언트들과는 다른 무언가를 원하는 독특한 클라이언트가 있을 수 있습니다.

String name = !isUnknown(aCustomer) ? aCustomer.getName() : "미확인 거주자";

이 경우 isUnknown 함수를 인라인 해주면 됩니다.

const name = aCustomer.isUnknown() ? "미확인 거주자" : aCustomer.getName();

느낀점

특이 케이스라 할지라도 공통된 작업이 필요하지 않으면 해당 패턴을 적용해 클래스를 하나 만들기보다 예외를 던지는게 훨씬 효율적인거같다는 생각이들었습니다. 그래도 클라이언트코드에서 객체의 상태를 체크하는게 아닌, 객체에게 메시지를 보내 체크하는 방식은 중복되는 코드도 줄이고 유지보수에도 좋아보입니다.

따라서 만약 실무에서 null을 대체할 수 있는 값이 없는 경우 코드를 작성한다면 아래와 같이 작성할 것 같습니다.

@NoArgsConstructor
@AllArgsConstructor
public class Site {

    private Customer customer;

    public Customer getCustomer() {
        if (this.customer == null) throw new UnknownCustomerException();
        return this.customer;
    }
}

참고 :
리팩토링 2판 10.5장
https://martinfowler.com/eaaCatalog/specialCase.html

profile
backend-developer🔥

0개의 댓글

관련 채용 정보