들어가며

객체지향 커뮤니티에 널리 퍼진 상속에 대한 불신과 오해를 풀기 위해서는 상속두 가지 용도로 사용된다는 사실을 이해하는 것이 중요하다.

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

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

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

결론부터 말하자면 동일한 메시지에 대해 서로 다르게 행동할 수 있는 다형적인 객체를 구현하기 위해서는 객체의 ‘행동’을 기반으로 타입 계층을 구성해야 한다.

이번 장에서는 올바른 타입 계층을 구성하는 원칙에 대해서 학습하기로 한다.

그렇다면 타입 계층이란 무엇인가? 상속을 이용해 타입 계층을 구현한다는 것이 무엇을 의미하는가? 이 질문에 대한 답을 찾기 위해 먼저 타입타입 계층의 개념을 알아보자.


타입

객체지향 프로그래밍에서의 타입에 대해서 이해를 하기 위해서는 먼저 프로그래밍 언어 관점에서의 타입개념 관점에서의 타입을 함께 살펴볼 필요가 있다.

개념 관점에서의 타입

개념 관점에서의 타입이란 우리가 인지하는 세상의 사물의 종류를 의미한다. 다시말해 우리가 인식하는 객체들에 적용하는 개념이나 아이디어를 가리켜 타입이라고 부른다. 타입은 사물을 분류하기 위한 틀로 사용된다. 예를 들어 자바, 루비, 자바스크립트, C를 프로그래밍 언어라고 부를 때 우리는 이것들을 프로그래밍 언어 타입으로 분류하고 있는 것이다.

어떤 대상이 타입으로 분류될 때 그 대상을 타입의 인스턴스라고 부른다. 자바, 루비, 자바스크립트, C는 프로그래밍 언어의 인스턴스이다. 일반적으로 타입의 인스턴스를 객체라고 부른다.

지금까지의 설명을 통해 타입심볼, 내연, 외연의 세 가지 요소로 구성된다는 사실을 알 수 있다.

  • 심볼(Symbol) : 타입에 이름을 붙인 것이다. 앞에서 프로그래밍 언어가 타입의 심볼에 해당한다.

  • 내연(Intension) : 타입의 정의로서 타입에 속하는 객체들이 가지는 공통적인 속성이나 행동을 가리킨다. 프로그래밍 언어의 정의"컴퓨터에게 특정한 작업을 지시하기 위한 어휘와 문법적 규칙의 집합"이 바로 내연에 속한다. 일반적으로 타입에 속하는 객체들이 공유하는 속성과 행동의 집합이 내연을 구성한다.

  • 외연(Extension) : 타입이 속하는 객체들의 집합이다. 프로그래밍 언어 타입의 경우에는 자바, 루비, 자바스크립트, C가 속한 집합이 외연을 구성한다.

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

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

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

  • 타입에 수행될 수 있는 유효한 오퍼레이션의 집합을 정의한다. 자바에서 + 연산자는 원시형 숫자 타입이나 문자열 타입의 객체에는 사용할 수 있지만 다른 클래스의 인스턴스에 대해서는 사용할 수 없다. 하지만 C++과 C#에서는 연산자 오버로딩을 통해 +연산자를 사용하는 것이 가능하다. 여기서 중요한 것은 모든 객체지향 언어들은 객체의 타입에 따라 적용 가능한 연산자의 종류를 제한함으로써 프로그래머의 실수를 막아준다는 것이다.

  • 타입에 수행되는 오퍼레이션에 대해 미리 약속된 문맥을 제공한다. 예를 들어 자바에서 a + b라는 연산이 있을 때 ab의 타입이 int라면 두 수를 더할 것이다. 하지만 ab의 타입이 String이라면 두 문자열을 하나의 문자열로 합칠 것이다. 따라서 a와 b에 부여된 타입이 + 연산자의 문맥을 정의한다.

정리하면 타입은 적용 가능한 오퍼레이션의 종류와 의미를 정의한다.

이제 타입의 개념을 객체지향 패러다임 관점에서 확장해보자.

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

지금까지의 내용을 바탕으로 타입을 다음과 같은 두 가지 관점에서 정의할 수 있다.

  • 개념 관점에서의 타입이란 공통의 특징을 공유하는 대상들의 분류이다.

  • 프로그래밍 언어 관점에서의 타입이란 동일한 오퍼레이션을 적용할 수 있는 인스턴스들의 집합이다.

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

우리는 이미 객체가 수신할 수 있는 메시지의 집합을 가리키는 멋진 용어를 알고있다. 바로 퍼블릭 인터페이스가 그것이다. 객체지향 프로그래밍에서 타입을 정의하는 것은 객체의 퍼블릭 인터페이스를 정의하는 것과 동일하다.

개념 관점에서의 타입공통의 특성을 가진 객체들을 분류하기 위한 기준이다. 그렇다면 여기서 공통의 특성이란 무엇일까? 타입이 오퍼레이션을 정의한다는 사실을 기억하면 쉽게 답을 구할 수 있다.

객체지향에서는 객체가 수신할 수 있는 메시지를 기준으로 타입을 분류하기 때문에 동일한 퍼블릭 인터페이스를 가지는 객체들은 동일한 타입으로 분류할 수 있다.

즉, 객체지향 프로그래밍 관점에서 타입은 다음과 같이 정의할 수 있다.

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

타입의 정의는 지금까지 줄곧 강조해왔던 객체에 관한 한 가지 사실을 다시 한번 강조한다. 객체에게 중요한 것은 속성이 아니라 행동이라는 사실이다. 어떤 객체들이 동일한 상태를 가지고 있더라도 퍼블릭 인터페이스가 다르다면 이들은 서로 다른 타입으로 분류된다. 반대로 어떤 객체들이 내부 상태는 다르지만 동일한 퍼블릭 인터페이스를 공유한다면 이들은 동일한 타입으로 분류된다.

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


타입 계층

타입 사이의 포함 관계

수학에서 집합은 다른 집합을 포함할 수 있다. 타입 역시 객체들의 집합이기 때문에 다른 타입을 포함하는 것이 가능하다. 타입 안에 포함된 객체들을 좀 더 상세한 기준으로 묶어 새로운 타입을 정의하면 이 새로운 타입은 자연스럽게 기존 타입의 부분집합이 된다.

앞에서 예로 든 자바, 루비, 자바스크립트, C는 프로그래밍 언어 타입의 인스턴스다. 따라서 이들을 아래 그림과 같이 프로그래밍 언어집합의 원소로 표현할 수 있다.

이 집합의 원소들을 좀 더 상세한 기준에 따라 분류할 수 있다. 자바, 루비 , 자바스크립트객체지향 언어로 분류할 수 있고, C절차적 언어로, 자바스크립트프로토타입 기반 언어로 분류할 수 있다.

아래 그림에서 알 수 있는 것처럼 타입은 집합의 관점에서 좀 더 세분화된 타입의 집합을 부분집합으로 포함할 수 있다. 프로그래밍 언어 타입은 객체지향 언어 타입과 절차적 언어 타입을 포함하고, 객체지향 언어 타입은 클래스 기반 언어 타입과 프로토타입 기반 언어 타입을 포함한다.

타입이 다른 타입에 포함될 수 있기 때문에 동일한 인스턴스가 하나 이상의 타입으로 분류되는 것도 가능하다. 자바프로그래밍 언어인 동시에 객체지향 언어에 속하며 더 세부적으로 클래스 기반 언어 타입에 속한다.

다른 타입을 포함하는 타입포함되는 타입보다 좀 더 일반화된 의미를 표현할 수 있다. 반면 포함되는 타입은 좀 더 특수하고 구체적이다. 프로그래밍 언어 타입은 객체지향 언어 타입보다 더 일반적이고 객체지향 언어 타입은 클래스 기반 언어 보다 더 일반적이다.

다른 타입을 포함하는 타입포함되는 타입보다 더 많은 인스턴스를 가진다. 위 그림에서 프로그래밍 언어 타입은 7개의 인스턴스를 포함하지만 클래스 기반 언어 타입은 이보다 적은 3개의 인스턴스만을 포함한다.

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

타입들은 아래 그림과 같이 일반화특수화 관계를 가진 계층으로 표현할 수 있다.

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

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

내연의 관점에서 특수한 타입의 정의는 일반적인 타입의 정의를 좀 더 구체화한 것이다. 예를들어 객체지향 언어의 내연컴퓨터에게 특정한 작업을 지시하기 위해 객체를 생성하고 객체 사이의 메시지 전송을 통한 협력 관계를 구성할 수 있는 어휘와 문법적 규칙의 집합 으로 정의할 수 있는데, 이것은 앞에서 살펴본 프로그래밍 언어 타입의 정의에 의미를 명확하게 하기 위한 설명을 덧붙여 구체화한 것이다.

집합을 의미하는 외연의 관점에서 일반적인 타입의 인스턴스 집합은 특수한 타입의 인스턴스 집합을 포함하는 슈퍼셋이다. 반대로 특수한 타입의 인스턴스 집합은 일반적인 타입의 인스턴스 집합에 포함된 서브셋이다. 따라서 특수한 타입에 속한 인스턴스는 동시에 더 일반적인 타입의 인스턴스이기도 하다.

따라서 내연외연의 관점에서 서브타입슈퍼타입을 다음과 같이 정의할 수 있다.

  • 슈퍼타입은 다음과 같은 특징을 가지는 타입을 가리킨다.
    • 집합이 다른 집합의 모든 멤버를 포함한다.
    • 타입 정의가 다른 타입보다 좀 더 일반적이다.
  • 서브타입은 다음과 같은 특징을 가지는 타입을 말한다.
    • 집합에 포함되는 인스턴스들이 좀 더 큰 집합에 포함된다.
    • 타입 정의가 다른 타입보다 좀 더 구체적이다.

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

이제 객체지향 프로그래밍 관점에서 타입의 정의가 더 일반적이고 더 특수하다는 사실이 어떤 의미를 가지는지 살펴보자. 객체의 타입을 결정하는 것은 퍼블릭 인터페이스다. 일반적인 타입이란 더 일반적인 퍼블릭 인터페이스를 가지는 객체들의 타입을 의미한다. 특수한 타입이란 더 특수한 퍼블릭 인터페이스를 가지는 객체들의 타입을 의미한다. 따라서 퍼블릭 인터페이스의 관점에서 슈퍼타입과 서브타입을 다음과 같이 정의할 수 있다.

  • 슈퍼타입이란 서브타입이 정의한 퍼블릭 인터페이스를 일반화시켜 상대적으로 범용적이고 넓은 의미로 정의한 것이다.

  • 서브타입이란 슈퍼타입이 정의한 퍼블릭 인터페이싀를 특수화시켜 상대적으로 구체적이고 좁은 의믜로 정의한 것이다.

뒤에서 일반적인 퍼블릭 인터페이스특수한 퍼블릭 인터페이스를 살펴볼 것이다. 일단 여기서는 일반적인 타입과 구체적인 타입 간의 관계를 형성하는 기준이 “퍼블릭 인터페이스” 라는 사실을 기억하자.

더 일반적인 퍼블릭 인터페이스를 가지는 객체들은 더 특수한 퍼블릭 인터페이스를 가지는 객체들의 “슈퍼 타입”이다. 서브타입의 인스턴스 집합은 슈퍼타입의 인스턴스 집합의 부분집합이기 때문에 더 특수한 퍼블릭 인터페이스를 가지는 객체들은 동시에 더 일반적인 퍼블릭 인터페이스를 가지는 객체들의 집합에 포함된다.

다시 한번 강조한다. 서브타입의 인스턴스는 슈퍼타입의 인스턴스로 간주될 수 있다.


서브클래싱과 서브타이핑

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

그렇다면 어떤 타입이 다른 타입의 서브타입이 되기 위해서는 어떤 조건을 만족해야 할까? 서브타입의 퍼블릭 인터페이스가 슈퍼타입의 퍼블릭 인터페이스보다 더 특수하다는 것은 어떤 의미일까? 이제부터는 타입 계층을 구현할 때 지켜야 하는 제약사항을 클래스와 상속의 관점에서 살펴보자.

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

반복해서 강조하지만 상속의 올바른 용도는 “타입 계층을 구현”하는 것이다. 그렇다면 어떤 조건을 만족시켜야만 타입 계층을 위해 올바르게 상속을 사용했다고 말할 수 있을까? 마틴 오더스키는 다음과 같은 질문을 해보고 두 질문에 모두 Yes일 경우에만 상속을 사용하라고 조언한다.

  • 상속 관계가 IS-A 관계를 모델링 하는가? 이것은 어플리케이션을 구성하는 어휘에 대한 우리의 관점에 기반한다 일반적으로 [자식클래스] IS A [부모클래스] 라고 말해도 이상하지 않다면 상속을 사용할 후보로 간주할 수 있다.

  • 클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한가? 상속 계층을 사용하는 클라이언트의 입장에서 부모 클래스자식 클래스의 차이점을 몰라야 한다. 이를 자식 클래스와 부모 클래스 사이의 행동 호환성이라고 한다.

설계 관점에서 상속을 적용할지 여부를 결정하기 위해 첫 번째 질문 보다는 두 번째 질문에 초점을 맞추는 것이 중요하다. 뒤에서 자세히 살펴보겠지만 클라이언트의 관점에서 두 클래스에 대해 “기대”하는 행동이 다르다면 비록 그것이 IS-A 관계로 표현할 수 있따고 하더라도 상속을 사용해서는 안된다.

IS-A 관계

마틴 오더스키의 조언에 따르면 두 클래스가 어휘적으로 IS-A 관계를 모델링 할 경우에만 상속을 사용해야 한다. 어떤 타입 S가 다른 타입 T의 일종이라면 당연히 ‘타입 S는 타입 T다(S is-a T)’라고 말할 수 있어야 한다. 객체지향 언어는 프로그래밍 언어다.라고 표현할 수 있고 클래스 기반 언어는 객체지향 언어다라고 표현할 수 있기 때문에 프로그래밍 언어, 객체지향 언어, 클래스기반 언어IS-A 관계를 만족시킨다.

하지만 IS-A 관계가 생각처럼 직관적이고 명쾌한 것은 아니다. 먼저 두 가지 익숙한 사실에서 이야기를 시작해보자.

  • 펭귄다.
  • 는 날 수 있다.

두 가지 사실을 조합하면 아래와 유사한 코드를 얻게 된다.

안타깝게도 이 코드는 반은 맞고 반은 틀리다. 펭귄은 분명 지만 날수 없는 새다. 하지만 코드는 분명히 펭귄은 새고, 따라서 날 수 있다.라고 주장한다.

이 예는 어휘적인 정의가 아니라 “기대되는 행동”에 따라 타입 계층을 구성해야 한다는 사실을 잘 보여준다. [어휘적인 정의]로는 펭귄은 새지만 만약 [새의 정의에 날 수 있다는 행동이 포함되면 펭귄은 새의 서브타입이 될 수 없다.] 만약 [새의 정의에 날 수 있다는 행동이 포함되지 않는다면 펭귄은 새의 서브타입이 될 수 있다.] 이 경우에는 어휘적인 관점과 행동 관점이 일치하게 된다.

따라서 타입 계층의 의미는 행동이라는 문맥에 따라서 달라질 수 있다. 그에 따라 올바른 타입 계층이라는 의미 역시 문맥에 따라 달라질 수 있다. 어떤 어플리케이션에서 에게 날 수 있다는 행동을 기대하지 않고 단지 울음 소리를 낼 수 있다는 행동만 기대한다면 새와 펭귄을 타입 계층으로 묶어도 무방하다. 따라서 슈퍼타입과 서브타입 관계에서는 IS-A 보다 “행동 호환성”이 더 중요하다.

어떤 두 대상을 언어적으로 IS-A라고 표현할 수 있더라도 일단은 “상속을 사용할 예비 후보”정도로만 생각하라.

중요하다. 너무 성급하게 상속을 적용하려고 서두르지 마라. 여러분의 애플리케이션 안에서 두 가지 후보 개념이 어떤 방식으로 사용되고 협력되는지 살펴본 후에 상속의 적용 여부를 결정해도 늦지 않다.

행동 호환성

펭귄이 새가 아니라는 사실을 받아들이기 위한 출발점은 타입이 행동과 관련이 있다는 사실에 주목하는 것이다. 타입의 이름 사이에 개념적으로 어떤 연관성이 있다고 하더라도 행동에 연관성이 없다면 IS-A 관계를 사용하지 말아야 한다.

분명 펭귄라는 단어가 풍기는 향기는 두 타입을 IS-A 관계로 묶고 싶을 만큼 매혹적인 것은 사실이다. 하지만 펭귄서로 다른 행동 방식은 이 둘을 동일한 타입 계층으로 묶어서는 안된다고 강하게 경고한다.

결론은 두 타입 사이에 “행동이 호환될 경우에만” 타입 계층으로 묶어야 한다는 것이다.

그렇다면 행동이 호환된다는 것은 무슨 의미일까? 단순히 동일한 메서드를 구현하고 있으면 행동이 호환되는 것일까?

여기서 중요한 것은 행동의 호환 여부를 판단하는 기준은 “클라이언트의 관점”이라는 것이다. 클라이언트두 타입이 동일하게 행동할 것이라고 기대한다면 두 타입을 타입 계층으로 묶을 수 있다. 클라이언트두 타입이 동일하게 행동하지 않을 것이라고 기대한다면 두 타입을 타입 계층으로 묶어서는 안된다.

Penguin이 Bird의 서브타입이 아닌 이유는 “클라이언트의 입장”에서 모든 새가 날 수 있다고 “가정” 하기 때문이다. 단순히 IS-A라고 표현할 수 있다고 해서 두 타입이 올바른 타입 계층을 구성한다고 말할 수 없다. 중요한 것은 “클라이언트의 기대”다. 타입 계층을 이해하기 위해서는 “그 타입 계층이 사용될 문맥을 이해”하는 것이 중요한 것이다.

다음과 같이 클라이언트가 날 수 있는 새만을 원한다고 가정해보자.

현재 PenguinBird의 자식 클래스이기 때문에 컴파일러는 업캐스팅을 허용한다. 따라서 flyBird 메서드의 인자로 Penguin의 인스턴스가 전달되는 것을 막을 수 없다. 하지만 Penguin은 날 수 없고 클라이언트는 모든 bird가 날 수 있기를 기대하기 때문에 flyBird 메서드로 전달돼서는 안된다. Penguin은 클라이언트의 기대를 저버리기 때문에 Bird의 서브타입이 아니다. 따라서 이 둘을 상속 관계로 연결한 위 설계는 수정돼야 한다.

이 문제를 해결하기 위해 주로 초보 개발자들은 바람직하지 않은 3가지 방법을 택한다. 아래에서 살펴보자.

첫 번째 바람직하지 않은 방법Penguinfly 메서드를 오버라이딩 해서 내부 구현을 비워두는 것이다.

이제 Penguin에게 fly 메시지를 전송하더라도 아무일도 일어나지 않는다. 따라서 Penguin은 날 수 없게 된다. 하지만 이 방법은 어떤 행동도 수행하지 않기 때문에 모든 bird가 날 수 있다는 “클라이언트의 기대”를 만족시키지 못하게 된다. 따라서 올바른 설계라고 할 수 없다. 이 설계에서 Penguin과 Bird의 행동은 클라이언트의 입장에서 호환되지 않기 때문에 올바르지 않다.

두 번째 바람직하지 않은 방법Penguinfly 메서드를 오버라이딩 한 후 예외를 던지게 하는 것이다.

하지만 이 경우에는 flyBird 메서드에 전달되는 인자의 타입에 따라 메서드가 실패하거나 성공하게 된다. flyBird 메서드는 모든 bird가 날 수 있다고 가정한다는 사실에 주목하자. 이 방법 역시 클라이언트의 관점에서 Bird와 Penguin의 행동이 호환되지 않는다.

세 번째 바람직하지 않은 방법flyBird 메서드를 수정해서 인자로 전달된 bird의 타입이 Penguin이 아닐 경우에만 fly 메서드를 전송하도록 하는 것이다.

하지만 이 방법 역시 문제가 있다. 만약 Penguin 이외에 날 수 없는 새가 상속 계층에 추가된다면 어떻게 할 것인가? flyBird 메서드 안에서 instanceof를 이용해 새로운 타입을 체크하는 코드를 추가해야 할 것이다. 이것은 new 연산자와 마찬가지로 구체적인 클래스에 대한 결합도를 높인다. 일반적으로 instanceof처럼 객체의 타입을 확인하는 코드는 새로운 타입을 추가할 때 마다 코드 수정을 요구하기 때문에 개방-폐쇄 원칙을 위반한다.

이제 바람직하지 않은 3가지 방법을 알아봤으니 바람직한 방법에 대해서 알아보자.

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

지금까지 살펴본 것 처럼 행동 호환성을 만족시키지 않는 상속 계층을 그대로 유지한 채 클라이언트의 기대를 충족시킬 수 있는 방법을 찾기란 쉽지 않다. 문제를 해결할 수 있는 방법은 클라이언트의 기대에 맞게 상속 계층을 분리하는 것 뿐이다.

flyBird 메서드는 파라미터로 전달되는 모든 bird가 날 수 있다고 가정하기 때문에 flyBird 메서드와 협력하는 모든 객체는 fly 메시지에 대해서 올바르게 응답할 수 있어야 한다. 따라서 Penguin의 인스턴스는 flyBird 메서드에 전달되어서는 안된다. 반면 Penguin과 협력하는 클라이언트는 날 수 없는 새와 협력할 것이라고 가정할 것이다. 따라서 날 수 있는 새와 날 수 없는 새를 명확하게 구분할 수 있게 상속 계층을 분리하면 서로 다른 요구사항을 가진 클라이언트를 만족시킬 수 있을 것이다.

다음 코드는 날 수 없는 새날 수 있는 새의 두 부류가 존재하며, 그 중 펭귄은 날 수 없는 새에 속한다는 사실을 분명하게 표현한다.

이제 flyBird 메서드는 FlyingBird 타입을 이용해 날 수 있는 새만 인자로 전달돼야 한다는 사실을 코드에 명시할 수 있다. 만약 날 수 없는 새와 협력하는 메서드가 존재한다면 파라미터의 타입을 Bird로 선언하면 된다.

아래 그림은 클라이언트의 기대에 따라 상속 계층을 분리한 후 클래스의 구조를 나타낸 것이다. 변경 후에는 모든 클래스들이 “행동 호환성”을 만족시킨다는 사실을 알 수 있다. Bird의 클라이언트는 자신과 협력하는 객체들이 fly라는 행동을 수행할 수 없다는 사실을 잘 알고 있다. 따라서 PenguinBird를 대체하더라도 놀라지 않을 것이다. FlyingBird 역시 Bird와 행동적인 측면에서 호환 가능한데 Bird의 클라이언트는 fly 메시지를 전송할 수 없기 때문에 Bird 대신 FlyingBird 인스턴스를 전달하더라도 문제가 되지 않기 때문이다.

이제 FlyingBird 타입의 인스턴스만이 fly 메시지를 수신할 수 있다. 날 수 없는 Bird의 서브타입인 Pengiun의 인스턴스에게 fly 메시지를 전송할 수 있는 방법은 없다. 따라서 잘못된 객체와 협력해서 기대했던 행동이 수행되지 않거나 예외가 던져지는 일은 발생하지 않을 것이다.

이 문제를 해결하는 다른 방법은 클라이언트에 따라 인터페이스를 분리하는 것이다. 만약 Bird가 날 수 있으면서 걸을 수도 있어야 하고, Penguin은 오직 걸을 수만 있다고 가정하자. 다시말해 Birdflywalk 메서드를 함께 구현하고 Penguin은 오직 walk 메서드만 구현해야 한다는 것이다. 그리고 오직 fly 메시지만 전송하는 클라이언트와 오직 walk 메시지만 전송하는 또 다른 클라이언트가 존재한다고 가정해보자.

인터페이스는 클라이언트가 기대하는 바에 따라 분리되어야 한다는 것을 기억하자. 하나의 클라이언트가 오직 fly 메시지만 전송하기를 원한다면 이 클라이언트에게는 fly 메시지만 보여야 한다. 다른 클라이언트가 오직 walk 메시지만 전송하기를 원한다면 이 클라이언트에게는 walk 메시지만 보여야 한다. 따라서 가장 좋은 방법은 fly 오퍼레이션을 가진 Flyer 인터페이스walk 오퍼레이션을 가진 Walker 인터페이스로 분리하는 것이다. 이제 BirdPenguin은 자신이 수행할 수 있는 인터페이스만 구현할 수 있다.

만약 PenguinBird의 코드를 재사용해야 한다면 어떻게 해야할까? Pengiun이 하나의 인터페이스만 구현하고 있기 때문에 문법상으로는 PenguinBird를 상속받더라도 문제가 안되겠지만 Penguin의 퍼블릭 인터페이스에 fly 오퍼레이션이 추가되기 때문에 이 방법을 사용할 수는 없다. 게다가 재사용을 위한 상속은 위험하다.

더 좋은 방법은 합성을 사용하는 것이다. 물론 Bird의 퍼블릭 인터페이스를 이용하여 재사용 가능하다는 전제를 만족시켜야 한다. 만약 Bird의 퍼블릭 인터페이스를 이용해서 재사용하기 어렵다면 Bird를 약간 수정해야 할 수도 있을 것이다. 대부분의 경우 불안정한 상속 계층을 계속 껴안고 가는 것 보다는 Bird를 재사용 가능하도록 수정하는 것이 더 좋은 방법이다.

“클라이언트의 기대에 따라 인터페이스를 분리”하면 변경에 대한 영향을 더 세밀하게 제어할 수 있게 된다. 대부분의 경우 인터페이스는 클라이언트의 요구가 바뀜에 따라 변경된다. 클라이언트에 따라 인터페이스를 분리하면 각 클라이언트의 요구가 바뀌더라도 영향의 파급 효과를 효과적으로 제어할 수 있게 된다.

두번째 윗 그림에서 Client1의 기대가 바뀌어서 Flyer의 인터페이스가 변경되어야 한다고 가정해보자. 이 경우 Flyer에 의존하고 있는 Bird가 영향을 받게 된다. 하지만 변경의 영향은 Bird에서 끝난다. Client2FlyerBird에 대해 전혀 알지 못하기 때문에 영향을 받지 않는다.

이처럼 “인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어”하는 설계 원칙을 “인터페이스 분리 원칙”이라고 부른다.

인터페이스 분리 원칙은 “비대한 인터페이스의 단점”을 해결한다. “비대한 인터페이스”를 가지는 클래스는 응집성이 없는 인터페이스를 가지는 클래스다. 즉, 이런 클래스의 인터페이스는 “메서드의 그룹으로 분해”될 수 있고 각 메서드 그룹은 각기 다른 클라이언트 집합을 지원한다.

비대한 클래스는 그 클라이언트 사이에 이상하고 해로운 결합이 생기게 만든다. 한 클라이언트가 이 비대한 클래스에 변경을 가하면 나머지 모든 클래스가 영향을 받게된다. 그러므로 [클라이언트는 자신이 실제로 호출하는 메서드에만 의존해야 한다.] 이것은 이 비대한 클래스의 인터페이스를 여러 개의 클라이언트에 특화된 인터페이스로 분리함으로써 성취될 수 있다. 이렇게 하면 호출되지 않는 메서드에 대한 클라이언트의 의존성을 끊고, 클라이언트가 서로에 대해 독립적이 되게 만들 수 있다.

이제 변경 후의 설계는 날 수 있는 새와 날 수 없는 새가 존재한다는 현실 세계를 정확하게 반영한다. 하지만 여기서 한 가지 주의해야 할 점이 있다. 설계가 꼭 현실 세계를 반영할 필요는 없다는 것이다.

중요한 것은 [설계가 반영할 도메인의 요구사항]이고 그 안에서 [클라이언트가 객체에게 요구하는 행동]이다.

현재의 요구사항이 날 수 있는 행동(fly)에 관심이 없다면 상속 계층에 FlyingBird를 추가하는 것은 설계를 불필요하게 복잡하게 만든다. 현실을 정확하게 묘사하는 것이 아니라 요구사항을 실용적으로 수용하는 것을 목표로 삼아야 한다.

다음에 인용하는 스콧 마이어스의 조언은 이 같은 ㅅ아황에서 어떤 자세를 취해야 하는지에 대한 유용한 기준을 제시한다.

모든 소프트웨어에 이상적인 설계같은 것은 없다는 사실을 간단히 반증하는 예라고 할 수 있다. 최고의 설계는 제작하려는 소프트웨어 시스템이 기대하는 바에 따라 달라진다. 여러분이 지금 만드는 애플리케이션이 비행에 대한 지식을 전혀 쓰지 않으며 나중에도 쓸 일이 없을것이라면, 날 수 있는 새와 날지 않는 새를 구분하지 않는 것이 탁월한 선택일 수도 있다. 실제로 이런 것들을 잘 구분해서 설계하는 쪽이 바람직하다. 나는 새도 있고 날 수 없는 새도 있다는 사실은 여러분이 본뜨려고 하는 세계가 어떤 것이냐에 따라 고려해도 되고 고려하지 않아도 되기 때문이다.

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

서브클래싱과 서브타이핑

그래서 언제 상속을 사용해야 하는가? 어떤 상속이 올바른 상속이고, 어떤 상속이 올바르지 않은 상속인가? 질문에 대한 답을 찾기 위해서는 이번 장을 처음 시작할 때 언급한 것처럼 상속이 두 가지 목적을 위해 사용된다는 것을 이해해야 한다. 하나는 코드 재사용을 위해서이고 하나는 타입 계층을 구성하기 위해서다. 사람들은 상속을 사용하는 두 가지 목적에 특별한 이름을 붙였는데 서브클래싱서브타이핑이다.

  • 서브클래싱 : 다른 클래스의 코드를 재사용할 목적으로 상속을 사용하는 경우를 말한다. 자식 클래스와 부모 클래스의 행동이 호환되지 않기 때문에 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 “대체”할 수 없다. 서브클래싱을 구현 상속 또는 클래스 상속이라고 부르기도 한다.

  • 서브타이핑 : 타입 계층을 구성하기 위해 상속을 사용하는 경우를 말한다. 영화 예매 시스템에서 구현한 DiscountPolicy 상속 계층이 서브타이핑에 속한다. 서브타이핑은 자식 클래스와 부모 클래스의 행동이 호환되기 때문에 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대체할 수 있다. 이때 부모 클래스는 자식 클래스의 슈퍼타입이 되고 자식 클래스는 부모 클래스의 서브타입이 된다. 서브타이핑을 인터페이스 상속이라고 부르기도 한다.

서브클래싱과 서브타이핑을 나누는 기준을 상속을 사용하는 ‘목적’이다. 자식 클래스가 부모 클래스의 코드를 재사용할 목적으로 상속을 사용했다면 서브클래싱이다. 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용할 목적으로 상속을 사용했다면 그것은 서브타이핑이다.

클래스 상속(서브 클래싱)은 객체의 구현을 정의할 때 이미 정의된 객체의 구현을 바탕으로 한다. 즉, ‘코드 공유’의 방법이다. 이에 비해 인터페이스 상속(서브타이핑)은 객체가 다른 곳에서 사용될 수 있음을 의미한다.

인터페이스 상속 관계를 갖는 경우 프로그램은 슈퍼타입으로 정의하지만 런타임에 서브타입 객체로 대체할 수 있다.

타입을 설명할 때 강조했던 것 처럼 슈퍼타입과 서브타입 사이의 관계에서 가장 중요한 것은 ‘퍼블릭 인터페이스’다. 슈퍼타입 인스턴스를 요구하는 모든 곳에서 서브타입 인스턴스를 대신 사용하기 위해 만족해야 하는 최소한의 조건은 서브타입의 퍼블릭 인터페이스가 슈퍼타입에서 정의한 퍼블릭 인터페이스와 동일하거나 더 많은 오퍼레이션을 포함 해야 한다는 것이다. 따라서 개념적으로 서브타입이 슈퍼타입의 퍼블릭 인터페이스를 상속받는 것 처럼 보인다. 이것이 서브타이핑을 인터페이스 상속이라고 부르는 이유이다. 그에 반해 서브클래싱은 클래스의 내부 구현 자체를 상속받는 것에 초점을 맞추기 때문에 구현 상속 또는 클래스 상속이라고 부른다.

서브타이핑 관계가 유지되기 위해서는 서브타입이 슈퍼타입이 하는 모든 행동을 동일하게 할 수 있어야 한다. 즉, 어떤 타입이 다른 타입의 서브타입이 되기 위해서는 행동 호환성을 만족시켜야 한다.

자식클래스가 부모클래스를 대신할 수 있기 위해서는 부모 클래스가 사용되는 모든 문맥에서 자식 클래스가 동일하게 행동할 수 있어야 한다. 그리고 행동 호환성을 만족하는 상속 관계는 부모 클래스를 새로운 자식 클래스로 대체하더라도 시스템이 문제 없이 동작할 것이라는 것을 보장해야 한다. 다시말해서 자식 클래스와 부모 클래스 사이의 행동 호환성은 부모 클래스에 대한 자식 클래스의 대체 가능성을 포함한다.

행동 호환성대체 가능성은 올바른 상속 관계를 구축하기 위해 따라야 할 지침이라고 할 수 있다. 오랜 시간 동안 이 지침은 리스코프 치환 원칙이라는 이름으로 정리되어 소개돼 왔다. 이제 리스코프 치환 원칙을 통해 지금까지 살펴 본 IS-A 관계와 행동 호환성을 다시 한번 정리해보자.


리스코프 치환 원칙

리스코프에 의하면 상속 관계로 연결한 두 클래스가 서브타이핑 관계를 만족시키기 위해서는 다음의 조건을 만족시켜야 한다.

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

리스코프 치환 원칙을 한마디로 정리하면, 서브타입은 그것의 기반 타입에 대해 대체 가능해야 한다. 는 것으로 ‘클라이언트’가 차이점을 인식하지 못한 채 기반 클래스의 인터페이스를 통해 서브클래스를 사용할 수 있어야 한다. 는 것이다. 리스코프 치환 원칙은 앞에서 논의한 행동 호환성을 설계 원칙으로 정리한 것이다. 리스코프 치환 원칙에 따르면 자식 클래스가 부모 클래스와 행동 호환성을 유지함으로써 부모 클래스를 대체할 수 있도록 구현된 상속 관계만을 서브타이핑이라고 불러야 한다.

10장에서 살펴본 Stack과 Vector의 예는 리스코프 치환 원칙을 위반하는 전형적인 예다. 클라이언트가 부모 클래스인 Vector에 대해 기대하는 행동Stack에 대해서는 기대할 수 없기 때문행동 호환성을 만족시키지 않기 때문이다. IS-A 관계의 애매모호함을 설명하기 위해 예로 들었던 PenguinBird 역시 리스코프 치환 원칙을 위반한다.

이번에 살펴 볼 예제는 더 미묘하다. 대부분의 사람들은 정사각형(Square) IS-A 직사각형(Rectangle)이라는 이야기를 당연하게 생각한다. 하지만 정사각형직사각형이 아닐 수 있다. 사실 정사각형직사각형은 리스코프 치환 원칙을 위반하는 전형적인 예 중 하나이다.

먼저 직사각형을 구현한 Rectangle부터 살펴보자.

Rectangle은 왼쪽 상단 모서리 위치 (x, y)와 너비(width), 높이(height)을 인스턴스 변수로 포함한다. setWidht 메서드와 setHeight 메서드는 직사각형의 너비와 높이를 변경하고 getArea 메서드는 직사각형의 너비를 반환한다.

이제 이 애플리케이션에 Square를 추가하자. 개념적으로 정사각형직사각형의 특수한 경우이고 직사각형정사각형의 일반적인 경우이기 때문에 정사각형직사각형 사이에 어휘적으로 IS-A 관계가 성립힌다. 이미 알고 있는 것 처럼 IS-A 관계를 구현하는 가장 간단한 방법은 상속을 이용하는 것이다.

정사각형은 너비와 높이가 동일해야 한다. 따라서 Square 클래스는 widthheight을 동일하게 설정해야 한다. 구현된 Square 클래스는 Square의 제약사항을 강제할 수 있도록 생성자에서 width 하나만 인자로 취하며, height의 값을 width와 동일한 값으로 설정한다. 또한 RectanglesetWidth 메서드와 setHeight 메서드를 오버라이딩 해서 너비와 높이가 항상 같도록 보장한다.

SquareRectangle의 자식 클래스이기 때문에 Rectangle이 사용되는 모든 곳에서 업캐스팅될 수 있다. 문제는 여기서 발생한다. Rectangle과 협력하는 클라이언트직사각형의 너비와 높이가 다르다고 가정한다. 따라서 아래의 예제 코드처럼 직사각형의 너비와 높이를 서로 다르게 설정하도록 프로그래밍 할 것이다.

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

직사각형은 너비와 높이가 다를 수 있다고 가정한다. 정사각형은 너비와 높이가 항상 동일하다고 가정한다. resize 메서드의 구현은 Rectangle이 세운 가정에 기반하기 때문에 직사각형의 너비와 높이를 독립적으로 변경할 수 있다고 가정한다. 하지만 Rectangle의 자리에 Square를 전달할 경우 이 가정은 무너지고 만다.

resize 메서드의 관점에서 Rectangle 대신 Square를 사용할 수 없기 때문에 Square는 Rectangle이 아니다. SquareRectangle구현을 재사용하고 있을 뿐이다. 두 클래스는 리스코프 치환 원칙을 위반하기 때문에 서브타이핑 관계가 아니라 서브클래싱관계이다.

RectangleIS-A라는 말이 얼마나 우리의 직관에서 벗어날 수 있는지를 잘 보여준다. 중요한 것은 ‘클라이언트의 관점’에서 행동이 호환되는지 여부다. 그리고 행동이 호환될 경우에만 자식 클래스가 부모 클래스 대신 사용될 수 있다.

클라이언트와 대체 가능성

SquareRectangle을 대체할 수 없는 이유는 클라이언트의 관점에서 Square와 Rectangle이 다르기 때문이다. SquareRectangle의 문제는 본질적으로 VectorStack이 가지고 있던 문제와 동일하다. 클라이언트의 관점에서 정사각형을 추상화한 Square는 직사각형을 추상화한 Rectangle과 동일하지 않다는 점이다.

Rectangle을 사용하는 클라이언트는 Rectangle의 너비와 높이가 다를 수 있다는 가정하에 코드를 개발한다. 반면 Square는 너비와 높이가 항상 같다. 너비와 높이가 다르다는 가정 하에 개발된 클라이언트 코드에서 Retangle을 Sqaure로 대체할 경우 Rectangle에 대해서 세워진 가정을 위반할 확률이 높다. 결국 코드는 예상한 대로 동작하지 않을 가능성이 높아진다.

리스코프 치환 원칙은 자식 클래스가 부모 클래스를 대체하기 위해서는 [부모 클래스에 대한 클라이언트의 가정을 준수]해야 한다는 점을 강조한다.

SquareRectangle의 자식 클래스로 만드는 것은 Rectangle에 대해 클라이언트가 세운 가정을 송두리째 뒤흔드는 것이다.

StackVector서브타이핑 관계가 아닌 서브클래싱 관계인 이유도 마찬가지다. StackVector가 리스코프 치환 원칙을 위반하는 가장 큰 이유는 상속으로 인해 Stack에 포함되어서는 안되는 Vector의 퍼블릭 인터페이스가 Stack의 퍼블릭 인터페이스에 포함됐기 때문이다.

[Vector]를 사용하는 클라이언트의 관점에서 [Stack]의 행동은 [Vector]의 행동과 호환되지 않는다. Vector의 클라이언트는 임의의 위치에 요소를 추가하거나 임의의 위치에 있는 요소를 추출할 것이라고 예상한다. 그러나 Stack의 클라이언트는 Stack이 임의의 위치에서 조회나 추가를 금지할 것이라고 예상한다.

Stack과 협력하는 클라이언트Vector와 협력하는 클라이언트Stack과 Vector 각각에 대해 전송할 수 있는 [메시지]와 [기대하는 행동]이 서로 다르다. 이것은 StackVector서로 다른 클라이언트와 협력해야 한다는 것을 의미한다.

리스코프 치환 원칙은 클라이언트와 격리한 채로 본 모델은 의미있게 검증하는 것이 불가능하다. 라는 아주 중요한 결론을 이끌어낸다. 어떤 모델의 유효성은 클라이언트의 관점에서만 검증 가능하다는 것이다.

리스코프 치환 원칙은 상속 관계에 있는 두 클래스 사이의 관계를 클라이언트와 떨어뜨려 놓고 판단하지 말라고 강조한다. 상속 관계는 [클라이언트의 관점]에서 자식 클래스가 부모 클래스를 [대체]할 수 있을 때만 올바르다.

[행동 호환성]과 [리스코프 치환 원칙]에서 한 가지만 기억해야 한다면 이것을 기억하라 [대체 가능성을 결정하는 것은 클라이언트다.]

클라이언트의 입장에서 IS-A 관계 다시 살펴보기

상속이 적합한지를 판단하기 위해 마틴 오더스키가 제안한 두 질문을 다시 떠올려 보자. 상속 관계가 어휘적으로 IS-A 관계를 모델링 한 것인가? 클라이언트의 입장에서 부모 클래스 대신 자식 클래스를 사용할 수 있는가?

사실 이 두 질문을 별개로 취급할 필요는 없다. 클라이언트의 관점에서 자식 클래스의 행동이 부모 클래스의 행동과 호한되지 않고 그로 인해 대체가 불가능하다면 어휘적으로 IS-A라고 말할 수 있다고 하더라도 그 관계를 IS-A 관계라고 할 수 없다.

IS-A클라이언트의 관점에서 IS-A일 때만 참이다. 정사각형은 직사각형인가? 클라이언트가 이 둘을 동일하게 취급할 수 있을 때만 그렇다. 펭귄은 새인가? 클라이언트의 관점에서 이 둘을 동일하게 취급할 수 있을 때만 그렇다.

IS-A 관계로 표현된 문장을 볼 때마다 문장 앞에 "클라이언트의 입장에서"라는 말이 빠져 있다고 생각하자. (클라이언트의 입장에서) 정사각형은 직사각형이다. (클라이언트의 입장에서) 펭귄은 새다. 클라이언트를 배제한 IS-A는 아무런 의미가 없다.

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

[행동]을 고려하지 않은 두 타입의 이름이 단순히 IS-A로 연결된다고 해서 상속 관계로 연결하지 마라. [이름이 아니라 행동이 우선이다.] 객체지향과 관련된 대부분의 규칙이 그런 것처럼 [IS-A 관계 역시 행동이 우선이다.]

결론적으로 상속이 서브타이핑이 되기 위해 사용될 경우에만 IS-A 관계다. 서브클래싱을 구현하기 위해 상속을 사용했다면 IS-A 관계라고 말할 수 없다.

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

지금까지 살펴본 것 처럼 리스코프 치환 원칙클라이언트가 어떤 자식 클래스와도 안정적으로 협력할 수 있는 상속 구조를 구현할 수 있는 가이드라인을 제시한다. 새로운 자식 클래스를 추가하더라도 클라이언트의 입장에서 동일하게 행동하기만 한다면 클라이언트를 수정하지 않고도 상속 계층을 확장할 수 있다. 다시말해서 클라이언트의 입장에서 퍼블릭 인터페이스의 행동 방식이 변경되지 않는다면 클라이언트의 코드를 변경하지 않고 새로운 자식 클래스와 협력할 수 있게 된다는 것이다.

리스코프 치환 원칙을 따르는 설계는 유연할 뿐만 아니라 확장성이 높다. 8장에서 중복 할인 정책을 구현하기 위해 기존의 DiscountPolicy 상속 계층에 새로운 자식 클래스인 OverlappedDiscountPolicy를 추가하더라도 클라이언트를 전혀 수정할 필요가 없던 것을 기억하는가?

사실 이 설계는 의존성 역전 원칙개방-폐쇄 원칙, 리스코프 치환 원칙이 한데 어우려져서 설계를 확장하게 만든 대표적인 예다.

  • 의존성 역전 원칙 : 구체 클래스인 MovieOverlappedDiscountPolicy 모두 추상 클래스인 DiscountPolicy에 의존한다. 상위 수준의 모듈인 Movie하위 수준의 모듈인 OverlappedDiscountPolicy 모두 추상 클래스인 DiscountPolicy에 의존한다. 따라서 이 설계는 의존성 설계 원칙을 만족한다.

  • 리스코프 치환 원칙 : DiscountPolicy와 협력하는 Movie(클라이언트)의 관점에서 DiscountPolicy 대신 OverlappedDiscountPolicy와 협력하더라도 아무런 문제가 없다. 다시 말해서 OverlappedDiscountPolicy는 클라이언트에 대한 영향 없이도 DiscountPolicy를 대체할 수 있다. 따라서 이 설계는 리스코프 치환 원칙을 만족한다.

  • 개방-폐쇄 원칙 : 중복 할인 정책이라는 새로운 기능을 추가하기 위해 DiscountPolicy의 자식 클래스인 OverlappedDiscountPolicy를 추가하더라도 Movie에는 영향을 끼치지 않는다. 다시 말해서 기능 확장을 하면서 기존 코드를 수정할 필요가 없다. 따라서 이 설계는 개방-폐쇄 원칙을 만족한다.

리스코프 치환 원칙이 어떻게 개방-폐쇄 원칙을 지원하는지 눈여겨 보기 바란다. 자식 클래스가 [클라이언트의 관점]에서 부모 클래스를 [대체]할 수 있다면 [기능 확장]을 위해 자식 클래스를 추가하더라도 [코드를 수정할 필요가 없어진다.] 따라서 리스코프 치환 원칙개방-폐쇄 원칙을 만족하는 설계를 위한 전제 조건이다. 일반적으로 리스코프 치환 원칙 위반은 잠재적인 개방-폐쇄 원칙 위반이다.

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

한 가지 잊지 말아야 하는 사실은 클래스 상속은 타입 계층을 구현할 수 있는 다양한 방법 중 하나일 뿐이라는 것이다. 자바와 C#의 인터페이스, 스칼라의 트레이트, 동적 타입 언어의 덕 타이핑 등의 기법을 사용하면 클래스 사이의 상속을 사용하지 않고 서브타이핑 관계를 구현할 수 있다. 물론 이런 기법을 사용하는 경우에도 리스코프 치환 원칙을 준수해야만 서브타이핑 관계라고 말할 수 있다.

구현 방법은 중요하지 않다. 핵심은 구현 방법과 무관하게 [클라이언트의 관점]에서 슈퍼 타입에 대해 기대하는 모든 것이 서브 타입에게도 적용돼야 한다는 것이다.

몇 가지 질문만이 남았다 클라이언트의 관점에서 자식 클래스가 부모 클래스를 대체할 수 있다는 것은 무엇을 의미하는가? 클라이언트의 관점에서 자식 클래스가 부모 클래스의 행동을 보존한다는 것은 무엇을 의미하는가?


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

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

리스코프 치환 원칙어떤 타입이 서브타입이 되기 위해서는 슈퍼타입의 인스턴스와 협력하는 [클라이언트의 관점]에서 슈퍼타입의 인스턴스를 서브타입으로 대체하더라도 협력에 지장이 없어야 한다는 것을 의미한다. 따라서 계약에 의한 설계를 사용하면 리스코프 치환 원칙이 강제하는 조건계약의 개념을 이용해 좀더 명확하게 설명할 수 있다.

리스코프 치환 원칙계약에 의한 설계사이의 관계를 다음과 같은 한 문장으로 요약할 수 있다.

서브타입이 리스코프 치환 원칙을 만족시키기 위해서는 클라이언트와 슈퍼타입간에 체결된 [계약]을 준수해야 한다.

이해를 돕기 위해 영화 예매 시스템에서 DiscountPolicy와 협력하는 Movie 클래스를 예로 들어보자.

MovieDiscountPolicy의 인스턴스에게 calculateDiscountAmount 메시지를 전송하는 클라이언트다. DiscountPolicyMovie의 메시지를 수신한 후 할이된 가격을 계산하여 반환한다.

계약에 의한 설계에 따르면 협력하는 클라이언트슈퍼타입의 인스턴스 사이에는 어떤 계약이 맺어져 있다. 클라이언트와 슈퍼타입은 이 계약을 준수할 때만 정상적으로 협력할 수 있다.

리스코프 치환 원칙서브타입이 그것의 슈퍼타입을 대체할 수 있어야 하고 클라이언트가 차이점을 인식하지 못한 채 슈퍼타입의 인터페이스를 이용해 서브타입과 협력할 수 있어야 한다고 말한다. 클라이언트의 입장에서 서브타입은 정말 슈퍼타입의 ‘한 종류’여야 하는 것이다.

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

서브타입이 슈퍼타입처럼 보일 수 있는 유일한 방법은 클라이언트가 슈퍼타입과 맺은 ‘계약’을 서브타입이 준수하는 것 뿐이다.

지금까지는 MovieDiscountPolicy 사이의 계약에 대해서는 크게 상관하지 않았다. 하지만 코드를 살펴보면 직접적으로 언급을 하지 않았을 뿐 암묵적인 사전조건사후조건이 존재한다는 사실을 알 수 있다.

먼저 사전조건부터 살펴보자. DiscountPolicycalculateDiscountAmount 메서드는 인자로 전달된 screeningnull인지 여부를 확인하지 않는다. 하지만 screeningnull이 전달된다면 screening.getMovieFee()가 실행될 때 NullPointerException 예외가 던져질 것이다.

screeningnull이 전달되는 것은 우리가 기대했던 것이 아니다. calculateDiscountAmount 메서드는 클라이언트가 전달하는 screening의 값이 null이 아니고 영화 시작 시간이 아직 지나지 않았다고 가정 할 것이다. 따라서 단정문(assertion)을 이용해서 사전조건을 다음과 같이 표현할 수 있다.

MoviecalcualteMovieFee 메서드를 살펴보면 DiscountPolicycalculateDiscountAmount 메서드의 반환값에 어떤 처리도 하지 않고 fee에서 차감하고 있음을 알 수 있다. 따라서 calculateDiscountAmount 메서드의 반환값은 항상 null이 아니어야 한다. 추가로 반환되는 값은 청구되는 요금이기 때문에 최소한 0원보다는 커야 한다. 따라서 사후조건은 다음과 같다.

다음은 calculateDiscountAmount 메서드에 사전조건사후조건을 추가한 것이다. 사전조건은 checkPrecondition 메서드로, 사후조건은 checkPostcondition 메서드로 구현돼 있다.

calculateDiscountAmount 메서드가 정의한 사전조건을 만족시키는 것은 Movie(클라이언트)의 책임이다. 따라서 Movie는 사전조건을 위반하는 screening을 전달해서는 안된다.

DiscountPolicy의 자식 클래스인 AmountDiscountPolicy, PercentDiscountPolicy, OverlappedDiscountPolicyMovie(클라이언트)DiscountPolicy(슈퍼타입, 서버) 사이에 체결된 계약을 만족시키는가? 이 클래스들은 DiscountPolicycalculateDiscountAmount 메서드를 그대로 상속받기 때문에 계약을 변경하지 않는다. 따라서 Movie(클라이언트)의 입장에서 이 클래스들은 DiscountPolicy를 대체할 수 있기 때문에 서브타이핑 관계라고 할 수 있다.

서브타입과 계약

물론 모든 상황이 이렇게 행복한 것만은 아니다. 계약의 관점에서 상속이 초래하는 가장 큰 문제는 자식 클래스가 부모 클래스의 메서드를 오버라이딩 할 수 있다는 것이다.

예를 들어 보자. DiscountPolicy를 상속받은 BrokenDiscountPolicy 클래스는 calculateDiscountAmount 메서드를 오버라이딩 한 후 여기에 새로운 사전조건을 추가한다. 새로운 사전조건은 checkStrongerPrecondition 메서드로 구현돼 있으며 종료 시간이 자정을 넘는 영화를 예매할 수 없다는 것이다. 따라서 DiscountPolicy(슈퍼타입)보다 더 강화된 사전조건을 정의한다.

BrokenDiscountPolicy 클래스가 DiscountPolicy 클래스의 자식 클래스이기 때문에 컴파일러는 아무런 제약 없이 업캐스팅을 허용한다. 따라서 Movie(클라이언트)BrokenDiscountPolicyDiscountPolicy로 간주할 것이다.

문제는 Movie(클라이언트)가 오직 DiscountPolicy(슈퍼타입)의 사전조건만 알고 있다는 점이다. MovieDiscountPolicy가 정의하고 있는 사전조건을 만족시키기 위해 null이 아니면서 시작 시간이 현재 시간 이후인 screening을 전달할 것이다. 따라서 자정이 지난 후에 종료되는 screening을 전달하더라도 문제가 없다고 가정할 것이다.

안타깝게도 BrokenDiscountPolicy의 사전조건은 이를 허용하지 않기 때문에 협력은 실패하고 만다. 다시 말해서 BrokenDiscountPolicy는 클라이언트의 관점에서 DiscountPolicy를 대체할 수 없기 때문에 서브타입이 아니다. 따라서 자식 클래스가 부모 클래스의 서브타입이 되기 위해서는 다음 조건을 만족시켜야 한다.

서브타입에 더 강력한 사전조건을 정의할 수 없다.

더 강력한 사전조건을 정의하는 경우가 문제라면 그 반대로 사전조건을 제거해서 약화시킨다면 어떻게 될까?

BrokenDiscountPolicyScreening에 대한 사전조건을 체크하지 않지만 Movie(클라이언트)DiscountPolicy(슈퍼타입)이 정의한 사전조건을 만족시키기 위해 null이 아니며 현재 시간 이후에 시작하는 Screening을 전달한다는 것을 보장하고 있다. 클라이언트는 이미 자신의 의무를 충실히 수행하고 있기 때문에 이 조건을 체크하지 않는 것이 기존 협력에 어떤 영향도 미치지 않는다. 이 경우에는 아무런 문제도 발생하지 않는다는 것이다. 따라서 다음과 같은 사실을 알 수 있다.

서브타입에 슈퍼타입과 같거나 더 약한 사전조건을 정의할 수 있다. (클라이언트가 이미 슈퍼타입이 정의한 사전조건을 만족시킬 것이므로)

만약 사후조건을 강화시킨다면 어떻게 될까?

BrokenDiscountPolicy(자식클래스)DiscountPolicy(부모클래스)에 정의된 사후조건amount가 null이 아니고 0원보다는 커야한다는 제약에 최소 1000원 이상은 돼야한다는 새로운 사후조건을 추가한다. 다시 말해서 사후조건을 강화하고 있다.

Movie(클라이언트) DiscountPolicy(슈퍼타입)의 사후조건만 알고있다. Movie는 최소한 0원보다 큰 금액을 반환받기만 하면 협력이 정상적으로 수행됐다고 가정한다. 따라서 BrokenDiscountPolicy가 1000원 이상의 금액을 반환하는 것은 Movie(클라이언트)DiscountPolicy(슈퍼타입) 사이에 체결된 계약을 위반하지 않는다. 이 예로부터 다음과 같은 사실을 알 수 있다.

서브타입에 슈퍼타입과 같거나 더 강한 사후조건을 정의할 수 있다. (슈퍼타입이 이미 클라이언트가 정의한 사후조건을 만족할 것이므로)

사후조건을 약하게 정의하면 어떻게 될까? checkPostcondition 메서드를 호출하는 부분을 제거해서 사후조건을 체크하지 않도록 변경해보자.

변경된 코드에서는 요금 계산 결과가 마이너스라도 그대로 반환할 것이다. Movie(클라이언트)는 자신과 협력하는 객체가 DiscountPolicy(슈퍼타입)의 인스턴스라고 생각하기 때문에 반환된 금액이 0원보다는 크다고 믿고 예매요금으로 사용할 것이다. 이것은 예매 금액으로 마이너스 금액이 설정되는, 원하지 않았던 결과로 이어지고 만다. 이 예로부터 다음과 같은 사실을 알 수 있다.

서브타입에 더 약한 사후조건을 정의할 수 없다.

지금까지 살펴본 것 처럼 리스코프 치환 원칙을 설명하기 위해 계약에 의한 설계 개념을 이용할 수 있다. 어떤 타입이 슈퍼타입에서 정의한 [사전조건]보다 [더 약한 사전조건]을 정의하고 있다면 그 타입은 서브타입이 될 수 있지만 [더 강한 사전조건]을 정의하고 있다면 서브타입이 될 수 없다. 또한 어떤 타입이 슈퍼타입에서 정의한 [사후 조건]보다 [더 강한 사후조건]을 정의하더라도 그 타입은 여전히 서브타입이지만 [더 약한 사후조건]을 정의한다면 서브타입의 조건이 깨지고 만다.

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

참고 및 출처

  • 오브젝트