본문 바로가기
스터디/오브젝트

오브젝트 05 - 책임 할당하기

by 디토20 2024. 2. 20.
반응형

 

 

 

 

 

오브젝트 05 - 책임 할당하기

 

 

5.1 책임 주도 설계를 향해

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

  • 객체에게 할당된 책임이 협력에 어울리지 않는다면 그 책임은 나쁜 것이다.
  • 객체의 입장에서는 책임이 조금 어색해 보이더라도 협력에 적합 하다면 그 책임은 좋은것이다.
  • 협력을 시작하는 주체는 메시지 전송자이기 때문에 좋은 책임이란 메시지 전송자에게 적합한 책임의미한다.
  • 메시지를 먼저 결정하면 메시지 수신자에 대한 정보가 없기 때문에 전송자의 관점에서 메시지 수신자가 깔끔하게 캡슐화된다.

 

5.2 책임 할당을 위한 GRASP 패턴

- 대중적으로 가장 널리 알려진 객체지향 패턴

 

5.2.1 도메인 개념에서 출발하기

  • 설계를 시작하기 전 도메인에 대한 모습을 대략적으로 그려 보는것은 유용하다.
  • 도메인 개념들을 책임 할당의 대상으로 사용하면 코드에 도메인의 모습을 투영하기가 좀 더 수월해진다.
영화 예매 시스템을 구성하는 도메인 개념

 

  • 설계를 시작하는 단계에서는 개념들의 의미와 관계가 정확하거나 완벽할 필요가 없다.
  • 중요한 것은 설계를 시작하는 것이지 도메인 개념들을 완벽하게 정리하는 것이 아니므로 너무 많은 시간을 들이지 말고 빠르게 설계와 구현을 진행하라.
  • 올바른 구현을 이끌어낼 수만 있다면 그것은 올바른 도메인 모델이다.

 

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

  • 책임 주도 설계 방식의 첫 단계는 애플리케이션이 제공해야 하는 기능을 애플리케이션의 책임으로 생각하는 것이다.
  • 메시지는 메시지를 수신할 객체가 아니라 메시지를 전송할 객체의 의도를 반영해서 결정해야 한다.
  • 책임 주도 설계를 하기 위해서는 두가지 질문을 반복해서 해야한다.
    1. 메시지를 전송할 객체는 무엇을 원하는가?
    2. 메시지를 수신할 적합한 객체는 누구인가?

 

 

 

 

 

5.2.2 높은 응집도와 낮은 결합도

  • 설계는 트레이드오프 활동이다.
  • 동일한 기능은 수많은 방법으로 구현될 수 있어, 몇 가지 설계 중에서 한 가지를 선택해야 하는 경우가 빈번하다.
  • 예를 들어, 앞서 설계한 스템에서는 할인 요금을 계산하기 위해 MovieDiscountCondition할인 여부를 판단을 요청한다.
  • 그렇설계의 대안으로 Movie 대신 Screening직접 DiscountCondition과 협력하하는 것은 어떨까?

 

 

  • 위 설계는 기능적인 측면에서만 놓고 보면 앞의 설계와 동일하다.
  • 그렇다면 왜 우리는 이 설계 대신 Movie가 DiscountCondition과 협력하는 방법을 선택한 것일까?
  • 그 이유는 결합도응집도에 있다.

 

결합도 측면
  • 도메인 상으로 MovieDiscountCondition목록을 속성으로 포함하고 있어 두 객체는 이미 결합되어 있다.
  • 하지만 ScreeningDiscountCondition협력할 경우에는 ScreeningDiscountCondition 사이에 새로운 결합도가 추가된다.
  • 따라서 결합도 관점에서는 DiscountConditionScreening 협력하는 것보Movie와 협력하는 더 낫다.

 

응집도 측면
  • Screening의 핵심 책임은 예매하는 것이나, DiscountCondition과 협력 한다면 Screening은 요금 계산 책임의 일부를 지게 된다.
  • 이 경우 Screening 은 DiscountCondition이 할인 여부를 판단할 수 있고 Movie가 이 할인 여부를 필요로 한다는 사실 역시 알고있어야한다.
  • 즉, 예매 요금을 계산하는 방식이 변경될 경우 Screening도 함께 변경해야 하는 것이다.
  • 결과 적으로 Screening과 DiscountCondition이 협력하게 되면 Screening은 서로 다른 이유로 변경되는 책임을 짊어지게 되므로 응집도가 낮아진다.
  • 반면 Movie의 주된 책임은 영화 요금을 계산하는 것이다. 따라서 영화 요금을 계산하는 데 필요한 할인 조건을 판단하기 위해 Movie가 DiscountCondition과 협력하는 것은 응집도에 아무런 불이익이 없다.

 

 

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

  • 객체를 A 생성해야 할 때 어떤 객체에게 객체 생성 책임을 할당해야 하는가?
    • 객체가 A 객체를 포함하거나 참조한다.
    • 객체가 A 객체를 기록한다.
    • 객체가 A 객체를 긴밀하게 사용한다.
    • 객체가A 객체를 초기화하는데 필요한 데이터를 가지고있다.
  • 즉, 생성될 객체에 대해 잘 알고 있어야 하거나 그 객체를 사용해야 하는 객체는 어떤 방식으로든 생성될 객체와 결합되므로, 해당 객체를 생성할 책임을 맡기는 것이다. 
  • 이미 결합돼 있는 객체에게 생성 책임을 할당하는 것은 설계의 전체적인 결합도에 영향을 마치지 않으므로 이미 존재하는 객체 사이의 관계를 이용하기 때문에 설계가 낮은 결합도를 유지할 수 있게 한다.

 

5.3 구현을 통한 검증

Screening을 구현하기
  • Screening은 영화를 예매할 책임을 맡으며   Reservation 인스턴스를 생성할 책임을 수행해 한다.
  • 다시 말해 Screening은 예매에 대한 정보 전문가인 동시에 Reservation 창조자다.
  • 협력 관점에서 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의 내부 구현에 대한 어떤 지식도 없이 전송할 메시지를 결정했다는 것이 중요하다.
  • 이처럼 Movie의 구현을 고려하지 않고 필요한 메시지를 결정하면 Movie의 내부 구현을 깔끔하게 캡슐화할 수 있다.
  • 메시지가 변경되지 않는  Movie에 어떤 수정을 가하더라도 Screening에는 영향을 미치지 않는다.

 

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 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;
    }
}

 

  • 요금을 계산하기 위해 Movie는 기본 금액(fee), 할인 조건(discountConditions), 할인 정책 등의 정보를 알아야 한다.
  • Movie는 먼저 discountConditions 원소를 차례대로 순회하면서 할인 여부를 판단하도록 요청한다.
  • 만약 할인 조건을 만족한다면 할인 요금을 계산하기 위해 calculateDiscountAmount 메서드 호출한다.

 

 

DiscountCondition 구현하기

 

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.isAfter(screening.getWhenScreened().toLocalTime()) >=0;
    }
    
    private boolean isSatisfiedBySequence(Screening screening) {
    	return sequence == screening.getSequence();
    }
}

 

  • DiscountCondition은 기간 조건을 위한 변수와 순번 조건을 위한 변수를 인스턴스 변수로 포함한다.
  • DiscountCondition은 할 조건을 판단하 위해 Screening 상영 시간과 상영 을 알아야 다.

 

 

5.3.1 코드 개선하기

  • 변경에 취약한 클래스를 포함하는 것은 큰 문제다.
  • 하나 이상의 변경 이유를 가진다면 응집도가 낮아지므로, 변경의 이유에 따라 클래스를 분리해야 한다.
  • 변경의 이유가 하나 이상인 클래스를 찾아내는 두가지 방법은 아래와 같다.
인스턴스 변수가 초기화되는 시점
  • 응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께 초기화한다.
  • 응집도가 낮은 클래스는 객체의 속성 중 일부만 초기화하고 일부는 초기화하지 않는다.
  • 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다.
    • ex) sequence(순번 조건) / dayOfWeek, startTime, endTime(기간 조건)

 

메서드들이 인스턴스 변수를 사용하는 방식
  • 모든 메서드가 객체의 모든 속성을 사용한다면 클래스의 응집도는 높다.
  • 반면 메서드들이 사용하는 속성에 따라 그룹이 나뉜다면 클래스의 응집도가 낮다.
  • 속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리해야 한다.

 

5.3.2 타입 분리하기

  • DiscountCondition의 가장 큰 문제는 두 개의 독립적인 타입이 하나의 클 래스 안에 공존하고 있다는 점이다.
  • 우선, 두 타입을 SequenceCondition과 PeriodCondition이라는 두 개의 클래스로 분리해보자.

 

public class PeriodCondition {
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime 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 boolean isSatisfiedBy(Screening screening) {
    	return sequence == screening.getSequence();
    }
}

 

  • 이렇게 하면, 앞에서 언급한 두가지 문제점이 해결되어 응집도가 향상된다.
  • 그러나 위의 코드를 사용하면 Movie의 인스턴스는 두 객체와 결합하게 되어 결합도가 높아진다.

 

5.3.3 다형성을 통해 분리하기

  • Movie의 객체는 클래스의 구현은 중요하지 않고 할인 여부 판단만이 중요한 요소다.
  • 역할을 사용해 객체의 구체적인 타입을 추상화 하자.

  • 이처럼 객체의 타입에 따라 행동을 분기해야 한다면 타입을 클래스로 정의하고 행동을 나누어 응집도 문제를 해결할 수 있다.
  • 이를 다형성(POLYMORPHISM) 패턴이라고 부른다.

 

 

5.3.4 변경으로부터 보호하기

  • DiscountCondition이라는 추상화가 구체적인 타입을 캡슐화한다.
  • 이는 기존 코드의 구현이 변경되거나 새로운 할인 조건이 추가되어도 Movie는 영향을 받지 않는것을 의미한다.
  • 오직 구현 클래스를 추가하는 것으로 할인 조건을 확장할 수 있다.
  • 이처럼 변경을 캡슐화하도록 책임을 할당하는 것을 변경 보호(PROTECTED VARIATIONS) 패턴이라고 부른다.
  • 하나의 클래스가 여러 타입의 행동을 구현한다면, 클래스를 분해핳고 책임을 분산시켜라

 

 

5.4 책임 주도 설계의 대안

  • 책임 주도 설계가 어렵다면, 최대한 빠르게 코드를 작성한 후 책임을 올바른 위치로 이동시키자.
  • 주의할 점은 코드를 수정한 후에 겉으로 드러나는 동작이 바뀌어서는 안된다.
  • 캡슐화를 향상시키고, 응집도를 높이고, 결합도를 낮춰야 하지만 동작은 그대로 유지해야 한다.
  • 이처럼 노출되는 동작은 바꾸지 않은 채 내부 구조를 변경하는 것을 리팩터링이라고 부른다.

 

5.4.1 메서드 응집도

  • 데이터 중심으로 설계의 도메인 객체들은 데이터의 집합일 뿐, 영화 예매의 모든 절차는 ReservationAgency에 집중돼 있었다.
  • ReservationAgency에 포함된 로직들을 적절한 객체의 책임으로 분배하면 책임 주도 설계와 거의 유사한 결과를 얻을 수 있다.
public class ReservationAgency {
    public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
        Movie movie =screening.getMovie();
        
        boolean discountable = false;
        for(DiscountCondition condition : movie.getDiscountConditions()) {
            if (condition.getType() == DiscountConditionType.PERIOD) {
                discountable = screening.getWhenScreened().getDayOfWeek().equals(condition.getDayOfWeek()) &&
                condition.getStartTime().compareTo(screening.getWhenScreened().tolocalTime()) <= 0 &&
                condition.getEndTime().compareTo(screening, getWhenScreened().tolocalTime()) >= 0; 
            } else {
                discountable = condition.getSequence() == screening.getSequence();
            }

            if (discountable) {
                break;
            }
        }
        
        Money fee;
        if (discountable) {
            Money discountAmount = Money.ZERO; 
            switch(movie.getMovieType()) {
                case AMOUNT_DISCOUNT:
                    discountAmount = movie.getDiscountAmount() ;
                    break;
                case PERCENT_DISCOUNT:
                    discountAmount = movie.getFee().times(movie.getDiscountPercent());
                    break;
                case NONE_DISCOUNT:
                    discountAmount = movie.getFee() ;
                    break;
        	}
            
            fee = movie.getFee().minus(discountAmount).times(audienceCount);
        } else {
            fee = movie.getFee();
        }
        return new Reservation(customer, screening, fee, audienceCount);
    }
}

 

문제점 - 몬스터 메서드
  • 코드를 한눈에 파악하기 어려워 이해하는데 너무 많은 시간이 걸린다
  • 하나의 메서드 안에서 너무 많은 작업을 처리해 변경이 필요할 때 수정할 부분을 찾기 어렵다.
  • 메서드 내부의 일부 로직만 수정해도 나머지 부분에서 버그 발생 확률이 높다.
  • 로직의 일부만 재사용하는 것이 불가능하다.
  • 코드를 재사용하기 위해서는 복붙해야 하므로 코드 중복을 초래한다.

 

해결방법
  • 메서드를 작게 분해해서 각 메서드의 응집도를 높여라.
  • 응집도 높은 메서드는 변경되는 이유가 단 하나여야한다.
  • 목적이 명확한 메서드들로 구성돼 있다면 변경을 처리하기 위해 어떤 메서드를 수정해야 하는지 쉽게 판단할 수 있다.
  • 메서드의 크기가 작고 목적이 분명해 재사용하기 쉽다.
public class ReservationAgency {

    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);
    }
    .
    .
    .
    
}

 

  • 이렇게 할 경우, 비록 클래스의 길이는 더 길어지지만 작고 명확하고 응집도 높은 메서드들로 구성된다.
  • 전체적인 흐름을 이해하기 쉬워진다.
  • 코드가 변경하기 쉬워진다.
728x90
반응형

댓글