관리 메뉴

너와 나의 스토리

[리팩터링] 특이 케이스 추가하기 Introduce Special Case 본문

개발/Refactoring

[리팩터링] 특이 케이스 추가하기 Introduce Special Case

노는게제일좋아! 2022. 1. 2. 17:23
반응형

배경

  • 고객 리스트가 있다고 할 때, 이름과 같은 정보가 적혀있지 않은 미확인 고객이 있을 수 있다. 이런 특이 케이스를 검사하는 코드가 있다고 생각하자.
  • 데이터 구조의 특정 값을 확인한 후 똑같은 동작을 수행하는 코드가 곳곳에 등장하는 경우가 더러 있다.
    • 흔히 볼 수 있는 중복 코드 중 하나다.
  • 특수한 경우의 공통 동작을 요소 하나에 모암서 사용하는 특이 케이스 패턴(Special Case Pattern)이라는 것이 있다.
    • 위와 같은 경우에 적용하면 좋을 메커니즘이다.
    • 이 패턴을 활용하면 특이 케이스를 확인하는 코드 대부분을 단순한 함수 호출로 바꿀 수 있다.
  • 널(null)은 특이 케이스로 처리해야 할 때가 많다. 그래서 이 패턴을 널 객체 패턴(Null Object Pattern)이라고도 한다. 

 

절차

  1. 컨테이너에 특이 케이스인지를 검사하는 속성을 추가하고, false를 반환하게 한다.
  2. 특이 케이스 객체를 만든다. 이 객체는 특이 케이스인지를 검사하는 속성만 포함하며, 이 속성은 true를 반환하게 한다.
  3. 클라이언트에서 특이 케이스인지를 검사하는 코드를 함수로 추출한다. 모든 클라이언트가 값을 직접 비교하는 대신 방금 추출한 함수를 사용하도록 고친다.
  4. 코드에 새로운 특이 케이스 대상을 추가한다. 함수의 반환 값으로 받거나 변환 함수를 적용하면 된다.
  5. 특이 케이스를 검사하는 함수 본문을 수정하여 특이 케이스 객체의 속성을 사용하도록 한다.
  6. 테스트한다.
  7. 여러 함수를 클래스로 묶기나 여러 함수를 변환 함수로 묶기를 적용하여 특이 케이스를 처리하는 공통 동작을 새로운 요소로 옮긴다.
  8. 아직도 특이 케이스 검사 함수를 이용하는 곳이 남아 있다면 검사 함수를 인라인 한다.

 

예시 1

  • 전력 회사는 전력이 필요한 현장(site)에 인프라를 설치해 서비스를 제공한다.
  • class Site {
        get customer() { return this._customer; }
    }
    
    class Customer {
        get name() {...}
        get billingPlan() {...}
        set billingPlan(arg) {...}
        get paymentHistory() {...}
    }
  • 기존의 누군가 이사 가고 새로운 누군가(아직 누구인지 모름)가 이사 온 경우, 데이터 레코드의 고객 필드를 "미확인 고객"이라는 문자열로 채운다.
    • 이런 상황을 감안하여 Site 클래스를 사용하는 클라이언트 코드들은 알려지지 않은 미확인 고객도 처리할 수 있어야 한다.
    • 이런 코드의 예는 다음과 같다.
    • // 클라이언트 1
      const aCustomer = site.customer;
      ...
      let customerName;
      if (aCustomer === "미확인 고객") customerName = "거주자";
      else customerName = aCustomer.name;
      
      // 클라이언트 2
      const plan = (aCustomer === "미확인 고객") ? 
          CustomElementRegistry.billingPlans.basic : acustomer.billingPlan;
      
      // 클라이언트 3
      if (aCustomer === "미확인 고객") aCustomer.billingPlan = newPlan;
      
      // 클라이언트 4
      const weeksDelinquent = (aCustomer === "미확인 고객") ?
          0 : aCustomer.paymentHistory.weeksDelinquentInLastyear;
    • * delinquent: 채무를 이행하지 않은
    • 코드 베이스를 훑어보니 미확인 고객을 처리해야 하는 클라이언트가 여러 개 발견됐고, 그 대부분에서 똑같은 방식으로 처리했다.
    • 미확인 고객
      • 고객 이름: "거주자"
      • 기본 요금제(billing plan) 청구
    • 많은 곳에서 이뤄지는 이 특이 케이스 검사와 공통된 반응이 우리에게 특이 케이스 객체를 도입할 때임을 말해준다.
  • Step 1: 미확인 고객인지를 나타내는 메서드를 고객 클래스에 추가한다.
    • class Customer {
          ...
          get isUnknown() { return false; }
      }
  • Step 2: 미확인 고객 전용 클래스를 만든다.
    • class UnknownCustomer {
          get isUnknown() { return true; }
      }
  • Step 3: 클라이언트에서 특이 케이스(UnknownCustomer)인지를 검사하는 코드를 함수로 추출하고 검사하는 곳에서 이를 사용하도록 고쳐야 한다.
    • function isUnknown(arg) {
          if (!((arg instanceof Customer || (arg == "미확인 고객"))))
              throw new Error(`잘못된 값과 비교: <${arg}>`);
          return (arg === "미확인 고객");
      }
      
      // 클라이언트 1
      const aCustomer = site.customer;
      ...
      let customerName;
      if(!isUnknown(aCustomer)) customerName = "거주자";
      else customerName = aCustomer.name;
      
      // 클라이언트 2
      const plan = isUnknown(aCustomer) ? CustomElementRegistry.billingPlans.basic : acustomer.billingPlan;
      
      // 클라이언트 3
      if(!isUnknown(aCustomer)) aCustomer.billingPlan = newPlan;
      
      // 클라이언트 4
      const weeksDelinquent = isUnknown(aCustomer) ?
          0 : aCustomer.paymentHistory.weeksDelinquentInLastyear;
  • Step 4: 특이 케이스일 때(미확인 고객일 때) Site 클래스가 UnknownCustomer 객체를 반환하도록 수정하자.
    • class Site {
          // Before: get customer() { return this._customer; }
          get customer() {
              return (this._customer === "미확인 고객") ? new UnknownCustomer() : this._customer;
          }
      }
  • Step 5: isUnknown() 함수를 수정하여 고객 객체의 속성을 사용하도록 하면 "미확인 고객" 문자열을 사용하던 코드는 완전히 사라진다.
    • function isUnknown(arg) {
          if (!((arg instanceof Customer || arg instanceof UnknownCustomer)))
              throw new Error(`잘못된 값과 비교: <${arg}>`);
          return arg.isUnknown;
      }
  • Step 6: 모든 기능이 잘 동작하는지 테스트
  • Step 7: 각 클라이언트에서 수행하는 특이 케이스 검사를 일반적인 기본값으로 대체할 수 있다면 이 검사 코드에 여러 함수를 클래스로 묶기를 적용할 수 있다. 
    • 지금 예에서는 미확인 고객의 이름으로 "거주자"를 사용하는 코드가 많다. 
    • // 클라이언트 1
      let customerName;
      if (aCustomer === "미확인 고객") customerName = "거주자";
      else customerName = aCustomer.name;
    • 다음과 같이 적절한 메서드를 Unknowncustomer 클래스에 추가하자.
    • class UnknownCustomer {
          ...
          get name() { return "거주자"; }
      }
    • 그러면 조건부 코드는 지워도 된다.
    • const customerName = aCustomer.name;
    • 요금제 속성을 처리하는 부분(클라이언트 1, 2)도 위 과정을 반복하면 된다.
    • // 클라이언트 2
      const plan = isUnknown(aCustomer) ? 
      	CustomElementRegistry.billingPlans.basic : aCustomer.billingPlan;
      
      // 클라이언트 3
      if(!isUnknown(aCustomer)) aCustomer.billingPlan = newPlan;
    • 현재 코드에서는 미확인 고객에 대해서는 세터를 호출하지 않지만 겉보기 동작을 똑같게 만들기 위해 세터를 만들어 준다. 
    • class UnknownCustomer {
          ...
          get billingPlan() { return CustomElementRegistry.billingPlans.basic; }
          set billingPlan(arg) {/* 무시 */ }
      }
      
      // 클라이언트 2
      const plan = aCustomer.billingPlan;
      
      // 클라이언트 3
      aCustomer.billingPlan = newPlan;
    • 특이 케이스 객체는 값 객체다. 따라서 항상 불변이어야 한다. 대체하려는 값이 가변이더라도 마찬가지이다.
    • 마지막 상황은 좀 더 복잡하다. 특이 케이스가 자신만의 속성을 갖는 또 다른 객체(Payment History)를 반환해야 하기 때문이다.
    • // 클라이언트 4
      const weeksDelinquent = isUnknown(aCustomer) ?
          0 : aCustomer.paymentHistory.weeksDelinquentInLastyear;
    • 특이 케이스 객체가 다른 객체를 반환해야 한다면 그 객체 역시 특이 케이스여야 하는 것이 일반적이다. 
      • 그래서 NullPaymentHistory를 만들기로 했다.
    • class UnknownCustomer {
          ...
          get paymentHistory() { return new NullPaymentHistory(); }
      }
      
      class NullPaymentHistory {
          get weeksDelinquentInLastyear() { return 0; }
      }
      
      // 클라이언트
      const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastyear;
    • 이런 식으로 계속해서 특이 케이스 검사 함수를 이용하는 곳이 남아 있다면 검사 함수를 인라인 해준다.
    • 특이 케이스로부터 다른 클라이언트들과는 다른 무언가를 원하는 독특한 클라이언트가 있을 수 있다.
    • // 튀는 클라이언트
      const name = !isUnknown(aCustomer) ? aCustomer.name : "미확인 거주자";
    • isUnknown() 함수를 인라인 해주면 된다.
    • const name = aCustomer.isUnknown ? "미확인 거주자" : aCustomer.name;

 

예시 2: 객체 리터럴 이용하기

  • 앞의 예처럼 단순한 값을 위해 클래스까지 동원하는 건 좀 과한 감이 있다.
  • 하지만 고객 정보가 갱신될 수 있어서 클래스가 꼭 필요했다. 
  • class Site {
        get customer() { return this._customer; }
    }
    
    class Customer {
        get name() {...}
        get billingPlan() {...}
        set billingPlan(arg) {...}
        get paymentHistory() {...}
    }
    
    // 클라이언트 1
    const aCustomer = site.customer;
    // ...
    let customerName;
    if (aCustomer === "미확인 고객") customerName = "거주자";
    else customerName = aCustomer.name;
    
    // 클라이언트 2
    const plan = (aCustomer === "미확인 고객") ? 
        CustomElementRegistry.billingPlans.basic : aCustomer.billingPlan;
    
    // 클라이언트 3
    const weeksDelinquent = isUnknown(aCustomer) ?
        0 : aCustomer.paymentHistory.weeksDelinquentInLastyear;
  • [Step 1] 앞의 예와 같이, 먼저 Customer에 isUnknown() 속성을 추가하고 [Step 2] 이 필드를 포함하는 특이 케이스 객체를 생성한다. 
    • 차이점이라면 이번에는 특이 케이스가 리터럴이다.
  • class Customer {
        ...
        get isUnknown() { return false; }
    }
    
    function createUnknownCustomer() {
        return {
            isUnknown: true,
        };
    }
  • [Step 3] 특이 테이스 조건 검사 부분을 함수로 추출한다.
  • function isUnknown(arg) {
        if (arg === "미확인 고객");
    }
    
    // 클라이언트 1
    let customerName;
    if (isUnknown(aCustomer)) customerName = "거주자";
    else customerName = aCustomer.name;
    
    // 클라이언트 2
    const plan = isUnknown(aCustomer) ?
        CustomElementRegistry.billingPlans.basic : aCustomer.billingPlan;
    
    // 클라이언트 3
    const weeksDelinquent = isUnknown(aCustomer) ?
        0 : aCustomer.paymentHistory.weeksDelinquentInLastyear;
  • [Step 4] 조건을 검사하는 코드와 Site 클래스에서 이 특이 케이스를 이용하도록 수정한다.
  • class Site {
        get customer() {
            return (this._customer === "미확인 고객") ? createUnknownCustomer() : this._customer;
        }
    }
    
    function isUnknown(arg) {
        return arg.isUnknown;
    }
  • [Step 7] 각각의 표준 응답을 적절한 리터럴 값으로 대체한다. 
  • function createUnknownCustomer() {
        return {
            isUnknown: true,
            name: "거주자",
            billingPlan: CustomElementRegistry.billingPlans.basic,
            paymentHistory: {
                weeksDelinquentInLastYear: 0,
            },
        };
    }
    
    // 클라이언트 1
    const customerName = acustomer.name; // 이름
    
    // 클라이언트 2
    const plan = aCustomer.billingPlan; // 요금제
    
    // 클라이언트 3
    const weeksDelinquent = aCustomer.paymentHistory.weeksDelinquentInLastyear; // 납부 이력

 

예시 3: 변환 함수 이용하기

  • 앞의 두 예는 모두 클래스와 관련 있지만, 변환 단계를 추가하면 같은 아이디어를 레코드에도 적용할 수 있다.
    • 위에 설명된 과정과 비슷하므로 간단하게만 설명하겠다. 
    • 자세한 내용은 책을 참고해 주세요.
  • {
        "name": "애크미 보턴",
        "location": "Malden MA",
        "customer": {
            "name": "애크미 산업",
            "billingPlan": "plan-451",
            "paymentHistory": {
                "weeksDelinquentInLastYear": 7
            }
    }
  • 고객이 알려지지 않은 경우도 있을 텐데, 앞서와 똑같이 "미확인 고객"으로 표시하자
  • {
        "name": "애크미 보턴",
        "location": "Malden MA",
        "customer": "미확인 고객"
    }
  • 클라이언트는 다음과 같다.
    • Json 데이터를 읽어서 customer 객체를 가져와서 이름을 확인한다.
  • // 클라이언트
    const rawSite = acquireSiteData();
    const site = enrichSite(rawSite); // 깊은 복사
    const aCustomer = site.customer;
    ...
    let customerName;
    if (aCustomer === "미확인 고객") customerName = "거주자";
    else customerName = aCustomer.name;
  • 이런 상황에서 우리는 위 과정을 거쳐서 enrichSite 함수를 다음과 같이 정의할 수 있다.
  • function enrichSite(aSite) {
        const result = _.cloneDeep(aSite) //  깊은 복사
        const unknownCustomer = {
            isUnknown: true,
            name: "거주자",
            billingPlan: CustomElementRegistry.billingPlans.basic,
        };
    
        if (isUnknown(result.customer)) result.customer = unknownCustomer;
        else result.customer.isUnknown = false;
    
        return result;
    }
  • 그러면 위 클라이언트 부분을 다음과 같이 수정할 수 있다.
  • // 클라이언트
    const rawSite = acquireSiteData();
    const site = enrichSite(rawSite); // 깊은 복사
    const aCustomer = site.customer;
    ...
    const customerName = aCustomer.name;

 

 

출처:

- [리팩터링 2판]

반응형
Comments