05 책임 할당하기

 

들어가며

 

데이터가 아닌 책임에 초점을 맞추면 데이터 중심 설계로 인해 발생되는 문제를 해결할 수 있다.

그러나 책임 주도 설계시 어떤 객체에게 어떤 책임을 할당할지 결정하기 쉽지않다.

GRASP 패턴은 책임할당의 어려움을 해결하기 위한 답을 제시해 줄 것이다.

 

 

 

01 책임 주도 설계를 향해

 

책임 중심 설계로 전환되기 위해서는 다음 두 가지 원칙을 따라야 한다.

 

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

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

 


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

 

클라이언트 관점에서 객체가 수행하는 행동이란 곧 객체의 책임을 의미한다. 그러므로 객체는 데이터가 아닌 외부에 제공하는 행동이 중요하다.

 

책임 중심 설계에서는 객체가 수행해야하는 책임을 결정 한 후 책임 수행에 필요한 데이터를 파악하는 것이다.

 

객체지향 설계에서 가장 중요한 것은 객체에게 적절한 책임을 할당하는 능력이다.

어떤책임을 할당 할지는 협력이라는 문맥안에서 결정할 수 있다.

 


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

 

책임은 객체의 입장이 아니라 객체가 참여하는 협력에 적합해야 한다.

예를 들면 협력을 시작하는 주체는 메세지 전송자이기 때문에 협력에 적합한 책임이란 메세지 수신자가 아니라 메세지 전송자에게 적합한 책임을 의미한다.

 

협력에 적합한 책임을 수확하기 위해서는 메세지를 결정한 후 그 메세지를 처리할 객체를 선택해야 한다.

 

위와 같은 내용을 주목해보면 클라이언트는 어떤 객체가 메세지를 수신할지 알지 못한다. 메세지를 먼저 결정하기 때문에 메세지 송신자는 메세지 수신자에 대해 어떠한 가정도 할 수 없어 송신자 관점에서 수신자는 깔끔하게 캡슐화된다.

 

 

 

02 책임 할당을 위한 GRASP 패턴

 

GRASP (General Responsibility Assignment Software Pattern, 일반적인 책임 할당을 위한 소프트웨어 패던) 이란?

책임 할당 기법중 가장 널리알려진 패턴이다.

 


도메인 개념에서 출발하기

 

영화 예매 시스템을 구성하는 도메인 개념

 

설계를 시작하기 전에 도메인에 대한 모습을 대략적(자세히 말고)으로 그린 후 도메인의 개념들을 책임 할당 대상으로 사용하자.

 


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

 

책임 주도 설계 방식의 첫 단계는 애플리케이션이 제공해야 하는 기능을 애플리케이션의 책임으로 생각하는 것이다.

이 책임을 애플리케이션에 대해 전송된 메세지로 간주하고 이 메세지를 책임일 첫 번째 객체를 선택하는 것으로 설계가 시작된다.

 

애플리케이션은 사용자에게 영화를 예매하는 기능을 제공해야 한다. 이를 책임으로 간주하면 애플리케이션은 영화를 예매할 책임이 있다고 말할 수 있다. 이후 책임을 수행하기위해 필요한 메세지를 결정하되 메세지는 메세지를 수신할 객체가 아닌 메세지를 전송할 객체의 의도를 반영해서 결정돼야 한다.

 

메세지를 전송할 객체는 무엇을 원하는가?  -> 영화를 예매하는 것이다.

 

 

메세지를 수신할 적합한 객체는 무엇인가? -> GRASP의 INFORMATION EXPERT패턴에 따르면 예매하는 데 필요한 정보를

가장 많이 알고있는 '상영'이라는 정보전문가에게 책임을 할당한다.

 

 

INFORMATION EXPERT 패턴이란? 객체가 자신이 소유하고 있는 정보와 관련된 작업을 수행한다는 일반적인 직관을 표현한 것이다.

 

예매하라 메세지를 완료하기 위해서는 예매가격을 계산하는 작업이 필요하다. 안타깝게도 Screening은 가격을 계산하는 데 필요한 정보를 모르기 때문에 해당 정보를 알고 있는 정보전문가인 Movie가 책임을 가진다.

Screening은 메세지 송신자로서 수신자인 Movie에게 가격을 계산하는 메세지를 송신한다.

이 같은 INFORMATION EXPERT 패턴을 통해 연쇄적인 메세지 전송과 수신이 이루어지며 자율적인 높은 객체들로 구성된 협력 공동체가 구축된다.!

 

 


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

 

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

Screening은 Reservation을 잘 알고, 긴밀하게 사용하며, 초기화에 필요한 데이터를 가지고 있다. Screening은 Reservation 객체 생성 책임을 가지는 CREATOR이다.

 

CREATOR 패턴이란? 객체 A를 생성해야 할 때 생성되는 A객체와 연결되거나 관련될 필요가 있는 객체에 A객체를 생성할 책임을 맡기는 것이다. 그러므로 두 객체는 서로 결합된다. 이미 결합돼 있는 객체에게 생성 책임을 할당하는 것은 설계의 전체적인 결합도에 영향을 미치지 않는다. 결과적으로 CREATOR 패턴은 이미 존재하는 객체 사이의 관계를 이용하기 때문에 설계가 낮은 결합도를 유지할 수 있다.

 

 

 

 

03 구현을 통한 검증

 

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);
        }
    }
    
    public LocalDateTime getWhenScreened() {
        return whenScreened;
    }
    
    public int getSequence() {
        return sequence;
    }
}

 

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

 

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

 

public enum DiscountConditionType {
    SEQUENCE,
    PERIOD
}

 

 

DiscountCondition 개선하기

 

DiscountCondition 클래스는 다음 세가지 이유로 변경될 수 있다.

 

새로운 할인 조건이 추가될 경우 -> isSatisfiedBy 메서드 내부 구현 변경

순번 조건을 판단하는 로직이 변경될 경우 -> isSatisfiedBySequence 메서드 내부 구현 변경 또는 sequence 속성 변경

기간 조건을 판단하는 로직이 변경될 경우 -> isSatisfiedByPeriod 메서드 내부 구현 변경 또는 dayOfWeek, startTime, endTime 속성 변경

 

즉, 하나 이상의 변경 이유를 가지기 때문에 응집도가 낮다.

 

클래스의 응집도가 낮은지 판단하기

 

  • 클래스가 하나 이상의 이유로 변경되는 경우 응집도가 낮은 것이다.
  • 클래스의 인스턴스를 초기화하는 시점에 경우에 따라 다른 속성들을 초기화하는 경우 응집도가 낮은 것이다.
  • 메서드 그룹이 속성 그룹을 사용하는지 여부로 나뉜다면 응집도가 낮은 것이다.

타입 분리하기

 

DiscountCondition을 SequenceCondition과 PeriodCondition이라는 두 개의 클래스로 분리해보자.

 

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

 

결과적으로 앞서 언급했던 문제점들이 모두 해결됐으며 개별 클래스들의 응집도가 향상됐다.

하지만 수정전 Movie와 협력하는 클래스는 DiscountCondition클래스 하나뿐이었다. 수정 후에도 마찬가지로 두 개의 클래스와 협력할 수 있어야한다.

 

그럼 Movie클래스 까지 수정을 해보자.

 

public class Movie {
    private List<PeriodCondition> periodConditions;
    private List<SequenceCondition> sequenceConditions;
    
    private boolean isDiscountable(Screening screening) {
        return checkPeriodConditions(screening) ||
                checkSequenceConditions(screening);
    }

    private boolean checkPeriodConditions(Screening screening) {
        return periodConditions.stream()
                .anyMatch(condition -> condition.isSatisfiedBy(screening));
    }

    private boolean checkSequenceConditions(Screening screening) {
        return sequenceConditions.stream()
                .anyMatch(condition -> condition.isSatisfiedBy(screening));
    }
}

수정 후 문제가 발생함을 알 수 있다.

코드 수정전에는 Movie가 DiscountCondition 클래스 하나와 결합되어 있었지만 수정 후에는 두 개의 클래스와 결합됐기 때문에 설계관점에서 결합도가 높아졌다. 또한 새로운 할인 조건이 추가하기 더욱 어려워졌다.

 

다형성을 통해 분리하기

 

사실 Movie입장에서 할인 여부를 판단하는 책임을 가진 클래스에게 메세지만 전송할 뿐 어떤 클래스인지는 관심이 없다.

역할을 사용하면 period, sequence 두 객체의 구체적인 타입을 추상화하여 Movie가 구체적인 클래스가 아닌 오직 역할에 대해서만 결합되도록 의존성을 제한할 수 있다.

 

GRASP에서는 이를 POLYMOPHISM(다형성) 패턴이라고 부른다.

 

 

역할을 대체할 클래스들 사이에서 구현을 공유 할 필요가 있다면 추상클래스, 그렇지 않고 책임만 정의하고 싶다면 인터페이스를 사용하자.

 

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

 

public class SequenceCondition implements DiscountCondition {
    private int sequence;

    public SequenceCondition(int sequence) {
        this.sequence = sequence;
    }

    public boolean isSatisfiedBy(Screening screening) {
        return sequence == screening.getSequence();
    }
}

 

public class PeriodCondition implements DiscountCondition {
    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;
    }
}

 


변경으로부터 보호하기

 

Movie입장에서 DiscountCondition의 타입이 캡슐화된다는것은 새로운 DiscountCondition 타입을 추가하더라도 Movie가 영향을 받지 않는다.

GRASP에서는 이를 PROTECTED VARIVATONS(변경 보호) 패턴이라고 부른다.

 


Movie 클래스 개선하기

 

Movie도 마찬가지로 새로운 Movie타입을 추가하더라도 Screening에 영향을 미치지 않게 할 수 있다.

 

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

    protected Money getFee() {
        return fee;
    }

    abstract protected Money calculateDiscountAmount();
}

 

public class AmountDiscountMovie extends Movie {
    private Money discountAmount;

    public AmountDiscountMovie(String title, Duration runningTime, Money fee, Money discountAmount,
                               DiscountCondition... 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,
                                DiscountCondition... 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;
    }
}

 

이제 모든 클래스의 내부 구현은 캡슐화 되며 변경의 이유를 오직 하나씩만 가진다.

또한 각 클래스는 응집도가 높고 다른 클래스와 최대한 느슨하게 결합돼 있다.

 


변경과 유연성

 

설계를 주도하는 것은 변경이다.

변경에 대비할 수 있는 두가지 방법이 존재한다.

코드를 이해하고 수정하기 쉽도록 단순하게 설계하는 방법과 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만드는 것이다.

대부분 전자가 더 좋은 방법이지만 유사한 변경이 반복적으로 발생한다면 복잡성이 상승하더라도 유연성을 추가하는 후자가 더 좋다.

 

예를들면  영화에 설정된 할인 정책을 실행중에 변경하기 위해서는 새로운 인스턴스를 생성 후 필요한 상태를 복사해야 한다.

해결 방법은 상속 대신 합성을 사용한다.

할인 정책들을 DiscountPolicy로 추상화 시킨 후 DiscountPolicy를 Movie에 합성시킨다.

 

 

 

 

04 책임 주도 설계의 대안

 

어느 정도 경험이 쌓인 숙련된 설계자조차 적절한 책임과 객체를 선택하는 일에 어려움을 느낀다.

책임과 객체 사이에서 방활할 때 돌파구를 찾기 위해 선택하는 방법은 최대한 빠르게 목적한 기능을 수행하는 코드로 작성한 후 이해하기 쉽고 수정하기 쉬운 소프트웨어로 개선하기 위해 겉으로 보이는 동작은 유지한 채 내부 구조만 변경하는 리팩터링을 진행하는것이다.

 


메서드 응집도

 

객체로 책임을 분배할 때 가장 먼저 할일은 메서드를 응집도 있는 수준으로 분해하는 것이다.

너무 긴메서드는 가독성이 떨어지며 유지보수에 부정적인 영향을 끼친다. 하나의 긴메서드(몬스터 메서드)를 여러 목적으로 나누어 작은 메서드로 분리시키고 주석을 메서드명으로 대신하자.

 

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 = Money.ZERO;
                    break;
                default:
            }

            fee = movie.getFee().minus(discountAmount);
        } else {
            fee = movie.getFee();
        }

        return new Reservation(customer, screening, fee.times(audienceCount), 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);
    }
    
    private boolean checkDiscountable(Screening screening) {
        return screening.getMovie().getDiscountConditions().stream()
                .anyMatch(condition -> condition.isDiscountable(screening));
    }
    
    private boolean checkDiscountable(Screening screening) {
        return screening.getMovie().getDiscountConditions().stream()
                        .anyMatch(condition -> isDiscountable(condition, screening);
    }
    
    public boolean isDiscountable(Screening screening) {
        if (type == DiscountConditionType.PERIOD) {
            return isSatisfiedByPeriod(screening);
        }
        return isSatisfiedBySequence(screening);
    }
    
    private boolean isSatisfiedByPeriod(DiscountCondition condition, Screening screening) {
        return screening.getWhenScreened().getDayOfWeek().equlas(condition.getDayOfWeek()) &&
               condition.getStartTime().compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
               condition.getEndTime().compareTo(screening.getWhenScreended().toLocalTime()) >= 0;
    }
    
    private boolean isSatisfiedBySequence(DiscountCondition condition, Screening screening) {
        return condition.getSequence() == screening.getSequence();
    }
    
    private Money calculateFee(Screening screening, boolean discountable,
                               int audienceCount) {
        if (discountable) {
            return screening.getMovie().getFee()
                    .minus(calculateDiscountedFee(screening.getMovie()))
                    .times(audienceCount);
        }
        return  screening.getMovie().getFee();
    }
    
    private Money calculateDiscountedFee(Movie movie) {
        switch(movie.getMovieType()) {
            case AMOUNT_DISCOUNT:
                return calculateAmountDiscountedFee(movie);
            case PERCENT_DISCOUNT:
                return calculatePercentDiscountedFee(movie);
            case NONE_DISCOUNT:
                return calculateNoneDiscountedFee(movie);
        }
    
        throw new IllegalArgumentException();
    }
    
    private Money calculateAmountDiscountedFee(Movie movie) {
        return movie.getDiscountAmount();
    }
    
    private Money calculatePercentDiscountedFee(Movie movie) {
        return movie.getFee().times(movie.getDiscountPercent());
    }
    
    private Money calculateNoneDiscountedFee(Movie movie) {
        return movie.getFee();
    }
    
    private Reservation createReservation(Screening screening,
                                          Customer customer, int audienceCount, Money fee) {
        return new Reservation(customer, screening, fee, audienceCount);
    }
}

 


객체를 자율적으로 만들자

 

ReservationAgency의 응집도는 여전히 낮다. 자율적인 객체로 만들어보자.

자신이 소유하고 있는 데이터를 자기 스스로 처리하도록 만드는 것이 자율적인 객체를 만드는 지름길이다.

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

 

public class ReservationAgency {
    
    public boolean isDiscountable(Screening screening) {
        if (type == DiscountConditionType.PERIOD) {
            return isSatisfiedByPeriod(screening);
        }
        return isSatisfiedBySequence(screening);
    }
}

isDiscountable메서드는 DiscountCondition에 속한 데이터를 주로 이용하기 때문에 해당메서드를 DiscountCondition으로 옮긴다.

'📚 Book > Object' 카테고리의 다른 글

08 의존성 관리하기  (0) 2020.03.08
07 객체 분해  (0) 2020.02.29
06 메세지와 인터페이스  (0) 2020.02.19
04 설계 품질과 트레이드 오프  (0) 2020.02.10
03 역할, 책임, 협력  (0) 2020.02.02