객체지향 #9부 - 확장시 일관성 있는 협력 짜기

객체지향 #9부 - 확장시 일관성 있는 협력 짜기

추상화를 통해 객체의 협력 관계를 잘 짰다면, 구현에 일종의 패턴이 있어야 한다. 그래야 보고 코드를 파악하기도 쉽고 확장도 쉽다.

일관성 있는 협력이란

추상화를 통해 객체의 협력 관계를 잘 짰다면, 구현에 일종의 패턴이 있어야 한다. 그래야 보고 코드를 파악하기도 쉽고 확장도 쉽다.
다형성에 따라 확장된 종류의 구현체에서 각각의 메서드가 작동하는 패턴이 보이지 않고 제각기 날뛰면 어떻겠는가?
내가 새로운 기능에 대한 확장을 구현할 때도 힘들고 내 코드를 보고 이해하기도 힘들 것이다.

App에서 유사한 기능에 대한 변경을 찾고, 변경을 캡슐화할 수 있는 적절한 추상화를 찾은 후 변하지 않는 공통적인 책임을 할당하자.

변하는 부분과 변하지 않는 부분을 분리한다.
변하는 부분을 추상화한다.
변하지 않는 부분을 이용해 추상화된 변하는 부분과 협력하여 일종의 협력 패턴 을 만들어 낸다.


추상적인 얘기는 그만두고 예시로 나온 핸드폰 과금 시스템 분석으로 한 스텝씩 따라가보자.

핸드폰 과금 계산BasicRatePolicy

핸드폰에서 전화한 만큼, 과금제에 따라 요금이 부과된다.
먼저 전화요금을 계산해줄 역할에 Phone 객체를 할당해보자.

1

//file: `Phone.java`

public class Phone {
  private RatePolicy ratePolicy;//과금 정책의 전문가에게 과금계산 부탁
  private List<Call> calls = new ArrayList<>();//전화 기록별 요금을 계산하기 위해 Call 타입의 리스트를 가지고 있다.

  public Phone(RatePolicy ratePolicy) {
    this.ratePolicy = ratePolicy;
  }

  public void call(Call call) {
    calls.add(call);
  }

  public List<Call> getCalls() {
    return Collections.unmodifiableList(calls);
  }

  public Money calculateFee() {
    return ratePolicy.calculateFee(this);
  }
}


  • Phone의 역할과 책임과 협력
    • 통화 목록에 대한 총 과금액을 계산하는 메세지를 가지고 있다.(calculateFee() 메세지 처리)
    • 계산하기 위해 전화 기록(Call) 리스트를 상태로 가지게 된다.
    • 특정 과금 정책에 종속되지 않고 다양한 과금 정책을 적용하기 위해 구체적인 과금 계산RatePolicy 에게 위임한다.

먼저 전화요금을 계산에 대한 책임을 가질 객체로 Phone을 선정한다. Phone은 그 책임을 수행하기 위해 통화 목록을 가지고 있게 된다.
그리고 통화 목록을 이용해서 총 요금을 계산하는 책임을 가지고 있는 RatePolicy에게 위임한다.
이렇게 책임을 분배하면 Phone은 통화목록 관리에 대한 책임을 가지고 전화 요금 계산에 대한 책임은 RatePolicy에게 넘긴다. (SRP)



//file: `RatePolicy.java`

public interface RatePolicy {
    Money calculateFee(Phone phone);
}
  • RatePolicy의 역할과 책임과 협력
    • 과금 계산의 정책을 정의한다. 이 인터페이스를 구현하는 클래스는 구체적인 과금 계산 로직을 제공하게 된다.
    • Phone에게 calculateFee(Phone) 메세지를 전송받으면 전화 목록을 이용해서 총 요금을 계산해서 반환한다.
    • 다양한 과금 정책을 적용하기 위해, 새로운 클래스가 RatePolicy 인터페이스를 구현할 수 있다.

다양한 과금 정책을 추상화해서 RatePolicy라는 추상체를 추출했다.
Phone은 추상화된 RatePolicy의 의존성을 주입받아 사용하게 된다.
Phone 입장에서는 구체적인 구현에 대해서는 알 필요가 없다.
이를 서브타입 캡슐화 라고 한다.
추상화를 통해 유연하다. 런타임시에 다형성을 통해 적절한 과금 정책을 선택하게 된다.


Q: Phone이 this를 사용하여 자신의 인스턴스를 RatePolicy의 calculateFee 메서드에 전달하는 이유는?

  public Money calculateFee() {
    return ratePolicy.calculateFee(this);
  }


이러한 설계 방식은 다음과 같은 객체지향적 이유와 장점을 가지고 있다.

  • 캡슐화 유지
    • ✏️정보 은닉: Phone 클래스는 자신의 내부 정보인 Call 리스트를 직접 외부에 노출하지 않습니다. 대신 Phone 객체 전체를 넘김으로써, Call 리스트와 관련된 데이터를 캡슐화하고 정보 은닉을 유지합니다.

    • ✏️데이터와 행위의 결합: Phone 객체는 Call 리스트와 함께 이와 관련된 행위(메서드)도 함께 가지고 있습니다. 이를 통해 데이터와 행위를 하나의 단위로 묶어 관리합니다.

  • 유연성과 확장성
    • ✏️추후 변경 용이성: Phone 객체를 전달함으로써, RatePolicy 구현체는 Phone 클래스의 다른 정보나 메서드에 접근할 필요가 생길 경우 쉽게 확장할 수 있습니다. 예를 들어, 향후 요금 계산 로직이 Call 리스트 외의 다른 정보를 필요로 하게 되더라도 Phone 클래스를 수정하지 않고 RatePolicy를 확장할 수 있습니다.

    • ✏️다형성 활용: RatePolicy 인터페이스를 구현하는 다양한 클래스에서 Phone 객체를 다르게 해석하거나 활용할 수 있습니다. 이는 다형성을 활용하여 다양한 요금 계산 전략을 적용할 수 있게 합니다.

  • 객체 간의 협력 강화
    • ✏️책임의 명확한 분배: Phone은 요금 계산의 책임을 RatePolicy에 위임합니다. RatePolicy는 필요한 정보를 Phone 객체로부터 얻어 요금 계산을 수행합니다. 이는 책임을 명확하게 분배하고, 각 객체의 역할을 강화합니다.




구현해보니 요금 정책이 기본으로 적용되는 요금제와 추가적인 선택이 필요한 요금제로 분류된다는 사실을 알게 되었다.
따라서 타입을 정의하기로 했다.

2


//file: `BasicRatePolicy.java`

public final class BasicRatePolicy implements RatePolicy {//final을 붙이면 상속이 불가능하다.
    private List<FeeRule> feeRules = new ArrayList<>();//기본 정책은 통화요금 계산을 위해 최소 1개 이상의 규칙을 가지고 있다.

    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 객체들과 협력하여 각 통화에 적절한 요금을 계산합니다.



abstract Class ? final Class인 이유


BasicRatePolicy가 final 클래스로 선언된 주된 이유는 다음과 같다.

  • 불변성 (Immutability)
    • 확장 방지: final 클래스는 상속을 통한 확장을 방지합니다. 이는 클래스의 불변성을 보장하며, 클래스가 의도한 대로만 사용되도록 합니다.
    • 예측 가능한 동작: 클래스가 변경되지 않음을 보장함으로써, 시스템의 복잡성을 줄이고 예측 가능한 동작을 유지할 수 있습니다.
  • 설계 의도의 명확성
    • 설계 의도의 표현: final 클래스는 해당 클래스가 현재의 형태로 완성되었으며, 추가적인 확장이나 변경이 필요하지 않다는 설계 의도를 명확히 표현합니다.
    • 특정 역할의 강조: BasicRatePolicy와 AdditionalRatePolicy가 각각 특정 역할을 수행하도록 설계되었으며, 이 역할을 벗어난 확장이나 변경을 원하지 않는다는 것을 나타냅니다.


기본 적용 정책에 대해 변화하는 부분을 모두 제거하고 남은 콘크리트 클래스라고 명시적으로 표현됐다고 생각하면 쉽다.



//file: `BasicRatePolicy.java`

public abstract class AdditionalRatePolicy implements RatePolicy {
    private RatePolicy next;

    public AdditionalRatePolicy(RatePolicy next) {
        this.next = next;
    }

    @Override
    public Money calculateFee(Phone phone) {
        Money fee = next.calculateFee(phone);
        return afterCalculated(fee) ;
    }

    abstract protected Money afterCalculated(Money fee);
}


  • AdditionalRatePolicy의 역할과 책임과 협력
    • 기본 정책에 추가적인 요금을 부과하는 기능을 제공한다.
    • 기본 정책에 추가적인 요금을 부과한 후의 총 요금을 계산한다.
    • RatePolicy 객체와 협력하여 기본 정책에 추가적인 요금을 부과한 후의 총 요금을 계산한다.


추상 클래스로 선언하였고, 미리 내부에 기본적인 로직 템플릿이 짜져있다.
확장 가능하고 변화해야 하는 부분을 protected로 선언해 하위의 구현체에서 구현하도록 표현하고 있다.
그리고 기본 정책에 몇 가지가 추가될지 모르는 상황이기 때문에 데코레이션 패턴으로 RatePolicy 추상체를 받아 사용하도록 했다.
이로 인해 Phone은 몇 개의 RatePolicy가 적용되었는지 알 필요가 없다. 구현체의 개수에 대해 캡슐화가 이뤄진 모습이다.




BasicRatePolicy에서 FeeRule에게 통화 시간에 대한 계산을 맡긴다.
일관된 협력을 위해 FeeRule에서 변화하는 조건을 추상체와 협력하게 수정한다.

이제 정책들을 살펴보고 분리해보자.

3 다양한 기본 정책의 종류


기본적으로 규칙에는 통화 시간이 조건에 충족되느냐 & 단위 시간당 요금 으로 나누어진다.
그에 따라 FeeRule은 FeeCondition과 FeePerDuration을 가지고 있다.

//file: `FeeRule.java,FeePerDuration.java`

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


  • FeeRule의 역할과 책임과 협력
    • 조건에 따른 요금 계산: FeeRule은 FeeCondition을 사용하여 특정 통화가 요금 계산에 해당하는지 판단하고, 해당하는 경우 FeePerDuration을 통해 요금을 계산합니다.
    • 요금 계산 결과 합산: 여러 시간 간격에 대한 요금을 계산하고, 이들을 합산하여 최종 요금을 도출합니다.

FeeRule은 통화 기록(Call)을 받아서 통화 시간에 대한 요금을 계산한다.
통화 기록이 얼마나 조건이 충족되는지 체크는 FeeCondition에게 위임한다.
FeeCondition이 해당 조건을 상태로 가지고 있기 때문이다.




FeeRule에서 FeeCondition의 추상체와 협력을 맺고 있다.
FeeRule 내부에는 변화하는 부분이 없다. 변화하는 부분을 캡슐화하고, 변화하지 않는 부분을 재사용하도록 설계했다.
00시~19시, 19시~24시 / 평일, 공휴일 / 초기 1분 , 초기 1분 이후/를 FeeCondtiion의 구현체로 만든다.
중요한건 다른 조건 - 금액 규칙이 생기면 그저 FeeCondition의 구현체를 추가하면 된다는 것이다.

여기서 각 정책을 Rule로 추상화해 변화하는 부분을 Condition으로, 단위시간당 요금을 FeePerDuration 객체로 변화하는것이 고난이도라 느껴졌다.
객체지향은.. 나누고 쪼개고 추상화가 80%가 아닐까?




4

//file: `고정요금방식`

//단위 조건이 없는 경우라도 FeeCondition을 구현하도록 한다.
public class FixedFeeCondition implements FeeCondition {
  @Override
  public List<DateTimeInterval> findTimeIntervals(Call call) {
    return Arrays.asList(call.getInterval());
  }
}
//file: `요일별 방식`

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());
  }
}
//file: `시간대별 방식`

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

//file: `기간별 방식`

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



여기서 중요한 점은 FeeRule - FeeCondition의 협력 방식이 일관된다는 점이다.
어떤 FeeCondition의 구현체를 보더라도 한 눈에 코드가 이해가 된다.
주어진 통화 기록(Call)에서 조건에 충족되는 구간을 검증해 List로 반환한다.



결론

아주 조금이나마 유연한 객체간의 협력과 코드로 옮기는 과정을 경험할 수 있었다.
다양한 디자인 패턴이 결국에는 변경되는 부분과 아닌 부분을 분리하고, 변화되는 부분을 캡슐화하는 것이라는 것을 알게 되었다.
한 단계 시야가 높아진 것 같다.
코드를 짤 때 비즈니스 로직을 분석하고 추상화를 통해 객체간의 협력을 잘 짜는 것, 아직은 갈 길이 멀다.


핸드폰 과금 전체도 핸드폰 과금 전체도