들어가며
객체지향을 설계 할 때에 있어 다형성
을 구현하는 메커니즘으로 주로 상속
을 사용한다. 하지만 이러한 상속
은 코드 재사용
이 아닌 타입 계층
을 구축할 때에 비로소 유연하고 확장 가능한 설계로 이어진다. 이번 장에서 살펴볼 계약에 의한 설계
는 클래스 간의 상속
관계에서 리스코프 치환 원칙
을 준수하기 위해 지켜야 할 조건들이 어떤 것이 있는지 알아본다.
계약에 의한 설계
인터페이스
만으로는 객체의 행동에 관한 다양한 관점을 전달하기 어렵다. 우리에게 필요한 것은 명령의 부수효과를 쉽고 명확하게 표현할 수 있는 커뮤니케이션 수단
이다. 이 시점이 되면 계약에 의한 설계(Design By Contract, DBC) 가 주는 혜택으로 눈을 돌릴 때가 된 것이다.
계약에 의한 설계
를 사용하면 협력에 필요한 다양한 제약과 부수효과를 명시적으로 정의하고 문서화 할 수 있다. 클라이언트 개발자는 오퍼레이션의 구현을 살펴보지 않더라도 객체의 사용법을 쉽게 이해할 수 있다. 계약
은 실행 가능하기 때문에 구현에 동기화돼 있는지 여부를 런타임
에 검증할 수 있다. 따라서 주석과 다르게 시간의 흐름에 뒤쳐질 걱정을 할 필요가 없다.
“계약에 의한 설계”는 클래스의 부수효과를 명시적으로 문서화하고 명확하게 커뮤니케이셔할 수 있을 뿐만 아니라 실행 가능한 검증 도구로써 사용할 수 있다.
이번 장에 소개할 예제 일부는 C# 언어로 작성돼 있으며 계약에 의한 설계 라이브러리
로 Code Contract를 사용한다.
이번 장에서 중요한 것은 코드가 아니라 개념
이다. 코드를 구현하는 방법보다는 계약에 의한 설계를 사용하는 이유와 장점
을 이해하는 것이 이번 장의 목표다.
협력과 계약
부수 효과를 명시적으로
객체지향의 핵심은 협력
안에서 객체들이 수행하는 행동
이다. 안타깝게도 프로그래밍 언어로 작성된 인터페이스
는 객체가 수신할 수 있는 메시지
는 정의할 수 있지만 객체 사이의 의사소통 방식
은 명확하게 정의할 수 없다. 메시지의 이름
과 파라미터 목록
은 시그니처
를 통해 전달할 수 있지만 협력을 위해 필요한 약속과 제약은 인터페이스를 통해 전달할 수 없기 때문에 협력과 관련된 상당한 내용이 암시적인 상태로 남게된다.
여기서는 6장 명령-쿼리 분리 원칙
을 설명하기 위해 소개했던 일정 관리 프로그램의 C# 버전을 이용해 계약에 의한 설계 개념을 설명하기로 한다. 명령과 쿼리를 분리했기 때문에 Event
클래스의 클라이언트는 먼저 IsSatisfed
메서드를 호출해서 RecurringSchedule
의 조건을 만족시키는지 여부를 확인한 후에 Reschedule
메서드를 호출해야 한다. 인터페이스만으로 메서드의 순서와 관련된 제약을 설명하기 쉽지 않지만 계약에 의한 설계 라이브러리인 Code Contract를 사용하면 IsSatisfed
메서드의 실행 결과가 true
일 때만 Reschedule
메서드를 호출할 수 있다는 사실을 명확하게 표현할 수 있다.
class Event | |
{ | |
public bool IsSatisfied(RecurringSchedule schedule) {...} | |
} | |
public void Reschedule(RecurringSchedule schedule) { | |
Contract.Requires(IsSatisfied(schedule)); | |
} |
이렇게 작성된 계약
은 문서화가 가능하다. 또한 문서화로 끝나는 것이 아니라 제약 조건의 만족 여부
를 실행 중에 체크할 수 도 있다. 따라서 계약에 의한 설계를 사용하면 제약 조건을 명시적으로 사용하고 자동으로 문서화 할 수 있을 뿐 아니라 실행을 통해 검증할 수 있다.
계약
현재 살고 있는 집을 리모델링 하고 싶다고 가정해보자. 우리게에는 리모델링 할 수 잇는 전문적인 지식이 부족하기 때문에 적절한 인테리어 전문가에게 작업을 위탁하고 계약을 체결할 것이다.
계약의 세부적인 내용은 상황에 따라 다르겠지만 일반적으로 다음과 같은 특성을 가진다.
- 각 계약 당사자는 계약으로부터
이익(benefit)
을 기대하고 이익을 얻기 위해의무(obligation)
을 이행한다. - 각 계약 당사자의 이익과 의무는 계약서에
문서화
된다.
여기서 눈여겨볼 부분은 한쪽의 의무가 반대쪽의 권리
가 된다는 것이다. 리모델링을 위탁하는 클라이언트 입장에서 의무
는 인테리어 전문가에게 대금을 지급하는 것이다. 그로 인해 얻게되는 이익
은 원하는 품질로 리모델링 된 집을 얻는 것이다. 리모델링 작업을 수행하는 인테리어 전문가 입장에서 의무
는 고객이 원하는 품질로 집을 리모델링 하는 것이다. 그로 인해 얻는 이익
은 대금을 지급받는 것이다.
두 계약 당사자 중 어느 한쪽이라도 계약서에 명시된 내용을 위반한다면 계약은 정상적으로 완료되지 않을것이다. 인테리어 전문가가 자신의 의무
인 리모델링 작업을 완료하지 못했다면 이익
으로 명시된 대금을 지급받지 못할 것이다. 인테리어 전문가가 리모델링 작업을 완료했는데도 고객이 자신의 의무
인 대금을 지급하지 못한다면 고객의 이익
인 리모델링된 집에서의 생활은 물거품이 될 것이다.
비록 우리가 계약상 클라이언트
라고 하더라도 인터레이 전문가가 계약을 이행하는 구체적인 방식에 대해서는 간섭하지 않는다는 사실을 기억하라. 리모델링 공사를 진행하는 구체적인 방법
은 인테리어 전문가가 자유롭게 결정할 수 있다. 작업 방식과 상관 없이 리모델링된 결과가 만족스럽다면 클라이언트는 인테리어 전문가가 계약을 정상적으로 이행한 것으로 간주할 것이다.
이처럼 계약은 협력을 명확하게 정의하고 커뮤니케이션 할 수 있는 범용적인 아이디어다. 그리고 사람들이 협력을 위해 사용하는 계약이라는 아이디어를 객체들이 협력하는 방식에도 적용할 수 있지 않을까 의문을 품음으로써 계약의 의한 설계
가 탄생되었다.
계약에 의한 설계
계약
은 협력
에 참여하는 두 객체 사이이의 의무와 이익
을 문서화 한 것이다.
협력
에 참여하는 각 객체는계약
으로부터이익
을 기대하고 이익을 얻기위해의무
를 이행한다.협력
에 참여하는 각 객체의이익
과의무
는 객체의인터페이스 상에 문서화
된다.
계약에 의한 설계
개념은 인터페이스에 대해 프로그래밍 하라
는 원칙을 확장한 것이다. 계약에 의한 설계를 이용하면 오퍼레이션의 시그니처
를 구성하는 다양한 요소들을 이용해 협력에 참여하는 객체들이 지켜야 하는 제약조건을 명시할 수 있다. 이 제약 조건을 인터페이스의 일부로 만듦으로써 코드를 분석하지 않고도 인터페이스의 사용법을 이해할 수 있다.
아래 코드는 자바 언어로 작성된 reverse
메서드의 구성 요소를 표현한 것으로, 협력을 위한 다양한 정보를 제공한다. 이 메서드는 public
가시성을 가지기 때문에 외부에서 호출 가능하다. 이 메서드를 사용하기 위해서는 Customer
타입과 int
타입의 인자를 전달해야 한다. 메서드 실행이 성공하면 반환 타입으로 Reservation
인스턴스를 반환한다는 사실도 알 수 있다.
public Reservation reverse(Customer customer, int audienceCount) |
우리는 메서드의 이름
과 매개변수의 이름
을 통해 오퍼레이션이 클라이언트에게 어떤 것을 제공하려고 하는지를 충분히 설명할 수 있다. 6장에서 설명한 클라이언트의 관점에서 의도를 드러내는 인터페이스
를 만들면 오퍼레이션의 시그니처만으로도 어느 정도 까지는 클라이언트와 서버가 협력을 위해 수행해야 하는 제약조건을 명시할 수 있다.
계약은 여기서 한 걸음 더 나아간다. reverse
메서드를 호출할 때 클라이언트 개발자는 customer
의 값으로 null
을 전달할 수 있고 audienceCount
값으로 음수를 포함한 어떤 정수도 전달할 수 있다고 가정할지 모른다. 하지만 이 메서드는 고객의 예약 정보를 생성하는 것이기 때문에 한 명이상의 예약자에 대해 예약 정보를 생성해야 한다. 따라서 customer
는 null
이 되면 안되고 audienceCount
의 값은 1보다 크거나 최소한 같아야 한다. 클라이언트가 이 조건을 만족하는 인자를 전달했다면 reverse
메서드가 반환하는 Reservation
인스턴스는 null
이 아니어야 한다.
협력하는 클라이언트는 정상적인 “상태”를 가진 객체와 협력해야 한다. 아래와 같이 정삭적인 Screening
은 movie
가 null
이 아니어야 하고 squence
는 1보다 크거나 같아야 하며, whenScreened
는 현재 시간 이후의 값을 가지고 있어야 한다. 이 조건을 만족하지 않는 Screening
은 예매할 수 없다. 따라서 어떤 Screening
인스턴스가 이 조건을 만족하지 않는다면 reverse
메서드를 호출할 수 없어야 한다.
public class Screening { | |
private Movie movie; // not null | |
private int sequence; // >= 1 | |
private LocalDateTime; // after current | |
} |
서버
는 자신이 처리할 수 있는 범위의 값들을 클라이언트가 전달
할 것이라고 기대한다. 클라이언트
는 자신이 원하는 값을 서버가 반환
할 것이라고 기대한다. 클라이언트는 메시지 전송 전과 후의 서버의 상태가 정상일것이라고 기대한다. 이 세 가지 기대가 바로 계약에 의한 설계를 구성하는 세 가지 요소에 대응된다. 이 요소들을 순서대로 사전조건
, 사후조건
, 불변식
이라고 부른다.
-
사전조건(precondition)
: 메서드가 호출되기 위해 만족돼야 하는 조건. 이것은메서드의 요구사항
을 명시한다.사전조건
이 만족되지 않을 경우 메서드가 실행되어서는 안된다. 사전조건을 만족시키는 것은메서드를 실행하는 클라이언트의 의무
이다. -
사후조건(postcondition)
: 메서드가 실행된 후에클라이언트에게 보장해야 하는 조건
. 클라이언트가 사전 조건을 만족시켰다면 메서드는사후조건
에 명시된 조건을 만족시켜야 한다. 만약 클라이언트가사전조건
을 만족시켰는데도사후조건
을 만족시키지 못한 경우에는 클라이언트에게 예외를 던져야 한다.사후조건을 만족시키는 것은 서버의 의무
이다. -
불변식(invariant)
: 항상참
이라고 보장되는서버의 조건
. 메서드가 실행되는 도중에는불변식
을 만족시키지 못할 수도 있지만 메서드를 실행하기 전이나 종료된 후에 불변식은 항상 참이어야 한다.
사전조건
, 사후조건
, 불변식
을 기술할 때는 실행 절차를 기술할 필요 없이 상태 변경만을 명시
하기 때문에 코드를 이해하고 분석하기 쉬워진다. 클라이언트 개발자
는 사전 조건에 명시된 조건을 만족시키지 않으면 메서드가 실행되지 않을 것이라는 사실을 잘 알고있따. 불변식
을 사용하면 클래스의 의미를 쉽게 설명할 수 있고 클라이언트 개발자가 객체를 더욱 쉽게 예측할 수 있다. 사후조건
을 믿는다면 객체가 내부적으로 어떤 방식으로 동작하는지 걱정할 필요가 없다. ‘사전조건’, ‘사후조건’, ‘불변식’에는 클라이언트 개발자가 알아야 하는 모든 것이 포함돼 있을 것이다.
여기서는 Screening
에 대한 제약 조건을 사전조건
, 사후조건
, 불변식
으로 구현할 것이다. 자바의 경우는 계약에 의한 설계 개념을 지원하지 않기 때문에 여기서는 C#으로 작성된 예제를 살펴본다.
사전 조건
사전조건
이란 메서드가 정상적으로 실행되기 위해 만족해야 하는 조건
이다. 사전조건을 만족시키는 것은 메서드를 실행하는 클라이언트의 의무
이다. 따라서 사전조건을 만족시키지 못해서 메서드가 실행되지 않을 경우 클라이언트에 버그
가 있다는 것을 의미한다. 사전조건이 만족되지 않을 경우 서버는 메서드를 실행할 의무가 없다.
일반적으로 사전조건은 메서드에 전달된 인자의 ‘정합성’을 체크하기 위해 사용된다. 예를 들어 reverse
메서드의 경우 인자로 전달된 customer
가 null
이 아니어야 하고 audienceCount
의 값은 1보다 크거나 같아야 한다. 이 조건을 만족시키지 못할 경우 reverse
메서드는 실행되지 말아야 한다. 따라서 이 조건을 메서드의 사전조건
으로 정의함으로써 메서드가 잘못된 값을 기반으로 실행되는 것을 방지할 수 있다.
아래는 메서드의 사전조건
을 정의하기 위해 사용되는 Contract
의 Reuqires
메서드다.
public Reservation Reseve(Customer customer, int audienceCount) | |
{ | |
Contract.Requires(customer != null); | |
Contract.Requries(audienceCount >= 1); | |
return new Reservation(customer, this, calculateFee(audienceCount), audienceCount); | |
} |
사전 조건
을 만족시킬 책임은 Reverse
메서드를 호출하는 클라이언트
에게 있다는 사실을 기억하자. 클라이언트
가 사전조건
을 만족시키지 못할 경우 Reverse
메서드는 최대한 빨리 실패해서 클라이언트에게 버그가 있다는 사실을 알린다.
이 에제는 계약에 의한 설계
의 장점이 무엇인지를 잘 보여준다. 계약에 의한 설계를 사용하면 계약만을 위해 준비된 전용 표기법
을 사용해 계약을 명확하게 표현할 수 있다. 또한 계약을 일반 로직과 분리해서 서술함으로써 계약을 좀 더 두드러지게 강조할 수 있다. 또한 계약이 메서드의 일부로 실행
되도록 함으로써 계약을 강제
할 수 있다.
사후 조건
사후조건
은 메서드의 실행 결과
가 올바른지 검사하고 실행 후에 객체가 유효한 상태로 남아있는지를 검증한다. 간단히 말해서 사후 조건
을 통해 메서드를 호출한 후에 어떤 일이 일어났는지를 설명할 수 있는 것이다. 클라이언트
가 사전조건
을 만족시켰는데도 서버
가 사후조건
을 만족시키지 못한다면 서버에 버그
가 있음을 의미한다.
일반적으로 사후조건
은 다음과 같은 세 가지 용도로 사용된다.
-
인스턴스 변수의 상태가 올바른지를 서술하기 위해
-
메서드에 전달된 파라미터의 값이 올바르게 변경되었는지 서술하기 위해
-
반환 값이 올바른지 서술하기 위해
다음과 같은 두 가지 이유로 인해 사전조건
보다 사후조건
을 정의하는 것이 더 어려울 수 있다.
- 한 메서드 안에서
return
문이 여러 번 나올 경우- 모든
return
문 마다 결괏값이 올바른지 검증하는 코드를 추가해야 한다. 다행히도계약에 의한 설계
를 지원하는 대부분의 라이브러리는 결괏값에 대한 사후 조건을 한 번만 기술할 수 있게 해준다.
- 모든
- 실행 전과 실행 후의 값을 비교해야 하는 경우
- 실행 전의 값이 메서드 실행으로 인해 다른 값으로 변경됐을 수 있기 때문에 두 값을 비교하기 어려울 수 있다. 다행히 계약에 의한 설계를 지원하는 대부분의 라이브러리는 실행 전의 값에 접근할 수 있는 간단한 방법을 제공한다.
Code Contracts
에서 사후조건
을 정의하기 위해서는 Contract.Ensures
메서드를 제공한다. Reserve
메서드의 사후조건
은 반환값이 Reservation
인스턴스가 null
이어서는 안된다는 것이다. 따라서 다음과 같이 사후조건
을 추가할 수 있다.
public Reservation Reserve(Customer customer, int audienceCount) { | |
Contract.Requires(customer != null); | |
Contract.Requires(audienceCount >= 1); | |
Contract.Ensures(Contract.Result<Reservation>() != null); | |
return new Reservation(customer, this, calculateFee(audienceCount), audienceCount); | |
} |
Ensures
메서드 안에서 사용된 Contract.Result<T>
메서드가 바로 Reserve
메서드의 실행 결과에 접근할 수 있게 해주는 메서드다. 이 메서드는 제네릭 타입
으로 메서드의 반환 타입에 대한 정보를 명시할 것을 요구한다.
Contract.Result<T>
메서드는 하나 이상의 종료 지점을 가지는 메서드에 대한 사후조건
을 정의할 때 유용하게 사용할 수 있다. 다음 메서드를 살펴보자.
public decimal Buy(Ticket ticket) | |
{ | |
if (bag.Invited) | |
{ | |
bag.Ticket = ticket; | |
return 0; | |
} | |
else | |
{ | |
bag.Ticket = ticket; | |
bag.MinusAmount(ticket.Fee); | |
return ticket.fee; | |
} | |
} |
Buy
메서드는 초대장이 있을 경우에는 0원을, 초대장이 없을 경우에는 티켓의 요금을 반환한다. 이 메서드에는 두 개의 return
문이 존재한다는 점을 주목하자. 만약 Code Contracts
를 사용하지 않는다면 사후조건
을 두 개의 return
문 모두에 중복해서 작성해야 했을 것이다. Contract.Result<T>
메서드는 이런 경우에 수고를 덜어준다. Contract.Result<T>
는 메서드 실행이 끝난 후에 실제로 반환되는 값을 전달하기 때문에 몇 번의 return
문이 나오더라도 다음과 같이 한 번만 기술하면 된다.
public decimal Buy(Ticket ticket) | |
{ | |
Contract.Requires(ticket != null); | |
Contract.Ensures(Contract.Result<decimal>() >= 0); | |
if(bag.Invited) | |
{ | |
bag.Ticket = ticket; | |
return 0; | |
} | |
else | |
{ | |
bag.Ticket = ticket; | |
bag.MinusAmount(ticket.Fee); | |
return ticket.Fee; | |
} | |
} |
Contract.OldValue<T>
를 이용하면 메서드 실행 전의 상태
에도 접근할 수 있다. 이 메서드를 이용하면 실행 중에 값이 변경되더라도 사후조건
에서 변경 이전의 값을 이용할 수 있게 해준다. 아래 코드에서 파라미터로 전달된 text
의 값이 메서드 실행 중에 변경되기 때문에 text
의 값을 이용하는 사후조건
이 정상적으로 체크되지 않는다.
public String Middle(string text) | |
{ | |
Contract.Requires(text != null && text.Length >= 2); | |
Contract.Ensures(Contract.Result<string>().Length < text.Length); | |
text = text.Substring(1, text.Length - 2); | |
return text.Trim(); | |
} |
이 경우 Contract.OldValue<T>
를 이용하면 메서드를 실행할 때의 text
의 값에 접근할 수 있다. 따라서 위 코드를 다음과 같이 변경하면 문제 없이 사후조건
을 검증할 수 있다.
public string Middle(string text) | |
{ | |
Contract.Requires(text != null && text.Length >= 2); | |
Contract.Ensures(Contract.Result<string>().Length < Contract.OldValue<string>(text).Length); | |
text = text.Substring(1, text.Length - 2); | |
return text.Trim(); | |
} |
불변식
사전조건
과 사후조건
은 각 메서드 마다 달라
지는데 반해 불변식은 인스턴스 생명 주기 전반에 걸쳐 지켜져야 하는 규칙을 명세한다. 일반적으로 불변식
은 객체의 내부 상태와 관련
이 있다.
불변식은 다음과 같은 두 가지 특성을 가진다.
-
불변식
은 클래스의 모든 인스턴스(멤버)가 생성된 후에 만족돼야 한다. 클래스에 정의된 모든 생성자는불변식
을 준수해야 한다는 것을 의미한다. -
불변식
은 클라이언트에 의해 호출 가능한 모든 메서드에 의해 준수돼야 한다. 메서드가실행 중
에는 객체의 상태가 불안정한 상태로 빠질 수 있기 때문에 불변식을 만족시킬 필요는 없지만메서드 실행 전과 메서드 종료 후에는 항상 불변식을 만족하는 상태가 유지
되어야 한다.
불변식
은 클래스의 모든 메서드의 사전조건과 사후조건에 추가되는 “공통의 조건”으로 생각할 수 있다.
-
불변식
은 메서드가 실행 되기 전에사후조건과 함께 실행
-
불변식
은 메서드가 실행된 후에사후조건과 함께 실행
만약 불변식을 수작업으로 작성한다면 모든 메서드에 동일한 불변식을 추가해야 할 것이다. (이를테면 자바에서는 모든 메서드에 동일한 assert
문을 작성함으로써 불변식을 추가한다.) C#의 Code Contracts에서는 Contract.Invariant
메서드를 이용해 불변식을 정의
할 수 있다. 불변식은 생성자 실행 후, 메서드 실행 전, 메서드 실행 후에 호출돼야 한다는 점을 기억하자. 만약 우리가 직접 불변식
을 코딩하고 관리해야 한다면 모든 생성자의 마지막 위치
, 메서드의 시작
, 메서드의 종료
지점에 불변식을 호출
하도록 일일히 코드를 작성해야 할 것이다.
이제 Screening
에 불변식
을 추가해보자 Screening
의 인스턴스가 생성되면 movie
는 null
이 아니어야 하고 seuqnece
는 1보다 크거나 같아야 하며, whenScreened
는 현재 시간 이후여야 한다. 이 불변식을 ContractInvariantMethod
애트리뷰트가 지정된 메서드 안에 구현하자.
public class Screening { | |
private Movie movie; | |
private int sequence; | |
private DateTime whenScreened; | |
[ContractInvariantMethod] | |
private void Invariant() { | |
Contract.Invariant(movie != null); | |
Contract.Invariant(sequence >= 1); | |
Contract.Invariant(whenScreened > DateTime.now(); | |
} | |
} |
Code Contracts 덕분에 객체의 생성자나 메서드 실행 전후에 불변식
을 직접 호출해야 하는 수고를 들일 필요가 없다.
계약에 의한 설계와 서브타이핑
지금까지 살펴본 것 처럼 계약에 의한 설계
의 핵심은 클라이언트와 서버 사이의 견고한 협력을 위해 준새 해야 하는 규약을 정의
하는 것이다. 여기서 우리는 클라이언트
라는 단어에 주목해야한다. 계약에 의한 설계
는 클라이언트가 만족시켜야 하는 사전 조건
과 클라이언트의 관점에서 서버가 만족시켜야 하는 사후 조건
을 기술한다. 계약에 의한 설계와 리스코프 치환 원칙이 만나는 지점이 바로 이곳이다. 리스코프 치환 원칙
은 슈퍼타입의 인스턴스와 협력하는 클라이언트의 관점
에서 서브타입의 인스턴스가 슈퍼타입을 대체
하더라도 협력에 지장이 없어야 한다는 것
을 의미한다.
서브타입이 리스코프 치환 원칙을 만족시키기 위해서는 클라이언트와 슈퍼타입 간에 체결된 계약을 준수해야 한다.
리스코프 치환 원칙
의 규칙을 두 가지 종류로 세분화 할 수 있다. 첫 번째 규칙은 협력에 참여하는 객체에 대한 기대를 표현하는 계약 규칙
이고 두 번째 규칙은 교체 가능한 타입과 관련된 가변성 규칙
이다.
계약 규칙
은 슈퍼타입과 서브타입 사이의 사전조건
, 사후조건
, 불변식
에 대해 서술할 수 있는 제약에 관한 규칙이다.
-
서브타입
에 더 강력한 사전조건을 정의할 수 없다. -
서브타입
에 더 완화된 사전조건을 정의할 수 없다. -
슈퍼타입
의 불변식은서브타입
에서도 반드시 유지돼야 한다.
가변성 규칙
은 파라미터와 리턴 타입의 변형
과 관련된 규칙이다.
-
서브타입
의 메서드 파라미터는반공변성
을 가져야 한다. -
서브타입
의리턴타입
은공변성
을 가져야 한다. -
서브타입
은슈퍼타입
이 발생시키는 예외와 다른 타입의 예외를 발생시켜서는 안된다.
대부분의 객체지향 언어에서 공변성
과 반공변성
이 중요해지는 곳은 상속이 제네릭 프로그래밍과 만나는 지점
이다. 여기서의 초점은 일반적인 클래스의 상속에 맞춰져 있으므로 제네릭 프로그래밍과 관련된 가변성 규칙은 다루지 않는다.
지금부터는 11장에서 살펴본 핸드폰 과금 시스템의 합성 버전
을 예제로 살펴본다. 이번 예제에서는 자바로 작성되어있으며 자바에서 기본적으로 제공하는 단정문인 assert
를 사용해 사전조건
, 사후조건
, 불변성
을 직접 구현한다. 즉, 계약에 의한 설계
가 특정 라이브러리나 프레임워크와는 상관이 없는 설계 개념이라는 점을 강조하기 위한 것이다.
계약에 의한 설계는 협력을 올바르게 설계하기 위해 고려해야 하는 설계 원칙과 설계 방법이다. 특정한 구현 메커니즘이 아니다.
계약 규칙
핸드폰 과금 시스템
에서 RatePolicy
는 아래 그림에서 알 수 있는 것처럼 기본 정책
과 부가 정책
을 구현하는 모든 객체들이 실체화 해야하는 인터페이스이다.
public interface RatePolicy { | |
Money calculateFee(List<Call> calls); | |
} |
여기서 한 가지 질문을 해보자 이 클래스들은 정말로 RatePolicy
의 서브 타입
인가? 다시 말해서 리스코프 치환 원칙
을 만족하는가? 이 질문에 답하기 위해서는 RatePolicy
인터페이스의 구현 클래스들이 RatePolicy
의 클라이언트인 Phone
과 체결한 계약을 준수하는지
를 살펴봐야 한다.
이해를 돕기 위해서 요금 청구서
를 발생하는 publishBill
메서드를 Phone
에 추가하자. 청구서의 개념을 구현한 Bill
클래스는 요금 청구의 대상인 phone
과 통화 요금인 fee
를 인스턴스 변수로 포함한다.
public class Bill { | |
private Phone phone; | |
private Money fee; | |
public Bill(Phone phone, Money fee) { | |
if (phone == null) { | |
throw new IllegalArgumentException(); | |
} | |
if (fee.isLessThan(Money.ZERO)) { | |
throw new IllegalArgumentException(); | |
} | |
this.phone = phone; | |
this.fee = fee; | |
} | |
} |
Phone
클래스의 publishBill
메서드는 가입자에게 청구할 요금을 담은 Bill
인스턴스를 생성한 후 반환한다.
public class Phone { | |
private RatePolicy ratePolicy; | |
private List<Call> calls = new ArrayList<>(); | |
public Phone(RatePolicy ratePolicy) { | |
this.ratePolicy = ratePolicy; | |
} | |
public void call(Call call) { | |
calls.add(call); | |
} | |
public Bill publishBill() { | |
return new Bill(this, ratePolicy, calculateFee(calls)); | |
} | |
} |
publishBill
메서드에서 calculateFee
의 반환값을 Bill
의 생성자에 전달한다는 부분에 주목하자. 청구서의 요금은 최소한 0원보다 크거나 같아야 하므로 calculateFee
의 반환 값은 0원 보다 커야 한다. 따라서 RatePolicy
의 calculateFee
의 사후조건
을 아래와 같이 정의할 수 있다.
assert result.isGraterThanOrEqual(Money.ZERO); |
이번에는 calculateFee
오퍼레이션을 호출할 때 클라이언트인 Phone
이 보장해야 하는 사전 조건
을 살펴보자. calculateFee
오퍼레이션은 파라미터로 전달된 Call
목록에 대한 요금의 총합을 계산한다. 이를 위해서는 파라미터인 calls
가 null
이 아니어야 한다. 따라서 calculateFee
오퍼레이션의 사전 조건
을 아래와 같이 정의할 수 있다.
assert calls != null; |
RatePolicy
인터페이스를 구현하는 클래스가 RatePolicy
의 서브타입
이 되기 위해서는 위에서 정의한 사전조건
과 사후조건
을 만족해야 한다. 먼저 기본정책
을 구현하는 추상 클래스인 BasicRatePolicy
에 사전조건
과 사후조건
을 추가하자.
public abstract class BasicRatePolicy implements RatePolicy { | |
@Override | |
public Money calculateFee(List<Call> calls) { | |
// 사전 조건 | |
assert calls != null; | |
Money result = Money.ZERO; | |
for (Call call : calls) { | |
result.plus(calculateCallFee(call)); | |
} | |
// 사후 조건 | |
assert result.isGraterThanOrEqual(Money.ZERO); | |
return result; | |
} | |
protected abstract Money calculateCallFee(Call call); | |
} |
부가 정책
을 구현하는 추상 클래스인 AdditionalRatePolicy
에도 사전 조건
과 사후 조건
을 추가해야 한다.
public abstract class AdditionalRatePolicy implements RatePolicy { | |
private RatePolicy next; | |
public AdditionalRatePolicy(RatePolicy next) { | |
this.next = next; | |
} | |
@Override | |
public Money calcualteFee(List<Call> calls) { | |
// 사전 조건 | |
assert calls != null; | |
Money fee = next.calculateFee(calls); | |
Money result = afterCalculated(fee); | |
//사후 조건 | |
assert result.isGraterThanOrEqual(Money.ZERO); | |
} | |
protected abstract Money afterCalculated(Money fee); | |
} |
지금까지 BasicRatePolicy
와 AdditionalRatePolicy
를 RatePolicy
의 서브타입으로 만들었다. 지금부터는 이 예제를 이용해 앞에서 설명한 계약 규칙
과 가변성 규칙
에 관해 자세히 살펴보자.
서브 타입에 더 강력한 사전조건을 정의할 수 없다.
한 번도 통화가 발생하지 않은 Phone
에 대한 청구서를 발행하는 시나리오를 고려해보자.
Phone phone = new Phone(new RegularPolicy(Money.wons(100), Duration.ofSeconds(10))); | |
Bill bill = phone.publishBill(); |
Phone
의 코드를 보면 내부적으로 통화 목록을 유지하는 인스턴스 변수인 calls
를 선언하는 동시에 빈 리스트로 초기화
하는 것을 알 수 있다. 따라서 위 코드처럼 단 한번도 call
메서드가 호출 (phone.call(Call call)
)되지 않은 경우 RatePolicy
의 calculateFee
메서드 인자로 빈 리스트가 전달될 것이다. calculateFee
의 사전 조건에서는 인자가 null
인 경우를 제외하고는 모든 값을 허용하기 때문에 위 코드는 사전 조건을 위반하지 않는다.
하지만 RegularPolicy
의 부모 클래스인 BasicRatePolicy
에 calls
가 빈 리스트여서는 안된다는 사전 조건
을 추가하면 어떻게 될까? Phone
을 생성한 후에 곧장 publishBill
메서드를 호출하는 앞의 예제에서는 사전조건을 만족시키지 않게 되기 때문에 정상적으로 실행되지 않을 것이다.
public abstract class BasicRatePolicy implements RatePolicy { | |
@Override | |
public Money calculateFee(List<Call> calls) { | |
// 사전 조건 | |
assert calls != null; | |
assert !calls.isEmpty(); // 강화된 사전조건 | |
} | |
} |
사전 조건
을 만족시키는 것은 클라이언트(Phone)
의 책임이다. 클라이언트
인 Phone
은 오직 RatePolicy
인터페이스만 알고있기 때문에 RatePolicy
가 null
을 제외한 어떤 calls
라도 받아들일 것이라고 가정(기대)
한다. 따라서 빈 리스트를 전달하더라도 문제가 발생하지 않는다고 예상할 것이다.
하지만 BasicRatePolicy
는 사전 조건에 새로운 조건을 추가(강화)
함으로써 Phone
과 RatePolicy
사이에 맺은 계약을 위반
한다. Phone
의 입장에서는 더이상 RatePolicy
와 BasicRatePolicy
가 동일하지 않다.
하나는 원래의 약속을 지키는 신뢰할 수 있는 협력자(RatePolicy
)이고, 다른 하나는 약속을 파기한 배신자(BasicRatePolicy
)이다. 클라이언트(Phone)의 관점에서 BasicRatePolicy는 RatePolicy를 대체할 수 없기 때문에 “리스코프 치환 원칙”을 위반한다. BasicRatePolicy는 RatePolicy의 서브타입이 아닌 것이다.
이 예에서 알 수 있는 것처럼 서브타입이 슈퍼타입에 정의된 사전조건을 강화하게 되면 기존에 체결된 계약을 위반하게 된다. 계약서에 명시된 의무보다 더 많은 의무를 짊어져야 한다는 사실을 순순히 납득하는 클라이언트
는 없을 것이다. 결국 사전조건을 강화한 서브타입은 클라이언트의 입장에서 수용이 불가능
하기 때문에 슈퍼타입
을 대체할 수 없게 된다. 따라서 사전조건 강화는 리스코프 치환 원칙 위반
이다.
반대로 사전조건을 완화
시키는 경우는 어떨까? 다음과 같이 사전조건을 완화해서 calls
가 null
인 인자를 전달해도 예외가 발생하지 않도록 수정해보자.
public abstract class BasicRatePolicy implements RatePolicy { | |
@Override | |
public Money calculateFee(List<Call> calls) { | |
if (calls == null) { | |
return Money.ZERO; | |
} | |
... | |
} | |
} |
다시한번 강조하지만 사전조건을 보장해야 하는 책임은 “클라이언트”에게 있다. 그리고 이미 클라이언트인 Phone
은 RatePolicy
의 calculateFee
오퍼레이션을 호출할 때 인자가 null
이 아닌 값을 전달하도록 보장하고 있을 것이다. 따라서 항상 인자는 null
이 아닐 것이고 null
여부를 체크하는 조건문은 무시되기 때문에 수정된 사전조건
은 협력에 영향을 미치지 않게 된다. 결과적으로 사전조건을 완화시키는 것은 리스코프 치환 원칙을 위반하지 않는다.
서브 타입에 더 완화된 사후조건을 정의할 수 없다.
이번에는 RatePolicy
의 calculateFee
오퍼레이션의 반환값이 0원보다 작은 경우를 다뤄보자. 다음은 10초당 100원을 부여하는 일반 요금제(RegularPolicy
)에 1000원을 할인해주는 기본 요금 할인 정책(RateDiscountablePolicy
)을 적용하는 시나리오를 구현한 것이다.
Phone phone = new Phone(new RateDiscountablePolicy(Monye.wons(1000), new RegularPolicy(Money.wons(100), Duration.ofSeconds(10))))); | |
phone.call(new Call(LocalDateTime.of(2017, 1, 1, 10, 10), LocalDateTime.of(2017, 1, 1, 11))); | |
Bill bill = phone.publishBill(); | |
가입자의 통화 목록에는 단 한번의 통화 내역만 존재하고 통화 시간은 1분이다. 이 사용자는 통화 시간 10초당 100원을 부과하는 요금제에 가입돼 있기 때문에 통화 요금은 600원 일 것이다. 문제는 이 사용자의 요금제에 1000원의 기본 요금 할인 정책이 추가돼 있다는 것이다. 따라서 할인 금액을 반영한 최종 청구 금액은 600원에서 1000원을 뺀 -400원이 될 것이다.
calculateFee
오퍼레이션은 반환값이 0보다 커야 한다는 사후조건
을 정의하고 있다. 사후조건
을 만족시킬 책임은 클라이언트가 아니라 서버
에 있다. Phone
은 반환된 요금이 0원보다 큰지를 확인할 의무가 없으며 사후조건을 위반한 책임은 전적으로 서버인 RateDiscountablePolicy가 져야 한다. RateDiscountablePolicy
는 계약을 만족시킬 수 없다는 사실을 안 즉시 예외를 발생시키기 때문에 calculateFee
오퍼레이션은 정삭적으로 실행되지 않고 종료된다.
이제 calculateFee
오퍼레이션이 정상적으로 실행되도록 RateDiscountablePolicy
의 부모 클래스인 AdditionalRatePolicy
에서 사후조건을 완화
시킨다고 가정해보자. 다음과 같이 사후조건
을 주석으로 처리해서 마이너스 요금이 반환되더라도 예외가 발생하지 않도록 수정해보자.
public abstract class AdditionalRatePolicy implements RatePolicy { | |
@Override | |
public Money calculateFee(List<Call> calls) { | |
// 사전 조건 | |
assert calls != null; | |
Money fee = next.calculateFee(calls); | |
Money result = calculateFee(fee); | |
// 사후 조건 | |
// assert result.isGraterThanOrEqual(Money.ZERO); // 사후 조건 완화 | |
return result; | |
} | |
} |
이제 AdditionalRatePolicy
는 마이너스 금액도 반환할 수 있기 때문에 Phone
과의 협력을 문제 없이 처리할 수 있다. 하지만 엉뚱하게도 Bill의 생성자에서 예외가 발생한다. Bill
의 생성자에서는 인자로 전달된 fee
가 마이너스 금액일 경우 예외를 던지도록 구현돼 있기 때문이다.
public class Bill { | |
public Bill(Phone phone, Money fee) { | |
throw new IllegalArgumentException(); | |
} | |
} |
문제는 AdditionalRatePolicy
가 마이너스 금액을 반환했다는 것이다. 하지만 예외 스택 트레이스는 Bill
의 생성자가 문제라고 지적한다. 우리는 스택 트레이스의 메시지를 근거로 Bill
에 전달된 마이너스 금액을 계산해낸 위치를 추적해야 할 것이다.
여기서 문제는 Bill이 아니다. Bill
의 입장에서 요금이 0원보다 크거나 같다고 가정하는 것은 자연스럽다.
문제는 AdditionalRatePolicy
가 사후 조건을 완화
함으로써 기존에 Phone
과 RatePolicy
사이에 체결된 계약을 위반
했기 때문에 발생한 것이다. 사후 조건을 완하한다는 것은 서버가 클라이언트에게 제공하겠다고 보장한 계약을 충족시켜주지 못한다는 것을 의미한다. 서버
는 계약을 위반
했기 때문에 더이상 계약은 유효하지 않다. 클라이언트
인 Phone
의 입장에서 AdditionalRatePolicy
는 더 이상 RatePolicy
가 아니다. 다시 말해서 AdditionalRatePolicy는 더 이상 RatePolicy의 서브 타입이 아니다.
이 예에서 알 수 있는 것 처럼 계약의 관점에서 사후조건은 완화할 수 없다. 계약서에 명시된 이익보다 더 적은 이익을 받게 된다는 사실을 납득할 수 있는 클라이언트가 있을까? 결국 사후조건을 완하
시키는 서버는 클라이언트 관점에서 수용할 수 없기 때문
에 슈퍼타입을 대체할 수 없다. 사후조건 완화는 리스코프 치환 원칙 위반이다.
반대로 사후조건을 강화
하는 경우는 어떨까? calculateFee
메서드가 100원보다 크거나 같은 금액을 반환하도록 사후조건을 강화
해보자.
public abstract class AdditionalRatePolicy implements RatePolicy { | |
@Override | |
public Money calculateFee(List<Call> calls) { | |
... | |
//사후 조건 | |
assert result.isGraterThanOrEqual(Money.wons(100)); // 사후 조건 강화 | |
return result; | |
} | |
} |
클라이언트(Phone)
은 반환된 요금이 0보다 크기만 하면 아무런 불만도 가지지 않기 때문에 위 변경은 클라이언트의 입장에서 아무런 영향도 끼치지 않는다. 요금이 100원보다 크다고 하더라도 어차피 그 금액은 0원보다는 큰 것이다. 따라서 사후조건 강화는 계약에 영향을 미치지 않는다.
일찍 실패하기
처음에는 의아하게 생각될 수도 있지만 마이너스 금액을 그대로 사용하는 것 보다 처리를 종료하는 것이 올바른 선택이다. 사후조건은 서버가 보장해야 한다는 것을 기억하라. 클라이언트인 Phone은 서버인 RatePolicy가 계약에 명시된 사후조건을 만족시킬 것이라고 가정하기 때문에 반환값을 체크할 필요가 없다. 따라서 Phone은 RatePolicy가 항상 플러스 금액을 반환할 것이라고 가정하고 별도의 확인 없이 반환값을 그대로 Bill의 생성자에 전달한다. 그리고 그 결과, 원인에서 멀리 떨어진 엉뚱한 곳에서 경보음이 울리게 되는 것이다.
Phone과 RatePolicy 사이의 협력을 종료시키지 않더라도 반환된 값을 이용하는 어디선가 문제가 발생할 것이다. 게다가 문제가 발생한 Bill의 생성자는 마이너스 금액을 계산한 로직이 위치한 곳이 아니다. 문제의 원인을 제공한 위치로부터 너무나도 멀리 떨어져 있다.
Phone과 RatePolicy 사이에서 예외를 발생시키면 이 문제를 해결할 수 있다. 예외가 발생한 그 지점이 바로 문제가 발생한 바로 그곳이다. 우리는 문제가 발생할 경우 원인이 어디인지를 빠르게 알기를 원한다. 지금의 편안함을 위해 오류를 감춰서는 안된다. 차라리 문제가 발생한 그 위치에서 프로그램이 실패하도록 만들어라. 문제의 원인을 파악할 수 있는 가장 빠른 방법은 문제가 발생하자 마자 프로그램이 일찍 실패하게 만드는 것이다.
슈퍼타입의 불변식은 서브타입에서도 반드시 유지돼야 한다.
불변식
은 메서드가 실행되기 전과 후에 반드시 만족시켜야 하는 조건
이다. 모든 객체는 객체가 생성된 직후부터 소멸되기 직전까지 불변식을 만족시켜야 한다. 하지만 메서드를 실행하는 도중에는 만족시키지 않아도 무방하다. 생성자의 경우 시작 지점에는 불변식을 만족시키지 않겠지만 생성자가 종료되는 시점에는 불변식을 만족시켜야 한다.
AdditionalRatePolicy
에서 다음 요금제를 가리키는 next
는 null
이어서는 안된다. 따라서 AdditionalRatePolicy
의 모든 메서드 실행 전과 후, 그리고 생성자의 마지막 지점에서 next
가 null
이어서는 안된다는 불변식
을 만족시켜야 한다.
public abstract class AdditionalRatePolicy implements RatePolicy { | |
protected RatePolicy next; | |
public AdditionalRatePolicy (RatePolicy next) { | |
this.next = next; | |
// 불변식 | |
assert next != null; // 생성자의 종료 지점에서는 불변식을 만족시켜야 한다. | |
} | |
@Override | |
public Money calculateFee(List<Call> calls) { | |
// 불변식 | |
assert next != null; | |
// 사전 조건 | |
assert calls != null; | |
... | |
// 사후 조건 | |
assert result.isGraterThanOrEqual(Money.ZERO); | |
// 불변식 | |
assert next != null; | |
return result; | |
} | |
} |
하지만 위 코드에서는 불변식
을 위반할 수 있는 취약점
이 존재한다. 인스턴스 변수인 next
가 private
이 아니라 protected
변수라는 사실을 눈치챘는가? AdditionalRatePolicy
의 자식 클래스는 부모 클래스 몰래 next
의 값을 수정하는 것이 가능하다.
수정된 RateDiscountablePolicy
의 코드를 보자. next
는 protected
변수이기 때문에 RateDiscountablePolicy
는 changeNext
메서드를 통해 언제라도 next
를 변경할 수 있다.
public class RateDiscountablePolicy extends AdditionalRatePolicy { | |
public void changeNext(RatePolicy next) { | |
this.next = next; | |
} | |
} |
문제는 chnageNext
메서드를 이용해서 next
의 값을 null
로 변경할 수 있다는 것이다. 이 경우 불변식
이 유지되지 않는다.
RateDiscountablePolicy policy = new RateDiscountablePolicy(Money.wons(1000), new RegularPolicy(Money.wons(100), Durations.ofSeconds(10))))); | |
policy.chnageNext(null); // 불변식 위반 |
이 예는 계약의 관점
에서 캡슐화
의 중요성을 잘 보여준다. 자식 클래스가 계약을 위반할 수 있는 코드를 작성하는 것을 막을 수 있는 유일한 방법은 인스턴스 변수의 가시성을 protected
가 아니라 private
로 만드는것 뿐이다. protected
인스턴스 변수를 가진 부모 클래스의 불변성
은 자식 클래스에 의해 언제라도 쉽게 무너질 수 있다. 모든 인스턴스 변수의 가시성은 private로 제한되어야 한다.
그렇다면 자식 클래스에서 인스턴스 변수의 상태를 변경하고 싶다면 어떻게 해야 할까? 부모 클래스에 protected
메서드를 제공하고 이 메서드를 통해 불변식을 체크하도록 해야한다.
public abstract class AdditionalRatePolicy implements RatePolicy { | |
private RatePolicy next; | |
public AdditionalRatePolicy(RatePolicy next) { | |
changeNext(next); | |
} | |
protected void changeNext(RatePolicy next) { | |
this.next = next; | |
// 불변식 | |
assert next != null; | |
} | |
} |
지금까지 리스코프 치환 원칙
과 관련된 계약 규칙
을 살펴보았다. 사실 계약에 의한 설계
와 리스코프 치환 원칙
의 중요한 내용 대부분은 지금까지 살펴본 계약 규칙
에 포함되어 있다.
지금부터는 중요도에 비해 대부분의 사람들이 크게 관심을 가지지 않는 가변성 규칙
에 대해 살펴보겠다. 가변성 규칙
이 계약 규칙
보다 인지도가 낮다고 하더라도 리스코프 치환 원칙
의 깊은 부분까지 이해하기 위해서는 가변성 규칙
을 이핸하는 것이 좋다. 특히 예외
와 관련된 규칙은 알아둘만한 가치가 있다.
가변성 규칙
서브타입은 슈퍼타입이 발생시키는 예외와 다른 타입의 예외를 발생시켜서는 안된다.
RatePolicy
의 calculateFee
오퍼레이션이 인자로 빈 리스트를 전달받았을 때 EmptyCallException
예외를 던지도록 계약을 수정해보자.
public class EmptyCallException extends RuntimeException {...} | |
public interface RatePolicy { | |
Money calculateFee(List<Call> calls) throws EmptyCallException; | |
} |
public abstract class BasicRatePolicy implements RatePolicy { | |
@Override | |
public Money calculateFee(List<Call> calls) { | |
if (calls == null || calls.isEmpty()) { | |
throw new EmptyCallException(); | |
} | |
} | |
} |
RatePolicy
와 협력하는 메서드가 있다고 가정하자. 이 메서드는 EmptyCallException
예외가 던져질 경우 이를 캐치한 후 0원을 반환한다.
public void calculate(RatePolicy policy, List<Call> calls) { | |
try { | |
return policy.calculateFee(calls); | |
} catch(EmptyCallException ex) { | |
return Money.ZERO; | |
} | |
} |
하지만 RatePolicy
를 구현하는 클래스가 EmptyCallException
예외가 아닌 다른 예외를 던진다면 어떻게 될까? 예를 들어 AdditionalRatePolicy
클래스가 다음과 같이 NoneElementException
예외를 던진다고 가정해보자.
public abstract class AdditionalRatePolicy implements RatePolicy { | |
@Override | |
public Money calculateFee(List<Call> calls) { | |
if (calls == null || calls.isEmpty()) { | |
throw new NoneElementException(); | |
} | |
} | |
} |
만약 NoneElementException
클래스가 다음과 같이 EmptyCallException
클래스의 자식 클래스라면 AdditionalRatePolicy
는 RatePolicy
를 대체할 수 있을 것이다.
public class EmptyCallException extends RuntimeException {...} | |
public class NoneElementException extends EmptyCallException {...} |
하지만 다음과 같이 상속 계층
이 다르면 하나의 catch
문으로 두 예외 모두를 처리할 수 없기 때문에 NoneElementException
은 예외 처리에서 잡히지 않게 된다. 결과적으로 클라이언트 입장
에서 협력의 결과가 예상을 빗나갔기 때문
에 AdditionalRatePolicy
는 RatePolicy
를 대체할 수 없다.
public class NoneElementException extends RuntimeException {...} | |
public class EmptyCallException extends RuntimeException {...} |
일반적으로 부모 클래스가 던지는 예외가 속한 상속 계층이 아닌 다른 상속 계층에 속하는 예외를 던질 경우 자식 클래스는 부모 클래스를 대체할 수 없다. 따라서 서브타입이 아니다.
이 규칙의 변형
이 존재한다. 하나는 자식 클래스에서 부모 클래스에서 정ㄹ의하지 않은 예외를 발생시키는 경우로서 13장에서 소개한 Bird
를 상속받는 Penguin
의 예가 이 경우에 해당한다. Bird
의 자식 클래스인 Penguin
은 fly
메서드 안에서 UnsuppertedOperationException
예외를 던진다. 개발자는 코드를 재사용하기 위해 Bird
를 상속받았지만 Penguin
은 날 수 없다는 제약조건을 만족시켜야 했기 때문에 fly
메서드의 실행을 막았던 것이다.
public class Bird { | |
public void fly() { | |
... | |
} | |
} | |
public class Penguin extends Bird { | |
@Override | |
public void fly() { | |
throw new UnsupportedOperationException(); | |
} | |
} |
예외를 던짐으로써 날 수 있는 행동을 정상적으로 동작하지 않도록 만들었기 때문에 Penguin
의 입장에서는 원하는 결과를 얻은 것이라고 생각할 수도 있다. 하지만 클라이언트는 협력하는 모든 Bird가 날 수 있다고 생각할 것이다. 클라이언트
는 Bird
의 인스턴스에게 fly
메시지를 전송했을 때 UnsuppertedOperationException
예외가 던져지리라고 기대하지 않았을 것이다. 따라서 클라이언트의 관점에서 Penguin은 Bird를 대체할 수 없다.
계약을 위반하는 또 다른 예는 fly 메서드의 기능을 퇴화시키는 경우다. 다음 코드처럼 Penguin
의 fly
메서드를 아무것도 하지 않게 만듦으로써 fly
메시지에는 응답할 수 있지만 나는 기능 자체를 제거
할 수 있다. 하지만 이 경우도 모든 Bird가 날 수 있다고 가정하는 클라이언트의 관점에서는 올바르지 않다.
public class Penguin extends Bird { | |
... | |
@Override | |
public void fly() { | |
} | |
} |
위 두 가지 예에는 예외를 던지느냐
, 아무것도 하지 않느냐
의 차이는 있지만 클라이언트의 관점
에서 부모 클래스가 하는 일보다 자식 클래스가 하는 일이 더 적은 일을 수행
한다는 공통점이 있다. 클라이언트의 관점에서 부모 클래스에 대해 기대했던 것보다 더 적은 일을 수행하는 자식 클래스는 부모 클래스와 동일하지 않다. 부모 클래스보다 못한 자식 클래스는 서브타입이 아니다.
서브타입의 리턴 타입은 공변성을 가져야 한다.
대부분의 사람들은 제네릭 프로그래밍
과 상속
을 함께 사용하는 시점에 이르서야 비로소 공변성
과 반공변성
에 대한 관심을 가진다. 하지만 제네릭 프로그래밍을 고려하지 않고도 상속이라는 문맥 안에서 공변성
과 반공변성
의 의미를 살펴보는 것은 의미가 있다. 먼저 공변성
, 반공변성
, 무공변성
의 개념을 살펴본 후 서브타이핑
과 공변성
, 반공변성
사이의 관계를 살펴보자.
-
공변성(covariance)
:S
와T
사이의 서브타입 관계가 그대로 유지된다. 이 경우 해당 위치에서 서브타입인S
가 슈퍼타입인T
대신에 사용될 수 있다. 우리가 흔히 이야기하는리스코프 치환 원칙
은공변성
과 관련된 원칙이라고 생각하면 된다. -
반공변성(contravariance)
:S
와T
사이의 서브타입 관계가역전
된다. 이 경우 해당 위치에서 슈퍼타입인T
가 서브타입인S
대신에 사용될 수 있다. -
무공변성(invariance)
:S
와T
사이에는 아멀 노간계도 존재하지 않는다. 따라서S
대신T
를 사용하거나T
대신S
를 사용할 수 없다.
이해를 돕기 위해 아래 그림과 같이 서브타입
관계를 구현한 세 개의 상속 계층을 살펴보자. 세 개의 상속 계층 모두 리스코프 치환 원칙
을 만족하도록 구현돼있다고 가정하자.
책을 출판하는 출판살늘 나타내는 Publisher
클래스는 독립 출판사를 구현하는 IndependentPublisher
클래스의 슈퍼타입이다.
public class Publisher {} | |
public class IndependentPulisher extends Publisher {} |
Book
은 Magazine
의 부모 클래스다. Book
은 책이나 잡지를 출판하는 Publisher
에 대한 참조를 보관한다.
public class Publisher {} | |
public class IndependentPulisher extends Publisher {} |
Magazine
은 Book
의 자식 클래스다.
public class Magazine extends Book { | |
public Magazine(Publisher publisher) { | |
super(publisher); | |
} | |
} |
Book
과 Magazine
을 판매하는 판매처는 두 종류가 존재하며 하나는 거리에서 책을 판매하는 가판대를 구현한 BookStall
이고 다른 하나는 전문적으로 잡지를 판매하는 MagazineStore
다. BookStall
은 독립출판사인 IndependentPublisher
에 의해 출간한 Book
만 판매할 수 있다.
public class BookStall { | |
public Book sell(IndependentPublisher independentPublisher) { | |
return new Book(independentPublisher); | |
} | |
} |
MagazineStore
역시 독립 출판사가 출간한 Magazine
만 판매할 수 있다고 가정할 것이다.
public class MagazineStore extends BookStall { | |
@Override | |
public Book sell(IndependentPublisher independentPublisher) { | |
return new Magazine(independentPublisher); | |
} | |
} |
이제 책을 구매하는 Customer
의 코드를 살펴보자. Customer
는 BookStall
에게 sell
메시지를 전송해 책을 구매한다.
public class Customer { | |
private Book book; | |
public void order(BookStall bookStall) { | |
this.book = bookStall.sell(new IndenpendentPublisher()); | |
} | |
} |
아래 그림은 클래스 사이의 관계를 그림으로 표현한 것이다.
지금까지 살펴본 서브타이핑
은 단순히 서브타입이 슈퍼타입의 모든 위치에서 대체 가능
하다는 것이다. 하지만 공변성
과 반공변성
의 영역으로 들어가기 위해서는 타입의 관계가 아니라 메서드의 리턴 타입과 파라미터 타입에 초점
을 맞춰야 한다.
먼저 리턴 타입 공변성
부터 살펴보자. BookStall
의 sell
메서드는 Book
의 인스턴스를 리턴하고, MagazineStore
의 sell
메서드는 Magazine
의 인스턴스를 리턴한다. 여기서 우리의 눈길을 끄는 것은 슈퍼타입인 BookStall이 슈퍼타입인 Book을 반환
하고 서브타입인 MagazineStore이 서브타입인 Magazine을 반환
한다는 것이다.
리스코프 치환 원칙
이 클라이언트 관점에서의 치환 가능성
이므로 BookStall의 클라이언트 관점
에서 리턴 타입 공변성
의 개념을 살펴볼 필요가 있다. Customer
클래스의 order
메서드를 살펴보면 BookStall
에게 sell
메시지를 전송하는 것을 알 수 있다.
다음과 같이 Customer
가 BookStall
에게 책을 구매하는 다음의 코드를 보자.
new Customer().order(new BookStall()); |
Customer
의 order
메서드는 BookStall
에게 sell
메시지를 전송한 후 Book
타입의 인스턴스 변수에 반환값을 저장한다. BookStall
의 sell
메서드의 리턴 타입이 Book
타입으로 선언돼 있기 때문에 이 코드는 직관적이다.
MagazineStore
는 BookStall
의 서브타입이므로 BookStall
을 대신하여 협력할 수 있다. 따라서 Customer
가 BookStall
에서 책을 구매할 수 있다면 자연스럽게 서브타입인 MagazineStore
에서도 책을 구매할 수 있다.
new Customer().order(new MagazineStore()); |
MagazineStore
의 sell
메서드는 Magazine
의 인스턴스를 반환한다. 그리고 Customer
의 order
메서드는 반환된 Magazine
을 Book
타입의 인스턴스 변수에 대입한다. 업캐스팅에 의해 Magazine
역시 Book
이기 때문에 Customer
의 입장에서는 둘 사이의 차이를 알지 못할 것이다. Magazine
의 인스턴스가 반환되더라도 Customer
의 입장에서는 Magazine
역시 Book
의 일종이기 때문에 MagazineStore
로 BookStall
을 치환하더라도 문제가 없는 것이다.
따라서 sell
메서드의 리턴 타입을 Book
에서 Magazine
으로 변경하더라도 Customer
입장에서는 동일하다. MagazineStore
의 sell
메서드는 비록 반환 타입은 다르지만 정확하게 BookStall
의 sell
메서드를 대체할 수 있다.
이처럼 부모 클래스에서 구현된 메서드를 자식 클래스에서 오버라이딩 할 때 부모 클래스에서 선언된 반환 타입의 서브 타입으로 지정할 수 있는 특성을 리턴 타입 공변성
이라고 부른다. 간단하게 말해서 리턴 타입 공변성
이란 메서드를 구현한 클래스의 타입 계층 방향과 리턴 타입의 타입 계층 방향이 동일한 경우를 가리킨다.
앞에서 리스코프 치환 원칙
과 관련된 계약 규칙
을 설명할 때 서브타입에서 메서드의 “사후조건이 강화”되더라도 클라이언트 입장에서는 영향이 없다고 했던것을 기억하자. 슈퍼타입
대신 서브타입
을 반환하는 것은 더 강력한 사후조건을 정의
하는 것과 같다. 따라서 리턴 타입 공변성
은 계약에 의한 설계
의 관점에서 계약을 위반하지 않는다. 앞의 예에서 메서드를 구현한 슈퍼타입(BookStall)이 리턴 값의 슈퍼 타입(Book)
을 반환한 경우 메서드를 오버라이딩하는 서브타입(MagazineStore)이 슈퍼타입에서 사용한 리턴타입의 서브타입(Magazine)을 리턴타입으로 사용
하더라도 클라이언트(Customer)의 입장에서 대체 가능한 것이다.
한 가지 기억해야 하는 사항은 공변성
과 반공변성
의 지원 여부는 언어에 따라 다르다는 것이다. 예를 들어 자바의 경우 리턴 타입 공변성을 지원
하지만 C#의 경우 지원하지 않는다. 즉 이론적으로는 메서드의 리턴 타입을 공변적으로 정의하면 리스코프 치환 원칙을 만족시킬 수 있지만 실제적으로는 언어의 지원 여부에 따라 리턴 타입 공변성을 사용하지 못할 수도 있다.
서브타입의 메서드 파라미터는 반공변성을 가져야 한다.
이제 파라미터의 반공변성
에 대해서 알아보자. Customer
는 BookStall
의 sell
메서드를 호출할 때 파라미터로 IndependentPublisher
인스턴스를 전달한다. 그리고 BookStall
의 서브타입인 MagazineStore
의 sell
메서드 역시 IndependentPublisher
타입의 인스턴스를 파라미터로 전달받도록 정의돼 있다.
여기서는 IndependentPublisher
가 Publisher의 서브타입
이라는 사실을 기억해야 한다. 우리는 이미 업캐스팅 개념을 ㅌ오해 메서드의 파라미터가 Publisher
타입으로 정의돼 있더라도 그 서브타입인 IndependentPublisher
의 인스턴스를 전달하더라도 메서드가 정상적으로 동작한다는 것을 잘 알고있다.
먼저 주의할 점은 자바에서는 파라미터 반공변성을 허용하지 않기 때문에 아래에서 소개한 코드가 정상적으로 컴파일되지 않는다는 사실이다. 여기서는 설명을 위해 자바가 파라미터 반공변성
을 허용한다고 가정할 것이다.
BookStall
의 자식 클래스인 MagazineStore
에서 sell
메서드의 파라미터를 아래 그림과 같이 IndependentPublisher
의 슈퍼타입인 Publisher
로 변경할 수 있다면 어떨까?
public class MagazineStore extends BookStall { | |
@Override | |
public Magazine sell(Publisher publisher) { | |
return new Magazine(publisher); | |
} | |
} |
Customer
의 order
메서드는 BookStall
의 sell
메서드에 IndependentPublisher
인스턴스를 전달한다. BookStall
대신 MagazineStore
인스턴스와 협력한다면 IndependentPublisher
인스턴스가 MagazineStore
의 sell
메서드의 파라미터로 전달될 것이다. 이 때 파라미터 타입이 슈퍼타입인 Publisher
타입으로 선언돼 있기 때문에 IndependentPublisher
인스턴스가 전달되더라도 문제가 없다.
이처럼 부모 클래스에서 구현된 메서드를 자식 클래스에서 오버라이딩 할 때 파라미터 타입을 부모 클래스에서 사용한 파라미터(IndependentPublisher)의 슈퍼 타입(Publisher)로 지정 가능한 특성
을 파라미터 타입 반공변성
이라고 부른다. 간단하게 말해서 파라미터 타입 반공변성이란 메서드를 정의한 클래스의 타입 계층과 파라미터의 타입 계층의 방향이 반대인 경우 서브타입 관계를 만족한다는 것을 의미한다.
앞에서 리스코프 치환 원칙
과 관련된 계약 규칙
을 설명할 때 섭즈타입에서 메서드의 “사전조건”이 약화되더라도 클라이언트 입장에서는 영향이 없다. 라고 했던 것을 기억하자. 서브타입 대신 슈퍼타입을 파라미터로 받는 것
은 더 약한 사전조건
을 정의하는 것과 같다. 따라서 파라미터 반공변성은 계약에 의한 설계 관점에서 계약을 위반하지 않는다. 앞의 예에서 메서드를 구현한 슈퍼타입(BookStall)
이 어떤 서브타입(IndependentPublisher)
을 파라미터로 받는 경우 메서드를 오버라이딩하는 서브타입(MagazineStore)
이 슈퍼타입에서 사용한 파라미터 타입의 슈퍼타입(Publisher)
를 파라미터 타입으로 사용하더라도 클라이언트의 입장(Customer)
에서는 대체 가능한 것이다.
앞에서 언급한 것 처럼 자바는 파라미터 반공변성을 지원하지 않는다. 따라서 앞의 MagazineStore
는 @Override
어노테이션으로 정상적으로 컴파일되지 않는다. 만약 컴파일을 위해 @Override
어노테이션을 제거한다면 자바 컴파일러는 오버라이딩이 아니라 이름만 같고 실제로는 서로 다른 메서드인 오버로딩
으로 판단한다. 따라서 MagazineStore
는 BookStall
을 대체할 수 없게 된다.
리턴 타입 공변성과 파라미터 타입 반공변성은 ‘사전 조건’과 ‘사후 조건’의 관점에서 설명할 수도 있다. 서브타입은 슈퍼타입에서 정의한 것 보다 더 강력한 사전조건을 정의할 수는 없지만 사전 조건을 완화할 수는 있다.
‘사전조건’은 ‘파라미터에 대한 제약조건’이므로 슈퍼타입에서 정의한 파라미터 타입에 대한 제약조건을 좀 더 완화할 수 있따는 것을 의미한다. 따라서 좀 더 완화된 ‘슈퍼타입’을 파라미터로 받을 수 있는것이다.
‘리턴타입’은 ‘사후조건’과 관련이 있으며 서브타입은 슈퍼타입에서 정의된 사후조건을 완화시킬수는 없지만 ‘강화’할 수는 있다는 사실을 기억하자. 따라서 슈퍼타입에서 정의한 리턴 타입보다 ‘더 강화된 서브타입 인스턴스를 반환’하는 것이 가능한 것이다.
사실 객체지향 언어 중에서 파라미터 반공변성
을 지원하는 언어는 거의 없다고 봐도 무방하다. 여기서 파라미터 반공변성
을 언급한 이유는 제네릭 프로그래밍
에서 파라미터 반공변성
이 중요한 의미를 가지기 때문에 이번 장에서 설명한 기본적인 내용을 알아두는 것이 제네릭 프로그래밍을 공부하는데 도움이 될 것이다.
진정한 서브타이핑 관계
를 만들고 싶다면 서브타입에 더 강력한 ‘사전조건’이나 더 완화된 ‘사후조건’을 정의해서는 안되며 ‘슈퍼타입의 불변식을 유지’하기 위해 항상 노력해야 한다. 또한 서브타입은 슈퍼타입에서 정의하지 않은 예외를 던져서는 안된다.
참고 및 출처
- 오브젝트