티스토리 뷰

반응형

마틴 파울러 - 리팩터링 (2판) 의 12장 - 상속 다루기 중 좋았던 것들 기록 ✏️✏️


 

12.6 타입 코드를 서브클래스로 바꾸기 (Replace Type Code with Subclasses)

 

 

보통 열거형, 문자열, 숫자 등의 타입 코드를 쓴다. 

타입코드만으로도 특별히 불편한 상황은 별로 없지만 그 이상의 무언가가 필요할 때가 있다. 

여기서 '그 이상' 이라 하면 바로 서브클래스를 가리킨다.

 

서브클래스는 두 가지 면에서 특히 매력적이다.

1. 조건에 따라 다르게 동작하도록 해주는 다형성을 제공 (타입 코드에 따라 동작이 달라져야하는 함수가 여러 개일 때 특히 유용)

2. 특정 타입에서만 의미가 있는 값을 사용하는 필드나 메서드가 있을 때 (필요한 서브클래스만 필요한 필드를 가지도록 하여 더 명확) 

 

 

이번 리팩터링은 대상 클래스에 직접 적용할지, 아니면 타입 코드 자체에 적용할 지를 고민해야한다. (✨ 이 부분이 좋았음 ✨) 

 

1. 대상 클래스에 직접 적용한다면 - 직원의 하위타입인 엔지니어를 만들 것

2. 타입 코드 자체에 적용한다면 - 직원에게 직원유형 '속성' 을 부여하고, 이 속성을 클래스로 정의해 엔지니어 속성과 관리자 속성 같은 서브클래스를 만들 것 

 

 

책에서는 1번 방식을 직접 상속, 2번 방식을 간접 상속이라고 말하고 있다. 

 

[ 직접 상속 예제 ]

 

class Engineer extends Employee {

}

class Salesperson extends Employee { 

}

class Manager extends Employee {


}


// 팩토리 함수 
function createEmployee(name, type) {
    switch (type) {
      case "engineer": return new Engineer(name);
      case "salesperson": return new Salesperson(name);
      case "manager": return new Manager(name);
    }
}

 

 

[ 간접 상속 예제 ] 

 

class Engineer extends EmployeeType {

}

class Salesperson extends EmployeeType { 

}

class Manager extends EmployeeType {


}


// Employee 클래스 
set type(arg) { this._type = Employee.createEmployeeType(arg); }
static function createEmployeeType(aString) {
    switch (aString) {
      case "engineer": return new Engineer();
      case "salesperson": return new Salesperson();
      case "manager": return new Manager();
      default: throw new Error('${aString}라는 직원 유형은 없습니다.')
    }
}

 

 

12.10 서브클래스를 위임으로 바꾸기 (Replace Subclass with Delegate)

 

 

 

속한 갈래에 따라 동작이 달라지는 객체들은 상속으로 표현하는게 자연스럽다. 공통 데이터와 동작은 모두 슈퍼클래스에 두고 서브클래스는 자신에 맞게 기능을 추가하거나 오버라이드하면 된다. 

 

하지만 상속에는 단점이 있다. 

가장 명확한 단점은 한 번만 쓸 수 있는 카드라는 것이다.  

무언가가 달라져야하는 이유가 여러 개여도 상속에서는 그중 단 하나의 이유만 선택해 기준으로 삼을 수 밖에 없다. 

예컨대 사람 객체의 동작을 '나이대'와 '소득수준'에 따라 달리 하고 싶다면 서브클래스는 젊은이와 어르신이 되거나, 혹은 부자와 서민이 되어야한다. 둘다는 안된다. 

 

또 다른 문제로, 상속은 클래스들의 관계를 아주 긴밀하게 결합한다. 부모를 수정하면 이미 존재하는 자식들의 기능을 해치기가 쉽게 때문에 각별히 주의해야한다. 그래서 자식들이 슈퍼클래스를 어떻게 상속해 쓰는 지를 이해해야 한다. 부모와 자식이 서로 다른 모듈에 속하거나 다른 팀에서 구현한다면 문제가 더 커진다. 

 

 

위임(delegate)는 두 문제를 모두 해결해준다. 

다양한 클래스에서 서로 다른 이유로 위임할 수 있다. 위임은 객체 사이의 일반적인 관계이므로 상호작용에 필요한 인터페이스를 명확히 정의할 수 있다. 즉 상속보다 결합도가 훨씬 약하다. 그래서 서브클래싱(상속) 관련 문제에 직면하게 되면 흔히들 서브클래스를 위임으로 바꾸곤 한다. 

 

유명한 원칙이 하나있다. "상속 보다는 컴포지션을 사용하라!"  여기서 컴포지션(composition)은 사실상 위임과 같은 말이다. 

이 원칙은 상속을 쓰지 말라는 게 아니라 과용하는 데 따른 반작용으로 나온 것이다. 

 

 

그럼 리팩토링을 시작해보자!  

책에 두가지 예제가 나오는 데, 두번째 예제의 축소 버전이다

function createBird(data) {
   switch (data.type) {
      case '유럽 제비':
          return new EuropeanSwallow(data);
      case '노르웨이 파랑 앵무':
          return new NorwegianBlueParrot(data);
      default:
         return new Bird(data);
   }
}


class Bird {
   constructor(data) {
      this._name = data.name;
      this._plumage = data.plumage;
   }

   get name() { return this._name; }
   get plumage() {
      return this._plumage || "보통이다";
   }
   get airSpeedVelocity() { return null; }
}



class EuropeanSwallow extends Bird {
     get airSpeedVelocity() { return 35; }
}


class NorwegianBlueParrot extends Bird {
    constructor(data) {
       super(data);
       this._voltage = data.voltage;
       this._isNailed = data.isNailed;
    }
  
    get plumage() {
       if (this._voltage > 100) return "그을렸다";
       else return this._plumage || "예쁘다";
    }

    get airSpeedVelocity() {
        return (this._isNailed) ? 0 : 10 + this._voltage / 10;
    }
}

 

 

우선 유럽 제비 부터 시작한다.

 

1. 빈 위임 클래스를 만든다. 

class EuropeanSwallowDelegate {

}

 

2. 위임필드를 초기화한다. 

 

이 예제에서는 타입 정보가 들어있는 data를 받는 생성자에서 초기화한다. 

class Bird {
   constructor(data) {
      this._name = data.name;
      this._plumage = data.plumage;
      this._speciesDelegate = this.selectSpeciesDelegate(data);
   }

   selectSpeciesDelegate(data) {
      switch (data.type) {
      case '유럽 제비':
          return new EuropeanSwallowDelegate();
      default: return null;
   }
   
   ...
}

 

 

3. 서브클래스의 메서드를 위임 클래스로 옮긴다. 

class EuropeanSwallowdelegate {
     get airSpeedVelocity() { return 35; }
}


class EuropeanSwallow extends Bird {
     get airSpeedVelocity() { return this._speciesDelegate.airSpeedVelocity; }
}

 

4. 슈퍼클래스의 메서드를 수정하여, 위임이 존재하면 위임의 메서드를 호출하도록 한다. 

class Bird {
   get airSpeedVelocity() { return this._speciesDelegate ? this._speciesDelegate.airSpeedVelocity : null; }
}

 

5.  서브 클래스를 제거한다. 

 

 

 

그 다음 노르웨이 파랑 앵무 차례다. 똑같이 위임클래스를 만들고 여기로 메소드를 옮기면 된다. 

노르웨이 파랑 앵무는 깃털 상태를 나타내는 plumage() 를 오버라이드 하고 있다. 

그래서 생성자에 Bird로의 역참조를 추가해준다. 

 

class NorwegianBlueParrotDelegate {
    constructor(data, bird) {
       ✔️ this._bird = bird;
       this._voltage = data.voltage;
       this._isNailed = data.isNailed;
    }
  
    get plumage() {
       if (this._voltage > 100) return "그을렸다";
       else return ✔️ this._bird._plumage || "예쁘다";
    }

    get airSpeedVelocity() {
        return (this._isNailed) ? 0 : 10 + this._voltage / 10;
    }
}

 

class Bird {
   ...
   selectSpeciesDelegate(data) {
      switch (data.type) {
      case '유럽 제비':
          return new EuropeanSwallowDelegate();
      case '노르웨이 파랑 앵무':
          return new NorwegianBlueParrotDelegate(data, this);
      default: return null;
   }
 }

 

 

까다로운 단계는 서브클래스에서 plumage() 메서드를 어떻게 제거하느냐다. 

아래 처럼 하면 다른 위임 클래스에는 이 속성이 없기 때문에 오류가 발생할 것이다. 

class Bird {
   ... 
   get plumage() {
      if (this._speciesDelegate):
          return this._speciesDelegate.plumage;   // 오류 발생
      else
         return this._plumage || "보통이다";
   }
}

 

 

다음처럼 조건을 더 정교하게 검사하는 방법도 있겠지만, 이 코드는 악취가 난다.

클래스의 종류를 꼭 집어서 검사하는 것은 절대 좋은 생각이 아니다. 

class Bird {
  ... 
   get plumage() {
      if (this._speciesDelegate instanceof NorwegianBlueParrotDelegate):
          return this._speciesDelegate.plumage;
      else
         return this._plumage || "보통이다";
   }
}

 

 

또 다른 선택지로 다른 서브 클래스에 기본 값을 두는 방법도 있지만 plumage()의 기본 메서드가 여러 클래스에 중복되어 들어가는 결과를 낳는다. 몇몇 생성자에 역참조를 대입하는 코드 역시 중복될 수 있다. 

class EuropeanSwallowDelegate {
     get plumage() {
        return this._bird._plumage || "보통이다";
   }
}

 

 

이 중복을 해결하는 자연스러운 방법은 바로 '상속' 이다. 

지금까지 만든 종 분류용 위임들에서 슈퍼클래스를 추출해보자 

class SpeciesDelegate {
     construct(data, bird) {
         this._bird = bird;
     }
    
     get plumage() {
        return this._bird._plumage || "보통이다";
     }
}


class EuropeanSwallowDelegate extends SpeciesDelegate {
     get airSpeedVelocity() { return 35; }
}


class NorwegianBlueParrotDelegate {
    constructor(data, bird) {
       super(data, bird)
       this._voltage = data.voltage;
       this._isNailed = data.isNailed;
    }
  
    get plumage() {
       if (this._voltage > 100) return "그을렸다";
       else return this._bird._plumage || "예쁘다";
    }

    get airSpeedVelocity() {
        return (this._isNailed) ? 0 : 10 + this._voltage / 10;
    }
}

 

 

슈퍼클래스가 생겼으니 Bird의 기본 동작 모두를 SpeciesDelegate 클래스로 옮길 수 있다.

그리고 speciesDelegate 필드에는 언제나 값이 들어있음이 보장된다. 

class Bird {
   ...
      
   selectSpeciesDelegate(data) {
      switch (data.type) {
      case '유럽 제비':
          return new EuropeanSwallowDelegate(data, this);
      case '노르웨이 파랑 앵무':
          return new NorwegianBlueParrotDelegate(data, this);
      default: return new SpeciesDelegate(data, this);
   }
   
   get plumage() { return this._speciesDelegate.plumage; }
}

 

이 예시는 원래의 서브클래스들을 위임으로 교체했지만 SpeciesDelegate 에는 여전히 처음 구조와 매우 비슷한 계층 구조가 존재한다.

Bird를 상속으로부터 구제한 것 외에 이 리팩토링으로 얻은 건 무엇일까? 위임으로 옮겨진 종 계층구조는 더 엄격하게 종과 관련한 내용만을 다루게 되었다. 다시 말해, 위임 클래스들은 종에 따라 달라지는 데이터, 메서드 만을 담게 되고 종과 상관없는 코드는 Bird 자체와 미래의 서브클래스들에 남는다. 

 

또한 이번 리팩터링의 예시를 통해 '상속보다 컴포지션을 사용하라' 보다는 '컴포지션이나 상속 어느 하나만 고집하지말고 적절히 혼용하라' 가 더 제대로 표현한 말임을 알 수 있다. 

 

반응형
댓글