위의 코드에서 새로운 style 이 추가될 경우 if 문이 수정되어야 한다. 또한 각 if 문마다 기본으로 박혀있는 문자열들이 있고, 그 문자열이 매 if 문마다 반복되고 있다.
자기가 어떤 종류인지 파악하기 위한 if 문이 있으며, 이 style 속성을 통해 어떤 메세지를 보낼지 결정하고 있다.
앞 장에서 오리 타입에 대해 이야기하면서 보았던 패턴을 떠올릴 수 있을 것이다. 객체의 클래스를 확인하고 이 객체에게 어떤 메세지를 전송할지 결정하는 것 말이다.
메세지 송신자의 입장에서 이런 표현을 쓸 수 있을 것이다. "나는 네가 누구인지 알고 있고, 따라서 네가 무엇을 하는지도 안다."
여기서 숨겨진 하위 타입(하위 클래스)를 찾아내야 한다.
변수 style 은 Bicycle 을 서로 다른 두 종류로 구분하고 있다. 하나의 클래스가 여러개의 서로 다르지만 연관된 타입을 가지고 있다.
즉 밀접히 연관된 타입들이 같은 행동을 공유하고 있지만 특정한 관점에서는 다른 경우인 것이다.
당연한 말이지만 객체는 메세지를 수신한다. 코드가 얼마나 복잡하든 메세지를 수신하는 객체는 다음의 두 가지 방법 중 하나로 메세지를 처리한다.
메세지를 직접 처리하거나
다른 객체가 처리할 수 있도록 메세지를 넘기거나
상속은 두 객체 사이의 관계를 정의한다. 첫 번째 객체가 이해할 수 없는 메세지를 수신하면 다음 객체에게 자동으로 메세지를 전달하거나 위임한다. 상속은 두 객체가 이와 같은 관계를 맺도록 정의해준다.
생뭃학적인 상속에 빗대면 다중 상속도 떠올릴 수 있겠지만 프로그래밍 언어에 따라 다중 상속을 지원할 수도 있고, 단일 상속만 지원할 수도 있다.
초반에 언급했듯 고전적 상속을 통한 메세지 전달은 클래스들 사이에 이루어지는 작업이다. 오리 타입은 클래스를 가로지르기 때문에 공통의 행동을 공유하기 위해 고전적 상속을 사용하지 않는다.
상속을 직접적으로 이용한 적이 없더라도 메세지의 자동 전달 시스템을 이용하고 있을 것이다. 어떤 객체가 이해할 수 없는 메세지를 수신하면 그 메세지를 상위 클래스로 이어 전달하여 이 메세지를 처리할 수 있는 메서드를 구현하고 있는 상위 클래스를 찾는다. JS 라면 프로토타입 체인이 유사한 예가 될 수 있겠다.
이해하지 못하는 메세지가 상위클래스의 연쇄를 타고 올라간다는 사실은, 하위 클래스는 상위 클래스의 모든 행동을 가지고 있고 여기서 추가적인 행동을 더 가지고 있다는 사실을 말해준다.
Bicycle 클래스는 상위 클래스가 아니라 구체 클래스이기 때문에 MountainBike 의 인스턴스가 여러 속성을 뒤죽박죽으로 가지고 있는 것이 당연한 상태이다.
Bicycle 클래스는 MountainBike의 형제 클래스에게 어울리는 행동과 부모 클래스에게 어울리는 행동을 모두 가지고 있다. 따라서 Bicycle은 MountainBike의 상위클래스일 수 없다.
애초에 문제는 클래스의 이름을 정할 때부터 시작되었다.
Bicycle 이라는 클래스는 애초에 만들어질 때부터 범용적이지 않고 특수한 형태의 자전거(로드 자전거)를 기반으로 만들어졌다. 그러다보니 MountainBike 가 만들어지면서 이름만 보면 상속관계를 암시하지만 기능적으로 전혀 그렇지 않은 형태가 되어버렸다.
하위클래스는 상위클래스의 특수한 형태이다. Bicycle 과 협업할 수 있는 모든 객체는 MountainBike 에 대해 아무것도 모른 채 MountainBike 와 협업할 수 있어야 한다. 이는 훼손되어서는 안되는 상속의 기본 원칙이다. 상속이 제대로 동작하려면 두 가지가 언제나 충족되어야 한다.
모델링하는 객체들이 명백하게 일반-특수 관계를 따라야 한다.
올바른 코딩 기술을 사용해야 한다.
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 에 묶여 있었으므로 공통 행동으로 옮겨보자.
이런 중간 과정을 생략하고 처음부터 코드를 Bicycle 에 잘 짜면 되는거 아니냐고 생각할 수도 있다. 하지만 '모두 아래로 내리고 그 다음에 필요한 것만 위로 올리는 전략' 이 이번 리팩터링의 핵심이다.
상속을 구현하는 데 따르는 여러 어려움은 구체적인 것과 추상적인 것을 제대로 구분하지 못하는 데서 기인한다. Bicycle 의 원래 코드는 이 두 가지 모두를 구현하고 있었다.
만약 원래의 Bicycle 코드에서 구체적인 구현만 RoadBike 로 내려 보내는 작업을 했다면 작은 실수 한 번만으로도 구체적인 구현을 상위 클래스에 남겨놓게 될 것이다.
반면 Bicycle 의 모든 코드를 일단 RoadBike 로 내려놓으면 구체적인 구현을 남겨놓을 걱정을 하지 않을 수 있다. 이런 상태에서 조심스럽게 추상화 코드를 찾아서 차근차근 위로 올리면 된다.
리팩터링 전략 및 일반적인 디자인 전략을 선택할 때 다음과 같은 질문을 던져보면 좋다. "내가 실수하면 어떤 일이 벌어질까?"
이번 경우에는 비어있는 상위 클래스를 만들고 추상화 코드를 위로 올리는 전략을 실수했을 때 벌어지는 최악의 경우를 생각해보자. 추상화할 수 있는 코드를 하나도 찾지 못하는게 최악의 경우가 될 수 있겠다.
반면 구체적인 구현을 아래로 내리는 방식으로 접근할 때 최악의 경우는 구체적인 행동을 상위 클래스에 남겨놓는 것이 된다. 이는 새로운 하위 클래스에게 적용될 수 있는 행동이 아니므로 상속의 기본 법칙을 위배하고, 신뢰할 수 없는 상속 관계가 된다.
신뢰할 수 없는 상속 관계와 협업하는 객체는 상속 관계의 문제점에 대해 잘 알고있어야 하고, 이럴 때 관리를 잘 못하면 객체의 클래스를 확인하는 등 상속 관계의 구조를 알고있어야 한다는 의존성을 만들어버리게 된다.
아주 이전의 예시를 생각하면, RoadBike와 MountainBike 모두 각자의 getSpares 메서드를 구현하고 있다. 하지만 이 메서드를 Bicycle 클래스로 끌어올리기에 어려운 부분이 있다. 같은 이름을 가지고 있는 메서드임에도 불구하고 각 서브클래스별로 행동이 크게 다르기 때문이다.
이제 이 행동의 특정 부분만을 공유하려 하기 때문에 얽힌 것을 정리하고 구체적인 것과 추상적인 것을 분리해야 한다.