본문 바로가기

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

Chapter 14 일관성 있는 협력

유사한 기능을 구현하기 위해 유사한 협력패턴을 사용해라. 

설계 재사용을 위해 객체 협력방식을 일관성 있게 만들어야 함.

 


01 핸드폰 과금 시스템 변경하기

 

 

 

기본정책 확장

 

핸드폰 과금 시스템 요금 정책 테이블

유형 형식
고정요금 방식 : 일정단위로 요금부과 A초당 B원 10초당 18원
시간대별 방식 : 시간구간별로 다른요금 부과

A시부터 B시까지 C초당 D원

B시부터 C시까지 D초당 E원

평일에는 10초당 38원

공휴일에는 10초당 19원

요일별 방식 : 요일별로 요금 차등부과

평일에는 A초당 B원

공휴일에는 A초당 C원

평일에는 10초당 38원

공휴일에는 10초당 19원

구간별 방식 : 전체 통화시간을 일정한 통화시간에 따라 나누고 구간별로 요금을 차등부과

초기 A분 동안 B초당 C원

A분 ~ D분까지 B초당 D원

D분 초과시 B초당 E원

초기 1분동안 10초당 50원

초기 1분 이후 10초당 20원

 

 

 

 

고정요금 방식 구현하기

 

//고정요금 방식 구현
public class FixedFeePolicy extends BasicRatePolicy {
    private Money amount;
    private Duration seconds;
    
    public FixedFeePolicy(Money amount, Duration seconds) {
        this.amount = amount;
        this.seconds = seconds;
    }
    
    @Override
    protected Money calculateCallFee(Call call) {
        return amount.times(call.getDuration().getSeconds() / seconds.getSeconds());
    }
}

 

 

 

시간대별 방식 구현하기

 

규칙에 따라 통화시간을 분할하는 방법을 결정하는것이 핵심 -> 기간을 관리하는 DateTimeInterval 클래스 추가

 

시작과 종료시간을 인스턴스 변수로 포함

객체 생성위한 정적메서드 of, toMidnight, fromMidnight, during 제공

 

 

public class DateTimeInterval {
    private LocalDateTime from;
    private LocalDateTime to;
    
    public static DateTimeInterval of(LocalDateTime from, LocalDateTime to) {
        return new DateTimeInterval(from, to);
    }
    
    public static DateTimeInterval toMidnight(LocalDateTime from) {
        return new DateTimeInterval(
            from,
            LocalDateTime.of(from.toLocalDate(), LocalTime.of(23,59,59)));
    }
    
    public static DateTimeInterval during(LocalDate date) {
        return new DateTimeInterval(
            LocalDateTime.of(date, LocalTime.of(0,0)),
            to);
    }
    
    private DateTimeInterval(LocalDateTime from, LocalDateTime to) {
        this.from = from;
        this.to = to;
    }
    
    public Duration duration() {
        return Duration.between(from, to);
    }
    
    public LocalDateTime getFrom() {
        return from;
    }
    
    public LocalDateTime getTo() {
        return to;
    }
}

 

Call 클래스의 from 과 to 를 interval이라는 인스턴스 변수로 묶음.

 

public class Call {
    private DateTimeInterval interval;
    
    public Call(LocalDateTime from, LocalDateTime to) {
        this.interval = DateTimeInterval.of(from, to);
    }
    
    public Duration getDuration() {
        return interval.duration();
    }
    
    public LocalDateTime getFrom() {
        return interval.getFrom();
    }
    
    public LocalDateTime getTo() {
        return interval.getTo();
    }
    
    public DateTimeInterval getInterval() {
        return interval;
    }
}

 

 

  • 통화 기간을 일자별로 분리
  • 일자별로 분리된 기간을 다시 시가나대별 규칙에 따라 분리 후 요금계산

두 작업을 객체의 책임으로 할당하기.

기간을 처리하는 전문가 -> DateTimeInterval

시간대별로 분할하는 전문가 -> TimeOfDayDiscountPolicy

 

전체 통화 시간을 분할하는 작업 TimeOfDayDiscountPolicy, DateTimeInterval, Call 사이의 협력으로 구현

 

TimeOfDayDiscountPolicy가 Call 에세 일자별로 통화기간을 분리할것을 요청

-> Call은 DateTimeInterval에게 기간을 일자 단위로 분할 한후 그 목록을 반환

 

TimeOfDayDiscountPolicy를 구현하자.

- 중요한 것은 시간에 따라 서로 다른 요금 규칙을 정의

List안에 동일한 인덱스에 요소들이 위치시킴.

 

public class TimeOfDayDiscountPolicy extends BasicRatePolicy {
    private List<LocalTime> starts = new ArrayList<>();
    private List<LocalTime> ends = new ArrayList<>();
    private List<Duration> duration = new ArrayList<>();
    private List<Money> amounts = new ArrayList<>();
    
    @Override
    protected Money calculateCallFee(Call call) {
        Money result = Money.ZERO;
        for(DateTimeInterval interval : call.splitByDay()){
            for(int loop=0; loop<starts.size();loop++){
                result.plus(amounts.get(loop).times(
                    Duration.between(from(interval, starts.get(loop)), to(interval, ends.get(loop)))
                    .getSeconds() / durations.get(loop).getSeconds()));
            }
        }
        return result;
    }
    
    private LocalTime from(DateTimeInterval interval, LocalTime from) {
        return interval.getFrom().toLocalTime().isBefore(from) ?
        from :
        interval.getFrom().toLocalTime();
    }
    
    private LocalTime to(DateTimeInterval interval, LocalTime to) {
        return interval.getTo().toLocalTime().isAfter(to) ? 
            to : 
            interval.getTo().toLocalTime();
    }
}

 

 

public class Call {
    // DateTimeInterval에 요청을 전달한 후 응답을 반환하는 위임 메서드
    public List<DateTimeInterval> splitByDay() {
        return interval.splitByDay();
    }
}


public class DateTimeInterval {
    public List<DateTimeInterval> splitByDay() {
        if (days() > 1) {
            return splitByDay(days());
        }
        return Arrays.asList(this);
    }
    
    private int days() {
        return Period.between(from.toLocalDate(), to.toLocalDate())
            .plusDays(1)
            .getDays();
    }
    
    private List<DateTimeInterval> splitByDay(int days) {
        List<DateTimeInterval> result = new ArrayList<>();
        addFirstDay(result);
        addMiddleDays(result, days);
        addLastDay(result);
        return result;
    }
    
    private void addFirstDay(List<DateTimeInterval> result) {
        result.add(DateTimeInterval.toMidnight(from));
    }
    
    private void addMiddleDays(List<DateTimeInterval> result, int days) {
        for(int loop=1; loop<days; loop++) {
            result.add(DateTimeInterval.during(from.toLocalDate().plusDays(loop)));
        }
    }
    
    private void addLastDay(List<DateTimeInterval> result) {
        result.add(DateTimeInterval.fromMidnight(to));
    }
}

 

 

 

요일별 방식 구현하기

 

 

public class DayOfWeekDiscountRule {
    private List<DayOfWeek> dayOfWeeks = new ArrayList<>(); // 필요한요일 목록
    private Duration duration = Duration.ZERO; // 단위시간
    private Money amount = Money.ZERO; // 단위요금
    
    public DayOfWeekDiscountRule(List<DayOfWeek> dayOfWeeks, Duration duration, Money amount) {
        this.dayOfWeeks = dayOfWeeks; 
        this.duration = duration;
        this.amount = amount;
    }
    
    public Money calculate(DateTimeInterval interval) {
        if(dayOfWeeks.contains(interval.getFrom().getDayOfWeek())) {
            return amount.times(interval.duration().getSeconds()/duration.getSeconds());
        }
        return Money.ZERO;
    }
}

// 통화기간을 날짜 경계로 분리, 각 통화기간을 요일별 요금정책에 따라 계산
public class DayOfWeekDiscountPolicy extends BasicRatePolicy {
    private List<DayOfWeekDiscountRole> rules = new ArrayList<>();
    
    public DayOfWeekDiscountPolicy(List<DayOfWeekDiscountRule> rules) {
        this.rules = rules;
    }
    
    @Override
    protected Money calculateCallFee(Call call) {
        Money result = Money.ZERO;
        for(DateTimeInterval interval : call.getInterval().splitByDay()) {
            for(DayOfWeekDiscountRule rule : rules) {
                result.plus(rule.calculate(interval));
            }
        }
        return result;
    }
}

 

 

 

구간별 방식 구현하기

 

 

public class DurationDiscountRule extends FixedFeePolicy {
    private Duration from;
    private Duration to;
    
    public DurationDiscountRule(Duration from, Duration to, Money amount, Duraiton seconds) {
        super(amount, seconds);
        this.from = from;
        this.to = to;
    }
    
    public Money calculate(Call call) {
        if(call.getDuration().compareTo(to) > 0) {
            return Money.ZERO;
        }
        if(call.getDuration().compareTo(from) < 0) {
            return Money.ZERO;
        }
        
        //데이터 전달용 Phone 생성
        Phone phone = new Phone(null);
        phone.call(new Call(call.getFrom().plus(from),
            call.getDuration().compareTo(to) > 0 ? call.getFrom().plus(to) : call.getTo()));
    
        return super.calculateFee(phone);
    }
}

public class DurationDiscountPolicy extends BasicRatePolicy {
    private List<DurationDiscountRule> rules = new ArrayList<>();
    
    public DurationDiscountPolicy(List<DurationDiscountRule> rules) {
        this.rules = rules;
    }
    
    @Override
    protected Money calculateCallFee(Call call) {
        Money result = Money.ZERO;
        for(DurationDiscountRule rule : rules) {
            result.plus(rule.calculate(call));
        }
        return result;
    }
}

 

 

현재 구현은 클래스들이 유사한 문제를 해결하고 있음에도 설계에 일관성이 없음.

 

유사한 기능을 서로 다른 방식으로 구현해서는 안된다.

 

코드 재사용을 위한 상속은 해롭다.

 


02 설계에 일관성 부여하기

 

협력을 일관성 있게 만들기 위한 기본 지침

- 변하는 개념을 변하지 않는 개념으로부터 분리하라

- 변하는 개념을 캡슐화하라

 

바뀌는 부분을 따로 뽑아서 캡슐화한다.

그렇게하면 나중에 바뀌지않는 부분에는 영향을 미치지 않은채로 그 부분만 고치거나 확장할 수 있다

 

조건 로직 대 객체 탐색

 

ex) 4장에서 절차적인 방식으로 구현한 ReservationAgency 구조

 

절차적인 프로그램에서 변경을 처리하는 전통적인 방법은 조건문의 분기를 추가하거나 개별 분기 로직을 수정하는것

객체지향 프로그램에서는 조건 로직을 다형성을 이용해 객체 사이의 이동으로 바꾸는것.

 

실제로 협력에 참여하는 주체는 구체적인 객체이므로,

객체들은 협력 안에서 DiscountPolicy 와 DiscountCondition을 대체 할 수 있어야 함.

 

캡슐화 다시 살펴보기

 

설계에서 변경을 강요하는 것이 무엇인지에 대해 고려하기보다는 재설계없이 변경할 수 있는것이 무엇인지 고려하라

 

캡슐화란 변하는 어떤 것이든 감추는 것이다.

 

  • 데이터 캡슐화 : 인스턴스 변수를 회부에서 접근불가. 메서드 통해서 접근.
  • 메서드 캡슐화 : 네서드의 가시성은 protected. 클래스 내부와 서브 클래스에서만 접근가능. 내부행동 캡슐화.
  • 객체 캡슐화 : 인스턴스 변수가 private 가시성을 가짐. 클래스간 관계 변경하더라도 외부에 영향은 미치지않음. 합성.
  • 서브타입 캡슐화 : 실행시점에 클래스의 인스턴스와 협력. 다형성 기반.

서브타입 캡슐화 와 객체 캡슐화를 적용하는 방법

- 변하는 부분을 분리해서 타입계층을 만듬. ex) 추상클래스나 인터페이스로 추상화한 후 상속.

- 변하지 않는 부분의 일부로 타입계층을 합성. ex) 합성관계로 연결하고 생성자를 통해 의존성 해결

 


03 일관성 있는 기본 정책 구현하기

 

 

변경 분리하기

 

각 기본정책을 구성하는 방식

  • 기본정책은 한 개 이상의 규칙으로 구성
  • 하나의 규칙은 적용조건과 단위요금의 조합
  • 적용조건의 형식은 다름.

변하지 않는 '규칙' 으로부터 변하는 '적용조건'을 분리해야 함.

 

 

변경 캡슐화하기

 

'적용조건'을 분리해서 추상화 한 후 시간대별, 요약별, 구간별 방식을 이 추상화의 서브타입으로 만듬 -> 서브타입 캡슐화

 

그 후 '규칙'이 적용조검을 표현하는 추상화를 합성으로 연결 -> 객체 캡슐화

 

기본정책을 표현하는 BasicRatePolicy는 FeeRule의 컬렉션을 포함.

 

FeeRule 은 '규칙' 구현하는 클래스

 

'단위요금'은 FeeRule의 인스턴스 변수인 feePerDuration에 저장됨.

 

FeeRule이 FeeCondition을 합성관계로 연결.

 

FeeCondition의 서브타입은 변하지않는 FeeRule로부터 캡슐화

 

서브타입에서 '적용조건' 구현.

 

 

협력 패턴 설계하기

 

BasicRatePolicy -> FeeRule -> FeeCondition

 

FeeRule은 하나의 Call에 대해 요금을 계산하는 책임을 수행

 

Call 요금을 계산하기 위해서 필요한 두개의 작업

- 전체통화시간을 각 규칙적용조건을 만족하는 구간들로 나누는 것 : FeeCondition에게 할당

- 분리된 통화구간에 단위요금을 적용해서 요금을 계산하는 것 : FeeRule에 할당

 

 

 

추상화 수준에서 협력 패턴 구하기

 

 

// 적용조건을 표현하는 추상화
public interface FeeCondition {
    // Call의 통화기간 중에서 적용조건을 만족하는 기간을 구한후 List에 담아 반환
    List<DateTimeInterval> findTimeIntervals(Call call);
}

public class FeeRule {
    private FeeCondition feeCondition;
    private FeePerDuraiton feePerDuration;
    
    public FeeRule(FeeCondition feeCondition, FeePerDuration feePerDuration) {
        this.feeCondition = feeCondition;
        this.feePerDuration = feePerDuration;
    }
    
    // 조건을 만족하는 시간의 목록을 반환받은후 feePerDuration 값 이용해 요금 계산
    public Money calculateFee(Call call) {
        return feeCondition.findTimeIntervals(call)
            .stream()
            .map(each -> feePerDuration.calculate(each))
            .reduce(Money.ZERO, (first, second) -> first.plus(second));
    }
}

// 단위 시간당 요금, 
public class FeePerDuration {
    private Money fee;
    private Duration duration;
    
    public FeePerDuration(Money fee, Duration duration) {
        this.fee = fee;
        this.duration = duration;
    }
    // 일정 기간 동안의 요금을 계산
    public Money calculate(DateTimeInterval interval) {
        return fee.times(interval.duration().getSeconds() / duration.getSeconds());
    }
}

public class BasicRatePolicy implements RatePolicy {
    private List<FeeRule> feeRules = new ArrayList<>();
    
    public BasicRatePolicy(FeeRule ... feeRules) {
        this.feeRules = Arrays.asList(feeRules);
    }
    
    @Override
    public Money calculateFee(Phone phone) {
        return phone.getCalls()
            .stream()
            .map(call -> calculate(call))
            .reduce(Money.ZERO, (first,second) -> first.plus(second));
    }
    
    private Money calculate(Call call) {
        return feeRules
            .stream()
            .map(rule -> rule.calculateFee(call))
            .reduce(Money.ZERO, (first,second)-> first.plus(second));
    }
}

 

BasicRatePolicy가 FeeRule의 컬렉션을 이용해 전체 통화요금을 계산하도록 수정 가능.

 

추상적인 수준에서의 협력 완성 -> 컨텍스트 확장, 추상화의 서브타입 추가

 

 

구체적인 협력 구현하기

 

시간대별 정책

 

public class TimeOfDayFeeCondition implements FeeCondition {
    private LocalTime from;
    private LocalTime to;
    
    public TimeOfDayFeeCondition(LocalTime from, LocalTime to) {
        this.from = from;
        this.to = to;
    }
    
    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        return call.getInterval().splitByDay() //날짜별로 시간 간격 분할후 사이시간대 구함.
            .stream()
            .map(each ->
                DateTimeInterval.of(
                    LocalDateTime.of(each.getFrom().toLocalDate(), from(each)),
                    LocalDateTime.of(each.getTo().toLocalDate(), to(each))))
            .collect(Collectors.toList());
    }
    
    private LocalTime from(DateTimeInterval interval) {
        return interval.getFrom().toLocalTime().isBefore(from) ?
                from : interval.getFrom().toLocalTime();
    }
    
    private LocalTime to(DateTimeInterval interval) {
        return interval.getTo().toLocalTime().isAfter(to) ?
                to : interval.getTo().toLocalTime();
    }
}

 

 

요일별 정책

 

 

public class DayOfWeekFeeCondition implements FeeCondition {
    // 여러 요일을 하나의 단위로 관리
    private List<DayOfWeek> dayOfWeeks = new ArrayList<>();
    
    public DayOfWeekFeeCondition(DayOfWeek ... dayOfWeeks) {
        this.dayOfWeeks = Arrays.asList(dayOfWeeks);
    }
    
    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        return call.getInterval() // 요일에 해당하는 기간만 추출 반환
                .splitByDay()
                .stream()
                .filter(each -> dayOfWeeks.contains(each.getFrom().getDayOfWeek()))
                .collect(Collectors.toList());
    }
}

 

 

구간별 정책

 

public class DurationFeeCondition implements FeeCondition {
    private Duration from;
    private Duration to;
    
    public DurationFeeCondition(Duration from, Duration to) {
        this.from = from;
        this.to = to;
    }
    
    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        if(call.getInterval().duration().compareTo(from)<0){
            return Collections.emptyList();
        }
        
        return Arrays.asList(DateTimeInterval.of(
                call.getInterval().getFrom().plus(from),
                call.getInterval().duration().compareTo(to) > 0 ?
                        call.getInterval().getFrom().plus(to) : 
                        call.getInterval().getTo()));
    }
}

 

기능을 추가할때 따르는 구조를 강제하기 때문에 일관성 있음.

 

공통 코드의 구조와 협력패턴은 모든 정책에 걸쳐 동일하기 때문에 하나의 코드를 이해하면 다른곳에 그래도 적용할 수 있음.

 

유사한 기능에 대해 유사한 협렵 패턴을 적용하는 것은 

-> 개념적 무결성 Conceptual Integrity를 유지하는 좋은 방법.

 

협력패턴에 맞추기

 

단위시간당 요금정보 계산

 

public class FixedFeeCondition implements FeeCondition {
    @Override
    public List<DateTimeInterval> findTimeInterval(Call call) {
        return Arrays.asList(call.getInterval());
        // 단 하나의 리스트를 반환한다고해도 비슷한 패턴을 유지해 개념적 무결성을 지킴.
    }
}

 

 

패턴을 찾아라

 

 

변경에 탄력적으로 대응할 수 있는 다양한 캡슐화 방법과 설계방법을 익히자.

 

 객체지향 설계는 객체의 행동과 그것을 지원하기 위한 구조를 계속 수정해 나가는 작업을 반복해 나가면서 다듬어진다. ... ...  이같은 과정을 거치면서 객체들이 자주 통신하는 경로는 더욱 효율적이게 되고, 주어진 작업을 수행하는 표준 방안이 정착된다. 협력패턴이 드러나는 것이다.