카테고리 없음

오브젝트 - 5. 책임 할당하기

jimanfe 2024. 12. 28. 01:47

데이터 중심 설계

- 행동보다 데이터를 먼저 결정

- 협력이라는 문맥을 벗어나 고립된 객체의 상태에 초점

때문에 

- 캡슐화를 위반하기 쉽고

- 결합도가 높아지며, 코드를 변경하기 어려워진다.

 

이에 대한 해결책은

- 데이터가 아닌 책임에 초점을 두는것 

이다. 

 

책임 중심설계는 어렵다.

- 어떤 객체에게 어떤 책임을 할당할지 결정하기 쉽지 않다 

 

일종의 트레이드오프 활동이며, 동일한 문제를 해결할 여러 대안이 존재한다. 

어떤게 최선인지는 상황과 문맥에 따라 다르다. 

 

책임 주도 설계

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

데이터는 객체가 책임을 수행하는데 필요한 재료를 제공할 뿐이다.

책임 중심의 설계에서는

1. 이 객체가 수행해야하는 책임은 무엇인가를 결정하고

2. 책임을 수행해야하는 데 필요한 데이터는 무엇인가를 결정한다. 

 

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

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

객체의 입장이 아닌 객체가 참여하는 협력이다. 

협력의 시작 주체는  메시지 전송자이다. 

때문에 협력에 적합한 책임이란 메시지 전송자에게 적합한 책임이다. 이는 곧 메시지를 전송하는 클라이언트의 의도에 적합한 책임을 할당해야됨을 의미한다. 

 

설계를 위해 해야할 질문

- X: 이클래스가 무엇을 해야하지?

- O: 메시지를 전송해야하는데 누구한테 전송하지? 

 

메시지는 클라이언트의 의도를 표현한다. 객체를 결정하기 전에 객체가 수신할 메시지를 먼저 결정한다. 

클라이언트는 어떤 객체가 메시지를 수신할지 알지 못한다. 

- 단지 임의 객체가 메시지를 수신할 것이라는 사실을 믿고 자신의 의도를 표현한 메시지를 전송한다. 

 

메시지 송신자는 메시지 수신자에 대한 어떠한 가정도 할 수 없다. 

메시지 전송자의 관점에서 메시지 수신자가 깔끔하게 캡슐화되는 것이다. 

 

책임 주도 설계

- 시스템이 사용자에게 제공해야하는 기능인 시스템 책임을 파악한다. 

- 시스템 책임을 더 작은 책임으로 분할한다. 

- 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임을 할당한다. 

- 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 또는 역할을 찾는다. 

- 해당 객체 또는 역할에게 책임을 할당함으로서 두 객체가 협력하게 한다. 

 

 

책임 할당을 위한 GRASP 패턴

GRASP

- Genenral Responsibility Assignment Software Pattern

 

설계 과정은 도메인안에 존재하는 개념들을 정리하는 것으로 시작된다. 

 

도메인 개념

도메인 개념들을 책임할당의 대상으로 사용하면 코드에 도메인의 모습을 투영하기가 수월해진다. 

 

영화예매 시스템 

- 예매와 상영은 n : 1

- 상영과 영화는 n : 1

- 영화와 할인 조건은 1: n

- 영화는 금액할인 영화, 비율할인 영화가 있다. 

- 할인 조건은 순번 조건과 기간 조건이 있다. 

 

책임을 할당받을 객체들의 종류와 관계에 대한 유용한 정보를 제공할 수 있으면 충분하다. 

 

도메인 모델이 구현을 염두에 두고 구조화 되는 것이 바람직하다.

 

정보 전문가에게 책임 할당

애플리케이션이 제공해야 하는 기능을 애플리케이션의 책임으로 생각하는 것이다. 

 

책임을 수행하는 데 필요한 메시지를 결정해야한다

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

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

 

영화 예매시스템

- 메시지의 이름: 예매하라

 

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

- 객체는 상태와 행동을 통합한 캡슐화의 단위

- 자신의 상태를 스스로 처리하는 자율적인 존재 

 

책임과 책임을 수행하는 데 필요한 상태는 동일한 객체안에 존재해야한다. 

 

정보 전문가 패턴

- 책임을 수행할 정보를 알고있는 정보 전문가 에게 책임을 할당

- 정보를 알고있는 객체만이 책임을 어떻게 수행할 지 스스로 결정

- 정보는 데이터와 다르다 : 정보를 알고 있다고 해서 그 정보를 저장할 필요는 없다. 

- 만약 스스로 처리할 수 없는 작업이 있다면 외부에 도움을 요청해야한다. 

 

 

예매의 경우, 필요한 정보를 가장 많이 알고있는 객체에게 "예매하라" 메시지를 처리할 책임 할당 (Screening)

- 영화 정보

- 상영시간

- 상영 순번

 

필요한 작업

- 예매 가격 계산

  - 영화 한편 가격 * 예매 인원 수

  - 가격을 계산하는데 필요한 정보를 모르기 때문에 외부의 객체에게 도움 요청 필요 (메시지: 가격을 계산하라)

  - Movie: 영화 가격을 계산하는데 필요한 정보를 알고 있음 

Movie 가 해야되는 것 

- 할인 가능한지 판단 

- 할인 정책에 따라 할인 요금을 제외한 금액 계산

- 영화가 스스로 처리할 수 없는 일: 할인 조건에 따라 할인 가능한지 판단하는 것

  - 메시지: 할인 여부를 판단하라, 객체: DiscountCondition 

 

 

설계 품질 평가

LOW Coupling패턴

- 의존성을 낮추고 변화의 영향을 줄이며 재사용성을 증가

 

HIGH Cohesion 패턴

- 높은 응집도를 유지할 수 있게 책임 할당 

 

 

책임을 할당할 수 있는 다양한 대안들이 존재. 응집도와 결합도 측면에서 더 나은 대안을 선택

 

위에서 설계한 대안

- Movie 와 DiscountCondition은 이미 결합

- DiscountCondition 과 협력하게 하면 설계 전체적으로 결합도를 추가하지 않고 협력 완성 

 

만약 Screening 이 직접 DiscountCondition 과 협력한다면? 

- Screening 과 DiscountCondition 사이에 새로운 결합 추가 

- Screening 이 영화 요금 계산과 관련된 책임 일부를 떠안아야 할 것

  - DiscountCondition이 할인여부를 판단할 수 있다는 사실 

  - Movie 가 할인 여부를 필요로 한다는 사실

- 예매 요금을 계산하는 방식이 변경되면 Screening 도 함께 변경

 

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

CREATOR 패턴

- 객체를 생성할 책임을 어떤 객체에게 할당할지에 대한 지침

객체 A를 생성할때 아래조건을 최대한 많이 만족하는 B 에게 할당

- B가 A 객체를 포함하거나 참조

- B가 A 객체를 기록

- B가 A객체를 긴밀하게 사용

- B가 A객체를 초기화하는데 필요한 데이터를 가지고 있다 (정보 전문가)

 

영화 협력의 최종 결과물: Reservation인스턴스 생성 

Screening

- 영화, 상영시간, 상영 순번에 대한 정보 전문가 

- 예매요금에 필요한 Movie 를 알고 있다. 

 

 

구현을 통한 검증

Screening 

 

public class Screening {
  

  
  // 2. 책임 수행에 필요한 인스턴스 변수 결정 
  private Movie movie;
  prviate int sequence;
  private LocalDateTime whenScreened;
  

  //1. 책임 결정: 예매하라
  public Reservation reserve(Customer customer, int audienceCount) {
  
  }
  
  // 3. 외부 객체에 도움요청 
  // 시그니쳐가 Screening screening: 이 메시지는 수신자인 Movie가 아니라 송신자인 Screening 의 의도를 표현 
  // Movie의 구현을 고려하지 않고 필요한 메시지를 결정 -> Movie의 내부구현을 깔끔하게 캡슐화 
  private Money calculateFee(int audienceCount) {
    return movie.calculateMovieFee(this).times(audienceCount);
  }
  
  public LocalDateTime getWhenScreened() {
    return whenScreened;
  }
  
  public int getSequence () {
    return sequence;
  }
  
}

 

 

Movie

public class Movie {
  // 2. 정보
  // 기본 금액, 할인 조건, 할인 정책 등의 정보를 알아야한다. 
  private String title;
  private Duration runningTime;
  private Money fee;
  private List<DiscountCondition> discountConditions
  
  private MovieType movieType;
  private Money discountAmount;
  private double discountPercent;
  
  // 1. 책임: Screening 은 Movie 와 협력하기 위해 calculateMovieFee 메시지를 전송 
  public Money caculateMovieFee(Screening screening) {
    // discountConditions 를 순회 및 DiscountCondition 인스턴스에게 isSatisfiedBy 메시지 전송 
    if(isDiscountable(screening)) {
      return fee.minus(calculateDiscountAmount());
    }
    
    return fee;
  }
  
  private boolean isDiscountable(Screening screening) {
    return discountConditions.stream().anyMatch(condition -> condition.isSatisfiedBy(sceening))
  }
  
  private Money calculateDiscountAmount() {
    switch(movieType) {
      case AMOUNT_DISCOUNT: 
        return calculateAmountDiscountAmount();
      case PERCENT_DISCOUNT:
        return calculatePercentDiscountAmount();
      case NONE_DISCOUNT:
        return calculateNoneDiscountAmount();
    }
    
    throw new IllegalStateException();
  }
  
  private Money calculateAmountDiscountAmount() {
    return discountAmount;
  }
  
  private Money calculatePercentDiscountAmount () {
    return fee.times(discountPercent);
  }
  
  private Money calculateNoneDiscountAmount() {
    return Money.ZERO;
  }
}

public enum MovieType {
  AMOUNT_DISCOUNT,
  PERCENT_DISCOUNT,
  NONE_DISCOUNT
}

 

 

DiscountCondtion

 

public class DiscountCondition {
  private DiscountConditionType type;
  private int sequence;
  private DayOfWeek dayOfWeek;
  private LocalTime startTime;
  private LocalTime endTime;
  
  // 새로운 할인조건이 추가된다면 if else 구문을 추가하여야한다. 
  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.isAfter(screening.getWhenScreened().toLocalTime()) >= 0;
  }
  
  // 순서조건을 판단하는 로직이 변경된다면 다음 메서드의 내부구현이 변경되어야한다. 
  private boolean isSatisfiedBySequence(Screening screening) {
    return sequence == screening.getSequence();
  }
}

public enum DiscountConditionType {
  SEQUENCE, // 순번 조건
  PERIOD // 기간 조건
}

 

변경의 이유를 파악할 수 있는 방법

1. 인스턴스 변수가 초기화되는 시점을 살펴보는것

응집도가 낮은 클래스는 객체의 속성 중 일부만 초기화 

-> 함께 초기화되는 속성을 기준으로 코드를 분리해야한다. 

 

2. 메서드들이 인스턴스 변수를 사용하는 방식 

모든 메서드가 객체의 모든 속성을 사용한다면 클래스의  응집도는 높다. 

 

메서드들이 사용하는 속성에 따라 그룹이 나뉜다면 응집도가 낮다

 

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

 

 

DiscountCondtion 개선

as-is : 순번조건과 기간조건이라는 두개의 독립적인 타입이 하나의 클래스안에 공존 

 

 

public class PeriodCondition {
  private DayOfWeek dayOfWeek;
  private LocalTime startTime;
  private LocalTime endTime;
  
  public boolena 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 SequenceCondtion (int sequence) {
    return sequence === screening.getSequence();
  }
}

 

 

두 클래스가 할인 여부를 판단하기 위해 사용하는 방법이 서로 다르다는 사실은 Movie 입장에서는 그다지 중요하지 않다. 

두 클래스는 동일한 역할이고, 역할은 협력 안에서 대체 가능성을 의미한다. 

 

역할을 대체할 클래스들 사이에서 구현을 공유해야한다면, 추상클래스를 사용하면 된다. 

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

 

 

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

 

public class PeriodCondition implements DiscountCondition {...}

public class SequenceCondition implements DiscountCondition {...}

 

객체의 암시적인 타입에 따라 행동을 분기해야한다면 암시적인 타입을 명시적인  클래스로 정의하고 행동을 나눔으로서 응집도 문제를 해결할 수 있다. 

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

두개의 서로 다른 변경이 두개의 서로다른 클래스 안으로 캡슐화된다. 

DiscountCondition이라는 역할이 Movie로부터 PeriodCondition 과 SequenceCondition의 존재를 감춘다. 

 

변경을 캡슐화하도록 책임을 할당 -> PROTECTED VARIATION 패턴

 

 Movie Class 개선

 

금액할인 정책 영화와 비율 할인 정책 영화라는 두가지 타입을 하나의 클래스 안에서 구현하고 있다. 

다형성 패턴을 통해 서로다른 행동을 타입별로 분리하면 Screening 과 Movie가 다형적으로 협력하기 때문에 Movie의 타입을 추가하더라도 Screening 에 영향을 미치지 않게 할 수 있다. 

Movie 의 경우에는 구현을 공유할 필요가 있다. 

 

public abstract class Movie {
  private String title;
  private Duration runningTime;
  private Money fee;
  private List<DiscountCondition> discountConditions;
  
  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 calcuateMovieFee(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 protected Money calculateDiscountAmount();
    
    
}

 

 

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

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

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