상속에는 두 얼굴이 있다. 하나는 부모의 코드를 물려받아 재사용하는 서브클래싱이고, 다른 하나는 부모 타입이 기대되는 모든 자리에 자식을 넣어도 되는 서브타이핑이다. 둘은 문법적으로 똑같이 extends로 쓰이지만, 목적이 다르다. 이 장은 “언제 상속이 타입 계층이 되는가”를 묻는다. 답은 코드가 아니라 행동에 있다.
is-a는 충분한 기준이 아니다
객체지향 입문서는 “펭귄은 새다(Penguin is-a Bird)이면 상속하라”고 가르친다. 하지만 자연어의 is-a는 함정이다. 펭귄은 분명 새지만, Bird에 fly()가 있다면 펭귄은 그 타입이 될 수 없다. 어휘적 분류가 아니라 클라이언트가 무엇을 기대하는가가 상속의 기준이다.
판단 기준: “A는 B다”라는 문장이 참인지가 아니라, “B를 쓰는 코드에 A를 넣어도 그 코드가 여전히 옳게 동작하는가”를 묻는다. 함정: is-a 문장에 이끌려 상속하면, 자식이 부모의 행동 일부를 지킬 수 없는 순간 클라이언트가 깨진다.
정사각형은 직사각형인가
수학적으로 정사각형은 직사각형의 특수한 경우다. 그래서 Square extends Rectangle이 자연스러워 보인다. 하지만 Rectangle의 너비와 높이를 독립적으로 바꿀 수 있다고 믿는 클라이언트 앞에서 이 상속은 무너진다. 아래 스텝이 그 붕괴를 보여준다.
class Rectangle { private int width; private int height; public Rectangle(int width, int height) { this.width = width; this.height = height; } public void setWidth(int width) { this.width = width; } public void setHeight(int height) { this.height = height; } public int getArea() { return width * height; }}// 클라이언트의 암묵적 계약: setWidth는 height를 건드리지 않는다.
Square는 그 자체로는 버그가 없다. 문제는 Rectangle을 인자로 받는 resize가 “너비를 바꿔도 높이는 그대로”라는 계약을 믿고 짜였다는 데 있다. Square는 이 계약을 지킬 수 없으므로, Rectangle의 자리에 들어갈 자격이 없다. 코드는 재사용됐지만 타입은 호환되지 않는다 — 이것이 서브클래싱은 됐으나 서브타이핑은 실패한 경우다.
리스코프 치환 원칙과 계약에 의한 설계
리스코프 치환 원칙(LSP)은 이 실패를 정확히 규정한다. 자식 타입은 부모 타입을 대체할 수 있어야 한다. 여기서 “대체 가능”의 기준은 계약에 의한 설계(Design by Contract)로 구체화된다.
- 사전조건은 강화하지 마라 — 자식이 부모보다 더 까다로운 입력을 요구하면, 부모를 믿던 클라이언트가 거부당한다.
- 사후조건은 약화하지 마라 — 자식이 부모보다 덜 보장하면, 부모의 결과를 믿던 클라이언트가 배신당한다.
Square는 getArea()가 width * height라는 부모의 사후조건을, setWidth가 높이를 바꾸는 방식으로 사실상 약화했다. 그래서 LSP 위반이다.
판단 기준: 자식이 부모 메서드를 오버라이드할 때, 부모가 받던 입력을 모두 받고(사전조건 유지) 부모가 약속한 결과를 모두 지키는지(사후조건 유지) 확인한다. 함정: 부모의 시그니처만 맞추고 행동은 다르게 구현하면 컴파일러는 통과시키지만 클라이언트는 런타임에 배신당한다.
두 상속을 가르는 그림
flowchart TD S["extends 로 상속했다"] S --> Q{"부모를 기대하는 모든<br/>클라이언트에 자식을 넣어도<br/>여전히 옳게 동작하는가?"} Q -->|예| SUB["서브타이핑<br/>진짜 타입 계층 · 다형성 안전"] Q -->|아니오| CLS["서브클래싱<br/>코드 재사용일 뿐 · 다형적으로 쓰면 위험"] CLS --> FIX["해법: 상속을 끊고 합성으로<br/>또는 공통 계약만 인터페이스로 추출"]
같은 extends라도 오른쪽 갈래에 떨어졌다면, 그 상속은 재사용의 편의일 뿐 다형성의 근거가 되지 못한다. 자바 표준 라이브러리의 Stack extends Vector가 대표적 반례다 — Stack은 LIFO를 약속하지만 부모 Vector의 add(index, e)를 물려받아 중간 삽입이 가능해지므로, Stack을 Vector로 취급하는 순간 스택의 불변식이 깨진다. 코드는 물려받되 타입 계층에는 넣지 말았어야 했다.
정리
- 상속은 서브클래싱(코드 재사용)과 서브타이핑(타입 계층)이라는 두 목적을 가진다. 다형성의 근거가 되는 것은 후자뿐이다.
- is-a 문장은 상속의 기준이 아니다. 기준은 “부모를 기대하는 클라이언트에 자식을 넣어도 옳게 동작하는가”다.
- 정사각형/직사각형은 서브클래싱은 되지만 서브타이핑은 실패하는 전형이다. 클라이언트의 계약(너비·높이 독립)을 자식이 지킬 수 없기 때문이다.
- LSP는 계약에 의한 설계로 구체화된다 — 사전조건을 강화하지 말고, 사후조건을 약화하지 마라.
- 타입 호환이 안 되는데 코드만 재사용하고 싶다면 상속 대신 합성을 택한다.
다음장으로 14장