상속의 첫 번째 용도는 타입 계층을 구현하는 것이다. 타입 계층 안에서 부모 클래스는 일반적인 개념을 구현하고 자식 클래스는 특수한 개념을 구현한다. 타입 계층의 관점에서 부모 클래스는 자식 클래스의 일반화이고 자식 클래스는 부모 클래스의 특수화다.
상속의 두 번째 용도는 코드 재사용이다. 하지만 재사용을 위해 상속을 사용하면 부모, 자식 클래스가 강하게 결합되기 때문에 변경하기 어려운 코드를 얻게 된다.
결론은 동일한 메시지에 대해 서로 다르게 행동할 수 있는 다형적인 객체를 구현하기 위해서는 객체의 행동을 기반으로 타입 계층을 구성해야 한다. 상속의 가치는 이러한 타입 계층을 구현할 수 있는 쉽고 편안한 방법을 제공한다는 데 있다. 타입 사이의 관계를 고려하지 않은 채 단순히 코드를 재사용하기 위해 상속을 사용해서는 안된다.
이번 장에서는 올바른 타입 계층을 구성하는 원칙을 살펴보기로 한다.
객체지향 프로그래밍에서 타입의 의미를 이해하려면 프로그래밍 언어 관점에서의 타입과 개념 관점에서의 타입을 함께 살펴볼 필요가 있다.
개념 관점에서 타입이란 우리가 인지하는 세상의 사물의 종류를 의미한다. 다시 말해 우리가 인식하는 객체들에 적용하는 개념이나 아이디어를 가리켜 타입이라고 부른다.
어떤 대상이 타입으로 분류될 때 그 대상을 타입의 인스턴스라고 부른다. 일반적으로 타입의 인스턴스를 객체라고 부른다.
프로그래밍 언어 관점에서 타입은 연속적인 비트에 의미와 제약을 부여하기 위해 사용된다. 하드웨어는 데이터를 0과 1로 구성된 일련의 비트 조합으로 취급한다. 하지만 비트 자체에는 타입이라는 개념이 존재하지 않는다. 비트에 담긴 데이터를 문자열로 다룰지, 정수로 다룰지는 전적으로 데이터를 사용하는 애플리케이션에 의해 결정된다.
프로그래밍 언어에서 타입은 다음 목적을 위해 사용된다.
객체의 타입이란 객체가 수신할 수 있는 메시지의 종류를 정의하는 것이다.
우리는 이미 객체가 수신할 수 있는 메시지의 집합을 가리키는 용어를 알고 있다. 바로 퍼블릭 인터페이스다.
수학에서 집합은 다른 집합을 포함할 수 있다. 타입 역시 객체들의 집합이기 때문에 다른 타입을 포함하는 것이 가능하다.

더 세분화할 수 있다.


터입 계층을 구성하는 투 타입 간의 관계에서 더 일반적인 타입을 슈퍼 타입이라고 부르고 더 특수한 타입을 서브 타입이라고 부른다. '프로그래밍 언어' 타입은 '객체지향 언어' 타입과 '절차적 언어' 타입의 슈퍼타입이고, '객체지향 언어'타입은 '클래스 기반 언어' 타입과 '프로토 타입 기반 언어' 타입의 슈퍼타입이다.
타입 계층을 구현할 때 지켜야 하는 제약사항을 클래스와 상속의 관점에서 살펴보자.
마틴 오더스키는 다음과 같은 질문을 해보고 두 질문에 모두 '예'라고 답할 수 있는 경우에만 상속을 사용하라고 조언한다.
어떤 타입 S가 다른 타입 T의 이롲ㅇ이라면 당연히 "타입 S는 타입T다 (S is-a T)"라고 말할 수 있어야 한다.
하지만 is-a 관계가 직관적이고 명쾌하진 않다. 다음의 예를 살펴보자.
public class Bird {
public void fly() {
System.out.println("날다.");
}
}
public class Penguin extends Bird {
}
이 코드는 반은 맞고 반은 틀리다. 펭귄은 새이지만 날 수 없다.
이 예는 어휘적 정보가 아닌 기대되는 행동에 따라 타입 계층을 구성해야 한다는 사실을 잘 보여준다.
타입 계층의 의미는 행동이라는 문맥에 따라 달라질 수 있다.
펭귄이 새가 아니라는 사실을 받아드리기 위한 출발점은 타입이 행동과 관련이 있다는 사실에 주목하는 것이다.
타입 계층을 이해하기 위해서는 그 타입 계층이 사용될 문맥을 이해하는 것이 중요하다
다음과 같이 클라이언트가 날 수 있는 새만을 원한다고 가정해보자.
public void flyBird(Bird bird) {
bird.fly();
}
현재 Penguin은 Bird의 자식 클래스이기 때문에 컴파일러는 업캐스팅을 허용한다. 따라서 flyBird 메서드의 인자로 Penguin의 인스턴스가 전달되는 것을 막을 수 있는 방법이 없다. 하지만 Penguin은 날 수 없고 클라이언트는 모든 bird가 날 수 있기를 기대하기 때문에 flyBird 메서드로 전달돼서는 안된다.
상속 관계를 유지하면서 문제를 해결하는 세 가지 방법이 있다.
첫 번째 방법은 Penguin의 fly 메서드를 오버라이딩해서 내부 구현을 비워두는 것이다.
public class Penguin extends Bird {
@Override
public void fly() {
}
}
이제 Penguin에게 fly 메시지를 전송하더라도 아무 일도 일어나지 않는다. 따라서 Penguin은 날 수 없게 된다. 하지만 이 방법은 어떤 행동도 수행하지 않기 때문에 모든 bird가 날 수 있다는 클라이언트의 기대를 만족시키지 못한다. 따라서 올바른 설계라고 할 수 없다.
두 번째 방법은 Penguin의 fly 메서드를 오버라이딩한 후 예외를 던지게 하는 것이다.
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException();
}
}
하지만 이 경우에는 flyBird 메서드에 전달되는 인자의 타입에 따라 메서드가 실패하거나 성공하게 된다. flyBird 메서드는 모든 bird가 날 수 있다고 가정한다는 사실에 주목하라.
세 번째 방법은 flyBird 메서드를 수정해서 인자로 전달된 bird의 타입이 Penguin이 아닐 경우에만 fly 메시지를 전송하도록 하는 것이다.
public class Bird {
public void flyBird(Bird bird) {
if (!(bird instanceof Penguin)) {
bird.fly();
}
}
}
하지만 이 방법 역시 문제가 있다. 만약 Penguin 이외에 날 수 없는 또 다른 새가 상속 계층에 추가된다면 어떻게 할 것인가?
지금까지 살펴본 것처럼 행동 호환성을 만족시키지 않는 상속계층을 그대로 유지한 채 클라이언트의 기대를 충족시킬 수 있는 방법을 찾기란 쉽지 않다. 문제를 해결할 수 있는 방법은 클라이언트의 기대에 맞게 상속 계층을 분리하는 것뿐이다.
flyBird 메서드는 파라미터로 전달되는 모든 새가 날 수 있다고 가정하기 때문에 flyBird 메서드와 협력하는 모든 객체는 fly 메시지에 대해 올바르게 응답할 수 있어야 한다.
public class Bird {
public void flyBird(FlyBird bird) {
bird.fly();
}
}
public class FlyBird extends Bird {
}
public class Penguin extends Bird {
}
이제 flyBird 메서드는 FlyBird 타입을 이용해 날 수 있는 새만 인자로 전달돼야 한다는 사실을 코드에 명시할 수 있다. 만약 날 수 없는 새와 협력하는 메서드가 존재한다면 파라미터 타입을 Bird로 선언하면 된다.

이제 FlyingBird 인스턴스만이 fly 메서드를 수신할 수 있다. 날 수 없는 Bird의 서브타입인 Penguin의 인스턴스에게 fly 메시지를 전송할 수 있는 방법은 없다. 따라서 잘못된 객체와 협력해서 기대했던 행동이 수행되지 않거나 예외가 던져지는 일은 일어나지 않을 것이다.
이 문제를 해결하는 다른 방법은 클라이언트에 따라 인터페이스를 분리하는 것이다. 만약 Bird가 날 수 있으면서 걸을 수 있어야 하고, Penguin은 오직 걸을 수만 있다고 가정하자.
가장 좋은 방법은 walk 오퍼레이션을 가진 Walker 인터페이스로 분리하는 것이다.

만약 Penguin이 Bird의 코드를 재사용해야 한다면 어떻게 해야 할까? Penguin이 하나의 인터페이스만 구현하고 있기 때문에 문법상으로는 Penguin이 Bird를 상속받더라도 문제가 안 되겠지만 Penguin의 퍼블릭 인터페이스에 fly 오퍼레이션이 추가되기 때문에 이 방법을 사용할 수는 없다.
더 좋은 방법은 합성을 사용하는 것이다. 물론 Bird의 퍼블릭 인터페이스를 통해 재사용 가능하다는 전제를 만족시켜야 한다. 만약 Bird의 퍼블릭 인터페이스를 통해 재사용하기 어렵다면 Bird를 약간 수정해야 할 수도 있을 것이다. 대부분의 경우에 불안정한 상속 계층을 껴안고 가는 것보다 Bird를 재사용 가능하도록 수정하는 것이 더 좋은 방법이다.

클라이언트에 따라 인터페이스를 분리하면 변경에 대한 영향을 더 세밀하게 제어할 수 있게 된다.. 대부분의 경우 인터페이스는 클라이언트의 요구가 바뀜에 따라 변경된다. 클라이언트에 따라 인터페이스를 분리하면 각 클라이언트의 요구가 바뀌더라도 영향의 파급 효과를 효과적으로 제어할 수 있게 된다.
그림 13.5에서 Client1의 기대가 바뀌어서 Flyer의 인터페이스가 변경돼야 한다고 가정해보자. 이 경우 Flyer에 의존하고 있는 Bird가 영향을 받게 된다. 하지만 변경의 영향은 Bird에서 끝난다. Client2는 Flyer나 Bird에 대해 전혀 알지 못하기 때문에 영향을 받지 않는다.
이처럼 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙이라고 부른다.
그래서 언제 상속을 사용해야 하는가? 사람들은 상속을 두 가지 목적에 특별한 이름을 붙였는데 서브클래싱과 서브타이핑이 그것이다.
1988년 바바라 리스코프는 올바른 상속 관계의 특징을 정의하기 위해 리스코프 치환 원칙을 발표했다.
리스코프 치환 원칙을 한마디로 정리하면 "서브타입은 그것의 기반 타입에 의해 대체 가능해야 한다"는 것으로 클라이언트가 "차이점을 인식하지 못한 채 기반 클래스의 인터페이스를 통해 서브클래스를 사용할 수 있어야 한다"는 것이다.
정사각형과 직사각형의 상속 관계는 리스포크 치환 원칙을 위반하는 고전적인 사례 중 하나다.
public class Rectangle {
private int x, y, width, height;
public Rectangle(int x, int y, int width, int height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public int getArea() {
return width * height;
}
}
이제 Squre를 추가하자. 개념적으로 정사각형은 직사각형의 특수한 경우이고 직사각형은 정사각형의 일반적인 경우이기 때문에 어휘적으로 is -a 관계가 성립한다.

정사각형은 직사각형이다.
public class Square extends Rectangle {
public Square(int x, int y, int width, int height) {
super(x, y, width, height);
}
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(int height) {
super.setWidth(height);
super.setHeight(height);
}
}
Square는 Rectangle의 자식 클래스이기 때문에 Rectangle이 사용되는 모든 곳에서 Rectangle로 업캐스팅될 수 있다. 문제는 여기서 발생한다. Rectangle과 협력하는 클라이언트는 직사각형의 너비와 높이가 다르다고 가정한다. 따라서 아래의 예제 코드처럼 직사각형의 너비와 높이를 서로 다르게 설정하도록 프로그래밍할 것이다.

그러나 위 코드에 resize 메서드에 인자로 Rectangle 대신 Square를 전달한다고 가정해보자. Square의 setWidth 메서드와 setHeight 메서드는 항상 정사각형의 너비와 높이를 같게 설정한다. 위 코드에 따르면 Square의 너비와 높이는 항상 더 나중에 설정된 height의 값으로 설정된다. 따라서 다음 메서드 실행이 실패할 것이다.

resize 관점에서 Rectangle 대신에 Suqare를 사용할 수 없기 때문에 Square는 Rectangle이 아니다. Square는 Rectangle의 구현을 재사용하고 있을 뿐이다. 두 클래스는 리스코프 치환 원칙을 위반하기 때문에 서브타임이 관계가 아니라 서브클래싱 관계다.
Square가 Rectangle을 대체할 수 없는 이유는 클라이언트 Square와 Rectangle가 다르기 때문이다.
리스코프 치환 원칙은 자식 클래스가 부모 클래스를 대체하기 위해서는 부모 클래스에 대한 클라이언트의 가정을 준수해야 한다는 것을 강조한다. Square를 Rectangle의 자식으로 만드는 것은 Rectangle에 대한 클라이언트가 세운 가정을 뒤흔드는 것이다.
행동 호환성과 리스코프 치환 원칙에서 한 가지만 기억해야 한다면 이것을 기억하라. 대체 가능성을 결정하는 것은 클라이언트다.
리스코프 치환 원칙은 클라이언트가 어떤 자식 클래스와도 안정적으로 협력할 수 있는 상속 주로를 구현할 수 있는 가이드라인을 제공한다. 새로운 자식 클래스를 추가하더라도 클라이언트 입장에서 동일하게 행동하기만 한다면 클라이언트를 수정하지 않고도 상속 계층을 확장할 수 있다.
8장에서 중복 할인 정책을 구현하기 위해 새로운 자식 클래스를 추가하더라도 클라이언트를 수정할 필요가 없었던 것을 기억하는가?

사실 이 설계는 역전 원칙과 개방-폐쇄 원칙, 라스코프 치환 원칙이 한데 어우러져 설계를 확장 가능하게 만든 대표적인 예다. 그림 13.8을 보면서 각 원칙이 어떻게 적용됐는지 살펴보자.
