소프트웨어는 사용자가 원하는 어떤 문제를 해결하기 위해 만들어진다.
이처럼 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야를 도메인이라 부른다.
객체지향의 장점은 객체를 이용해 도메인의 의미를 풍부하게 표현할 수 있다는 것이다.
따라서 의미를 좀 더 명시적이고 분명하게 표현할 수 있다면 객체를 사용해 해당 개념을 구현하면 된다.
그 개념이 비록 하나의 인스턴스 변수만 포함하더라도 개념을 명시적으로 표현하는 것은 전체적인 설계의 명확성과 유연성을 높인다.
객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메시지를 전송하는 것뿐이다.
다른 객체에 요청이 도착할 때 해당 객체가 메시지를 수신했다고 이야기한다.
메시지를 수신한 객체는 스스로의 결정에 따라 자율적으로 메시지를 처리할 방법을 결정하고 그 방법을 메서드라고 부른다.
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);
}
위처럼 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에 위임하는 디자인 패턴을 Template Method 패턴이라고 부른다.
위의 코드를 보면 getDiscountAmount
에 대한 처리를 자식 클래스에 위임하는 것이다.
하지만 이를 활용하는 calculateDiscountAmount
는 부모 클래스에서 구현하는 것이다.
상속을 사용하게 되면 코드의 의존성과 실행 시점의 의존성이 서로 달라질 수 있다.
이는 코드를 이해하기 어렵게 만들지만, 코드는 더 유연해지고 확장 가능해진다.
상속이 가치 있는 이유는 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문이다.
결과적으로 자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신할 수 있기 때문에 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있다. (그렇기에 역활이 클래스를 결정한다는 것이다.)#### 다형성
메시지를 수신받았을 때 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라지는데 이를 다형성이라 한다.
즉 클래스의 인터페이스가 동일해야 하는데 인터페이스를 통일하기 위해 사용한 구현 방법이 상속인 것이다.
또 다형성은 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실을 기반으로 한다.
다형성을 구현하는 방법은 메시지에 응답하기 위해 실행될 메서드를 컴파일 시점이 아닌 실행 시점에 결정하는 것이다.
즉, 메시지와 메서드를 실행 시점에 바인딩한다.
이를 지연 바인딩 또는 동적 바인딩이라 부른다.
설계할 때 고민해야 할 부분일 것 같아 조금 더 알아보려 한다.
public class Movie {
public Money calculateMovieFee(Screening screening) {
if (discountPolicy == null) {
return fee;
}
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
사실 위의 코드가 어떠한 문제를 가졌는지 처음에는 몰랐다.
이 코드의 문제는 기존 할인 정책의 경우에는 할인할 금액을 계산하는 책임이 DiscountPolicy의 자식 클래스에 있었지만 할인 정책이 없는 경우에만 할인 금액이 0원이라는 사실을 결정하는 책임이 DiscountPolicy가 아닌 Movie 쪽에 생긴다. (if (discountPolicy == null) { ... }
)
따라서 책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 대부분의 경우에서 좋지 않은 선택이라 한다.
항상 예외 케이스를 최소화하고 일관성을 유지할 방법을 선택하여야 한다.
책에서는 그 방법의 하나를 NoneDiscountPolicy 클래스를 추가하는 것을 제시한다.
public class NoneDiscountPolicy extends DiscountPolicy {
@Override
protected Money getDiscountAmount(Screening screening) {
return Money.Zero;
}
}
이렇게 되면 DiscountPolicy의 getDiscountAmount가 아무런 의미가 없어진다.
그렇기에 추가로 변경이 따른다.
우선 기존의 DiscountPolicy를 인터페이스로 변경한다.
public interface DiscountPolicy {
Money calculateDiscountAmount(Screening screening);
}
그리고 기존의 DiscountPolicy를 DefaultDiscountPolicy로 변경하고 인터페이스를 구현한다.
NoneDiscountPolicy도 DiscountPolicy를 구현하게 한다면 개념적인 혼란과 결합을 제거할 수 있다.
합성은 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법을 말한다.
상속은 객체지향에서 코드를 재사용하기 위해 널리 사용되는 기법이지만 캡슐화를 위반하고 설계를 유연하지 못하게 하는 단점이 있다.
그렇다면 상속과 다른 점은 무엇일까?
상속이 부모 클래스의 코드와 자식 클래스의 코드를 컴파일 시점에 하나의 단위로 강하게 결합하는 데 비해 합성은 인터페이스를 통해 약하게 결합한다.
인터페이스에 정의된 메시지를 통해서만 코드를 재사용하는 방법을 합성이라 한다.
합성은 상속이 가지는 두 가지 문제를 모두 해결한다.
인터페이스에 정의된 메시지를 통해서만 재사용할 수 있기 때문에 구현을 효과적으로 캡슐화할 수 있다.
또한 의존하는 인스턴스를 교체하는 것이 비교적 쉽기 때문에 설계를 유연하게 만든다.
상속은 클래스를 통해 강하게 결합하는 데 비해 합성은 메시지를 통해 느슨하게 결합한다.
그렇다면 "합성을 처음 설계할 때부터 생각할 수 있을까?" 하는 생각이 든다.
처음 생각할 수 있는 설계는 위와 같고
이후에나 위와 같은 설계를 생각할 수 있지 않을까? 하는 생각이 든다.
그렇다면 상속에서 확장으로 설계를 변경해야 하는 경우는 언제일까?
그 판단을 어떤 기준으로 할 수 있을지 궁금하다.