본문 바로가기

[스터디] OOP - 책임 할당하기

책임 주도 설계를 향해

아래 두 가지 원칙을 기억하고 따른다.

  • 데이터보다 행동을 먼저 결정하라.
  • 협력 문맥 속에서 책임을 결정하라.

 

협력 문맥 속에서 책임을 결정하라.

책임은 객체의 입장이 아니라 객체가 참여하는 협력에 적합해야 한다.

다시 말해, 메시지 전송자에게 적합한 책임을 말한다.

 

⌲ 따라서, 메시지 송신자는 메시지 수신자에 대한 어떠한 가정도 할 수 없다. (캡슐화)

 

객체가 수행할 책임이 정리될 때까지 객체의 상태에 대해 정의하지 않는다.

 

 

GRASP 패턴

GRASP 패턴이란?

General Responsibility Assignment Software Pattern의 약자로, 객체에게 책임을 할당할 때 지침으로 삼을 원칙들의 집합을 패턴 형식으로 정리한 것

 

설계 순서

1. 도메인 개념에서 출발하기

설계 시작 전, 도메인에 대한 개략적인 모습을 그려보자.

 

도메인 개념은 설계에 도움이 되는 모음으로만 간주하고 시간을 많이 투자하지는 않느다. 올바른 도메인 모델이란 존재하지 않고, 역으로 코드를 구현하면서 도메인 개념을 바꾸기도 한다.

 

필자는 책의 영화예매 시스템이 아닌 흔히 볼 수 있는 약관동의 시스템을 개발한다고 가정한다.

 

상세 스펙

  • 하위 항목은 변경 불가능한 경우와 개별 동의 가능한 두가지 경우로 나뉜다.
  • 하위 항목은 하위 항목 목록을 가질 수 있다. (2-depth, 3-depth... 가능)
  • 버튼 활성화(active) 여부는 모든 필수항목이 동의되었을 때이다. (선택 항목은 제외)

 

 

위 화면을 기반으로 도메인 모델을 설계하면 다음과 같다.

 

 

2. 정보 전문가에게 책임을 할당하기

어플리케이션(시스템)의 기능을 책임으로 간주하여 시스템 책임을 생성한다.

 

메시지를 생성할 때, 메시지를 수신할 객체가 아닌 메시지를 전송할 객체의 의도를 반영한다.

 

메시지를 전송할 객체는 무엇을 원하는가?

 

약관동의 예제에서 명확한 기능은 모든 필수 항목을 약관동의하는 것이다.

 

따라서 시스템 책임은 다음과 같다.

 

 

메시지를 받을 객체를 선택한다.

 

메시지를 수신할 적합한 객체는 누구인가?

 

3장에서 보았던 정보 전문가 패턴에 따라 정보 전문가에게 할당한다.

 

정보 전문가에게 할당하면 정보를 알고 있는 객체가 책임을 어떻게 수행할지 스스로 결정할 수 있어 캡슐화를 유지하기 쉽다.

 

여기서 '정보'란 객체가 반드시 저장하고 있을 필요는 없는 정보이다. 해당 정보를 제공할 다른 객체를 알거나, 정보를 계산해서 제공할 수도 있다.

 

약관동의 예제에서 필수항목을 모두 동의하라는 책임을 수행하는데 많은 정보를 알고 있는 객체는 누구인가?

 

아마도 모든 항목 리스트를 가지고 있는 약관 리스트일거 같다. 

 

따라서 AgreementList 에 책임을 할당한다.

 

 

스스로 처리할 수 없는 작업은 외부에 도움을 요청해야 한다. 이 요칭이 새로운 메시지가 된다.

 

모든 필수항목을 약관동의하려면 각 항목을 약관동의해야 한다.

 

따라서 각 항목을 약관동의하는 '약관동의하라'는 메시지를 생성한다.

 

 

또한, 약관동의하려면 그 항목의 하위항목을 모두 동의해야 한다.

따라서 '하위 항목을 동의하라'는 메시지를 생성하고 정보전문가에게 책임을 할당한다.

 

 

2-1. 설계는 트레이드오프 활동이다.

실제 설계를 진행하다보면 몇 가지 가능한 설계 중에서 하나를 선택해야 한다.

 

예를 들어, 약관동의에서 모든 하위항목이 동의되었는지를 검증할 때 SubAgreementItemAgreementList 가 직접 협력할 수도 있다.

 

이 경우 올바른 책임 할당을 위해 높은 응집도(HIGH COHESION)와 낮은 결합도(LOW COUPLING)를 항상 염두에 둔다.

 

낮은 결합도의 관점에서...

도메인 모델에서 이미 결합된 도메인은 별도 결합을 추가하지 않고도 협력을 완성할 수 있으므로 더 좋은 설계이다.

 

높은 응집도의 관점에서...

객체가 수정될 때 이와 관련이 적은 객체까지 수정해야 한다면 응집도가 낮은 것이다. 따라서 변경 가능성을 고려했을 때 관련있는 객체만을 수정해야 한다면 높은 응집도를 가진 좋은 설계이다.

 

3. 창조자에게 객체 생성 책임을 할당하라. (CREATOR 패턴)

AgreementList 는 누가 생성해야 할까? CREATOR 패턴은 이 질문에 대한 지침을 제공한다.

책의 예제에서는 영화예매 시스템의 최종 결과물은 Reservation 인스턴스를 생성하는 것으로, 이 인스턴스를 생성할 책임을 예제로 Creator 패턴을 설명하고 있다.
  • B가 A 객체를 포함하거나 참조한다.
  • B가 A 객체를 기록한다.
  • B가 A 객체를 사용한다.
  • B가 A 객체를 초기화하는데 필요한 데이터를 가지고 있다. (B는 A의 정보전문가)

AgreementList 를 생성할 책임은 A, B, C 에 대한 AgreementList 인스턴스를 생성하고 관리할 AgreementStore 에게 책임을 할당한다.

 

 

구현을 통한 검증

이제 구현을 통해 검증한다. (TS 기반)

 

먼저, AgreementList 를 생성한다. AgreementList 는 모든 필수 항목을 약관동의하는 책임을 수행해야 한다.

따라서 '모든 필수항목을 동의하라' 는 메시지를 수행할 메서드를 정의한다.

 (화면 상 '전체 동의'에 해당하는 부분으로 동의/미동의 toggle가능하다.)

 

class AgreementList {
  toggleAll() {}
}

 

이제 위 책임을 수행하는데 필요한 변수를 결정한다.

import { AgreementItem } from "./AgreementItem";

class AgreementList {
  private checked: boolean = false;

  constructor(private agreementList: AgreementItem[]) {}

  toggleAll() {
    this.agreementList.forEach((agreementItem) => {
      agreementItem.toggle(!this.checked);
    });
  }
}

 

중요한 것은 메시지의 시그니처를 agreementItem.toggle 로 선언한 점이다. AgreementListAgreementItem 의 내부 구현에 대해 어떤 지식도 없이 전송할 메시지를 결정한다. 이처럼 내부 구현을 고려하지 않고 필요한 메시지를 먼저 결정하면 내부 구현을 깔끔하게 캡슐화할 수 있다.

 

이제 AgreementItem 은 리스트와 협력하기 위해  toggle을 구현한다.

class AgreementItem {
  toggle(checked: boolean) {}
}

 

자신의 동의 상태를 관리하기 위한 내부 데이터를 알아야 한다.

class AgreementItem {
  private checked = false;

  constructor(
    private label: string,
    private link?: string,
    private subAgreementList?: SubAgreementItem[]
  ) {}

  toggle(checked: boolean) {}
}

class SubAgreementItem {}

 

동의 여부를 변경하면 그 하위의 항목들까지 동기화해야 한다. 구현에 필요한 private getter또한 추가한다.

 

class AgreementItem {
  private checked = false;

  constructor(
    private label: string,
    private link?: string,
    private subAgreementList?: SubAgreementItem[]
  ) {}

  toggle(checked: boolean) {
    const nextValue = checked !== undefined ? checked : !this.isChecked;
    this.checked = nextValue;

    if (this.hasSubAgreementList) {
      for (const subAgreementItem of this.subAgreementList) {
        subAgreementItem.toggle(nextValue, { force: true });
      }
    }
  }

  private get isChecked() {
    return this.checked;
  }

  private get hasSubAgreementList() {
    return this.subAgreementList?.length > 0;
  }
}

 

AgreementItem 은 각 하위 항목들을 동기화하기 위해 '하위 항목을 동의하라'는 메시지를 전송한다. 따라서 SubAgreementItem 은 이 메시지를 처리하기 위해 toggle 메소드를 구현한다.

 

class SubAgreementItem {
  toggle(checked: boolean) {}
}

 

마찬가지로 책임을 수행하는데 필요한 정보를 추가한다.

 

class SubAgreementItem {
  private checked = false;

  constructor(
    private subAgreementList: SubAgreementItem[],
    private canChange: boolean
  ) {}

  toggle(checked: boolean, options?: { force: boolean }) {
    if (!this.canChange && !options?.force) {
      return;
    }

    const nextValue = checked !== undefined ? checked : !this.isChecked;
    this.checked = nextValue;

    if (this.hasSubAgreementList) {
      for (const subAgreementItem of this.subAgreementList) {
        subAgreementItem.toggle(nextValue);
      }
    }
  }

  private get isChecked() {
    return this.checked;
  }

  private get hasSubAgreementList() {
    return this.subAgreementList?.length > 0;
  }
}

 

구현이 완료되었다. 그러나 몇가지 문제점이 있다.

 

변경에 취약한 클래스

변경에 취약한 클래스란, 코드를 수정해야 하는 이유를 하나 이상 갖는 클래스를 말한다.

만약에 위 예시에서 SubAgreementItemAgreementItem 의 일부로 포함한 설계를 했다면 다음과 같은 코드로 구현할 수 있다.

 

const AGREEMENT_TYPE = {
  SUB: "SUB",
  COMMON: "COMMON"
} as const;

type AgreementType = typeof AGREEMENT_TYPE[keyof typeof AGREEMENT_TYPE];

class AgreementItem {
  private checked = false;

  constructor(
    private type: AgreementType,
    private label: string,
    private required: boolean,
    private link?: string,
    private canChange?: boolean, // subAgreementItem only
    private subAgreementList?: AgreementItem[]
  ) {}

  toggle(checked: boolean) {
    const nextValue = checked !== undefined ? checked : !this.isChecked;
    this.checked = nextValue;

    if (this.type === AGREEMENT_TYPE.SUB) {
      return this.toggleSubAgreement(nextValue, { force: true });
    }

    if (this.hasSubAgreementList) {
      for (const subAgreementItem of this.subAgreementList) {
        subAgreementItem.toggleSubAgreement(nextValue, { force: true });
      }
    }
  }

  toggleSubAgreement(checked: boolean, options?: { force: boolean }) {
    if (!this.canChange && !options?.force) {
      return
    }

    if (this.hasSubAgreementList) {
      for (const subAgreementItem of this.subAgreementList) {
        subAgreementItem.toggleSubAgreement(checked, { force: true });
      }
    }
  }

  get isSatisfied() {
    return this.required ? this.isChecked : true;
  }

  get isChecked() {
    if (this.hasSubAgreementList) {
      return this.subAgreementList.every(
        (subAgreementItem) => subAgreementItem.isChecked
      );
    }

    return this.checked;
  }

  private get hasSubAgreementList() {
    return this.subAgreementList?.length > 0;
  }
}

 

type 추가로 해당 항목이 하위항목인지 상위항목인지 구분짓고 있다. 왜 위의 코드보다 앞서서 구현한 코드가 나을까?

 

특정 정보로 타입을 구분하게 되면 AgreementItem 의 변경 이유가 다양해지기 때문이다.

  • 상위 항목의 toggle 조건이 변경되는 경우
  • 하위 항목의 toggle 조건이 변경되는 경우
  • 상위 항목이 포함하고 있는 정보가 수정되는 경우
  • 하위 항목이 포함하고 있는 정보가 수정되는 경우

 

상위항목과 하위항목은 포함하고 있는 데이터도 미미한 차이가 있고, toggle의 조건도 다르다. 때문에 하나 이상의 변경 이슈를 가지게 된다. 이것을 다른 말로 응집도가 낮다고 한다.

 

위 문제를 해결하는 좋은 방법은 변경의 이유에 따라 클래스를 분리하는 것이다.

 

그것이 앞서서 작성한 SubAgreementItem 클래스로 분리된 코드이다.

 

이처럼 설계를 개선할 때 변경의 이유가 하나 이상인 클래스부터ㅓ 시작하면 좋다.

 

변경의 이유가 하나 이상인 클래스가 나타내는 패턴이 있다.

  •  인스턴스 변수가 초기화되는 시점 : 응집도가 낮은 클래스는 인스턴스 초기화 시 모든 속성을 초기하하지 않는다.
    • 일부만 초기화하거나, 초기화되는 시점이 다르다.
    • 함께 초기화되는 속성을 기준으로 코드를 분리하자.
  • 메서드들이 인스턴스 변수를 사용하는 방식
    • 메서드들이 사용하는 속성에 따라 그룹이 나뉜다면 클래스의 응집도는 낮다.
    • 속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리한다.

 

다형성을 통해 분리하기

AgreementList 입장에서 그 항목이 상위항목인지 하위항목인지는 크게 중요하지 않다. 더 크게 보면 그 항목의 성격이 중요하지 않다.

이 때 3장에서 보았던 '역할'의 개념이 등장한다.

 

AgreementList 추가입장에서는 항목을 동의한다는 동일한 책임을 수행하므로 그 항목의 성격을 알지 못한채 오직 역할에만 결합되도록 의존성을 제한할 수 있다.

역할을 사용하면 객체의 구체적인 타입을 추상화할 수 있다.

 

위의 코드에서 AgreementItem 을 추상클래스로 변경하고, SubAgreementItem 에 상속받아 toggle을 구현한다.

 

abstract class AgreementItem {
  private checked = false;

  constructor(
    private label: string,
    private required: boolean,
    private link?: string,
    private subAgreementList?: SubAgreementItem[]
  ) {}

  toggle(checked: boolean) {
    const nextValue = checked !== undefined ? checked : !this.isChecked;
    this.checked = nextValue;

    if (this.hasSubAgreementList) {
      for (const subAgreementItem of this.subAgreementList) {
        subAgreementItem.toggle(nextValue, { force: true });
      }
    }
  }

  get isSatisfied() {
    return this.required ? this.isChecked : true;
  }

  get isChecked() {
    if (this.hasSubAgreementList) {
      return this.subAgreementList.every(
        (subAgreementItem) => subAgreementItem.isChecked
      );
    }

    return this.checked;
  }

  private get hasSubAgreementList() {
    return this.subAgreementList?.length > 0;
  }
}

class SubAgreementItem extends AgreementItem {
  constructor(
    private canChange: boolean,
    private label: string,
    private required: boolean,
    private link?: string,
    private subAgreementList?: SubAgreementItem[]
  ) {
    super(label, required, link, subAgreementList);
  }

  toggle(checked: boolean, options?: { force: boolean }) {
    if (!this.canChange && !options?.force) {
      return;
    }

    super.toggle(checked);
  }
}

 

이제 AgreementListAgreementItem 의 타입을 몰라도 된다. 즉, AgreementListAgreementItem 의 관계는 다형적이다.

 

객체의 암시적인 타입에 따라 행동을 분기해야 한다면, 암시적인 타입을 명시적인 클래스로 정의하고 행동을 나누어 응집도 문제를 해결할 수 있다. 이것은 다형성(POLYMORPHISM) 패턴이라고 한다.

 

⌲ 리팩터링에서는 '조건부 로직을 다형성으로 바꾸기'에 해당한다.

 

변경으로부터 보호하기

위 설계에서 새로운 성격의 항목을 추가한다면 어떻게 될까?

 

이 경우, AgreementItem 을 상속하는 새로운 타입의 클래스를 추가하면 된다. 즉, AgreementList 입장에서 AgreementItem 의 종류를 감출 수 있다. 이처럼 변경을 캡슐화하도록 책임을 할당하는 것을 PROTECTED VARIATIONS(변경 보호) 패턴이라고 부른다.

 

정리

  • 하나의 클래스가 여러 타입의 행동을 구현하고 있다면 클래스를 분해하고 다형성 패턴에 따라 책임을 분산한다.
  • 하나의 변경으로 인해 여러 클래스들을 변경될 가능성이 높다면 변경 보호 패턴에 따라 안정적인 인터페이스 뒤로 변경을 캡슐화한다.