상속
상속
· 마지막 업데이트: 2026-08-10 · 36 min read
상속(Inheritance)은 기존 클래스·타입의 구조, 구현, 계약 또는 관계를 이어받아 새로운 정의를 만드는 기전이다. 많은 클래스 기반 언어는 이를 특수화와 서브타입 관계를 표현하는 데도 쓰지만, 그건 상속의 정의가 아니라 사용 용례다.
클래스 상속에서는 구현이 함께 물려올 수 있고, 인터페이스 상속에서는 주로 타입 계약과 관계가 물려온다. 객체지향 문맥에서는 보통 컴포지션과 대비된다.
상속을 요약하고 컴포지션과 비교하면 다음과 같다.
방식 | 뜻 |
|---|---|
상속 | 상위 타입·클래스의 멤버와 계약을 하위 타입에 연결하고, 언어에 따라 구현도 물려받는다 |
컴포지션 | 다른 객체를 부품처럼 조립하고 일을 맡긴다 |
- 방식
상속
- 뜻
상위 타입·클래스의 멤버와 계약을 하위 타입에 연결하고, 언어에 따라 구현도 물려받는다
- 방식
컴포지션
- 뜻
다른 객체를 부품처럼 조립하고 일을 맡긴다
「상속보다 컴포지션」이라는 조언은 워낙 널리 퍼져서, 상속을 쓰면 일단 변명부터 해야 하는 분위기가 인터넷 커뮤니티에 있다.
개인적인 메모: 다만 한국 프로그래밍씬에서는 여전히 상속 쓰라고 하니까 '국제적인' 커뮤니티에서는 그렇다라는 뜻이다.\
한국의 모 금융 SI에서 여전히 깊은 상속을 하는데 코드 리뷰하다가 싸웠던 기억이 난다. 물론 돈 주는 사람 마음이라고 하지만, 경력 하나 믿고 모든걸 상속으로 구현하는게 맞다는 믿음은 어디서 발생하는건지 모르겠다.
핵심적으로 말하자면 그 조언은 상속을 금지하라는 말이 아니었다.
프로그래밍이 발전하면서 점점 상황에 따라 세분화되면서 여러 상황에 걸쳐 쓰던 것을 특정 상황에 맞게 줄이라는 것 정도로 이해해야지. 아예 안쓰는 것도 문제가 된다.
상속은 단순한 위임형 컴포지션이 자동으로 제공하지 않는 성질이 하나 있는데, 기반 코드의 가상 self 호출이 실제 자식 구현으로 되돌아오는 암묵적 개방 재귀(open recursion)이다. 이 문서는 그 자동 배선이 언제 유용하고, 그 대가로 무엇을 내주는지 정리한다.
내가 여기서 붙잡고 싶은 핵심은 이쪽이다.
항목 | 요약 |
|---|---|
구현 상속이 제공할 수 있는 것 | open recursion. 기반 코드의 가상 호출이 자식 구현으로 되돌아온다 |
그 대가 | 재정의 가능한 self 호출과 자식에게 노출된 상태 일부가 사실상 하위 클래스 계약이 된다 |
판단 기준 | 이 되돌아옴이 실제로 필요한가, 아니면 코드 재사용이 목적인가 |
- 항목
구현 상속이 제공할 수 있는 것
- 요약
open recursion. 기반 코드의 가상 호출이 자식 구현으로 되돌아온다
- 항목
그 대가
- 요약
재정의 가능한 self 호출과 자식에게 노출된 상태 일부가 사실상 하위 클래스 계약이 된다
- 항목
판단 기준
- 요약
이 되돌아옴이 실제로 필요한가, 아니면 코드 재사용이 목적인가
용어 구분
상속이라는 한 단어에 여러 개념이 겹쳐 있다.
일단 분리해야한다.
이름 | 뜻 | 구현시 얻는 이득 |
|---|---|---|
구현 상속 | 기반 클래스의 상태 표현과 메서드 구현을 언어의 상속·접근 규칙에 따라 재사용한다 | 코드 재사용, 가상 메서드에 한해 open recursion |
인터페이스 상속 | 계약을 물려받고, 언어에 따라 기본 구현도 물려받는다 | 다형성, 타입 관계 |
서브클래싱 | 클래스 계층에서 아래에 놓이는 관계 | 문법적 관계 |
서브타이핑 | 타입 시스템이 상위 타입 자리에 놓는 것을 허용하는 관계 | 정적 대체 가능성 |
행동적 서브타이핑 | 상위 타입의 의미적 계약까지 보존하는 관계 | 실제 대체 가능성 |
- 이름
구현 상속
- 뜻
기반 클래스의 상태 표현과 메서드 구현을 언어의 상속·접근 규칙에 따라 재사용한다
- 구현시 얻는 이득
코드 재사용, 가상 메서드에 한해 open recursion
- 이름
인터페이스 상속
- 뜻
계약을 물려받고, 언어에 따라 기본 구현도 물려받는다
- 구현시 얻는 이득
다형성, 타입 관계
- 이름
서브클래싱
- 뜻
클래스 계층에서 아래에 놓이는 관계
- 구현시 얻는 이득
문법적 관계
- 이름
서브타이핑
- 뜻
타입 시스템이 상위 타입 자리에 놓는 것을 허용하는 관계
- 구현시 얻는 이득
정적 대체 가능성
- 이름
행동적 서브타이핑
- 뜻
상위 타입의 의미적 계약까지 보존하는 관계
- 구현시 얻는 이득
실제 대체 가능성
여기서 서브클래싱과 서브타이핑을 같은 것으로 보는 사람들이 많은데, 살짝 다르다. Cook, Hill, Canning이 1990년 논문에서 정확히 그 구분을 다루었는데, 한국에서는 소개되지 않았다.[1] 상속은 구현을 물려받는 기전일 수 있고, 서브타이핑은 타입 시스템이 치환을 허용하는 관계이며, 행동적 서브타이핑은 그 치환이 의미적 계약까지 보존하는 관계인데, 여러 언어가 class B : A 같은 한 문법으로 둘을 함께 표현한다. 다만 C++의 private 상속처럼 외부에서 대체 가능성을 공개하지 않는 경우도 있고, 인터페이스 구현처럼 구현을 물려받지 않고 서브타입 관계만 만드는 경우도 있다. 그래서 하나만 원해도 둘 다 오는 일이 생기는 언어와 문법이 있는 반면, 둘을 분리할 수 있는 문법도 있다.
클래스 기반 구현 상속과 다른 구성·재사용 기전
이름 | 언어 | 상속과 다른 점 |
|---|---|---|
mixin | Ruby, Scala, Python 등 | 구현 조각을 조합하는 기전. 언어에 따라 |
trait | Rust, Scala, Smalltalk/Pharo 계열 | 계약과 동작을 조합하는 단위. 상태·충돌·선형화 규칙은 언어마다 다르다 |
임베딩 | Go | 필드·메서드를 승격하지만 override식 self 재지정은 없다 |
프로토타입 위임 | JavaScript | 클래스 계층 대신 객체의 prototype chain을 따라 위임한다. 흔히 prototypal inheritance라고도 부른다 |
- 이름
mixin
- 언어
Ruby, Scala, Python 등
- 상속과 다른 점
구현 조각을 조합하는 기전. 언어에 따라
mixin inheritance의 한 형태로 다뤄진다 [2]
- 이름
trait
- 언어
Rust, Scala, Smalltalk/Pharo 계열
- 상속과 다른 점
계약과 동작을 조합하는 단위. 상태·충돌·선형화 규칙은 언어마다 다르다
- 이름
임베딩
- 언어
Go
- 상속과 다른 점
필드·메서드를 승격하지만 override식 self 재지정은 없다
- 이름
프로토타입 위임
- 언어
JavaScript
- 상속과 다른 점
클래스 계층 대신 객체의 prototype chain을 따라 위임한다. 흔히 prototypal inheritance라고도 부른다
이 표의 “다르다”는 상속과 무관하다는 뜻이 아니다. mixin과 프로토타입 위임은 각각 상속의 변형으로 설명되기도 한다. 여기서 분리하는 기준은 클래스 기반 구현 상속과 같은 타입 관계·상태 재사용·디스패치 규칙을 제공하는가다.
임베딩은 특히 헷갈린다. 겉보기 문법이 상속과 비슷해서 상속인 줄 알기 쉬운데, 기반 메서드의 receiver가 바깥 타입으로 자동 교체되는 상속식 open recursion은 없다.
어디서 왔는가
상속은 한 번에 완성된 문법으로 발명된 것이 아니다. 시뮬레이션에서 공통 구조를 확장해야 했던 필요, 객체 시스템에서 공통 동작을 공유해야 했던 필요, 정적 타입 언어에서 대체 가능한 구현을 표현해야 했던 필요가 서로 겹치면서 현재의 의미가 만들어졌다.
출발점은 Simula다. Simula I은 시뮬레이션을 위한 언어였고, Simula 67에서는 클래스와 접두(prefixing)를 통해 한 클래스의 구조를 다른 클래스가 확장할 수 있었다. Dahl과 Nygaard가 훗날 정리한 역사에서도 핵심 문제는 시뮬레이션 대상의 공통 부분을 정의하고, 그 부분을 여러 특수한 프로세스에 맞게 확장하는 일이었다.[3] 그러므로 “처음 목적은 코드 재사용이 아니라 분류였다”라고 단정하기보다는, 시뮬레이션 모델의 분류와 확장을 위해 나온 기전이 나중에 코드 재사용과 상속 계층의 원형이 되었다고 쓰는 편이 정확하다.
그 뒤 상속은 여러 언어에서 서로 다른 이유로 변형됐다. Smalltalk-72에는 상속이 없었지만, 공통 동작을 여러 클래스에 복사해야 하는 문제가 커지면서 Smalltalk-76에 상속이 들어갔다. Dan Ingalls의 회고는 이 변화를 “공통 동작을 복사하지 않고 공유하고 싶었다”는 유지보수 문제와 연결한다.[4] C++은 1979년의 C with Classes에서 시작해 Simula의 클래스와 파생 클래스 개념을 C의 성능·호환성 맥락으로 가져왔고, 1980년의 초기 구현부터 public/private 접근 제어를 갖고 있었다. 다만 이때는 가상 함수가 없었고, 1983년 C++로 넘어가면서 virtual 함수에 의한 동적 다형성이 들어갔다. 다중 상속은 그보다 늦어서 1989년 6월 Release 2.0에서 도입됐다.[5] Java는 여러 클래스를 상속하지 않고 하나의 기반 클래스와 여러 인터페이스를 조합하는 모델을 택했다.[6] 한편 여러 언어와 연구는 mixin·trait·위임·임베딩처럼 상속이 한 덩어리로 묶고 있던 기능을 분리하는 방법을 발전시켰다. 이 갈래는 Java 이후에 시작된 것이 아니다. mixin 기반 상속의 형식화는 1990년에 이미 나와 있었다.[2]
상속의 역사에서 반복된 질문
시기 | 변곡점 | 그때 해결하려던 문제 |
|---|---|---|
1960년대 | Simula 67의 클래스와 접두 | 시뮬레이션 모델의 공통 구조를 정의하고 특수화하기 |
1970년대 | Smalltalk-76의 상속 | 여러 클래스에 공통 동작을 복사하지 않고 공유하기 |
1979~1983년 | C with Classes에서 C++로 | Simula식 클래스와 파생 클래스를 C에 도입하고, C++로 넘어가며 |
1990년대 | 서브타이핑 이론, GoF, Java | 구현 재사용·대체 가능성·계층 복잡도를 분리해서 다루기 |
1990년대 | 다중 상속·mixin·서브타이핑 분리 연구 | 재사용과 타입 관계를 나누고, 계층 결합을 분석하기 |
2000년대 | trait 연구의 정교화 | 재사용 단위를 좁히고 충돌 해결 규칙을 명시하기 |
2009년 이후 | Go의 임베딩, Rust의 trait | 클래스 상속 없이 조합과 계약을 제공하기 |
2011년 공개, 2016년 1.0 | Kotlin의 기본 | 상속과 재정의를 명시적 opt-in으로 만들기 |
- 시기
1960년대
- 변곡점
Simula 67의 클래스와 접두
- 그때 해결하려던 문제
시뮬레이션 모델의 공통 구조를 정의하고 특수화하기
- 시기
1970년대
- 변곡점
Smalltalk-76의 상속
- 그때 해결하려던 문제
여러 클래스에 공통 동작을 복사하지 않고 공유하기
- 시기
1979~1983년
- 변곡점
C with Classes에서 C++로
- 그때 해결하려던 문제
Simula식 클래스와 파생 클래스를 C에 도입하고, C++로 넘어가며
virtual함수에 의한 동적 다형성을 더하기
- 시기
1990년대
- 변곡점
서브타이핑 이론, GoF, Java
- 그때 해결하려던 문제
구현 재사용·대체 가능성·계층 복잡도를 분리해서 다루기
- 시기
1990년대
- 변곡점
다중 상속·mixin·서브타이핑 분리 연구
- 그때 해결하려던 문제
재사용과 타입 관계를 나누고, 계층 결합을 분석하기
- 시기
2000년대
- 변곡점
trait 연구의 정교화
- 그때 해결하려던 문제
재사용 단위를 좁히고 충돌 해결 규칙을 명시하기
- 시기
2009년 이후
- 변곡점
Go의 임베딩, Rust의 trait
- 그때 해결하려던 문제
클래스 상속 없이 조합과 계약을 제공하기
- 시기
2011년 공개, 2016년 1.0
- 변곡점
Kotlin의 기본
final- 그때 해결하려던 문제
상속과 재정의를 명시적 opt-in으로 만들기
이 흐름은 상속이 단순히 유행했다가 사라진 역사는 아니라는 점을 보여준다. 매 세대는 상속이 제공하던 기능 중 필요한 것을 남기고, 문제가 된 결합은 다른 문법이나 기본값으로 밀어냈다. 내가 보기에는 상속이 자연스럽게 느껴지는 이유 중 하나가 우리가 현실을 분류로 이해하기 때문이다. 그런데 실무 코드에서는 분류보다 독립적으로 변하는 변경 축을 다뤄야 하는 경우가 많고, 그때 상속 트리가 어긋나기 시작한다. 상속 트리는 분류를 표현하는 데는 강하지만, 변경 축을 나누는 데는 약하다.
개인 메모: 학교에서 배우는 예제가 대체로 Animal, Dog, Cat인 것이 이 출신 때문인 것 같다. 그런데 실무에서 짜는 코드에 동물은 안 나온다. 나오는 것은 결제 수단, 할인 정책, 리포트 형식 같은 것들이고 그것들은 분류라기보다 변경 축으로 갈라지는 경우가 많다.
상속이 한 번에 묶는 네 가지
Taivalsaari는 1996년 ACM Computing Surveys 논문에서 상속의 의미와 사용법에 대해 연구자들 사이에도 합의가 잘 되지 않는다고 정리했다.[7] 이 문서에서 실무적으로 갈라 보면, 그 혼란의 일부는 상속이라는 하나의 문법이 성격이 다른 역할을 함께 맡는 데서 온다. 아래 네 갈래는 그 논문의 분류가 아니라 이 문서가 세운 정리다.
상속이 하는 일 | 원하는 사람 | 원하지 않는데 딸려 올 수 있는 것 |
|---|---|---|
구현을 물려준다 | 코드를 재사용하고 싶은 사람 | 많은 언어에서 타입 관계와 대체 가능성까지 함께 온다 |
타입 계층을 만든다 | 다형성을 쓰고 싶은 사람 | 부모 구현에 대한 결합 |
개념을 분류한다 | 도메인을 표현하고 싶은 사람 | 한 축으로만 나눠지는 제약 |
일부만 재정의하게 한다 | 뼈대는 두고 단계만 바꾸고 싶은 사람 | 부모의 내부 호출 순서가 계약이 됨 |
- 상속이 하는 일
구현을 물려준다
- 원하는 사람
코드를 재사용하고 싶은 사람
- 원하지 않는데 딸려 올 수 있는 것
많은 언어에서 타입 관계와 대체 가능성까지 함께 온다
- 상속이 하는 일
타입 계층을 만든다
- 원하는 사람
다형성을 쓰고 싶은 사람
- 원하지 않는데 딸려 올 수 있는 것
부모 구현에 대한 결합
- 상속이 하는 일
개념을 분류한다
- 원하는 사람
도메인을 표현하고 싶은 사람
- 원하지 않는데 딸려 올 수 있는 것
한 축으로만 나눠지는 제약
- 상속이 하는 일
일부만 재정의하게 한다
- 원하는 사람
뼈대는 두고 단계만 바꾸고 싶은 사람
- 원하지 않는데 딸려 올 수 있는 것
부모의 내부 호출 순서가 계약이 됨
넷이 항상 같은 방향이면 문제가 없다. 프로그램이 커지면 갈라진다. 코드를 재사용하고 싶은 이유와 타입 관계가 필요한 이유가 서로 다른 곳을 가리키기 시작한다.
컴포지션은 이 넷 중 구현 재사용과 도메인 분류를 객체 조립으로 옮길 수 있게 한다. 타입 관계가 필요하면 인터페이스나 별도의 타입 추상화를 함께 사용할 수 있다. 그래서 컴포지션이 항상 상속의 네 번째 기능만 남긴다고 말할 수는 없지만, 상속의 암묵적 self 재지정이 필요한 자리는 여전히 별도로 남는다.
구현 상속이 제공하는 암묵적 open recursion
기반 메서드가 자기 자신의 다른 가상 메서드를 부를 때, 그 호출이 실제 런타임 타입의 재정의로 디스패치되는 성질을 open recursion이라고 한다. 상속이 있는 모든 호출이 그런 것은 아니다. virtual이 아닌 메서드, final 메서드, 정적으로 바인딩된 호출은 이 성질을 제공하지 않는다. 여기서 말하는 상속의 장점은 클래스 기반 구현 상속이 이 자기 참조를 암묵적으로 배선해 준다는 뜻이다. 말로는 애매하니 돌려 보는 편이 빠르다.
// 1. 상속: open recursion 이 공짜로 온다.
public class Base
{
public virtual string Name() => "Base";
// Greet 은 Base 에 있지만, 안에서 부르는 Name 은
// 실제 런타임 타입의 override 로 열려 있다.
public string Greet() => "hello from " + Name();
}
public sealed class Derived : Base
{
public override string Name() => "Derived";
}
// 2. 컴포지션으로 흉내 내면 끊긴다.
public sealed class BaseComponent
{
public string Name() => "BaseComponent";
// 여기서 this 는 BaseComponent 자신이다.
// 래퍼가 Name 을 아무리 재정의해도 여기로 되돌아오지 않는다.
public string Greet() => "hello from " + Name();
}
public sealed class WrapperNaive
{
private readonly BaseComponent inner = new BaseComponent();
public string Name() => "WrapperNaive";
public string Greet() => inner.Greet();
}
// 3. self 를 명시적으로 주입하면 복원할 수 있다. 대신 손으로 배선해야 한다.
public interface INamer
{
string Name();
}
public sealed class BaseWithSelf
{
private readonly INamer self;
public BaseWithSelf(INamer self) => this.self = self;
public string Greet() => "hello from " + self.Name();
}
public sealed class WrapperExplicit : INamer
{
private readonly BaseWithSelf inner;
// 생성자에서 this를 협력 객체에 넘긴다.
// 협력 객체가 생성 도중 this를 호출하거나 외부에 공개하면
// 초기화가 끝나지 않은 상태가 관찰될 수 있다.
// 이 예제는 참조를 저장만 하지만 배선 규율은 추가된다.
public WrapperExplicit() => inner = new BaseWithSelf(this);
public string Name() => "WrapperExplicit";
public string Greet() => inner.Greet();
}실행 결과다.
상속 Greet() = hello from Derived
컴포지션 위임 Greet() = hello from BaseComponent
self 명시 주입 Greet() = hello from WrapperExplicit두 번째 줄이 핵심이다. 위임만으로는 되돌아옴이 자동으로 생기지 않는다. 내부 객체의 this는 래퍼가 아니라 내부 객체 자신이기 때문이다. 다만 세 번째 예처럼 self를 명시적으로 주입하면 컴포지션으로도 같은 효과를 만들 수 있고, mixin·trait·고차 함수도 open recursion을 다른 방식으로 모델링할 수 있다. 따라서 open recursion을 상속만의 논리적 독점물이라고 부르면 안 된다. 이 문서에서 비교하는 것은 암묵적으로 제공되는 방식과 명시적으로 배선해야 하는 방식의 차이다.
세 번째 줄은 복원이 가능하다는 것을 보여준다. 대신 생성자에서 this를 협력 객체에 넘겨야 하고, 그 객체가 생성 중에 호출하거나 외부로 내보내면 초기화가 끝나지 않은 상태를 볼 수 있다. 이 예제는 참조만 저장하므로 즉시 문제가 생기지는 않지만, 상속보다 배선 규칙이 하나 더 생긴다는 사실은 남는다.
Go의 임베딩은 상속이 아니다
Go에는 상속이 없고 임베딩이 있다. 문법이 비슷해 보여서 같은 것으로 착각하기 쉬운데, 정확히 이 지점에서 다르다.
type Base struct{}
func (b Base) Name() string { return "Base" }
func (b Base) Greet() string { return "hello from " + b.Name() }
type Derived struct {
Base // 임베딩. 겉보기에는 상속처럼 읽힌다.
}
// Derived 가 Name 을 "재정의"한 것처럼 보인다.
func (d Derived) Name() string { return "Derived" }돌려 보면 이렇게 나온다.
임베딩 d.Name() = Derived
임베딩 d.Greet() = hello from Base
명시적 self 주입 e.Greet() = hello from DerivedExplicitd.Name()은 Derived의 것이 불리는데 d.Greet()은 여전히 Base의 Name을 부른다. 임베딩은 메서드를 바깥 타입으로 승격시켜 줄 뿐, 임베디드 메서드의 receiver를 바깥 타입으로 바꾸는 상속식 되돌아옴을 만들지 않는다.
여기서 흔한 오해가 하나 있다. 인터페이스로 호출하면 달라지지 않느냐는 것이다. 달라지지 않는다.
type Greeter interface {
Name() string
Greet() string
}
var g Greeter = Derived{}구체 타입 d.Name() = Derived
구체 타입 d.Greet() = hello from Base
인터페이스 g.Name() = Derived
인터페이스 g.Greet() = hello from Base인터페이스 호출 자체는 동적 값의 메서드 집합을 따르므로 Go에 동적 디스패치가 없는 것은 아니다. 다만 Greet은 Base에서 승격된 메서드이고 그 안의 b.Name()은 여전히 Base 리시버에 묶여 있다. 동적 디스패치는 바깥 경계에서 일어나고, 임베디드 메서드 내부의 self 호출까지 바깥 타입으로 재지정되지는 않는다. Go에서 이 예제와 같은 self 재지정 효과가 필요하면 인터페이스 필드로 self를 손수 꽂아야 하고, 그것이 세 번째 줄이다. [8]
이건 Go의 결함이 아니라 선택이다. 이 예제처럼 임베딩된 메서드가 바깥 타입의 override로 되돌아오지 않으면, 앞에서 다룬 open-recursion 기반의 취약한 기반 클래스 문제는 생기지 않는다. 인터페이스에 self를 명시적으로 주입하면 비슷한 결합을 다시 만들 수 있지만, 그때는 배선이 코드에 드러난다.
개인 메모: 임베딩을 상속이라고 부르는 글이 꽤 많은데, 위 세 줄을 직접 돌려 보면 다시는 헷갈리지 않는다. 개념 논쟁보다 출력 한 줄이 빠를 때가 있다.
취약한 기반 클래스 문제
open recursion에는 대가가 있다. 부모가 내부에서 재정의 가능한 메서드를 부르는 순간, 그 호출 순서와 횟수는 내부 구현에만 머물지 않는다. 자식이 관찰하거나 의존할 수 있기 때문이다. 문서에 적지 않아도 하위 클래스 계약처럼 작동한다.
이 현상은 형식적으로 연구된 주제다. Mikhajlov와 Sekerinski는 1998년 ECOOP 논문에서 취약한 기반 클래스 문제를 정의하고, 기반 클래스를 안전하게 진화시키기 위해 지켜야 할 조건들을 정리했다.[9]
돌려 보면 이렇다. 기반 클래스 두 판을 준비한다. 외부에서 보는 동작은 완전히 같고, 내부 구현만 다르다.
// 기반 클래스 v1. AddRange 는 내부에서 Add 를 부른다.
public class BagV1
{
protected readonly List<string> items = new List<string>();
public virtual void Add(string item) => items.Add(item);
public virtual void AddRange(IEnumerable<string> range)
{
foreach (var item in range)
{
Add(item); // 내부 self dispatch. 이게 사실상 계약이 된다.
}
}
public int Count => items.Count;
}
// 기반 클래스 v2. 외부 계약은 동일하고 내부 구현만 바꿨다.
public class BagV2
{
protected readonly List<string> items = new List<string>();
public virtual void Add(string item) => items.Add(item);
public virtual void AddRange(IEnumerable<string> range)
{
items.AddRange(range); // Add 를 거치지 않는다.
}
public int Count => items.Count;
}자식은 추가된 개수를 세고 싶었을 뿐이다.
public sealed class CountingV1 : BagV1
{
public int Added;
public override void Add(string item) { Added++; base.Add(item); }
public override void AddRange(IEnumerable<string> range) { base.AddRange(range); }
}
public sealed class CountingV2 : BagV2
{
public int Added;
public override void Add(string item) { Added++; base.Add(item); }
public override void AddRange(IEnumerable<string> range) { base.AddRange(range); }
}결과다.
기반 v1 Count=3 Added=3
기반 v2 Count=3 Added=0
자식 코드는 한 글자도 바뀌지 않았다.
기반 클래스의 내부 구현만 바뀌었는데 Added 가 3 에서 0 이 됐다.Count는 둘 다 3이다. 공개 계약은 지켜졌다. 그런데 자식이 세던 값이 사라졌다.
문제의 성격을 정확히 짚어야 한다. 기반 클래스 작성자는 일반 사용자용 공개 계약을 어기지 않았다. AddRange가 내부적으로 Add를 부른다는 사실은 일반적인 공개 API 계약에 적혀 있지 않았기 때문이다. 그러나 클래스를 상속용으로 열어 두었다면 이야기가 달라진다. 재정의 가능한 메서드의 내부 호출은 하위 클래스가 관찰할 수 있으므로, 상속 계약으로는 문서화했어야 할 지점이다. 문제는 바로 이런 내부 구현이 상속을 통해 계약으로 승격된다는 데 있다. Snyder가 1986년 논문에서 상속이 캡슐화의 장점을 약화시킬 수 있다고 지적한 것이 이 구조다.[10]
여기서 나오는 실무 규칙이 셋이다.
규칙 | 이유 |
|---|---|
재정의 가능한 메서드의 내부 호출을 의식적으로 제한한다 | 호출 순서가 암묵적 계약이 되는 것을 막는다 |
알고리즘을 고정할 부분은 비가상 private 메서드로, 재정의 지점은 protected 훅으로 따로 판다 | 무엇이 계약인지 명시한다 |
그 훅의 호출 시점과 횟수를 상속 계약에 남긴다 | 문서가 없으면 자식은 동작을 추측할 수밖에 없다 |
- 규칙
재정의 가능한 메서드의 내부 호출을 의식적으로 제한한다
- 이유
호출 순서가 암묵적 계약이 되는 것을 막는다
- 규칙
알고리즘을 고정할 부분은 비가상 private 메서드로, 재정의 지점은 protected 훅으로 따로 판다
- 이유
무엇이 계약인지 명시한다
- 규칙
그 훅의 호출 시점과 횟수를 상속 계약에 남긴다
- 이유
문서가 없으면 자식은 동작을 추측할 수밖에 없다
만약 자식이 개수를 세는 것이 목적이었다면, 상속보다 위임 래퍼가 안전하다. 래퍼는 공개 API만 쓰므로 기반 클래스의 내부가 바뀌어도 깨지지 않는다. 「상속보다 컴포지션」이 가장 정확하게 맞는 지점이 여기다.
상속과 서브타이핑은 다르다
class B : A를 쓰면 대부분의 언어에서 B의 인스턴스를 A 자리에 넣을 수 있게 된다. 여기까지는 정적 타입 시스템의 판단이다. 컴파일러는 언어가 정의한 타입 규칙을 검사하지만, 그 치환이 프로그램의 의미적 계약까지 보존하는지는 일반적으로 보장하지 않는다. 그 자리에 넣어도 프로그램이 여전히 옳은가는 한 층 위의 문제이며, 여기서 Liskov와 Wing이 정리한 행동적 서브타이핑 조건이 따라붙는다. 상위 타입에 대해 증명된 성질이 하위 타입에서도 유지되어야 한다는 것이다.[11]
이건 상속만의 문제가 아니다. 인터페이스를 구현해도 타입 시스템 수준의 치환은 허용되고, 행동적 서브타이핑의 의무도 똑같이 따라온다. 다만 상위 타입의 계약이 Add 성공을 보장하는데 하위 타입만 이를 거부한다면 대체 가능성을 위반한다. .NET의 ICollection<T>.Add처럼 컬렉션이 읽기 전용이면 NotSupportedException을 던질 수 있다고 상위 계약이 허용하는 경우, 예외를 던지는 것만으로 LSP 위반이라고 할 수는 없다.[12]
그래서 정확한 정리는 이렇다.
문장 | 참인가 |
|---|---|
상속은 서브타입 관계를 만든다 | 대부분의 언어에서 그렇다. 다만 여기서 만들어지는 것은 타입 시스템 수준의 치환 허용이다 |
타입 시스템이 치환을 허용하면 행동적 서브타이핑도 따라온다 | 성립하지 않는다. 정적 타입 적합성과 행동 계약의 보존은 다른 문제다 |
행동적 서브타이핑은 상위 타입의 의미적 계약까지 요구한다 | 참이다. 이건 컴파일러가 아니라 작성자가 지켜야 한다 |
그러므로 상속이 나쁘다 | 성립하지 않는다 |
그러므로 대체 가능성이 필요 없으면 상속으로 타입 관계를 만들지 않는다 | 이게 맞는 결론이다 |
- 문장
상속은 서브타입 관계를 만든다
- 참인가
대부분의 언어에서 그렇다. 다만 여기서 만들어지는 것은 타입 시스템 수준의 치환 허용이다
- 문장
타입 시스템이 치환을 허용하면 행동적 서브타이핑도 따라온다
- 참인가
성립하지 않는다. 정적 타입 적합성과 행동 계약의 보존은 다른 문제다
- 문장
행동적 서브타이핑은 상위 타입의 의미적 계약까지 요구한다
- 참인가
참이다. 이건 컴파일러가 아니라 작성자가 지켜야 한다
- 문장
그러므로 상속이 나쁘다
- 참인가
성립하지 않는다
- 문장
그러므로 대체 가능성이 필요 없으면 상속으로 타입 관계를 만들지 않는다
- 참인가
이게 맞는 결론이다
구현만 재사용하고 싶고 대체 가능성은 필요 없다면, 상속은 원하지 않는 것을 하나 더 주는 셈이다. C++의 private 상속이나 위임이 그 자리에 맞는다.
다중 상속과 선형화
여러 부모에게서 물려받으면 같은 이름이 여러 경로로 도착할 수 있다. 이른바 다이아몬드 문제다.
A
/ \
B C
\ /
DD가 A의 상태를 몇 벌 갖는가, A의 메서드 중 어느 것이 불리는가. 언어마다 답이 다르다.
언어 | 처리 방식 |
|---|---|
C++ | 기본은 A가 두 벌. |
Python | C3 선형화로 메서드 결정 순서를 하나로 정한다 |
Java | 여러 클래스로부터의 다중 상속은 금지한다. 여러 인터페이스의 기본 메서드가 충돌하면 구현 클래스가 반드시 재정의해야 하고, 하지 않으면 컴파일 오류다 [13] |
C# | 여러 클래스로부터의 다중 상속은 금지한다. 인터페이스 기본 구현이 여럿이면 유일한 가장 구체적인 구현을 고르고, 그런 구현이 없으면 컴파일 오류다. 구현 클래스가 그 멤버를 직접 구현하거나 인터페이스 계층에서 명시적으로 해소해야 한다 [13] |
Ruby, Scala | mixin을 선형화 순서에 따라 끼워 넣는다 |
Rust | 상속이 없고 trait으로 구성한다 |
- 언어
C++
- 처리 방식
기본은 A가 두 벌.
virtual상속으로 한 벌로 합칠 수 있다
- 언어
Python
- 처리 방식
C3 선형화로 메서드 결정 순서를 하나로 정한다
- 언어
Java
- 처리 방식
여러 클래스로부터의 다중 상속은 금지한다. 여러 인터페이스의 기본 메서드가 충돌하면 구현 클래스가 반드시 재정의해야 하고, 하지 않으면 컴파일 오류다 [13]
- 언어
C#
- 처리 방식
여러 클래스로부터의 다중 상속은 금지한다. 인터페이스 기본 구현이 여럿이면 유일한 가장 구체적인 구현을 고르고, 그런 구현이 없으면 컴파일 오류다. 구현 클래스가 그 멤버를 직접 구현하거나 인터페이스 계층에서 명시적으로 해소해야 한다 [13]
- 언어
Ruby, Scala
- 처리 방식
mixin을 선형화 순서에 따라 끼워 넣는다
- 언어
Rust
- 처리 방식
상속이 없고 trait으로 구성한다
선형화는 문제를 없앤 것이 아니라 규칙으로 결정했을 뿐이다. 계층에 클래스가 하나 추가되면 순서가 바뀔 수 있고, 그러면 아무도 손대지 않은 코드의 동작이 달라진다.
Schärli 등은 2003년 ECOOP 논문에서 단일 상속, 다중 상속, mixin 상속이 각각 개념적·실용적 한계를 갖는다고 정리하고, 원래의 trait을 상태 없는 동작의 재사용 단위로 제안했다.[14] 이 설명은 그 논문의 trait 모델에 대한 것이지, 오늘날 trait이라는 이름을 쓰는 모든 언어의 공통 규칙은 아니다. Rust trait은 필드를 직접 선언하지 않지만 기본 메서드·연관 타입·연관 상수를 가질 수 있고, Scala trait은 구체적인 멤버와 상태를 가질 수 있다. [15] 충돌을 어떻게 해결하는지도 언어마다 다르므로 “trait은 항상 무상태이고 항상 명시적으로 충돌을 해결한다”라고 일반화하면 안 된다.
언어들이 내린 결정
지난 삼십 년 동안 언어들이 상속에 대해 내린 결정을 늘어놓으면 방향이 보인다.
결정 | 언어 | 의미 |
|---|---|---|
다중 클래스 상속 금지 | Java, C# | 여러 클래스를 직접 상속하지 않고, 여러 인터페이스 구현과 별도의 기본 구현 규칙으로 관계를 만든다 [13] |
메서드가 기본 비가상 | C++, C# | 재정의를 명시적 선택으로 만든다. 단, 클래스 자체의 상속 가능 여부와는 별개다 |
클래스가 기본 final | Kotlin | 확장을 허가제로 만든다 |
클래스 상속 없음 | Go, Rust | Go는 임베딩·메서드 집합을, Rust는 trait·supertrait를 사용한다 |
인터페이스 기본 구현 | Java 8 이후, C# 8 이후 | 클래스 구현 상속과 다른 재사용·호환성 경로를 제공한다 |
- 결정
다중 클래스 상속 금지
- 언어
Java, C#
- 의미
여러 클래스를 직접 상속하지 않고, 여러 인터페이스 구현과 별도의 기본 구현 규칙으로 관계를 만든다 [13]
- 결정
메서드가 기본 비가상
- 언어
C++, C#
- 의미
재정의를 명시적 선택으로 만든다. 단, 클래스 자체의 상속 가능 여부와는 별개다
- 결정
클래스가 기본 final
- 언어
Kotlin
- 의미
확장을 허가제로 만든다
- 결정
클래스 상속 없음
- 언어
Go, Rust
- 의미
Go는 임베딩·메서드 집합을, Rust는 trait·supertrait를 사용한다
- 결정
인터페이스 기본 구현
- 언어
Java 8 이후, C# 8 이후
- 의미
클래스 구현 상속과 다른 재사용·호환성 경로를 제공한다
상속 기반 다형성 자체를 값 의미론과 타입 소거로 대체하자는 주장도 나왔다.[16] 다만 주류 언어들이 실제로 간 길은 그쪽이 아니었다.
공통된 단일 방향이 있었다고 말하기는 어렵다. Java와 C#은 다중 클래스 상속은 막았지만 여러 인터페이스와 기본 구현 경로는 남겼고, C++은 비가상 메서드를 기본값으로 두었으며, Kotlin은 클래스와 멤버를 기본적으로 닫았다. Go와 Rust는 클래스 상속 자체를 채택하지 않고 임베딩·trait·인터페이스를 제공했다. 더 정확한 공통점은 확장 경계를 한 가지 상속 문법에 몰아넣지 않고, 언어마다 상속·계약·구성의 경계를 명시적으로 나누려 했다는 것이다.
「상속보다 컴포지션」이라는 처방은 유행처럼 오르내렸지만, 상속·재정의·계약·구성을 예전보다 명시적인 선택으로 분리하려는 방향은 언어의 문법과 기본값에 흡수되어 남았다. 다음 세대는 그 논쟁을 따로 배우지 않아도 그런 언어를 그냥 쓴다.
언제 상속이 맞는가
다음에 해당하면 상속이 자연스럽다.
조건 | 설명 |
|---|---|
알고리즘 뼈대를 고정하고 단계만 열고 싶다 | Template Method. open recursion이 본질적으로 필요하다[17] |
프레임워크가 상속 기반 확장점을 요구한다 | UI 생명주기, 게임 엔진 컴포넌트, 테스트 픽스처 |
타입 계층 자체가 도메인 모델의 핵심이다 | 표현식 트리, 추상 구문 트리, 대수적 자료형 흉내 |
부모와 자식을 같은 팀이 같은 모듈에서 관리한다 | 기반 클래스를 바꿀 때 자식까지 함께 고칠 수 있다 |
안정적인 하위 타입 관계가 실제로 존재한다 | 대체 가능성이 필요하고 앞으로도 유지된다 |
- 조건
알고리즘 뼈대를 고정하고 단계만 열고 싶다
- 설명
Template Method. open recursion이 본질적으로 필요하다[17]
- 조건
프레임워크가 상속 기반 확장점을 요구한다
- 설명
UI 생명주기, 게임 엔진 컴포넌트, 테스트 픽스처
- 조건
타입 계층 자체가 도메인 모델의 핵심이다
- 설명
표현식 트리, 추상 구문 트리, 대수적 자료형 흉내
- 조건
부모와 자식을 같은 팀이 같은 모듈에서 관리한다
- 설명
기반 클래스를 바꿀 때 자식까지 함께 고칠 수 있다
- 조건
안정적인 하위 타입 관계가 실제로 존재한다
- 설명
대체 가능성이 필요하고 앞으로도 유지된다
반대로 다음이면 상속이 아니다.
신호 | 대신 쓸 것 |
|---|---|
부모의 코드 몇 줄을 재사용하고 싶을 뿐이다 | 함수 추출, 위임 |
자식 클래스 이름이 조합식으로 길어진다 | 변경 축별 컴포지션 |
부모의 내부 구현을 알아야 자식을 쓸 수 있다 | 위임 래퍼 |
런타임에 행동을 바꿔야 한다 | 전략 객체 |
부모가 다른 팀 또는 외부 라이브러리의 것이다 | 위임 래퍼 |
- 신호
부모의 코드 몇 줄을 재사용하고 싶을 뿐이다
- 대신 쓸 것
함수 추출, 위임
- 신호
자식 클래스 이름이 조합식으로 길어진다
- 대신 쓸 것
변경 축별 컴포지션
- 신호
부모의 내부 구현을 알아야 자식을 쓸 수 있다
- 대신 쓸 것
위임 래퍼
- 신호
런타임에 행동을 바꿔야 한다
- 대신 쓸 것
전략 객체
- 신호
부모가 다른 팀 또는 외부 라이브러리의 것이다
- 대신 쓸 것
위임 래퍼
마지막 항목이 특히 중요하다. 다른 팀이 관리하는 public 클래스를 상속하고 그쪽의 문서화되지 않은 내부 호출 순서에 의존하면, 다음 버전에서 그 구현이 바뀔 때 이쪽이 조용히 깨진다. 이때 상대는 공개된 상속 계약을 어긴 것이 아닐 수 있고, 그러면 하소연할 데도 없다. 반대로 그 호출 순서가 상속 계약으로 문서화되어 있었다면 이야기가 달라진다. 그건 상대 쪽의 계약 위반이다. 그래서 외부 클래스를 상속하기 전에 확인할 것은 그 클래스가 상속용으로 문서화되어 있는가다.
개인 메모: 외부 라이브러리를 상속해서 확장한 코드는 업그레이드할 때 제일 먼저 터진다. 그런데 처음 짤 때는 그게 제일 빨라 보인다. 빨라 보이는 길이 나중에 청구서를 보내는 구조는 프로그래밍에 꽤 흔하다.
상속을 열 때 먼저 정할 것
Bloch는 Effective Java에서 상속을 두 항목으로 나눠 다룬다. 하나는 컴포지션을 선호하라는 것이고, 다른 하나는 상속을 위해 설계하고 문서화하거나 아니면 상속을 금지하라는 것이다.[18] 두 번째가 실무에서 더 자주 필요하다.
상속용으로 만든 클래스는 메서드 몇 개를 virtual로 바꾼다고 끝나지 않는다. 자식이 무엇을 믿어도 되는지, 기반 클래스가 어디까지 책임질지를 먼저 정해야 한다.
확인할 것 | 이유 |
|---|---|
재정의 가능한 메서드의 호출 시점과 순서 | 자식은 그 흐름을 관찰하고 의존할 수 있다 |
자기 호출을 줄이고 private 헬퍼로 분리하기 | 내부 호출이 숨은 계약으로 커지는 범위를 줄인다 |
생성자에서 재정의 가능한 메서드를 부르지 않기 | 파생 객체의 초기화가 끝나기 전에 호출될 수 있다 |
확장점을 protected 훅으로 좁게 열기 | 공개할 지점만 남기고 나머지는 닫을 수 있다 |
상태를 protected로 노출하지 않기 | 내부 표현을 바꾸기 어려워진다 |
상속할 계획이 없다면 클래스를 | 기본값을 안전한 쪽에 둘 수 있다 |
- 확인할 것
재정의 가능한 메서드의 호출 시점과 순서
- 이유
자식은 그 흐름을 관찰하고 의존할 수 있다
- 확인할 것
자기 호출을 줄이고 private 헬퍼로 분리하기
- 이유
내부 호출이 숨은 계약으로 커지는 범위를 줄인다
- 확인할 것
생성자에서 재정의 가능한 메서드를 부르지 않기
- 이유
파생 객체의 초기화가 끝나기 전에 호출될 수 있다
- 확인할 것
확장점을 protected 훅으로 좁게 열기
- 이유
공개할 지점만 남기고 나머지는 닫을 수 있다
- 확인할 것
상태를 protected로 노출하지 않기
- 이유
내부 표현을 바꾸기 어려워진다
- 확인할 것
상속할 계획이 없다면 클래스를
sealed로 닫기- 이유
기본값을 안전한 쪽에 둘 수 있다
세 번째 항목은 자주 물린다. 생성자에서 재정의 가능한 메서드를 부르면 안 되는 이유는 파생 객체의 초기화가 아직 끝나지 않았기 때문이다. 세부 순서는 언어마다 다르다. Java에서는 부모 생성자가 자식 필드 초기화보다 먼저 실행된다. C#에서는 자식의 필드 초기화식이 부모 생성자보다 먼저 실행되지만, 자식 생성자 본문은 부모 생성자가 끝난 뒤 실행된다.[6] 그래서 C#의 기반 생성자에서 호출된 override는 필드 초기화식의 값은 볼 수 있어도 자식 생성자 본문이 설정할 상태는 아직 볼 수 없다. 어느 경우든 완전히 초기화되지 않은 객체를 관찰한다는 위험은 같다.
성능 이야기
상속과 가상 디스패치는 다르지만 실무에서 같이 온다. 그리고 내부 루프에서는 비용이 보일 수 있다.
비용 | 설명 |
|---|---|
가상 호출 | 런타임 구현에 따라 간접 호출·디스패치 테이블·인라인 캐시 등을 거친다 |
인라인 방해 | 호출 대상이 정해지지 않으면 인라인이 어려울 수 있다 |
분기 예측 | 호출 대상이 자주 바뀌면 예측이 빗나갈 수 있다 |
객체 크기 | 구현에 따라 객체나 타입 메타데이터에 디스패치 정보가 필요하다. 모든 언어에서 객체마다 vtable 포인터가 붙는 것은 아니다 |
- 비용
가상 호출
- 설명
런타임 구현에 따라 간접 호출·디스패치 테이블·인라인 캐시 등을 거친다
- 비용
인라인 방해
- 설명
호출 대상이 정해지지 않으면 인라인이 어려울 수 있다
- 비용
분기 예측
- 설명
호출 대상이 자주 바뀌면 예측이 빗나갈 수 있다
- 비용
객체 크기
- 설명
구현에 따라 객체나 타입 메타데이터에 디스패치 정보가 필요하다. 모든 언어에서 객체마다 vtable 포인터가 붙는 것은 아니다
다만 정확히 말할 필요가 있다. 현대 컴파일러와 런타임은 호출 지점에서 실제 타입이 하나로 관측되면 역가상화·인라이닝을 시도한다. 그래서 가상 호출이 언제나 느린 것이 아니라, 호출 대상이 여러 개로 갈리고 최적화가 풀리는 지점에서 비용이 드러날 수 있다. 측정 없이 상속을 성능 이유로 걷어내는 것은 대개 근거가 없다.
반대 방향도 있지만 조건을 붙여야 한다. 참조형 객체를 별도 부품으로 합성하면 간접 참조가 하나 추가될 수 있다. 반면 값 형식이나 언어가 직접 포함하는 멤버를 사용한 컴포지션은 데이터를 바깥 객체 안에 함께 배치할 수 있다. 상속과 컴포지션의 실제 메모리 배치는 언어와 런타임에 따라 달라지므로, 어느 쪽의 데이터 지역성이 항상 좋다고 말할 수는 없다. 특정 구현에서 참조형 컴포지션과 기반 클래스 배치를 비교할 때만 상속 쪽의 간접 참조가 하나 적을 수 있다.[19]
객체 수가 많고 hot loop가 병목이라면 둘의 우열보다 배열·SoA 구조로 바꾸는 효과가 더 클 수 있다. 데이터 지향 설계 쪽 이야기다.
내 기준
상속을 쓸지 판단할 때 쓰는 질문들이다.
질문 | 예면 |
|---|---|
부모 코드가 자식 구현으로 되돌아와야 하는가 | 상속을 검토할 강한 이유가 있다 |
대체 가능성이 실제로 필요한가 | 인터페이스·프로토콜·기반 타입 같은 명시적 타입 추상화가 필요하다 |
부모를 내가 고칠 수 있는가 | 아니면 위임 래퍼 |
부모의 내부 구현을 몰라도 자식을 쓸 수 있는가 | 아니면 설계가 덜 됐다 |
자식 클래스 이름이 조합식인가 | 컴포지션으로 축을 나눈다 |
재정의 지점이 문서에 적혀 있는가 | 없다면 그 경계부터 확인한다 |
이 계층이 앞으로도 한 축으로 유지되는가 | 아니면 상속 트리가 곧 막힌다 |
- 질문
부모 코드가 자식 구현으로 되돌아와야 하는가
- 예면
상속을 검토할 강한 이유가 있다
- 질문
대체 가능성이 실제로 필요한가
- 예면
인터페이스·프로토콜·기반 타입 같은 명시적 타입 추상화가 필요하다
- 질문
부모를 내가 고칠 수 있는가
- 예면
아니면 위임 래퍼
- 질문
부모의 내부 구현을 몰라도 자식을 쓸 수 있는가
- 예면
아니면 설계가 덜 됐다
- 질문
자식 클래스 이름이 조합식인가
- 예면
컴포지션으로 축을 나눈다
- 질문
재정의 지점이 문서에 적혀 있는가
- 예면
없다면 그 경계부터 확인한다
- 질문
이 계층이 앞으로도 한 축으로 유지되는가
- 예면
아니면 상속 트리가 곧 막힌다
구현 재사용만 놓고 상속과 컴포지션을 비교한다면, 기반 코드가 자식 구현으로 되돌아올 필요가 없다는 답은 컴포지션 쪽으로 강하게 기운다. 다만 명목적 서브타이핑, 프레임워크 확장점, 닫힌 타입 계층이 따로 필요하다면 나머지 질문도 계속 봐야 한다.
요약하자면
이 문서가 구현 상속과 컴포지션을 비교하며 강조한 핵심적인 행동 차이는 암묵적 open recursion이다. 기반 코드가 자식 구현으로 되돌아오는 연결을 언어가 자동으로 배선해 주는가의 차이다. 다만 self 주입이나 다른 조합 기법으로 같은 효과를 만들 수 있으므로, 이를 상속의 논리적 독점물이라고 부르는 것은 정확하지 않다.
그 대가는 재정의 가능한 self 호출의 순서와 횟수, protected 상태와 생성 순서처럼 자식이 관찰할 수 있는 내부 구조 일부가 하위 클래스 계약이 된다는 점이다. 기반 클래스를 고칠 수 없다는 뜻은 아니지만, 자식이 의존하는 지점을 모르면 조용히 깨뜨릴 수 있다.
구현 재사용 관점에서 가장 먼저 물을 질문은 되돌아옴이 필요한가다. 필요하면 상속을 검토하되 확장점을 좁게 열고 그 경계를 문서에 남긴다. 필요하지 않다면 위임이 더 단순하다. 다만 명목적 타입 관계나 프레임워크 계약이 따로 필요하면 그 조건까지 함께 본다.
「상속보다 컴포지션」은 상속을 쓰지 말라는 말이 아니라, 구현 재사용을 위한 지름길로 타입 관계와 확장 계약까지 함께 사지 말라는 말에 가깝다.
같이 보기
컴포지션
객체지향_프로그래밍
프로그래밍 패턴
전략 패턴
데코레이터 패턴
대수적 데이터 타입
데이터 지향 설계
참고문헌
<a id="ref-1"></a>[1] William R. Cook; Walter L. Hill; Peter S. Canning. "Inheritance Is Not Subtyping". POPL 1990. DOI: 10.1145/96709.96721. 상속과 서브타이핑을 같은 것으로 보면 안 된다는 고전적 논의.
<a id="ref-2"></a>[2] Gilad Bracha; William R. Cook. "Mixin-based Inheritance". OOPSLA/ECOOP 1990, 303-311. DOI: 10.1145/97945.97982. mixin을 상속의 한 변형이자 추상 서브클래스 조합으로 다룬다.
<a id="ref-3"></a>[3] Ole-Johan Dahl; Kristen Nygaard. "The Development of the SIMULA Languages". History of Programming Languages, 1978. DOI: 10.1145/800025.1198392. 클래스와 서브클래스, 접두 기전이 시뮬레이션 언어에서 어떤 필요로 나왔는지를 설계자 본인들이 정리했다.
<a id="ref-4"></a>[4] Daniel H. H. Ingalls. "The Evolution of Smalltalk: From Smalltalk-72 through Squeak". Proceedings of the ACM on Programming Languages, 4(HOPL), Article 85, 2020. DOI: 10.1145/3386335. HOPL IV 학술대회 자체는 2021년 6월에 PLDI와 병행 개최되었으므로 연도를 인용할 때 게재 연도와 개최 연도를 섞지 않는 편이 좋다. Smalltalk-72에는 상속이 없었고, 공통 동작의 복사와 공유 문제를 해결하는 과정에서 Smalltalk-76의 상속이 등장한 흐름을 설계자의 회고로 확인할 수 있다.
<a id="ref-5"></a>[5] Bjarne Stroustrup. "A History of C++: 1979–1991". HOPL 1993. C with Classes에서 C++로 이어진 과정과 Simula의 영향, 접근 제어·다중 상속 등의 설계 결정을 설명한다.
<a id="ref-6"></a>[6] 언어별 공식 명세: Java Language Specification, Java SE 8, C# language specification — Classes. Java와 C#의 클래스 상속·인터페이스·가상 메서드 규칙을 확인하는 기준 문서다.
<a id="ref-7"></a>[7] Antero Taivalsaari. "On the Notion of Inheritance". ACM Computing Surveys, 28(3), 438-479, 1996. DOI: 10.1145/243439.243441. 상속이 무엇이고 어떻게 써야 하는지에 연구자들 사이에서도 합의가 없다는 점을 정리한 개관 논문.
<a id="ref-8"></a>[8] The Go Programming Language Specification. Go의 임베디드 필드, 승격된 메서드, 메서드 집합과 인터페이스 호출 규칙을 정의한다.
<a id="ref-9"></a>[9] Leonid Mikhajlov; Emil Sekerinski. "A Study of The Fragile Base Class Problem". ECOOP 1998, LNCS 1445. DOI: 10.1007/BFb0054099. 기반 클래스를 바꿨을 때 파생 클래스가 깨지는 현상을 형식적으로 정의하고 안전 조건을 제시한다.
<a id="ref-10"></a>[10] Alan Snyder. "Encapsulation and Inheritance in Object-Oriented Programming Languages". OOPSLA 1986. DOI: 10.1145/28697.28702. 상속이 캡슐화의 장점을 약화할 수 있다는 초기 논의.
<a id="ref-11"></a>[11] Barbara Liskov; Jeannette Wing. "A Behavioral Notion of Subtyping". ACM Transactions on Programming Languages and Systems, 16(6), 1811-1841, 1994. DOI: 10.1145/197320.197383.
<a id="ref-12"></a>[12] .NET API — ICollection<T>.Add. 읽기 전용 컬렉션에서는 NotSupportedException을 던질 수 있다는 계약을 확인할 수 있다.
<a id="ref-13"></a>[13] 언어별 인터페이스 기본 구현과 다중 상속 설명: Oracle Java Tutorial — Multiple Inheritance of State, Implementation, and Type, C# language specification — Interfaces. Java와 C#은 여러 클래스를 상속하지 않지만, 여러 인터페이스 구현과 기본 구현의 충돌을 각 언어의 규칙으로 다룬다. C# 명세 19.4.10 "Most specific implementation" 절이 유일한 가장 구체적인 구현을 요구하고, 그런 구현이 없으면 오류가 난다고 규정한다.
<a id="ref-14"></a>[14] Nathanael Schärli; Stéphane Ducasse; Oscar Nierstrasz; Andrew P. Black. "Traits: Composable Units of Behaviour". ECOOP 2003, 248-274. DOI: 10.1007/978-3-540-45070-2_12. 이 논문이 제안한 trait 모델은 상태 없는 메서드 묶음을 재사용 단위로 삼는다. 현대 언어의 trait 전체가 같은 제약을 갖는다는 뜻은 아니다.
<a id="ref-15"></a>[15] 언어별 trait·상속 명세: The Rust Reference — Traits, Scala 2.13 Language Specification — Classes and Objects, Kotlin — Inheritance. trait과 class의 상태·기본 구현·상속 가능 여부가 언어마다 다르다는 점을 확인할 수 있다.
<a id="ref-16"></a>[16] Sean Parent. "Inheritance Is The Base Class of Evil". GoingNative 2013. 상속 기반 다형성을 값 의미론과 타입 소거로 대체하는 접근을 제시한 발표.
<a id="ref-17"></a>[17] Erich Gamma; Richard Helm; Ralph Johnson; John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1994. 클래스 상속보다 객체 컴포지션을 선호하라는 원칙과, Template Method처럼 상속을 전제로 하는 패턴이 함께 실려 있다.
<a id="ref-18"></a>[18] Joshua Bloch. Effective Java, 3rd edition. Addison-Wesley, 2018. Item 18 "Favor composition over inheritance", Item 19 "Design and document for inheritance or else prohibit it".
<a id="ref-19"></a>[19] C# reference types, C# value types. 참조 형식은 참조를 저장하고 값 형식은 값을 직접 포함한다는 언어 규칙을 확인할 수 있다.