티스토리 뷰

단순히 코드를 재사용할 목적으로 상속을 사용해서는 안된다. 재사용을 위해 상속을 사용할 경우 부모 클래스와 자식 클래스가 강하게 결합된다. 상속을 사용하는 일차적인 목표는 코드 재사용이 아니라 타입 계층을 구현하는 것이어야 한다.

코드 재사용은 상속이 아닌 합성을 사용하는 게 올바르다.

💡 객체지향 프로그래밍과 객체 기반 프로그래밍
객체 기반 프로그래밍이란 상태와 행동을 캡슐화한 객체를 조합해서 프로그램을 구성하는 방식을 가리킨다. 이 정의에 따르면 객체지향 프로그래밍 역시 객체 기반 프로그래밍의 한 종류다. 단 객체 기반 프로그래밍은 상속과 다형성을 지원하지 않는다.

타입


먼저, 프로그래밍 언어 관점에서의 타입과 개념 관점에서의 타입에 대해 알아보자.

개념 관점의 타입

개념 관점에서 타입은 우리가 인지하는 세상의 사물의 종류를 의미한다. 타입은 사물을 분류하기 위한 틀로 사용될 수 있다. 예를 들어, 자바, 루비, 자바스크립트, C를 프로그래밍 언어라 부를 때 우리는 이것들을 프로그래밍 언어라는 타입으로 분류하고 있는 것이다.

  • 심볼(Symbol) : 타입에 이름을 붙인 것. 앞에서 ‘프로그래밍 언어’가 타입의 심볼에 해당
  • 내연(Intension) : 타입의 정의. 타입에 속하는 객체들이 가지는 공통적인 속성이나 행동을 가리킴. ‘프로그래밍 언어’의 정의인 ‘컴퓨터에게 특정한 작업을 지시하기 위한 어휘와 문법적 규칙의 집합’이 내연
  • 외연(Extension) : 타입에 속하는 객체들의 집합. ‘프로그래밍 언어’ 타입의 경우에는 자바, 루비, 자바스크립트, C가 속한 집합이 외연.

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

프로그래밍 언어의 관점에서 타입은 비트 묶음에 의미를 부여하기 위해 정의된 제약과 규칙을 가리킨다. 프로그래밍 언어에서 타입은 아래의 두 가지 목적을 위해 사용된다.

  • 타입에 수행될 수 있는 유효한 오퍼레이션의 집합을 정의 - 타입에 따라 적용 가능한 연산자의 종류를 제한
  • 타입에 수행되는 오퍼레이션에 대해 미리 약속된 문맥을 제공 - 객체의 타입에 따라 문맥이 바뀜

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

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

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

 

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

객체지향 패러다임의 관점에서 객체의 타입이란 객체가 수신할 수 있는 메시지의 종류를 정의하는 것이다. 이것이 바로 퍼블릭 인터페이스이다. 다시 말해, 객체지향 프로그래밍에서 타입을 정의하는 것은 객체의 퍼블릭 인터페이스를 정의하는 것과 동일하다.

타입 계층


타입 사이의 포함관계

타입은 객체들의 집합이기 때문에 다른 타입을 포함하는 것이 가능하다. 타입 안에 포함된 객체들을 좀 더 상세한 기준으로 묶어 새로운 타입을 정의하면 이 새로운 타입은 자연스럽게 기존 타입의 부분집합이 된다. 즉, 타입 안에 정의된 객체들을 더 특수하고 세부적인 정의로 나누는 것이 가능하다. 

더 일반적인 타입을 슈퍼 타입, 더 세분화되고 특수한 타입을 서브타입이라고 한다. 일반화는 타입의 정의를 좀 더 보편적이고 추상적으로 만드는 과정. 특수화는 타입의 정의를 좀 더 구체적이고 문맥 종속적으로 만드는 과정을 의미한다.

슈퍼 타입

  • 집합이 다른 집합의 모든 멤버를 포함된다.
  • 타입 정의가 다른 타입보다 좀 더 일반적이다.

서브타입

  • 집합에 포함되는 인스턴스들이 더 큰 집합에 포함된다.
  • 타입 정의가 다른 타입보다 좀 더 구체적이다.

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

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

서브 타입 - 슈퍼 타입이 정의한 퍼블릭 인터페이스를 특수화시켜 상대적으로 구체적이고 좁은 의미로 정의한 것

 

객체지향 프로그래밍에서 일반적인 타입과 구체적인 타입 간의 관계를 형성하는 기준은 ‘퍼블릭 인터페이스’다. 일반적인 타입이란, 비교하려는 타입에 속한 객체들의 퍼블릭 인터페이스보다 더 일반적인 퍼블릭 인터페이스를 가지는 객체들의 타입을 의미한다. 특수한 타입이란, 비교하려는 타입에 속한 객체들의 퍼블릭 인터페이스보다 더 특수한 퍼블릭 인터페이스를 가지는 객체들의 타입을 의미한다.

 

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

서브 클래싱과 서브 타이핑


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

언제 상속을 사용해야 할까?

is-a관계

마틴 오더스키의 말에 따르면 두 클래스가 어휘적으로 is - a 관계를 모델링할 경우에 상속을 사용해야 한다고 한다. 예를 들면 어떤 타입 S가 다른 타입 T의 일종이라면 당연히 "타입 S는 타입 T다( S is-a T )"라고 말할 수 있어야 한다. 하지만 is - a 관계가 생각처럼 직관적이고 명쾌한 것은 아니다. 새와 펭귄을 예로 들면 is - a 관계가 직관을 쉽게 배신할 수 있다는 사실을 알 수 있다.

 

  • 펭귄은 새다.
  • 새는 날 수 있다.
public class Bird {
	public void fly() { ... }
	...
}

public class Penguin extends Bird {
	...
}

이 코드는 분명히 “펭귄은 새고, 따라서 날 수 있다”라고 한다. 하지만 펭귄은 날 수 없는 새다. 이 예는 어휘적인 정의가 아니라 기대되는 행동에 따라 타입 계층을 구성해야 한다는 사실을 보여준다. 슈퍼 타입과 서브타입 관계에서는 is - a 보다 행동 호환성이 더 중요하다.

행동 호환성

타입의 이름 사이에 개념적으로 연관성이 있어도, 행동에 연관성이 없다면 is - a 관계를 사용하지 말아야 한다. 두 타입 사이에 행동이 호환될 경우에만 타입 계층으로 묶어야 한다는 것이다. 중요한 것은 행동의 호환 여부를 판단하는 기준은 클라이언트 관점이라는 것이다. 클라이언트가 두 타입 모두 동일하게 행동할 것이라고 기대한다면 두 타입을 타입 계층을 묶을 수 있다. 

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

행동 호환성을 만족시키지 않는 상속 계층을 유지한 채 클라이언트의 기대를 충족시키기는 어렵다. 따라서, 클라이언트의 기대에 맞게 상속 계층을 분리해야 한다.

날 수 있는 새와 날 수 없는 새를 명확하게 구분하여 상속 계층 분리

이제 FlyingBird 타입의 인스턴스만이 fly 메시지를 수신할 수 있다. 날 수 없는 Bird의 서브 타입인 Penguin의 인스턴스에게 fly 메시지를 전송할 수 있는 방법은 없다.

행동 호환성 문제를 해결하는 다른 방법은 클라이언트에 따라 인터페이스를 분리하는 것이다. 만약 Bird가 날 수 있으면서 걸을 수도 있어야 하고, Penguin은 오직 걸을 수 만 있다고 가정하자. 가장 좋은 방법은 fly 오퍼레이션을 가진 Flyer 인터페이스와 walk 오퍼레이션을 가진 Walker 인터페이스로 분리하는 것이다.

클라이언트의 기대에 따른 인터페이스 분리

이제 Bird와 Penguin은 자신이 수행할 수 있는 인터페이스만 구현할 수 있다. 만약 Penguin이 Bird의 코드를 재사용해야 한다면 어떻게 해야 할까? Penguin이 Bird를 상속받으면 Penguin의 퍼블릭 인터페이스에 fly 오퍼레이션이 추가되기 때문에 사용할 수 없다. 게다가 재사용을 위한 상속은 위험하다. 더 좋은 방법은 합성을 사용하는 것이다.

합성을 이용한 코드 재사용

클라이언트에 따라 인터페이스를 분리하면 변경에 대한 영향을 더 세밀하게 제어할 수 있다. 이처럼 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의한 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙(Interface Segregation Principle, ISP)라고 부른다.

💡 인터페이스 분리 원칙(Interface Segregation Principle, ISP)
이 원칙은 ‘비대한’ 인터페이스의 단점을 해결한다. 비대한 인터페이스를 가지는 클래스는 응집성이 없는 인터페이스를 가지는 클래스다. 즉, 이런 클래스의 인터페이스는 메서드의 그룹으로 분해될 수 있고, 각 그룹은 각기 다른 클라이언트 집합을 지원한다. 이렇게 하면 호출하지 않는 메서드에 대한 클라이언트의 의존성을 끊고, 클라이언트가 서로에 대해 독립적이 되게 만들 수 있다.

서브 클래싱과 서브 타이핑

그래서 언제 상속을 사용해야 하는가? 어떤 상속이 올바른 상속이고, 어떤 상속이 올바르지 않은 상속인가? 먼저, 상속이 두 가지 목적을 위해 사용된다는 사실을 이해해야 한다.

  • 코드 재사용
  • 타입 계층 구성

위 두 가지 목적은 서브 클래싱과 서브 타이핑이라고 불린다.

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

상속은 서브 클래싱보다는 서브 타이핑 관계를 구현하기 위해 사용하여야 한다. 서브 타이핑 관계가 유지되기 위해서는 서브타입이 슈퍼 타입이 하는 모든 행동을 동일하게 할 수 있어야 한다. 즉, 어떤 타입이 다른 타입의 서브타입이 되기 위해서는 행동 호환성을 만족시켜야 한다. 또한, 행동 호환성을 만족하는 상속 관계는 부모 클래스를 새로운 자식 클래스로 대체하더라도 시스템이 문제없이 동작한다는 것을 보장해야 한다. 즉, 행동 호환성은 대체 가능성(substitutability)을 포함한다.

리스코프 치환 원칙


리스코프 치환 원칙은 “서브타입은 그것의 기반 타입에 대해 대체 가능해야 한다”는 것이다. 다시 말하면, 클라이언트가 “차이점을 인식하지 못한 채 기반 클래스의 인터페이스를 통해 서브클래스를 사용할 수 있어야 한다”는 것이다.

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

is - a 관계 다시 살펴보기

is - a 관계는 객체지향에서 중요한 것은 객체의 속성이 아니라 객체의 행동이다. 기억하자 이름이 아니라 행동이 먼저다. 객체지향과 관련된 대부분의 규칙이 그런 것처럼 is - a 관계 역시 행동이 우선이다.

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

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


클라이언트와 서버 사이의 협력을 의무와 이익으로 구성된 계약의 관점에서 표현하는 것을 계약에 의한 설계(Design By Contract, DBC)라고 부른다. 계약에 의한 설계는 아래의 세 가지 요소로 구성된다.

  • 클라이언트가 정상적으로 메서드를 실행하기 위해 만족시켜야 하는 사전 조건(PreCondition)
  • 메서드가 실행된 후에 서버가 클라이언트에게 보장해야 하는 사후 조건(PostCondition)
  • 메서드 실행 전과 실행 후에 인스턴스가 만족시켜야 하는 클래스 불변식(Class Invariant)

서브타입이 리스코프 치환 원칙을 만족시키기 위해서는 클라이언트와 슈퍼 타입 간에 체결된 계약을 준수해야 한다. 클라이언트의 입장에서 서브타입은 슈퍼 타입의 한 종류여야 한다. 만약 상속을 받지만 부모 클래스를 대체할 수 없다면 서브클래스가 서브타입이 아닐 수 있다.

서브타입과 계약

계약의 관점에서 상속이 초래하는 큰 문제는 자식 클래스가 부모 클래스의 메서드를 오버라이딩 할 수 있다는 것이다.

자식 클래스가 부모 클래스의 서브타입이 되기 위해서는 다음의 조건들을 만족시켜야 한다.

  • 서브타입에 더 강력한 사전 조건을 정의할 수 없다.
  • 서브타입에 슈퍼 타입과 같거나 더 약한 사전 조건을 정의할 수 있다.
  • 서브타입에 슈퍼 타입과 같거나 더 강한 사후 조건을 정의할 수 있다.
  • 서브타입에 더 약한 사후 조건을 정의할 수 없다.

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

댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday