5장. 책임 할당하기
1. 책임 주도 설계를 향해
데이터 중심의 설계에서 책임 중심의 설계로 전환하기 위해서는 다음의 두 가지 원칙을 따라야 한다.
- 데이터보다 행동을 먼저 결정하라
- 협력이라는 문맥 안에서 책임을 결정하라
데이터보다 행동을 먼저 결정하라
책임 중심의 설계에서는 객체의 행동, 즉 책임을 먼저 결정한 후에 객체의 상태를 결정한다.
협력이라는 문맥 안에서 책임을 결정하라
협력에 적합한 책임을 수확하기 위해서는 객체를 결정한 후에 메시지를 선택하는 것이 아니라 메시지를 결정한 후에 메시지 스스로 객체를 선택해야 한다.
협력이라는 문맥 안에서 메시지에 집중하는 책입 중심의 설계는 캡슐화의 원리를 지키기가 훨씬 쉬워진다.
책임 주도 설계
다음은 3장에서 설명한 책임 주도 설계의 흐름을 다시 나열한 것이다.
- 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악한다.
- 시스템 책임을 더 작은 책임으로 분할한다.
- 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임을 할당한다.
- 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 또는 역할을 찾는다.
- 해당 객체 또는 역할에게 책임을 할당함으로써 두 객체가 협력하게 한다.
2. 책임 할당을 위한 GRASP 패턴
GRASP 패턴은 "General Responsibility Assignment Software Pattern(일반적인 책임 할당을 위한 소프트웨어 패턴)"의 약자로 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의 집합을 패턴 형식으로 정리한 것이다.
도메인 개념에서 출발하기
위의 그림은 영화 예매 시스템을 구성하는 도메인 개념과 개념 사이의 관계를 대략적으로 표현한 것이다. 설계를 시작하는 단계에서는 개념들의 의미와 관계가 정확하거나 완벽할 필요가 없다.
이 단계에서는 책임을 할당받을 객체들의 종류와 관계에 대한 유용한 정보를 제공할 수 있다면 충분하다. 중요한 것은 설계를 시작하는 것이지 도메인 개념들을 완벽하게 정리하는 것이 아니다.
정보 전문가에게 책임을 할당하라
책임 주도 설계 방식의 첫 단계는 애플리케이션이 제공해야 하는 기능을 애플리케이션의 책임으로 생각하는 것이다. 이 책임을 애플리케이션에 대해 전송된 메시지로 간주하고 이 메시지를 책임질 첫 번째 객체를 선택하는 것으로 설계를 시작한다.
메시지는 메시지를 수신할 객체가 아니라 메시지를 전송할 객체의 의도를 반영해서 결정해야 한다.
따라서 다음 두 질문에 집중하면 된다.
- 메시지를 전송할 객체는 무엇을 원하는가?
- 메시지를 수신할 적합한 객체는 누구인가?
객체의 책임과 책임을 수행하는 데 필요한 상태는 동일한 객체 안에 존재해야 한다. 따라서 객체에게 책임을 할당하는 첫 번째 원칙은 책임을 수행할 정보를 알고 있는 객체에게 책임을 할당하는 것이다.
GRASP에서는 이를 INFORMATION EXPERT(정보 전문가) 패턴이라고 부른다.
여기서 이야기하는 정보는 데이터와 다르다는 사실에 주의하라. 책임을 수행하는 객체가 정보를 '알고' 있다고 해서 그 정보를 '저장'하고 있을 필요는 없다.
어떤 방식이건 정보 전문가가 데이터를 반드시 저장하고 있을 필요는 없다는 사실을 이해하는 것이 중요하다.
위의 그림에서 Screening(상영)은 영화 예매를 위한 정보 전문가다. 그래서 예매에 관한 책임을 할당받았지만, 가격을 계산하는것은 못하기에 외부의 Movie에게 도움을 요청하여 Movie가 가격계산의 책임을 지게 된다. 할인 여부도 마찬가지로 외부의 DiscountCondition에게 책임을 지게 하면 된다.
위의 예제처럼 정보 전문가 패턴은 상태와 행동을 함께 가지는 단위라는 객체지향의 가장 기본적인원리를 책임 할당의 관점에서 표현한다.
이런식으로 정보 전문가 패턴을 따르는것만으로도 자율성이 높은 객체들도 구성된 협력 공동체를 구축할 가능성이 높아지는 것이다.
높은 응집도와 낮은 결합도
위의 설계에서 Movie 대신 Screening이 직접 DiscountCondition과 협력하게 해보자.
이전 설계와의 차이점이라면 DiscountCondition과 협력하는 객체가 Movie가 아니라 Screening이라는 것 뿐이다.
하지만 이 설계를 선택하지 않고 이전의 설계를 선택하는 이유는 응집도와 결합도의 차이 때문이다.
도메인 상으로 Movie는 DiscountCondition의 목록을 속성으로 포함하고 있다. 그렇다는것은 Movie와 DiscountCondition은 이미 결합되어 있다는것인데, Screening이 DiscountCondition과 협력하게 되면 새로운 결합도가 추가되는 결과가 나오게 된다.
그래서 이전 설계처럼 Movie와 DiscountCondition이 협력하게 하면 낮은 결합도(LOW COUPLING)을 유지할 수가 있다. GRASP에서는 이를 **LOW COUPLING(낮은 결합도)**라고 부른다.
HIGH COHESION(높은 응집도) 패턴 또한 존재한다.
HIGH COHESION 패턴의 관점에서 본다면, 위의 설계는 예매 요금을 계산하는 방식이 변경될 경우 Screening도 함께 변경해야 한다. 결과적으로 Screening은 자기가 원래 짊어지던 책임과는 다른 이유로 변경되는 책임을 짊어지게 되므로 응집도가 낮아질 수 밖에 없다.
창조자에게 객체 생성 책임을 할당하라
GRASP의 CREATOR(창조자) 패턴은 이 같은 경우에 사용할 수 있는 책임 할당 패턴으로서 객체를 생성할 책임을 어떤 객체에게 할당할지에 대한 지침을 제공한다.
- 해당 객체가 만들 객체를 포함하거나 참조한다.
- 해당 객체가 만들 객체를 기록한다.
- 해당 객체가 만들 객체를 긴밀하게 사용한다.
- 해당 객체가 만들 객체를 초기화하는 데 필요한 데이터를 가지고 있다.(정보 전문가 패턴)
위의 조건을 최대한 만족하는 객체에게 객체 생성 책임을 할당하면 된다.
3. 구현을 통한 검증
- Screening
public class Screening {
private Movie movie;
private int sequence;
private LocalDateTime whenScreened;
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);
}
}
Movie에 전송하는 메시지의 시그니처인 calculateMovieFee(Screening screening)을 보자.
이 메시지는 수신자인 Movie가 아니라 송신자인 Screening의 관점에서 의도를 표현한다.
이처럼 Movie의 구현을 고려하지 않고 필요한 메시지를 결정하면 Movie의 내부 구현을 깔끔하게 캡슐화할 수 있다.
변경에 취약한 클래스 개선하기
응집도가 낮다는 것은 서로 연관성이 없는 기능이나 게이터가 하나의 클래스 안에 뭉쳐져 있다는 것을 의미한다. 따라서 낮은 응집도가 초래하는 문제를 해결하기 위해서는 변경의 이유에 따라 클래스를 분리해야 한다.
지금까지 살펴본 것처럼 일반적으로 설계를 개선하는 작업은 변경의 이유가 하나 이상인 클래스를 찾는 것으로부터 시작하는 것이 좋다.
첫 번째는 인스턴스 변수가 초기화되는 시점을 살펴보는 것이다. 응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께 초기화하지만, 응집도가 낮은 클래스는 객체의 속성 중 일부만 초기화한다. 따라서 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다.
두 번째는 메서드들이 인스턴스 변수를 사용하는 방식을 살펴보는 것이다.
모든 메서드가 객체의 모든 속성을 사용한다면 클래스의 응집도는 높다고 볼 수 있다.
반면 메서드들이 인스턴스 변수를 사용하는 속성에 따라 그룹이 나뉜다면 클래스의 응집도가 낮다고 볼 수 있다.
이 경우 클래스의 응집도를 높이기 위해서는 속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리해야 한다.
정리하자면 다음과 같다.
- 클래스가 하나 이상의 이유로 변경된다면, 변경의 이유를 기준으로 클래스를 분리하라.
- 클래스의 인스턴스를 초기화하는 시점에 경우에 따라 서로 다른 속성들을 초기화 하고 있다면, 초기화되는 속성의 그룹을 기준으로 클래스를 분리하라.
- 메서드 그룹이 속성 그룹을 사용하는지 여부로 나뉜다면, 속성 그룹을 기준으로 클래스를 분리하라.
타입 분리하기
public class DiscountCondition {
private DiscountConditionType type;
private int sequence;
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
public boolean isSatisfiedBy(Screening screening) {
if (type == DiscountConditionType.PERIOD) {
return isSatisfiedByPeriod(screening);
}
return isSatisfiedBySequence(screening);
}
private boolean isSatisfiedByPeriod(Screening screening) {
return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
endTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0;
}
private boolean isSatisfiedBySequence(Screening screening) {
return sequence == screening.getSequence();
}
}
DiscountCondition은 sequence, period라는 독립적인 타입이 공존하고 있다.
이런 경우에 클래스를 타입별로 분리해주면(SequenceCondition, PeriodCondition) 문제점이 해결되지만, Movie가 두개의 클래스와 결합되게 되어버린다. 이 문제는 다형성을 통해 해결할 수 있다.
다형성을 통해 분리하기
Movie의 입장에서 보면 SequenceCondition과 PeriodCondition은 아무 차이도 없다.
할인 가능 여부를 반환해 주기만 하면 Movie 입장에서는 상관없기 때문이다.
따라서 DiscountCondition을 암시적인 타입으로 다시 만들어주고 변화하는 행동에 따라 분리한 타입에 책임을 할당해주면 된다. GRASP에서는 이를 POLYMORPHISM(다형성) 패턴이라고 부른다.
변경으로부터 보호하기
위의 그림을 보면 DiscountCondition이 구체적인 타입을 캡슐화하고 있다.
Movie의 관점에서 보면, DiscountCondition의 내부를 알 수 없기 때문에 DiscountCondition의 내부가 변하더라고 아무 영향을 받지 않는다. 그렇기에 Movie는 DiscountCondition의 변경으로부터 영향을 받지 않는다.
이처럼 변경을 캡슐화하도록 책임을 할당하는 것을 GRASP에서는 PROTECTED VARIATIONS(변경 보호) 패턴이라고 부른다.
변경과 유연성
상속을 이용할때 새로운 할인 정책이 추가된다면 인스턴스를 생성하고, 상태를 복사하고, 식별자를 관리하는 코드를 매번 추가해줘야한다. 이 일은 번거로울뿐만 아니라 오류가 발생하기도 쉽다.
해결 방법은 상속 대신 합성을 사용하는 것이다.
위의 그림은 Movie를 상속받아 할인 정책을 구현하였다. 이 할인 정책을 DiscountPolicy로 분리한 후 Movie에 합성시키면 유연한 설계가 완성된다.
이제 금액할인에서 비율할인으로 바꾸는일은 Movie에 연결된 DiscountPolicy의 인스턴스를 교체하는 단순한 작업으로 끝낼 수 있다.
Movie movie = new Movie("타이타닉",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(...));
movie.changeDiscountPolicy(new PercentDiscountPolicy(...));
코드의 구조가 위처럼 바뀌면 도메인 구조 또한 바뀐다.
4. 책임 주도 설계의 대안
책임 주도 설계에 익숙해지기 위해서는 부단한 노력과 시간이 필요하다. 그러나 어느 정도 경험을 쌓은 숙련된 설계자조차도 적절한 책임과 객체를 선택하는 일에 어려움을 느끼고는 한다.
이럴 때 개인적으로 돌파구를 찾기 위해 선택하는 방법은 최대한 빠르게 목적한 기능을 수행하는 코드를 작성하는 것이다. 책임과 협력에 관해 고민하기 보다는 일단 실행되는 코드를 얻고 난 후에 코드 상에 명확하게 드러나는 책임들을 올바른 위치로 이동시키는 것이다.
이처럼 이해하기 쉽고 수정하기 쉬운 소프트웨어로 개선하기 위해 겉으로 보이는 동작은 바꾸지 않은 채 내부 구조를 변경하는 것을 **리팩터링(Refactoring)**이라고 부른다.
메서드 응집도
긴 메서드는 다양한 측면에서 코드의 유지보수에 부정적인 영향을 미친다.
- 어떤 일을 수행하는지 한눈에 파악하기 어렵기 때문에 코드를 전체적으로 이해하는데 너무 많은 시간이 걸린다.
- 하나의 메서드 안에서 나무 많은 작업을 처리하기 떄문에 변경이 필요할 때 수정해야 할 부분을 찾기 어렵다.
- 메서드 내부의 일부 로직만 수정하더라도 메서드의 나머지 부분에서 버그가 발생할 확률이 높다.
- 로직의 일부만 재사용하는 것이 불가능하다.
- 코드를 재사용하는 유일한 방법은 원하는 코드를 복사해서 붙여넣는 것뿐이므로 코드 중복을 초래하기 쉽다.
위에서 설명한 이러한 메서드를 **몬스터 메서드(monster method)**라고 부른다.
응집도가 낮은 메서드는 로직의 흐름을 이해하기 위해 주석이 필요한 경우가 대부분이다.
주석을 추가하는 댓니 메서드를 작게 분해해서 각 메서드의 응집도를 높여라.
따라서 객체로 책임을 분배할 때 가장 먼저 할 일은 메서드를 응집도 있는 수준으로 분해하는 것이다.
객체를 자율적으로 만들자
자신이 소유하고 있는 데이터를 자기 스스로 처리하도록 만드는 것이 자율적인 객체를 만드는 지름길이다. (자신의 데이터를 자기 스스로 처리하도록 만든다는것이 책 전반적으로 반복되며 설명하고 있다. 매우 중요함!)
따라서 메서드가 사용하는 데이터를 저장하고 있는 클래스로 메서드를 이동시키면 된다.
본인이 책임 주도 설계 방법에 익숙하지 않다면 일단 데이터 중심으로 구현한 후 이를 리팩터링하더라도 유사한 결과를 얻을 수 있다. 그러므로 처음부터 책임 주도 설계 방법을 따르는 것보다 동작하는 코드를 작성한 후에 리팩터링하는 것이 더 훌륭한 결과물을 낳을 수도 있다. 캡슐화, 결합도, 응지도를 이해하고 훌륭한 객체지향 원칙을 적용하기 위해 노력한다면 책임 주도 설계 방법을 단계적으로 따르지 않더라도 유연하고 깔끔한 코드를 얻을 수 있을 것이다.