인프런 커뮤니티 질문&답변

Hika Maeng님의 프로필 이미지

작성한 질문수

오브젝트 - 기초편

5-1. 객체 구현하기

DiscountPolicy 구현 및 설계에 대해 궁금한 점이 있습니다.

해결된 질문

24.07.28 13:48 작성

·

2K

10

  1. 상영은 영화에게 요금계산을 맡기고

  2. 영화는 다시 정책에게 요금 계산을 맡기죠

  3. 정책은 다시 컨디션에게 할인 여부를 판정하게 하고

  4. 컨디션은 다시 상영에게 협조를 구합니다.

대표적으로 4번의 상황에서 나오는 코드가 시퀀스판정입니다.

헌데 이 코드의 구조를 보면 말이 컨디션이 스크린과 협조한거지 상영의 속성을 그대로 까서 얻은 것과 진배 없습니다.

더 나아가 컨디션은 상영의 속성 변화나 애당초 주어진 상영의 지식 정도에 큰 영향을 받아 구현됩니다. 상영의 지식이 적으면 구현할 수 있는 컨디션도 좁은 범위의 가능성을 갖게 되며 상영이 추가적인 정보가 확장된다면 컨디션도 더 많은 구조로 확장할 수 있게 됩니다. 즉 이 둘은 완전히는 아니지만 변화율이 상당히 긴밀하다 할 수 있습니다.

현실적으로는 마케팅팀의 입김에 의해 컨디션을 추가하려다보니 상영에 정보가 충분치 않아 추가하게 될 가능성이 높아 보입니다.

또한 이런면에서 상영은 역할이나 책임을 수행한다기보다 컨디션 입장에서는 그냥 데이터클래스로 보이는 수준이라고 생각됩니다. 이미 컨디션의 이름이 상영의 속성을 평가하겠다는 뉘앙스를 강하게 풍기고 있습니다.

제 생각에는 이러한 이유로 컨디션과 상영이 충분히 디커플링 되어야 한다고 생각합니다.

그래야 할인조건을 만드는 변화율과 상영의 변화율을 분리할 수 있기 때문입니다.

이 디커플링으로 가장 적절한 장소는 정책의 calculateDiscount메소드의 for루프라 생각됩니다.

interface ConditionInfo{
  int getSequence()
  ZonedDateTime getStartTime()
  int getMinAge()
  int getRunningTimeMinute()
}
...
for(DiscountCondition each:conditions){
   ConditionInfo info = new ScreenInfo(Screen);
   if(each.isSatisfiedBy(info)){
     ...

게다가 이 설계는 일견 의존성 흐름이 단방향처럼 보이지만

결국 정책이 상영을 알고 상영은 영화를 알게 되면서 다시 영화가 정책을 알게 되는 순환 의존성의 구조로 귀결됩니다. 물론 영화가 정책에 의존하는 부분은 단지 소유밖에 없으니 그나마 나은 편이지만, 정책이 컨디션에게 상영을 던져주면서 부탁하면 컨디션이 상영에 정보가 충분치 않은 경우 상영이 다시 영화에 추가 정보를 만들게 하는 순환구조가 일어납니다.

사실 정책이 getDiscountAmount하는 과정도 말이 좋아 상영에게 받은거지 그건 결국 완전히 영화의 가격에 의존하는 로직입니다. 즉 사실상 정책과 영화의 단방향 의존성은 순환의존성으로 깨져버린 상태라 디자인상 그냥 정책 생성시 영화를 넣어주는게 더 유지보수가 편한 거 아니냐? 라는 생각도 들었습니다.

전체 도메인에서 정책 내의 컨디션이 핵심비지니스 로직이라고 판단되는 바, 이 부분을 더 추상화할 필요는 충분히 있다 생각됩니다.


답변 2

11

조영호님의 프로필 이미지
조영호
지식공유자

2024. 07. 28. 15:16

먼저 설계는 트레이드오프 활동이라는 점을 말씀드리고 싶습니다.

모든 것을 만족할 수는 없기 때문에(흔히 말하는 품질속성의 이슈입니다) 여러가지 솔루션 중에서 가장 적합하다고 생각되는 것을 선택하시면 됩니다.

Hika Maeng님께서 말씀하신 솔루션도 충분히 좋은 해결책이고 여러가지 가정을 가지고 트레이드오프하면서 좀 더 나은 솔루션을 선택하면 될것 같습니다.
(의존성에 대해서는 현재 준비중인 오브젝트 다음 강의에서 좀 더 자세히 다룰 예정입니다.)

아래에는 제가 가정했던 내용을 적어놓겠습니다.

말씀하신 의존성 사이클과는 무관하지만 현재 설계에 대해 고민한 부분에 대해서는 오브젝트 책 6장 200페이지에 수록된 부분을 인용하고 뒤이어서 말씀하신 부분을 트레이드오프해 보겠습니다. 🙂

안타깝게도 묻지 말고 시켜라와 디미터 법칙을 준수하는 것이 항상 긍정적인 결과로만 귀결되는 것은 아니다. 모든 상황에서 맹목적으로 위임 메서드를 추가하면 같은 퍼블릭 인터페이스 안에 어울리지 않는 오퍼레이션들이 공존하게 된다. 결과적으로 객체는 상관 없는 책임들을 한꺼번에 떠안게 되기 때문에 결과적으로 응집도가 낮아진다.

클래스는 하나의 변경 원인만을 가져야 한다. 서로 상관없는 책임들이 함께 뭉쳐있는 클래스는 응집도가 낮으며 작은 변경으로도 쉽게 무너질 수 있다. 따라서 디미터 법칙과 묻지 말고 시켜라 원칙을 무작정 따르면 애플리케이션은 응집도가 낮은 객체로 넘쳐날 것이다.

영화 예매 시스템의 PeriodCondition 클래스를 살펴보자. isSatisfiedBy 메서드는 screening에게 질의한 상영 시작 시간을 이용해 할인 여부를 결정한다. 이 코드는 얼핏 보기에는 Screening의 내부 상태를 가져와서 사용하기 때문에 캡슐화를 위반한 것으로 보일 수 있다.

public class PeriodCondition implements DiscountCondition {
  public boolean isSatisfiedBy(Screening screening) {
    return screening.getStartTime().getDayOfWeek().equals(dayOfWeek) &&
      startTime.compareTo(screening.getStartTime().toLocalTime()) <= 0 &&
      endTime.compareTo(screening.getStartTime().toLocalTime()) >= 0;
  }
}

따라서 할인 여부를 판단하는 로직을 Screening의 isDiscountable 메서드로 옮기고 PeriodCondition이 이 메서드를 호출하도록 변경한다면 묻지 말고 시켜라 스타일을 준수하는 퍼블릭 인터페이스를 얻을 수 있다고 생각할 것이다.

public class Screening {
  public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime startTime, 
      LocalTime endTime) {
    return whenScreened.getDayOfWeek().equals(dayOfWeek) &&
           startTime.compareTo(whenScreened.toLocalTime()) <= 0 &&
           endTime.compareTo(whenScreened.toLocalTime()) >= 0;
  }
}

public class PeriodCondition implements DiscountCondition {
  public boolean isSatisfiedBy(Screening screening) {
    return screening.isDiscountable(dayOfWeek, startTime, endTime);
  }
}

하지만 이렇게 하면 Screening이 기간에 따른 할인 조건을 판단하는 책임을 떠안게 된다. 이것이 Screening이 담당해야 하는 본질적인 책임인가? 그렇지 않다. Screening의 본질적인 책임은 영화를 예매하는 것이다. Screening이 직접 할인 조건을 판단하게 되면 객체의 응집도가 낮아진다. 반면 PeriodCondition의 입장에서는 할인 조건을 판단하는 책임이 본질적이다.

게다가 Screening은 PeriodCondition의 인스턴스 변수를 인자로 받기 때문에 PeriodCondition의 인스턴스 변수 목록이 변경될 경우에도 영향을 받게 된다. 이것은 Screening과 PeriodCondition 사이의 결합도를 높인다. 따라서 Screening의 캡슐화를 향상시키는 것보다 Screening의 응집도를 높이고 Screening과 PeriodCondition 사이의 결합도를 낮추는 것이 전체적인 관점에서 더 좋은 방법이다.

일단 사이클에 대해서는 현재 클래스들이 서로 다른 패키지에 속해 있느냐 아니냐를 기준으로 판단하면 되는데요

현재 클래스들은 동일한 패키지에 속해 있기 때문에 의존성이 사이클이 돌더라도 크게 이슈는 없습니다.

잘 설계된 패키지는 같이 변하는 클래스들을 같은 패키지에 담는 CCP(Common Closure Principle)를 만족시키기 때문에 같은 패키지 내부에서의 의존성 사이클을 해결하려고 하면 코드가 불필요하게 복잡해집니다.

첨부해주신 코드는 결합도 측면에서 불필요한 복잡성이라는 냄새가 나는데요.

interface ConditionInfo{
  int getSequence()
  ZonedDateTime getStartTime()
  int getMinAge()
  int getRunningTimeMinute()
}
...
for(DiscountCondition each:conditions){
   ConditionInfo info = new ScreenInfo(Screen);
   if(each.isSatisfiedBy(info)){
     ...

위 코드에서 새로운 컨디션을 추가하면서 Screening에 새로운 속성을 추가한다고 가정해보겠습니다.

아래와 같은 코드를 함께 수정해야 합니다.

  1. Screening에 새로운 속성과 getter 추가

  2. ConditionInfo 인터페이스에 새로운 getter 오퍼레이션 추가

  3. ScreenInfo 클래스에 새로운 속성과 getter 추가(만약 Screening에 위임한다면 getter 추가)

  4. 새로운 DsicountCondition 추가

위 설계는 인터페이스를 사용해서 의존성을 끊은 것처럼 보이지만 실제로는 동시에 여러 클래스가 함께 수정해야하기 때문에 현재의 코드 사이즈로 보면 결과적으로 큰 메리트가 없어 보이기는 합니다.

현재의 코드에서는 다음과 같이 수정하면 됩니다.

  1. Screening에 새로운 속성과 getter 추가

     

  2. 새로운 DsicountCondition 추가

추가적으로 하나의 구현 클래스만 가지는 ScreenInfo 인터페이스도 불필요해 보이고요. 강의에서도 말씀드렸지만 인터페이스나 추상클래스로 구현했다고해서 추상화가 아니라 변하지 않아야 하는데 현재의 설계는 불안정한 요소에 의존하고 있습니다.

말씀드린 것처럼 설계는 트레이드오프이기 때문에 어떤 부분이 변화되고 있기 그 변화로 인해 코드의 어떤 부분이 함께 수정되거나 영향을 받는지에 따라 복잡성과 실용성 사이에서 저울질을 해야할 필요가 있습니다.

요구사항이 더 복잡해지고 코드의 사이즈가 커지면서 변경에 의한 파급효과가 이슈가 된다면 그때 적절한 추상화를 도입하는게 어떨까 합니다. :)

죄송하게도 트레이드오프를 빼고 설계를 설명드릴 수 없다보니 답변이 좀 장황해지네요. ㅠㅠ

추가적으로 논의할 부분 질문 주시면 또 답변 드리도록 하겠습니다.

7

Hika Maeng님의 프로필 이미지
Hika Maeng
질문자

2024. 07. 28. 17:14

감사합니다. 용호님과 대화를 나눌 수 있는 게 너무 좋습니다(설계관련 의견을 나눌 사람은 희귀해서..) 저 코드는 간략화한 거고 실제로는 구상 Info를 만들어내는 다양한 조합기가 info생성 시 관여한다고 생각했습니다. 패키지도 물론 별도로 ^^
암튼 트레이드 오프라는 말씀이 많이 와닿아요. 하지만 어느 정도 예측되는 확장파트를 고려안할 수 없으니 그야말로 미묘한 밸런스의 학문이라 생각이 드네요.

조영호님의 프로필 이미지
조영호
지식공유자

2024. 07. 28. 17:54

제 입장에서 더 감사드리죠. 🙂
강의를 꼼꼼하게 봐주시고 다양한 각도에서 고민해 주셔서 감사합니다!

아마 강의 들으시는 분들도 비슷한 고민을 하실 수도 있어서 오히려 이렇게 질문 남겨주시는게 다른 분들에게도 도움이 된다고 생각합니다.
덤으로 저도 설계 관련해서 이야기 나누는게 즐겁습니다.

설계 관련해서 깊이 있는 논의가 이뤄지는 채널이 없다보니 제 강의를 빌미로 이런저런 이야기 나누고 의견 주고받을 수 있어서 정말 좋네요.

주제를 벗어나도 상관없으니 다양한 이야기를 나눌 수 있으면 좋겠습니다!
강의 완강하신 것도 정말 감사드려요 🙂