14장 일관성 있는 협력

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

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

객체지향 패러다임의 장점은 설계를 재사용할 수 있다는 것이다. 하지만 재사용은 공짜로 얻어지지 않는다. 재사용을 위해서는 객체들의 협력 방식을 일관성 있게 만들어야 한다. 일관성은 설계에 드는 비용을 감소시킨다. 과거의 해결 방법을 반복적으로 사용해서 유사한 기능을 구현하는 데 드는 시간과 노력을 대폭 줄일 수 있기 때문이다.

일관성이 있는 설계가 가져다 주는 더 큰 이익은 코드가 이해하기 쉬워진다는 것이다. 특정한 문제를 유사한 방법으로 해결하고 있다는 사실을 알면 문제를 이해하는 것만으로도 코드의 구조를 예상할 수 있게 된다.

즉, 추상화 수준의 패턴이나 구조를 이해함으로써 구조나 패턴에 대한 청킹이 가능하다는 것이다.

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

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

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

기본 정책 확장

11장에서 구현한 핸드폰 과금 시스템의 요금을 수정된 예제로 진행한다.

  • 고정요금 방식: 일정 시간 단위로 동일한 요금을 부과하는 방식이다.
  • 시간대별 방식: 하루 24시간을 특정한 시간 구간으로 나눈 후 각 구간별로 서로 다른 요금을 부과하는 방식이다.
  • 요일별 방식: 요일별로 요금을 차등 부과하는 방식이다.
  • 구간별 방식: 전체 통화 시간을 일정한 통화 시간에 따라 나누고 각 구간별로 요금을 차등 부과하는 방식이다.

고정요금 방식 구현하기

고정요금 방식은 기존의 일반요금제와 동일하기 때문에 기존 RegularPolicy 클래스의 이름을 FixedFeePolicy로 변경한다.

시간대별 방식 구현하기

시간대별 방식에 따라 요금을 계산하기 위해서는 통화 기간을 정해진 시간대별 나눈 후 각 시간대별로 서로 다른 계산 규칙을 적용해야 한다.

여기서 핵심은 시간대별 방식이 날짜까지 고려해야 하는 문제이다. 실제 도메인은 시간이라는 선상에 날짜가 크게 겹쳐있기 때문에 이를 고려해야 한다.

단순한 도메인을 개발의 개념으로 확장할 때, 구현과 설계의 차이가 발생한다.

이를 좀 더 유연하고 확장성 있게 개발하기 위해 DateTimeInterval 클래스를 추가한다. 해당 클래스는 시작 시간과 종료 시간을 인스턴스 변수로 포함하며, 객체 생성을 위한 정적 메서드을 제공한다.

통화 시간을 일자와 시간 기준으로 분할해서 계산해보자. 이를 위해 요금 계산 로직을 다음과 같이 두 개의 단계로 나눠 구현해야 한다.

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

이 두 작업을 객체의 책임으로 할당해 보자. 책임을 할당하는 기본 원칙은 책임을 수행하는 데 필요한 정보를 가장 잘 알고 있는 정보 전문가에게 할당하는 것이다. (앞 장에서 배운 정보 전문가 원칙)

통화 기간을 일자 단위로 나누는 작업의 정보 전문가는 DateTimeInterval이다. 따라서 통화 기간을 일자 단위로 나누는 책임은 DateTimeInterval에게 할당하고 Call이 DateTimeInterval에게 분할을 요청하도록 협력을 설계하는 것이 적절한 것이다.

두 번째인 시간대별로 분할하는 작업의 정보 전문가는 시간대별 기준을 잘 알고 있는 요금 정책이며 여기서는 TimeOfDayDiscountPolicy이 해당된다.

해당 시간대와 날짜에 따른 요금을 계산하는 로직 자체를 분리하여 사용자는 해당 정보를 사용할 수 있도록 분리를 하는 것이다. 즉, DiscountPolicy는 해당 타입을 포함한 서브 타입들은 공통된 데이터를 받을 수 있도록 보장해야 한다는 것이다. 이를 위해 DateTimeInterval, Call통해 파싱한다.

책에선 해당 공통된 데이터를 4개의 다른 리스트를 가지는 것으로 해결했다.

요일별 방식 구현하기

요일별 방식은 요일별로 요금 규칙을 다르게 설정할 수 있다. 각 규칙은 요일의 목록, 단위 시간, 단위 요금이라는 세 가지 요소로 구성된다.

책에선 요일의 정보를 DayOfWeekDiscountRule로 분리하였다.

구간별 방식 구현하기

구간별 방식을 구현하기 앞서 지금까지 구현한 고정요금, 시간대별, 요일별은 정상적으로 동작함을 알 수 있지만, 모아놓고 보면 문제점들이 드러난다. 가장 큰 문재점은 모든 클래스가 유사한 문제를 해결하고 있음에도 설계에 일관성이 없다는 것에 있다.

이 클래스들은 기본 정책을 구현한다는 공통의 목적을 공유한다. 하지만 정책을 구현하는 방식은 완전히 다르다. 다시 말해서 개념적으로는 연관돼 있지만 구현 방식에 있어서 완전히 제각각이라는 것이다.

비일관성은 두 가지 상황에서 발목을 잡는다. 하나는 새로운 구현을 추가해야 하는 상황이고, 또 다른 하나는 기존의 구현을 이해해야 하는 상황이다. 그리고 이 장애물이 문제인 이유는 개발자로서 우리가 수행하는 대부분의 활동이 코드를 추가하고 이해하는 일과 깊숙히 연관돼 있기 때문이다.

코드는 사람이 짜기 때문에 코드의 재사용이 아니더라도 같은 패턴을 사용해야 한다. 만약 기계가 모두 짜고 사용한다면 이런 추상화, 캡슐화, 패턴을 고려할 필요가 없지만, 사람이기에 공통된 모델을 쉽게 공유해야 한다.

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

설계에 일관성 부여하기

일관성 있게 설계를 만드는 데 가장 훌륭한 조언은 다양한 설계 경험을 익히는 것이다. 풍부한 설계 경험을 가진 사람은 어떤 변경이 중요한지, 그리고 그 변경을 어떻게 다뤄야 하는지에 대한 통찰력을 가지게 된다.

경험의 중요성을 매일 느끼고 있다. 단순한 도메인이나 설계가 필요없는 수준이 아닌 필요성이 강한 프로젝트를 해보고 싶고 경험적인 스킬도 넓히고 싶다.

일관성 있는 설계를 위한 두 번째 조언은 널리 알려진 디자인 패턴을 학습하고 변경이라는 문맥 안에서 디자인 패턴을 적용해는 것이다. 디자인 패턴은 반복적으로 적용할 수 있는 설계 구조를 제공한다고 하더라도 모든 경우에 적합한 패턴을 찾을 수 있는 것은 아니다. 따라서 일관성 있게 만들기 위해 다음과 같은 기본 지침을 따르는 것이 도움이 된다.

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

디자인 패턴은 항상 정답을 알려주지 않는다.

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

조건 로직 대 객체 탐색

절차지향 프로그램에서 변경을 처리하는 전통적인 방법은 이처럼 조건문의 분기를 추가하거나 개별 분기 로직을 수정하는 것이다. 하지만 객체지향은 다른 접근법을 사용한다. 객체지향에서 변경을 다루는 전통적인 방법은 조건 로직을 객체 사잉의 이동으로 바꾸는 것이다.

절차지향의 조건은 매우 정적인 형태로 나오고, 객체지향은 동적인 형태로 나온다.

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

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

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

해당 추상화의 품질이 높다면 협업자는 모두 같은 추상화에 의존하는 코드를 만들게 되고 자연스럽게 추상클래스가 의도한 패턴으로 협럭이 이루어진다. 또한, 코드 자체도 추상화 수준으로 이해하게 되어 코드를 이해하는데 더 쉬워진다.

변경에 초점을 맞추고 캡슐화의 관점에서 설계를 바라보면 일관성 있는 협력 패턴을 얻을 수 있다.

캡슐화 다시 살펴보기

대부분의 사람들은 캡슐화를 데이터 은닉으로 알고 있다. 그러나 캡슐화는 데이터 은닉 이상이다. 단순하게 데이터를 감추는 것이 아닌 소프트웨어에서 변할 수 있는 개념을 감추는 것이다.

더 나아가서 감춰야 하는 이유는 이후에 변경에 대한 유연한 대처, 객체의 자율성, 느슨한 연결을 위한 제어의 역전을 위함이다.

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

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

협력을 일관성 있게 만들기 위해 가장 일반적인 방법은 서브타입 캡슐화와 객체 캡슐화를 조합하는 것이다. 서브타입 캡슐화와 객체 캡슐화를 적용하는 방법은 다음과 같다.

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

변하지 않는 부분으로부터 변하는부분을 분리한다. 변하는 부분들의 공통적인 행동을 추상 클래스나 인터페이스로 추상화한 후 변하는 부분들이 추상 클래스나 인터페이스를 상속받게 만든다.

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

앞에서 구현한 타입 계층을 변하지 않는 부분에 합성한다. 변하지 않는 부분에서는 변경되는 구체적인 사항에 결합돼서는 안된다. 의존성 주입과 같이 결합도를 느슨하게 유지할 수 있는 방법을 이용해 오직 추상화에만 의존하게 만든다.

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

변경 분리하기

의존성 있는 협력을 만들기 위한 첫 번째 단계는 변하는 개념과 변하지 않는 개념을 분리하는 것이라고 말했다. 현재 핸드폰 요금 계산 모델에서 변하는 부분과 변하지 않는 부분을 분리해보자.

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

변경 캡슐화하기

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

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

위 내용으로 이해하니 머리속에 잘 그려지는 것 같다. 서브타입 캡슐화와 객체 캡슐화를 잘 생각해보자.

협력 패턴 설계하기

변하는 부분과 변하지 않는 부분을 분리하고, 변하는 부분을 적절히 추상화하고 나면 변하는 부분을 생략한 채 변하지 않는 부분만을 이용해 객체 사이의 협력을 이야기할 수 있다. 추상화만으로 구성한 협력은 추상화를 구체적인 사례로 대체함으로써 다양한 상황으로 확장할 수 있게 된다.

이때가 추상화에 의존하고 있으며 객체지향을 통해 재사용성과 유지보수가 높은 코드를 만들었다고 할 수 있다.

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

앞서 다룬 변하는 조건인 ‘적용조건’을 추상화한 FeeCondition에서 시작한다. 이 추상화를 의존하는 FeeRule을 만들어서 내부에서 합성 관계로 연결한다.

이후 DI를 통해 제어를 역전하거나 런타임에 협력관계를 변경할 수 있도록 만들어야 한다.

구체적인 협력 구현하기

현재 요금제가 시간대별 정책인지, 요일별 정책인지는 인터페이스를 실체화하는 클래스에 따라 달라진다.

시간대별 정책

FeeCondition을 상속받아 TimeOfDayFeeCondition을 만들어 시간대별 정책을 구현한다. 추가적으로 시작시간과 종료 시간이 필요하다.

요일별 정책

똑같이 변하지 않는 부분을 추상화한 FeeCondition을 상속받아 DayOfWeekFeeCondition을 만들어 요일별 정책을 구현한다. 추가적으로 요일을 필요로 한다.

구간별 정책

어려웠던 구간별 정책도 마찬가지로 FeeCondition을 상속받아 DurationFeeCondition을 만들어 구간별 정책을 구현한다.

이처럼 유사한 기능에 대해 유사한 협력 패턴을 적용하는 것은 객체지향 시스템에서 개념적 무결성을 유지할 수 있는 가장 효과적인 방법이다. 만약 시스템이 일관성 있는 몇 개의 협력 패턴으로 구성된다면 시스템을 이해하고, 수정하고, 확장하는 데 필요한 노력과 시간을 아낄 수 있다. 따라서 협력을 설계하고 있다면 항상 기존의 협력 패턴을 따를 수는 없는지 고민하자.

협력 패턴에 맞추기

협력 패턴에 발을 맞추다 보면 어떤 때에는 기존의 협력 방식에서 벗어날 수밖에 없을 때가 있다. 이런 경우에 또 다른 협력 패턴을 사용하기보다 약간을 비틀어서라도, 이상한 구조를 가지더라도 전체적인 일관성을 유지하는 것이 중요하다.

처음에는 일관성을 유지하는 것처럼 보이던 협력 패턴이 시간이 흐르면서 새로운 요구사항이 추가되는 과정에서 일관성의 벽에 조금씩 금이 가는 경우를 보게 된다. 협력을 설계하는 초기 단계에서 모든 요구사항을 미리 예상할 수 없기 때문에 이것은 잘못이 아니며 꽤나 자연스러운 현상이다.

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

패턴을 찾아라

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

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

느낀점

책에선 구현에 관한 코드가 많이 나왔지만, 핵심은 코드가 아니다. 해당 구조를 만들기 까지의 과정이 중요하다는 점이 핵심이다. 여기서 구현되는 코드들은 사실 과정을 보여주기 위한 수단이라는 생각이다.

논의사항

  • 게임 개발에서 대표적인 기능을 예로 변하지 않는 것과 변하는 것에 대해서 이야기 해보면 좋을 것 같습니다.

댓글남기기