들어가며

앞서 서브 클래싱과 서브 타이핑 챕터를 학습하면서 객체지향 프로그래밍에서의 타입 계층이 무엇을 의미하는지 대략적으로 이해했다. 객체지향을 처음 접한 입문자들이 흔히 오해하는 부분이 클래스와 타입을 동일한 것으로 간주하는 것이다. 타입은 개념의 분류를 의미하고 클래스는 타입을 구현하는 한 가지 방법일 뿐이다.

타입은 다양한 방법으로 구현할 수 있다. 사실 타입의 개념을 이해하는데 가장 큰 걸림돌은 타입을 구현하는 방법이 다양하다는 것이다. 심지어 타입을 구현할 수 있는 독자적인 방법을 제공하는 언어도 있다.

타입 계층타입보다 상황이 좀 더 복잡한데 다양한 방식으로 구현된 타입들을 하나의 타입 계층 안에 조합할 수 있기 때문이다. 예를 들어 자바에서는 인터페이스클래스를 이용하여 개별 타입을 구현한 후 이 두 가지 종류의 타입 구현체를 함께 포함하도록 타입 계층을 구성할 수 있다.

이번 챕터를 공부하면서 반드시 알아야 할 두 가지 사항이 있다.

  1. 타입 계층은 동일한 메시지에 대한 행동 호환성을 전제로 하기 때문에 이번 챕터에서 언급하는 모든 방법은 타입 계층을 구현하는 방법인 동시에 다형성을 구현하는 방법이기도 하다. 이번 장에서 설명하는 방법을 자세히 살펴보면 공통적으로 슈퍼 타입에 대해 전송한 메시지서브타입 별로 다르게 처리할 수 있는 방법을 제공한다는 사실을 알 수 있다. 이 방법들은 동적 메서드 탐색과 유사한 방식을 이용해 적절한 메서드를 검색한다.

  2. 이번 장에서 제시하는 방법을 이용하여 타입 계층을 구현한다고 해서 서브타이핑 관계가 보장되는 것은 아니다. 앞선 챕터에서 설명한 것 처럼 올바른 서브타이핑 관계가 되기 위해서는 서브타입이 슈퍼타입을 대체할 수 있도록 리스코프 치환 원칙을 준수해야 한다. 리스코프 치환 원칙은 특정한 구현 방법에 의해 보장되는 것이 아니다. 즉, 서브타이핑 관계를 구현하는 특정 구현 방법이 있는 것이 아니다. 따라서 클라이언트의 관점에서 타입을 동일하게 다룰 수 있도록 [의미적]으로 [행동 호환성]을 보장하는 것은 전적으로 프로그래머의 책임이다. 뒤에서 다루는 다양한 구현 방법을 이용할 때 타입 사이의 리스코프 치환 원칙을 준수하도록 만드는 책임은 전적으로 우리에게 있다는 사실을 기억하자.

이제부터 타입타입 계층을 구현할 수 있는 다양한 방법을 살펴보자.

클래스를 이용한 타입 계층 구현

클래스 기반 객체지향 언어를 사용하는 대부분의 사람들은 타입이라는 말에 반사적으로 클래스를 떠올린다. 타입은 객체의 [퍼블릭 인터페이스]를 가리키기 때문에 결과적으로 클래스는 객체의 타입과 구현을 동시에 정의하는 것과 같다. 이것이 객체지향 언어에서 클래스를 사용자 정의 타입이라고 부르는 이유다.

10장에서 구현한 Phone 클래스를 살펴보자.

Phone의 인스턴스는 calculateFee메시지를 수신할 수 있는 퍼블릭 메서드를 구현한다. 이 메서드는 결과적으로 Phone의 퍼블릭 인터페이스를 구성한다. 타입은 퍼블릭 인터페이스를 의미하기 때문에 Phone 클래스는 Phone 타입을 구현한다고 말할 수 있다. Phone은 calculateFee 메시지에 응답할 수 있는 타입을 선언하는 동시에 객체 구현을 정희하고 있는 것이다.

Phone의 경우처럼 타입을 구현할 수 있는 방법이 단 한 가지만 존재하는 경우에는 타입과 클래스를 동일하게 취급해도 무방하다. 여기서 타입의 구현 방법이 단 한 가지라는 말이 중요하다. 타입을 구현할 수 있는 다양한 방법이 존재하는 순간 타입과 클래스는 갈라지기 시작한다.

Phone퍼블릭 인터페이스는 동일하지만 다른 방식으로 구현해야 하는 객체가 필요하다고 가정해보자. 다시 말해서 구현은 다르지만 Phone과 동일한 타입으로 분류되는 객체가 필요한 것이다. 퍼블릭 인터페이스는 유지하면서 새로운 구현을 가진 객체를 추가할 수 있는 가장 간단한 방법은 상속을 이용하는 것이다.

상속을 이용하면 자식 클래스가 부모 클래스의 구현뿐 아니라 퍼블릭 인터페이스도 물려받을 수 있기 때문에 타입 계층을 쉽게 구현할 수 있다. 하지만 10장에서 살펴본 것 처럼 상속은 자식 클래스를 부모 클래스의 구현에 강하게 결합시키기 때문에 구체 클래스를 상속받는 것은 피해야 한다. 가급적 추상 클래스를 상속받거나 인터페이스를 구현하는 방법을 사용하자.

클래스는 타입을 구현할 수 있는 다양한 방법 중 하나일 뿐이다. 비교적 최근에 발표된 객체지향 언어들은 클래스를 사용하지 않고도 타입을 구현할 수 있는 방법을 제공한다. 대표적인 것이 자바와 C#의 인터페이스이다.

인터페이스를 이용한 타입 계층 구현

간단한 게임을 개발하고 있다고 가정하자. 게임은 사용자와 상호작용 할 수 있는 다양한 객체들로 구성된다. 수많은 객체들 중에서 실제로 플레이어의 게임 플레이에 영향을 미치는 객체들을 동일한 타입으로 분류하기를 원한다고 가정하자. 이 객체들의 타입을 GameObject라고 부를 것이다.

게임 안에는 GameObject 타입으로 분류될 수 있는 다양한 객체들이 존재한다. 화면 상에서 폭발 효과를 표현하는 Explosion사운드 효과를 표현하는 SoundGameObject 타입의 대표적인 예다. 이 중에서 ExplosionSound는 게임에 필요한 다양한 효과 중 하나이기 때문에 이들을 다시 Effect 타입으로 분류할 수 있다. 이 중에서 Explosion화면에 표시될 수 있기 때문에 Displayable 타입으로도 분류될 수 있다. 아마 Displayable 타입에는 적대적인 Monster와 플레이어가 직접 조작 가능한 Player 타입도 존재할 것이다.

이제 클래스와 상속을 이용해 이 객체들을 구현하는 방법을 생각해보자. Sound 타입은 Effect 타입의 서브타입이기 때문에 Effect 클래스를 상속받아야 한다. Explosion 타입은 Effect 타입인 동시에 Displayable 타입이기 때문에 Effect 클래스와 Displayable 클래스를 동시에 상속받아야ㄴ 한다.

문제는 대부분의 언어들이 다중 상속을 지원하지 않는다는 데 있따. 만약 자바를 사용하고 있다면 Explosion 클래스Effect 클래스Displayable 클래스를 동시에 상속받을 수 있는 방법은 없다.

게다가 이 클래스들을 동일한 상속 계층 안에 구현하고 싶지도 않다. 클래스들을 상속 관계로 연결하면 자식 클래스가 부모 클래스의 구현에 강하게 결합될 확률이 높다. 결과적으로 상속 계층 안의 클래스 하나를 변경했는데도 게임에 포함된 수많은 클래스들이 영향을 받을 수 있다.

상속으로 인한 결합도 문제를 피하고 다중 상속이라는 구현 제약도 해결할 수 있는 방법은 클래스가 아닌 인터페이스를 사용하는 것이다.

GameObject 타입은 좀 더 많은 행동을 가진 다른 타입에 의해 확장될 수 있다. 예를 들어, 게임의 많은 요소들은 화면으로 표시될 필요가 있다. 이 객체들은 화면 표시라는 동일한 행동을 제공하기 때문에 별도의 타입으로 분류돼야 한다. 이들을 Displayable 타입으로 분류하는 것이 좋을 것 같다.

Displayable 인터페이스GameObject를 확장한다는 사실에 주목하자. 위 코드는 Displayable 타입을 GameObject 타입의 서브타입으로 정의한다. 결과적으로 Displayable 타입의 모든 인스턴스는 GameObject 타입의 인스턴스 집합에도 포함된다. 이처럼 인터페이스가 다른 인터페이스를 [확장]하도록 만들면 슈퍼타입과 서브타입간의 [타입계층]을 구성할 수 있다.

화면에 표시될 수 있는 Displayable 타입의 인스턴스들 중에는 다른 요소들과의 충돌로 인해 이동에 제약을 받거나 피해를 입는 등의 처리가 필요한 객체들이 존재한다. 이런 객체들을 위해 Collidable 타입을 정의하고 충돌 체크를 위한 collideWith 오퍼레이션을 추가하자. 충돌을 체크하는 객체들은 모두 화면에 표시 가능해야 하기 때문에 Collidable 타입은 Displayable 타입의 서브타입이어야 한다. Displayable 타입은 GameObject의 서브타입이므로 Collidable 타입은 자동적으로 GameObject의 서브타입이 된다.

화면에 표시되지 않더라도 게임에 다양한 효과를 부여할 수 있는 부가적인 요소들이 필요하다. 대표적인 것이 게임의 배경음악과 효과음이다. 이들은 특정한 조건에 따라 활성화돼야 하므로 이 행동을 제공하는 Effect 라는 타입을 정의하자.

이제 타입에 속할 객체들을 구현하자. 여기서는 인터페이스로 정의한 타입을 구현하기 위해 클래스를 사용할 것이다. 자바같은 경우 인터페이스를 이용해 타입의 퍼블릭 인터페이스를 정의하고 클래스를 이용해 객체를 구현하는 것이 일반적인 패턴이다. 인터페이스와 클래스를 함께 조합하면 다중 상속의 딜레마에 빠지지 않을 수 있고 단일 상속 게층으로 인한 결합도 문제도 피할 수 있다.

가장 먼저 사용자의 분신이 되어줄 Player가 필요하다. Player는 화면에 표시돼야 할 뿐만 아니라 화면상에 표현된 다른 객체들과의 충돌을 체크해야 한다. 따라서 PlayableCollidable 타입의 인스턴스여야 한다. 클래스를 이용해 타입을 구현하자.

플레이어를 공격할 Monster 역시 Collidable 타입이 정의한 행동을 제공해야 한다.

효과음은 화면에 표시될 필요도 없고 다른 요소와 충돌 여부를 체크할 필요도 없다. 하지만 플레이어 캐릭터와 몬스터 캐릭터가 충돌하는 경우처럼 특정 이벤트가 발생할 경우 활성화되어야 한다. 따라서 효과음을 구현한 Sount 클래스는 Effect 인터페이스를 구현해야 한다.

다양한 폭발 효과를 구현한 Explosion 객체는 화면에 표시될 수 있어야 하고 충돌 등의 특정 조건에 의해 활성화 되는 Effect의 일종이다. Sound와 달리 다른 요소들과의 충돌 여부를 체크할 필요는 없기 때문에 DisplayableEffect 인터페이스를 구현하면 된다.

아래 그림은 지금까지 살펴본 타입타입을 구현한 클래스사이의 관계를 그림으로 표현한 것이다.

이 그림으로부터 다음과 같은 사실을 알 수 있다.

  • 여러 클래스가 동일한 타입을 구현할 수 있다. Plyaer와 Monster 클래스는 서로 다른 클래스지만 이 두 클래스의 인스턴스들은 Collidable 인터페이스를 구현하고 있기 때문에 동일한 메시지에 응답할 수 있다. 따라서 서로 다른 클래스를 이용해서 구현됐지만 타입은 동일하다.

  • 하나의 클래스가 여러 타입을 구현할 수 있다. Explosion 인스턴스는 Displayable 인터페이스와 동시에 Effect 인터페이스도 구현한다. 따라서 Explosion의 인스턴스는 Displayable 타입인 동시에 Effect 타입이기도 하다.

중요한 것은 인터페이스를 이용해 타입을 정의하고 클래스를 이용해 객체를 구현하면 클래스 상속을 사용하지 않고도 타입 계층을 구현할 수 있다는 사실이다.

클래스와 타입 간의 차이를 이해하는 것은 중요한 일이다.

객체의 클래스는 객체의 구현을 정의한다. 클래스는 [객체의 내부 상태]와 [오퍼레이션 구현 방법]을 정의한다.

객체의 타입은 [인터페이스]만을 정의하는 것으로, 객체가 반응(응답)할 수 있는 [오퍼레이션 집합을 정의]한다.

하나의 객체가 여러 타입을 가질 수 있고 서로 다른 클래스의 객체들이 동일한 타입을 가질 수 있다. 즉, 객체의 구현은 다를지라도 인터페이스는 같을 수 있다는 것이다.

클래스와 타입 간에는 밀접한 관련이 있다. 클래스도 객체가 만족할 수 있는 오퍼레이션을 정의하고 있으므로 타입을 정의하는 것이기도 하다. 그래서 객체가 클래스의 인터페이스라고 말할 때 객체는 클래스가 정의하고 있는 인터페이스를 지원한다는 뜻을 내포한다.

영화 예매 시스템에서도 영화의 할인 조건을 구현한 타입 계층을 구현하기 위해 자바의 인터페이스클래스를 사용했다.

이 예제에서는 할인 조건이라는 타입을 정의하기 위해 자바 인터페이스로 DiscountCondition을 정의했다. 클래스인 SequenceConditionPeriodCondtionDiscountCondition 타입으로 분류될 객체들에 대한 구현을 담고 있다.

클래스와 타입의 차이점을 이해하는 것은 설계 관점에서 매우 중요하다.

[타입]은 [동일한 퍼블릭 인터페이스]를 가진 객체들의 범주다.

[클래스]는 [타입에 속한 객체들을 구현]하기 위한 [구현 메커니즘]이다.

객체지향에서 중요한 것은 협력 안에서 객체가 제공하는 [행동]이라는 사실을 기억하자. 따라서 중요한 것은 [클래스 자체가 아니라 타입]이다.

[타입]이 먼저 식별된 후에 타입에 속하는 객체를 구현하기 위해 [클래스]를 사용하는 것이다.

이 예제는 클래스가 객체지향의 중심이 아니라는 사실을 잘 보여준다. 클래스가 아니라 타입에 집중하라. 중요한 것은 객체가 외부에 제공하는 행동, 즉 타입을 중심으로 객체들의 계층을 설계하는 것이다. 타입이 아니라 클래스를 강조하면 객체의 퍼블릭 인터페이스가 아닌 세부 구현에 결합된 협력 관계를 낳게된다.

추상 클래스를 이용한 타입 계층 구현

클래스 상속을 이용해 구현을 공유하면서도 결합도로 인한 부작용을 피하는 방법도 있다. 바로 추상 클래스를 이용하는 방법이다. 영화 예매 시스템에서는 할인 정책을 구현하기 위한 DiscountPolicy가 추상 클래스에 해당한다.

이제 추상클래스인 DiscountPolicy를 상속받는 구체 클래스를 추가함으로써 타입 계층을 구현할 수 있다.

구체 클래스로 타입을 정의해서 상속받는 방법과 추상 클래스로 타입을 정의해서 상속받는 방법 사이에는 두 가지 주요한 차이점이 있다. 하나는 추상화의 정도이고 다른 하나는 상속을 사용하는 의도이다.

첫 번째로 의존하는 대상의 추상화 정도가 다르다. 앞에서 클래스를 이용해 타입 계층을 구현했던 Phone 클래스의 경우 자식 클래스인 NightlyDiscountPhone의 calculateFee 메서드가 부모 클래스인 Phone의 calcuateFee 메서드의 구체적인 내부 구현에 강하게 결합된다. 따라서 Phone의 내부 구현이 변경될 경우 자식 클래스인 NightlyDiscountPhone도 함께 변경될 가능성이 높다.

이에 비해 추상 클래스인 DiscountPolicy의 경우 자식 클래스인 AmountDiscountPolicyPercentDiscountPolicyDiscountPolicy내부 구현이 아닌 추상 메서드의 시그니처에만 의존한다. 이 경우 자식 클래스들은 DiscountPolicy가 어떤 식으로 구현되어 있는지 알 필요가 없다. 단지 추상 메서드로 정의된 getDiscountPolicy 메서드를 오버라이딩한다면 된다는 사실에만 의존해도 무방하다.

여기서 부모 클래스와 자식 클래스 모두 추상메서드인 getDiscountAmount에 의존한다는 사실이 중요하다. 이것은 의존성 역전 원칙의 변형이다. DiscountPolicy는 할인 조건을 판단하는 고차원 모듈이다. 그에 비해 AmountDiscountPolicyPercentDiscountPolicy는 할인 금액을 계산하는 저차원 모듈이다. 고차원 모듈과 저차원 모듈 모두 추상 메서드인 getDiscountAmount에 의존한다.

또한 DiscountPolicy의 구체 메서드인 calcuateDiscountAmount가 추상 메서드 getDiscountAmount를 호출하며 자식 클래스들은 모두 이 추상메서드의 시그니처를 준수한다. 따라서 구체적인 메서드가 추상적인 메서드에 의존하기 때문에 의존성 역전 원칙을 따른다고 할 수 있다. 결과적으로 이 설계는 유연한 동시에 변화에 안정적이다.

한 가지 조언은 모든 구체 클래스의 부모 클래스를 항상 추상 클래스로 만들기 위해 노력하라는 것이다. 의존하는 대상이 더 추상적일수록 결합도는 낮아지고 결합도가 낮아질수록 변경으로 인한 영향도는 줄어든다. DiscountPolicy의 자식 클래스들은 구체적인 구현이 아닌 추상 메서드에 의존하고 있기 때문에 추상메서드의 명세가 변경되지 않는 한 영향을 받지 않는다.

두 번째 차이점을 상속을 사용하는 의도다. Phone은 상속을 염두에 두고 설계된 것이 아니다. Phone의 설계자는 나중에 NightlyDiscountPhone이라는 개념이 추가될 것이라는 사실을 알지 못했다. 따라서 Phone에는 미래의 확장을 위한 어떤 준비도 돼있지 않다. 사실 NightlyDiscountPhone의 개발자가 Phone의 코드를 재사용하기 위해 상속을 사용한 것은 트릭에 가깝다.

그에 반해 DiscountPolicy는 처음부터 상속을 염두에 두고 설계된 클래스다. DiscountPolicy는 추상 클래스이기 때문에 자신의 인스턴스를 직접 생성할 수 없다. DiscountPolicy의 유일한 목적은 자식 클래스를 추가하는 것이다. 이 클래스는 추상메서드를 제공함으로써 상속 계층을 쉽게 확장할 수 있게 하고 결합도로 인한 부작용을 방지할 수 있는 안전망을 제공한다.

추상 클래스와 인터페이스 결합하기

대부분의 객체지향 언어들은 하나의 부모 클래스만 가질 수 있도록 허용하는 단일 상속만을 지원한다. 이 경우 여러 타입으로 분류되는 타입이 문제가 될 수 있는데 오직 클래스만을 이용해 타입을 구현할 경우 단일상속만으로 이 문제를 해결할 수는 없기 때문이다. 이 경우 해결 방법은 타입 계층을 오묘한 방식으로 비트는 것이다.

자바와 C#에서 제공하는 인터페이스를 이용해 타입을 정의하면 다중 상속 문제를 해결할 수 있다. 클래스가 구현할 수 있는 인터페이스의 수에는 제한이 없기 때문에 (인터페이스를 사용하면)하나의 클래스가 하나의 타입으로 분류 가능하도록 손쉽게 확장할 수 있다.

물론 인터페이스를 사용하는 방법에도 단점은 존재한다. 자바 8 버전 이하나 C#에서 제공하는 인터페이스에는 구현 코드를 포함할 수 없기 때문에 인터페이스만으로 중복 코드를 해결하기는 어렵다는 점이다. 따라서 효과적인 접근 방법은 인터페이스를 이용해 타입을 정의하고 특정 상속 계층에 국한된 코드를 공유할 필요가 있을 때에는 추상 클래스를 이용하여 코드 중복을 방지하는 것이다. 이런 형태로 추상 클래스를 사용하는 방식을 골격 구현 추상 클래스라고 부른다.

인터페이스가 메서드 구현 부분(메서드 몸체)를 포함하지는 않지만 인터페이스를 사용해 타입을 정의한다고 해서 프로그래머가 구현을 하는데 도움을 못 주는 것은 아니다. 외부에 공개한 각각의 중요한 인터페이스와 연관시킨 골격 구현 추상 클래스를 제공함으로써 인터페이스와 추상 클래스의 장점을 결합할 수 있다. 그렇게 함으로써 인터페이스는 여전히 타입을 정의하지만 골격 구현 클래스는 그것을 구현하는 모든 일을 맡는다.

DiscountPolicy 타입은 추상 클래스를 이용해서 구현했기 때문에 DiscountPolicy 타입에 속하는 모든 객체들은 하나의 상속 계층 안에 묶여야 하는 제약을 가진다. 이제 상속 계층에 대한 제약을 완화시켜 DiscountPolicy 타입으로 분류될 수 있는 객체들이 구현 시에 서로 다른 상속 계층에 속할 수 있도록 만들고 싶다고 가정해보자. 가장 좋은 방법은 인터페이스와 추상 클래스를 결합하는 것이다. DiscountPolicy 타입을 추상 클래스에서 인터페이스로 변경하고 공통 코드를 담을 골격 구현 추상 클래스 DefaultDiscountPolicy를 추가함으로써 상속 계층이라는 굴레에서 벗어날 수 있다.

아래 그림은 인터페이스와 골격 구현 추상클래스를 함께 사용해서 구현한 DiscountPolicy 타입 계층을 그림으로 표현한 것이다.

인터페이스와 추상 클래스를 함께 사용하는 방법은 추상 클래스만 사용하는 방법에 비해 두 가지 장점이 있다.

  • 다양한 구현 방법이 필요할 경우 새로운 추상 클래스를 추가해서 쉽게 해결할 수 있다. 예를 들어, 금액 할인 정책을 더 빠른 속도로 처리할 수 있는 방법과 메모리를 더 적게 차지하는 방법 모두를 구현해 놓고 상황에 따라 적절한 방법을 선택하게 할 수 있다.

  • 이미 부모 클래스가 존재하는 클래스라고 하더라도 인터페이스를 추가(구현)함으로써 새로운 타입으로 쉽게 확장할 수 있다. 즉, class Achild extends Aparent implements DiscountPolicy와 같이 인터페이스를 구현함으로써 새로운 타입으로 확장 가능하다. 만일 DiscountPolicy 타입이 추상 클래스로 구현돼 있는 경우에 이 문제를 해결할 수 있는 유일한 방법은 상속 계층을 다시 조정하는 것 뿐이다.

여러분의 설계가 상속 계층에 얽매이지 않는 타입 계층을 요구한다면 인터페이스로 타입을 정의하라. 추상 클래스로 기본 구현을 제공해서 중복 코드를 제거하라. 하지만 이런 복잡성이 필요하지 않다면 타입을 정의하기 위해 인터페이스나 추상 클래스 둘 중 하나만 사용하라. 타입의 구현 방법이 단 한가지 이거나 단일 상속 계층만으로 타입 계층을 구현하는데 무리가 없다면 클래스나 추상클래스를 이용해 타입을 정의하는 것이 더 좋다. 그 외의 상황이라면 인터페이스를 사용하는것을 고려하라.

덕 타이핑 사용하기

덕 타이핑은 주로 동적 타입 언어에서 사용하는 방법으로서 다음과 같은 덕 테스트(duck test)를 프로그래밍 언어에 적용한 것이다.

어떤 새가 오리처럼 걷고, 오리처럼 헤엄치며, 오리처럼 꽥꽥 소리를 낸다면 나는 이 새를 오리라고 부를 것이다.

덕 테스트는 어떤 대상의 행동이 오리와 같다면 그것을 오리라는 타입으로 취급해도 무방하다는 것이다. 다시 말해 객체가 어떤 인터페이스에 정의된 행동을 수행할 수만 있다면 그 객체를 해당 타입으로 분류해도 문제가 없다.

안타깝게도 자바같은 대부분의 정적 타입 언어에서는 덕 타이핑을 지원하지 않는다. 다음의 Employee, SalariedEmployee, HourlyEmployee 클래스를 보자.

SalariedEmployeeHourlyEmployee 클래스는 Employee 인터페이스에 정의된 calculatePay 오퍼레이션과 동일한 시그니처를 가진 퍼블릭 인터페이스를 포함하고 있다. 따라서 SalariedEmployeeHourlyEmployee 클래스가 동일한 퍼블릭 인터페이스를 공유하기 때문에 동일한 타입으로 취급할 수 있다고 예상할 것이다. 하지만 자바 같은 대부분의 정적 타입 언어에서는 두 클래스를 동일한 타입으로 취급하기 위해서는 코드상의 타입이 동일하게 선언돼 있어야 한다. 단순히 동일한 시그니처의 메서드를 포함한다고 해서 같은 타입으로 판단하지 않는다. 따라서 SalariedEmployeeHourlyEmployeeEmployee 타입이 아니다. 만약 아래의 calcualte 메서드의 첫 번째 인자로 SalariedEmployeeHourlyEmployee 인스턴스를 전달한다면 컴파일 오류가 발생할 것이다.

이 메서드에 SalariedEmployeeHourlyEmployee 인스턴스를 전달하기 위해서는 두 클래스가 Employee 인터페이스를 명시적으로 구현해야 한다. 이처럼 정적 타입 언어에서는 객체의 퍼블릭 인터페이스만으로 타입을 추측하는 것이 불가능 하며 모든 요소의 타입이 명시적으로 기술돼 있어야 한다.

반면 런타임에 타입을 결정하는 동적 타입 언어는 특정한 클래스를 상속받거나 인터페이스를 구현하지 않고도 객체가 수신할 수 있는 메시지의 집합으로 객체의 타입을 결정할 수 있다. 다음은 바로 전의 Employee 예제를 루비로 작성한 것이다.

루비와 같은 동적 타입 언어에서는 명시적으로 동일한 클래스를 상속받거나 동일한 인터페이스를 구현하지 않더라도 시그니처가 동일한 메서드를 가진 클래스를 같은 타입으로 취급할 수 있다. 위 예제에서 SalariedEmployeeHourlyEmployee 클래스의 인스턴스는 calculatePay(taxRate)라는 동일한 시그니처를 가진 메서드를 구현하고 있기 때문에 동일한 타입으로 간주할 수 있다. 따라서 다음과 같은 메서드의 인자로 인스턴스를 전달해도 문제가 없다.

이것이 바로 덕 타이핑이다. calculatePay(taxRate)라는 행동을 수행할 수 있으며 이 객체를 Employee라고 부를 수 있는 것이다. 마치 꽥꽥거리는 모든 것을 오리라고 부르는 것 처럼 말이다.

덕 타이핑은 타입이 행동에 대한 것이라는 사실을 강조한다. 두 객체가 동일하게 행동한다면 내부 구현이 어떤 방식이든 상관없다. 타입 관점에서 두 객체는 동일한 타입인 것이다.

8장에서 유연한 설계의 한 가지 조건으로 컨텍스트 독립성이라는 개념을 설명했다. 인터페이스클래스보다 더 유연한 설계를 가능하게 해주는 이유는 클래스가 정의하는 구현이라는 컨텍스트에 독립적인 코드를 작성할 수 있게 해주기 때문이다. 덕 타이핑은 여기서 한 걸음 더 나아간다. 단지 메서드의 시그니처만 동일하다면 명시적인 타입 선언이라는 컨텍스트를 제거할 수 있다. 덕 타이핑은 클래스나 인터페이스에 대한 의존성을 메시지에 대한 의존성으로 대체한다. 결과적으로 코드는 낮은 결합도를 유지하고 변경에 유연하게 대응할 수 있다.

객체지향 설계의 목표는 코드의 수정 비용을 줄이는 거싱다. 우리는 애플리케이션 설계의 핵심은 메시지라는 점도 알고 있고 엄격하게 정의된 퍼블릭 인터페이스를 구축하는 과정이 왜 중요한지도 알고 있다. 이제 이 둘을 통합한 강력한 설계 기술을 연마하면 수정 비용을 줄일 수 있다. 이 기술의 이름은 ‘덕 타이핑’이다. 덕 타입은 특정 클래스에 종속되지 않은 퍼블릭 인터페이스다. 여러 클래스를 가로지르는 이런 인터페이스는 클래스에 대한 값비싼 의존을 메시지에 대한 부드러운 의존으로 대치시킨다. 그리고 애플리케이션을 굉장히 유연하게 만들어준다.

믹스인과 타입 계층

믹스인(mixin)은 객체를 생성할 때 코드를 일부 섞어 넣을 수 있도록 만들어진 일종의 추상 서브 클래스다. 언어마다 구현 방법에 차이는 있지만 믹스인을 사용하는 목적은 다양한 객체 구현 안에서 동일한 '행동'을 중복 코드 없이 재사용할 수 있게 만드는 것이다.

여기서 행동이라는 단어에 주목하자. 믹스인을 통해 코드를 재사용하는 객체들은 동일한 행동을 공유하게 된다. 다시말해 공통의 행동이 믹스인된 객체들은 동일한 메시지를 수신할 수 있는 퍼블릭 인터페이스를 공유하게 되는 것이다. 타입퍼블릭 인터페이스와 관련이 있기 때문에 대부분의 믹스인을 구현하는 기법들은 타입을 정의하는 것으로 볼 수 있다.

이해를 돕기 위해 스칼라 언어에서 믹스인을 구현하기 위해 제공되는 트레이트(trait)를 살펴보자. 다양한 애플리케이션을 작성하다 보면 동일한 타입의 객체들을 비교해야 할 필요가 있다. 예상하겠지만 이럴 때마다 매번 모든 클래스에 비교 연산자를 추가하는 것은 고역스러울 수 밖에 없다.

이 문제를 해결하기 위해 스칼라는 비교와 관련된 공통적인 구현을 믹스인해서 재사용 할 수 있게 Ordred라는 트레이트를 제공한다. Ordered 트레이트는 내부적으로 추상 메서드 compare를 사용해 <, >, <=, >= 연산자를 구현한다.

이제 비교 연산자를 추가하고 싶은 클래스에 Ordered 트레이트를 믹스인하고 추상 메서드 compare를 오버라이딩하기만 하면 공짜로 <, >, <=, >= 연산자를 퍼블릭 인터페이스에 추가할 수 있게된다. 예를들어, 금액을 표현하는 Money 클래스에 비교 연산자를 추가하고 싶다면 다음과 같이 Ordered 트레이트를 믹스인 하면 된다.

Ordered 트레이트는 구현 뿐만 아니라 퍼블릭 메서드를 퍼블릭 인터페이스에 추가하기 때문에 이제 MoneyOrdered 트레이트를 요구하는 모든 위치에서 Ordered를 대체할 수 있다. 이것은 서브타입의 요건인 리스코프 치환 원칙을 만족시키기 때문에 MoneyOrdered 타입으로 분류될 수 있다.

Money 예젠는 최근의 객체지향 언어에서 풍부한 인터페이스를 만들기 위해 믹스인을 사용하는 경향을 잘 보여준다. Ordered 트레이트를 믹스인 하기 전의 Money+ 연산자와 - 연산자만을 퍼블릭 인터페이스에 포함하고 있는 간결한 클래스였다. 하지만 Ordered 트레이트를 믹스인 하고 추상 메서드 compare를 구현하는 순간 Money의 퍼블릭 인터페이스 안에는 <, >, <=, >= 라는 다수의 연산자가 자동으로 추가된다. 결과적으로 Money의 인터페이스는 더 많은 연산자로 인해 풍부해졌다.

믹스인은 간결한 인터페이스를 가진 클래스를 풍부한 인터페이스를 가진 클래스로 만들기 위해 사용될 수 있다. 물론 풍부한 인터페이스를 정의한 트레이트의 서브타입으로 해당 클래스를 만드는 부수적인 효과도 얻으면서 말이다.

트레이트의 주된 사용법 중 하나는 어떤 클래스가 그 클래스가 이미 갖고있는 메서드(Money의 compare)를 기반으로 하는 새로운 메서드(Ordered의 <, >, <=, >=)를 추가하는 것이다. 다시말해서 간결한 인터페이스를 풍부한 인터페이스로 만들 때 트레이트를 사용할 수 있다. 트레이트를 이용해 인터페이스를 풍성하게 만들고 싶다면 트레이트에 간결한 인터페이스 역할을 하는 추상 메서드(compare)를 구현하고 그런 추상 메서드를 활용해 풍부한 인터페이스 역할을 할 여러 메서드(<, >, <=, >=)를 같은 트레이트 안에서 구현하면 된다. 풍성해진 트레이트를 클래스에 믹스인 하고, 추상 메서드로 지정한 간결한 인터페이스만 구현하면 결국 풍부한 인터페이스의 구현을 모두 포함한 클래스를 완성할 수 있다.

스칼라의 트레이트와 유사하게 자바 8에 새롭게 추가된 디폴트 메서드인터페이스에 메서드의 기본 구현을 추가하는 것을 허용한다. 인터페이스에 디폴트 메서드가 구현돼 있다면 이 인터페이스를 구현하는 클래스는 기본 구현을 가지고 있는 메서드를 구현할 필요가 없다. 디폴트 메서드를 사용하면 추상 클래스가 제공하는 코드(구현) 재사용성이라는 혜택을 그대로 누리면서도 특정한 상속 계층에 얽매이지 않는 인터페이스의 장점을 유지할 수 있다.

자바의 경우에는 믹스인을 구현하기 위해 디폴트 메서드를 사용할 수 있으며, 이를 통해 간결한 인터페이스를 가진 클래스를 풍부한 인터페이스를 가진 클래스로 변경할 수 있다. 아래 코드와 같이 DiscountPolicy 인터페이스의 calculateDiscountAmount 오퍼레이션을 디폴트 메서드로 구현하면 더 이상 기본 구현을 제공하기 위해 인터페이스를 구현하는 추상 클래스를 만들 필요가 없을 것이다.

가장 먼저 눈에 띄는 것은 default 키워드를 사용해서 calculateDiscountAmount 오퍼레이션의 기본 구현을 제공했다는 것이다. 이제 추상 클래스를 사용하지 않고 DiscountPolicy 인터페이스를 상속받는 것만으로도 쉽게 AmountDiscountPolicyPercentDiscountPolicy를 추가할 수 있을 것이다.

하지만 디폴트 메서드가 제공하는 헤택을 누리면서 설계를 견고하게 유지하기 위해서는 디폴트 메서드가 가지는 한계를 분명하게 인식하는 것이 중요하다. 다시 한번 DiscountPolicy 인터페이스르 살펴보자. 인터페이스와 추상 클래스를 혼합했던 방식에서는 보이지 않던 getConditions 오퍼레이션과 getDiscountPolicy 오퍼레이션이 인터페이스에 추가된 것을 확인할 수 있다. 이것은 디폴트 메서드인 calculateDiscountAmount 메서드가 내부적으로 두 개의 메서드를 사용하기 때문에 이 인터페이스를 구현하는 모든 클래스들은 해당 메서드의 구현을 제공해야 한다는 것을 명시한 것이다. 이 방법은 앞에서 Ordered 트레이트에서 사용했던 방법(<, >, <=, >= 메서드가 내부적으로 compare 메서드를 사용하는 것)과 정확하게 일치한다.

문제는 이 메서드들이 인터페이스에 정의돼 있기 때문에 클래스 안에서 퍼블릭 메서드로 구현해야 한다는 것이다. 추상 클래스를 사용했던 경우에는 getDiscountAmount의 가시성이 protected 였던 것을 기억하자. getDiscountAmount 메서드가 원래는 구현을 위해 추상클래스 내부에서만 사용될 메서드였기 때문이다.

하지만 이제 디폴트 메서드 안에서 사용된다는 이유만으로 public 메서드가 되어야 한다. 이것은 외부에 노출할 필요가 없는 메서드를 불필요하게 퍼블릭 인터페이스에 추가하는 결과를 낳게된다.

getConditions 메서드의 경우에는 문제가 더 심각한데 클래스 내부에서 DiscountCondtion의 목록을 관리한다는 사실을 외부에 공개할뿐만 아니라 public 메서드를 제공함으로써 이 목록에 접근할 수 있게 해준다. 이것은 설계의 제 1원칙으로 강조해왔던 캡슐화를 약화시킨다.

금액 할인 정책을 구현한 AmountDiscountPolicy의 코드를 보면 문제를 이해할 수 있을 것이다. 불필요한 public 메서드인 getConditions의 구현이 필요한 이유는 인터페이스가 메서드 구현을 포함할 수는 있지만 인스턴스 변수를 포함할 수는 없기 때문이다. 내부 구현에 해당하는 getDiscountAmount 메서드가 public 메서드로 구현된 이유는 인터페이스에 포함된 디폴트 메서드가 해당 메서드를 호출하기 때문이다. 따라서 디폴트 메서드를 사용해 추상 클래스를 대체할 경우 인터페이스가 불필요하게 비대해지고 캡슐화가 약화될 수도 있다는 사실을 인지해야 한다.

게다가 이 방법은 AmountDiscountPolicyPercentDiscountPolicy 클래스 사이의 중복 코드를 완벽하게 제거해주지도 못한다. 비율 할인 정책을 구현한 PercentDiscountPolicy의 코드를 살펴보면 두 클래스 사이에 코드가 얼마나 많이 중복되는지 잘 알 수 있을 것이다.

이것은 자바 8에 디폴트 메서드를 추가한 이유가 인터페이스로 추상 클래스의 역할을 대체하려는 것이 아니기 때문이다. 디폴트 메서드가 추가된 이유는 기존에 널리 사용되고 있는 인터페이스에 새로운 오퍼레이션을 추가할 경우에 발생하는 하위 호환성 문제를 해결하기 위해서지 추상 클래스를 제거하기 위한 것이 아니다. 따라서 타입을 정의하기 위해 디폴트 메서드를 사용할 생각이라면 그 한계를 명확하게 알아두기 바란다.

인터페이스에 새로운 메서드를 추가하는 등 인터페이스를 바꾸고 싶을 때는 문제가 발생한다. 인터페이스를 바꾸면 이전에 해당 인터페이스를 구현했던 모든 클래스의 구현도 고쳐야 하기 때문이다. 자바 8에서는 기본 구현을 포함하는 인터페이스를 정의하는 두 가지 방법을 제공한다. 첫 번째는 인터페이스 내부에 정적 메서드를 사용하는 것이다. 두 번째는 인터페이스의 기본 구현을 제공할 수 있게 디폴트 메서드라는 기능을 사용하는 것이다. 즉 자바 8에서는 메서드 구현을 포함하는 인터페이스를 정의할 수 있다. 결과적으로 기존 인터페이스를 구현하는 클래스는 자동으로 인터페이스에 추가된 새로운 메서드의 디폴트 메서드를 상속받게 된다.

타입을 정의하는 기준은 객체가 외부에 제공하는 퍼블릭 인터페이스(행동)이기 때문에 실제로 타입의 개념을 코드로 옮길 수 있는 다양한 방법이 존재하며 동시에 타입의 구현 방법만큼이나 다양한 방식으로 타입 계층을 구현할 수 있다.

여기서 중요한 것은 어떤 방법을 사용하더라도 타입 계층을 구현했다고 해서 그 안에 들어있는 모든 타입 구현체들이 서브타입과 슈퍼타입의 조건을 만족시키는 것은 아니라는 것이다. 어떤 타입이 다른 타입의 서브타입이 되기 위해서는 구현할 때 리스코프 치환 원칙을 준수해야 한다.

여기서 사용된 방법을 사용해 타입 계층을 구현한다고 하더라도 리스코프 치환 원칙을 준수하지 않는다면 올바른 타입 계층을 구현한 것이 아니다. 만약 그렇다면 코드 재사용서브타이핑을 혼동하고 있는 것은 아닌지 고민해보기 바란다.

참고 및 출처

  • 오브젝트