개요
온라인 영화 예매 시스템을 통해 올바르게 된 객체지향 설계가 무엇인지, 이 책을 읽으면서 이해하게 될 다양한 주제들을 먼저 간단하게 살펴보자.
요구사항
요구사항 1
사용자는 영화 예매 시스템을 이용해 보고 싶은 영화를 예매 할 수 있다. 이 예제에서는 '영화' 와 '상영'을 구분해야한다.
1. 영화
- 제목, 상영시간, 가격 정보와 같이 영화가 가지고 있는 기본 정보
2. 상영
- 실제로 관객들이 영화를 관람하는 사건, 상영 일자, 시간, 순번 등을 가지고 있다.
이렇게 나눈 이유는 클라이언트가 실제로 예매하는 건 영화 그 자체가 아닌 상영을 예매하는 것이기 때문이다. 이 부분이 이해가 안될 수 있는데 객체의 의인화를 통해 책임을 확실히 구분 한 것이다.
요구사항 2
영화 예매시 특정한 조건을 만족하면 요금할인을 받을 수 있다. 할인액은 결정하는데에는 두 가지 규칙을 통해 결정된다.
1. 할인 조건
가격의 할인 여부를 결정하며 상영 순번을 이용한 '순서 조건', 영화 상영 시작을 통한 '기간 조건'을 통해 할인 여부를 결정한다.
2. 할인 정책
할인 요금을 결정한다. 예매 요금에서 일정 금액을 할인해주는 '금액 할인 정책' 과 일정 비율의 요금을 할인 해주는 '비율 할인 정책' 이 있다. 영화 별로는 하나의 할인 정책, 다수의 할인 조건을 함께 지정 할 수 있다.
객체 지향 프로그래밍을 하기 위한 준비
협력, 객체, 클래스
클래스 기반의 객체지향 언어에 익숙한 사람이라면 가장 먼저 어떤 클래스(class)가 필요한지 고민할 것이다. 그리고 이후에 어떤 속성과 메서드가 필요한지 고민한다.
이 방식은 객체지향의 본질과는 거리가 멀다. 객체지향은 말 그대로 객체를 지향하는 것.
객체지향 프로그래밍을 하기 위한 초점
1. 어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민하라.
- 클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화 한 것, 클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 가지는지를 먼저 결정해야 한다.
2. 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다.
- 객체는 홀로 존재하지 않고, 다른 객체에게 도움을 주거나 의존하면서 살아가는 협력적인 존재이다. 이러한 사고 방식은 설계를 유연하게 확장 가능하게 만든다.
도메인의 구조를 따르는 프로그램 구조
도메인(domain)이란?
- 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야
- 영화 예매 시스템의 목적은 영화를 쉽고 빠르게 예매하려는 사용자의 문제를 해결하려고 하는 것. 이런 것을 도메인이라고 함.
- 요구사항과 프로그램을 객체라는 동일한 관점에서 바라볼 수 있어 도메인을 구성하는 개념들이 프로그램의 객체와 클래스로 매끄럽게 연결 될 수 있다.
- 따라서 일반적으로 클래스의 이름은 대응되는 도메인 개념의 이름과 동일하거나 유사해야 한다. 이렇게 하면 프로그램의 구조를 이해하기 쉽고 예상하기 쉽게 만들 수 있다.
클래스 구현
1. 상영(Screening) 구현
public class Screening {
private Movie movie;
private int sequence;
private LocalDateTime whenScreened;
public Screening(Movie movie, int sequence, LocalDateTime whenScreened) {
this.movie = movie;
this.sequence = sequence;
this.whenScreened = whenScreende;
}
public LocalDateTime getStartTime() {
return whenScreende;
}
public boolean isSequence(int sequence) {
return this.sequence == sequence;
}
public Money getMovieFee() {
return movie.getFee();
}
}
인스턴스 변수의 가시성은 private이고 메서드의 가시성은 public 에 주목해야 한다. 객체의 상태에 대한 변경을 메서드를 통해서만 가능하게 구현해놓은 것이다. 이렇게 하면 객체의 자율성을 보장 할 수 있다.
자율적인 객체
객체는 상태와 행동을 함께 가지는 복합적인 존재이고, 객체가 스스로 판단하고 행동하는 자율적인 존재이다.
- 데이터와 기능을 객체 내부로 함께 묶는 것을 캡슐화 라고 한다.
- 객체지향 프로그래밍에서는 더 나아가 외부에서의 접근을 통제할 수 있는 접근 제어 매커니즘을 제공하고, 이를 위해 접근 수정자를 제공한다.
위 처럼 통제를 하는 이유는 객체를 자율적인 존재로 만들기 위해서 이고, 객체가 자율적인 존재가 되기 위해서는 외부의 간섭을 최소화해야 하기 때문이다. 그리고 캡슐화와 접근 제어는 객체를 두 부분으로 나눌 수 있게 해준다.
1. 퍼블릭 인터페이스(public interface)
- 외부에서 접근이 가능한 부분이다.
2. 구현(implementation)
- 외부에서 접근이 불가능하고, 오직 내부에서만 접근 가능한 부분이다.
여기서 프로그래머의 관점에서 클래스 작성자는 새로운 데이터 타입을 프로그램에 추가하고, 클라이언트 프로그래머는 클래스 작성자가 추가한 데이터 타입을 사용하는데, 클라이언트 프로그래머는 클래스 작성자의 내부 구현을 몰라도 사용이 가능해야 하며, 클래스 작성자는 클라이언트 프로그래머에게 영향을 미치지 않으면서 내부 구현을 마음대로 변경할 수 있어야 한다. 이를 구현 은닉 이라고 한다.
협력하는 객체들의 공동체
Screening의 reserve 메서드는 영화를 예매 한 후 예매 정보를 담고 있는 Reservation의 인스턴스를 생성해서 반환한다.
이때, 인자로 예매자(Customer)와 인원수(audienceCount)를 받는다.
public class Screening {
public Reservation reserve(Customer customer, int audienceCount) {
return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
}
private Money calculateFee(int audienceCount) {
return movie.calculateMovieFee(this).times(audienceCount);
}
}
그리고 calculateFee 메서드는 요금을 계산하기 위해서 사용하는데 계산하기 위해 Movie에서 calculateMovieFee 메서드를 호출하는 것을 볼 수 있다. 그리고 반환으로 금액과 관련 된 Money 라는 인스턴스를 반환한다. 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(GigDecimal.valueOf(amount));
}
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 타입으로 사용했었는데, 금액과 관련된 로직이 서로 다른곳에 중복되어 구현되는 것을 막을 수 없었다.
하지만 위 처럼 금액이란 것 자체를 객체로 표현을 해서 도메인의 의미를 더욱 풍부하게 표현할 수 있게되고 명시적으로 표현 할 수 있다. 하나의 인스턴스 변수만 포함해도 개념을 명시적으로 표현 할 수 있게 되고 이는 전체적인 설계의 명확성 및 유연성을 증가시킨다.
Screening의 reserve 메서드가 반환하는 Reservation은 고객, 상영 정보, 예매 요금, 인원 수에 대한 정보를 가지고 있다.
public class Reservation {
private Customer customer;
private Screening screening;
private Money fee;
private int audienceCount;
public Reservation(Customer customer, Screening screening, Money fee, int audienceCount) {
this.customer = customer;
this.screening = screening;
this.fee = fee;
this.audienceCount = audienceCount;
}
}
위의 과정처럼 각 인스턴스들이 서로의 메서드를 호출하면서 상호작용한다. 이처럼 시스템의 어떤 기능을 구현하기 위해 객체들 사이에 이뤄지는 상호작용을 협력(Collaboration) 이라고 부른다.
객체지향 프로그래밍을 작성할 때는 먼저 협력의 관점에서 어떤 객체가 필요한지를 결정하고, 객체들의 공통 상태와 행위를 구현하기 위해 클래스를 작성해야 한다.
요청과 응답 그리고 메시지
객체는 퍼블릭 인터페이스를 통해서 내부 상태에 접근할 수 있다. 즉, 객체는 다른 객체의 퍼블릭 인터페이스를 통해서 무언가를 수행하도록 요청(request) 할 수 있고, 요청을 받은 객체는 자율적인 방법에 따라서 요청을 처리한 후 응답(response) 한다.
객체가 다른 객체와 상호작용 할 수 있는 유일한 방법은 메시지를 전송(send a message) 하는 것 뿐이다. 이 때, 다른 객체에게 요청이 도착해서 해당 객체가 메시지를 수신(receive a message) 하면 객체는 스스로의 결정에 따라 자율적으로 메시지를 처리할 방법을 결정하는데, 이처럼 자신만이 처리할 방법을 메서드(method) 라고 부르는 것이다.
이 부분이 굉장히 헷갈리는데 저자는 메시지와 메서드를 구분하는 것이 중요하다고 한다. 추가적으로 나중에는 메시지를 오퍼레이션이라고도 부르기도 하는데 용어에 대한 부분을 정확하게 잡고 넘어가는 것 또한 중요하다.
할인 요금 구하기
Movie의 예매 요금을 계산하기 위한 협력 과정을 살펴보자.
public class Movie {
private String title; // 제목
private Duration runningTime; // 상영 시간
private Moune fee; // 기본 요금
private DiscountPolicy discountPolicy; // 할인 정책
public Movie(String title,Duration runningTime, Money fee,DiscountPolicy discountPolicy) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountPolicy = discountPolicy;
}
public Money getFee() {
return fee;
}
public Money calculateMovieFee(Screening screeing) {
return fee.monus(discountPolicy.calculateDiscountAmount(screening)));
}
}
해당 메서드에는 Movie가 어떠한 할인 정책을 사용할지에 대한 코드가 보이지 않는다. 영화는 어떠한 할인 정책을 선택할 것인지에 대한 책임이 없다. 그리고 이를 calculateMovieFee 메서드에서 discountPolicy 에게 메시지를 전송해서 해결한다. 위에서 중요한 부분은 상속과 다형성, 그리고 추상화라는 중요한 객체지향언어에 대한 특징이 사용했다는 점이다.
할인 정책과 할인 조건
할인 정책에는 금액 할인 정책과 비율 할인 정책이 있는데, 해당 정책들의 코드는 유사하고 할인 요금을 계산하는 방식만 조금 다르다. 따라서, 공통 코드를 보관하기 위해 추상 클래스를 사용한다.
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);
}
템플릿 메서드 패턴을 사용해서 추상 클래스의 구현체에 따라서, 다르게 수행되도록 getDiscountAmount 메서드를 각 자손 클래스에서 다르게 적용되도록 하였다. 먼저 추상클래스를 상속받은 할인 정책부터 살펴보자.
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().tiems(percent);
}
}
위에서 얘기했듯이 둘의 코드는 크게 차이나지 않지만, 계산하는 방식만 다르다고 하였다. 위의 코드에서 보이는 것처럼 getDiscountAmount(Screening screening) 메서드가 다르게 구현된 것을 볼 수 있고, 이를 적절히 구현하기 위해 필요한 상태를 추가하였다. 이어서 할인 조건도 살펴보자.
public interface DiscountCondition {
boolean isSatisfiedBy(Screening screening);
}
public class SequenceCondition implements DiscountCondition {
private int sequence;
public SequenceCondition(int sequence) {
this.sequence = sequence;
}
public boolean isSatisfiedBy(Screening screening) { // 파라미터로 전달된 Screening의 상영 순번과 일치할 경우 할인 가능한 것으로 판단
return screening.isSequence(sequence);
}
}
public class SequenceCondition implements DiscountCondition {
private int sequence;
public SequenceCondition(int sequence) {
this.sequence = sequence;
}
public boolean isSatisfiedBy(Screening screening) { // 파라미터로 전달된 Screening의 상영 순번과 일치할 경우 할인 가능한 것으로 판단
return screening.isSequence(sequence);
}
}
둘다 여부를 판단하기 위한 기본적인 공통 코드를 인터페이스로 구성하고 구현하도록 하였다. 그리고 DiscountCondition 에게 메시지를 전송하는 객체는 추상화를 통해 구현체를 모르더라도 올바른 결과를 가지고 올 수 있다.
위 처럼 구현하여, DiscountPolicy 라는 객체에게 메시지를 전달 할 때, 클라이언트 프로그래머는 문제 없이 호출 할 수 있고, 클래스 작성자는 상속 및 인터페이스 구현을 이용해서 다른 할인 정책이 추가가 된다거나, 할인 조건이 추가가 된다면 유연하게 추가 할 수 있다.
상속과 다형성
상속과 다형성에 대해서 얘기를 할 때, 컴파일 시간 의존성(정적 의존), 실행 시간 의존성(런타임 의존성, 동적 의존) 두 가지에 대해서 구분을 해야한다.
위 그림을 봤을 때, 컴파일 당시에는 Movie는 DiscountPolicy 밖에 알지 못한다. 하지만 프로그램이 실행하면서 동적으로 AmountDiscountPolicy나 PercentDiscountPolicy가 수행 될 수 있다.
이렇게 정적 의존성과 동적 의존성이 차이가 많이 날 수록 코드를 이해하기가 어려워진다. 이 부분은 추상화에 대한 트레이드 오프이다.
1. 정적 의존성과 동적 의존성을 동일하게 한 경우
코드를 알아보기가 쉽고, 해당 객체에서 빠르게 어떤 일을 하는지 파악 할 수 있다. 다만, 하나의 객체가 하는 일이 너무 많아 질 수 있다.
2. 정적 의존성과 동적 의존성이 다른 경우
코드를 이해하기 위해 어떤 객체와 연결되어야 하는지 찾아봐야 하고, 이 부분은 추상화 된 부분이 많을수록 파악하기 복잡해진다. 하지만 메시지를 전송하는 객체에 대한 입장에서 보면 코드는 유연하고 변경도 쉽게 할 수 있다.
위의 두 가지의 경우가 객체지향 개발의 트레이드 오프이다.
차이에 의한 프로그래밍, 상속과 인터페이스
어떠한 객체를 하나 더 만들려고 하는데, 해당 객체가 수행해야 할 행동들이 기존의 어떤 객체와 매우 흡사하다고 할 때, 기존의 객체의 코드를 수정하지 않고 재사용하여 객체를 생성 할 수 있다. 이러한 방법이 바로 상속이다. 이처럼 부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게 만드는 방법을 차이에 의한 프로그래밍 이라고 부른다.
여기서는 상속을 또 크게 두 가지로 구분한다.
1. 구현 상속
- 단순히 코드를 재사용하기 위한 목적으로 상속을 구현하는 것이다.
2. 인터페이스 상속
- 다형적인 협력을 위해서 부모 클래스와 자식 클래스가 퍼블릭 인터페이스를 공유할 수 있도록 상속을 이용하는 것이다. 저자는 기본적으로 인터페이스 상속을 해야한다고 한다. 구현 상속은 변경에 취약한 코드를 낳게 될 확률이 높기 때문이다.
일반적으로 상속을 배울 때에는 메서드나 인스턴스 변수를 재사용하는 것이 목적라고 바라보지만, 사실 상속이 가치있는 이유는 부모 클래스가 제공하는 모든 퍼블릭 인터페이스를 자식 클래스가 물려 받을 수 있기 때문이다. 자식 클래스가 부모가 수신 가능한 모든 퍼블릭 인터페이스를 수신할 수 있기 때문에 외부 객체는 자식 클래스를 부모 클래스와 동일 타입으로 간주 할 수 있다. 이 처럼 자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅 이라고 부른다.
여기서 살짝 헷갈렸던 부분이 우리는 기존의 자바를 배우면서 업캐스팅과 다운캐스팅을 다음과 같은 용어로 사용한다.
업캐스팅 : 자식 인스턴스가 부모 타입으로 형변환 하는것.
다운캐스팅 : 부모 타입을 가진 자식 인스턴스가 자식 타입으로 형변환 하는 것
이 책에서도 비슷한 의미로 사용을 한다. 자식 인스턴스가 부모 타입으로 형 변환을 할 때에는 암시적 변환이 가능한데, 이를 자식 클래스가 부모 클래스를 대신 할 수 있다. 라는 의미로 설명한 것이다.
다형성
지금까지 거슬러온 관점으로 다형성을 다음과 같이 설명한다.
다형성은 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라서 달라진다.
즉, 클라이언트 입장에서는 추상화 된 클래스에게 메시지를 보내지만 분명 수신받는 객체는 구성체에 따라서 달라 질 수 있는 것이다.
따라서, 다형성은 정적 의존과 동적 의존이 다를 수 있다는 사실을 기반으로 한다.
메시지와 메서드를 실행 시점에 결정하는 것을 지연 바인딩(Lazy Binding) 혹은 동적 바인딩(Dynamic Binding)이라고 부르고, 컴파일 시점에 실행될 함수나 프로시저를 결정하는 것을 초기 바인딩(Early binding) 또는 정적 바인딩(Static Binding)이라고 부른다고 한다.
느낀점
기존의 객체 지향의 사실과 오해의 교재에서 배운 내용들을 직접 코드로 확인 해 볼 수 있었다. 이어서 다음 장들에서는 어떠한 사고 방식과 어떠한 생각을 거쳐서 지금처럼 유연한 객체지향 설계를 만들 수 있게 됐는지를 확인 할 수 있는 부분이 하나 하나 나오게 되는데, 이렇게 리팩토링 하는 과정이 특히 인상적이다! 앞으로도 책을 읽어가면서 추가적인 포스팅을 할 예정이다.
'도서 > 오브젝트' 카테고리의 다른 글
4. 설계 품질과 트레이드 오프 (0) | 2023.06.04 |
---|---|
3. 역할, 책임, 협력 (0) | 2023.05.26 |
1. 객체, 설계 (0) | 2023.05.18 |