본문 바로가기
Study/오브젝트

[오브젝트] 14장. 일관성 있는 협력

by Nahwasa 2023. 1. 21.

스터디 메인 페이지

목차

    - ☆ 표시가 붙은 부분은 스터디 중 나온 얘기 혹은 제 개인적인 생각이나 제가 이해한 방식을 적어놓은 것으로, 책에 나오지 않는 내용입니다. 따라서 책에서 말하고자 하는 바와 다를 수 있습니다.

    - 모든 이미지의 출처는 오브젝트(조용호 저) 책 입니다.

     


     

    CHAPTER 14. 일관성 있는 협력

    ⚈ 애플리케이션을 개발하다 보면 유사한 요구사항을 반복적으로 추가하거나 수정하게 되는 경우가 있다.

    • 이러한 상황에서 각 협력이 서로 다른 패턴을 따를 경우에는 전체적인 설계의 일관성이 서서히 무너지게 된다.
    • 객체지향 패러다임의 장점은 설계를 재사용할 수 있다는 것이다. -> 재사용은 공짜로 얻어지지 않는다. 재사용을 위해서는 객체들의 협력 방식을 일관성 있게 만들어야 한다.
    • 과거의 해결 방법을 반복적으로 사용해서 유사한 기능을 구현하는 데 드는 시간과 노력을 대폭 줄일 수 있다. 또한 코드가 이해하기 쉬워진다.

     

    ⚈ 지금 보고 있는 코드가 얼마 전에 봤던 코드와 유사하다는 사실을 아는 순간 새로운 코드가 직관적인 모습으로 다가오는 것을 느끼게 될 것이다.

     

    ⚈ 14장의 주제 : 일관성 있는 협력 패턴을 적용하면 여러분의 코드가 이해하기 쉽고 직관적이며 유연해진다는 것

     


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

    ⚈ 기본 정책 확장

    • 11장에서 구현한 핸드폰 과금 시스템의 요금 정책을 수정 (11장 정리내용)
    • 기존에는 기본 정책에 일본 요금제와 심야 할인 요금제 두 가지 종류가 있었음
    • 14장에서는 기본 정책을 이하와 같이 확장할 것. 부가 정책은 변화 없음

     

    11장에서 조합의 폭발 얘기 나올 때 설명한 그림처럼 14장에서 새로운 기본 정책을 적용할 때 조합 가능한 모든 경우의 수 그림

     

    14장에서 구현하게 될 클래스 구조 (짙은 색이 새로운 기본 정책)

     

    구현된 코드

    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());
        }
    }
    
    ---
    
    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, 999_999_999)));
        }
    
        public static DateTimeInterval fromMidnight(LocalDateTime to) {
            return new DateTimeInterval(LocalDateTime.of(to.toLocalDate(), LocalTime.of(0, 0)), to);
        }
    
        public static DateTimeInterval during(LocalDate date) {
            return new DateTimeInterval(
                    LocalDateTime.of(date, LocalTime.of(0, 0)),
                    LocalDateTime.of(date, LocalTime.of(23, 59, 59, 999_999_999)));
        }
    
        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;
        }
    
        public List<DateTimeInterval> splitByDay() {
            if (days() > 0) {
                return split(days());
            }
    
            return Arrays.asList(this);
        }
    
        private long days() {
            return Duration.between(from.toLocalDate().atStartOfDay(), to.toLocalDate().atStartOfDay()).toDays();
        }
    
        private List<DateTimeInterval> split(long 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, long 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 String toString() {
            return "[ " + from + " - " + to + " ]";
        }
    }
    
    ---
    
    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;
    	}
    
    	public List<DateTimeInterval> splitByDay() {
    		return interval.splitByDay();
    	}
    }
    
    ---
    
    public class TimeOfDayDiscountPolicy extends BasicRatePolicy {
        private List<LocalTime> starts = new ArrayList<LocalTime>();
        private List<LocalTime> ends = new ArrayList<LocalTime>();
        private List<Duration> durations = new ArrayList<Duration>();
        private List<Money>  amounts = new ArrayList<Money>();
    
        @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 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<DayOfWeekDiscountRule> 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, Duration 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;
            }
    
            // 부모 클래스의 calculateFee(phone)은 Phone 클래스를 파라미터로 받는다.
            // calculateFee(phone)을 재사용하기 위해 데이터를 전달할 용도로 임시 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 설계에 일관성 부여하기

    일관성 있는 설계를 위한 조언

    • 다양한 설계 경험을 익히라는 것 -> 하지만 이런 설계 경험을 단기간에 쌓아 올리는 것은 생각보다 어려운 일이다.
    • 널리 알려진 디자인 패턴을 학습하고 변경이라는 문맥 안에서 디자인 패턴을 적용해 보라는 것. (디자인 패턴 : 특정한 변경에 대해 일관성 있는 설계를 만들 수 있는 경험 법칙을 모아놓은 일종의 설계 탬플릿)

     

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

    • 변하는 개념을 변하지 않는 개념으로부터 분리하라. (☆ 디자인 패턴 얘기!)
    • 변하는 개념을 캡슐화하라.

     

    위 두 가지 지침은 훌륭한 구조를 설계하기 위해 따라야 하는 기본적인 원칙이기도 하다. 지금까지 이 책에서 설명했던 모든 원칙과 개념들 역시 대부분 변경의 캡슐화라는 목표를 향한다.

     

    객체지향에서 변경을 다루는 전통적인 방법은 조건 로직을 객체 사이의 이동으로 바꾸는 것이다.

     

    캡슐화는 데이터 은닉(data hiding) 이상이다.

    • 데이터 은닉 : 오직 외부에 공개된 메서드를 통해서만 객체의 내부에 접근할 수 있게 제한함으로써 객체 내부의 상태 구현을 숨기는 기법
    • 캡슐화 : 단순히 데이터를 감추는 것이 아니라 소프트웨어 안에서 변할 수 있는 모든 '개념'을 감추는 것이다. 즉, "캡슐화란 변하는 어떤 것이든 감추는 것이다"
    • 캡슐화란 단지 데이터 은닉을 의미하는 것이 아니다. 코드 수정으로 인한 파급효과를 제어할 수 있는 모든 기법이 캡슐화의 일종이다.

     


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

    협력을 일관성 있게 만들기 위해서는 변경을 캡슐화해서 파급효과를 줄여야 한다.

    • 변경을 캡슐화하는 가장 좋은 방법은 변하지 않는 부분으로부터 변하는 부분을 분리하는 것이다.

     

    구현된 코드

    public interface FeeCondition {
        List<DateTimeInterval> findTimeIntervals(Call call);
    }
    
    ---
    
    public class FeeRule {
        private FeeCondition feeCondition;
        private FeePerDuration feePerDuration;
    
        public FeeRule(FeeCondition feeCondition, FeePerDuration feePerDuration) {
            this.feeCondition = feeCondition;
            this.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(Math.ceil((double)interval.duration().toNanos() / duration.toNanos()));
        }
    }
    
    ---
    
    public final 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));
        }
    }
    
    ---
    
    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()
                    .filter(each -> from(each).isBefore(to(each)))
                    .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()));
        }
    }

     

    일관성 있는 협력

    • 이제 기본 정책을 추가하기 위해 규칙을 지키는 것보다 어기는 것이 더 어렵다.
    • 일관성 있는 협력은 개발자에게 확장 포인트를 강제하기 때문에 정해진 구조를 우회하기 어렵게 만든다.

     

    지속적으로 개선하라

    • 처음에는 일관성을 유지하는 것처럼 보이던 협력 패턴이 시간이 흐르면서 새로운 요구사항이 추가되는 과정에서 일관성의 벽에 조금씩 금이 가는 경우를 자주 보게 된다.
    • 초기 단계에서는 모든 요구사항을 미리 예상할 수 없기 때문에 이것은 잘못이 아니며 꽤나 자연스러운 현상이다.
    • 오히려 새로운 요구사항을 수용할 수 있는 협력 패턴을 향해 설계를 진화시킬 수 있는 좋은 신호로 받아들여야 한다.
    • 협력은 고정된 것이 아니다. 만약 현재의 협력 패턴이 변경의 무게를 지탱하기 어렵다면 변경을 수용할 수 있는 협력패턴을 향해 과감하게 리팩터링하라.

     

    패턴을 찾아라

    • 일관성 있는 협력의 핵심은 변경을 분리하고 캡슐화하는 것이다.
    • 협력을 일관성 있게 만드는 과정은 유사한 기능을 구현하기 위해 반복적으로 적용할 수 있는 협력의 구조를 찾아가는 기나긴 여정이다. 따라서 협력을 일관성 있게 만든다는 것은 유사한 변경을 수용할 수 있는 협력 패턴을 발견하는 것과 동일하다.
    • 협력 패턴과 관련한 두 가지 개념 : 패턴, 프레임워크 (15장에서 할 얘기)

    댓글