본문 바로가기

IT Book Summary/Object: 객체지향설계

Chapter 05 책임 할당하기

책임 할당 과정은 일종의 트레이드 오프 활동

다양한 책임 할당 방법이 존재

 

GRASP 패턴

- 다양한 기준에 따라 책임을 할당하고 결과를 트레이드 오프 하는 기준

 

 


01 책임 주도 설계를 향해

 

 

데이터보다 행동을 먼저 결정하라

 

객체의 행동 -> 데이터

수행해야할 책임은 무엇인가? -> 이 책임을 수행하는데 필요한 데이터는 무엇인가?

 

협력이라는 문맥 안에서 책임을 결정하라

 

객체에 할당된 책임의 품질은 협력에 적합한 정도로 결정.

객체가 참여하는 협력에 적합하다면 좋은 책임이다.

 

메시지가 객체를 선택하게 하자.

 

클래스 기반 설계에서 메시지 기반 설계로의 자리바꿈은 우리가 해오던 설계 활동의 전환점이다.

"메시지를 전송해야 하는데 누구에게 전송해야 하지?" 라고 질문하는 것.

객체를 가지고 있기 때문에 메시지를 보내는 것이 아니다. 메시지를 전송하기 때문에 객체를 갖게 된 것이다. - Metz

 

메시지는 클라이언트의 의도를 표현하는 것이다.

 

메시지를 수신하는 객체는 처리할 책임을 할당 받는다.

 

메시지를 먼저 결정하기 때문에 전송자는 수신자에 대한 가정을 할 수 없으며 캡슐화 되는 것

 

책임 주도 설계

 

다시한번 3장

 

  • 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악
  • 시스템 책임을 더 작은 책임으로 분할
  • 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임을 할당
  • 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 또는 역할을 찾음
  • 해당 객체 또는 역할에게 책임을 할당함으로써 두 객체가 협력

 


02 책임 할당을 위한 GRASP 패턴

 

크레이그 라만(Craig Larman) GRASP(Gerneral Responsibility Assignment Software Pattern) 패턴

- 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의 집합을 패턴형식으로 정리한 것

 

도메인 개념에서 출발하기

 

설계전 도메인에 대한 개략적 모습을 그려보는것이 유용하다.

도메인 개념들을 책임 할당의 대상으로 사용하자.

 

1 상영 *  ㅡ  1 영화  1 ㅡ  * 할인조건

  |                  |                   |

* 예매      금액 할인영화    순번조건    

              비율 할인영화    기간조건

 

책임을 할당받을 객체들의 종류와 관계에 대한 유용한 정보를 제공하는 것 만으로 충분하다

 

 

정보 전문가에게 책임을 할당하라

 

책임 주도 설계 첫번째 단계 - app이 제공해야 하는 기능을 app의 책임으로 생각하는 것.

메시지는 메시지를 전송할 객체의 의도를 반영해서 결정

 

메시지를 전송할 객체는 무엇을 원하는가?

-> "예매하라"

 

메시지를 수신할 적합한 객체는 누구인가?

->책임을 수행할 정보를 알고 있는객체에게 책임을 할당. 정보전문가 패턴 (INFORMATIOIN EXPERT)

상영(Screening) 도메인 개념이 영화예매에 필요한 다양한 정보를 알고 있다. 그에게 책임을 할당하자.

 

연쇄적인 메시지 전송과 수신을 통해 협력 공동체가 구성

 

예매가격을 계산하는 작업이 필요하다.

 

Screening 은 계산에 필요한 정보를 모르기 때문에 외부 객체에 도움을 요청해야 한다.

이 요청이 새로운 메시지가 된다. - "가격을 계산하라"

 

가격을 계산하는데 필요한 정보 전문가 = Movie

 

"할인 여부를 판단하라" - 정보전문가 = DiscountCondition

 

정보전문가 패턴을 따르기만 해도 자율성 높은 객체들로 구성된 협력 공동체를 구축할 가능성이 높아진다.

 

높은 응집도와 낮은 결합도

 

설계는 트레이드 오프 활동이다.

 

DiscountCondition 이 Movie와 협력하는 것이 좋을까, Screening 과 협력하는 것이 좋을까?

Movie가 DiscountCondition과 협력하는 방법을 선택한 이유

-> Low Coupling , High Cohesion 낮은 결합도와 높은 응집도 패턴

 

Screening 이 DiscountCondition과 협력하게되면 ?

새로운 결합도가 생성되므로 Low Coupling 패턴에서는 Movie와 협력하는것이 낫다.

또한 Screening 이 영화요금 계산과 관련된 책임 일부를 떠안아야 하므로 예매요금 계산 방식이 변경될 경우 Screening 도 변경된다.

High Cohesion 패턴 관점에서도 영화요금 계산과 관련된 Movie가 협력하는것이 더 낫다.

 

창조자에게 객체 생성 책임을 할당하라

 

영화예매 협력의 최종 결과물은 Reservation 인스턴스를 생성하는 것.

협력에 참여하는 어떤 객체는 Reservation 인스턴스를 생성할 책임을 할당해야 한다는것을 의미.

 

CREATOR 창조자 패턴은 책임할당 지침을 제공한다.

생성되는 객체와 연결되거나 관련되는 객체에 해당 객체를 생성할 책임을 맡기는 것.

이미존재하는 객체 사이의 관계를 이용하기 때문에 설계가 낮은 결합도 유지.

 

이제 코드를 작성하자.

 


03 구현을 통한 검증

 

Screening 영화예매 책임.

예매에 대한 정보전문가인 동시에 Reservation 창조자

예매하라 메서드를 구현하자

 

Movie에 전송하는 메세지를 송진자인 Screening 의 의도를 표현하려고 calculateMovieFee(Screening 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 Movie calculateFee(int audienceCount) {
        return movie.calculateMovieFee(this).times(audienceCount);
    }
}

 

 

Movie 요금계산 메서드를 구현하자

 

 

public class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private List<DiscountCondition> discountCondition;
    
    private MovieType movieType;
    private Money discounAmount;
    private double discountPercent;
    
    public Money calculateMovieFee(Screening screening) {
        if (isDiscountable(screening)) {
            return fee.minus(calculateDiscountAmount());
        }
        
        return fee;
    }
    
    private boolean isDiscountable(Screening screening){
        return discountCondition.stream()
            .anyMatch(condition -> condition.isSatisfiedBy(screening));
    }
}

 

MovieType 단순 열거형

 

public enum MovieType {
    AMOUNT_DISCOUNT,	//금액 할인 정책
    PERCENT_DISCOUNT,	//비율 할인 정책
    NONE_DISCOUNT		//미적용
}

 

 

실제 할인 요금을 계산하는 calculateDiscountAmount 메서드는 movieType값에 따라 메서드를 호출

 

public class Movie {
    private Money calculateDiacountAmount() {
        switch(movieType) {
            case AMOUNT_DISCOUNT:
                return calcuateAmountDiscountAmount();
            case PERCENT_DISCOUNT:
                return calcuatePercentDiscountAmount();
            case NONE_DISCOUNT:
                return calcuateNoneDiscountAmount();
        }
        
        throw new IllegalStateException();
    }
    
    private Money calculateAmountDiscountAmount() {
        return discountAmount;
    }
    
    private Money calcuatePercentDiscountAmount() {
        return fee.times(discountPercent);
    }
    
    private Money calculateNoneDiscountAmount() {
        return Money.ZERO;
    }

}

 

 

DiscountCondition 이 가지는 인스턴스 변수

- 기간조건을 위한 요일, 시작시간, 종료시간

- 순번조건을 위한 상영순번

- 할인 조건의 종류

isSatisfiedBy 메서드는 type값에 따라 적절한 메서드 호출

 

DiscountCondition 개선하기

 

문제점 - 변경에 취약한 클래스를 포함하고 있다.

 

DiscountCondition 은 세가지 다른 이유로 변경 가능성 있다.

  • 새로운 할인조건 추가
  • 순번조건을 판단하는 로직 변경
  • 기간 조건을 판단하는 로직 변경

하나 이상의 변경이유를 가지므로 응집도가 낮다.

-> 변경의 이유에 따라 클래스를 분리해야 한다.

 

인스턴스 변수가 초기화 되는 시점을 보자

응집도가 높은 클래스는 인스턴스 생성할 때 모든 속성을 함께 초기화 함. 응집도가 낮은 클래스는 속성중 일부만 초기화 하고 일부는 남겨지기 때문에 문제가 된다.

-> 초기화 되는 속성을 기준으로 코드를 분리

 

메서드가 인스턴스 변수를 사용하는 방식을 보자

사용하는 속성에 따라 그룹이 나뉠때 클래스 응집도가 낮다

-> 속성그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리

 

 

타입 분리하기

 

기간

public class PeriodCondition {
    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;
    }
    
    public boolean isSatisfiedBy (Screening screening) {
        return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
        startTime.compareTo(screening.getWhenScreened().toLocalTime())<=0 &&
        endTime.compareTo(screening.getWhenScreened().toLocalTime()) >=0);
    }
}

 

순번

 

public class SequenceCondition {
    private int sequence;
    
    public SequenceCondition(int sequence) {
        this.sequence = sequence;
    }
    
    public boolean isSatisfiedBy (Screening screening) {
        return sequence == screening.getSequence();
    }
}

 

클래스분리 후 새로운 문제 - 두개의 서로다은 클래스의 인스턴스와 협력해야함.

 

방법1) Movie 클래스 안에 SequenceCondition 목록과 PeriodCondition 목록을 따로 유지하는 것.

새로운 문제 - Movie가 클래스 양쪽에 모두 결합. 결합도가 높아짐.

 

public class Movie {
    private List<PeriodCondition> periodConditions;
    private List<SequenceCondition> sequenceConditions;
    
    private boolean isDiscountable(Screening screening) {
        return checkPeriodCondition(screening) || checkSequenceConditions(screening);
    }
    
    private boolean checkPeriodCondition(Screening screening) {
        return periodCondions.stream()
            .anyMatch(condition -> condition.isSatisfiedBy(screening));
    }
    
    private boolean checkSequenceCondition(Screening screening) {
        return sequenceCondions.stream()
            .anyMatch(condition -> condition.isSatisfiedBy(screening));
    }
}

 

응집도가 높아졌지만 변경과 캡슐화 관점에서는 품질이 나빠짐.

 

 

다형성을 통해 분리하기

 

역할을 대체할 클래스들 사이에서 구현을 공유해야 할 필요가 있다추상클래스

구현을 공유할 필요 없이 역할을 대체하는 객체들의 책임만 정의하고 싶다인터페이스

 

DiscountCondition 인터페이스를 이용하자.

 

public interface DiscountCondition {
    boolean isSatisfiedBy(Screening screening);
}

public class PeriodConditon implements DiscountContion{
    ...
}

public class SequenceConditon implements DiscountContion{
    ...
}

 

Movie가 전송한 메세지를 수신한 객체의 구체적인 클래스가 무엇인가에 따라 적절한 메서드가 실행될 것이다.

 

POLYMORPHISM(다형성) 패턴 - 객체의 타입에 따라 변하는 행동이 있다면 분리하고 변화하는 행동을 각 타입의 책임으로 할당하라

 

변경으로부터 보호하기

 

Movie의 관점에서 새로운 DiscountCondition 이 추가되더라도 영향을 받지 않음. 할인조건의 종류를 확장할 수 있다.

 

변경을 캡슐화 하도록 책임을 할당하는것 - PROTECTED VARIATIONS(변경보호) 패턴

 

변경과 확장에 유연하게 대처할 수 있는 설계

 

Movie 클래스 개선하기

 

Movie 의 경우 구현을 공유할 필요가 있으므로 추상클래스를 이용해 역할을 구현하자

 

public abstract class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private List<DiscountCondition> discountCondition;
    
    public Movie (String title, Duration runningTime, Money fee, 
        DiscountCondition... discountConditions) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.discountConditions = Arrays.asList(discountConditions);
    }
    
    public Money calculateMovieFee(Screening screening) {
        if (isDiscountable(screening)) {
            return fee.minus(calculateDiscountAmount());
        }
        
        return fee;
    }
    
    private boolean isDiscountable(Screening screening){
        return discountCondition.stream()
            .anyMatch(condition -> condition.isSatisfiedBy(screening));
    }
    
    abstract protected Money calculateDiscountAmount();
}

 

 

discountAmount, discountPercent 와 메서드들은 적절한 자식 클래스로 옮겨진다.

 

 

public class AmountDiscountMovie extends Movie {
    private Money discountAmount;
    
    public AmountDiscountMovie(String title, Duration runningTime, Money fee, 
        , Money discountAmount, DiscountCondition... discountConditions) {
        super(title, runningTime, fee, discountCondition);
        this.discountAmount = discountAmount;
    }
    
    @Override
    protected Money calculateDiscountAmount() {
        return discountAmount;
    }
}

 

calculateDiscountAmount 메서드를 오버라이딩한 후 할인금액 반환

 

public class PercentDiscountMovie extends Movie {
    private double percent;
    
    public PercentDiscountMovie(String title, Duration runningTime, Money fee, 
        , double percent, DiscountCondition... discountConditions) {
        super(title, runningTime, fee, discountCondition);
        this.percent = percent;
    }
    
    @Override
    protected Money calculateDiscountAmount() {
        return getFee().times(percent);
    }
}

 

영화의 기본금액 필요

Movie에 금액을 반환하는 getFee() 생성. 서브클래스에서만 사용하므로 protected

protected Money getFee() {
    return fee;
}

 

 

할인정책 적용하지 않을때

 

public class NoneDiscountMovie extends Movie {    
    public NoneDiscountMovie(String title, Duration runningTime, Money fee) {
        super(title, runningTime, fee, discountCondition);
    }
    
    @Override
    protected Money calculateDiscountAmount() {
        return Money.ZERO;
    }
}

 

모든 클래스 내부구현 캡슐화, 변경의 이유를 하나씩만 가진다.

응집도는 높고 결합도는 느슨하다

클래스는 작고 한가지 일만 수행하며 이제 책임은 적절히 분배되어 있다.

 

변경과 유연성

 

 설계를 주도하는 것은 변경. 그에 대비하는 두가지 방법

- 코드를 이해하고 수정하기 쉽도록 단순하게 설계

- 코드를 수정하지 않고도 변경을 수용할 수 있도록 더 유연하게 설계

 

상속 대신 합성을 사용

 

새로운 할인정책이 추가되더라도 추가적인 코드를 작성할 필요가 없고 

단순히 새로운 클래스를 추가하고 클래스의 인스턴스를 Movie의 changeDiscountPolicy 메서드에 전달 하기만하면 됨.

 

 

Movie movie = new Movie("타이타닉",
                    Dration.ofMinutes(120),
                    Money.wons(10000),
                    new AmountDiscountPolicy(...));
movie.changeDiscountPolicy(new PercentDiscountPolicy(...));

 

도메인 모델은 도메인이 포함된 개념과 관계뿐만 아니라 도메인이 요구하는 유연성도 정확하게 반영한다.

구현과 밀접한 관계를 맺음

 


04 책임 주도 설계의 대안

 

겉으로 보이는 동작은 바꾸지 않은 채 내부구조를 변경하는것 리팩터링(Refactoring)

 

메서드 응집도

 

데이터 중심으로 설계된 영화 예매 시스템은 모든 절차는 ReservationAgency에 집중되어 있었음.

 

reserve메서드는 길고 이해하기 어려움.

 

몬스터 메서드(monster method)

- 응집도가 낮은 메서드는 로직의 흐름을 이해하기 어려움.

- 메서드의 응집도를 높이는 이유도 변경과 관련이 깊다.

- 클래스가 작고 목적이 분명한 메서드가 좋음.

 

 

객체를 자율적으로 만들자

 

메서드가 사용하는 데이터를 저장하고 있는 클래스로 메서드를 이동시키자.

-캡슐화, 높은 응집도, 낮은 결합도를 위해

 

ReservationAgency 의 isDiscountable 메서드를 DiscountCondition으로 이동

 

public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;
    
    public boolean isDiscountable(Screening screening) {
        if(type == DiscountConditionType.PERIOD) {
            return isSatisfiedByPeriod(screening);
        }
        
        return isSatisfiedBySequence(screening);
    }
    
    private boolean isSatisfiedByPeriod(Screening screening) {
        return screening.getWhenScreened().getDayOfWeek().equals(dayOfWeek) &&
        startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
        endTime.compareTo(screenging.getWhenScreened().toLocalTime()) >= 0;
    }
    
    private boolean isSatisfiedBySequence() {
        return sequence == screening.getSequence();
    }
}

 

isDiscountable 메서드는 퍼블릭으로 변경

 

DiscountCondition 내부에서만 인스턴스 변수에 접근. 캡슐화 됨.

 

할인조건을 계산하는 로직이 모여있어 응집도가 높아짐.

 

이제 ReservationAgency 는 메서드만 호출하도록 변경.

 

public class ReservationAgency {
    private boolean checkDiscountable(Screenging screening) {
        return screenging.getMovie().getDiscountCondition().stream()
            .anyMatch(condition ->  condition.isDiscountable(screening));
    }
}

 

 

이제 책임 주도 설계 방법을 적용했던 DiscountCondition 클래스의 초기모습과 비슷해졌다.

 

책임주도 설계 방법에 익숙하지 않다면 일단 데이터 중심으로 구현한 후 이를 리팩터링 해도 유사한 결과를 얻을 수 있다. 일단 동작하는 코드를 작성한 수 에 리팩터링하는것이 더 훌륭한 결과물을 낳을수도 있다.