상속의 첫 번째 용도는 타입 계층을 구현하는 것이다. 타입 계층의 관점에서 부모 클래스는 자식 클래스의 일반화(generalization)이고 자식 클래스는 부모 클래스의 특수화(specialization)다.
상속의 두 번째 용도는 코드 재사용이다. 하지만 재사용을 위해 상속을 사용할 경우 부모 클래스와 자식 클래스가 강하게 결합되기 때문에 변경하기 어려운 코드를 얻게 될 확률이 높다.
상속을 사용하는 일차적인 목표는 코드 재사용이 아니라 타입 계층을 구현하는 것이어야 한다. 반면 타입 계층을 목표로 상속을 사용하면 다형적으로 동작하는 객체들의 관계에 기반해 확장 가능하고 유연한 설계를 얻을 수 있게 된다.
그렇다면 타입 계층이란 무엇인가? 상속을 이용해 타입 계층을 구현한다는 것이 무엇을 의미하는가?
객체지향 패러다임 관점의 타입
타입을 다음과 같은 두가지 관점에서 정의할 수 있다.
객체지향 프로그래밍에서 오퍼레이션은 객체가 수신할 수 있는 메시지를 의미한다. 따라서 객체의 타입이란 객체가 수신할 수 있는 메시지의 종류를 정의하는 것이다.
객체가 수신할 수 있는 메시지를 기준으로 타입을 분류하기 때문에 동일한 퍼블릭 인터페이스를 가지는 객체들은 동일한 타입으로 분류할 수 있다.
그렇다면 어떤 조건을 만족시켜야만 타입 계층을 위해 올바르게 상속을 사용했다고 말할 수 있을까?
상속 관계가 is-a 관계를 모델링하는가?
일반적으로 "[자식 클래스]는 [부모 클래스]다"라고 말해도 이상하지 않다면 상속을 사용할 후보로 간주할 수 있다.
클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한가?
상속 계층을 사용하는 클라이언트의 입장에서 부모 클래스와 자식 클래스의 차이점을 몰라야 한다. 이를 자식 클래스와 부모 클래스 사이의 행동 호환성이라고 부른다.
행동이 호환된다는 것은 무슨 의미일까? 단순히 동일한 메서드를 구현하고 있으면 행동이 호환되는 것일까?
여기서 중요한 것은 행동의 호환 여부를 판단하는 기준은 클라이언트의 관점이라는 것이다.
public void flyBird(Bird bird) {
// 인자로 전달된 모든 bird는 날 수 있어야 한다.
bird.fly();
}
하지만 Bird 클래스를 상속 받은 Penguin 클래스가 있다고 생각해보자. 펭귄은 새 이지만 날수는 없다. 이는 잘못된 잘못된 상속으로 인한 행동 호환성에 오류가 발생하는 대표적인 사례이다.
문제를 해결할 수 있는 방법은 클라이언트의 기대에 맞게 상속 계층을 분리하는 것뿐이다. 따라서 날 수 있는 새와 날 수 없는 새를 명확하게 구분할 수 있게 상속 계층을 분리하면 서로 다른 요구사항을 가진 클라이언트를 만족시킬 수 있을 것이다.
public class Bird {
...
}
public class FlyingBird extends Bird {
public void fly() { ... }
...
}
public class Penguin extends Bird {
...
}
이제 flyBird 메서드는 FlyingBird 타입을 이용해 날 수 있는 새만 인자로 전달 될 것이다.
public void flyBird(FlyingBird bird) {
bird.fly();
}
하나의 클라이언트가 오직 fly 메시지만 전송하기를 원한다면 이 클라이언트에게는 fly 메시지만 보여야 한다. 다른 클라이언트가 오직 walk 메시지만 전송하기를 원한다면 이 클라이언트에게는 walk 메시지만 보여야 한다.
클라이언트에 따라 인터페이스를 분리하면 각 클라이언트의 요구가 바뀌더라도 영향의 파급 효과를 효과적으로 제어할 수 있게 된다.
이처럼 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙(Integerface Segrefation Principle, ISP)이라고 부른다.
행동 호환성을 만족하는 상속 관계는 부모 클래스를 새로운 자식 클래스로 대체 하더라도 시스템이 문제 없이 동작할 것이라는 것을 보장해야 한다. 다시 말해서 자식 클래스와 부모 클래스 사이의 행동 호환성은 부모 클래스에 대한 자식 클래스의 대체 가능성을 포함한다.
행동 호환성과 대체 가능성은 올바른 상속 관계를 구축하기 위해 따라야 할 지침이라고 할 수 있다.
리스코프 치완 원칙은 "서브 타입은 그것의 기반 타입에 대해 대체 가능해야 한다"는 것으로 클라이언트가 "차이점을 인식하지 못한 채 파생 클래스의 인터페이스를 통해 서브 클래스를 사용할 수 있어야 한다"는 것이다.
클라이언트와 서버 사이의 협력을 의무(obligation)와 이익(benefit)으로 구성된 계약의 관점에서 표현하는 것을 계약에 의한 설계라고 부른다. 계약에 의한 설계는 클라이언트가 정상적으로 메서드를 실행하기 위해 만족시켜야 하는 사전 조건(precondition)과 메서드가 실행된 후에 서버가 클라이언트에게 보장해야 하는 사후 조건(postcondirion)과 메서드 실행 전과 실행후에 인스턴스가 만족시켜야 하는 클래스 불변식(class invariant)의 세 가지 요소로 구성된다.
어떤 타입이 슈퍼 타입에서 정의한 사전 조건보다 더 약한 사전 조건을 정의하고 있다면 그 타입은 서브 타입이 될 수 있지만 더 강한 사전 조건을 정의한다면 서브 타입이 될 수 없다. 어떤 타입이 슈퍼 타입에서 정의한 사후 조건보다 더 강한 사후 조건을 정의 하더라도 그 타입은 여전히 서브 타입이지만 더 약한 사후 조건을 정의한다면 서브 타입의 조건이 깨지고 말 것이다.