04. 책임 할당하기

jrun
Research Team — DAWN
10 min readJun 1, 2022

--

저번 포스팅에서는 데이터 중심 설계에 대해 알아봤으니 이번엔 책임 중심의 설계에 대해 알아보겠습니다.

책임에 초점을 맞춰 설계할 때 가장 큰 문제는 어떤 객체에게 어떤 책임을 할당할지 결정하는 것입니다. 이 과정은 일종의 트레이드오프 활동이고 무엇이 최선인지는 상황과 문맥에 따라 달라집니다.

1. 책임 주도 설계

여기엔 다음 두 가지 원칙이 있습니다.

  • 데이터보다 행동을 먼저 결정할 것
  • 협력이라는 문맥 내에서 책임을 결정할 것

데이터 보다 행동을 먼저 결정할 것

객체에게 중요한 것은 데이터가 아니라 외부에 제공하는 행동, 즉 객체의 책임입니다. 앞서 말했듯 데이터에 초점을 맞추면 캡슐화가 약화되고 낮은 응집도, 높은 결합도라는 문제를 야기할 수 있습니다.

고로 설계에서는 “이 객체가 수행해야 하는 책임을 무엇인가?”를 결정한 뒤에 “이 책임을 수행하는데 필요한 데이터는 무엇인가?”를 결정해야 합니다.

협력이라는 문맥 내에서 책임을 결정할 것

그래서 객체에게 책임을 어떻게 할당해야 할까요? 어떻게 해야 잘한 걸까요?

책임의 품질은 협력에 적합한 정도로 결정됩니다. 객체의 입장에서는 책임이 조금 어색하더라도 협력에 적합하다면 그 책임은 좋은 것으로 볼 수 있습니다. 객체의 입장이 아니라 객체가 참여하는 협력에 적합해야 합니다.

그리고 협력을 시작하는 주체는 수신 객체가 아니라 전송 객체입니다. 고로 협력에 적합한 책임이란 메시지 전송자에게 적합한 책임을 말합니다.
메시지를 전송해야 하는데 누구에게 전송하지?” 라는 질문에서 시작해보는 것은 어떨까요? 이렇게 설계할 경우 메시지 수신자에 대한 어떠한 가정도 할 수 없기에 전송자의 관점에서 메시지 수신자가 깔끔하게 캡슐화 된다는 장점이 있습니다.

2. 책임 할당을 위한 GRASP 패턴

GRASP = “General Responsibility Assignment Software Pattern” = 일반적인 책임 할당을 위한 패턴 그저 앞서 1~4장에 다룬 기반 지식을 서로 공유하고 토의할 수 있게 이름을 붙인 것입니다.

도메인 개념에서 시작

도메인 안에는 무수히 많은 개념들이 존재하며 이 도메인 개념들을 책임 할당의 대상으로 사용하면 코드에 도메인의 모습을 투영 하기가 좀 더 수월해집니다.
고로 책임을 할당할 때 먼저 고려해야할 후보도메인 개념입니다.
(수신자를 고를 때 먼저 고려해야하는 것이지 메시지보다 먼저 고려하면 안됩니다.)

그리고 설계를 시작하는 단계에서는 개념들의 의미나 관계가 정확하거나 완벽할 필요가 없습니다. 고로 설계를 완전체가 아닌 단순한 개념들의 모음 정도로 간주하는게 좋습니다. 시간을 너무 쓰면 좋지 않아요.

정보 전문가에게 책임을 할당할 것

사용자에게 영화를 예매하는 기능을 제공한다고 가정해 봅니다. 그리고 메시지는 수신할 객체가 아니라 전송할 객체의 의도를 반영해서 결정해야 합니다. 일단 아래질문을 따라가봅니다.

Q1. 메시지를 전송할 객체는 무엇을 원하나?
A1. 예매해줘!

Q2. 메시지를 수신할 적합한 객체는 누구?
A2. 여기에 답하기 전, 객체는 자신의 상태를 자율적으로 처리하는 존재임에 집중해야 합니다. 고로 책임을 할당하는 첫 번째 원칙은 책임을 수행할 정보를 아는 객체에게 책임을 할당하는 것. 이를 정보 전문가 패턴이라고 합니다.

+ 정보를 알고있다고 해서 꼭 그 정보를 직접 저장할 필요는 없습니다. 필요한 정보를 알고있는 다른 객체를 알고있다면 외부에 도움을 요청할 수 있어요.

높은 응집도와 낮은 결합도

설계는 트레이드오프 활동입니다. 동일한 기능을 구현할 수 있는 무수히 많은 설계가 존재하기에 정보 전문가 패턴 이외의 다른 책임 할당 패턴을 고려해야할 수도 있습니다.

[방법1] Screening이 Movie에게 가격 계산을 요청하고 Movie가 DiscountPolicy에게 할인 여부를 요청하는 방식이 있다면
[방법2] Screening이 DiscountPolicy에게 할인 여부을 요청하고 그 정보와 함께 Movie에게 가격 계산을 요청할 수 있지 않을까요?
두 방법 사이의 차이는 무엇일까요?

그 차이는 응집도와 결합도의 관점에서 서술할 수 있습니다. 우리는 두 협력 패턴 중 더 높은 응집도를 가지고 더 낮은 결합도를 가진 설계를 선택해야 합니다.

우선 결합도 관점에서 살펴봅니다. [방법2]의 경우 Screening과 DiscountCondition 사이에 새로운 결합도가 추가됩니다. 고로 결합도의 관점에서는 [방법1]이 더 나은 설계 대안이라고 볼 수 있습니다.

이제 응집도 관점에서 살펴봅니다. Screening의 주된 책임은 예매를 생성하는 것 입니다. 만약 [방법2]를 따른다면 할인 여부같은 요금계산에 관련된 책임을 일부 떠안아야 합니다. 또 Screening은 DiscountPolicy가 할인 여부를 판단할 수 있고 Movie가 이 정보를 필요로 한다는 사실도 알아야 합니다.

[방법1]의 경우를 봅니다. Movie의 책임은 요금을 계산하는 것입니다. 고로 요금을 계산하는데 필요한 조건을 위해 Discount 관련 객체와 협력하는 것은 너무나 당연합니다. 응집도에 아무런 해도 끼치지 않습니다. 응집도의 관점에서도 [방법1]이 더 나은 설계방법입니다.

창조자에게 객체 생성 책임을 할당할 것

위에서 말한 예매 협력의 최종 결과물은 Reservation 인스턴스를 생성하는 것입니다. 협력에 참여하는 어떤 객체에게는 Reservation 인스턴스를 생성할 책임을 할당해야 한다는 것을 의미합니다. GRASP의 CREATOR 패턴을 사용해봅니다.

CREATOR 패턴

객체 A를 생성해야 할 때 아래 조건을 최대한 많이 만족하는 B에게 객체 생성 책임을 할당해야 함을 말합니다.

  • A객체를 포함하거나 참조한다
  • A객체를 기록한다.
  • A객체를 긴밀하게 사용한다.
  • A를 초기화하는 데 필요한 데이터를 가지고 있다. (정보 전문가인 경우)

즉 어떤 방식으로든 생성되는 객체와 연결, 관련될 필요가 있는 객체에게 생성할 책임을 맡기는 것. 이미 결합돼 있는 객체에게 생성 책임을 할당하는 것은 설계의 결합도에 영향을 주지 않는 것으로 봅니다.

DiscountCondition 개선하기 — 변경에 취약한 코드

현 상황은 변경에 취약합니다.

  1. 할인 조건이 추가되면 L8 -isSatisfiedBy 안에 if 문을 매번 변경해야 합니다.
  2. 순번 조건에 대한 요구사항이 변경되면 isSatisfiedBySequence 가 수정될 수 있습니다.
  3. 기간 조건이 변경되면 isSatisfiedByPeriod 도 수정될 수 있습니다.

DiscountCondition은 위 3가지 이유로 수정될 수 있습니다. 수정 되어야할 이유가 너무 많습니다. 변경되어야 할 이유가 여러가지라는 것은 연관없는 기능과 데이터가 하나의 클래스 안에 뭉쳐있다는 것을 의미합니다. 즉, 응집도가 낮다는 것입니다. 고로 변경의 이유에 따라 클래스를 분리해야 합니다.

그리고 변경의 이유가 하나 이상인 클래스에는 위험 징후를 또렷하게 드러내는 몇가지 패턴이 존재합니다. 다행이다!

첫 번째 방법으로는 인스턴스 변수가 초기화되는 시점을 살펴보는 것입니다. 응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께 초기화하는 경향이 있습니다.
DiscountCondition은 순번 조건일 경우엔 sequence만, 기간 조건일 경우 sequence를 제외한 날짜 속성만 초기화하고 있습니다. 이 때 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다.

두 번째 방법으로는 메서드들이 인스턴스 변수를 사용하는 방식을 살펴보는 것이다. 모든 메서드가 모든 속성을 사용한다면 응집도가 높다고 볼 수 있습니다. 반면 메서드들이 사용하는 속성에 따라 그룹이 나뉜다면 응집도가 낮은 것으로 볼 수 있습니다.
isSatisfiedBySequenceisSatisfiedByPeriod 가 사용하는 속성이 명확히 분리되고 있음을 볼 수 있습니다. 이 또한 속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 분리해야 합니다.

타입 분리하기

DiscountCondition의 가장 큰 문제는 순번 조건, 기간 조건이라는 두개의 독립적인 타입이 하나의 클래스 안에 공존한다는 점입니다. 두 타입을 다형성을 통해 각기 다른 클래스로 분리하는게 좋을 것 같습니다.

DiscountCondition은 인터페이스로 분리하고 이를 구현한 PeriodCondition, SequenceCondition으로 분리할 수 있습니다.
이렇게 객체의 타입에 따라 행동이 변한다면 타입을 분리하고 변하는 행동을 각 타입의 책임으로 할당해야 합니다. 그리고 그 책임들은 하나의 역할을 바라봅니다. 이를 다형성 패턴이라고 부릅니다.

POLYMORPHISM 패턴

if~ else 혹은 switch~ 등의 조건 논리를 사용해 설계한다면 변화가 일어났을 때마다 해당 논리를 수정해야 합니다.

고로 객체의 타입을 검사해 타입에 따라 다른 행동을 수행해야 합니다.

변경으로부터 보호하기

위 구현들의 경우 DiscountCondition이라는 역할 (인터페이스)이 Movie로부터 Period, SequenceCondition의 존재를 감추고 있습니다. 캡슐화되기 때문에 새로운 DiscountCondition 타입이 수정되더라도 Movie는 아무 상관이 없습니다. 너무 좋아요!

이처럼 변경을 캡슐화 하도록 하는 것은 변경 보호 패턴이라고 합니다.

PROTECTED VARIATIONS 패턴

이는 변화와 불안정성이 다른 요소에 영향을 주지 않도록 하는 패턴입니다. 변화가 예상되는 불안정한 지점을 식별하고 그 주위에 안정된 인터페이스를 제공한다. 변경될 것 같으면 캡슐화하는 것 입니다.

Movie 개선하기

Movie도 비슷한 문제가 있습니다. 금액 할인과 비율 할인이라는 두 타입이 동시에 존재하기 때문에 이 클래스가 수정 될 이유가 두 개 이상입니다. 응집도가 낮다는 것이죠.

이는 다형성 패턴을 사용해 분리할 수 있습니다.
1. Movie를 추상 클래스로 변경하고 할인 구현부를 추상 메서드로 변경합니다.
2. 그리고 AmountDiscountMovie, PercentDiscountMovie가 Movie를 상속받도록 구현합니다.

상속과 합성

위의 Movie처럼 상속을 사용해 구현했을 때, 실행 도중 변경되어야 한다면? 인스턴스를 생성한 후 필요한 정보를 복사해야 합니다.

이는 번거로울 뿐만이 아니라 오류가 발생하기도 쉽습니다. 이럴 경우 상속 대신 합성을 사용할 수 있습니다. 코드의 복잡성이 높아지더라도 변경을 쉽게 수용할 수 있게 만드는 것이다.
Movie에서 DiscountPolicy를 독립적인 클래스로 구분하고 Movie에서 인스턴스 변수로 사용합니다.

movie.changeDiscountPolicy(new PercentDiscountPolicy(...));

3. 책임 주도 설계의 대안

책임 주도 설계에 익숙해지기는 너무 어렵습니다… (이번 포스팅 유독 길지 않나요?) 많은 사람들이 절적한 책임과 객체 사이에서 방황하고 있는데 이럴 때는 그냥 최대한 빠르게 목적을 달성할 수 있는 코드를 작성하고 코드 상에 명확하게 드러나는 책임들을 하나씩 올바른 위치로 이동시키면 좋습니다.

주의할 점은 이렇게 코드를 수정할 때 겉으로 드러나는 동작이 바뀌어서는 안된다는 것입니다. 캡슐화를 향상시키고, 응집도를 높이고, 결합도는 낮춰야하지만 동작은 그대로 유지해야 한다. 이를 리팩토링이라고 한다.

메서드 응집도

4장에서 작성한 ReservationAgency 클래스를 봅니당

너무 길고 이해하기 어렵습니다. 수정해야할 부분을 찾기도 어렵습니다. 로직의 일부를 재사용할 수도 없습니다. 이런 메서드를 몬스터 메서드라고 부릅니다.
메서드들이 명령문의 그룹으로 구성되고 각 그룹에 주석을 달아야 할 필요가 있다면 응집도가 낮은것으로 봐도 무방합니다.

다음은 5장에서 구현한 바를 토대로 리팩토링한 결과물입니다.

리팩토링의 결과로 public 메서드는 상위 수준의 명세를 나타내고 메서드가 어떠한 일을 하는지 메서드의 이름만으로 쉽게 알아볼 수 있습니다.

상세한 할인 조건, 할인 계산은 다른 클래스의 책임으로 넘겼기에 조건, 계산 방식이 변하더라도 ReservationAgency는 변하지 않음을 알 수 있습니다.

다음은 협력에 앞서 필요한 메시지, 인터페이스에 대해 알아봅니다!

--

--