[스터디: 오브젝트] 설계 개선하기
앞 장에서 책임 중심 설계와 데이터 중심 설계를 비교하며 책임 중심 설계의 장점을 코드로 살펴봤다.
그러면 책임 중심 설계를 위해서는 책임을 올바른 객체에게 할당해야 하는데 어떤 객체가 올바른 객체인지 어떻게 알 수 있을까?
책의 3장에서는 아래와 같은 내용이 나온다.
협력은 객체에게 할당할 책임을 결정할 수 있는 문맥을 제공한다.
따라서 협력을 고려하여 적절한 책임을 할당하면 좋은 설계(책임 중심 설계를 할 수 있다.)
이렇게 협력을 기반으로 책임 주도 설계를 하는 방법은 아래 순서를 따른다.
- 도메인 개념에서 생각한다.
- 메시지를 생각하고, 메시지를 수신할 적절한 객체에 책임을 할당한다.
- 객체 내부로 들어가서 메시지 처리를 위한 절차와 구현을 고민해본다.
- 책임을 더 작은 책임으로 나누어가며 2-3 과정을 반복한다.
처음에 이 내용을 봤었을 때 2번에 대해서 한가지 의문점이 들었다.
전문가 패턴에서는 책임을 할당할 때, 책임을 수행할 정보를 가장 잘 알고 있는 객체(전문가)에게 할당하라고 말한다.
그런데 아직 객체의 상태를 정의하지 않았는데, 객체가 무엇을 아는지 어떻게 알 수 있는지 궁금했다.
책을 좀 더 읽어보니 저자가 말하는 정보란 상태와 같은 데이터가 아니라, 알고 있다는 사실을 뜻한다.
자신이 책임을 수행하지 않더라도, 관련된 객체를 알고 있다면, 정보를 가지고 있다고 해석하면 된다.
결국 처음에 짠 도메인 모델을 통해 정보를 판단할 수 있고, 이를 바탕으로 책임이 할당되기 때문에 도메인 모델을 그리는 것이 매우 중요할 것이라고 생각했다.
그러면 올바른 도메인 모델은 존재하는 것일까?
이전 장에서는 아래와 같이 영화 예매의 도메인 모델을 설계했었다.
하지만 영화에 따라 할인정책이 나뉘는 거라며, 영화 자체를 나누어서 아래와 같은 도메인 모델을 그릴 수도 있다.
완벽한 도메인 설계는 처음부터 구현하기 어렵고, 도메인만으로는 뭐가 완벽한 도메인 인지도 알기 어렵다.
처음에 설계한 도메인에 맞추어 설계를 하되, 코드를 구현하면서 유연성과 재사용성을 고려하면서 얻는 통찰로 도메인의 개념을 변경하면서, 완벽한 도메인 설계에 다가는 방법이 더 합리적일 것이다.
설계에 필요한 것은 올바른 도메인 모델이 아니라,
구현에 도움을 주는 유용한 도메인 모델이다.
GRASP 패턴
이제 그러면 도메인 모델을 기반으로 한 설계를 변경하는 법을 배워보자.
이를 위해 GRASP 패턴을 소개한다.
GRASP 패턴이란 General Responsibility Assignment Software Pattern의 약자로 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의 집합을 패턴으로 정리한 것이다.
다양한 패턴을 소개해 주었지만 핵심만 요약하자면 아래와 같다.
높은 응집도와 낮은 결합도를 가지도록 설계해야한다.
- Low Coupling, High Cohension 패턴
객체 생성을 담당하는 객체에게 책임을 할당해야한다.
- 창조자 패턴
타입을 명시적으로 정의하고 각 타입에 다형적으로 행동하는 책임을 할당한다.
- Polymolphism 패턴
용어가 중요하다기 보다는 코드를 보고 직접 수정하는것이 더욱 와닿을 것 같아서 영화 예매 시스템을 설계한다고 가정하고 코드를 작성해보았다.
영화 예매를 위해서 현실 세계의 영화 예매를 떠올렸다.
영화라는 객체와 상영이라는 객체가 m:n으로 연결되어 있고, 상영에 맞추어 영화를 예매한다.
또한 다양한 할인 조건이 존재하고, 영화별로 어떤 할인 조건이 있는지가 정해져 있을 것이다.
영화의 요금은 Movie가 가지고 있을 것이고, 할인 조건도 연결되어 있기 때문에 Movie 객체의 책임으로 할당한다.
이를 바탕으로 코드를 작성하였다.
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountConditions;
private MovieType movieType;
private Money discountAmount;
private double discountPercent;
public Money calculateMovieFee(Screening screening) {
if (isDiscountable(screening)) {
return fee.minus(calculateDiscountAmount());
}
return fee;
}
private boolean isDiscountable(Screening screening) {
return discountConditions.stream()
.anyMatch(condition -> condition.isSatisfiedBy(screening));
}
private Money calculateDiscountAmount() {
switch(movieType) {
case AMOUNT_DISCOUNT:
return discountAmount;
case PERCENT_DISCOUNT:
return fee.times(discountPercent);
case NONE_DISCOUNT:
return Money.ZERO;
}
}
throw new IllegalStateException();
}
}
fee는 Movie 객체의 상태이기 때문에 Movie 객체안에서 fee 와 관련된 작업들을 최대한 다루기 위해서 이렇게 설계하였다.
만약 내가 짠 코드가 좋은 책임 주도 설계인지 알기 위해서는 아래 조건을 생각해보면 된다.
1. 로직이 추가되었을 때 코드를 변경해야 한다면, 변경해야하는 이유를 기준으로 분리해야한다.
2. 인스턴스 변수가 일부만 초기화 된다면, 초기화 되는 속성끼리 분리해야한다.
3. 특정 메서드가 일부 속성만 사용한다면, 그 속성과 메서드를 분리해야한다.
프로젝트를 수행하면서 1번 경우는 자주 생각해보지 않아 잘 모르지만, 2번과 3번의 경우에는 자주 해당되는 것 같다.
내가 위에서 짠 코드에서도 위와 같은 문제가 발생한다.
- movieType의 종류가 하나 더 추가된다면 calculateDiscountAmount() 메서드를 변경해야한다.
- Movie 객체는 경우에 따라 discountAmount 나 discountPercent가 초기화 되지 않는다.
- Movie 객체는 movieType에 따라서 discountAmount나 discountPercent 상태만을 사용한다.
해결을 위해 원칙에 맞추어 discountAmount와 discountPercent를 다른 객체로 분리하였다.
public abstract class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountConditions;
...
public Money calculateMovieFee(Screening screening) {
if (isDiscountable(screening)) {
return fee.minus(calculateDiscountAmount());
}
return fee;
}
private boolean isDiscountable(Screening screening) {
return discountConditions.stream()
.anyMatch(condition -> condition.isSatisfiedBy(screening));
}
abstract private Money calculateDiscountAmount();
}
public class AmountDiscountMovie extends Movie {
private Money discountAmount;
// 생성자 생략
@Override
private Money calculateDiscountAmount() {
return discountAmount;
}
}
public class PercentDiscountMovie extends Movie {
private double percent;
// 생성자 생략
@Override
private Money calculateDiscountAmount() {
retufn fee.times(percent);
}
}
public class NoneDiscountMovie extends Movie {
// 생성자 생략
@Override
private Money calculateDiscountAmount(){
return Money.ZERO;
}
}
AmountDiscountMovie와 PercentDiscountMovie로 분리하긴 했지만, 일부 구현을 공유하기 때문에 Movie를 추상 클래스로 선언했다.
이제 위에서 언급한 문제들이 모두 해결되었다.
- movieType의 종류가 하나 더 추가된다면 Movie를 상속받는 클래스를 하나 더 만들어주기만 하면 된다.
- Movie 객체의 서브 타입 객체들은 항상 모든 필드를 동시에 초기화 한다.
- Movie 객체의 서브 타입 객체들은 모든 필드를 사용한다.
이전 코드처럼 if-else 구문에 따라 사용하는 필드가 다르다면, 변화가 일어났을 때 구현을 바꾸어야 한다.
평소에 이런 코드를 자주 작성하였는데, 객체가 항상 사용하는 필드만 가지도록 클래스를 분리하는 법을 배울 수 있었다.
상속보다는 합성을 이용하라
앞서 소개한 코드는 올바르게 설계된 것일까?
앞서 생성한 코드의 구조에서 타이타닉 영화가 금액 할인에서 비율 할인으로 바뀐다고 가정해보자.
이미 타이타닉이라는 title을 가진 영화는 AmountDiscountMovie 객체로 생성되어 있을 것이다.
따라서 할인 정책을 바꾸기 위해서는 기존의 다른 값들을 모두 복사한 PercentDiscountMovie로 새로 생성해 줘야 한다.
누가 봐도 변경에 잘 대처하지 못하는 구조이다.
그 이유느 자주 바뀌는 할인 정책이라는 개념이 상속을 통해서 구현되어 있기 때문이다.
이를 해결하기 위해 Movie 객체에서 할인 정책을 합성을 통해 참조하도록 설계해보자.
위의 설계의 경우에 타이타닉 영화의 할인 정책을 변경하기 위해서는 아래의 코드만 작성해주면 된다.
movie.chageDiscountPolicy(new PercentDiscountMovie(...));
변경에 유연한 설계를 위해 코드를 변경하다보니 결국 도메인 구조가 변경되었다.
처음부터 좋은 도메인 구조를 떠올리기는 쉽지 않다.
먼저 절차지향적으로 빠르게 돌아가는 코드를 구현하고, 이를 객체지향적으로 리펙터링 하는게 훨씬 쉬울 것이다.
중요한 것은 리펙터링을 하더라도 기능이 변경되어서는 안된다는 것이다.
리펙터링을 한 후에 기능이 잘 작동하는지 계속해서 확인하는 과정이 꼭 필요하다.
우테코에서 테스트 코드를 처음 접하면서 테스트 코드를 통해 리펙터링을 자주 하더라도, 빠르게 이를 확인할 수 있었다.
책에서 테스트 코드에 관하여 언급하진 않았지만 이 부분에서 한번 더 테스트코드의 중요성을 느낄 수 있었으며, 좋은 설계를 위해서는 테스트코드가 필수적이라는 것을 상기시킬 수 있었다.
명세서 인터페이스
객체와 메서드를 분리하다보면 아래와 같이 public 인터페이스는 단순히 흐름을 나열한 명세서와 같은 형태를 자주 띈다.
public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
boolean discountable = checkDiscountable(screening);
Money fee = calculateFee(screening, discountable, audienceCount);
return createReservation(screening, customer, audienceCount, fee);
}
위 코드에서는 reserve() 메서드는 할인 가능여부를 확인하고, 요금을 계산해서, 예약을 생성한다는 것을 쉽게 알 수 있다.
우테코를 하면서도 이런 메서드를 작성하는 경우가 많았는데, 처음에는 이게 올바르지 못한 메서드라고 생각했다.
메서드는 하나의 기능을 수행해야 하는데 이 메서드는 기능을 수행하기 보다는 단순히 흐름을 나열하고 있기 때문이다.
하지만, 책임을 수행하고 그 과정에서 다른 객체의 협력이 필요하다면, 위와 같은 설계가 나올 수 밖에 없다.
오히려 책임을 수행하기 위한 흐름을 알아보기 쉽고, 수정 시에도 어디를 수정해야할 지 쉽게 파악할 수 있다는 장점이 있다.
인터페이스는 기능을 수행하는 것이 아니라, 협력을 위해서 존재하기 때문에 위와 같이 흐름을 명세하는것이 인터페이스의 역할이라는 생각이 들었다.
느낀점
우테코에서 코드를 짜면서 느끼던 것들을 책에서 이론으로 가르쳐 주어 개념이 더 잘 와닿았다.
우테코를 하면서 객체지향적으로 설계하고, 메서드를 분리하기 위해서 계속해서 고민하였는데, 책을 읽으면서 이런 고민들의 퍼즐 조각이 맞춰지는 것 같다.
요즘 오브젝트 책을 너무 재미있게 읽고 있고, 이제까지 읽은 개발 서적 중 가장 생각할거리를 많이 던져주어 인생서적이 되었다.
책을 읽으면서 계속 객체지향에 대해서 깨달음을 얻는 것 같아서 너무 뿌듯하고, 앞으로의 내용이 기대된다.