들어가며

객체지향을 설계 할 때에 있어 다형성을 구현하는 메커니즘으로 주로 상속을 사용한다. 하지만 이러한 상속코드 재사용이 아닌 타입 계층을 구축할 때에 비로소 유연하고 확장 가능한 설계로 이어진다. 이번 장에서 살펴볼 계약에 의한 설계는 클래스 간의 상속 관계에서 리스코프 치환 원칙을 준수하기 위해 지켜야 할 조건들이 어떤 것이 있는지 알아본다.


계약에 의한 설계

인터페이스 만으로는 객체의 행동에 관한 다양한 관점을 전달하기 어렵다. 우리에게 필요한 것은 명령의 부수효과를 쉽고 명확하게 표현할 수 있는 커뮤니케이션 수단이다. 이 시점이 되면 계약에 의한 설계(Design By Contract, DBC) 가 주는 혜택으로 눈을 돌릴 때가 된 것이다.

계약에 의한 설계를 사용하면 협력에 필요한 다양한 제약과 부수효과를 명시적으로 정의하고 문서화 할 수 있다. 클라이언트 개발자는 오퍼레이션의 구현을 살펴보지 않더라도 객체의 사용법을 쉽게 이해할 수 있다. 계약은 실행 가능하기 때문에 구현에 동기화돼 있는지 여부를 런타임에 검증할 수 있다. 따라서 주석과 다르게 시간의 흐름에 뒤쳐질 걱정을 할 필요가 없다.

“계약에 의한 설계”는 클래스의 부수효과를 명시적으로 문서화하고 명확하게 커뮤니케이셔할 수 있을 뿐만 아니라 실행 가능한 검증 도구로써 사용할 수 있다.

이번 장에 소개할 예제 일부는 C# 언어로 작성돼 있으며 계약에 의한 설계 라이브러리로 Code Contract를 사용한다.

이번 장에서 중요한 것은 코드가 아니라 개념이다. 코드를 구현하는 방법보다는 계약에 의한 설계를 사용하는 이유와 장점을 이해하는 것이 이번 장의 목표다.


협력과 계약

부수 효과를 명시적으로

객체지향의 핵심은 협력 안에서 객체들이 수행하는 행동이다. 안타깝게도 프로그래밍 언어로 작성된 인터페이스는 객체가 수신할 수 있는 메시지는 정의할 수 있지만 객체 사이의 의사소통 방식은 명확하게 정의할 수 없다. 메시지의 이름파라미터 목록시그니처를 통해 전달할 수 있지만 협력을 위해 필요한 약속과 제약은 인터페이스를 통해 전달할 수 없기 때문에 협력과 관련된 상당한 내용이 암시적인 상태로 남게된다.

여기서는 6장 명령-쿼리 분리 원칙을 설명하기 위해 소개했던 일정 관리 프로그램의 C# 버전을 이용해 계약에 의한 설계 개념을 설명하기로 한다. 명령과 쿼리를 분리했기 때문에 Event 클래스의 클라이언트는 먼저 IsSatisfed 메서드를 호출해서 RecurringSchedule의 조건을 만족시키는지 여부를 확인한 후에 Reschedule 메서드를 호출해야 한다. 인터페이스만으로 메서드의 순서와 관련된 제약을 설명하기 쉽지 않지만 계약에 의한 설계 라이브러리인 Code Contract를 사용하면 IsSatisfed 메서드의 실행 결과가 true일 때만 Reschedule 메서드를 호출할 수 있다는 사실을 명확하게 표현할 수 있다.

이렇게 작성된 계약은 문서화가 가능하다. 또한 문서화로 끝나는 것이 아니라 제약 조건의 만족 여부를 실행 중에 체크할 수 도 있다. 따라서 계약에 의한 설계를 사용하면 제약 조건을 명시적으로 사용하고 자동으로 문서화 할 수 있을 뿐 아니라 실행을 통해 검증할 수 있다.

계약

현재 살고 있는 집을 리모델링 하고 싶다고 가정해보자. 우리게에는 리모델링 할 수 잇는 전문적인 지식이 부족하기 때문에 적절한 인테리어 전문가에게 작업을 위탁하고 계약을 체결할 것이다.

계약의 세부적인 내용은 상황에 따라 다르겠지만 일반적으로 다음과 같은 특성을 가진다.

  • 각 계약 당사자는 계약으로부터 이익(benefit)을 기대하고 이익을 얻기 위해 의무(obligation)을 이행한다.
  • 각 계약 당사자의 이익과 의무는 계약서에 문서화된다.

여기서 눈여겨볼 부분은 한쪽의 의무가 반대쪽의 권리가 된다는 것이다. 리모델링을 위탁하는 클라이언트 입장에서 의무는 인테리어 전문가에게 대금을 지급하는 것이다. 그로 인해 얻게되는 이익은 원하는 품질로 리모델링 된 집을 얻는 것이다. 리모델링 작업을 수행하는 인테리어 전문가 입장에서 의무는 고객이 원하는 품질로 집을 리모델링 하는 것이다. 그로 인해 얻는 이익은 대금을 지급받는 것이다.

두 계약 당사자 중 어느 한쪽이라도 계약서에 명시된 내용을 위반한다면 계약은 정상적으로 완료되지 않을것이다. 인테리어 전문가가 자신의 의무인 리모델링 작업을 완료하지 못했다면 이익으로 명시된 대금을 지급받지 못할 것이다. 인테리어 전문가가 리모델링 작업을 완료했는데도 고객이 자신의 의무인 대금을 지급하지 못한다면 고객의 이익인 리모델링된 집에서의 생활은 물거품이 될 것이다.

비록 우리가 계약상 클라이언트라고 하더라도 인터레이 전문가가 계약을 이행하는 구체적인 방식에 대해서는 간섭하지 않는다는 사실을 기억하라. 리모델링 공사를 진행하는 구체적인 방법은 인테리어 전문가가 자유롭게 결정할 수 있다. 작업 방식과 상관 없이 리모델링된 결과가 만족스럽다면 클라이언트는 인테리어 전문가가 계약을 정상적으로 이행한 것으로 간주할 것이다.

이처럼 계약은 협력을 명확하게 정의하고 커뮤니케이션 할 수 있는 범용적인 아이디어다. 그리고 사람들이 협력을 위해 사용하는 계약이라는 아이디어를 객체들이 협력하는 방식에도 적용할 수 있지 않을까 의문을 품음으로써 계약의 의한 설계가 탄생되었다.


계약에 의한 설계

계약협력에 참여하는 두 객체 사이이의 의무와 이익을 문서화 한 것이다.

  • 협력에 참여하는 각 객체는 계약으로부터 이익을 기대하고 이익을 얻기위해 의무를 이행한다.
  • 협력에 참여하는 각 객체의 이익의무는 객체의 인터페이스 상에 문서화된다.

계약에 의한 설계 개념은 인터페이스에 대해 프로그래밍 하라는 원칙을 확장한 것이다. 계약에 의한 설계를 이용하면 오퍼레이션의 시그니처를 구성하는 다양한 요소들을 이용해 협력에 참여하는 객체들이 지켜야 하는 제약조건을 명시할 수 있다. 이 제약 조건을 인터페이스의 일부로 만듦으로써 코드를 분석하지 않고도 인터페이스의 사용법을 이해할 수 있다.

아래 코드는 자바 언어로 작성된 reverse 메서드의 구성 요소를 표현한 것으로, 협력을 위한 다양한 정보를 제공한다. 이 메서드는 public 가시성을 가지기 때문에 외부에서 호출 가능하다. 이 메서드를 사용하기 위해서는 Customer 타입과 int 타입의 인자를 전달해야 한다. 메서드 실행이 성공하면 반환 타입으로 Reservation 인스턴스를 반환한다는 사실도 알 수 있다.

우리는 메서드의 이름매개변수의 이름을 통해 오퍼레이션이 클라이언트에게 어떤 것을 제공하려고 하는지를 충분히 설명할 수 있다. 6장에서 설명한 클라이언트의 관점에서 의도를 드러내는 인터페이스를 만들면 오퍼레이션의 시그니처만으로도 어느 정도 까지는 클라이언트와 서버가 협력을 위해 수행해야 하는 제약조건을 명시할 수 있다.

계약은 여기서 한 걸음 더 나아간다. reverse 메서드를 호출할 때 클라이언트 개발자는 customer의 값으로 null을 전달할 수 있고 audienceCount 값으로 음수를 포함한 어떤 정수도 전달할 수 있다고 가정할지 모른다. 하지만 이 메서드는 고객의 예약 정보를 생성하는 것이기 때문에 한 명이상의 예약자에 대해 예약 정보를 생성해야 한다. 따라서 customernull이 되면 안되고 audienceCount의 값은 1보다 크거나 최소한 같아야 한다. 클라이언트가 이 조건을 만족하는 인자를 전달했다면 reverse 메서드가 반환하는 Reservation 인스턴스는 null이 아니어야 한다.

협력하는 클라이언트는 정상적인 “상태”를 가진 객체와 협력해야 한다. 아래와 같이 정삭적인 Screeningmovienull이 아니어야 하고 squence는 1보다 크거나 같아야 하며, whenScreened는 현재 시간 이후의 값을 가지고 있어야 한다. 이 조건을 만족하지 않는 Screening은 예매할 수 없다. 따라서 어떤 Screening 인스턴스가 이 조건을 만족하지 않는다면 reverse 메서드를 호출할 수 없어야 한다.

서버는 자신이 처리할 수 있는 범위의 값들을 클라이언트가 전달할 것이라고 기대한다. 클라이언트는 자신이 원하는 값을 서버가 반환할 것이라고 기대한다. 클라이언트는 메시지 전송 전과 후의 서버의 상태가 정상일것이라고 기대한다. 이 세 가지 기대가 바로 계약에 의한 설계를 구성하는 세 가지 요소에 대응된다. 이 요소들을 순서대로 사전조건, 사후조건, 불변식이라고 부른다.

  • 사전조건(precondition) : 메서드가 호출되기 위해 만족돼야 하는 조건. 이것은 메서드의 요구사항을 명시한다. 사전조건이 만족되지 않을 경우 메서드가 실행되어서는 안된다. 사전조건을 만족시키는 것은 메서드를 실행하는 클라이언트의 의무이다.

  • 사후조건(postcondition): 메서드가 실행된 후에 클라이언트에게 보장해야 하는 조건. 클라이언트가 사전 조건을 만족시켰다면 메서드는 사후조건 에 명시된 조건을 만족시켜야 한다. 만약 클라이언트가 사전조건을 만족시켰는데도 사후조건을 만족시키지 못한 경우에는 클라이언트에게 예외를 던져야 한다. 사후조건을 만족시키는 것은 서버의 의무이다.

  • 불변식(invariant) : 항상 이라고 보장되는 서버의 조건. 메서드가 실행되는 도중에는 불변식을 만족시키지 못할 수도 있지만 메서드를 실행하기 전이나 종료된 후에 불변식은 항상 참이어야 한다.

사전조건, 사후조건, 불변식을 기술할 때는 실행 절차를 기술할 필요 없이 상태 변경만을 명시하기 때문에 코드를 이해하고 분석하기 쉬워진다. 클라이언트 개발자는 사전 조건에 명시된 조건을 만족시키지 않으면 메서드가 실행되지 않을 것이라는 사실을 잘 알고있따. 불변식을 사용하면 클래스의 의미를 쉽게 설명할 수 있고 클라이언트 개발자가 객체를 더욱 쉽게 예측할 수 있다. 사후조건을 믿는다면 객체가 내부적으로 어떤 방식으로 동작하는지 걱정할 필요가 없다. ‘사전조건’, ‘사후조건’, ‘불변식’에는 클라이언트 개발자가 알아야 하는 모든 것이 포함돼 있을 것이다.

여기서는 Screening에 대한 제약 조건을 사전조건, 사후조건, 불변식으로 구현할 것이다. 자바의 경우는 계약에 의한 설계 개념을 지원하지 않기 때문에 여기서는 C#으로 작성된 예제를 살펴본다.

사전 조건

사전조건이란 메서드가 정상적으로 실행되기 위해 만족해야 하는 조건이다. 사전조건을 만족시키는 것은 메서드를 실행하는 클라이언트의 의무이다. 따라서 사전조건을 만족시키지 못해서 메서드가 실행되지 않을 경우 클라이언트에 버그가 있다는 것을 의미한다. 사전조건이 만족되지 않을 경우 서버는 메서드를 실행할 의무가 없다.

일반적으로 사전조건은 메서드에 전달된 인자의 ‘정합성’을 체크하기 위해 사용된다. 예를 들어 reverse 메서드의 경우 인자로 전달된 customernull이 아니어야 하고 audienceCount의 값은 1보다 크거나 같아야 한다. 이 조건을 만족시키지 못할 경우 reverse 메서드는 실행되지 말아야 한다. 따라서 이 조건을 메서드의 사전조건으로 정의함으로써 메서드가 잘못된 값을 기반으로 실행되는 것을 방지할 수 있다.

아래는 메서드의 사전조건을 정의하기 위해 사용되는 ContractReuqires 메서드다.

사전 조건을 만족시킬 책임은 Reverse 메서드를 호출하는 클라이언트에게 있다는 사실을 기억하자. 클라이언트사전조건을 만족시키지 못할 경우 Reverse 메서드는 최대한 빨리 실패해서 클라이언트에게 버그가 있다는 사실을 알린다.

이 에제는 계약에 의한 설계의 장점이 무엇인지를 잘 보여준다. 계약에 의한 설계를 사용하면 계약만을 위해 준비된 전용 표기법을 사용해 계약을 명확하게 표현할 수 있다. 또한 계약을 일반 로직과 분리해서 서술함으로써 계약을 좀 더 두드러지게 강조할 수 있다. 또한 계약이 메서드의 일부로 실행되도록 함으로써 계약을 강제할 수 있다.

사후 조건

사후조건은 메서드의 실행 결과가 올바른지 검사하고 실행 후에 객체가 유효한 상태로 남아있는지를 검증한다. 간단히 말해서 사후 조건을 통해 메서드를 호출한 후에 어떤 일이 일어났는지를 설명할 수 있는 것이다. 클라이언트사전조건을 만족시켰는데도 서버사후조건을 만족시키지 못한다면 서버에 버그가 있음을 의미한다.

일반적으로 사후조건은 다음과 같은 세 가지 용도로 사용된다.

  • 인스턴스 변수의 상태가 올바른지를 서술하기 위해

  • 메서드에 전달된 파라미터의 값이 올바르게 변경되었는지 서술하기 위해

  • 반환 값이 올바른지 서술하기 위해

다음과 같은 두 가지 이유로 인해 사전조건보다 사후조건을 정의하는 것이 더 어려울 수 있다.

  • 한 메서드 안에서 return 문이 여러 번 나올 경우
    • 모든 return 문 마다 결괏값이 올바른지 검증하는 코드를 추가해야 한다. 다행히도 계약에 의한 설계를 지원하는 대부분의 라이브러리는 결괏값에 대한 사후 조건을 한 번만 기술할 수 있게 해준다.
  • 실행 전과 실행 후의 값을 비교해야 하는 경우
    • 실행 전의 값이 메서드 실행으로 인해 다른 값으로 변경됐을 수 있기 때문에 두 값을 비교하기 어려울 수 있다. 다행히 계약에 의한 설계를 지원하는 대부분의 라이브러리는 실행 전의 값에 접근할 수 있는 간단한 방법을 제공한다.

Code Contracts에서 사후조건을 정의하기 위해서는 Contract.Ensures 메서드를 제공한다. Reserve 메서드의 사후조건은 반환값이 Reservation 인스턴스가 null이어서는 안된다는 것이다. 따라서 다음과 같이 사후조건을 추가할 수 있다.

Ensures 메서드 안에서 사용된 Contract.Result<T> 메서드가 바로 Reserve 메서드의 실행 결과에 접근할 수 있게 해주는 메서드다. 이 메서드는 제네릭 타입으로 메서드의 반환 타입에 대한 정보를 명시할 것을 요구한다.

Contract.Result<T> 메서드는 하나 이상의 종료 지점을 가지는 메서드에 대한 사후조건을 정의할 때 유용하게 사용할 수 있다. 다음 메서드를 살펴보자.

Buy 메서드는 초대장이 있을 경우에는 0원을, 초대장이 없을 경우에는 티켓의 요금을 반환한다. 이 메서드에는 두 개의 return 문이 존재한다는 점을 주목하자. 만약 Code Contracts를 사용하지 않는다면 사후조건을 두 개의 return 문 모두에 중복해서 작성해야 했을 것이다. Contract.Result<T> 메서드는 이런 경우에 수고를 덜어준다. Contract.Result<T>는 메서드 실행이 끝난 후에 실제로 반환되는 값을 전달하기 때문에 몇 번의 return문이 나오더라도 다음과 같이 한 번만 기술하면 된다.

Contract.OldValue<T>를 이용하면 메서드 실행 전의 상태에도 접근할 수 있다. 이 메서드를 이용하면 실행 중에 값이 변경되더라도 사후조건에서 변경 이전의 값을 이용할 수 있게 해준다. 아래 코드에서 파라미터로 전달된 text의 값이 메서드 실행 중에 변경되기 때문에 text의 값을 이용하는 사후조건이 정상적으로 체크되지 않는다.

이 경우 Contract.OldValue<T>를 이용하면 메서드를 실행할 때의 text의 값에 접근할 수 있다. 따라서 위 코드를 다음과 같이 변경하면 문제 없이 사후조건을 검증할 수 있다.

불변식

사전조건사후조건각 메서드 마다 달라지는데 반해 불변식은 인스턴스 생명 주기 전반에 걸쳐 지켜져야 하는 규칙을 명세한다. 일반적으로 불변식객체의 내부 상태와 관련이 있다.

불변식은 다음과 같은 두 가지 특성을 가진다.

  • 불변식클래스의 모든 인스턴스(멤버)가 생성된 후에 만족돼야 한다. 클래스에 정의된 모든 생성자는 불변식을 준수해야 한다는 것을 의미한다.

  • 불변식클라이언트에 의해 호출 가능한 모든 메서드에 의해 준수돼야 한다. 메서드가 실행 중에는 객체의 상태가 불안정한 상태로 빠질 수 있기 때문에 불변식을 만족시킬 필요는 없지만 메서드 실행 전과 메서드 종료 후에는 항상 불변식을 만족하는 상태가 유지되어야 한다.

불변식클래스의 모든 메서드의 사전조건과 사후조건에 추가되는 “공통의 조건”으로 생각할 수 있다.

  • 불변식은 메서드가 실행 되기 전에 사후조건과 함께 실행

  • 불변식은 메서드가 실행된 후에 사후조건과 함께 실행

만약 불변식을 수작업으로 작성한다면 모든 메서드에 동일한 불변식을 추가해야 할 것이다. (이를테면 자바에서는 모든 메서드에 동일한 assert 문을 작성함으로써 불변식을 추가한다.) C#의 Code Contracts에서는 Contract.Invariant 메서드를 이용해 불변식을 정의할 수 있다. 불변식은 생성자 실행 후, 메서드 실행 전, 메서드 실행 후에 호출돼야 한다는 점을 기억하자. 만약 우리가 직접 불변식을 코딩하고 관리해야 한다면 모든 생성자의 마지막 위치, 메서드의 시작, 메서드의 종료 지점에 불변식을 호출하도록 일일히 코드를 작성해야 할 것이다.

이제 Screening불변식을 추가해보자 Screening의 인스턴스가 생성되면 movienull이 아니어야 하고 seuqnece는 1보다 크거나 같아야 하며, whenScreened는 현재 시간 이후여야 한다. 이 불변식을 ContractInvariantMethod 애트리뷰트가 지정된 메서드 안에 구현하자.

Code Contracts 덕분에 객체의 생성자나 메서드 실행 전후에 불변식을 직접 호출해야 하는 수고를 들일 필요가 없다.


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

지금까지 살펴본 것 처럼 계약에 의한 설계의 핵심은 클라이언트와 서버 사이의 견고한 협력을 위해 준새 해야 하는 규약을 정의하는 것이다. 여기서 우리는 클라이언트라는 단어에 주목해야한다. 계약에 의한 설계클라이언트가 만족시켜야 하는 사전 조건클라이언트의 관점에서 서버가 만족시켜야 하는 사후 조건을 기술한다. 계약에 의한 설계와 리스코프 치환 원칙이 만나는 지점이 바로 이곳이다. 리스코프 치환 원칙은 슈퍼타입의 인스턴스와 협력하는 클라이언트의 관점에서 서브타입의 인스턴스가 슈퍼타입을 대체하더라도 협력에 지장이 없어야 한다는 것을 의미한다.

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

리스코프 치환 원칙의 규칙을 두 가지 종류로 세분화 할 수 있다. 첫 번째 규칙은 협력에 참여하는 객체에 대한 기대를 표현하는 계약 규칙이고 두 번째 규칙은 교체 가능한 타입과 관련된 가변성 규칙이다.

계약 규칙은 슈퍼타입과 서브타입 사이의 사전조건, 사후조건, 불변식에 대해 서술할 수 있는 제약에 관한 규칙이다.

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

  • 서브타입에 더 완화된 사전조건을 정의할 수 없다.

  • 슈퍼타입의 불변식은 서브타입에서도 반드시 유지돼야 한다.

가변성 규칙파라미터와 리턴 타입의 변형과 관련된 규칙이다.

  • 서브타입의 메서드 파라미터는 반공변성을 가져야 한다.

  • 서브타입리턴타입공변성을 가져야 한다.

  • 서브타입슈퍼타입이 발생시키는 예외와 다른 타입의 예외를 발생시켜서는 안된다.

대부분의 객체지향 언어에서 공변성반공변성이 중요해지는 곳은 상속이 제네릭 프로그래밍과 만나는 지점이다. 여기서의 초점은 일반적인 클래스의 상속에 맞춰져 있으므로 제네릭 프로그래밍과 관련된 가변성 규칙은 다루지 않는다.

지금부터는 11장에서 살펴본 핸드폰 과금 시스템의 합성 버전을 예제로 살펴본다. 이번 예제에서는 자바로 작성되어있으며 자바에서 기본적으로 제공하는 단정문인 assert를 사용해 사전조건, 사후조건, 불변성을 직접 구현한다. 즉, 계약에 의한 설계가 특정 라이브러리나 프레임워크와는 상관이 없는 설계 개념이라는 점을 강조하기 위한 것이다.

계약에 의한 설계는 협력을 올바르게 설계하기 위해 고려해야 하는 설계 원칙과 설계 방법이다. 특정한 구현 메커니즘이 아니다.

계약 규칙

핸드폰 과금 시스템에서 RatePolicy는 아래 그림에서 알 수 있는 것처럼 기본 정책부가 정책을 구현하는 모든 객체들이 실체화 해야하는 인터페이스이다.

여기서 한 가지 질문을 해보자 이 클래스들은 정말로 RatePolicy서브 타입인가? 다시 말해서 리스코프 치환 원칙을 만족하는가? 이 질문에 답하기 위해서는 RatePolicy 인터페이스의 구현 클래스들이 RatePolicy의 클라이언트인 Phone과 체결한 계약을 준수하는지를 살펴봐야 한다.

이해를 돕기 위해서 요금 청구서를 발생하는 publishBill 메서드를 Phone에 추가하자. 청구서의 개념을 구현한 Bill 클래스는 요금 청구의 대상인 phone과 통화 요금인 fee를 인스턴스 변수로 포함한다.

Phone 클래스의 publishBill 메서드는 가입자에게 청구할 요금을 담은 Bill 인스턴스를 생성한 후 반환한다.

publishBill 메서드에서 calculateFee의 반환값을 Bill의 생성자에 전달한다는 부분에 주목하자. 청구서의 요금은 최소한 0원보다 크거나 같아야 하므로 calculateFee의 반환 값은 0원 보다 커야 한다. 따라서 RatePolicycalculateFee사후조건을 아래와 같이 정의할 수 있다.

이번에는 calculateFee 오퍼레이션을 호출할 때 클라이언트인 Phone이 보장해야 하는 사전 조건을 살펴보자. calculateFee 오퍼레이션은 파라미터로 전달된 Call 목록에 대한 요금의 총합을 계산한다. 이를 위해서는 파라미터인 callsnull이 아니어야 한다. 따라서 calculateFee 오퍼레이션의 사전 조건을 아래와 같이 정의할 수 있다.

RatePolicy 인터페이스를 구현하는 클래스가 RatePolicy서브타입이 되기 위해서는 위에서 정의한 사전조건사후조건을 만족해야 한다. 먼저 기본정책을 구현하는 추상 클래스인 BasicRatePolicy사전조건사후조건을 추가하자.

부가 정책을 구현하는 추상 클래스인 AdditionalRatePolicy에도 사전 조건사후 조건을 추가해야 한다.

지금까지 BasicRatePolicyAdditionalRatePolicyRatePolicy의 서브타입으로 만들었다. 지금부터는 이 예제를 이용해 앞에서 설명한 계약 규칙가변성 규칙에 관해 자세히 살펴보자.

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

한 번도 통화가 발생하지 않은 Phone에 대한 청구서를 발행하는 시나리오를 고려해보자.

Phone의 코드를 보면 내부적으로 통화 목록을 유지하는 인스턴스 변수인 calls선언하는 동시에 빈 리스트로 초기화하는 것을 알 수 있다. 따라서 위 코드처럼 단 한번도 call 메서드가 호출 (phone.call(Call call))되지 않은 경우 RatePolicycalculateFee 메서드 인자로 빈 리스트가 전달될 것이다. calculateFee의 사전 조건에서는 인자가 null인 경우를 제외하고는 모든 값을 허용하기 때문에 위 코드는 사전 조건을 위반하지 않는다.

하지만 RegularPolicy의 부모 클래스인 BasicRatePolicycalls가 빈 리스트여서는 안된다는 사전 조건을 추가하면 어떻게 될까? Phone을 생성한 후에 곧장 publishBill 메서드를 호출하는 앞의 예제에서는 사전조건을 만족시키지 않게 되기 때문에 정상적으로 실행되지 않을 것이다.

사전 조건을 만족시키는 것은 클라이언트(Phone)의 책임이다. 클라이언트Phone은 오직 RatePolicy 인터페이스만 알고있기 때문에 RatePolicynull을 제외한 어떤 calls라도 받아들일 것이라고 가정(기대)한다. 따라서 빈 리스트를 전달하더라도 문제가 발생하지 않는다고 예상할 것이다.

하지만 BasicRatePolicy사전 조건에 새로운 조건을 추가(강화)함으로써 PhoneRatePolicy 사이에 맺은 계약을 위반한다. Phone의 입장에서는 더이상 RatePolicyBasicRatePolicy동일하지 않다. 하나는 원래의 약속을 지키는 신뢰할 수 있는 협력자(RatePolicy)이고, 다른 하나는 약속을 파기한 배신자(BasicRatePolicy)이다. 클라이언트(Phone)의 관점에서 BasicRatePolicy는 RatePolicy를 대체할 수 없기 때문에 “리스코프 치환 원칙”을 위반한다. BasicRatePolicy는 RatePolicy의 서브타입이 아닌 것이다.

이 예에서 알 수 있는 것처럼 서브타입이 슈퍼타입에 정의된 사전조건을 강화하게 되면 기존에 체결된 계약을 위반하게 된다. 계약서에 명시된 의무보다 더 많은 의무를 짊어져야 한다는 사실을 순순히 납득하는 클라이언트는 없을 것이다. 결국 사전조건을 강화한 서브타입은 클라이언트의 입장에서 수용이 불가능하기 때문에 슈퍼타입을 대체할 수 없게 된다. 따라서 사전조건 강화는 리스코프 치환 원칙 위반이다.

반대로 사전조건을 완화시키는 경우는 어떨까? 다음과 같이 사전조건을 완화해서 callsnull인 인자를 전달해도 예외가 발생하지 않도록 수정해보자.

다시한번 강조하지만 사전조건을 보장해야 하는 책임은 “클라이언트”에게 있다. 그리고 이미 클라이언트인 PhoneRatePolicycalculateFee 오퍼레이션을 호출할 때 인자가 null이 아닌 값을 전달하도록 보장하고 있을 것이다. 따라서 항상 인자는 null이 아닐 것이고 null 여부를 체크하는 조건문은 무시되기 때문에 수정된 사전조건은 협력에 영향을 미치지 않게 된다. 결과적으로 사전조건을 완화시키는 것은 리스코프 치환 원칙을 위반하지 않는다.

서브 타입에 더 완화된 사후조건을 정의할 수 없다.

이번에는 RatePolicycalculateFee 오퍼레이션의 반환값이 0원보다 작은 경우를 다뤄보자. 다음은 10초당 100원을 부여하는 일반 요금제(RegularPolicy)에 1000원을 할인해주는 기본 요금 할인 정책(RateDiscountablePolicy)을 적용하는 시나리오를 구현한 것이다.

가입자의 통화 목록에는 단 한번의 통화 내역만 존재하고 통화 시간은 1분이다. 이 사용자는 통화 시간 10초당 100원을 부과하는 요금제에 가입돼 있기 때문에 통화 요금은 600원 일 것이다. 문제는 이 사용자의 요금제에 1000원의 기본 요금 할인 정책이 추가돼 있다는 것이다. 따라서 할인 금액을 반영한 최종 청구 금액은 600원에서 1000원을 뺀 -400원이 될 것이다.

calculateFee 오퍼레이션은 반환값이 0보다 커야 한다는 사후조건을 정의하고 있다. 사후조건을 만족시킬 책임은 클라이언트가 아니라 서버에 있다. Phone은 반환된 요금이 0원보다 큰지를 확인할 의무가 없으며 사후조건을 위반한 책임은 전적으로 서버인 RateDiscountablePolicy가 져야 한다. RateDiscountablePolicy는 계약을 만족시킬 수 없다는 사실을 안 즉시 예외를 발생시키기 때문에 calculateFee 오퍼레이션은 정삭적으로 실행되지 않고 종료된다.

이제 calculateFee 오퍼레이션이 정상적으로 실행되도록 RateDiscountablePolicy의 부모 클래스인 AdditionalRatePolicy에서 사후조건을 완화시킨다고 가정해보자. 다음과 같이 사후조건을 주석으로 처리해서 마이너스 요금이 반환되더라도 예외가 발생하지 않도록 수정해보자.

이제 AdditionalRatePolicy는 마이너스 금액도 반환할 수 있기 때문에 Phone과의 협력을 문제 없이 처리할 수 있다. 하지만 엉뚱하게도 Bill의 생성자에서 예외가 발생한다. Bill의 생성자에서는 인자로 전달된 fee가 마이너스 금액일 경우 예외를 던지도록 구현돼 있기 때문이다.

문제는 AdditionalRatePolicy가 마이너스 금액을 반환했다는 것이다. 하지만 예외 스택 트레이스는 Bill의 생성자가 문제라고 지적한다. 우리는 스택 트레이스의 메시지를 근거로 Bill에 전달된 마이너스 금액을 계산해낸 위치를 추적해야 할 것이다.

여기서 문제는 Bill이 아니다. Bill의 입장에서 요금이 0원보다 크거나 같다고 가정하는 것은 자연스럽다. 문제는 AdditionalRatePolicy사후 조건을 완화함으로써 기존에 PhoneRatePolicy 사이에 체결된 계약을 위반했기 때문에 발생한 것이다. 사후 조건을 완하한다는 것은 서버가 클라이언트에게 제공하겠다고 보장한 계약을 충족시켜주지 못한다는 것을 의미한다. 서버계약을 위반했기 때문에 더이상 계약은 유효하지 않다. 클라이언트Phone의 입장에서 AdditionalRatePolicy는 더 이상 RatePolicy가 아니다. 다시 말해서 AdditionalRatePolicy는 더 이상 RatePolicy의 서브 타입이 아니다.

이 예에서 알 수 있는 것 처럼 계약의 관점에서 사후조건은 완화할 수 없다. 계약서에 명시된 이익보다 더 적은 이익을 받게 된다는 사실을 납득할 수 있는 클라이언트가 있을까? 결국 사후조건을 완하시키는 서버는 클라이언트 관점에서 수용할 수 없기 때문에 슈퍼타입을 대체할 수 없다. 사후조건 완화는 리스코프 치환 원칙 위반이다.

반대로 사후조건을 강화하는 경우는 어떨까? calculateFee 메서드가 100원보다 크거나 같은 금액을 반환하도록 사후조건을 강화해보자.

클라이언트(Phone)은 반환된 요금이 0보다 크기만 하면 아무런 불만도 가지지 않기 때문에 위 변경은 클라이언트의 입장에서 아무런 영향도 끼치지 않는다. 요금이 100원보다 크다고 하더라도 어차피 그 금액은 0원보다는 큰 것이다. 따라서 사후조건 강화는 계약에 영향을 미치지 않는다.

일찍 실패하기

처음에는 의아하게 생각될 수도 있지만 마이너스 금액을 그대로 사용하는 것 보다 처리를 종료하는 것이 올바른 선택이다. 사후조건은 서버가 보장해야 한다는 것을 기억하라. 클라이언트인 Phone은 서버인 RatePolicy가 계약에 명시된 사후조건을 만족시킬 것이라고 가정하기 때문에 반환값을 체크할 필요가 없다. 따라서 Phone은 RatePolicy가 항상 플러스 금액을 반환할 것이라고 가정하고 별도의 확인 없이 반환값을 그대로 Bill의 생성자에 전달한다. 그리고 그 결과, 원인에서 멀리 떨어진 엉뚱한 곳에서 경보음이 울리게 되는 것이다.

Phone과 RatePolicy 사이의 협력을 종료시키지 않더라도 반환된 값을 이용하는 어디선가 문제가 발생할 것이다. 게다가 문제가 발생한 Bill의 생성자는 마이너스 금액을 계산한 로직이 위치한 곳이 아니다. 문제의 원인을 제공한 위치로부터 너무나도 멀리 떨어져 있다.

Phone과 RatePolicy 사이에서 예외를 발생시키면 이 문제를 해결할 수 있다. 예외가 발생한 그 지점이 바로 문제가 발생한 바로 그곳이다. 우리는 문제가 발생할 경우 원인이 어디인지를 빠르게 알기를 원한다. 지금의 편안함을 위해 오류를 감춰서는 안된다. 차라리 문제가 발생한 그 위치에서 프로그램이 실패하도록 만들어라. 문제의 원인을 파악할 수 있는 가장 빠른 방법은 문제가 발생하자 마자 프로그램이 일찍 실패하게 만드는 것이다.

슈퍼타입의 불변식은 서브타입에서도 반드시 유지돼야 한다.

불변식메서드가 실행되기 전과 후에 반드시 만족시켜야 하는 조건이다. 모든 객체는 객체가 생성된 직후부터 소멸되기 직전까지 불변식을 만족시켜야 한다. 하지만 메서드를 실행하는 도중에는 만족시키지 않아도 무방하다. 생성자의 경우 시작 지점에는 불변식을 만족시키지 않겠지만 생성자가 종료되는 시점에는 불변식을 만족시켜야 한다.

AdditionalRatePolicy에서 다음 요금제를 가리키는 nextnull이어서는 안된다. 따라서 AdditionalRatePolicy의 모든 메서드 실행 전과 후, 그리고 생성자의 마지막 지점에서 nextnull이어서는 안된다는 불변식을 만족시켜야 한다.

하지만 위 코드에서는 불변식을 위반할 수 있는 취약점이 존재한다. 인스턴스 변수인 nextprivate이 아니라 protected 변수라는 사실을 눈치챘는가? AdditionalRatePolicy의 자식 클래스는 부모 클래스 몰래 next의 값을 수정하는 것이 가능하다.

수정된 RateDiscountablePolicy의 코드를 보자. nextprotected 변수이기 때문에 RateDiscountablePolicychangeNext 메서드를 통해 언제라도 next를 변경할 수 있다.

문제는 chnageNext 메서드를 이용해서 next의 값을 null로 변경할 수 있다는 것이다. 이 경우 불변식이 유지되지 않는다.

이 예는 계약의 관점에서 캡슐화의 중요성을 잘 보여준다. 자식 클래스가 계약을 위반할 수 있는 코드를 작성하는 것을 막을 수 있는 유일한 방법은 인스턴스 변수의 가시성을 protected가 아니라 private로 만드는것 뿐이다. protected 인스턴스 변수를 가진 부모 클래스의 불변성은 자식 클래스에 의해 언제라도 쉽게 무너질 수 있다. 모든 인스턴스 변수의 가시성은 private로 제한되어야 한다.

그렇다면 자식 클래스에서 인스턴스 변수의 상태를 변경하고 싶다면 어떻게 해야 할까? 부모 클래스에 protected 메서드를 제공하고 이 메서드를 통해 불변식을 체크하도록 해야한다.

지금까지 리스코프 치환 원칙과 관련된 계약 규칙을 살펴보았다. 사실 계약에 의한 설계리스코프 치환 원칙의 중요한 내용 대부분은 지금까지 살펴본 계약 규칙에 포함되어 있다.

지금부터는 중요도에 비해 대부분의 사람들이 크게 관심을 가지지 않는 가변성 규칙에 대해 살펴보겠다. 가변성 규칙계약 규칙보다 인지도가 낮다고 하더라도 리스코프 치환 원칙의 깊은 부분까지 이해하기 위해서는 가변성 규칙을 이핸하는 것이 좋다. 특히 예외와 관련된 규칙은 알아둘만한 가치가 있다.


가변성 규칙

서브타입은 슈퍼타입이 발생시키는 예외와 다른 타입의 예외를 발생시켜서는 안된다.

RatePolicycalculateFee 오퍼레이션이 인자로 빈 리스트를 전달받았을 때 EmptyCallException 예외를 던지도록 계약을 수정해보자.

RatePolicy와 협력하는 메서드가 있다고 가정하자. 이 메서드는 EmptyCallException 예외가 던져질 경우 이를 캐치한 후 0원을 반환한다.

하지만 RatePolicy를 구현하는 클래스가 EmptyCallException 예외가 아닌 다른 예외를 던진다면 어떻게 될까? 예를 들어 AdditionalRatePolicy 클래스가 다음과 같이 NoneElementException 예외를 던진다고 가정해보자.

만약 NoneElementException 클래스가 다음과 같이 EmptyCallException 클래스의 자식 클래스라면 AdditionalRatePolicyRatePolicy를 대체할 수 있을 것이다.

하지만 다음과 같이 상속 계층이 다르면 하나의 catch문으로 두 예외 모두를 처리할 수 없기 때문에 NoneElementException은 예외 처리에서 잡히지 않게 된다. 결과적으로 클라이언트 입장에서 협력의 결과가 예상을 빗나갔기 때문AdditionalRatePolicyRatePolicy를 대체할 수 없다.

일반적으로 부모 클래스가 던지는 예외가 속한 상속 계층이 아닌 다른 상속 계층에 속하는 예외를 던질 경우 자식 클래스는 부모 클래스를 대체할 수 없다. 따라서 서브타입이 아니다.

이 규칙의 변형이 존재한다. 하나는 자식 클래스에서 부모 클래스에서 정ㄹ의하지 않은 예외를 발생시키는 경우로서 13장에서 소개한 Bird를 상속받는 Penguin의 예가 이 경우에 해당한다. Bird의 자식 클래스인 Penguinfly 메서드 안에서 UnsuppertedOperationException 예외를 던진다. 개발자는 코드를 재사용하기 위해 Bird를 상속받았지만 Penguin은 날 수 없다는 제약조건을 만족시켜야 했기 때문에 fly 메서드의 실행을 막았던 것이다.

예외를 던짐으로써 날 수 있는 행동을 정상적으로 동작하지 않도록 만들었기 때문에 Penguin의 입장에서는 원하는 결과를 얻은 것이라고 생각할 수도 있다. 하지만 클라이언트는 협력하는 모든 Bird가 날 수 있다고 생각할 것이다. 클라이언트Bird의 인스턴스에게 fly 메시지를 전송했을 때 UnsuppertedOperationException 예외가 던져지리라고 기대하지 않았을 것이다. 따라서 클라이언트의 관점에서 Penguin은 Bird를 대체할 수 없다.

계약을 위반하는 또 다른 예는 fly 메서드의 기능을 퇴화시키는 경우다. 다음 코드처럼 Penguinfly 메서드를 아무것도 하지 않게 만듦으로써 fly 메시지에는 응답할 수 있지만 나는 기능 자체를 제거할 수 있다. 하지만 이 경우도 모든 Bird가 날 수 있다고 가정하는 클라이언트의 관점에서는 올바르지 않다.

위 두 가지 예에는 예외를 던지느냐, 아무것도 하지 않느냐의 차이는 있지만 클라이언트의 관점에서 부모 클래스가 하는 일보다 자식 클래스가 하는 일이 더 적은 일을 수행한다는 공통점이 있다. 클라이언트의 관점에서 부모 클래스에 대해 기대했던 것보다 더 적은 일을 수행하는 자식 클래스는 부모 클래스와 동일하지 않다. 부모 클래스보다 못한 자식 클래스는 서브타입이 아니다.

서브타입의 리턴 타입은 공변성을 가져야 한다.

대부분의 사람들은 제네릭 프로그래밍상속을 함께 사용하는 시점에 이르서야 비로소 공변성반공변성에 대한 관심을 가진다. 하지만 제네릭 프로그래밍을 고려하지 않고도 상속이라는 문맥 안에서 공변성반공변성의 의미를 살펴보는 것은 의미가 있다. 먼저 공변성, 반공변성, 무공변성의 개념을 살펴본 후 서브타이핑공변성, 반공변성 사이의 관계를 살펴보자.

  • 공변성(covariance) : ST 사이의 서브타입 관계가 그대로 유지된다. 이 경우 해당 위치에서 서브타입인 S가 슈퍼타입인 T 대신에 사용될 수 있다. 우리가 흔히 이야기하는 리스코프 치환 원칙공변성과 관련된 원칙이라고 생각하면 된다.

  • 반공변성(contravariance) : ST 사이의 서브타입 관계가 역전된다. 이 경우 해당 위치에서 슈퍼타입인 T가 서브타입인 S 대신에 사용될 수 있다.

  • 무공변성(invariance) : ST 사이에는 아멀 노간계도 존재하지 않는다. 따라서 S 대신 T를 사용하거나 T 대신 S를 사용할 수 없다.

이해를 돕기 위해 아래 그림과 같이 서브타입 관계를 구현한 세 개의 상속 계층을 살펴보자. 세 개의 상속 계층 모두 리스코프 치환 원칙을 만족하도록 구현돼있다고 가정하자.

책을 출판하는 출판살늘 나타내는 Publisher 클래스는 독립 출판사를 구현하는 IndependentPublisher 클래스의 슈퍼타입이다.

BookMagazine의 부모 클래스다. Book은 책이나 잡지를 출판하는 Publisher에 대한 참조를 보관한다.

MagazineBook의 자식 클래스다.

BookMagazine을 판매하는 판매처는 두 종류가 존재하며 하나는 거리에서 책을 판매하는 가판대를 구현한 BookStall이고 다른 하나는 전문적으로 잡지를 판매하는 MagazineStore다. BookStall은 독립출판사인 IndependentPublisher에 의해 출간한 Book만 판매할 수 있다.

MagazineStore 역시 독립 출판사가 출간한 Magazine만 판매할 수 있다고 가정할 것이다.

이제 책을 구매하는 Customer의 코드를 살펴보자. CustomerBookStall에게 sell 메시지를 전송해 책을 구매한다.

아래 그림은 클래스 사이의 관계를 그림으로 표현한 것이다.

지금까지 살펴본 서브타이핑은 단순히 서브타입이 슈퍼타입의 모든 위치에서 대체 가능하다는 것이다. 하지만 공변성반공변성의 영역으로 들어가기 위해서는 타입의 관계가 아니라 메서드의 리턴 타입과 파라미터 타입에 초점을 맞춰야 한다.

먼저 리턴 타입 공변성부터 살펴보자. BookStallsell 메서드는 Book의 인스턴스를 리턴하고, MagazineStoresell 메서드는 Magazine의 인스턴스를 리턴한다. 여기서 우리의 눈길을 끄는 것은 슈퍼타입인 BookStall이 슈퍼타입인 Book을 반환하고 서브타입인 MagazineStore이 서브타입인 Magazine을 반환한다는 것이다.

리스코프 치환 원칙클라이언트 관점에서의 치환 가능성이므로 BookStall의 클라이언트 관점에서 리턴 타입 공변성의 개념을 살펴볼 필요가 있다. Customer 클래스의 order 메서드를 살펴보면 BookStall에게 sell 메시지를 전송하는 것을 알 수 있다.

다음과 같이 CustomerBookStall에게 책을 구매하는 다음의 코드를 보자.

Customerorder 메서드는 BookStall에게 sell 메시지를 전송한 후 Book 타입의 인스턴스 변수에 반환값을 저장한다. BookStallsell 메서드의 리턴 타입이 Book 타입으로 선언돼 있기 때문에 이 코드는 직관적이다.

MagazineStoreBookStall의 서브타입이므로 BookStall을 대신하여 협력할 수 있다. 따라서 CustomerBookStall에서 책을 구매할 수 있다면 자연스럽게 서브타입인 MagazineStore에서도 책을 구매할 수 있다.

MagazineStoresell 메서드는 Magazine의 인스턴스를 반환한다. 그리고 Customerorder 메서드는 반환된 MagazineBook 타입의 인스턴스 변수에 대입한다. 업캐스팅에 의해 Magazine 역시 Book이기 때문에 Customer의 입장에서는 둘 사이의 차이를 알지 못할 것이다. Magazine의 인스턴스가 반환되더라도 Customer의 입장에서는 Magazine 역시 Book의 일종이기 때문에 MagazineStoreBookStall을 치환하더라도 문제가 없는 것이다.

따라서 sell 메서드의 리턴 타입을 Book에서 Magazine으로 변경하더라도 Customer 입장에서는 동일하다. MagazineStoresell메서드는 비록 반환 타입은 다르지만 정확하게 BookStallsell 메서드를 대체할 수 있다.

이처럼 부모 클래스에서 구현된 메서드를 자식 클래스에서 오버라이딩 할 때 부모 클래스에서 선언된 반환 타입의 서브 타입으로 지정할 수 있는 특성을 리턴 타입 공변성 이라고 부른다. 간단하게 말해서 리턴 타입 공변성이란 메서드를 구현한 클래스의 타입 계층 방향과 리턴 타입의 타입 계층 방향이 동일한 경우를 가리킨다.

앞에서 리스코프 치환 원칙과 관련된 계약 규칙을 설명할 때 서브타입에서 메서드의 “사후조건이 강화”되더라도 클라이언트 입장에서는 영향이 없다고 했던것을 기억하자. 슈퍼타입 대신 서브타입을 반환하는 것은 더 강력한 사후조건을 정의하는 것과 같다. 따라서 리턴 타입 공변성계약에 의한 설계의 관점에서 계약을 위반하지 않는다. 앞의 예에서 메서드를 구현한 슈퍼타입(BookStall)이 리턴 값의 슈퍼 타입(Book)을 반환한 경우 메서드를 오버라이딩하는 서브타입(MagazineStore)이 슈퍼타입에서 사용한 리턴타입의 서브타입(Magazine)을 리턴타입으로 사용하더라도 클라이언트(Customer)의 입장에서 대체 가능한 것이다.

한 가지 기억해야 하는 사항은 공변성반공변성의 지원 여부는 언어에 따라 다르다는 것이다. 예를 들어 자바의 경우 리턴 타입 공변성을 지원하지만 C#의 경우 지원하지 않는다. 즉 이론적으로는 메서드의 리턴 타입을 공변적으로 정의하면 리스코프 치환 원칙을 만족시킬 수 있지만 실제적으로는 언어의 지원 여부에 따라 리턴 타입 공변성을 사용하지 못할 수도 있다.

서브타입의 메서드 파라미터는 반공변성을 가져야 한다.

이제 파라미터의 반공변성에 대해서 알아보자. CustomerBookStallsell 메서드를 호출할 때 파라미터로 IndependentPublisher 인스턴스를 전달한다. 그리고 BookStall의 서브타입인 MagazineStoresell 메서드 역시 IndependentPublisher 타입의 인스턴스를 파라미터로 전달받도록 정의돼 있다.

여기서는 IndependentPublisherPublisher의 서브타입이라는 사실을 기억해야 한다. 우리는 이미 업캐스팅 개념을 ㅌ오해 메서드의 파라미터가 Publisher 타입으로 정의돼 있더라도 그 서브타입인 IndependentPublisher의 인스턴스를 전달하더라도 메서드가 정상적으로 동작한다는 것을 잘 알고있다.

먼저 주의할 점은 자바에서는 파라미터 반공변성을 허용하지 않기 때문에 아래에서 소개한 코드가 정상적으로 컴파일되지 않는다는 사실이다. 여기서는 설명을 위해 자바가 파라미터 반공변성을 허용한다고 가정할 것이다.

BookStall의 자식 클래스인 MagazineStore에서 sell 메서드의 파라미터를 아래 그림과 같이 IndependentPublisher의 슈퍼타입인 Publisher로 변경할 수 있다면 어떨까?

Customerorder 메서드는 BookStallsell 메서드에 IndependentPublisher 인스턴스를 전달한다. BookStall 대신 MagazineStore 인스턴스와 협력한다면 IndependentPublisher 인스턴스가 MagazineStoresell 메서드의 파라미터로 전달될 것이다. 이 때 파라미터 타입이 슈퍼타입인 Publisher 타입으로 선언돼 있기 때문에 IndependentPublisher 인스턴스가 전달되더라도 문제가 없다.

이처럼 부모 클래스에서 구현된 메서드를 자식 클래스에서 오버라이딩 할 때 파라미터 타입을 부모 클래스에서 사용한 파라미터(IndependentPublisher)의 슈퍼 타입(Publisher)로 지정 가능한 특성파라미터 타입 반공변성이라고 부른다. 간단하게 말해서 파라미터 타입 반공변성이란 메서드를 정의한 클래스의 타입 계층과 파라미터의 타입 계층의 방향이 반대인 경우 서브타입 관계를 만족한다는 것을 의미한다.

앞에서 리스코프 치환 원칙과 관련된 계약 규칙을 설명할 때 섭즈타입에서 메서드의 “사전조건”이 약화되더라도 클라이언트 입장에서는 영향이 없다. 라고 했던 것을 기억하자. 서브타입 대신 슈퍼타입을 파라미터로 받는 것더 약한 사전조건을 정의하는 것과 같다. 따라서 파라미터 반공변성은 계약에 의한 설계 관점에서 계약을 위반하지 않는다. 앞의 예에서 메서드를 구현한 슈퍼타입(BookStall)이 어떤 서브타입(IndependentPublisher)을 파라미터로 받는 경우 메서드를 오버라이딩하는 서브타입(MagazineStore)이 슈퍼타입에서 사용한 파라미터 타입의 슈퍼타입(Publisher)를 파라미터 타입으로 사용하더라도 클라이언트의 입장(Customer)에서는 대체 가능한 것이다.

앞에서 언급한 것 처럼 자바는 파라미터 반공변성을 지원하지 않는다. 따라서 앞의 MagazineStore@Override 어노테이션으로 정상적으로 컴파일되지 않는다. 만약 컴파일을 위해 @Override 어노테이션을 제거한다면 자바 컴파일러는 오버라이딩이 아니라 이름만 같고 실제로는 서로 다른 메서드인 오버로딩으로 판단한다. 따라서 MagazineStoreBookStall을 대체할 수 없게 된다.

리턴 타입 공변성과 파라미터 타입 반공변성은 ‘사전 조건’과 ‘사후 조건’의 관점에서 설명할 수도 있다. 서브타입은 슈퍼타입에서 정의한 것 보다 더 강력한 사전조건을 정의할 수는 없지만 사전 조건을 완화할 수는 있다.

‘사전조건’은 ‘파라미터에 대한 제약조건’이므로 슈퍼타입에서 정의한 파라미터 타입에 대한 제약조건을 좀 더 완화할 수 있따는 것을 의미한다. 따라서 좀 더 완화된 ‘슈퍼타입’을 파라미터로 받을 수 있는것이다.

‘리턴타입’은 ‘사후조건’과 관련이 있으며 서브타입은 슈퍼타입에서 정의된 사후조건을 완화시킬수는 없지만 ‘강화’할 수는 있다는 사실을 기억하자. 따라서 슈퍼타입에서 정의한 리턴 타입보다 ‘더 강화된 서브타입 인스턴스를 반환’하는 것이 가능한 것이다.

사실 객체지향 언어 중에서 파라미터 반공변성을 지원하는 언어는 거의 없다고 봐도 무방하다. 여기서 파라미터 반공변성을 언급한 이유는 제네릭 프로그래밍에서 파라미터 반공변성이 중요한 의미를 가지기 때문에 이번 장에서 설명한 기본적인 내용을 알아두는 것이 제네릭 프로그래밍을 공부하는데 도움이 될 것이다.

진정한 서브타이핑 관계를 만들고 싶다면 서브타입에 더 강력한 ‘사전조건’이나 더 완화된 ‘사후조건’을 정의해서는 안되며 ‘슈퍼타입의 불변식을 유지’하기 위해 항상 노력해야 한다. 또한 서브타입은 슈퍼타입에서 정의하지 않은 예외를 던져서는 안된다.

참고 및 출처

  • 오브젝트