객체지향 #3부 - 객체 협력간의 책임 할당

객체지향 #3부 - 객체 협력간의 책임 할당

도메인 설계시에 협력관계가 형성되면, 객체는 메세지에 대한 수행 책임을 부여받게 된다. 이런 역할에 대한 책임을 어떻게 설정할 수 있을까?

객체에 할당된 책임의 품질은 객체 협력관계에서 협력에 적합한가? 로 결정된다.
클래스의 이름이라던가 느낌과는 다른 이야기다.
ex) Movie 클래스가 영화 할인액 계산의 책임을 가지고 있는 것 처럼
이제부터 어떻게 책임을 할당하는게 좋은 방법인지 생각해보자.

객체에 책임 할당 원리

가장 먼저 바꿔야할 생각은 이 객체는 뭐할지 정하자 라는 객체 1인칭 시점에서 벗어나야 한다.
그 이전에 다른 객체로부터 프로그램 진행에서 필요한 수행을 메세지로 요청받고, 다른 객체의 메세지 요청을 내가 가진 상태로 처리해주는 것이 책임이라는 점이다.
최소한의 협력관계에서 특정 객체의 메세지를 누가 받아야 하지? 라고 사고해야한다.
요청을 하는 객체는 누군가 나의 요청을 받아서 처리 하는지 알 필요가 없어지고 누군가 받아서 처리해주길 바라는 메세지를 보내면 된다.
전송자 - 수신자 간의 격리가 이루어져 캡슐화의 원리가 지키기 쉬워진다.

객체 책임 할당 원칙을 지키자 GRASP 패턴

General Responsibility Assignment Software Pattern
일반적인 책임 할당을 위한 소프트웨어 패턴

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

프로그램의 존재 이유는 도메인을 해결하기 위함이다.
구축하려는 시스템의 개략적인 모습을 그려본다.

김현우_도메인개념 영화 예매 프로그램을 만들 때 짜이는 개략적인 도메인 개념

도메인 개념은 구현의 기반으로써 유연하게 사용된다.

2. 정보 전문가에게 책임을 할당하기 (INFORMATION EXPERT 패턴)

Application이 제공해야 하는 기능을 구현하기 위해 요청된 진입 메세지를 설정한다.
영화 예매 프로그램의 경우 예매하라 가 된다.
중요한 점은 메시지를 수신자의 입장이 아니라 전송자가 왜 필요한지에 대한 의도를 담아 메시지를 설정해야 한다.

  • 메세지를 전송할 객체는 무엇을 원하는가?
  • 메세지를 수신할 적합 객체는 무엇인가?

객체는 책임 + 책임을 수행하기위해 필요한 상태 라는 것을 상기해야 한다.
메시지가 먼저 정해지면, 이후 메시지를 수행하기 좋은 객체를 선정하여야 한다.
기준은 도메인 개념을 참고하고, 메시지를 수행하기 위해 필요한 정보를 가장 많이 알고 있는 객체를 선택한다.

정보는 객체의 데이터와 다르다. 책임 수행에 선정된 객체가 저장하고 있을 필요는 없다.
또 다른 객체를 호출해 부탁해도 되고 계산해서 제공해도 된다.

3. 메세지 책임 할당시 항상 고민해봐야 할 것(LOW COUPLING, HIGH COHENSION 패턴)

Q : 책임 설계에서 높은 응집도와 낮은 결합도를 지향하는 이유가 무엇일까?

객체에게 책임을 할당하거나 설계 대안 중 하나를 선택해야 할 때는 높은 응집도와 낮은 결합도를 위한 최선의 선택을 해야 한다.

LOW COUPLING(낮은 결합도)를 위해 협력에 필요한 작업 완수를 위해 적절한 의존성을 설정해야 한다.
EX) Screening 객체가 Movie 객체에게 금액 계산을 요구할 때, DiscountCondition 객체는 Screening과 의존하는게 좋을까 Movie와 결합하는게 좋을까?

책임을 수행하는데 꼭 필요한 객체들끼리 의존시켜 의존성을 낮추고 변화의 영향을 줄이며 재사용성을 증가시킬 수 있다.

HIGH COHENSION(높은 응집도)는 서로 다른 이유로 변경되는 책임이 없을 수록 좋다.
자기가 부여받은 역할의 책임을 수행하는데 집중할 수 있기 때문이다.

객체간의 관계에서 복잡성을 관리할 수 있다.

높은 응집도와 낮은 결합도를 지향하면 설계의 품질 향상에 도움이 된다.
책임 수행에 꼭 필요한 객체만 의존하였는가? 책임과 연관성이 없는 객체는 의존하지 않았는가? 를 고민해보자.
그리고 한 객체가 하는 일이 너무 많지는 않은가? 를 고민해보자.
심지어 객체에 대한 Validation도 분리하는 것이 좋다. (검증 로직과 비즈니스 로직이 함께 들어있으면 2가지 관점에서 변경이 이뤄지므로)

객체 생성에 대한 책임 할당 (CREATOR 패턴)

특정 객체 A를 생성해야 할 책임을 할당할 때, 아래 조건을 최대한 만족하는 객체에게 할당해야 한다.

  • A객체를 포함하거나 참조한다.
  • A객체를 기록한다.
  • A객체를 긴밀하게 사용한다.
  • A객체를 초기화하는데 많은 데이터를 가지고 있다.

EX) 영화 예매 시스템일 경우, 모든 예매절차가 끝나면 Reservation (예약) 객체가 생성되야 한다.

객체 타입에 따라 변하는 행동시 책임 할당 (POLYMORPHISM 패턴)

객체 타입에 따라 변하는 행동을 할당할 때는 다형성을 이용해야 한다.

내부 로직에 타입별로 if ~ else, switch ~ case 조건을 생성하면 조건이 추가될 때 마다 내부 코드가 변경된다.
여기서는 다양한 타입의 객체들이 같은 인터페이스를 통해 서로 다른 행동을 구현할 수 있어 코드 유연성을 높이는 것에 중점을 둔다.

//다형성을 사용하지 않은 예
public class Movie{
    private List<PeriodCondition> periodConditionList;
    private List<SequenceCondition> sequenceConditionList;
    
    public Money calculateMovieFee(Screening screening){
        for(PeriodCondition each : periodConditionList){
            if(each.isSatisfiedBy(screening)){
                return fee.minus(each.calculateFee(fee));
            }
        }
        
        for(SequenceCondition each : sequenceConditionList){
            if(each.isSatisfiedBy(screening)){
                return fee.minus(each.calculateFee(fee));
            }
        }
        
        return fee;
    }
    
}

//다형성을 사용한 예

public abstract class DiscountCondition{
    public abstract boolean isSatisfiedBy(Screening screening);
    public abstract Money calculateFee(Money fee);
}

public class PeriodCondition extends DiscountCondition{
    @Override
    public boolean isSatisfiedBy(Screening screening){
        return screening.getWhenScreened().getDayOfWeek().equals(dayOfWeek) &&
                startTime.compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
                endTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
    }
    
    @Override
    public Money calculateFee(Money fee){
        return fee.minus(discountAmount);
    }
}


public class Movie {
    private List<DiscountCondition> discountConditions;
    
    public Money calculateMovieFee(Screening screening){
        for(DiscountCondition each : discountConditions){
            if(each.isSatisfiedBy(screening)){
                return each.calculateFee(fee);
            }
        }
        
        return fee;
    }
}

Movie로 부터 DiscountCondition의 세부 존재들을 감출 수 있다.

책임 할당 관점에서의 캡슐화 (PROTECTED VARIATIONS 패턴)

변화가 예상되는 부분을 인터페이스나 추상 클래스로 분리하고, 구체적인 구현은 이를 상속받거나 구현하는 클래스에서 하도록 하는 것이다.
시스템이 외부의 변화나 불확실성으로부터 보호를 중점으로 생각한다.
즉, 외부에서 발생할 수 있는 변경사항을 캡슐화하여 시스템의 나머지 부분이 그 변화로부터 영향을 받지 않도록 합니다.
주로 인터페이스나 추상 클래스를 통해 구현되며, 변화의 영향을 최소화하는 데 중점을 둡니다.

하나의 클래스가 여러 타입의 행동을 구현하고 있는 것 처럼 보이면, 클래스를 분해하여 역할과 구현체들로 책임을 분산시킨다.
예측 가능한 변경으로 인해 여러 클래스들이 불안정해진다면 안정적인 인터페이스 뒤로 변경을 캡슐화 한다.

Q : PROTECTED VARIATIONS 패턴과 POLYMORPHISM 패턴의 차이점은 무엇일까?

두 패턴 모두 인터페이스나 추상 클래스를 사용하지만, 목적과 적용 방식에서 차이가 있습니다.
PROTECTED VARIATIONS는 주로 변화로부터 시스템을 보호하는 데 중점을 두고, POLYMORPHISM은 다양한 타입의 객체들이 같은 인터페이스를 통해 서로 다른 행동을 구현할 수 있도록 함으로써 코드의 유연성을 높이는 데 중점을 둡니다.

Q : PROTECTED VARIATIONS 패턴과 POLYMORPHISM 패턴을 지키지 않는다면?

여러 타입을 하나의 클래스 안에 구현하고 있기 때문에 여러 이유로 변경에 취약해진다.(낮은 응집도)
또한, 변경이 예상되는 지점을 안정적인 인터페이스를 통해 캡슐화하지 않았기 때문에 변경에 대한 영향도가 높아진다.(높은 결합도)


책임 할당시 생각해볼 점 정리

  • 해결하고자 하는 도메인의 개략적인 모델을 그려본다.
  • 시나리오에서 수행되어야 하는 메세지를 추출해낸다.
    • 메세지는 시키는 객체 기준으로 생각하고 만들어야 한다.
    • 시키는 객체에서는 누가 수행하는지도 모르고 그냥 ‘나 이거 필요해, 누군가 이거해줘’ 식의 설계를 해야 캡슐화가 가능하다.
    • EX) Screening이 요금계산이 필요할 때, calculateMovieFee() 라는 메세지를 보내면 된다.
    • 심지어 받는 객체가 Movie여도 말이다. (Screening은 Movie가 하는지 몰?루)
  • 메세지를 수행할 역할을 설정한다.
    • 도메인 개념을 참고한다. 그렇다고 도메인 개념을 그대로 따라가는 것은 아니다.
    • 메세지를 수행할 객체를 설정할 때는 메세지를 수행하기 위해 필요한 정보를 가장 많이 알고 있는 객체를 선택한다.
    • 생성, 상태 변경, 조건 충족 등 다양한 메세지가 있는데 이를 수행하기 위해 필요한 정보를 가장 많이 알고 있는 객체를 선택한다.
    • 단, 지속적으로 낮은 결합도와 높은 응집도를 가진 협력인지 고민해봐야 한다.
    • 의존성이 불필요하게 있는지, 책임 수행에 정보를 더 많이 가진 객체가 있는데 엄한 애가 수행하고 있지는 않은지 고려해 결합을 낮춘다.
    • 한 객체가 메세지를 수행하는데 있어서 하는 일이 너무 많지는 않은지 고민해본다. 하는 일이 많으면 그 하는 일 만큼 그 객체가 변경에 영향을 받는다.

설계 진행 후 개선

  • 변경의 이유를 하나로 유지시키려 노력한다.
    • 구체화된 종류별로 메서드가 존재하면 변경의 이유가 여러가지가 된다. (메서드 파라미터나 메서드 명에 내부 속성이 들어가지 않게 조심)
  • 인스턴스 변수가 초기화 되는 시점 살피기
    • 응집도가 높은 클래스는 생성할 때 모든 속성을 함께 초기화한다.
    • 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다.
  • 메서드들이 인스턴스 변수를 사용하는 방식을 살피기
    • 응집도가 높은 클래스는 메서드에서 인스턴스 변수를 모두 사용한다.
  • 한 클래스에 여러 타입의 작업이 이뤄진다면 역할과 타입들로 분해하자.
  • 변경이 예측가능한 부분은 안정적인 인터페이스로 캡슐화하자.

보통의 객체는 협력에 참여하는 동안 송신자 / 수신자 역할을 동시에 수행한다.
협력의 관점에서 객체는 수신하는 메세지 / 외부에 전송하는 메세지의 집합으로 구성된다.

요점은, 독립적으로 수행할 수 있는 일보다 큰 책임을 수행하기 위해서 다른 객체와 협력하고, 그 협력은 외부에 전송하는 메세지를 사용한다.

메세지 = 명령어 + 인자(argument)
isSatisfiedBy(screening) 메세지 전송 = 메세지 + + 누가 받을지 수신자 추가 condition.isSatisfiedBy(screening)

메서드 vs 메세지

메세지를 기반으로 객체간 협력을 설계하면, 요청자는 요청만 하면 어떤 제공자가 제공을 해주는지에는 관심이 없다.
요청자가 메세지에 대한 전송을 하면, 특정 객체가 그 메세지를 받아서 요청을 처리해주는데 이때 실제 실행되는 코드가 메서드다.
메서드는 지연바인딩으로 인해 어떤 클래스의 메서드인지 늦게 결정이 되고, 이는 협력에서 결합도를 낮춰 더 유연하고 확장 가능한 코드 설계가 가능해진다.

메세지 - 추상화된 오퍼레이션 / 메서드 - 실행 코드


메세지는 그만큼 설계에 큰 영향을 미친다. 그럼 지금부터 유연하고 재사용 가능한 퍼블릭 인터페이스를 만드는 원칙과 기법을 알아보자.

퍼블릭 인터페이스의 품질에 영향을 미치는 원칙과 기법

  • 디미터 법칙
  • 묻지 말고 시켜라
  • 의도를 드러내는 인터페이스
  • 명령-쿼리 분리

디미터 법칙

호출한 객체의 속성이나 메서드를 통해 다른 객체를 호출하는 것을 피해라

이는 결합도를 낮추고 유지보수성을 높이는 데 도움이 된다.


//디미터 법칙을 지키지 않은 예 - item 내부에 Product 내부에 Price가 있다 것을 드러내고 사용자는 이 점을 알아야만 쓸 수 있어서 까다롭다.
public class ShoppingCart {
    public void calculateTotalPrice() {
        for (Item item : items) {
            Price price = item.getProduct().getPrice();
            // 가격 계산 로직...
        }
    }
}

묻지 말고 시켜라

객체의 상태를 직접 조회하지 말고, 해당 객체에 특정 작업을 수행하도록 요청하는 것을 의미한다.

객체의 내부 상태는 해당 객체의 캡슐화 내에 있어야 하며, 외부에서는 객체의 행동에만 집중해야 한다.


//나쁜 예

public class LightSwitch {
    private boolean isOn;

    public boolean isOn() {
        return isOn;
    }
}

LightSwitch lightSwitch = new LightSwitch();
if (lightSwitch.isOn()) {
    // 불을 끈다
}


//좋은 예
public class LightSwitch {
  private boolean isOn;

  public void toggle() {
    if (isOn) {
      // 불을 끈다
    } else {
      // 불을 켠다
    }
    isOn = !isOn;
  }
}

  LightSwitch lightSwitch = new LightSwitch();
lightSwitch.toggle();

의도를 드러내는 인터페이스 (Intention-Revealing Interface)

메서드와 클래스의 이름이 그들의 목적과 사용 방법을 명확하게 드러내야 한다는 원칙

이는 코드의 가독성과 유지보수성을 향상시키는 데 중요하다.

//나쁜 예 - process가 뭐하는 앤대?
public class DataProcessor {
  public void process(List<String> data) {
    // 데이터 처리 로직
  }
}


//좋은 예
public class DataProcessor {
  public void removeInvalidRecords(List<String> data) {
    // 유효하지 않은 레코드 제거 로직
  }
}


여기서 중요한 점은 메서드의 이름을 짓는게 어떻게 가 아니라 무엇을 하느냐에 초점을 둬야 한다.
어떻게 할인을 하느냐?는 내부 구현의 캡슐화가 깨지고 요청자의 주된 관심사가 아니다.
그저 할인을 하냐 못하냐가 주된 관심사이다. 무엇으로 같은 행동을 한다면 같은 타입으로 묶을 수 있다.

구현을 꽁꽁 숨기고 협력과 관련된 의도만 표현해야 한다.

명령-쿼리 분리 (Command-Query Separation)

이 원칙은 객체의 메서드가 “명령(Command)”과 “쿼리(Query)” 중 하나만 수행해야 한다고 주장한다.

“명령”은 객체의 상태를 변경하고, “쿼리”는 객체의 정보를 반환하지만 상태를 변경하지 않는다. 이 두 가지는 분리되어야 한다.


//좋은 예
public class Cart {
    public void addItem(Item item) {
        // 아이템 추가 로직
    }

    public boolean containsItem(Item item) {
        return items.contains(item);
    }
}