들어가며

객체는 협력을 위해 존재한다. 협력은 객체가 존재하는 이유와 문맥을 제공한다. 잘 설계된 애플리케이션은 이해하기가 쉽고 수정이 용이하며 재사용 가능한 협력의 모임이다. 객체지향 설계의 목표는 적절한 책임을 수행하는 객체들의 협력을 기반으로 결합도가 낮고 재사용 가능한 코드 구조를 창조하는 것이다.

애플리케이션을 개발하다 보면 유사한 요구사항을 반복적으로 추가하거나 수정하게 되는 경우가 발생한다. 이때 객체들의 협력 구조가 서로 다른 경우에는 코드를 이해하기도 어렵고 코드 수정으로 인해 버그가 발생할 위험도 높아진다. 유사한 요구사항을 계속 추가해야하는 상황에서 각 협력이 서로 다른 패턴을 따를 경우에는 전체적인 설계의 일관성이 서서히 무너지게 된다.

객체지향 패러다임의 장점은 설계를 재사용할 수 있다는 것이다. 하지만 재사용은 공짜로 얻어지지 않는다. 재사용을 위해서는 객체들의 협력 방식을 일관성 있게 만들어야 한다. 일관성은 설계에 드는 비용을 감소시킨다. 과거의 해결 방법을 반복적으로 사용해서 유사한 기능을 구현하는 데 드는 시간과 노력을 대폭 줄일 수 있기 때문이다. 일관성 있는 설계가 가져다 주는 더 큰 이익은 코드가 이해하기가 쉬워진다는 것이다. 특정한 문제를 유사한 방법으로 해결하고 있다는 사실을 알면 문제를 이해하는것 만으로도 코드의 구조를 예상할 수 있게된다.

가능하면 유사한 기능을 구현하기 위해 유사한 협력 패턴을 사용하라. 객체들의 협력이 전제척으로 일관성 있는 유사한 패턴을 따른다면 시스템을 이해하고 확장하기 위해 요구되는 정신적인 부담을 크게 줄일 수 있다. 지금 보고 있는 코드가 얼마 전에 봤던 코드와 유사하다는 사실을 아는 순간 새로운 코드가 직관적인 모습으로 다가오는 것을 느끼게 될 거싱다.

일관성 있는 협력 패턴을 적용하면 여러분의 코드가 이해하기 쉽고 직관적이며 유연해진다는 것이 이번 장의 주제다. 코드를 살펴보는 것으로 시작하자.


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

기본 정책 확장

앞서 11장에서 구현한 핸드폰 과금 시스템의 요금 정책을 수정해야 한다고 가정하자. 지금까지 기본 정책에는 일반 요금제심야 할인 요금제의 두 가지 종류가 있었다. 이번 장에서는 기본 정책을 아래 표와 같이 4가지 방식으로 확장할 것이다. 부가 정책에 대한 요구사항은 변화가 없다.

유형 형식
고정요금 방식 A초당 B원 10초당 18원
시간대별 방식 A시 부터 B시까지 C초당 D원
B시부터 C시까지 C초당 E원
00시부터 19시까지 10초당 18원
19시부터 24시까지 10초당 15원
요일별 방식 평일에는 A초당 B원
공휴일에는 A초당 C원
평일에는 10초당 38원
공휴일에는 10초당 19원
구간별 방식 초기 A분 동안 B초당 C원
A분 ~ D분까지 B초당 D원
D분 초과 시 B초당 E원
초기 1분동안 10초당 50원
초기 1분 이후 10초당 20원

기본 정책을 구성하는 4가지 방식에 관해 간단히 살펴보자.

  • 고정요금 방식 : 일정 시간 단위로 동일한 요금을 부과하는 방식이다. 모든 통화에 대해 동일하게 10초당 9원을 부과하는 방식이 고정요금 방식의 예에 해당한다 기존의 ‘일반 요금제’와 동일하다.

  • 시간대별 방식 : 하루 24시간을 특정한 시간 구간으로 나눈 후 각 구간별로 서로 다른 요금을 부과하는 방식이다. 예를 들어, 0시 ~ 19시 까지는 10초당 18원을, 19시 ~ 24시까지는 10초당 15원의 요금을 부과하는 방식이다. 기존의 ‘심야 할인 요금제’는 밤 10시를 기준으로 요금을 부과한 시간대별 방식이다.

  • 요일별 방식 : 요일별로 요금을 차등 부과하는 방식이다. 이 방식을 사용하면 월요일부터 금요일까지는 10초당 38원을, 토요일과 일요일에는 10초당 19원을 부과하는 요금제를 만들 수 있다.

  • 구간별 방식 : 전체 통화 시간을 일정한 통화 시간에 따라 나누고 각 구간별로 요금을 차등 부과하는 방식이다. 예를들어, 통화 구간을 초기 1분과 1분 이후로 나눈 후 초기 1분 동안은 10초당 50원을, 그 이후에는 10초당 20원을 부과하는 방식이 구간별 방식에 해당한다. 만약 어떤 사용자의 전체 통화 시간이 60분이라면 처음 1분에 대해서는 10초당 50원이 부과되고 나머지 59분에 대해서는 10초당 20원의 요금이 부과될 것이다.

아래 그림은 11장에서 요금 정책을 설명하면서 사용한 그림을 수정한 것으로서 새로운 기본 정책을 적용할 때 조합 가능한 모든 경우의 수를 나타낸 것이다.

아래 그림은 이번 장에서 구현하게 될 클래스 구조를 그림으로 나타낸 것이다. 짙은 색으로 표현된 클래스들이 새로운 기본 정책을 구현한 클래스들이다. 고졍요금 방식은 FixedFeePolicy, 시간대별 방식은 TimeOfDayDiscountPolicy, 요일별 방식은 DayOfWeekDiscountPolicy, 구간별 방식은 DurationDiscountPolicy라는 이름의 클래스로 구현할 것이다.

고정요금 방식 구현하기

가장 간단한 고정요금 방식부터 시작해보자. 고정요금 방식은 기존의 일반요금제와 동일하기 때문에 기존의 RegularPolicy 클래스의 이름을 FixedFeePolicy로 수정하기만 하면 된다.

시간대별 방식 구현하기

시간대별 방식에 따라 요금을 계산하기 위해서는 통화 기간을 정해진 시간대별로 나눈 후 각 시간대별로 서로 다른 계산 규칙을 적용해야 한다. 아래 그림은 0시부터 19시까지의 통화에 대해서는 10초당 18원의 요금을 부과하고, 19시부터 24시 까지는 10초당 15원의 요금을 부과하는 시간대별 방식을 나타낸 것이다. 가입자가 18시부터 20시까지 2시간 동안 통화를 했다면 18시부터 19시까지 1시간 동안의 통화에 대해서는 10초당 18원의 요금이 부과되고, 19시부터 20시까지 1시간 동안의 통화에 대해서는 10초당 15원의 요금이 부과된다.

여기서 한 가지 고려해야 할 조건이 있다. 만약 통화가 여러 날에 걸쳐서 이뤄진다면 어떻게 될까? 아래 그림처럼 3일에 걸쳐 통화를 한 가입자가 있다고 가정해보자.

이 경우 시간대별 방식에 따라 요금을 구현하려면 규칙에 정의된 구간별로 통화를 구분해야 한다. 즉, 위 그림의 통화는 아래 그림과 같이 통화 구간을 분리한 후 각 구간에 대해 개별적으로 계솬된 요금을 합해야 한다.

여기서 이야기하고 싶은 것은 시간대별 방식의 통화요금을 계산하기 위해서는 통화의 시작 시간과 종료 시간뿐만 아니라 시작 일자와 종료 일자도 함께 고려해야 한다는 것이다.

시간대별 방식을 구현하는데 있어 핵심은 규칙에 따라 통화 시간을 분할하는 방법을 결정하는 것이다. 이를 위해 기간을 편하게 관리할 수 있는 DateTimeInterval 클래스를 추가하자. DateTimeInterval시작시간(from)종료 시간(to)을 인스턴스 변수로 포함하며, 객체 생성을 위한 정적 메서드(static)인 of, toMidnight, fromMidnight, during을 제공한다.

기존의 Call 클래스는 통화 기간을 저장하기 위해 fromto라는 두 개의 LocalDateTime 타입의 인스턴스 변수를 포함하고 있었다. 이제 기간을 하나의 단위로 표현할 수 있는 DateTimeInterval 타입을 사용할 수 있으므로 fromtointerval이라는 하나의 인스턴스 변수로 묶을 수 있다.

이제 필요한 것들은 준비되었으니 전체 통화 시간을 일자시간 기준으로 분할하여 계산해보자. 이를 위해 요금 계산 로직을 다음과 같이 두 개의 단계로 나눠 구현할 필요가 있다.

  • 통화 기간을 일자별로 분리한다.

  • 일자별로 분리된 기간을 다시 시간대별 규칙에 따라 분리한 후 각 기간에 대해 요금을 계산한다.

위의 두 작업을 객체의 책임으로 할당해보자. 책임을 할당하는 기본 원칙은 책임을 수행하는 데 필요한 정보를 가장 잘 알고있는 정보 전문가에게 할당하는 것이다. 먼저 통화 기간을 일자 단위로 나누는 작업의 정보 전문가는 누구인가? 통화 기간에 대한 정보를 가장 잘 알고있는 객체는 Call이다. 하지만 Call은 통화 기간은 잘 알지 몰라도 기간 자체를 처리하는 방법에 대해서는 전문가가 아니다. 기간을 처리하는 방법에 대한 전문가는 바로 DateTimeInterval이다. 따라서 통화 기간을 일자 단위로 나누는 책임DateTimeInterval에게 할당하고 CallDateTimeInterval에게 분할을 요청하도록 협력을 설계하는 것이 적절할 것이다.

두 번째 작업인 시간대 별로 분할하는 작업의 정보 전문가는 누구인가? 시간대별 기준을 잘 알고있는 요금 정책이며 여기서는 TimeOfDayDiscountPolicy라는 이름의 클래스로 구현할 것이다.

전체 통화 시간을 분할하는 작업은 아래 그림과 같이 TimeOfDayDiscountPolicy, Call, DateTimeInterval 사이의 협력으로 구현할 수 있다.

  1. 먼저 TimeOfDayDiscountPolicy는 통화 기간을 알고있는 Call에게 일자별로 통화 기간을 분리할 것을 요청한다.

  2. Call은 이 요청을 DateTimeInterval에게 위임한다.

  3. DateTimeInterval은 기간을 일자 단위로 분할한 후 분할된 목록을 반환한다.

  4. Call은 반환된 목록을 그대로 TimeOfDayDiscountPolicy에게 반환한다.

  5. TimeOfDayDiscountPolicy는 일자별 기간의 목록을 대상으로 루프를 돌리면서 각 시간대 별 기준에 맞는 시작시간(from)과 종료시간(to)를 얻는다.

이해를 돕기 위해 구체적인 예를 통해 분할 과정을 살펴보자. 시간대별 방식 요금제에 가입한 사용자가 1월 1일 10시 ~ 1월 3일 15시에 걸쳐 통화를 했다고 가정해보자. 그리고 각 시간대 별 요금은 아래와 같다.

  1. 0시 ~ 19시 까지는 10초당 18원의 요금

  2. 19시 ~ 24시 까지는 10초당 15원의 요금

앞에서 설명한 것처럼 시간대별 방식으로 요금을 계산하기 위해서는 우선 날짜별로 통화 시간을 분리해야 한다. Call은 기간을 저장하고 있는 DateTimeInterval 타입의 인스턴스 변수인 interval에게 splitByDay 메시지를 전송한다. splitByDay 메서드는 아래 그림처럼 1월 1일 10시 ~ 24시, 1월 2일 0시 ~ 24시, 1월 3일 0시 ~ 15시를 저장하는 3개의 DateTimeInterval 인스턴스를 포함하는 List를 반환 할 것이다.

Call은 이렇게 분리된 List를 시간대별 방식을 위한 TimeOfDayDiscountPolicy 클래스에게 반환한다. TimeOfDayDiscountPolicy 클래스는 일자별로 분리된 각 DateTimeInterval 인스턴스들을 요금 정책에 정의된 각 시간대 별로 분할한 후 요금을 부과해야 한다.

첫 번째 통화 구간과 두 번째 통화 구간은 조금 복잡한데 1월 1일 10시 ~ 24시1월 2일 0시 ~ 24시가 두 요금 규칙의 시간대에 걸쳐있기 때문이다. 이 요금을 계산하기 위해서는 반환된 일자별 통화 구간을 다시 시간대 별로 나눈 후 나뉘어진 시간대 별로 요금을 계산한 합을 구해야 한다. 따라서 통화 구간을 19시 기준으로 나누고 1월 1일 10시 ~ 19시1월 2일 0시 ~ 19시는 10초당 18원으로, 1월 1일 19시 ~ 24시1월 2일 19시 ~24시는 10초당 15원으로 요금을 계산해야 한다. 세 번째 통화 구간인 1월 3일 0시 ~ 15시는 전체가 0시부터 19시 사이에 포함되기 때문에 전체 구간에 대해 10초당 18원의 요금 규칙을 적용하면 될 것이다.

결과적으로 1월 1일 10시 ~ 1월 3일 15시 사이의 통화 요금을 계산하기 위한 전체 통화 기간은 아래 그림에서 알 수 있는 것 처럼 1월 1일 10시 ~ 19시, 1우러 1일 19시 ~ 24시, 1월 2일 0시 ~ 19시, 1월 2일 19시 ~ 24시, 1월 3일 0시 ~ 15시의 5개로 분리된다.

이제 대략적인 로직을 살펴봤으므로 TimeOfDayDiscountPolicy 클래스를 구현해보자. 이 클래스에서 가장 중요한 것은 시간에 따라 서로 다른 요금 규칙을 정의하는 방법을 결정하는 것이다. 하나의 통화 시간대를 구성하는 데는 시작 시간, 종료 시간, 단위 시간, 단위 요금이 필요하다. 앞의 예에서 살펴본 0시부터 19시 까지의 통화 시간에 대해서는 10초당 18원의 요금을 부과하는 규칙에서 시작 시간은 0시, 종료 시간은 19시, 단위 시간은 10초, 단위 요금은 18원이 된다. 그리고 시간대별 방식은 하나 이상의 시간대로 구성되기 때문에 이 4가지 요소가 하나 이상 존재해야 한다.

시간대별 방식을 담당한 개발자는 이 문제를 4개의 서로 다른 List를 가지는 것으로 해결했다. TimeOfDayDiscountPolicy 클래스는 시작 시간의 List, 종료 시간의 List, 단위 시간의 List, 단위 요금의 List를 포함하며 같은 규칙에 포함된 요소들은 List 안에서 동일한 인덱스에 위치하게 된다.

아래 그림은 0시부터 19시 까지의 통화에 대해서는 10초당 18원의 요금을 부과하고 19시부터 24시 까지는 10초당 15원의 요금을 부과하는 시간대별 방식 요금제를 4개의 리스트로 구성한 예를 표현한 것이다. 같은 규칙에 속하는 요소들이 시작 시간의 List인 starts, 종료 시간의 List인 ends 단위 시간의 List인 durations, 단위 요금의 List인 amounts 안에서 같은 인덱스에 위치한다는 것을 알 수 있을 것이다.

다음은 TimeOfDayDiscountPolicy 클래스의 전체 코드를 나타낸 것이다. 지금까지 설명한 내용을 히했다면 큰 무리 없이 코드를 이해할 수 있을 것이다.

CallsplitByDay 메서드는 DateTimeInterval에 요청을 전달한 후 응답을 반환하는 간단한 위임 메서드다.

DateTimeInterval 클래스의 splitByDay 메서드는 통화 기간을 일자별로 분할해서 반환한다. days 메서드는 fromto 사이에 포함된 날짜 수를 반환한다. 만약 days 메서드의 반환값이 1보다 크다면 (여러 날에 걸쳐있는 경우라면) split 메서드를 호출해서 날짜 수만큼 분리한다. 만약 days 메서드의 반환값이 1이라면(하루 안의 가간이라면) 현재의 DateTimeInterval 인스턴스를 리스트에 담아 그대로 반환한다.

요일별 방식 구현하기

요일별 방식요일별로 요금 규칙을 다르게 설정할 수 있다. 각 규칙은 요일의 목록, 단위 시간,단위 요금이라는 세 가지 요소로 구성된다. 요일별 방식을 사용하면 [월요일 ~ 금요일 10초당 38원], [토요일, 일요일 10초당 19원]을 부과하는 식으로 요금 정책을 설정할 수 있다.

먼저 요일별 방식을 구성하는 규칙들을 구현해야 한다. 시간대별 방식을 개발한 프로그래머는 4개의 List을 이용해서 규칙을 정의했지만 요일별 방식을 개발하는 프로그래머는 규칙을 DayOfWeekDiscountRule이라는 하나의 클래스로 구현하는 것이 더 나은 설계라고 판단했다.

예상할 수 있겠지만 DayOfWeekDiscountRule 클래스는 규칙을 정의하기 위해 필요한 요일의 목록(dayOfWeeks), 단위 시간(duration), 단위 요금(amount)을 인스턴스 변수로 포함한다. calcualte 메서드는 파라미터로 전달된 interval이 요일 조건을 만족시킬 경우 단위 시간과 단위 요금을 이용해 통화 요금을 계산한다.

요일별 방식 역시 통화 기간이 여러 날에 걸쳐있을 수 있다는 사실에 주목하자. 따라서 시간대별 방식과 동일하게 통화 기간을 날짜 경계로 분리하고 분리된 각 통화 기간을 요일별로 설정된 요금 정책에 따라 적절하게 계산해야 한다. 시간대별 방식을 이해했다면 요일별 방식을 구현한 DayOfWeekDiscountPolicy 클래스의 코드를 이해하는데 큰 어려움은 없을 것이다.

구간별 방식 구현하기

이제 구간별 방식만 남았다. 잠시 숨을 고르고 지금까지 작업한 고정요금 방식, 시간대별 방식, 요일별 방식의 구현 클래스를 천천히 살펴보자. FixedFeePolicy, TimeOfDayDiscountPolicy, DayOfWeekDiscountPolicy의 세 클래스는 통화 요금을 정확하게 계산하고 있고 응집도결합도 측면에서도 특별히 문제는 없어보인다. 클래스들을 따로 떨어트려 놓고 살펴보면 그럭저럭 괜찮은 구현으로 보이기까지 한다. 하지만 이 클래스들을 함께 모아놓고 보면 그동안 보이지 않았던 문제점들이 보이기 시작한다.

뒤에서 좀 더 자세히 살펴보겠지만 현재 구현의 가장 큰 문제점은 이 클래스들이 유사한 문제를 해결하고 있음에도 불구하고 설계에 일관성이 없다는 것이다. 이 클래스들은 기본 정책을 구현한다는 공통의 목적을 공유한다. 하지만 정책을 구현하는 방식은 완전히 다르다. 다시말해서 개념적으로는 연관돼 있지만 구현 방식에 있어서는 완전히 제각각이라는 것이다.

비일관성은 두 가지 상황에서 발목을 잡는다.

  1. 새로운 구현을 추가해야 하는 상황.

  2. 기존의 구현을 이해해야 하는 상황

그리고 이 장애물이 문제인 이유는 개발자로서 우리가 수행하는 대부분의 활동이 코드를 추가하고 이해하는 일과 깊숙히 연간돼 있기 때문이다.

먼저 새로운 구현을 추가해야 하는 경우에 발생하는 문제점부터 살펴보자. 여러분이 구간별 방식을 추가해야 하는 개발자라고 가정해보자. 어떻게 구현을 시작하겠는가? 우선 BasicRatePolicy를 상속받는 DurationDiscountPolicy 클래스를 추가할 것이다. 그리고 calculateCallFee 메서드를 오버라이딩 하고 나서 메서드 내부를 채우기 시작할 것이다.

이제 기본 정책 설계에서 가장 중요한 문제인 여러개의 규칙을 구성하는 방법을 결정해야 한다. 구간별 방식은 전체 통화 시간을 일정한 시간 간격으로 분할한 후 분할된 구간별로 규칙을 다르게 부과할 수 있어야 한다.

앞에서 구현한 시간대별 방식, 요일별 방식의 경우에도 여러개의 규칙이 필요했다는 것을 기억하자. 시간대별 방식을 구현한 TimeOfDayDiscountPolicy는 규칙을 구성하는 시작 일자, 종료 일자, 단위 시간, 단위 요금을 각각 별도의 List로 관리했다. 반면에 요일별 방식을 구현한 DayOfWeekDiscountPolicyDayOfWeekDiscountRule이라는 별도의 클래스를 사용했다.

이 두 클래스는 요구사항의 관점에서는 여러 개의 규칙을 사용한다는 공통점을 공유하지만 구현 방식은 완전히 다르다. 여기에 고정요금 방식을 구현한 FixedFeePolicy 클래스를 함께 놓고 보면 상황은 더 복잡해진다. FixedFeePolicy는 오직 하나의 규칙으로만 구성되기 때문에 전혀 다른 구현 방식을 따른다. 결과적으로 세 가지 기본 정책에 대한 세 가지 서로 다른 구현 방식이 존재한다는 것이다.

여러분이라면 구간별 방식을 어떻게 구현하겠는가? TimeOfDayDiscountPolicy처럼 각 요소를 정하는 다수의 List를 유지하겠는가? 아니면 DayOfWeekDiscountPolicy처럼 규칙을 구현하는 독립적인 객체를 추가하겠는가? 아니면 FixedFeePolicy처럼 전혀 다른 새로운 방법을 고안하겠는가? 결정이 어려운 이유는 어떤 방식을 선택하더라도 구간별 방식을 구현하는데 문제가 없다는 것이다. 하지만 전체적인 일관성이라는 측면에서 보면 어떤 방식을 따르더라도 문제가 더 커지게 된다. 현재의 설계는 새로운 기본 정책을 추가하면 추가할수록 코드 사이의 일관성은 점점 더 어긋나게 되는 것이다.

일관성 없는 코드가 가지는 두 번째 문제점은 코드를 이해하기 어렵다는 것이다. 요일별 방식의 구현을 이해하면 시간대별 방식을 이해는게 쉬운가? 그렇지 않다. 요일병 방식의 구현을 이해한 후에 고정 요금 방식의 구현을 본다면 구조를 분석하기가 쉬운가? 전혀 아니다.서로 다른 구현 방식이 코드를 이해하는 데 오히려 방해가 될 뿐이다.

대부분의 사람들은 유사한 요구사항을 구현하는 코드는 유사한 방식으로 구현될 것이라고 예상한다. 하지만 유사한 요구사항이 서로 다른 방식으로 구현돼 있다면 유가사항이 유사하다는 사실 자체도 의심하게 될 것이다. 이 코드가 정말 유사한 요구사항을 구현한 것이라면 왜 이렇게 다른 방식으로 구현한 것일까? 유사한 요구사항을 구현하는 서로 다른 구조의 코드는 코드를 이해하는 데 심리적인 장벽을 만든다.

결론은 유사한 기능을 서로 다른 방식으로 구현해서는 안된다는 것이다. 일관성 없는 설계와 마주한 개발자는 여러 가지 해결 방법 중에서 현재의 요구사항을 해결하기에 가장 적절한 방법을 찾아야 하는 부담을 안게 된다.

유사한 기능은 유사한 방식으로 구현해야 한다. 객체지향에서 기능을 구현하는 유일한 방법은 객체 사이의 협력을 만드는 것 뿐이므로 유지보수 가능한 시스템을 구축하는 첫걸음은 협력을 일관성있게 만드는 것이다.

다시 구간별 방식을 구현하는 문제로 돌아오자. 여러분이라면 구간별 방식을 어떻게 구현하겠는가? 이미 설명한 것처럼 어떤 방법을 선택하더라도 문제가 크게 달라지지 않는다. 여기서는 구간별 방식의 구현을 담당하고 있는 개발자가 기존 방법과는 전혀 다른 새로운 방법으로 구간별 방식을 구현하기로 결정했다고 가정하겠다.

이 개발자는 요일별 방식의 경우처럼 규칙을 정의하는 새로운 클래스를 추가하기로 결정했다. 요일별 방식과 다른 점은 코드를 재사용하기 위해서 FixedFeePolicy 클래스를 상속한다는 것이다. DurationDiscountRule 클래스의 calculate 메서드 안에서 부모 클래스의 calculateFee 메서드를 호출하는 부분을 눈여겨 보기 바란다.

이제 여러개의 DurationDiscountRule을 이용해 DurationDiscountPolicy를 구현할 수 있다.

DurationDiscountPolicy 클래스는 할인 요금을 정상적으로 계산하고, 각 클래스는 하나의 책임만을 수행한다. 하지만 이 설계를 훌륭하다고 말하기는 어려운데 기본 정책을 구현하는 기존 클래스들과 일관성이 없기 때문이다. 기존의 설계가 어떤 가이드도 제공하지 않기 때문에 새로운 기본정책을 구현해야 하는 상황에서 또 다른 개발자는 또 다른 방식으로 기본 정책을 구현할 가능성이 높다. 아마 시간이 흐를수록 설계의 일관성은 더욱더 어긋나게 될 것이다.

지금까지 기본 정책이라는 구체적인 예를 통해 일관성을 고려하지 않은 설계가 가지는 문제점에 관해 살펴봤다. 아마 지금까지 설명했던 구현에 문제가 있다는 사실이 잘 이해되지 않는 사람들도 있을 것이다. 궁금증을 해결할 수 있는 가장 빠르고 확실한 방법은 지금까지 구현한 코드와 일관성 있게 작성한 코드를 비교해 보는 것이다.

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

10장을 주의깊게 학습했다면 DurationDiscountRule 클래스가 상속을 잘못 사용한 경우라는 사실을 눈치챘을 것이다. 문제는 부모 클래스인 FixedFeePolicy는 상속을 위해 설계된 클래스가 아니고 DurationDiscountRule은 FixedFeePolicy의 서브타입이 아니라는 점이다. DurationDiscountRule이 FixedFeePolicy를 상속받는 이유는 FixedFeePolicy 클래스에 선언된 인스턴스 변수인 amount, seconds와 calculateFee 메서드를 재사용하기 위해서다. 다시 말해서 코드 재사용을 위한 상속을 사용한 것이다. 두 클래스 사이의 강한 결합도는 설계 개선과 새로운 기능의 추가를 방해한다.

이 코드는 이해하기도 어려운데 FixedFeePolicy의 calculateFee 메서드를 재사용하기 위해 DurationDiscountRule의 calculate 메서드 안에서 Phone과 Call의 인스턴스를 생성하는 것이 꽤나 부자연스러워 보이기 때문이다. 이것은 상속을 위해 설계된 클래스가 아닌 FixedFeePolicy를 재사용하기 위해 억지로 코드를 비튼 결과다.

설계에 일관성 부여하기

일관성 있는 설계를 만드는 데 가장 훌륭한 조언은 다양한 설계 경험을 익히라는 것이다. 풍부한 설계 경험을 가진 사람은 어떤 변경이 중요한지, 그리고 그 변경을 어떻게 다뤄야 하는지에 대한 통찰력을 가지게 된다. 따라서 설계 경험이 풍부하면 풍부할수록 어떤 위치에서 일관성을 보장해야 하고 일관성을 제공하기 위해 어떤 방법을 사용해야 하는지를 직관적으로 결정할 수 있다. 하지만 이런 설계 경험을 단기간에 쌓아 올리는 것은 생각보다 어려운 일이다.

일관성 있는 설계를 위한 두 번째 조언은 널리 알려진 디자인 패턴을 학습하고 변경이라는 문맥에서 디자인 패턴을 적용해보라는 것이다. 디자인 패턴은 특정한 변경에 대해 일관성 있는 설계를 만들 수 있는 경험 법칙을 모아놓은 일종의 설계 템플릿이다. 디자인 패턴을 학습하면 빠른 시간 안에 전문가의 경험을 흡수할 수 있다.

비록 디자인 패턴이 반복적으로 적용할 수 있는 설계 구조를 제공한다고 하더라도 모든 경우에 적합한 패턴을 찾을수 있는 것은 아니다. 따라서 협력을 일관성있게 만들기 위해 다음과 같은 기본 지침을 따르는 것이 도움이 될 것이다.

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

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

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

언제나 그런 것처럼 원칙을 이해하는 가장 좋은 방법은 코드를 살펴보는 것이다. 영화 예매 시스템으로 다시 돌아가보자.

애플리케이션에서 달라지는 부분을 찾아내고, 달라지지 않는 부분으로부터 분리시킨다. 이것은 여러 설계의 원칙 중에서 첫 번째 원칙이다. 즉, 코드에서 새로운 요구사항이 있을 때마다 바뀌는 부분이 있다면 그 행동을 바뀌지 않는 다른 부분으로부터 골라내서 분리해야 한다는 것을 알 수 있다. 이 원칙은 다음과 같은 식으로 생각할 수도 있다.

바뀌는 부분을 따로 뽑아서 캡슐화한다. 그렇게 하면 나중에 바뀌지 않는 부분에는 영향을 미치지 않은 채로 그 부분만 고치거나 확장할 수 있다.

조건 로직 대 객체 탐색

다음은 4장에서 절차적인 방식으로 구현했던 ReservationAgency의 기본 구조를 정리한 것이다.

위 코드에는 두 개의 조건 로직이 존재한다. 하나는 할인 조건의 종류를 결정하는 부분이고 다른 하나는 할인 정책을 결정하는 부분이다. 이 설계가 나쁜 이유는 변경의 주기가 서로 다른 코드가 한 클래스 안에 뭉쳐있기 때문(응집도가 낮기 때문)이다. 또한 새로운 할인 정책이나 할인 조건을 추가하기 위해서는 기존 코드의 내부를 수정해야 하기 때문에 오류가 발생할 확률이 높아진다.

할인 조건과 할인 정책의 종류를 판단하는 두 개의 if문이 존재하며 새로운 조건이 필요하면 우리는 이 if문들에 새로운 else절을 추가하게 될 것이다. 따라서 조건에 따라 분기되는 어떤 로직들이 있다면 이 로직들이 바로 개별적인 변경이라고 볼 수 있다. 절차지향 프로그램에서 변경을 처리하는 전통적인 방법은 이처럼 조건문의 분기를 추가하거나 개별 분기 로직을 수정하는 것이다.

객체지향은 조금 다른 접근방법을 취한다. 객체지향에서 변경을 다루는 전통적인 방법은 조건 로직을 객체 사이의 이동으로 바꾸는 것이다. 아래 코드를 보면 Movie는 현재의 할인 정책이 어떤 종류인지 확인하지 않는다. 단순히 현재의 할인 정책을 나타내는 discountPolicy에 필요한 메시지를 전송할 뿐이다. 할인 정책의 종류를 체크하던 조건문discountPolicy로의 객체 이동으로 대체된 것이다.

다형성은 바로 이런 조건 로직을 객체 사이의 이동으로 바꾸기 위해 객체지향이 제공하는 설계 기법이다. 할인 금액을 계산하는 구쳊거인 방법은 메시지를 수신하는 discountPolicy의 구체적인 타입에 따라 결정된다. MoviediscountPolicy가 자신의 요청을 잘 처리해줄 것이라고 믿고 메시지를 전송할 뿐이다.

DiscountPolicy와 할인 조건을 구현하는 DiscountCondition 사이의 협력 역시 마찬가지다. DiscountPolicyDiscountCondition을 믿고 isSatisfiedBy 메시지를 전송한다.

DiscountPolicyDiscountCondition은 협력에 참여하는 객체들이 수행하는 역할이다. 추상적인 수준에서 협력은 아래의 그림과 같이 역할을 따라 흐른다.

하지만 실제로 협력에 참여하는 주체는 구체적인 객체다. 이 객체들은 협력 안에서 DiscountPolicyDiscountCondition대체할 수 있어야 한다. 다시 말해서 DiscountPolicyDiscountCondition서브타입이어야 한다. 아래 그림은 클래스와 인터페이스를 이용해 이 타입 계층을 구현한 것이다.

Movie는 현재의 할인 정책이 어떤 종류인지 판단하지 않는다. 단지 DiscountPolicy로 향하는 참조를 통해 메시지를 전달할 뿐이다. 할인 정책의 구체적인 종류는 메시지를 수신한 객체의 타입에 따라 달라지며 실행할 메서드를 결정하는 것은 순전히 메시지를 수신한 객체의 책임이다. DiscountPolicy 역시 할인 조건의 종류를 판단하지 않는다. 단지 DiscountCondition으로 향하는 참조를 통해 메시지를 전달할 뿐이다. 객체지향적인 코드는 조건을 판단하지 않는다. 단지 다음 객체로 이동할 뿐이다.

지금까지 살펴본 것처럼 조건 로직객체 사이의 이동으로 대체하기 위해서는 커다란 클래스를 더 작은 클래스로 분리해야 한다. 그렇다면 클래스를 분리하기 위해 “어떤 기준”을 따르는 것이 좋을까? 가장 중요한 기준은 변경의 이유와 주기다. 클래스는 명확히 단 하나의 이유에 의해서만 변경(단일 책임의 원칙)되어야 하고 클래스 안의 모든 코드는 함께 변경되어야 한다.

큰 메서드 안에 뭉쳐있던 조건 로직들을 압력에 맞춰 작은 클래스들로 분리하고 나면 인스턴스들 사이의 협력 패턴에 일관성을 부여하기가 더 쉬워진다. 유사한 행동을 하는 작은 클래스들이 자연스럽게 역할이라는 추상화로 묶이게 되고 역할 사이에서 이뤄지는 협력 방식이 전체 설계의 일관성을 유지할 수 있게 이끌어주기 때문이다.

MovieDiscountPolicy, DiscountCondition 사이의 협력 패턴은 변경을 기준으로 클래스를 분리함으로써 어떻게 일관성 있는 협력을 얻을 수 있는지를 잘 보여준다. 이 협력 패턴은 말 그대로 일관성이 있기 때문에 이해하기 쉽다. Movie, DiscoutnPolicy, DiscoutnCondition 사이의 협력 방식을 이해하면 새로운 할인 정책에 마주치더라도 설계를 쉽게 이해할 수 있다.

이 설계는 새로운 할인 정책과 할인 조건을 추가하기도 용이하다. 새로운 할인 정책은 DiscountPolicy의 자식 클래스로, 할인 조건은 DiscountCondition 인터페이스를 실체화하는 클래스로 구현해야 한다는 가이드를 제공하기 때문이다. 게다가 기존 코드를 수정할 필요도 없다.

따라서 협력을 일관성 있게 만들기 위해 따라야 하는 첫 번째 지침은 다음과 같다.

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

할인 정책과 할인 조건의 타입을 체크하는 하나하나의 조건문이 개별적인 변경(독립적인 변경)이었다는 점을 기억하라. 우리는 각 조건문을 개별적인 객체로 분리하였고 이 객체들과 일관성있게 협력하기 위해 타입 계층을 구성하였다. 그리고 이 타입계층을 클라이언트로부터 분리하기 위해 역할을 도입하고 최종적으로 이 역할추상 클래스인터페이스로 구현하였다. 결과적으로 변하는 개념을 별도의 서브타입으로 분리한 후 이 서브타입들을 클라이언트로부터 캡슐화 한 것이다.

따라서 일관성 있는 협력을 만들기 위한 두 번째 지침은 다음과 같다.

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

Movie로부터 할인 정책을 분리한 후 추상 클래스인 DiscountPolicy를 부모로 삼아 상속 계층을 구성한 이유가 바로 Movie(클라이언트)로 부터 구체적인 할인 정책들을 캡슐화하기 위해서다. 실행 시점에 Movie는 자신과 협력하는 객체의 구체적인 타입에 대해 알지 못한다. Movie가 알고있는 사실은 협력하는 객체가 단지 DiscountPolicy 클래스의 인터페이스에 정의된 calculateDiscountAmount 메시지를 이해할 수 있다는 것 뿐이다. 메시지 수신자의 구체적인 타입은 Movie에 대해 완벽하게 캡슐화된다.

핵심은 훌륭한 추상화를 찾아 추상화에 의존하도록 만드는 것이다. 추상화에 대한 의존은 결합도를 낮추고 결과적으로 대체 가능한 역할로 구성된 협력을 설계할 수 있게 해준다. 따라서 선택하는 추상화의 품질이 캡슐화의 품질을 결정한다.

타입을 캡슐화하고 낮은 의존성을 유지하기 위해서는 지금까지 살펴본 다양한 기법들이 필요하다. 6장에서 살펴본 인터페이스 설계 원칙들을 적용하면 구현을 효과적으로 캡슐화하는 코드를 구현할 수 있다. 8장과 9장에서 설명한 의존성 관리 기법타입을 캡슐화하기 위해 낮은 결합도를 유지할 수 있는 방법을 잘 보여준다. 타입을 캡슐화하기 위해서가 아니라 코드 재사용을 위해 상속을 사용하고 있따면 10장의 주의사항을 살펴보기 바란다. 상속 대신 11장에서 설명한 합성을 사용하는 것도 캡슐화를 보장할 수 있는 훌륭한 방법이다. 13장에서 설명한 원칙을 따르면 리스코프 치환 원칙을 준수하는 타입 계층을 구현하는 데 상속을 이용할 수 있을 것이다.

변경에 초점을 맞추고 캡슐화의 관점에서 설계를 바라보면 일관성 있는 협력 패턴을 얻을 수 있다. 다음 장에서 살펴볼 디자인 패턴은 이 같은 접근법을 통해 우리가 얻을 수 있는 훌륭한 설계에 대한 경험의 산물이다.

구성요소를 캡슐화하는 실행 지침은 객체지향의 핵심 덕목 중 하나다. 시스템을 책임을 캡슐화한 섬들로 분리하고 그 섬들 간의 결합도를 제한하라.

이 실행 지침이 드러나는 또 다른 주제가 패턴이다. GOF에 의하면 인터페이스에 대해 설계해야 한다고 조언하는데, 이것은 결합도가 느슨해질 수 있도록 엔티티 사이의 관계가 추상적인 수준에서 결정해야 한다는 사실을 다르게 표현한 것이다. 패턴은 매우 빈번하게 요소들이 관계를 맺을 수 있는 대상을 “추상적인 기반 타입”으로 제한한다.

캡슐화 다시 살펴보기

많은 사람들은 객체의 캡슐화에 관한 이야기를 들으면 반사적으로 데이터 은닉을 떠올린다. 데이터 은닉이란 오직 외부에 공개된 메서드를 통해서만 객체의 내부에 접근할 수 있게 제한함으로써 객체 내부의 상태 구현을 숨기는 기법을 가리킨다. 간단하게 말해서 클래스의 모든 인스턴스 변수를 private으로 선언해야 하고 오직 해당 클래스의 메서드만이 인스턴스 변수에 접근할 수 있어야 한다는 것이다.

그러나 캡슐화데이터 은닉 이상이다. GOF가 저술한 디자인 패턴은 설계와 관련된 풍부하고 가치 있는 조언들로 가득 찬 보물과 같은 책이다. 이 책에는 캡슐화와 관련해서 우리의 관심을 끌 만한 중요한 조언들이 들어 있다.

설계에서 무엇이 변화할 수 있는지 고려하라. 이 접근법은 재설계의 원인에 초점을 맞추는 것과 반대되는 것이다. 설계에 변경을 강요하는 것이 무엇인지에 대해 고려하기보다는 재설계 없이 변경할 수 있는 것이 무엇인지 고려하라. 여기서의 초점은 많은 디자인 패턴의 주제인 변화하는 개념을 캡슐화하는 것이다.

GOF의 조언에 따르면 캡슐화란 단순히 데이터를 감추는 것이 아니다. 소프트웨어 안에서 변할 수 있는 모든 “개념”을 감추는 것이다. 개념이라는 말이 다소 추상적으로 들린다면 간단히 다음처럼 생각하자.

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

캡슐화를 단순히 데이터 숨기기로만 생각한다면 이 말이 조금은 이상하게 들릴 것이다. 하지만 실제로 설계라는 맥락에서 캡슐화를 논의할 때는 그 안에 항상 변경이라는 주제가 녹아있다.

캡슐화의 가장 대표적인 예는 객체의 퍼블릭 인터페이스와 구현을 분리하는 것이다. 객체를 구현한 개발자는 필요할 때 객체의 내부 구현을 수정하기를 원한다. 객체와 협력하는 클라이언트의 개발자는 객체의 인터페이스가 변하지 않기를 원한다. 따라서 자주 변경되는 내부 구현을 안정적인 퍼블릭 인터페이스 뒤로 숨겨야 한다.

다시 한번 강조하지만 캡슐화란 단순히 데이터를 감추는 것이 아니다. 소프트웨어 안에서 변할 수 있는 어떤 개념이라도 감추는 것이다. 이 사실을 기억하면서 아래 그림을 살펴보자.

위 그림에서는 다음과 같은 다양한 종류의 캡슐화가 공존한다.

  • 데이터 캡슐화 : Movie 클래스의 인스턴스 변수 title의 가시성은 private이기 때문에 외부에서 직접 접근할 수 없다. 이 속성에 접근할 수 있는 유일한 방법은 메서드를 이용하는 것뿐이다. 다시말해 클래스는 내부에 관리하는 데이터를 캡슐화한다.

  • 메서드 캡슐화 : DiscountPolicy 클래스에서 정의돼 있는 getDiscountAmount 메서드의 가시성은 protected다. 클래스의 외부에서는 이 메서드에 직접 접근할 수 없고 클래스 내부와 서브클래스에서만 접근이 가능하다. 따라서 클래스 외부에 영향을 미치지 않고 메서드를 수정할 수 있다. 다시말해 클래스의 내부 행동을 캡슐화 하고 있는 것이다.

  • 객체 캡슐화 : Movie 클래스는 DiscountPolicy 타입의 인스턴스 변수 discountPolicy를 포함(합성)한다. 이 인스턴스 변수는 private 가시성을 가지기 때문에 Movie와 DiscountPolicy 사이의 관계를 변경하더라도 외부에는 영향을 미치지 않는다. 다시 말해서 객체와 객체 사이의 관계를 캡슐화한다. 객체 캡슐화합성을 의미한다.

  • 서브타입 캡슐화 : Movie는 DiscountPolicy에 대해서는 알고 있지만 서브타입인 AmountDiscountPolicy와 PercentDiscountPolicy에 대해서는 알지 못한다 그러나 실제로 실행 시점에는 이 클래스들의 인스턴스와 협력할 수 있다. 이것은 기반 클래스인 DiscountPolicy와의 추상적인 관계가 AmountDiscountPolicy와 PercentDiscountPolicy의 존재를 감추고 있기 때문이다. 다시 말해 서브타입의 종류를 캡슐화 하고 있는 것이다. 서브타입 캡슐화다형성의 기반이 된다.

캡슐화란 단지 데이터 은닉을 의미하는 것이 아니다. 코드 수정으로 인한 파급 효과를 제어할 수 있는 모든 기법이 캡슐화의 일종이다. 일반적으로 데이터 캡슐화메서드 캡슐화개별 객체에 대한 변경을 관리하기 위해 사용하고 객체 캡슐화서브타입 캡슐화는 협력에 참여하는 객체들의 관계에 대한 변경을 관리하기 위해 사용한다.

변경을 캡슐화 할 수 있는 다양한 방법이 존재하지만 협력의 일관성을 위해 가장 일반적으로 사용하는 방법은 서브타입 캡슐화와 객체 캡슐화를 조합하는 것이다. 위 그림을 살펴보면 알 수 있는 것 처럼 서브타입 캡슐화는 인터페이스 상속을 사용하고 객체 캡슈로하는 합성을 사용한다.

서브타입 캡슐화객체 캡슐화를 적용하는 방법은 다음과 같다.

변하는 부분을 분리해서 타입 계층을 만든다.

변하지 않는 부분으로부터 변하는 부분을 분리한다. 변하는 부분들의 공통적인 행동추상 클래스인터페이스로 추상화 한 후 변하는 부분들이 이 추상 클래스나 인터페이스를 상속받게 만든다. 이제 변하는 부분은 변하지 않는 부분의 서브타입이 된다. 영화 예매 시스템이서 DiscountPolicy추상 클래스로, DiscountCondition인터페이스로 구현한 점을 눈여겨보기 바란다.

변하지 않는 부분의 일부로 변하는 부분(타입 계층)을 합성한다.

앞에서 구현한 타입 계층변하지 않는 부분에 합성한다. 변하지 않는 부분에서는 변경되는 구체적인 사항에 결합돼서는 안된다. 의존성 주입과 같이 결합도를 느슨하게 유지할 수 있는 방법을 이용해 오직 추상화에만 의존하게 만든다. 이제 변하지 않는 부분은 변하는 부분의 구체적인 종류에 대해서는 알지 못할 것이다. 변경이 캡슐화된 것이다. MovieDiscountPolicy합성 관계로 연결하고 생성자를 통해 의존성을 해결한 이유가 바로 이 때문이다.

여기서 설명한 방법은 조건 로직객체 이동으로 대체함으로써 변경을 캡슐화 할 수 있는 다양한 방법 중에서 가장 대표적인 방법일 뿐이다. 변경의 이유에 따라 캡슐화할 수 있는 다양한 방법이 궁금하다면 디자인 패턴을 살펴보기 바란다.

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

변경 분리하기

일관성 있는 협력을 만들기 위한 첫 번째 단계는 변하는 개념과 변하지 않는 개념을 분리하는 것이다. 그렇다면 핸드폰 과금 시스템의 기본 정책에서 변하는 부분과 변하지 않는 부분은 무엇인가? 이 질문에 답하기 위해서는 기본 정책의 요구사항을 정리해 볼 필요가 있다.

아래의 표는 기본 정책을 구성하는 각 방식별 요금 규칙을 정리한 것이다. 표시된 시간대별, 요일별, 구간별 방식의 패턴을 살펴보면 어느 정도 유사한 형태를 띤다는 것을 알 수 있따. 고정요금 방식은 이 세 가지 방식과는 조금 다르기 때문에 뒤에서 다시 살펴보겠다.

먼저 시간대별, 요일별, 구간별 방식의 공통점은 각 기본 정책을 구성하는 방식이 유사하다는 점이다.

  • 기본 정책은 한 개 이상의 규칙으로 구성된다.

  • 하나의 규칙적용 조건단위 요금의 조합이다.

단위 요금은 말 그대로 단위 시간당 요금 정보를 의미한다. 적용조건은 통화 요금을 계산하는 조건을 의미한다. 단위요금적용조건이 모여 하나의 규칙을 구성한다. 그림에서 볼 수 있는 것처럼 시간대별, 요일별, 구간별 방식은 하나 이상의 규칙들의 집합이라는 공통점을 가진다.

이미 눈치 챘겠지만 시간대별, 요일별, 구간별 방식의 차이점은 각 기본 정책별로 요금을 계산하는 “적용 조건”의 형식이 다르다는 것이다. 모든 규칙에 적용조건이 포함된다는 사실은 변하지 않지만 실제 조건의 세부적인 내용은 다르다. 시간대별 방식은 통화 시간이 특정 시간 구간에 포함될 경우에만 요금을 계산한다. 요일별 방식은 특정 요일에 해당할 경우에만 요금을 계산한다. 구간별 방식은 통화시간의 구간이 경과 시간 안에 포함될 경우에만 요금을 계산한다. 조건의 세부 내용이 바로 변화에 해당한다.

공통점은 변하지 않는 부분이다. 차이점은 변하는 부분이다. 우리의 목적은 변하지 않는 것과 변하는 것을 분리하는 것이라는 점을 기억하라. 따라서 변하지 않는 규칙으로부터 변하는 적용조건을 분리해야 한다.

변경 캡슐화하기

협력을 일관성 있게 만들기 위해서는 변경을 캡슐화해서 파급효과를 줄여야 한다. 변경을 캡슐화하는 가장 좋은 방법은 변하지 않는 부분으로부터 변하는 부분을 분리하는 것이다. 물론 변하는 부분의 공통점을 추상화하는 것도 잊어서는 안된다. 이제 변하지 않는 부분이 오직 이 추상화에만 의존하도록 관계를 제한하면 변경을 캡슐화할 수 있게 된다.

여기서 변하지 않는 것은 규칙이다. 변하는 것은 적용 조건이다. 따라서 규칙으로부터 적용조건을 분리해서 추상화한 후 시간대별, 요일별, 구간별 방식을 이 추상화서브타입으로 만든다. 이것이 서브타입 캡슐화다. 그 후에 규칙적용조건을 표현하는 추상화를 합성 관계로 연결한다. 이것이 객체 캡슐화다.

아래 그림은 개별 규칙을 구성하는 데 필요한 클래스들의 관계를 나타낸 것이다. 하나의 기본 정책은 하나 이상의 규칙들로 구성된다. 따라서 기본 정책을 표현하는 BasicRatePolicyFeeRule의 컬렉션을 포함한다.

FeeRule규칙을 구현하는 클래스이며 단위요금FeeRule의 인스턴스 변수인 feePerDuration에 저장돼 있다. FeeCondition적용 조건을 구현하는 인터페이스이며 변하는 부분을 캡슐화 하는 추상화다. 각 기본 정책별로 달라지는 부분은 각각의 서브타입으로 구현된다. 이름에서 예상할 수 있는 것처럼 TimeOfDayFeeCondtion은 시간대별 방식, DayOfWeekFeeCondtion은 요일별 방식, DurationFeeCondition은 구간별 방식을 구현한다.

FeeRuleFeeCondition합성 관계로 연결하고 있다는 점에 주목하라. FeeRule이 오직 FeeCondition에만 의존하고 있다는 점도 주목하라. FeeRuleFeeCondition의 어떤 서브타입도 알지 못한다. 따라서 변하는 FeeCondition의 서브타입은 변하지 않는 FeeRule로부터 캡슐화된다.

정리해보자. 이 도메인 모델은 앞에서 설명한 것처럼 변하지 않는 부분으로부터 변하는 부분을 효과적으로 분리한다. 변하지 않는 부분은 기본 정책이 여러 규칙들의 집합이며, 하나의 규칙적용 조건단위 요금으로 구성된다는 것이다. 이 관계는 BasicRatePolicy, FeeRule, FeeCondition의 조합으로 구현된다. 변하는 부분은 적용조건의 세부적인 내용이다. 이것은 FeeCondition의 서브타입인 TimeOfDayFeeCondtion, DayOfWeekFeeCondtion, DurationFeeCondition으로 구현된다. 그리고 FeeRule추상화인 FeeCondition에 대해서만 의존하기 때문에 적용조건이 변하더라도 영향을 받지 않는다. 즉, 적용조건이라는 변경에 대해 캡슐화돼 있다.

협력 패턴 설계하기

이제 객체들의 협력 방식을 고민해보자. 변하는 부분과 변하지 않는 부분을 분리하고, 변하는 부분을 적절히 추상화하고 나면 변하는 부분은 생략한 채 변하지 않는 부분만을 이용해서 객체 사이의 협력을 이야기 할 수 있다. 추상화만으로 구성한 협력은 추상화를 구체적인 사례로 대체함으로써 다양한 상황으로 확장할 수 있게 된다. 다시 말해서 재사용 가능한 협력 패턴이 선명하게 드러나는 것이다. 아래 그림은 이런 변하지 않는 추상화만을 남긴 것이다.

아래 그림은 위의 추상화들이 참여하는 협력을 나타낸 것이다. 협력은 BasicRatePolicycalculateFee 메시지를 수신했을 때 시작된다. BasicRatePolicycalculateFee 메서드는 인자로 전달받은 통화 목록(List 타입)의 전체 요금을 계산한다. `BasicRatePolicy`는 목록에 포함된 각 `Call` 별로 `FeeRule`의 `calculateFee` 메서드를 실행한다. 하나의 `BasicRatePolicy`는 하나 이상의 `FeeRule`로 구성되기 때문에 `Call` 하나당 `FeeRule`에 다수의 `calculateFee` 메시지가 전송된다.

FeeRule하나의 Call에 대해 요금을 계산하는 책임을 수행한다. 현재 FeeRule은 단위 시간당 요금인 feePerDuration과 요금을 적용할 조건을 판단하는 적용조건인 FeeCondition의 인스턴스를 알고 있다.

하나의 Call 요금을 계산하기 위해서는 두 개의 작업이 필요하다. 하나는 전체 통화 시간을 각 규칙의 “적용조건”을 만족하는 구간들로 나누는 것이다. 다른 하나는 이렇게 분리된 통화 구간에 “단위요금”을 적용해서 요금을 계산하는 것이다.

이해를 돕기 위해 아래 그림처럼 00시 ~ 19시까지 10초당 18원19시 ~ 24시까지 10초당 15원을 부과하는 두 개의 규칙으로 구성된 시간대별 방식이 있다고 가정하자. 이 요금제에 가입한 사용자가 1월 1일 10시부터 1월 3일 15시까지 통화를 한 경우 통화 요금을 계산하기 위해 먼저 전체 통화 시간을 두 규칙의 적용 기준인 00시 ~ 19시, 19시 ~ 24시 기준으로 나눈다. 그 결과, 아래 그림과 같이 00시 ~ 19시 사이에 포함되는 세 개의 기간과, 19시 ~ 24시에 포함되는 두 개의 구간을 얻게 된다. 이제 각 구간에 포함된 요소들에 대해 요금기준인 10초당 18원, 10초당 15원을 적용해서 요금을 계산한 후 모두 더하면 최종 요금을 얻게 된다.

객체지향에서는 모든 작업을 객체의 책임으로 생각하기 때문에 이 두 개의 책임을 객체에게 할당하자. 전체 통화 시간을 각 규칙적용조건을 만족하는 구간들로 나누는 첫 번째 작업은 적용조건을 가장 잘 알고 있는 정보 전문가인 FeeCondition에게 할당하는 것이 적절할 것이다. 이렇게 분리된 통화 구간에 단위요금을 적용해서 요금을 계산하는 두 번째 작업은 요금기준의 정보 전문가인 FeeRule이 담당하는 것이 적절할 것이다.

아래 그림은 이 협력 과정을 그림으로 나타낸 것이다. FeeRuleFeeCondition의 인스턴스에게 findTimeIntervals 메시지를 전송한다. findTimeIntervals는 통화 기간 중에서 적용조건을 만족하는 구간을 가지는 DateTimeIntervalList를 반환한다. FeeRulefeePerDuration 정보를 이용해 반환받은 기간만큼의 통화 요금을 계산한 후 반환한다.

이 협력에 FeeCondition이라는 추상화가 참여하고 있다는 것에 주목하라. 만약 기간별 방식으로 요금을 계산하고 싶다면 아래 그림처럼 TimeOfDayFeeCondition의 인스턴스가 FeeCondition의 자리를 대신할 것이다.

이 협력 구조가 잘 작동할까? 그렇다고 믿고 싶지만 확신할 수 는 없다. 올바른 방향으로 나아가고 있는지 확인할 수 있는 유일한 방법은 협력을 직접 구현해보는 것뿐이다.

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

먼저 적용 조건을 표현하는 추상화인 FeeCondition에서 시작하자. FeeConditionfindTimeIntervals라는 단 하나의 오퍼레이션을 포함하는 간단한 인터페이스다. 이 오퍼레이션은 인자로 전달된 Call의 통화 기간 중에서 적용조건을 만족하는 기간을 구한 후 List에 담아 반환한다.

FeeRule단위요금(feePerDuration)적용조건(feeCondition)을 저장하는 두 개의 인스턴스 변수로 구성된다. FeeRulecalculateFee 메서드는 FeeCondition에게 findTimeIntervals 메시지를 전송해서 조건을 만족하는 시간의 목록을 반환받은 후 feePerDuration의 값을 이용해 요금을 계산한다.

FeePerDuration 클래스는 단위 시간당 요금이라는 개념을 표현하고 이 정보를 이용해 일정 기간동안의 요금을 계산하는 calculate 메서드를 구현한다.

이제 BasicRatePolicyFeeRule컬렉션을 이용해 전체 통화 요금을 계산하도록 수정할 수 있다.

지금까지 구현한 클래스와 인터페이스는 모두 변하지 않는 추상화에 해당한다. 이 요소들을 조합하면 전체적인 협력 구조가 완성된다. 다시 말해서 변하지 않는 요소와 추상적인 요소만으로도 요금 계산에 필요한 전체적인 협력 구조를 설명할 수 있다는 것이다. 이것이 핵심이다. 변하는 것과 변하지 않는 것을 분리하고 변하는 것을 캡슐화한 코드는 오로지 변하지 않는 것과 추상화에 대한 의존성만으로도 전체적인 협력을 구현할 수 있다. 변하는 것은 추상화 뒤에 캡슐화되어 숨겨져 있기 때문에 전체적인 협력의 구조에 영향을 미치지 않는다.

이제 여러분은 추상적인 수준에서의 협력을 완성했다. 하지만 협력이 동작하기 위해서는 구체적이고 살아있는 컨텍스트로 확장해야 한다. 이 목표는 FeeCondition이라는 추상화의 서브타입을 추가함으로써 달성할 수 있다.

구체적인 협력 구현하기

현재의 요금제가 시간대별 정책인지, 요일별 정책인지, 구간별 정책인지를 결정하는 기준은 FeeCondition을 대체하는 객체의 타입이 무엇인가에 달려있다. 다시 말해서 FeeCondition 인터페이스를 실체화하는 클래스에 따라 기본 정책의 종류가 달라진다.

시간대별 정책 시간대별 정책의 적용조건을 구현하는 TimeOfDayCondition에서 시작하자. TimeOfDayCondition의 인스턴스는 협력 안에서 FeeCondition대체할 수 있어야한다. 따라서 FeeCondition의 인터페이스를 구현하는 서브타입으로 만들어야 한다. 시간대별 정책의 적용조건은 시작시간부터 종료시간까지 패턴으로 구성되기 때문에 시작시간(from)종료시간(to)을 인스턴스 변수로 포함한다.

findTimeIntervals 메서드는 인자로 전달된 Call의 통화 기간 중에서 TimeOfDayFeeCondtionfromto 사이에 포함되는 시간 구간을 반환한다. 이를 위해 DateTimeIntervalsplitByDay 메서드를 호출해서 날짜별로 시간 간격을 분할한 후 fromto 사이의 시간대를 구하면 된다.

요일별 정책 요일별 정책의 적용조건은 DayOfWeekFeeCondition 클래스로 구현한다. 이 클래스 역시 FeeCondition 인터페이스를 구현한다. 요일별 정책은 평일이나 주말처럼 서로 인접한 다수의 요일들을 하나의 단위로 묶어 적용하는 것이 일반적이다. 따라서 여러 요일을 하나의 단위로 관리할 수 있도록 DayOfWeek의 컬렉션을 인스턴스 변수로 포함한다.

findTimeIntervals 메서드는 Call의 기간 중에서 요일에 해당하는 기간만을 추출해 반환하면 된다.

구간별 정책 처음 설계에서 구간별 정책을 추가할 때 겪었던 어려움을 떠올려보자. 이전의 설계에서는 새로운 기본 정책을 추가하기 위해 따라야 하는 지침이 존재하지 않았기 때문에 개발자는 자신이 선호하는 방식으로 구간별 방식을 추가해야 했다. 이처럼 유사한 기능을 서로 다른 방식으로 구현하면 협력의 일관성을 유지하기 어렵기 때문에 이해하고 유지보수하기 어려운 코드가 만들어질 수 밖에 없다는 사실을 알 수 있었다.

협력을 일관성있게 만들면 문제를 해결할 수 있다. 간단하게 FeeCondition 인터페이스를 구현하는 DurationFeeCondition 클래스를 추가한 후 findTimeIntervals 메서드를 오버라이딩하면 된다.

이 예제는 변경을 캡슐화해서 협력을 일관성있게 만들면 어떤 장점을 얻을수 있는지를 잘 보여준다. 변하는 부분을 변하지 않는 부분으로부터 분리했기 때문에 변하지 않는 부분을 재사용할 수 있다. 그리고 새로운 기능을 추가하기 위해 오직 변하는 부분만 구현하면 되기 때문에 원하는 기능을 쉽게 완성할 수 있다. 따라서 코드의 재사용성이 향상되고 테스트해야 하는 코드의 양이 감소한다. 기능을 추가할 때 따라야 하는 구조를 강제할 수 있기 때문에 기능을 추가하거나 변경할 때도 설계의 일관성이 무너지지 않는다. 새로운 기본 정책을 추가하고 싶다면 FeeCondition 인터페이스를 구현하는 클래스를 구현하고 FeeRule과 연결하기만 하면 된다.

기본 정책을 추가하기 위해 규칙을 지키는 것 보다 어기는 것이 더 어렵다는 점에 주목하자. 일관성 있는 협력은 개발자에게 확장 포인트를 강제하기 때문에 정해진 구조를 우회하기 어렵게 만든다. 개발자는 코드의 형태로 주어진 제약 안에 머물러야 하지만 작은 문제에 집중할 수 있는 자유를 얻는다. 그리고 이 작은 문제에 대한 해결책을 전체 문맥에 연결함으로써 협력을 확장하고 구체화할 수 있다.

변경 전의 설계는 전체적으로 일관성이 떨어지기 때문에 코드에 대해 가지고 있던 기존의 지식이 유사한 기능을 이해하는데 아무런 도움도 되지 않았다. 오히려 기존 코드에 대한 선입견이 이해에 걸림돌로 작용했다.

협력을 일관성 있게 만들면 상황이 달라진다. 변하지 않는 부분은 모든 기본 정책에서 공통적이라는 것을 기억하라. 이 공통 코드의 구조와 협력 패턴은 모든 기본 정책에 걸쳐 동일하기 때문에 코드를 한 번 이해하면 이 지식을 다른 코드를 이해하는 데 그대로 적용할 수 있다.

일단 일관성 있는 협력을 이해하고 나면 변하는 부분만 따로 뗴어내어 독립적으로 이해하더라도 전체적인 구조를 쉽게 이해할 수 있다.

유사한 기능에 대해 유사한 협력 패턴을 적용하는 것은 객체지향 시스템에서 “개념적 무결성”을 유지할 수 있는 가장 효과적인 방법이다.

개념적 무결성일관성과 동일한 뜻으로 간주해도 무방하다. 시스템이 일관성 있는 몇 개의 협력 패턴으로 구성된다면 시스템을 이해하고, 수정하고, 확장하는 데 필요한 노력과 시간을 아낄 수 있다. 따라서 협력을 설계하고 있다면 항상 기존의 협력 패턴을 따를 수 는 없는지 고민하라. 그것이 시스템의 개념적 무결성을 지키는 최선의 방법일 것이다.

저자는 개념적 무결성(일관성)이 시스템 설계에서 가장 중요하다고 감히 주장한다. 좋은 기능들이긴 하지만 서로 독립적이고 조화되지 못한 아이디어들을 담고 있는 시스템보다는 여러 가지 다양한 기능이나 갱신된 내용은 비록 빠졌더라도 하나로 통합된 일련의 설계 아이디어를 반영하는 시스템이 훨씬 좋다.

협력 패턴에 맞추기

이제 고정 요금 정책만 남았다. 여러 개의 규칙으로 구성되고 규칙적용조건단위요금의 조합으로 구성되는 시간대별, 요일별, 기간별 정책과 달리 고정요금 정책은 규칙이라는 개념이 필요하지 않고 단위요금 정보만 있으면 충분하다. 고정요금 정책은 기존의 협력 방식에서 벗어날 수밖에 없는 것이다. 우리는 벽에 부딪히고 말았다.

이런 경우에 또 다른 협력 패턴을 적용하는 것이 최선의 선택인가? 그렇지 않다. 가급적 기존의 협력 패턴에 맞추는 것이 가장 좋은 방법이다. 비록 설계를 약간 비트는 것이 조금은 이상한 구조를 낳더라도 전체적으로 일관성을 유지할 수 있는 설계를 선택하는 것이 현명하다.

고정요금 정책을 어떻게 하면 기존 협력에 맞출 수 있을까? 유일하게 협력할 수 있는 일은 FeeCondition의 서브타입을 추가하는 것뿐이다. 나머지는 변하지 않는 부분이므로 수정할 수 없다. 따라서 이 문제를 해결할 수 있는 유일한 방법은 고정요금 방식의 FeeCondition을 추가하고 인자로 전달된 Call의 전체 통화 시간을 반환하게 하는 것이다. 이 컬렉션을 반환받은 FeeRule은 단위 시간당 요금 정보를 이용해 전체 통화 기간에 대한 요금을 계산할 것이다.

FixedFeeCondition 클래스를 추가하자.

개념적으로는 불필요한 FixedFeeCondition 클래스를 추가하고 findTimeIntervals 메서드의 반환 타입이 List임에도 항상 단 하나의 DateTimeInterval 인스턴스를 반환한다는 사실이 마음에 조금 걸리지만 개념적 무결성을 무너뜨리는 것보다는 약간의 부조화를 수용하는 편이 더 낫다.

아래 그림은 핸드폰 과금 시스템의 기본 정책과 부가 정책을 아우르는 전체 클래스 다이어그램을 나타낸 것이다. 추상화와 함께 합성과 상속이 변경을 어떻게 캡슐화하고 있는지 다시 한번 음미해보기 바란다.

지속적으로 개선하라

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

협력은 고정된 것이 아니다. 현재의 협력 패턴이 변경의 무게를 지탱하기 어렵다면 변경을 수용할 수 있는 협력 패턴을 향해 과감하게 리팩터링하라. 요구사항의 변경에 따라 협력 역시 지속적으로 개선해야 한다. 중요한 것은 현재의 설계에 맹목적으로 일관성을 맞추는 것이 아니라 달라지는 변경의 방향에 맞춰서 지속적으로 코드를 개선하려는 의지다.

패턴을 찾아라

지금까지 살펴본 것처럼 일관성 있는 협력의 핵심은 변경을 분리하고 캡슐화 하는 것이다. 변경을 캡슐화하는 방법이 협력에 참여하는 객체들의 역할책임을 결정하고 이렇게 결정된 협력이 코드의 구조를 결정한다. 따라서 훌륭한 설계자가 되는 첫걸음은 변경의 방향을 파악할 수 있는 날카로운 감각을 기르는 것이다. 그리고 이 변경에 탄력적으로 대응할 수 있는 다양한 캡슐화 방법과 설계 방법을 익히는 것 역시 중요하다.

애플리케이션에서 유사한 기능에 대한 변경이 지속적으로 발생하고 있다면 변경을 캡슐화할 수 있는 적절한 추상화를 찾은 후, 이 추상화변하지 않는 공통적인 책임을 할당하라. 현재의 구조가 변경을 캡슐화하기에 적합하지 않다면 코드를 수정하지 않고도 원하는 변경을 수용할 수 있도록 협력과 코드를 리팩터링하라 변경을 수용할 수 있는 적절한 역할과 책임을 찾다보면 협력의 일관성이 서서히 윤곽을 드러낼 것이다.

협력을 일관성 있게 만드는 과정은 유사한 기능을 구현하기 위해 반복적으로 적용할 수 있는 협력의 구조를 찾아가는 기나긴 여정이다. 따라서 협력을 일관성 있게 만든다는 것은 유사한 변경을 수용할 수 있는 협력 패턴을 발견하는 것과 동일하다.

객체지향 설계는 객체의 행동과 그것을 지원하기 위한 구조를 계속 수정해 나가는 작업을 반복해 나가면서 다듬어진다. 객체, 역할, 책임은 계속 진화해 나가는 것이다. 협력자들 간에 부하를 좀 더 균형 있게 배분하는 방법을 새로 만들어내면 나눠줄 책임이 바뀌게 된다. 만약 객체들이 서로 통신하는 방법을 개선해냈다면 이들 간의 상호작용은 재정의돼야 한다. 이 같은 과정을 거치면서 객체들이 자주 통신하는 경로는 더욱 효율적이게 되고, 주어진 작업을 수행하는 표준 방안이 정착된다. 협력 패턴이 드러나는 것이다.

협력 패턴과 관련하여 언급할 가치가 있는 두 가지 개념이 있다. 하나는 패턴이고 하나는 프레임워크다. 다음 장에서 두 개념을 간단하게 살펴보자.

참고 및 출처

  • 오브젝트