상속의 용도는 크게 2가지라 볼 수 있다.
코드 재사용은 부모-자식 간 높은 결합도로 인해 변경하지 어려운 코드가 된다. 상속은 코드 재사용이 아닌 타입 계층을 구현하는데 사용하자.
동일한 메시지에 대해 서로 다르게 행동할 수 있는 다형적 객체를 구현해야 한다. 이를 위해선 객체의 행동을 기반으로 타입 계층을 구성해야 한다. 상속은 이러한 타입 계층을 구현할 때 유용하다.
의미
사물의 종류 (e.g. 프로그래밍 언어)
인스턴스
어떤 대상이 타입으로 분류될 때 그 대상 (e.g. Java, C++)
일반적으로 타입의 인스턴스를 객체라 부른다.
구성
의미
연속적인 비트에 의미와 제약을 부여
(비트 자체는 타입이라는 개념이 없다. 비트에 담긴 데이터를 문자열로 다룰지 정수로 다룰지는 전적으로 어플리케이션에서 결정한다.)
목적
a + b
-> String
, int
여부에 따라 +
연산자의 문맥이 제공된다.)타입은 적용 가능한 오퍼레이션의 종류와 의미를 정의함으로써 코드의 의미를 명확하게 전달하고 개발자의 실수를 방지하기 위해 사용된다 볼 수 있다.
오퍼레이션
객체가 수신할 수 있는 메시지
의미
객체가 수신할 수 있는 메시지의 종류 (퍼블릭 인터페이스)를 정의하는 것
즉, 객체지향에서 타입은 퍼블릭 인터페이스를 정의하는 것이라 볼 수 있으며 동일한 퍼블릭 인터페이스를 제공하는 객체들은 동일한 타입으로 분류됨을 알 수 있다.
어떤 객체들이 동일한 상태를 가져도 퍼블릭 인터페이스가 다르면 서로 다른 타입으로 분류된다.
타입 계층을 구성하는 타입은 크게 2가지로 나뉜다.
중요
일반적인 타입과 구체적인 타입간의 관계를 형성하는 기준은 퍼블릭 인터페이스이다.
서브클래싱
이 둘은 상속을 사용하는 목적에 따라 구분이 된다. 재사용을 목적으로 하면 서브클래싱이며 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용할 목적일아면 서브타이핑이다. 상속이 안좋다고 하는 상황은 서브클래싱에 속한다.
상속 관계가 is-a 관계를 모델링하는가?
상속 관계가 is-a 관계를 모델링한다고 했다. 여기서 is-a 관계는 행동에 초점을 둬야 하며 클라이언트 관점에서 말하는 것이다. 다음 문장을 살펴보자.
e.g.
앞서 is-a 관계에서 행동의 초점을 클라이언트 입장에서 바라봐야 한다고 했다. 이 말은 즉 클라이언트 입장에서 부모 클래스 타입으로 자식 클래스를 사용해도 무방한가?를 뜻한다. 클라이언트는 자식-부모 인스턴스의 차이점을 몰라야 한다.
Penguin
클래스는 현재 Bird
클래스의 자식 클래스다. 따라서, Bird
의 fly()
메서드를 오버라이딩해야 된다. 바꿔말하면 사용자 입장에서 Penguin
객체에 fly()
메서드를 호출하는게 당연하다는 뜻이다. 그러나 설계는 그렇지 않다.
펭귄은 날 수 없으므로 fly()
메서드를 오버라이딩하는게 어색하다. "행동"에 초점을 두지 않아 발생한 문제다. 이렇게 부모-자식 클래스간 행동이 호환 여부를 행동 호환성이라 하며 지금 상황은 행동 호환성을 만족하지 못하므로 올바른 타입 계층이라 볼 수 없다.
이를 극복하기 위한 방법으로 크게 2가지가 있다.
fly()
메서드 내부 구현 비우기fly()
호출 시 예외 발생시키기두 방법 모두 클라이어트 입장에서 기대한 결과일까? 전혀 아니다.
추가로 instanceof
를 활용하는 방법이 있다. 그러나 이는 확장에 불리하고 구체적인 클래스에 대한 결합도를 높인다. 이처럼 객체의 타입을 확인하는 코드는 새로운 타입을 추가할 때 마다 코드 수정을 요구하므로 OCP를 위반한다. 가급적이면 사용하지말자.
앞서 Bird
의 자식 클래스로 Penguin
을 두면 다양한 문제가 발생했다. 크게 2가지 방법으로 계층을 분리하여 이 문제들을 해결해보자.
1. 클라이언트의 기대에 따라 상속 계층을 분리
Penguin
인스턴스에 fly
메시지를 전송할 수 있는 방법이 없으므로 앞서 언급한 문제들이 해결된다. 만약, 펭귄은 걸을 수 있다고 가정하면 어떨까? Penguin
에 walk()
라는 메서드를 구현해야 한다.
결과적으로 행동을 기반으로 계층을 분리해야 클라이언트 기대에 맞는 설계가 가능해진다.
2. 클라이언트의 기대에 따른 인터페이스 분리
특정 클라이언트가 walk()
라는 메시지를 전송하길 원하면 이 클라이언트에게는 walk()
라는 메시지만 보여야 한다. 가장 좋은 방법은 이처럼 오퍼레이션을 기준으로 인터페이스를 분리하는 것이다. 덕분에 Bird
와 Penguin
은 독립적으로 자신이 수행할 수 있는 인터페이스만 구현할 수 있다.
Peuguin
에서 Bird
의 코드를 재사용하는 경우엔 1번이 유리해보인다. 그러나, 이는 Penguin
내부 규칙을 와해한다. 상속을 재사용 목적으로 사용하면 안된다는 걸 다시 한 번 명심하자. 재사용은 2번을 사용 + 합성으로 충분히 대체가 가능하다.
대부분의 경우 불안정한 상속 계층을 계속 껴안고 가는 것보단 Bird
를 재사용 가능하도록 수정하는 것이 더 좋은 방법이다.
클라이언트에 따라 인터페이스를 분리하면 변경에 대한 영향을 더 세밀하게 제어할 수 있다. 대부분의 경우 인터페이스는 클라이언트의 요구가 바뀜에 따라 변경된다.
2번 그림에서 Client1
의 기대가 바뀌어 Flyer
인터페이스 변경이 발생했다 생각해보자. 이는 Flyer
를 의존하는 Bird
가 영향을 받지만 다른 클래스나 클라이언트는(Penguin
, Client2)는 영향을 받지 않는다.
이처럼 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙(Interface Segregation Principle, ISP)라고 한다. 이는 비대한 인터페이스를 특화된 여러 개의 인터페이스로 나눠 변경을 세밀하게 관리함으로써 설계를 더 유연하게 한다.
서브타이핑의 관계가 유지되기 위해서는 서브타입이 슈퍼타입이 하는 모든 행동을 동일하게 할 수 있어야 한다. 이를 위해선 행동 호환성을 만족해야 한다. 더 나아가, 부모 클래스가 사용되는 문맥에 자식 클래스가 동일하게 사용될 수 있어야 한다.
다시 말해 자식-부모 클래스간 행동 호환성은 부모 클래스에 대한 자식 클래스이 대체 가능성을 포함한다.
서브타입은 그것의 기반 타입(부모 클래스)에 대해 대체 가능해야 한다.
(이 때, 대체 가능 여부를 결정하는 건 클라이언트이다.)
클라이언트가 차이점을 인식하지 못한 채 기반 클래스의 인터페이스를 통해 서브 클래스(자식 클래스)를 사용할 수 있어야 한다. 즉, 부모 클래스에 대한 클라이언트의 가정을 자식 클래스가 준수해야 한다는 것이다. 자식 클래스가 부모 클래스를 대체할 수 있을 때 상속을 사용해야 한다.
Stack, Vector
Vector
에 대해 기대하는 행동을 Stack
에 대해서는 기대할 수 없다. (행동 호환성 만족 X)add()
, get()
)를 자식 클래스에 포함Penguin, Bird
Penguin
은 Bird
의 행동(fly()
)을 기대할 수 없다.Rectangle, Square
Rectangle
과 협력하는 클라이언트는 직사각형의 너비와 높이가 다르다고 가정한다.Squre
(정사각형)에서 호환되지 않는다.public void resize(Rectangle rectangle, int width, int height) {
rectangle.setWidth(width);
rectangle.setHeight(height);
}
LSP는 클라이언트가 어떤 자식 클래스와도 안정적으로 협력할 수 있는 상속 구조 구현 가이드라인을 제공하여 유연성과 확장성을 높인다. 아래 예제는 DIP, LSP, OCP를 모두 준수하는 코드이다. 이를 통해 LSP가 어떻게 OCP를 준수하게 유도하는지 살펴보자.
OverlappedDiscountPolicy.java
public class OverlappedDiscountPolicy extends DiscountPolicy {
private List<DiscountPolicy> discountPolicies = new ArrayList<>();
public OverlappedDiscountPolicy(DiscountPolicy ... discountPolicies) {
this. discountPolicies = Arrays.asList(discountPolicies);
}
@Override
protected Money getDiscountAmount(Screening screening) {
Money result = Money.ZERO;
for(DiscountPolicy each : discountPolicies) {
result = result.plus(each.calculateDiscountAmount(screening));
}
return result;
}
}
DIP
Movie
, XXXDiscountPolicy
모두 추상체(DiscountPolicy
)에 의존Movie
, 하위 모듈 XXXDiscountPolicy
모두 추상체(DiscountPolicy
)에 의존LSP
Movie
(클라이언트)와 XXXDiscountPolicy
는 모두 협력 가능XXXDiscountPolicy
는 클라이언트 영향 없이 DiscountPolicy
대체 가능OCP
Movie
(클라이언트)를 수정하지 않고 DiscountPolicy
구현체를 추가함으로써 기능 추가 가능즉, LSP는 OCP 만족하기 위한 잠재적 조건이라 볼 수 있다. 바꿔말하면 LSP 위반시 OCP 위반 가능성이 높아진다.
클라이언트-서버 사이의 협력을 의무와 이익으로 구성된 계약의 관점으로 표현하는 것을 뜻한다.
e.g. 사전/사후 조건
Movie.java
public class Movie {
...
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
DiscountPolicy.java
public abstract class DiscountPolicy {
private List<DiscountCondition> conditions = new ArrayList<>();
public DiscountPolicy(DiscountCondition ... conditions) {
this.conditions = Arrays.asList(conditions);
}
public Money calculateDiscountAmount(Screening screening) {
for(DiscountCondition each : conditions) {
if (each.isSatisfiedBy(screening)) {
return getDiscountAmount(screening);
}
}
return Money.ZERO;
}
abstract protected Money getDiscountAmount(Screening Screening);
}
Movie.caculateMovieFee(Screening screening)
을 기준으로 사전/사후 조건을 살펴보자.
사전 조건
screening != null && screening.getStartTime().isAfter(LocalDateTime.now());
사후 조건
amount != null && amount.isGreaterThanOrEqaul(Money.ZERO);
서브 타입 ⊂ 서브 클래스
클라이언트 입장에서 서브 타입은 슈퍼 타입의 한 종류여야 한다. 이는 목적에 따라 구분하며 재사용 목적이라면 서브 타입이라할 수 없다.
LSP는 어떤 타입이 서브 타입이 되기 위해선 슈퍼 타입의 인스턴스와 협력하는 클라이언트 관점에서 서브 타입의 인스턴스가 슈퍼타입을 대체해도 지장이 없어야 함을 의미한다.
계약의 관점에서 상속의 가장 큰 문제점은 오버라이딩이다. 이 때 반드시 준수해야 되는 규칙이 있다.
(더 강한 사후 조건) ⊂ (사후 조건) 이므로 클라이언트 입장에서 응답값이 변질되지 않는다. 그러나, 사전 조건이 더 강해지면 파라미터 문맥이 제한되므로 클라이언트 입장에서 사용이 이전과 달라진다. 이는 LSP를 위반한다고 볼 수 있다.