두 번째 챕터 '객체지향 프로그래밍'에서는 간단한 영화 예매 시스템을 직접 코드로 작성해보면서 객체지향 패러다임에 대해 설명한다.
사용자는 특정한 조건을 만족하면 요금을 할인 받을 수 있으며, 그 조건은 다음과 같다.
할인 조건은 다중 선택이 가능하며, 순서 조건과 기간 조건을 섞는 것이 가능하다. 반면에 할인 정책은 하나만 할당할 수 있다. 또한 할인 정책은 지정하지 않는 것도 가능하다.
대부분의 사람들은 클래스를 결정한 후에 클래스에 어떤 속성과 메서드가 필요한지 고민한다.
이는 아쉽게도 객체지향의 본질과는 거리가 멀다.
진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 객체에 초점을 맞출 때에만 얻을 수 있으며, 이를 위해서는 다음 두 가지에 집중해야 한다.
어떤 클래스가 필요한지 고민하기 전에 어떤 객체들이 필요한지 고민해야 한다.
클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화한 것이다.
따라서 클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 가지는지를 먼저 결정해야 한다.
객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다.
객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고 이 타입을 기반으로 클래스를 구현해야 한다.
훌륭한 협력이 훌륭한 객체를 낳고, 훌륭한 객체가 훌륭한 클래스를 낳는다.
클래스는 내부와 외부로 구분되며, 훌륭한 클래스를 설계하기 위한 핵심은 어떤 부분을 외부에 공개하고 어떤 부분을 내부에 감출지를 결정하는 것이다.
대부분의 객체지향 프로그래밍 언어들은 상태와 행동을 캡슐화하는 것에서 더 나아가 외부에서의 접근을 통제할 수 있는 접근 제어(access control) 매커니즘도 함께 제공한다.
객체지향의 핵심은 스스로 상태를 관리하고, 판단하고, 행동하는 자율적인 객체들의 공동체를 구성하는 것이다.
객체가 자율적으로 움직이도록 하기 위해서는 외부의 간섭을 최소화해야 한다. 외부에서는 객체가 어떤 상태인지 알아서는 안 되며, 그저 스스로 최선의 방법을 결정할 수 있다고 믿고 기다려야 한다.
일반적으로 객체의 상태는 숨기고, 행동만 외부에 공개해야 한다.
객체의 상태는 어떤 행동에 의한 결과라고 볼 수 있다. 이러한 객체의 상태를 외부에서 접근한다면 객체는 스스로 행동할 필요가 없어지고, 이는 곧 객체의 자율성을 무너뜨리는 것이다.
새로운 데이터 타입을 프로그램에 추가하는 클래스 작성자(class creator)와 클래스 작성자가 추가한 데이터 타입을 사용하는 클라이언트 프로그래머(client programmer)가 있다고 생각해보자.
클래스 작성자는 클라이언트 프로그래머에게 필요한 부분만 공개하고, 나머지는 접근하지 못하도록 숨겨야 한다. 이를 구현 은닉(implementation hiding)이라고 부른다.
구현을 숨김으로써, 클라이언트 프로그래머에 대한 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있다. 또한 클라이언트 프로그래머는 인터페이스만 알고 있어도 클래스를 사용할 수 있기 때문에 알아야 하는 지식의 양을 줄일 수 있다.
본 책에서는 영화의 금액을 구현할 때, Long 타입 대신 Money 타입을 직접 만들어서 구현한다.
public class Money {
public static final Money ZERO = Money.wons(0);
private final BigDecimal amount;
public static Money wons(long amount) {
return new Money(BigDecimal.valueOf(amount));
}
public static Money wons(double amount) {
return new Money(BigDecimal.valueOf(amount));
}
private Money(BigDecimal amount) {
this.amount = amount;
}
public Money plus(Money amount) {
return new Money(this.amount.add(amount.amount));
}
public Money minus(Money amount) {
return new Money(this.amount.subtract(amount.amount));
}
public Money times(double percent) {
return new Money(this.amount.multiply(
BigDecimal.valueOf(percent)));
}
public boolean isLessThan(Money other) {
return amount.compareTo(other.amount) < 0;
}
public boolean isGreaterThanOrEqual(Money other) {
return amount.compareTo(other.amount) >= 0;
}
}
Long 타입은 변수의 크기나 연산자의 종류와 관련된 구현 관점의 제약은 표현할 수 있지만, 저장하는 값이 금액과 관련되어 있다는 의미를 전달할 수는 없다.
따라서 금액을 구현할 때 Long 타입을 사용하는 대신 Money 타입을 직접 만들어서 사용한다.
이를 통해 금액과 관련된 로직이 서로 다른 곳에 중복되어 구현되는 것을 막을 수도 있다.
객체지향의 장점은 객체를 이용해 도메인의 의미를 풍부하게 표현할 수 있다는 것임을 기억하자.
아래 Movie 클래스를 살펴보자.
public class Movie {
private String title;
private Duration runnintTime;
private Money fee;
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runnintTime, Money fee, DiscountPolicy discountPolicy) {
this.title = title;
this.runnintTime = runnintTime;
this.fee = fee;
this.discountPolicy = discountPolicy;
}
public Money getFee() {
return fee;
}
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
calculateMovieFee() 메서드는 DiscountPolicy에게 calculateDiscountAmount 메시지를 전송하여 할인 요금을 반환받는다. 이때, 그 어디에서도 어떤 할인 정책을 사용할 것인지 결정하는 코드가 존재하지 않는다.
이를 통해 코드에 상속과 다형성이라는 개념이 숨겨져 있음을 알 수 있다.
할인 정책은 다음과 같이 추상 클래스로 구현하며, 할인 조건에 따라 할인 금액을 계산하는 책임을 가지고 있다.
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);
}
DiscountPolicy는 할인 여부와 요금 계산에 필요한 전체적인 흐름은 정의하지만, 실제로 요금을 계산하는 부분은 추상 메서드인 getDiscountAmount() 메서드에게 위임한다.
이처럼 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고, 중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴을 TEMPLATE METHOD 패턴이라고 부른다.
public class AmountDiscountPolicy extends DiscountPolicy {
private Money discountAmount;
public AmountDiscountPolicy(Money discountAmount, DiscountCondition... conditions) {
super(conditions);
this.discountAmount = discountAmount;
}
@Override
protected Money getDiscountAmount(Screening screening) {
return discountAmount;
}
}
public class PercentDiscountPolicy extends DiscountPolicy {
private double percent;
public PercentDiscountPolicy(double percent, DiscountCondition... conditions) {
super(conditions);
this.percent = percent;
}
@Override
protected Money getDiscountAmount(Screening screening) {
return screening.getMovieFee().times(percent);
}
}
AmountDiscountPolicy와 PercentDiscountPolicy는 모두 부모 클래스인 DiscountPolicy의 getDiscountAmount()를 오버라이딩한다.
하나의 영화에 대해 단 하나의 할인 정책만 설정할 수 있지만, 할인 조건의 경우에는 여러 개를 적용할 수 있다고 했다. Movie와 DiscountPolicy의 생성자는 이런 제약을 강제한다.
Movie의 생성자는 오직 하나의 DiscountPolicy 인스턴스만 허용하지만, DiscountPolicy의 생성자는 여러 개의 DiscountCondition 인스턴스를 허용한다.
이처럼 생성자의 파라미터 목록을 이용해 필요한 정보를 전달하도록 강제하면 올바른 상태를 가진 객체의 생성을 보장할 수 있다.
Movie avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(Money.wons(800),
new SequenceCondition(1),
new SequenceCondition(10),
new PeriodCondition(DayOfWeek.MONDAY, LocalTime.of(10, 0), LocalTime.of(11, 59)),
new PeriodCondition(DayOfWeek.THURSDAY, LocalTime.of(10, 0), LocalTime.of(20, 59))));
클래스 사이의 의존성과 객체 사이의 의존성은 동일하지 않을 수 있다.
Movie와 DiscountPolicy의 구조를 보면 이해하기 더 쉬울 것이다.

위 그림과 Movie 코드를 보면, 컴파일 시점에 Movie가 DiscountPolicy를 의존한다는 것을 알 수 있다.
하지만 실제로 애플리케이션이 실행되면 Movie는 AmountDiscountPolicy 또는 PercentDiscountPolicy를 의존하게 된다.
이처럼 컴파일 시간 의존성과 실행 시간 의존성이 다르다는 사실은 확장 가능한 객체지향 설계가 가지는 특징 중 하나다.
코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드를 이해하기 어려워지는 대신, 더 유연해지고 확장 가능해진다.
따라서 훌륭한 객체지향 설계를 위해서는 항상 유연성과 가독성 사이에서 고민해야 한다.
위에서 설명한 것처럼 Movie는 DiscountPolicy에게 메시지를 전송하지만, 실행 시점에 실제로 실행되는 메서드는 Movie와 협력하는 객체의 실제 클래스가 무엇인지에 따라 달라지는 것을 다형성이라고 부른다.
다형성은 컴파일 시간 의존성과 실행 시간 의존성을 다르게 만들 수 있는 객체지향의 특성을 이용해 서로 다른 메서드를 실행할 수 있게 한다.
다형성이란 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력을 의미한다.
따라서 다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야 한다. 즉, 인터페이스가 동일해야 한다.
앞에서 살펴본 예제와 달리 구현은 필요가 없고 순수하게 인터페이스만 공유하고 싶은 경우를 위해 자바에서는 인터페이스라는 프로그래밍 요소를 제공한다.
추상 클래스를 이용해 다형성을 구현했던 할인 정책과 달리 할인 조건은 구현을 공유할 필요가 없기 때문에 인터페이스를 이용해 타입 계층을 구현한다.
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
public class SequenceCondition implements DiscountCondition {
private int sequence;
public SequenceCondition(int sequence) {
this.sequence = sequence;
}
@Override
public boolean isSatisfiedBy(Screening screening) {
return screening.isSequence(sequence);
}
}
public class PeriodCondition implements DiscountCondition {
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
public PeriodCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
this.dayOfWeek = dayOfWeek;
this.startTime = startTime;
this.endTime = endTime;
}
@Override
public boolean isSatisfiedBy(Screening screening) {
return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
}
}
DiscountPolicy와 DiscountCondition은 둘 다 같은 계층에 속하는 클래스들이 공통으로 가질 수 있는 인터페이스를 정의하며, 구현의 일부(abstract class인 경우) 또는 전체(interface인 경우)를 자식 클래스가 결정할 수 있도록 결정권을 위임한다.
아래는 자식 클래스를 생략한 코드 구조를 그림으로 표현한 것이다.

이 그림을 통해 추상화의 두 가지 장점을 알 수 있다.
추상화를 이용해 상위 정책을 기술한다는 것은 기본적인 애플리케이션의 협력 흐름을 기술한다는 것을 의미한다.
영화의 예매 가격을 계산하기 위한 흐름은 항상 Movie에서 DiscountPolicy로, 그리고 다시 DiscountCondition을 향해 흐른다. 할인 정책이나 할인 조건의 새로운 자식 클래스들은 추상화를 이용해서 정의한 상위의 협력 흐름을 그대로 따르게 된다.
재사용 가능한 설계의 기본을 이루는 디자인 패턴이나 프레임워크 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 매커니즘을 활용하고 있다.
이러한 사실을 통해 추상화가 얼마나 강력한지를 간접적으로 느낄 수 있다.
추상화를 이용하면 왜 설계가 좀 더 유연해진다는걸까?
이번에는 앞에서 살펴본 것과 조금 다른 Movie 클래스를 살펴보자.
public class Movie {
...
public Money calculateMovieFee(Screening screening) {
if (discountPolicy == null) {
return fee;
}
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
영화는 하나의 할인 정책을 할당할 수도, 할당하지 않을 수도 있다고 했다. 만약 위와 같이 할인 정책이 없는 영화의 경우, 이를 예외 케이스로 취급하기 때문에 지금까지 일관성 있던 협력 방식이 무너지게 된다는 문제점이 존재한다.
기존에는 할인 금액을 계산하는 책임이 DiscountPolicy의 자식 클래스에 있었지만, 할인 정책이 없는 경우에는 할인 금액이 0원이라는 사실을 결정하는 책임이 Movie에 있기 때문이다.
책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 대부분의 경우 좋지 않은 선택이다. 항상 예외 케이스를 최소화하고 일관성을 유지할 수 있는 방법을 선택하자.
일관성을 지키기 위해 0원이라는 할인 요금을 계산할 책임을 그대로 DiscountPolicy 계층에 유지시켜야 하는데, 이를 위해서 NoneDiscountPolicy라는 새로운 클래스를 추가하자.
public class NoneDiscountPolicy extends DiscountPolicy {
@Override
protected Money getDiscountAmount(Screening screening) {
return Money.ZERO;
}
}
이제 할인 정책이 없는 경우 Movie가 할인 금액을 결정하는 책임을 가지지 않아도 된다.
이처럼 기존의 클래스는 수정하지 않고 새로운 클래스를 추가하는 것만으로도 애플리케이션의 기능을 확장할 수 있다는 것을 기억하자.
지금까지 추상 클래스와 인터페이스가 무슨 차이를 가지는지, 어떤 상황에서 무엇을 사용해야 하는지 감이 잘 잡히지 않았는데, 이번 챕터를 읽고나서 조금은 감이 잡힌듯 하여 간결하게 정리해본다.
추상 클래스와 인터페이스는 둘 다 추상 메서드를 가지며, 자식 클래스가 이를 구현할 수 있다.
여기까지만 보면 아직 둘의 차이를 느낄 수 없다.
그러나 추상 클래스는 인터페이스와 달리 일반 메서드 또한 가질 수 있다.
따라서 자식 클래스들 중 몇몇은 내부 구현이 동일하다면 추상 클래스를 사용하고, 자식 클래스들의 내부 구현이 모두 다르다면 인터페이스를 사용하자.
만약 "이럴거면 그냥 추상 클래스만 사용하면 되는 것이 아닌가?" 하는 의문이 들었다면, 추상 클래스는 한 번만 상속이 가능하고 인터페이스는 다중 구현이 가능하다는 사실을 되짚어보자.
앞에서 살펴본 Movie가 DiscountPolicy의 코드를 재사용하는 방법은 합성이라는 개념을 사용한 것이다.
합성(Composition)
다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법
이 설계를 상속을 사용하도록 변경할 수도 있지만, 그럼에도 많은 사람들이 상속보다는 합성을 선호한다.
그 이유는 무엇일까?
상속은 캡슐화를 위반한다.
상속을 이용하기 위해서는 자식 클래스가 부모 클래스의 내부 구조를 잘 알고 있어야 하는데, 이는 결과적으로 부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화된다.
캡슐화의 약화는 자식 클래스가 부모 클래스에 강하게 결합되도록 만들기 때문에 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률을 높인다.
설계를 유연하지 못하게 만든다.
상속은 부모 클래스와 자식 클래스의 관계를 컴파일 시점에 결정하기 때문에 실행 시점에 객체의 종류를 변경하는 것이 불가능하다.
Movie는 요금을 계산하기 위해 DiscountPolicy의 코드를 재사용한다. 이 방법은 부모 클래스와 자식 클래스를 강하게 결합하는 상속에 비해 Movie가 DiscountPolicy의 인터페이스를 통해 약하게 결합된다는 장점을 가지고 있다.
이처럼 합성은 상속이 가지는 두 가지 문제점을 모두 해결한다.
이처럼 코드를 재사용하는 경우에는 상속보다 합성을 선호하는 것이 옳다.
하지만 다형성을 위해 인터페이스를 재사용하는 경우에는 상속과 합성을 함께 조합해서 사용할 수밖에 없다.
나는 객체지향의 사실과 오해라는 책을 통해 객체지향의 본질에 대한 개념, 즉 객체지향은 메시지를 통한 협력이라는 사실을 머리에 집어넣을 수 있었다.
하지만 여전히 객체지향의 4대 요소 캡슐화, 상속, 추상화, 다형성을 실제로 적용하기란 굉장히 어려웠다.
이런 내게 이번 장은 4개의 개념을 적용하는 방법과 객체지향 프로그래밍이란 무엇인지를 제대로 알려주었다.
특히 추상 클래스와 인터페이스를 구분하기 어려워했는데, 이를 개념적으로나마 극복해낼 수 있었던 것 같다.