상속에는 두 얼굴이 있다. 하나는 부모의 코드를 물려받아 재사용하는 서브클래싱이고, 다른 하나는 부모 타입이 기대되는 모든 자리에 자식을 넣어도 되는 서브타이핑이다. 둘은 문법적으로 똑같이 extends로 쓰이지만, 목적이 다르다. 이 장은 “언제 상속이 타입 계층이 되는가”를 묻는다. 답은 코드가 아니라 행동에 있다.

타입에는 두 관점이 있다

상속을 제대로 이해하려면 먼저 “타입이 무엇인가”를 정리해야 한다. 타입에는 두 가지 관점이 공존한다.

  • 개념 관점(conceptual perspective): 타입은 우리가 세상을 분류하는 범주다. “새”, “펭귄”, “정사각형”은 사람의 머릿속에 있는 개념이고, 그 개념에 속하는 대상들이 공유하는 공통 성질이 곧 타입이다. 이 관점에서 타입은 어휘이자 의미다.
  • 구현 관점(implementation perspective): 프로그래밍 언어에서 타입은 객체가 어떤 오퍼레이션을 제공하는가로 정의된다. 어떤 데이터를 담느냐가 아니라, 어떤 메시지에 응답할 수 있느냐가 타입을 결정한다.

이 둘은 다르다. 개념 관점에서 “펭귄은 새”라는 말은 참이지만, 구현 관점에서 펭귄이 fly()라는 오퍼레이션을 감당할 수 있느냐는 별개의 문제다. 객체지향에서 실제로 프로그램을 지배하는 것은 구현 관점 — 객체가 응답할 수 있는 메시지의 집합, 즉 퍼블릭 인터페이스다.

판단 기준: 두 대상이 같은 타입인지 물을 때는 “같은 범주에 속하는가”(개념)가 아니라 “같은 메시지에 같은 방식으로 응답하는가”(구현)를 먼저 확인한다. 함정: 개념 관점의 분류를 그대로 구현 관점의 상속으로 옮기면, 어휘상으로는 맞지만 오퍼레이션상으로는 어긋나는 타입이 만들어진다.

타입 계층은 일반화와 특수화다

타입들 사이의 계층 관계는 **일반화(generalization)**와 **특수화(specialization)**로 설명된다. 슈퍼타입은 서브타입보다 더 일반적이고, 서브타입은 슈퍼타입보다 더 특수하다. 그런데 여기서 “더 일반적”과 “더 특수”의 기준을 반드시 퍼블릭 인터페이스에 두어야 한다.

  • 슈퍼타입은 서브타입이 응답할 수 있는 메시지 집합의 부분집합을 정의한다. 서브타입은 슈퍼타입의 모든 메시지에 응답할 수 있으면서 자신만의 메시지를 더 가진다.
  • 즉 특수화란 “속성을 더 가진다”가 아니라 “슈퍼타입이 하던 일을 모두 하면서 더 많은 일을 한다”는 뜻이다.

판단 기준: 서브타입은 슈퍼타입이 이해하는 모든 메시지를 이해해야 한다 — 인터페이스가 계층의 기준이다. 함정: 데이터 필드(구현)의 포함 관계로 계층을 판단하면, 인터페이스는 호환되지 않는데 계층으로 묶는 실수를 저지른다.

행동 호환성의 판정자는 클라이언트다

두 타입 사이에 상속 관계를 맺어도 되는지는 프로그래밍 언어가 판정하지 않는다. extends 한 줄은 언제나 컴파일된다. 진짜 판정자는 그 타입을 사용하는 클라이언트다.

클라이언트는 슈퍼타입의 퍼블릭 인터페이스에 대해 어떤 행동을 기대한다. Rectangle을 쓰는 클라이언트는 “너비를 바꿔도 높이는 그대로”라고 기대하고, Bird를 쓰는 클라이언트는 “fly()를 부르면 난다”라고 기대한다. 서브타입이 이 기대를 지키면 행동 호환이고, 못 지키면 — 언어 문법과 무관하게 — 서브타입이 아니다.

판단 기준: 상속의 정당성은 슈퍼타입/서브타입 두 클래스만 봐서는 결정할 수 없다. 반드시 그 타입에 의존하는 클라이언트가 무엇을 기대하는지까지 넣고 판단한다. 함정: “부모 클래스와 자식 클래스가 논리적으로 잘 맞는다”고 만족하면, 정작 그 둘을 함께 쓰는 클라이언트 관점을 빠뜨린 채 위험한 계층을 만든다.

is-a는 충분한 기준이 아니다

객체지향 입문서는 “펭귄은 새다(Penguin is-a Bird)이면 상속하라”고 가르친다. 하지만 자연어의 is-a는 함정이다. 펭귄은 분명 새지만, Birdfly()가 있다면 펭귄은 그 타입이 될 수 없다. 어휘적 분류가 아니라 클라이언트가 무엇을 기대하는가가 상속의 기준이다.

판단 기준: “A는 B다”라는 문장이 참인지가 아니라, “B를 쓰는 코드에 A를 넣어도 그 코드가 여전히 옳게 동작하는가”를 묻는다. 함정: is-a 문장에 이끌려 상속하면, 자식이 부모의 행동 일부를 지킬 수 없는 순간 클라이언트가 깨진다.

펭귄-새 문제의 세 가지 대응, 그리고 왜 모두 실패하는가

Birdfly()가 있고 Penguin extends Bird인 상황을 어떻게든 살려 보려는 시도는 대개 셋 중 하나다. 그리고 셋 다 실패한다.

대응 1 — Bird에서 fly()를 제거한다. 모든 새가 나는 것은 아니니 fly()를 빼면 펭귄 문제는 사라진다. 하지만 이제 나는 새를 다루던 클라이언트가 갈 곳을 잃는다. fly()를 다른 곳에 새로 두어야 하고, 대다수의 정상적인 새가 나는 능력을 표현하지 못한다. 개념적으로 참인 사실(“대부분의 새는 난다”)을 모델에서 지워 버린 것이다.

class Bird {
    // fly() 를 지웠다. 하지만 나는 새를 쓰던 코드는 이제 무엇에 의존하지?
    public void eat() { /* ... */ }
}
 
class Penguin extends Bird { }
 
// 나는 새만 골라 쓰던 클라이언트가 표현할 대상을 잃는다.
void migrate(Bird bird) {
    // bird.fly();  ← 더 이상 부를 수 없다. Bird는 난다는 보장을 잃었다.
}

대응 2 — Penguin에서 fly()를 오버라이딩해 아무것도 하지 않게 한다. 컴파일은 통과하고 예외도 없다. 하지만 이것은 사후조건을 은밀히 약화한 것이다. fly()를 부른 클라이언트는 “이제 이 새는 날고 있다”고 믿는데, 펭귄은 조용히 무시했다. 겉으로는 멀쩡하지만 클라이언트의 기대가 배신당했고, 이 배신은 런타임에도 드러나지 않아 더 위험하다.

class Penguin extends Bird {
    @Override
    public void fly() {
        // 아무것도 하지 않는다 — "날았다"는 사후조건을 조용히 약화한다.
    }
}

대응 3 — Penguin.fly()가 예외를 던지게 한다. 정직하게 “나는 못 난다”고 알린다. 하지만 이제 Bird를 받는 모든 클라이언트가 fly() 호출을 try-catch로 감싸거나 instanceof Penguin으로 분기해야 한다. 다형성으로 없애려던 조건 분기가 클라이언트 쪽으로 되살아나고, 슈퍼타입을 슈퍼타입답게 쓸 수 없게 된다.

class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException();   // 정직하지만…
    }
}
 
void migrate(Bird bird) {
    if (!(bird instanceof Penguin)) bird.fly();   // 다형성이 조건문으로 되살아난다
}

세 대응이 모두 실패하는 이유는 하나다. 문제는 펭귄이 아니라 상속 관계 자체다. PenguinBird의 서브타입이 아닌데 서브타입으로 선언했기 때문에, 어디를 고쳐도 모순이 다른 자리로 옮겨 갈 뿐이다. 올바른 해법은 fly()를 가진 FlyingBird를 따로 두고 펭귄은 그 계층에 넣지 않는 것 — 즉 행동을 기준으로 계층을 다시 그리는 것이다.

판단 기준: 서브타입이 슈퍼타입의 오퍼레이션 하나를 감당하지 못한다면, 그것은 “그 메서드를 어떻게 처리하지”의 문제가 아니라 “이 상속이 애초에 옳은가”의 문제다. 함정: 제거·무시·예외 어느 국소 수정으로도 잘못된 계층은 고쳐지지 않는다. 모순을 옮길 뿐이다.

정사각형은 직사각형인가

수학적으로 정사각형은 직사각형의 특수한 경우다. 그래서 Square extends Rectangle이 자연스러워 보인다. 하지만 Rectangle의 너비와 높이를 독립적으로 바꿀 수 있다고 믿는 클라이언트 앞에서 이 상속은 무너진다. 아래 스텝이 그 붕괴를 보여준다.

Refactoring Step 직사각형 — 너비와 높이는 독립적이다
1/3
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)은 이 실패를 정확히 규정한다. 바버라 리스코프의 정식화는 이렇다 — S가 T의 서브타입이라면, T 타입의 객체를 사용하는 프로그램에서 그 객체를 S 타입의 객체로 바꾸어 넣어도 프로그램의 행동은 바뀌지 않아야 한다. 핵심은 “슈퍼타입에 대해 성립하던 성질(property)이 서브타입에 대해서도 성립해야 한다”는 것이고, 여기서 “성질”의 기준은 계약에 의한 설계(Design by Contract)로 구체화된다.

계약에 의한 설계는 메서드가 지켜야 할 약속을 세 가지로 나눈다 — 클라이언트가 만족시켜야 하는 사전조건(precondition), 메서드가 보장해야 하는 사후조건(postcondition), 그리고 객체가 언제나 참으로 유지해야 하는 불변식(invariant). LSP는 서브타입이 이 세 계약을 어떻게 다루어야 하는지로 번역된다.

  • 사전조건은 강화하지 마라(no strengthening) — 자식이 부모보다 더 까다로운 입력을 요구하면, 부모를 믿고 넉넉한 입력을 넣던 클라이언트가 거부당한다. 자식은 부모와 같거나 더 느슨한 입력을 받아야 한다.
  • 사후조건은 약화하지 마라(no weakening) — 자식이 부모보다 덜 보장하면, 부모의 결과를 믿던 클라이언트가 배신당한다. 자식은 부모와 같거나 더 강한 결과를 보장해야 한다.
  • 불변식은 유지하라(preserve) — 부모가 언제나 참이라고 약속한 성질을 자식이 깨뜨리면, 그 성질에 기대던 코드가 무너진다.

이 세 규칙으로 Rectangle/Square를 다시 서술하면 위반이 명확해진다. Rectangle의 불변식은 “setWidth(w) 이후 getWidth()==w이고 getHeight()는 이전 값 그대로”다. Rectangle.setWidth의 사후조건은 “너비만 바뀌고 높이는 불변”이다. Square.setWidth는 이 사후조건을 지키지 못한다 — 높이까지 바꾸므로 사후조건을 약화했고, “너비와 높이는 독립”이라는 불변식을 깨뜨렸다. 그래서 Square는 계약상 Rectangle의 서브타입이 아니다. getArea()width * height라는 계산 자체는 두 클래스 모두 지키지만, 그 값에 도달하는 과정의 계약이 깨졌다는 점이 핵심이다.

판단 기준: 자식이 부모 메서드를 오버라이드할 때, 사전조건은 같거나 넓게, 사후조건은 같거나 좁게, 불변식은 그대로 지키는지 세 가지를 각각 대조한다. 함정: 부모의 시그니처만 맞추고 계약은 다르게 구현하면 컴파일러는 통과시키지만 클라이언트는 런타임에 배신당한다. 시그니처는 타입 검사기가 보지만 계약은 사람이 봐야 한다.

is-a는 어휘가 아니라 행동이 결정한다

여기까지 오면 “정사각형은 직사각형이다”라는 문장의 진위가 상속과 무관하다는 것이 분명해진다. 수학의 세계에서 그 문장은 참이지만, 우리가 짜는 것은 수학이 아니라 가변 상태를 가진 객체들의 협력이다. Rectangle이 너비와 높이를 독립적으로 바꿀 수 있는 오퍼레이션을 제공하는 순간, 그 오퍼레이션의 계약을 지키지 못하는 어떤 것도 Rectangle이 될 수 없다 — 그 개념이 자연어로 무엇이라 불리든 상관없다.

같은 이유로, 어휘상 아무 관계없어 보이는 두 개념이 같은 메시지에 같은 계약으로 응답한다면 그들은 서로의 서브타입일 수 있다. is-a는 사전에서 찾는 것이 아니라 클라이언트가 기대하는 행동에서 판정된다.

판단 기준: 상속 여부를 정할 때 “이 개념이 저 개념의 일종인가”라는 어휘 질문을 “이 객체가 저 타입의 계약을 모두 지키는가”라는 행동 질문으로 바꾼다. 함정: 자연어 분류를 코드의 타입 계층으로 직역하면, 언어적으로는 흠잡을 데 없는데 행동상으로는 치환 불가능한 계층이 태어난다.

두 상속을 가르는 그림

flowchart TD
    S["extends 로 상속했다"]
    S --> Q{"부모를 기대하는 모든<br/>클라이언트에 자식을 넣어도<br/>여전히 옳게 동작하는가?"}
    Q -->|예| SUB["서브타이핑<br/>진짜 타입 계층 · 다형성 안전"]
    Q -->|아니오| CLS["서브클래싱<br/>코드 재사용일 뿐 · 다형적으로 쓰면 위험"]
    CLS --> FIX["해법: 상속을 끊고 합성으로<br/>또는 공통 계약만 인터페이스로 추출"]

같은 extends라도 오른쪽 갈래에 떨어졌다면, 그 상속은 재사용의 편의일 뿐 다형성의 근거가 되지 못한다. 자바 표준 라이브러리의 Stack extends Vector가 대표적 반례다 — Stack은 LIFO를 약속하지만 부모 Vectoradd(index, e)를 물려받아 중간 삽입이 가능해지므로, StackVector로 취급하는 순간 스택의 불변식이 깨진다. 코드는 물려받되 타입 계층에는 넣지 말았어야 했다.

서브클래싱은 구현 상속, 서브타이핑은 인터페이스 상속

이제 두 상속을 최종적으로 갈라 정리하자. 같은 extends 키워드가 사실은 목적이 전혀 다른 두 가지 일을 한다.

  • 서브클래싱(subclassing) = 구현 상속(implementation inheritance): 부모 클래스의 구현을 물려받아 코드를 재사용하는 것이 목적이다. 부모 타입을 대체할 수 있느냐는 관심 밖이다. 다형적으로 쓰이지 않는, 순전히 코드 공유를 위한 상속이다.
  • 서브타이핑(subtyping) = 인터페이스 상속(interface inheritance): 부모 타입이 기대되는 자리에 자식을 넣어도 되도록, **인터페이스(퍼블릭 계약)**를 물려받는 것이 목적이다. 다형성과 유연성의 근거는 오직 여기에 있다.

문제는 자바 같은 언어가 두 상속을 문법적으로 구분해 주지 않는다는 데 있다. extends 한 줄로 구현과 인터페이스를 한꺼번에 물려받으므로, 개발자가 “지금 나는 코드를 재사용하려는가, 타입 계층을 만들려는가”를 스스로 의식해야 한다. 코드 재사용만 원한다면 상속 대신 합성을 택해 구현만 빌리고 타입 관계는 맺지 않는 편이 안전하고, 타입 계층을 원한다면 반드시 LSP와 계약을 통과하는지 검증해야 한다.

판단 기준: 상속을 쓰기 전에 목적을 언어화한다 — “코드 재사용이면 합성을 먼저 검토, 다형성이면 LSP 검증”이다. 함정: extends가 주는 즉각적인 코드 재사용의 편리함에 이끌려 상속하면, 원치도 않은 서브타이핑 관계까지 딸려 와 나중에 다형적으로 쓰이는 순간 계약이 깨진다.

정리

  • 상속은 서브클래싱(코드 재사용)과 서브타이핑(타입 계층)이라는 두 목적을 가진다. 다형성의 근거가 되는 것은 후자뿐이다.
  • is-a 문장은 상속의 기준이 아니다. 기준은 “부모를 기대하는 클라이언트에 자식을 넣어도 옳게 동작하는가”다.
  • 정사각형/직사각형은 서브클래싱은 되지만 서브타이핑은 실패하는 전형이다. 클라이언트의 계약(너비·높이 독립)을 자식이 지킬 수 없기 때문이다.
  • LSP는 계약에 의한 설계로 구체화된다 — 사전조건을 강화하지 말고, 사후조건을 약화하지 마라.
  • 타입 호환이 안 되는데 코드만 재사용하고 싶다면 상속 대신 합성을 택한다.

다음장으로 14장