[오브젝트] chap14을 읽고

애플리케이션을 개발하다 보면 유사한 요구사항을 반복적으로 추가하거나 수정하게 되는 경우가 있다. 이때 객체들의 협력 구조가 서로 다른 경우에는 코드를 이해하기도 어렵고 코드 수정으로 인해 버그가 발생할 위험성도 높아진다.

설계의 재사용을 위해서는 객체들의 협력방식을 일관성 있게 만들어야 한다. 일관성 있는 설계는 유사한 기능을 구현하는데 드는 시간을 줄이며, 코드가 이해하기 쉬워진다.

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

비일관성의 문제점

비일관성은 두 가지 상황에서 발목을 잡는다. 하나는 새로운 구현을 추가해야 하는 상황이고, 또 다른 하나는 기존의 구현을 이해해야 하는 상황이다.

만약 기존의 클래스들의 구현 방식이 서로 다를 때 새로운 요구사항을 추가해야 한다면 어떤 구현을 따르거나 새로운 방법을 고안해야 할 것이다. 결정이 어려운 이유는 어떤 방식을 택하더라고 문제가 없다는 것이다. 개별 클래스로만 놓고 보면 문제가 없기 때문이다. 하지만 새로운 요구 사항을 추가할수록 코드 사이의 일관성을 점점 더 어긋나게 된다.

서로 다른 구현 방식은 코드를 이해하는데 방해가 된다. 유사한 요구사항을 구현하는 서로 다른 구조의 코드는 코드를 이해하는 데 심리적인 장벽을 만든다.

결론은 유사한 기능은 유사한 방식으로 구현해야 한다는 것이다.

설계에 일관성 부여하기

설계에 일관성을 부여하려면 다양한 설계 경험을 익혀야 한다. 설계 경험을 단기간에 쌓아 올리기 어렵다면 널리 알려진 디자인 패턴을 학습하고 변경이라는 문맥 안에서 디자인 패턴을 적용해 보라.

디자인 패턴을 학습하면 빠른 시간 안에 전문가의 경험을 흡수할 수 있다. 하지만 디자인 패턴을 적용하고 싶어도 모든 경우에 적합한 패턴을 찾을 수 있는 것은 아니다. 따라서 협력을 일관성 있게 만들기 위해 다음과 같은 지침을 따르자.

  • 변하는 개념을 변하지 않는 개념으로부터 분리하라.
  • 변하는 개념을 캡슐화하라.

조건 로직 대 객체 탐색

1
2
3
4
5
if (condition.getType() == DiscountConditionType.PERIOD) {
    // DO SOMETHING
} else {
    // DO SOMETHING
}

만약 위와 같은 코드가 있다고 하자. 이 설계가 나쁜 이유는 변경의 주기가 다른 코드가 한 클래스 안에 뭉쳐있기 때문이다. 또한 새로운 조건을 추가하기 위해서는 기존 코드의 내부를 수정해야 하기 때문에 오류가 발생할 확률이 높아진다.

객체지향은 이를 다르게 접근한다. 조건 로직을 객체 사이의 이동으로 바꾸는 것이다. 다형성을 이용해 조건 로직을 객체 사이의 이동으로 바꾼다. 클라이언트는 객체가 자신의 요청을 잘 처리해줄 것이라고 믿고 메시지를 전송할 뿐이다. 실행할 메서드를 결정하는 것은 순전히 메시지를 수신한 객체의 책임이다.

이처럼 조건 로직을 객체 사이의 이동으로 대체하기 위해서는 커다란 클래스를 더 작은 클래스들로 분리해야한다. 클래스를 분리하기 위한 기준은 변경의 이유와 주기이다. 클래스는 명확히 단 하나의 이유에 의해서만 변경되어야 하고 클래스 안의 모든 코드는 함께 변경되어야 한다. 즉 단일 책임 원칙을 따르도록 클래스를 분리해야한다.

조건 로직들을 변경의 압력에 맞춰 분리하고 나면 협력 패턴에 일관성을 부여하기 더 쉬워진다. 유사한 클래스들이 역할이라는 추상화로 묶이게 되고 역할 전체 설계의 일관성을 유지할 수 있게 이끌어주기 때문이다.

다형성을 이용해 변하는 개념을 별도의 서브타입으로 분리할 수 있다. 또한 분리된 서브타입은 단 하나의 변경 이유를 가지기 때문에 변하는 개념을 캡슐화한다.

여기서 6장의 인터페이스 설계 원칙을 준수하면 구현을 캡슐화 할 수 있게되고, 8장과 9장의 의존성 관리 기법은 타입을 캡슐화할 수 있게 된다. 10장에서 설명한 것 처럼 코드 재사용을 위한 상속을 사용하고 있는지 점검해보자. 상속 대신 합성을 고려할 수 있을 때는 11장을 참고하라. 13장의 원칙을 따르면 리스코프 원칙을 준수하는 타입 계층을 구현하는데 상속을 이용할 수 있다.

캡슐화 다시 살펴보기

캡슐화는 일반적으로 데이터 은닉을 떠올리게 하지만 그 이상의 의미를 내포하고 있다. 소프트웨어 안에서 변할 수 있는 어떤것이든 감추는 것이다. 다양한 종류의 캡슐화가 존재한다.

  • 데이터 캡슐화
  • 메서드 캡슐화
  • 객체 캡슐화(합성)
  • 서브타입 캡슐화(다형성)

코드 수정으로 인한 파급효과를 제어할 수 있는 모든 기법이 캡슐화의 일종이다. 변경을 캡슐화할 수 있는 다양한 방법이 존재하지만 협력을 일관성 있게 만들기 위해 가장 일반적으로 사용하는 방법은 서브타입 캡슐화와 객체 캡슐화를 조합하는 것이다. 적용 순서는 다음과 같다.

  • 변하는 부분을 분리해서 타입 계층을 만든다.
    • 변하는 부분들의 공통적인 행동을 추상 클래스나 인터페이스로 추상화하고 변하는 부분들이 이를 상속받게 만든다.
  • 변하지 않는 부분의 일부로 타입 계층을 합성한다.
    • 앞에서 구현한 타입 계층을 변하지 않는 부분에 합성한다. 이렇게 하면 변하지 않는 부분은 변경되는 추상화에 의존하기 때문에 구체적인 사항에 결합되지 않는다.

실제로 적용해보기

전화 요금 과금 시스템을 생각해보며 일관성 있는 설계를 해보자. 전화 요금의 기본 정책은 고정요금 방식, 시간대별 방식, 요일별 방식, 구간별 방식이 존재한다.

변경 분리하기

위의 4가지 기본 정책에서 변하는 부분과 변하지 않는 부분을 구별해보자. 공통점은 각 기본 정책을 구성하는 방식이 유사하다는 것이다. 한 개 이상의 규칙으로 구성되어 있고 이 규칙은 적용조건과 단위 요금의 조합이다.

단위요금과 적용조건이 모여 하나의 규칙을 구성한다. 모든 규칙에 적용조건이 포함된다는 사실은 변하지 않지만 실제 세부적인 조건이 다르다.

  • 변하지 않는 부분 : 기본 정책은 여러 규칙의 집합이다. 하나의 규칙은 적용조건과 단위요금으로 구성된다.
  • 변하는 부분 : 적용조건의 세부 내용

따라서 규칙에서 적용조건을 분리해야 한다.

변경 캡슐화하기

협력을 일관성 있게 만들기 위해서는 변경을 캡슐화해서 파급효과를 줄여야 한다. 변하지 않는 부분이 오직 이 추상화에만 의존하도록 관계를 제한하면 변경을 캡슐화 할 수 있게 된다.

BasicRatePolicy는 정책을 나타내는 클래스이며 FeeRule을 인스턴스 변수로 가진다.

FeeRule은 규칙을 구현하는 클래스이며 단위요금은 FeeRule의 인스턴스 변수로 저장된다. FeeRule은 적용조건인 FeeCondition을 인스턴스 변수로 가지고 있으며 FeeCondition은 여러 서브타입으로 구현된다.

FeeRuleFeeCondition은 합성관계로 연결되고 있어서 FeeCondition의 어떤 서브타입도 알지 못한다.

협력 패턴 설계하기

변하는 부분과 변하지 않는 부분을 분리하고, 변하는 부분을 캡슐화 하면 변하지 않는 부분만을 이용해 객체 사이의 협력을 이야기 할 수 있다. 재사용 가능한 협력 패턴이 선명하게 드러나게 된다.

협력은 BasicRatePolicycalculateFee(phone) 메서드를 수신했을 때 시작된다. 모든 Call의 전체 요금을 계산하게 된다. BasicRatePolicy는 각 Call별로 FeeRulecalculateFee(call) 메시지를 전송한다. FeeRule은 여러개 존재하므로 Call 하나당 FeeRule에 다수의 calculateFee 메시지가 전송된다.

하나의 Call에 대한 요금을 계산하기 위해서는 두 단계의 작업이 필요하다. 전체 통화 시간을 각 ‘규칙’의 ‘적용조건’을 만족하는 구간으로 나누고, 분리된 통화 구간에 ‘단위요금’을 적용해 요금을 계산하는 것이다.

첫번째 작업은 적용조건을 가장 잘 알고있는 정보 전문가인 FeeCondition에 할당하고, 단위요금을 적용해 요금을 계산하는 작업은 ‘요금기준’의 정보 전문가인 FeeRule이 담당하는 것이 적절할 것이다.

다음과 같이 구현할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public interface FeeCondition {
    List<DateTimeInterval> findTimeIntervals(Call call);
}

@AllArgumentConstructor
public class FeeRule {
    private FeeCondition feeCondition;
    private 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));
    }
}

@AllArgumentConstructor
public class FeePerDuration {
    private Money fee;
    private Duration duration;

    public Money calculate(DateTimeInterval interval) {
        return fee.times(Math.ceil((double)interval.duration().toNanos() / duration.toNanos()));
    }
}

@AllArgumentConstructor
public class BasicRatePolicy implements RatePolicy {
    private List<FeeRule> feeRules = new ArrayList<>();

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

위의 코드는 모두 변하지 않는 추상화에 해당한다. 이 요소들의 조합으로 전체적인 협력 구조가 완성된다. 추상적인 요소만으로 전체적인 협력 구조를 설명할 수 있게 되었다. 이제 FeeCondition의 서브타입을 추가하면 협력이 구체적인 컨텍스트로 확장된다.

구체적인 협력 구현하기

이제 FeeCondition의 서브타입들을 구현하면 된다. 오직 변하는 부분만 구현하면 되기 때문에 원하는 기능을 쉽게 완성할 수 있다. 코드의 재사용성이 향상되고 테스트해야 하는 코드의 양이 감소한다. 기능을 추가할 때 따라야 하는 구조를 강제할 수 있기 때문에 설계의 일관성이 무너지지 않는다.

새로운 정책을 추가할 때 설계 규칙을 어기는 것이 더 어렵게 되었다. 일관성 있게 협력해야하는 강제성이 생긴 것이다. 이런 이유로 코드를 한번 이해하게 되면 이 지식을 다른 코드를 이해하는데 그대로 적용할 수 있게 된다.

협력 패턴에 맞추기

고정 요금 정책은 어떻게 할 것인가? 다른 정책과 달리 고정요금 정책은 ‘규칙’이라는 개념이 필요하지 않다. 단위 요금만 존재하고 적용 조건은 필요하지 않다.

이런 경우에 새로운 협력 패턴을 적용하는 것이 효과적일까? 아니다. 기존의 패턴에 맞추는 것이 가장 좋은 방법이다. 설계를 약간 비트는 것이 조금은 이상한 구조를 낳더라도 전체의 일관성을 유지할 수 있는 설계를 선택하는 것이 현명하다.

1
2
3
4
5
6
public class FixedFeeCondition implements FeeCondition {
    @Override
    public List<DateTimeInterval> findTimeIntervals(Call call) {
        return Arrays.asList(call.getInterval());
    }
}

위처럼 개념적으로 불필요한 클래스를 추가하는 것이 개념적 무결성(일관성)을 무너뜨리는 것보다 낫다. 약간의 부조화를 수용하는 편이 낫다.

패턴을 찾아라

일관성 있는 협력의 핵심은 변경을 분리하고 캡슐화하는 것이다. 변경의 방향을 파악할 수 있는 날카로운 감각을 길러라. 그리고 변경에 탄력적으로 대응할 수 있는 다양한 캡슐화 방법과 설계 방법을 익히자.

유사한 기능에 대한 변경이 지속적으로 발생하고 있다면 변경을 캡슐화할 수 있는 적절한 추상화를 찾고, 이 추상화에 변하지 않는 공통적인 책임을 할당하라.

태그:

카테고리:

업데이트:

댓글남기기