13장 서브클래싱과 서브타이핑

상속에 대한 오해와 불신은 많지만 궁극적으로 상속은 두 가지 용도로 사용된다.

상속의 첫 번째 용도는 타입 계층을 구현하는 것이다. 타입 계층 안에서 부모 클래스는 일반적인 개념을 구현하고 자식 클래스는 특수한 개념을 구현한다. 타입 계층의 관점에서 부모 클래스는 자식 클래스의 일반화(generalization)이고 자식 클래스는 부모 클래스의 특수화(specialization)이다.

상속의 두 번째 용도는 코드 재사용이다. 상속은 간단한 선언만으로 부모 클래스의 코드를 재사용할 수 있는 마법의 주문과도 같다. 상속을 사용하면 점진적으로 애플리케이션의 기능을 확장할 수 있다. 하지만 재사용을 위해 상속을 사용할 경우 부모 클래스와 자식 클래스가 강하게 결합되기 때문에 변경하기 어려운 코드를 얻게 될 확률이 높다.

상속을 사용하는 일차적인 목표는 코드 재사용이 아니라 타입 계층을 구현하는 것이어야 한다. 상속은 코드를 쉽게 재사용할 수 있는 방법을 제공하지만 부모 클래스와 자식 클래스를 강하게 결합시키기 때문에 설계의 변경과 진화를 방해한다. 반면 타입 계층을 목표로 상속을 사용하면 다형적으로 동작하는 객체들의 관계에 기반해 확장 가능하고 유연한 설계를 얻을 수 있게 된다.

이 예로 내가 생각한 타입의 계층은 깊어지는 하위 클래스들의 타입을 묶기에 되게 적합하다는 생각이 든다. 오우거라는 몬스터가 있을 때, 오우거는 합성으로 만들고 이에 대한 타입을 상속 구조로 북부의 오우거, 남부의 오우거, 동부의 오우거 등으로 하나의 타입으로 볼 수 있다는 점?

or 게임 엔진이라는 특수성 위에 즉, 어떠한 툴, 엔진에 종속적인 구조를 만들 때 유용한 것 같다.

결론은 동일한 메시지에 대해 서로 다르게 행동할 수 있는 다형적인 객체를 구현하기 위해서는 객체의 행동을 기반으로 타입 계층을 구성해야 한다. 상속의 가치는 이러한 타입 계층으로 구현할 수 있는 쉽게 편안한 방법을 제공한다는 데 있다. 타입 사이의 관계를 고려하지 않은 채 단순히 코드를 재사용하기 위해 상속을 사용해서는 안 된다.

타입

객체지향 프로그래밍에서 타입의 의미를 이해하려면 프로그래밍 언어의 관점에서의 타입과 개념 관점에서의 타입을 함께 살펴볼 필요가 있다.

개념 관점의 타입

개념 관점에서 타입이란 우리가 인지하는 세상의 사물의 종류를 의미한다. 다시 말해 우리가 인식하는 객체들에 적용하는 개념이나 아이디어를 가리켜 타입이라고 한다.

어떤 대상이 타입으로 분류될 때 그 대상을 타입의 인스턴스(instance)라고 한다. 언어적으로는 인스턴스를 객체라고 칭한다.

  • 심볼(symbol): 타입에 이름을 붙인 것으로 프로그래밍 언어인 C, C#, C++이 프로그래밍 언어라는 타입일 때, 프로그래밍 언어가 심볼이다.
  • 내연(intension): 타입의 정의로서 타입에 속하는 객체들이 가지는 공통적인 속성이나 행동을 가리킨다. 프로그래밍 언어 타입의 경우 프로그래밍 언어의 공통적인 특징이 내연을 구성한다.
  • 외연(extension): 타입에 속하는 객체들의 집합이다. 프로그래밍 언어 타입의 경우 C, C#, C++가 속한 집합이 외연을 구성한다.

프로그래밍 언어 관점의 타입

프로그래밍 언어 관점에서 타입은 연속적인 비트에 의미와 제약을 부여하기 위해 사용된다. (비트 자체에는 타입이라는 개념이 존재하지 않는다.) 비트에 담긴 데이터를 문자열로 다룰지, 정수로 다룰지는 전적으로 데이터를 사용하는 애플리케이션에 의해 결정된다. 따라서 프로그래밍 언어의 관점에서 타입은 비트 묶음에 의미를 부여하기 위해 정의된 제약과 규칙을 가리킨다.

실제 데이터는 비트로 표현되기에, 우리는 클래스라는 타입, 정수라는 타입이로 이를 디코딩하여 사용한다.

프로그래밍 언어에서는 타입은 두 가지 목적을 위해 사용된다.

  • 타입에 수행될 수 있는 유효한 오퍼레이션의 집합을 정의한다.
    • +연산자를 통해 정수나 문자열 타입의 객체에 대해서 연산이 가능하고 C#이나 C++은 연산자 오버로딩을 통해 객체에 대한 연산을 정의할 수 있다. 모든 객체지향 언어들은 객체의 타입에 따라 적용 가능한 연산자의 종류를 제한함으로써 프로그래머의 실수를 막아준다.
  • 타입에 수행되는 오퍼레이션에 대해 미리 약속된 문맥을 제공한다.
    • C#의 new 연산자의 경우 타입에 정의된 크기만큼 저장 공간을 할당하고 생성된 객체를 초기화하기 위해 타입 생성자를 호출한다.

즉, 타입은 적용 가능한 오퍼레이션의 종류와 의미를 정의함으로써 코드의 의미를 명확하게 전달하고 개발자의 실수를 방지하기 위해 사용된다.

객체지향 패러다임 관점의 타입

  • 개념 관점에서 타입이란 공통의 특징을 공유하는 대상들의 분류다.
  • 프로그래밍 언어 관점에서 타입이란 동일한 오퍼레이션을 적용할 수 있는 인스턴스의 집합이다.

프로그래밍 언어의 관점에서 타입은 호출 가능한 오퍼레이션의 집합을 정의한다. 객체지향 프로그래밍에서 오퍼레이션은 객체가 수신할 수 있는 메시지를 의미한다. 따라서 객체의 타입이란 객체가 수신할 수 있는 메시지의 종류를 정의한다.

이것은 int, string도 마찬가지다. 타입이란 객체가 수신할 수 있는 메시지의 종류!

객체가 수신할 수 있는 메시지의 집합이라는 말은 퍼블릭 인터페이스라는 말이며 즉, 객체지향 프로그래밍에서 타입을 정의하는 것은 결국 객체의 퍼블릭 인터페이스를 정의하는 것이다. 다시 말해서 객체의 핵심은 행위라는 것이다.

개념 관점에서 타입은 공통의 특성을 가진 객체들을 분류하기 위한 기준으로 그렇다면 여기서 공통의 특성이란 동일한 퍼블릭 인터페이스를 가지는 객체들은 동일한 타입으로 분류할 수 있다.

같은 클래스가 아닌 같은 인터페이스를 구현한 객체들, 결국 상속, 다형성을 하게 되면 같은 인터페이스를 지닐 수 밖에 없게 되기에 같은 인터페이스를 구현한 객체들이라는 말이 선행되어야 하고 더 큰 분류로 봐야 한다.

객체의 퍼블릭 인터페이스가 객체의 타입을 결정한다. 따라서 동일한 퍼블릭 인터페이스를 제공하는 객체들은 동일한 타입으로 분류된다.

객체에게 중요한 것은 속성이 아니라 행동이라는 것이다. 어떤 객체들이 동일한 상태를 가지고 있더라도 퍼블릭 인터페이스가 다르다면 이들은 서로 다른 타입으로 분류된다. 반대로 어떤 객체들이 내부 상태는 다르지만 동일한 퍼블릭 인터페이스를 공유한다면 이들은 동일한 타입으로 분류된다.

객체를 바라볼 때는 항상 객체가 외부에 제공하는 행동에 초점을 맞춰야 한다. 객체의 타입을 결정하는 것은 내부의 속성이 아니라 객체가 외부에 제공하는 행동이라는 사실을 기억하라.

타입 계층

타입 사이의 포함관계

포함하는 타입은 외연 관점에서는 더 크고 내연 관점에서는 더 일반적이다. 이와 반대로 포함되는 타입은 외연 관점에서는 더 작고 내연 관점에서는 더 특수하다. 이것은 포함 관계로 연결된 타입 사이에 개념적으로 일반화와 특수화 관계가 존재한다는 것을 의미한다.

타입 계층을 구성하는 두 타입 간의 관계에서 더 일반적인 타입을 슈퍼타입이라고 부르고 더 특수한 타입을 서브타입이라고 부른다. ‘프로그래밍 언어’ 타입은 ‘객체지향 언어’ 타입과 ‘절차적 언어’ 타입의 슈퍼타입이고, ‘객체지향 언어’ 타입은 ‘클래스 기반 언어’ 타입과 ‘프로토타입 기반 언어’ 타입의 슈퍼타입이다.

내연과 외연의 관점에서 일반화와 특수화에 대한 정의는 다음과 같다. 객체의 정의를 의미하는 내연 관점에서 일반화란 어떤 타입의 정의를 좀 더 보편적이고 추상적으로 만드는 과정을 의미한다. 반대로 특수화란 어떤 타입의 정의를 좀 더 구체적이고 문맥 종속적으로 만드는 과정을 의미한다.

특수화의 문맥 종속적이라는 부분이 중요하다. 일반화는 보편적이라는 것이다.

내연의 관점에서 특수한 타입의 정의는 일반적인 타입의 정의를 좀 더 구체화한 것이다. 집합을 의미하는 외연의 관점에서 일반적인 타입의 인스턴스 집합은 특수한 타입의 인스턴스 집합을 포함하는 슈퍼셋이다. 반대로 특수한 타입의 인스턴스 집합은 일반적인 타입의 인스턴스 집합에 포함된 서브셋이다.

일반화는 다른 타입을 완전히 포함하거나 내포하는 타입을 식별하는 행위 또는 그 행위의 결과를 가리킨다. 특수화는 다른 타입 안에 전체적으로 포함되거나 완전히 내포되는 타입을 식별하는 행위 또는 그 행위의 결과를 가리킨다.

객체지향 프로그래밍과 타입 계층

객체의 타입을 결정하는 것이 퍼블릭 인터페이스라는 것을 명확하게 이해했을 것이다. 일반적인 타입이란 비교하려는 타입에 속한 객체들의 퍼블릭 인터페이스보다 더 일반적인 퍼블릭 인터페이스를 가지는 객체들의 타입을 의미한다. 특수한 타입이란 비교하려는 타입에 속한 객체들의 퍼블릭 인터페이스보다 더 특수한 퍼블릭 인터페이스를 가지는 객체들의 타입을 의미한다.

  • 서브타입의 인스턴스는 슈퍼타입의 인스턴스로 간주될 수 있다.

다형성의 수준으로 이해해도 좋고, 일상생활의 일반화와 특수화 관계를 떠올리며 이해해도 좋을 것 같다.

서브클래싱과 서브타이핑

객체지향 프로그래밍 언어에서 타입을 구현하는 가장 일반적인 방법은 클래스를 이용하는 것이다. 그리고 타입 계층을 구현하는 일반적인 방법은 상속을 이용하는 것이다. 상속을 이용해 타입 게층을 구현한다는 것은 부모 클래스가 슈퍼타입의 역할을, 자식 클래스가 서브타입의 역할을 수행하도록 클래스 사이의 관계를 정의한다는 것을 의미한다.

언제 상속을 사용해야 하는가?

결국 다시 돌아와서 상속은 타입 계층을 구현하기 위해서 사용하는 것이다.

  • 상속 관계가 is-a 관계를 모델링하는가?
  • 클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한가?

is-a 관계

is-a 관계는 has-a로 다 표현이 가능하지 않을까? 불확실성이 가득한 개발환경에서 is-a를 대처할 수 있는가?

가장 유명한 상속의 문제인 죽음의 다이아몬드문제로 펭귄의 예제가 나온다. 결국 행동에 따라 타입 계층을 구성해야 한다는 사실을 잘 보여준다. 따라서 타입 계층의 의미는 행동이라는 문맥에 따라 달라질 수 있다. 그에 따라 올바른 타입 계층이라는 의미 역시 문맥에 따라 달라질 수 있다.

행동 호환성

결론은 두 타입 사이에 행동이 호환될 경우에만 타입 계층으로 묶어야 한다는 것이다. 클라이언트 기준 관점으로 두 타입이 동일하게 행동할 것이라고 기대한다면 두 타입을 타입 계층으로 묶을 수 있다. 반대로 클라이언트가 두 타입이 동일하게 행동하지 않을 것이라고 기대한다면 두 타입을 타입 계층으로 묶어서는 안 된다.

클라이언트 기대에 따라 계층 분리하기

상속 계층을 그대로 유지한채로 문제점을 해결하긴 쉽지 않다. (펭귄문제) 결국 문제를 해결하는 방법은 위에서 다룬 클라이언트의 기대에 맞게 상속 계층을 분리해야 한다.

여러 방법이 있을 수 있지만, 결국 헤매다 인터페이스를 활용하여 행동 자체를 타입으로 분류하기 시작한다. 결국 합성으로 해당 인터페이스를 가지는 구조로 만들게 된다. 이 때 발생하는 변경의 비용은 이미 다른 객체나 클라이언트로 분리되기에 추후의 유지보수성을 생각하더라도 이득이된다.

인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙(Interface Segregation Principle, ISP)이라고 한다.

최고의 설계는 제작하려는 소프트웨어 시스템이 기대하는 바에 따라 달라진다. 오늘도 그렇고 미래에도 마찬가지다. 지금 만드는 애플리케이션이 비행에 대한 지식을 전혀 쓰지 않으며 나중에도 쓸 일이 없을 것이라면, 날 수 있는 새와 날지 않는 새를 구분하지 않는 것이 탁월한 선택일 수 있다. 실제로 이런 것들을 잘 구분해서 설계하는 쪽이 바람직하다. 나는 새도 있고 날 수 없는 새도 있다는 사실은 우리가 본뜨려고 하는 세계가 어떤 것이냐에 따라 고려해도 되고 고려하지 않아도 된다.

결국 요점은 자연어에 현혹되지 말고 요구사항 속에서 클라이언트가 기대하는 행동에 집중하라는 것이다. 클래스의 이름 사이에 어떤 연관성이 있다는 사실은 아무런 의미도 없다. 두 클래스 사이에 행동이 호환되지 않는다면 올바른 타입 계층이 아니기 때문에 상속을 사용해서는 안 된다.

서브클래싱과 서브타이핑

결국 다시 돌아가 상속을 사용때는 목적성이 중요하다는 점을 기억해야 한다. 상속은 코드 재사용을 위해 사용하고, 타입 계층을 구성하기 위해서 사용한다. 상속을 사용하는 목적에 따라 상속 관계를 서브클래싱 관계와 서브타이핑으로 나타낼 수 있다.

  • 서브클래싱: 다른 클래스의 코드를 재사용할 목적으로 상속을 사용하는 경우를 가리키며, 자식 클래스와 부모 클래스의 행동이 호환되지 않기 때문에 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대체할 수 없다. 서브클래싱을 구현 상속(implementation inheritance)또는 클래스 상속(class inheritance)이라고 부른다.
  • 서브타이핑: 타입 계층을 구성하기 위해 상속을 사용하는 경우를 가리킨다. 자식 클래스와 부모 클래스의 행동이 호환되기 때문에 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대체할 수 있다. 이때 부모 클래스는 자식 클래스의 슈퍼타입이 되고 자식 클래스는 부모 클래스의 서브타입이 된다. 서브타이핑을 인터페이스 상속(interface inheritance)이라고 부른다.

결국 슈퍼타입과 서브타입 사이의 관계에서 가장 중요한 것은 퍼블릭 인터페이스다. 슈퍼타입 인스턴스를 요구하는 모든 곳에서 서브타입의 인스턴스를 대신 사용하기 위해 만족해야 하는 최소한의 조건은 서브타입의 퍼블릭 인터페이스가 슈퍼타입에서 정의한 퍼블릭 인터페이스와 동일하거나 더 많은 오퍼레이션을 포함해야 한다는 것이다.

따라서 개념적으로는 서브타입이 슈퍼타입의 퍼블릭 인터페이스를 상속받은 것처럼 보이게 된다. 이것이 서브타이핑을 인터페이스 상속이라고 부르는 이유이다.

서브타이핑 관계가 유지되기 위해서는 서브타입이 슈퍼타입이 하는 모든 행동을 동일시 할 수 있어야 한다. 즉, 어떤 타입이 다른 타입의 서브타입이 되기 위해서는 행동 호환성(behavioral substitution)을 만족해야 한다. 또한 행동 호환성을 유지하게 되면 부모 클래스에 대한 자식 클래스의 대체 가능성(substitutability)을 포함한다.

리스코프 치환 원칙

S형의 각 객체 o1에 대해 T형의 객체 o2가 하나 있고, T에 정의된 모든 프로그램 P에서 T가 S로 치환될 때, P의 동작이 변하지 않으면 S는 T의 서브타입이다.

한마디로 “서브타입은 그것의 기반 타입에 대해 대체 가능해야 한다.”라는 원칙이다. 이 원칙은 서브타입이 슈퍼타입의 퍼블릭 인터페이스를 상속받아야 한다는 것을 강조한다.

리스코프 치환 원칙에 따르면 자식 클래스가 부모 클래스와 행동 호한성을 유지함으로써 부모 클래스를 대체할 수 있도록 구현된 상속 관계만을 서브타이핑이라고 불러야 한다.

리스코프 치환의 원칙에서 가장 유명한 직사각형과 정사각형의 문제로 우리가 is-a라는 말이 얼마나 우리의 직관에서 벗어날 수 있는지를 잘 보여준다. 중요한 것은 클라이언트 관점에서 행동이 호환되는지 여부다. 그리고 행동이 호환될 경우에만 자식 클래스가 부모 클래스 대신 사용될 수 있다.

클라이언트와 대체 가능성

리스코프 치환 원칙은 자식 클래스가 부모 클래스를 대체하기 위해서는 부모 클래스에 대한 클라이언트의 가정을 준수해야 한다는 것을 강조한다. “클라이언트와 격리한 채로 본 모델은 의미 있게 검증하는 것이 불가능하다.”라는 결론이 나온다. 즉, 어떤 모델의 유효성은 클라이언트 관점에서만 검증 가능하다는 것이다.

결국 설계는 당시의 환경과 클라이언트, 여러 복합적인 요소의 산물이기 때문에 쉽게 평가할 수 없는 요소인 것 같다.

is-a 관계 다시 살펴보기

is-a 관계는 객체지향에서 중요한 것은 객체의 속성이 아니라 객체의 행동이라는 점을 강조한다. 일반적으로 클라이언트를 고려하지 않은 채 개념과 속성의 측면에서 상속 관계를 정할 경우 리스코프 치환 원칙을 위반하는 서브클래싱에 이르게 될 확률이 높다.

모든 코드가 is-a관계나 객체의 행동을 초점으로 객체지향적이어야 한다는 것은 아니다. 말 그대로 인터페이스적인 부분과 다형성이 필요한 부분에서만 이야기하는 것이다.

이름이 아니라 행동이 먼저다. is-a의 관계에 집착할 필요가 없다. 결론적으로 상속이 서브타이핑을 위해 사용될 경우에만 is-a의 관계이다. 서브클래싱을 구현하기 위해 상속을 사용했다면 is-a 관계라고 말할 수 없다.

리스코프 치환 원칙은 유연한 설계의 기반이다

리스코프 치환 원칙은 클라이언트가 어떤 자식 클래스와도 안정적으로 협력할 수 있는 상속 구조를 구현할 수 있는 가이드라인을 제공한다. 새로운 자식 클래스를 추가하더라도 클라이언트의 입장에서 동일하게 행동하기만 한다면 클라이언트를 수정하지 않고도 상속 계층을 확장할 수 있다.

리스코프 치환 원칙은 클래스 마다의 원칙적인 설계가 들어가야 하며 다른 원칙들을 준수할 수 있도록 도와준다. 연쇄적이진 않음

타입 계층과 리스코프 치환 원칙

언어마다 환경의 차이가 있을 수 있지만, 핵심은 구현 방법과 무관하게 클라이언트 관점에서 슈퍼타입에 대해 기대하는 모든 것이 서브타입에게도 적용돼야 한다는 것이다.

계약에 의한 설계와 서브타이핑

클라이언트와 서버 사이의 협력을 의무와 이익으로 구성된 계약의 관점에서 표현하는 것을 계약에 의한 설계(Design by Contract, DbC)라고 한다. 계약에 의한 설계는 클라이언트가 정상적으로 메서드를 실행하기 위해 만족시켜야 하는 사전조건(precondition)과 메서드가 실행된 후에 서버가 클라이언트에게 보장해야 하는 사후조건(postcondition), 메서드가 실행 전과 실행 후에 인스턴스가 만족시켜야 하는 클래스 불변식(class invariant)의 세 가지 요소로 구성된다.

계약에 의한 설계와 리스코프 치환 원칙의 관계는 클라이언트와 슈퍼타입 간에 체결된 ‘계약’을 준수함으로 설명이 가능하다. 계약에 의한 설계에 따르면 협력하는 클라이언트와 슈퍼타입의 인스턴스 사이에는 어떤 계약이 맺어져 있다. 클라이언트와 슈퍼타입은 이 계약을 준수할 때만 정상적으로 협력할 수 있다.

서브클래스와 서브타입은 서로 다른 개념이라는 사실이라는 것으로 어떤 클래스가 다른 클래스를 상속받으면 그 클래스의 자식 클래스 또는 서브클래스가 되지만 모든 서브 클래스가 서브타입인 것은 아니다. 코드 재사용을 위해 사용했다면, 그리고 클라이언트의 관점에서 자식 클래스가 부모 클래스를 대체할 수 없다면 서브타입이라고 할 수 없다.

예제에 나오는 예외를 던져 현재 시스템의 순서를 뒤집는 방법

서브타입과 계약

계약의 관점에서 상속이 초래하는 가장 큰 문제는 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 수 있다는 것이다. 어떤 타입이 슈퍼타입에서 정의한 사전조건보다 더 약한 사전조건을 정의하고 있다면 그 타입은 서브타입이 될 수 있지만 더 강한 사전 조건을 정의한다면 서브타입이 될 수 없다. 어떤 타입이 슈퍼타입에서 정의한 사후조건보다 더 강한 사후조건을 정의하더라도 그 타입은 여전히 서브타입이지만 더 약한 사후조건을 정의한다면 서브타입의 조건이 깨지고 만다.

계약에 의한 설계는 클라이언트 관점에서의 대체 가능성을 계약으로 설명할 수 있다는 사실을 잘 보여준다. 따라서 서브타이핑을 위해 상속을 사용하고 있다면 부모 클래스가 클라이언트와 맺고 있는 계약에 대해서 깊게 고민해봐야 한다.

느낀점

이미 잘 알고 있는 내용을 다룬다고 생각했는데, 언어적인 차이와 더 세밀한 부분을 정확하게 알 수 있어서 좋았다. 계약에 의한 프로그래밍은 기억으론 좋은 코드 나쁜 코드에서도 다뤘던 내용이라 이번에 찾아 볼 수 있었다.

확실히 특수화, 일반화 관계에 대해서 설계적으로 꼭 코드나 객체지향이 아니더라도 확장해서 쓸 수 있겠다는 생각이 들었다.

논의사항

  • 게임 개발에서 발생할 수 있는 리스코프 치환 원칙의 위반은 어떤 것이 있을까요?

댓글남기기