루비로 배우는 객체지향 디자인 - 6장

Created
Jul 25, 2020 06:31 AM
Tags

상속을 이용해 새로운 행동 얻기

  • 고전적 상속에 대해 이야기한다. 고전적이라는 개념은 클래스라는 단어와 맞짝을 이룰 뿐, 낡은 기술이라는 뜻이 아니다. 다른 방식의 상속 시스템과 상위/하위 클래스를 통한 상속 시스템을 구분하기 위해 사용할 뿐이다.
  • 기본적으로 상속이란 자동화된 메세지 전달 시스템이다. 객체가 이해하지 못한 메세지를 어디로 전달해야하는지 정의하는 것이다.
    • 특정 객체가 이해할 수 없는 메세지를 전달받았을 경우 그 객체는 메세지를 다른 메세지로 전달하는데, 명시적으로 메세지를 위임하는 코드를 작성하지 않아도 두 객체 사이의 상속 관계를 정의하면 자동으로 메세지 전달이 이루어진다.
  • 상속은 어느 시점에 필요할까? 예시부터 시작해보자.
class Bicycle { constructor({ size, tapeColor }) { this.size = size; this.tapeColor = tapeColor; } getSpares() { return { chain: "10-speed", tireSize: "23", tapeColor: this.tapeColor, }; } } const bike = new Bicycle({ size: "M", tapeColor: "red" }); console.log(bike.size); // 'M' console.log(bike.getSpares()); // { chain: '10-speed', tireSize: '23', tapeColor: 'red' }
  • 단순한 자전거를 표현한 위의 클래스를 넘어 산악자전거나 로드바이크를 만들어야 한다고 할 떄, 어떻게 지원하도록 만들 수 있을까?
    • 이미 대부분의 행동이 구현된 구체 클래스(Bicycle)가 있기 때문에, 같은 클래스에 변수나 메서드를 추가하는 방식으로 구현하겠다는 생각이 들 수 있다. 안 좋은 예시를 먼저 보여주고자 하므로 계속 예제를 이어나간다.
class Bicycle { constructor({ size, tapeColor, style, frontShock, rearShock }) { this.size = size; this.tapeColor = tapeColor; this.style = style; this.frontShock = frontShock; this.rearShock = rearShock; } getSpares() { if (this.style === "road") { return { chain: "10-speed", tireSize: "23", // 밀리미터 tapeColor: this.tapeColor, }; } return { chain: "10-speed", tireSize: "2.1", // 인치 tapeColor: this.tapeColor, }; } } const bike = new Bicycle({ size: "S", tapeColor: "red", style: "mountain", frontShock: "Manitou", rearShock: "Fox", }); console.log(bike.getSpares()); // { chain: '10-speed', tireSize: '2.1', tapeColor: 'red' }
  • 위의 코드에서 새로운 style 이 추가될 경우 if 문이 수정되어야 한다. 또한 각 if 문마다 기본으로 박혀있는 문자열들이 있고, 그 문자열이 매 if 문마다 반복되고 있다.
    • 자기가 어떤 종류인지 파악하기 위한 if 문이 있으며, 이 style 속성을 통해 어떤 메세지를 보낼지 결정하고 있다.
    • 앞 장에서 오리 타입에 대해 이야기하면서 보았던 패턴을 떠올릴 수 있을 것이다. 객체의 클래스를 확인하고 이 객체에게 어떤 메세지를 전송할지 결정하는 것 말이다.
    • 메세지 송신자의 입장에서 이런 표현을 쓸 수 있을 것이다. "나는 네가 누구인지 알고 있고, 따라서 네가 무엇을 하는지도 안다."
  • 여기서 숨겨진 하위 타입(하위 클래스)를 찾아내야 한다.
    • 변수 style 은 Bicycle 을 서로 다른 두 종류로 구분하고 있다. 하나의 클래스가 여러개의 서로 다르지만 연관된 타입을 가지고 있다.
    • 즉 밀접히 연관된 타입들이 같은 행동을 공유하고 있지만 특정한 관점에서는 다른 경우인 것이다.
  • 당연한 말이지만 객체는 메세지를 수신한다. 코드가 얼마나 복잡하든 메세지를 수신하는 객체는 다음의 두 가지 방법 중 하나로 메세지를 처리한다.
    • 메세지를 직접 처리하거나
    • 다른 객체가 처리할 수 있도록 메세지를 넘기거나
  • 상속은 두 객체 사이의 관계를 정의한다. 첫 번째 객체가 이해할 수 없는 메세지를 수신하면 다음 객체에게 자동으로 메세지를 전달하거나 위임한다. 상속은 두 객체가 이와 같은 관계를 맺도록 정의해준다.
  • 생뭃학적인 상속에 빗대면 다중 상속도 떠올릴 수 있겠지만 프로그래밍 언어에 따라 다중 상속을 지원할 수도 있고, 단일 상속만 지원할 수도 있다.
  • 초반에 언급했듯 고전적 상속을 통한 메세지 전달은 클래스들 사이에 이루어지는 작업이다. 오리 타입은 클래스를 가로지르기 때문에 공통의 행동을 공유하기 위해 고전적 상속을 사용하지 않는다.
  • 상속을 직접적으로 이용한 적이 없더라도 메세지의 자동 전달 시스템을 이용하고 있을 것이다. 어떤 객체가 이해할 수 없는 메세지를 수신하면 그 메세지를 상위 클래스로 이어 전달하여 이 메세지를 처리할 수 있는 메서드를 구현하고 있는 상위 클래스를 찾는다. JS 라면 프로토타입 체인이 유사한 예가 될 수 있겠다.
    • 이해하지 못하는 메세지가 상위클래스의 연쇄를 타고 올라간다는 사실은, 하위 클래스는 상위 클래스의 모든 행동을 가지고 있고 여기서 추가적인 행동을 더 가지고 있다는 사실을 말해준다.
  • 상속을 잘못 사용한다면? - MountainBike 의 예
class MountainBike extends Bicycle { constructor(props = {}) { super(props); this.frontShock = props.frontShock; this.rearShock = props.rearShock; } getSpares() { return { ...super.getSpares(), rearShock: this.rearShock, frontShock: this.frontShock, }; } } const mountainBike = new MountainBike({ size: "S", frontShock: "Manitou", rearShock: "Fox", }); console.log(mountainBike.size); // S console.log(mountainBike.getSpares()); /** * { * chain: '10-speed', * tireSize: '23', // 타이어 사이즈가 기대한것과 다름 * tapeColor: undefined, // 해당 사항 없음 * rearShock: 'Fox', * frontShock: 'Manitou' * } */
  • Bicycle 클래스는 상위 클래스가 아니라 구체 클래스이기 때문에 MountainBike 의 인스턴스가 여러 속성을 뒤죽박죽으로 가지고 있는 것이 당연한 상태이다.
  • Bicycle 클래스는 MountainBike의 형제 클래스에게 어울리는 행동과 부모 클래스에게 어울리는 행동을 모두 가지고 있다. 따라서 Bicycle은 MountainBike의 상위클래스일 수 없다.
  • 애초에 문제는 클래스의 이름을 정할 때부터 시작되었다.
  • Bicycle 이라는 클래스는 애초에 만들어질 때부터 범용적이지 않고 특수한 형태의 자전거(로드 자전거)를 기반으로 만들어졌다. 그러다보니 MountainBike 가 만들어지면서 이름만 보면 상속관계를 암시하지만 기능적으로 전혀 그렇지 않은 형태가 되어버렸다.
  • 하위클래스는 상위클래스의 특수한 형태이다. Bicycle 과 협업할 수 있는 모든 객체는 MountainBike 에 대해 아무것도 모른 채 MountainBike 와 협업할 수 있어야 한다. 이는 훼손되어서는 안되는 상속의 기본 원칙이다. 상속이 제대로 동작하려면 두 가지가 언제나 충족되어야 한다.
      1. 모델링하는 객체들이 명백하게 일반-특수 관계를 따라야 한다.
      1. 올바른 코딩 기술을 사용해야 한다.
  • Bicycle 에 담겨있던 RoadBike 와 관련된 로직을 떼어내고, Bicycle 은 추상 클래스로 만들 수 있겠다.
    • 추상 클래스는 상속받기 위해 존재한다.
    • 이 클래스는 하위 클래스들이 공유하는 공통된 행동의 저장소이고, 이 추상 클래스를 상속받은 하위 클래스들은 구체적인 형태를 제공할 수 있다.
    • 하나의 하위 클래슬르 위한 추상 클래스를 만드는 것은 당연 말이 안되고, 두 종류의 자전거를 다루어야 하는 상황에서도 상속을 사용하는 것을 천천히 생각해 보아야 한다. 상속 관계를 만드는데는 높은 비용이 들기 때문이다.
    • 이 비용을 최소화하는 가장 좋은 방법은 하위클래스가 추상 클래스를 필요로 하기 바로 직전에 추상 클래스를 만드는 것이다.
    • 상속 관계를 만들지 말지를 선택하는 것은 '세 번째 종류의 자전거가 얼마나 빨리 필요하게 될지' 그리고 '중복 코드를 관리하는 비용이 얼마인지' 사이에 달려 있다.
    • 기다릴 수 있다면 기다리는 것이 좋지만, 두 개의 구체적인 종류가 있고 상속 관계를 만드는 것이 올바른 선택인 듯 보인다면 두려워하지 말고 만들자.
// 이번엔 abstract class 가 지원되는 타입스크립트로 작성해본다. abstract class Bicycle { // 비어있음 // 여기 있던 내용은 모두 RoadBike 로 옮겨감 } class RoadBike extends Bicycle { // Bicycle 의 하위 클래스가 되었다. // 기존의 Bicycle 클래스가 가지고 있던 모든 코드를 갖고 있다. } class MountainBike extends Bicycle { // 어전히 Bicycle 의 하위 클래스이다. // 코드는 아직 바뀌지 않았다. } const roadBike = new RoadBike({ size: "M", tapeColor: "red" }); console.log(roadBike.size); // 'M' const mountainBike = new MountainBike({ size: "S", frontShock: "Manitou", rearShock: "Fox", }); console.log(mountainBike.size); // Error
  • 이런 식으로 코드를 재배치하는 것은 단순히 문제가 되는 지점을 이동시킨 것에 불과하다. 자전거들이 공유하는 행동들은 RoadBike 안에 갇혀있는데 MountainBike 는 그 행동에 접근할 수 없기 때문이다.
    • 하지만 하위 클래스의 코드를 상위 클래스로 올리는 것이 그 반대보다 수월하기 떄문에 유용한 전략이다.
    • 위의 코드가 에러나는 이유는 명백하다. MountainBike 뿐 아니라 그 상위 클래스인 Bicycle 에서도 size 를 구현하고 있지 않기 때문이다.
  • size 나 spares 는 모든 자전거에 적용될 수 있는 메서드이다. 이 행동은 Bicycle 의 퍼블릭 인터페이스에 속한다. 현재는 RoadBike 에 묶여 있었으므로 공통 행동으로 옮겨보자.
abstract class Bicycle { private _size: string; constructor({ size }: { size: string }) { this._size = size; } get size() { return this._size; } } class RoadBike extends Bicycle { private _tapeColor: string; constructor(props: { tapeColor: string; size: string }) { super(props); this._tapeColor = props.tapeColor; } } class MountainBike extends Bicycle { private _frontShock: string; private _rearShock: string; constructor(props: { size: string; frontShock: string; rearShock: string }) { super(props); this._frontShock = props.frontShock; this._rearShock = props.rearShock; } } const roadBike = new RoadBike({ size: "M", tapeColor: "red" }); const mountainBike = new MountainBike({ size: "S", frontShock: "Manitou", rearShock: "Fox", }); console.log(roadBike.size); // M console.log(mountainBike.size); // S
  • 이런 중간 과정을 생략하고 처음부터 코드를 Bicycle 에 잘 짜면 되는거 아니냐고 생각할 수도 있다. 하지만 '모두 아래로 내리고 그 다음에 필요한 것만 위로 올리는 전략' 이 이번 리팩터링의 핵심이다.
    • 상속을 구현하는 데 따르는 여러 어려움은 구체적인 것과 추상적인 것을 제대로 구분하지 못하는 데서 기인한다. Bicycle 의 원래 코드는 이 두 가지 모두를 구현하고 있었다.
    • 만약 원래의 Bicycle 코드에서 구체적인 구현만 RoadBike 로 내려 보내는 작업을 했다면 작은 실수 한 번만으로도 구체적인 구현을 상위 클래스에 남겨놓게 될 것이다.
    • 반면 Bicycle 의 모든 코드를 일단 RoadBike 로 내려놓으면 구체적인 구현을 남겨놓을 걱정을 하지 않을 수 있다. 이런 상태에서 조심스럽게 추상화 코드를 찾아서 차근차근 위로 올리면 된다.
  • 리팩터링 전략 및 일반적인 디자인 전략을 선택할 때 다음과 같은 질문을 던져보면 좋다. "내가 실수하면 어떤 일이 벌어질까?"
    • 이번 경우에는 비어있는 상위 클래스를 만들고 추상화 코드를 위로 올리는 전략을 실수했을 때 벌어지는 최악의 경우를 생각해보자. 추상화할 수 있는 코드를 하나도 찾지 못하는게 최악의 경우가 될 수 있겠다.
    • 반면 구체적인 구현을 아래로 내리는 방식으로 접근할 때 최악의 경우는 구체적인 행동을 상위 클래스에 남겨놓는 것이 된다. 이는 새로운 하위 클래스에게 적용될 수 있는 행동이 아니므로 상속의 기본 법칙을 위배하고, 신뢰할 수 없는 상속 관계가 된다.
    • 신뢰할 수 없는 상속 관계와 협업하는 객체는 상속 관계의 문제점에 대해 잘 알고있어야 하고, 이럴 때 관리를 잘 못하면 객체의 클래스를 확인하는 등 상속 관계의 구조를 알고있어야 한다는 의존성을 만들어버리게 된다.
  • 아주 이전의 예시를 생각하면, RoadBike와 MountainBike 모두 각자의 getSpares 메서드를 구현하고 있다. 하지만 이 메서드를 Bicycle 클래스로 끌어올리기에 어려운 부분이 있다. 같은 이름을 가지고 있는 메서드임에도 불구하고 각 서브클래스별로 행동이 크게 다르기 때문이다.
    • 이제 이 행동의 특정 부분만을 공유하려 하기 때문에 얽힌 것을 정리하고 구체적인 것과 추상적인 것을 분리해야 한다.
    • 추상적인 것은 Bicycle로, 구체적인 것은 RoadBike로
  • 모든 자전거가 공유해야하는 사항을 먼저 확인해보자
    • Bicycle 은 chain, tireSize 를 갖는다
    • 모든 자전거는 chain의 기본값을 공유하고 있다.
    • 하위클래스는 자신만의 tireSize 기본값을 갖는다.
    • 하위클래스의 구체적인 인스턴스는 기본값을 무시하고 인스턴스 고유의 값을 설정할 수 있다.
interface BicycleProps { size: string; chain: string; tireSize: number; } abstract class Bicycle { private _size: string; private _chain: string; private _tireSize: number; constructor({ size, chain, tireSize }: BicycleProps) { this._size = size; this._chain = chain; this._tireSize = tireSize; } // ... }
  • 위의 코드로 첫 번째와 마지막 요구사항이 해결되었다. 아직은 특별한 것이 없다. 여기에 Bicycle 의 constructor에서 기본값을 가져오는 메서드를 전송하도록 할 것이다. 이런 형태의 메서드를 템플릿 메서드라고 한다.
class Bicycle { // ... constructor({ size, chain, tireSize }) { this._size = size; this._chain = chain || this.defaultChain; this._tireSize = tireSize || this.defaultTireSize; } get defaultChain() { return '10-speed'; } get defaultTireSize() { throw new Error('not implemented'); } // ... } class RoadBike extends Bicycle { // ... get defaultTireSize() { return 23; } } class MountainBike extends Bicycle { // ... get defaultTireSize() { return 2.1; } }
  • 위의 예제는 타입스크립트로 작성하기엔 조금 억지스러운 면이 있는데, defaultTireSize 를 제대로 구현하지 않으면 이미 컴파일 단계에서 에러가 나기 때문이다. 아래는 동적 언어일 경우를 상정하여 설명한다.
    • 새로운 요구사항(보통 새로운 자전거 종류)를 추가하는 과정에서 코드를 처음 작성한 사람보다 이해도가 떨어지는 사람이 defaultTireSize 를 구현하는 것을 잊어버린다면?
    • 다음과 같은 간단한 원칙을 따르는 것이 좋다. 템플릿 메서드 패턴을 사용하는 클래스는 자신이 전송하는 메서드를 직접 구현해 놓아야 한다는 것이다. 비록 에러를 리턴하는 형식이 될지라도 말이다.
  • 에러를 던질 경우 가능하면 추가적인 정보와 함께 디버깅하기 쉬운 메세지를 전달해주는 것이 좋다.
    • 에러 상황에서 도움이 되는 메세지를 제공하는 코드를 작성하면 지금 당장 별로 힘을 들이지 않고도 나중에는 큰 보상을 받을 수 있다.
    • 각각의 에러 메세지는 사소한 것이지만 그 사소한 것들이 모여 큰 결과를 낳는다.
    • 템플릿 메서드 패턴을 사용할 때는 언제나 호출되는 메서드를 작성하고 유용한 에러 메세지를 제공해야 한다. 이런 문서화는 반드시 필요하다.
  • 이제 상위 클래스는 거의 완성되었고 spares 만 구현하면 된다. 먼저 쉽고 명료한 방법을 본 다음 거기서 파생되는 문제를 해결한 버전을 확인해보자.
class Bicycle { constructor({ size, chain, tireSize }) { this._size = size; this._chain = chain || this.defaultChain; this._tireSize = tireSize || this.defaultTireSize; } get spares() { return { tireSize: this._tireSize, chain: this._chain, }; } get defaultChain() { return "10-speed"; } get defaultTireSize() { throw new Error("not implemented"); } } class RoadBike extends Bicycle { constructor(props) { super(props); this._tapeColor = props.tapeColor; } get spares() { return { ...super.spares, tapeColor: this._tapeColor, }; } get defaultTireSize() { return 23; } } class MountainBike extends Bicycle { constructor(props) { super(props); this._frontShock = props.frontShock; this._rearShock = props.rearShock; } get spares() { return { ...super.spares, rearShock: this._rearShock, }; } get defaultTireSize() { return 2.1; } }
  • MountainBike, RoadBike 가 서로 비슷한 패턴을 따르고 있다. 모두 자기 자신에 대해 아는 것이 있고, 자신이 가진 상위클래스에 대해 아는 것도 있다.
    • 상위 클래스의 spares 가 객체를 반환한다는 점
    • constructor 메서드에 반응한다는 점
  • 다른 클래스에 대해 알고 있다면 여기서 의존성이 만들어진다. 그리고 의존성은 객체를 강하게 결합시킨다.
    • 그래서 만약에 또 다른 자전거 클래스를 만들어 Bicycle 을 상속한 경우 super 를 활용하는 것을 깜빡했을 경우 치명적인 문제가 발생할 것이다.
    • 하위 클래스가 자신과 관련된 특수한 부품에 대해 알고 있어야 하는 것은 당연하다. 하지만 하위 클래스가 추상화된 상위 클래스와 어떻게 소통해야 하는지도 알아야 할 때는 문제가 발생한다.
    • 하위 클래스가 super 를 전송한다는 것은 스스로 알고리즘에 대해 알고 있다고 말하는 것이다. 만약 알고리즘이 바뀐다면, 하위 클래스의 고유한 특징은 아무것도 바뀌지 않았다 하더라도, 제대로 작동하지 않을 수 있다.
  • 하위 클래스가 알고리즘을 알고 있고 super 를 전송하는 대신 훅(hook) 메세지를 전송할 수 있다. constructor 말미에 postInitialize 메서드를 호출하는 것이다.
class Bicycle { constructor(props) { this._size = props.size; this._chain = props.chain || this.defaultChain; this._tireSize = props.tireSize || this.defaultTireSize; this.postInitialize(props); } postInitialize(props) { // do nothing } // ... } class RoadBike extends Bicycle { // 필요하다면 RoadBike 가 재정의할 수 있다. postInitialize(props) { this._tapeColor = props.tapeColor } }
  • 이 변화로 인해 RoadBike의 constructor에서 super 를 제거했을 뿐 아니라 constructor 자체를 지울 수 있게 되었다. 이제 RoadBike는 초기화 과정에 관여하지 않는다.
    • 대신 좀 더 크고 추상적인 알고리즘에 자신만의 특수한 내용을 추가한다. 이 알고리즘은 추상화된 상위클래스 Bicycle에서 정의되어 있고, postInitialize 를 전송한다는 것은 Bicycle의 책임이다.
    • 덕분에 RoadBike, Bicycle 모두서로에 대해 덜 알게 되었고, 둘 사이의 결합이 줄어들었다. 또한 미래의 변경사항에 보다 유연하게 대처할 수 있게 되었다.
    • RoadBike는 언제 postInitialize 메서드가 호출되는지 모르고, 누가 이 메세지를 전송하는지도 신경쓰지 않는다. 반면 Bicycle은 언제든 이 메세지를 전송하 ㄹ수 있다. 객체가 초기화될 때 전송되어야 한다는 제약도 없다.
    • 언제 전송할지를 상위 클래스가 관리한다는 것은 하위 클래스를 변경하지 않고도 알고리즘을 수정할 수 있다는 뜻이다.
  • spares 에 대해서도 동일한 방식의 리팩토링을 하는 예가 있으나 생략
  • 요약
    • 공통된 행동을 많이 공유하고 있지만 특정 관점에서만 다르고, 동시에 서로 연관된 타입들을 다루는 문제는 상속으로 해결할 수 있다.
      • 공통된 코드를 고립시키고 공통의 알고리즘을 추상 클래스가 구현할 수 있도록 해주기 때문이다.
      • 동시에 하위 클래스가 자신만의 특수한 행동을 추가할 수 있는 여지도 남겨 놓는다.
    • 추상화된 상위 클래스를 만드는 가장 좋은 방법은 구체적인 하위 클래스의 코드를 위로 올리는 것이다. 최소한 세 개의 서로 다른 구체 클래스를 가지고 있다면 올바른 추상을 찾아내는 것은 어려운 일이 아닐 것이다.
    • 추상화된 상위 클래스는 템플릿 메서드 패턴을 이용하여 하위 클래스가 자신의 특수한 내용을 추가할 수 있도록 돕는다.
    • 그리고 훅 메서드를 통해 super 를 전송하지 않고도 특수한 내용을 전달할 수 있도록 해 준다.
      • 훅 메서드를 이용하면 하위 클래스가 추상화 알고리즘을 알지 못해도 자신의 특수한 내용을 추가할 수 있다.
      • 하위 클래스가 super 를 전송하지 않아도 괜찮기 때문에, 상속 관게 계층 사이의 결합이 느슨해진다. 또한 수정을 잘 받아들일 수 있게 된다.
    • 잘 디자인된 상속 관계는 새로운 하위 클래스를 통해 쉽게 확장할 수 있다. 애플리케이션에 대해 잘 모르는 프로그래머도 확장할 수 있다. 이 손쉬운 확장성이 상속의 가장 강력한 장점이다.
    • 안정적이고 공통된 추상적 형태를 갖는 여러 가지 구체적인 형태들을 만들어야 한다면 상속은 매우 효율적인 해결책일 수 있다.