[오브젝트] 5장 책임 할당하기

ppparkta·2025년 6월 7일
0

오브젝트

목록 보기
7/14

지난 시간 복습

  • 데이터 중심의 설계의 문제점
    • 캡슐화 위반
    • 높은 결합도
    • 낮은 응집도

→ 이 문제를 해결하기 위한 방법이 책임 중심 설계

  • 책임 중심 설계
    • 어려운 점: 누구에게 무슨 책임을 줄지 판단하는 것
    • 정답이 없는 트레이드오프 사이에서 가장 나은 방법을 판단하는 힘을 길러야 함

책임 할당하기

학습 목표
객체에게 책임 할당하는 기본적인 원리 익히기

책임 주도 설계에서 책임을 할당하는 기본적인 원리를 이해하기 위해서는 어떤 객체에게 어떤 책임을 줄 지 고민해야 한다. 이번 장에서는 GRASP 패턴을 이에 대한 답을 얻을 것이다.

책임 주도 설계를 향해

책임 주도 설계로 전환하기 위해서는 두 가지 원칙을 따라야 한다.

📌 책임 주도 설계 원칙
1. 데이터보다 행동을 먼저 결정하라
2. 협력이라는 문맥 안에서 책임을 결정하라

데이터보다 행동을 먼저 결정하라

클라이언트의 관점에서 객체가 수행하는 행동은 곧 객체의 책임과 같다.

  • 책임이란 협력에 참여하기 위한 존재이다. 협력 속에서 수행하는 책임이 곧 객체의 존재가치이다.

책임 중심으로 무게중심을 옮기기 위해서는 순서를 기억하라.

📌 순서에 유의하자!
1. 이 객체의 책임은 무엇인가?
2. 책임 수행에 필요한 데이터는 무엇인가?

책임을 먼저 생각하고 데이터를 결정해라.

협력이라는 문맥 안에서 책임을 결정하라

책임이 협력이라는 문맥에 어울리지 않는다면 그 책임은 나쁜 책임이다.

객체의 입장에서는 그 책임이 어색해보이더라고 협력에 적합하면 좋은 책임이라고 본다.

여기서 협력의 대상은 메시지의 송신자이다. 즉, 전송자에게 적합한 책임을 의미한다.

메시지는 클라이언트의 의도를 남긴 것이다. 메시지(=책임)를 먼저 결정하고 그 메시지를 수행할(전송할) 객체를 선택하면 송신자가 수신자에 대한 어떠한 가정도 할 수 없기 때문에 송신자 관점에서 수신자가 완벽히 캡슐화된다.

책임 주도 설계

책임 주도 설계의 흐름

  1. 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악한다
  2. 시스템 책임을 더 작게 나눈다
  3. 나눈 책임을 수행할 적절한 객체나 역할를 찾아 책임을 할당한다
  4. 책임 수행 중에 다른 객체의 도움이 필요하다면 이를 책임질 적절한 객체나 역할을 찾는다.
  5. 그 객체나 역할에게 책임을 할당하여 두 객체가 협력하게 한다.

책임 할당을 위한 GRASP 패턴

책임 할당 기법 중 가장 유명한 것은 GRASP 패턴이다.
general responsibility assignment software pattern의 약자로, 일반적인 책임 할당을 위한 소프트웨어 패턴이다.

도메인 개념에서 출발하기

본격적인 설계를 시작하기 전에 도메인에 대한 개략적인 모습을 그려 보는 것은 유용하다.

완벽한 설계를 할 필요는 없다. 도메인 설계는 프로덕트에 대한 이해를 빠르게 하기 위해서 작성하는 것이지, 본 설계 그대로 구현을 하지는 않아도 된다. 단지 방향을 잡는 것일 뿐이다.

올바른 도메인 모델은 존재하지 않는다. 단지 도구현에 도움이 되는 실용적이고 유용한 모델을 도출하는 것이 답이될 뿐이다.

여기서부터 이해를 위해서 책과는 조금 다른 개념으로 직접 예시를 들었다. 카페에서 주문하는 예시를 들어봤다.

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

객체에게 올바른 책임을 할당하면 된다.

애플리케이션의 기능을 책임이라고 생각하면 된다. 이 애플리케이션의 기능은 카페 메뉴를 주문한다 이므로 책임도 이와 같다. 이 책임은 곧 메시지이다.

메시지는 메시지를 전송할 객체의 의도를 반영해서 결정해야 한다.

📌 메시지를 전송할 객체의 의도를 반영해서 메시지를 결정해야 한다.
1. 메시지를 전송할 객체는 무엇을 원하는가?
2. 메시지를 수신할 적합한 객체는 누구인가?

  1. 메시지를 전송할 객체는 무엇을 원할까?

    아직 객체는 미정이지만, 이 객체가 원하는 것은 카페 메뉴를 주문하는 것이다. 메시지 이름은 주문하라로 하겠다.

  2. 메시지를 수신할 적합한 객체는 누구일까?

    객체는 상태와 행동을 통합한 캡슐화 단위이다. 객체는 자시 자신의 정보를 스스로 처리하는 자율적인 존재이므로 객체의 책임과 책임을 수행하는 데 필요한 상태는 동일한 객체 안에 존재해야 한다.

객체에게 책임을 할당하는 첫번째 원칙은 책임을 수행할 정보를 알고 있는 객체에게 책임을 할당하는 것이다. 이를 정보 전문가 패턴(information expert pattern)이라고 부른다.

주의해야 할 점은, 여기서 말하는 정보와 데이터는 다르다는 점이다. 어떠한 정보를 알고있다고 해서 꼭 저장하고 있을 필요는 없다. 그냥 그 정보를 많이 알고있는 객체에게 메시지에 대한 책임을 할당하면 된다.

정보 전문가 패턴에 의거해 메시지를 먼저 정하고 메시지를 송신할 객체를 정했다.

주문상품과 상품을 주문상품이 알고 있기 때문에 세부적인 메시지는 제외하고 큰 틀의 메시지만 정의했다.

높은 응집도와 낮은 결합도

만약에 주문상품들이 아니라 주문에서 할인과 협력하도록 하면 어떻게 될까?

기능 면에서 아무런 차이가 없지만, 응집도와 결합도에서 차이가 발생한다. 높은 응집도와 낮은 결합도는 객체에 책임을 할당할 때 항상 고려해야 하는 기본 원리다.

책임을 할당할 수 있는 다양한 대안들이 존재한다면
응집도와 결합도 측면에서 더 나은 대안을 선택하는 것이 좋다.

GRASP 패턴에서는 이를 Low Coupling(낮은 결합도) 패턴과 High Cohesion(높은 응집도) 패턴이라고 부른다.

  1. Low Coupling(낮은 결합도) 패턴

    어떻게 하면 의존성을 낮추고 변화의 영향을 줄이며 재사용성을 증가시킬 수 있을까? 결합도가 낮게 유지되도록 책임을 할당하라.

    이 관점에서 봤을 때 주문할인을 들고있지 않기 때문에 할인을 들고있는 주문상품들에서 할인과 협력하는 것이 더 나은 설계이다.

  1. High Cohesion(높은 응집도) 패턴

    어떻게 하면 복잡성을 관리할 수 있는 수준으로 유지할 수 있을까? 높은 응집도를 유지할 수 있게 책임을 할당하라.

    주문의 가장 중요한 책임은 주문하는 것이다. 주문 금액을 계산하고 할인금액까지 적용하는 것은 엄밀히 주문과 다른 책임이므로 응집도가 낮아진다.

    그에 비해 주문상품들의 주요 책임은 주문된 금액을 계산하는 것이다. 따라서 할인 금액까지 적용하는 것 또한 책임에 포함된다. 응집도에 아무런 영향을 주지 않는다.

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

GRASP의 CREATOR 패턴은 객체를 생성할 책임을 어떤 객체에게 할당할지에 대한 지침을 제공한다.

📌 CREATOR 패턴

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

구현을 통한 검증

여기서부터는 오브젝트 코드를 따라감.

DiscountCondition 개선하기

변경에 취약한 클래스 → 코드를 수정해야 하는 이유를 하나 이상 가지는 클래스

  • DiscountCondition이 변경에 취약한 이유
    • 새로운 할인 조건 추가 시
      • if~else 구문 수정, discountCondition 속성 추가
    • 순번 조건 판단하는 로직 변경 시
      • isSatisfiedBySequence 메서드 내부 구현 수정, 순번 조건 판단 데이터 변경 시 sequence 속성 변경
    • 기간 조건을 판단하는 로직이 변경 시
      • isSatisfiedByPeriod 메서드 내부 구현 수정, 기간 조건 판단 데이터 변경 시 datOfWeek, startTime, endTime 속성 변경
public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public boolean isSatisfiedBy(Screening screening) {
        if (type == DiscountConditionType.PERIOD) {
            return isSatisfiedByPeriod(screening);
        }
        return isSatisfiedBySequence(screening);
    }

    private boolean isSatisfiedByPeriod(Screening screening) {
        return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek()) &&
                startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
                endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
    }

    private boolean isSatisfiedBySequence(Screening screening) {
        return sequence == screening.getSequence();
    }
}

응집도가 낮다. 응집도가 낮다는 것은 서로 연관성 없는 기능이나 데이터가 하나의 클래스 안에 뭉쳐져 있다는 것을 의미한다. 응집도가 초래하는 문제를 해결하기 위해서는 변경의 이유에 따라 클래스를 분리해야 한다.

설계 개선 작업은 일반적으로 변경의 이유가 하나 이상인 클래스를 찾는 것으로부터 시작하는 것이 좋다.

📌 코드를 통해 변경의 이유를 파악할 수 있는 방법

  • 인스턴스 변수가 초기화되는 시점 찾기
    • 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다
  • 메서드들이 인스턴스 변수를 사용하는 방식 살펴보기
    • 모든 메서드가 객체의 모든 속성을 사용한다면 응집도가 높고, 사용하는 속성에 따라 그룹이 나뉜다면 클래스의 응집도가 낮다고 볼 수 있다
    • 속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리해야 한다

클래스 응집도 판단하기

  1. 클래스가 하나 이상의 이유로 변경돼야 한다면 응집도가 낮다. 변경의 이유를 기준으로 클래스를 분리하라.
  2. 클래스의 인스턴스를 초기화하는 시점에 경우에 따라 서로 다른 속성들을 초기화하고 있다면 응집도가 낮은 것이다. 초기화되는 속성의 그룹을 기준으로 클래스를 분리하라.
  3. 메서드 그룹이 속성 그룹을 사용하는지 여부로 나뉜다면 응집도가 낮은 것이다. 그룹을 기준으로 클래스를 분리하라.

타입 분리하기

DiscountCondition의 가장 큰 문제→순번 조건, 기간 조건이 하나의 클래스에 공존하는 것

두 클래스로 분리해보자.

클래스를 나눈 관점에서 생각해보면 설계측면에서 결합도가 높아졌고, 새로운 할인 조건을 추가하기 더 어려워졌다.

다형성을 통해 분리하기

Movie입장에서 할인한다는 기능 자체는 ~Condition 객체 두개 모두 전혀 차이가 없다. 여기서 역할이 등장한다.

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

  • 역할을 대체할 클래스들 사이에서 구현을 공유해야 한다면 추상클래스를 사용한다
  • 구현을 공유할 필요 없이 역할을 대체하는 객체들의 책임만 정의하고 싶다면 인터페이스를 사용한다

객체의 타입에 따라 변하는 행동이 있다면 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당한다. 이것을 GRASP 패턴에서 POLYMORPHISM(다형성) 패턴이라고 부른다.

변경으로부터 보호하기

만약 이후에 새로운 할인 조건이 추가되어도 Movie에서는 이를 알 수 없다. 오직 DiscountCondition의 구현체를 추가하는 것으로 할인 조건을 확장할 수 있다.

변경을 캡슐화하도록 책임을 할당하는 것을 GRASP에서 PROTECTED VARIATIONS(변경 보호) 패턴이라고 한다.

Movie 클래스 개선

Movie도 금액 할인 정책 영화와 비율 할인 정책 영화 두 타입을 하나의 클래스 안에서 구현하고 있다. 그래서 하나 이상의 이유로 변경될 수 있다. → 응집도가 낮다.

역할의 개념을 도입하여 다형성 패턴을 사용해 서로 다른 행동을 타입별로 분리하면 된다. 이를 도입하면 변경 보호 패턴을 이용해 타입 종류를 인터페이스 뒤로 캡슐화 할 수 있기도 하다.

변경과 유연성

설계를 주도하는 것은 변경이다. 개발자로서 변경에 대비할 수 있는 두 가지 방법이 있다.

  1. 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계한다.
  2. 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만드는 것이다.

대부분의 경우 단순한 설계가 좋다. 그러나 유사한 변경이 반복적으로 발생하고 있다면 복잡성이 상승하더라도 유연성을 추가하는 두 번째 방법이 좋다. → 합성 사용

Movie가 실행 중간에 다른 할인 정책으로 변경되어야 한다면 Movie자체를 갈아끼우는 것보다 할인 정책을 합성하여 갈아끼우는 것이 유연성에 좋다.

책임 주도 설계의 대안

책임 주도 설계의 대안이 있다.

바로 리팩토링!

당연한 말이다. 원래 애플리케이션은 (잘) 변경하기 위해서 만들어진다. 따라서 RDD를 따르기 어렵다면 리팩토링을 통해 올바른 책임을 할당할 수 있다.

메서드 응집도

데이터 중심 설계에서 ReservationAgency의 reserve를 살펴보자.

public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
    Movie movie = screening.getMovie();

    boolean discountable = false;
    for (DiscountCondition condition : movie.getDiscountConditions()) {
        if (condition.getType() == DiscountConditionType.PERIOD) {
            discountable = screening.getWhenScreend().getDayOfWeek().equals(condition.getDayOfWeek()) &&
                    condition.getStartTime().compareTo(screening.getWhenScreend().toLocalTime()) <= 0 &&
                    condition.getEndTime().compareTo(screening.getWhenScreend().toLocalTime()) >= 0;
        } else {
            discountable = condition.getSequence() == screening.getSequence();
        }

        if (discountable) {
            break;
        }
    }

    Money fee;
    if (discountable) {
        Money discountAmount = Money.ZERO;
        switch (movie.getMovieType()) {
            case AMOUNT_DISCOUNT:
                discountAmount = movie.getDiscountAmount();
                break;
            case PERCENT_DISCOUNT:
                discountAmount = movie.getFee().times(movie.getDiscountPercent());
                break;
            case NONE_DISCOUNT:
                discountAmount = Money.ZERO;
                break;
        }
        fee = movie.getFee().minus(discountAmount);
    } else {
        fee = movie.getFee();
    }
    return new Reservation(customer, screening, fee, audienceCount);
}

길이도 길고 이해하기도 어렵다.

  • 어떤 일을 수행하는지 한눈에 파악하기 어렵다→코드 이해에 많은 시간이 걸린다
  • 하나의 메서드 안에서 너무 많은 작업을 처리한다→수정할 부분 찾기 어렵다
  • 메서드 일부만 수정해도 나머지에서 버그가 터지기 쉽다
  • 로직 일부만 재사용하는 것이 불가능하다
  • 코드를 재사용하는 유일한 방법은 복붙이다.→코드 중복을 초래하기 쉽다

객체로 책임을 분배할 때 가장 먼저 할 일은 메서드를 응집도 있는 수준으로 분해하는 것이다.

ReservationAgency 클래스가 오직 하나의 작업만 수행하고 하나의 변경 이유만 가지는 작고, 명확하고, 응집도가 높은 메서드로 구성되게 리팩토링한다.

이렇게 하면 클래스 길이는 길어졌지만, 일반적으로 명확성의 가치가 클래스 길이보다 더 중요하다.

메서드 응집도(하나의 메서드가 가진 응집도)는 낮아졌지만 클래스 응집도는 여전히 높다. 그렇다면 변경의 이유가 다른 메서드들을 적절한 위치로 분배하자.

객체를 자율적으로 만들자

어떤 메서드를 어떤 클래스로 이동시켜야 할까?

객체는 자율적인 존재여야 한다는 사실을 떠올리면 된다.

profile
겉촉속촉

0개의 댓글