책임 할당하기

김민우·2024년 1월 16일
0

오브젝트 스터디

목록 보기
5/13

데이터를 중심으로 하여 설계를 하면 캡슐화가 지켜지지 않아 응집도가 낮아지고 결합도가 높아진다. 이러한 코드는 변경에 유연하게 대처하기 힘들다.

데이터가 아닌 책임에 초점을 둬야 한다. 이를 통해 앞서 언급한 단점들을 모두 해결하여 유연한 코드를 작성할 수 있다.

그러나, 책임에 초점을 두는 설계는 객체마다 어떤 책임을 할당해야 하는지 결정하는게 매우 어렵다. 상황마다 다양한 선택지가 있을텐데 우리는 이러한 트레이드 오프 속에서 최선의 선택을 해야된다.

책임 주도 설계


책임 주도 설계는 단어 그대로 책임을 토대로 객체의 상태를 결정하는 설계 방식이다. 이 방식에서 반드시 알아둬야 하는 2가지에 대해 살펴보자.

데이터보다 행동이 먼저

설계 시 아래 순서로 고민을 해보자.

  1. 객체가 수행해야 하는 책임이 무엇인가?
  2. 객체에 필요한 데이터는 무엇인가?

객체에게 중요한 것은 외부에 제공하는 행동이다. 데이터는 객체가 책임을 수행하는데 필요한 재료에 불과하다. 책임이 어느정도 정리 될 때 비로소 내부 상태에 관심을 갖게 된다. 이처럼 행동은 데이터를 결정하는 흐름이 되어야 한다.

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

객체 지향에서 가장 중요한 것은 객체에 적당한 책임을 부여하는 것이다. 또한, 객체는 협력을 통해 서로 상호작용하는 존재임을 꼭 기억하자.

현 객체가 속한 협력 안에서 수행하는 책임이 적합한가? 아무리 객체 입장에서 어색하더라도 협력 내에서 적합하다면 이는 적절한 책임이라 볼 수 있다.

이처럼 협력은 객체의 책임을 할당하는데 엄청난 힌트를 제공한다.

객체를 결정하기 전에 수신할 메시지를 결정

외부 객체는 자신이 보낸 메시지를 누가 수신하는지 모른다. 단지, 어떤 식으로 메시지를 전송하는 누군가가 수신을 한다고 굳게 믿고 있다. 이 누군가는 해당 메시지를 처리하는 책임을 부여받는다.

이러한 구조 덕분에 캡슐화가 이루어진다. 따라서, 응집도가 높아지고 결합도가 낮아진다.

GRASP(General Responsibility Assignment Software Pattern)


GRASP란 일반적인 책임 할당을 위한 소프트웨어 패턴란 뜻이며 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들의 집합이다. 이 원칙들에 대해 알아보자.

설계 전 도메인의 대략적 모습을 그려보자

어떤 책임을 할당할 때 가장 유용한 정보는 바로 도메인 개념이다.

e.g. 영화 예매 도메인

  • 하나의 영화는 여러 번 상영될 수 있다.
  • 영화는 다수의 할인 조건이 있다.
  • 할인 조건은 2가지로 구분된다.
  • 영화는 금액/비율 중 하나의 정책만 적용 가능하다.

이처럼 도메인 개념은 설계 시 필요한 많은 정보를 제공한다. 단, 도메인 개념을 너무 많이 고민하면 안된다. 도메인 개념에 정답은 없으며 설계를 구현하면서 많이 바뀐다.

따라서, 적당히 틀이 잡힌 것 같다면 설계와 구현을 진행하자.

중요
도메인 모델에 정답은 없다. 처음 구현을 염두해두고 도메인을 설계하겠지만 추후 이를 토대로 작성한 코드가 도메인을 바라보는 관점을 다르게 한다.

설계 시 필요한 도메인 모델은 도메인을 모두 투영한 것이 아닌 구현에 도움이 되는 모델임을 명시하자.

정보 전문가 패턴

앞서 정보 전문가 패턴은 아래와 같이 진행된다.

  1. 익명으로 부터 메시지 수신
  2. 이를 처리할 객체 선택
    • 만약, 스스로 처리할 수 없다면 다른 객체에게 메시지 전달
      • 이를 전달받은 다른 객체에서 1번 과정 시작
      • 협력이 시작

이처럼 책임을 수행할 때 필요한 정보를 알고 있는 객체를 찾으면 된다. 정보와 행동을 최대한 가까운 곳에 위치시키므로 캡슐화를 유지할 수 있어 자율성이 높은 객체들로 구성된 협력 구성체를 구축할 수 있다.

중요) 정보 != 데이터
책임을 수행하는 객체는 정보를 "알고"있다고 해서 그 정보를 "저장"할 필요는 없다.
정보는 이를 저장할 수 있는 다른 객체를 알고있거나 필요한 정보를 계산해서 제공할 수 있다.

낮은 결합도, 높은 응집도

책임 할당에 여러 경우의 수가 있는 경우 낮은 결합도와 높은 응집도를 유지할 수 있는 경우를 선택하자. 이들은 설계를 평가할 때 적용할 수 있는 평가 원리라 볼 수 있다.

e.g. 영화 예매 시 할인 여부를 판단하는 책임을 부여하는 방법들

  • Screening -> Movie -> DiscountCondition

  • Screening
    -> Movie
    -> DiscountCondition

2번째의 경우 Screening이 가격 계산, 할인 여부 판단 모두에 관여하여 응집도가 낮아진다. 따라서, 1번째를 선택하는게 더 현명하다 볼 수 있다.

이처럼 책임을 할당할 수 있는 여러 대안이 존재한다? 응집도/결합도를 고려하자.

창조차(Creater) 패턴

협력을 통해 클라이언트의 메시지에 응답을 한다. 응답에 필요한 객체를 만드는 책임을 누가 가질까?

B 객체를 통해 A 객체를 만드는 경우 아래 4가지를 만족해야 한다.

  • B가 A 객체를 참조한다.
  • B가 A 객체를 기록한다.
  • B가 A 객체를 긴밀히 사용한다.
  • B가 A 객체를 초기화하는데 필요한 데이터를 가지고 있다.
    • 이 경우, B는 A에 대한 정보 전문가라 볼 수 있다.

영화 예매의 경우 최종적으로 Reservation 객체를 생성해야 한다.

Reservation에 필요한 정보는 다음과 같다.

  • 상영 시간
  • 상영 영화
  • 상영 순번
  • 최종 예매 가격

이를 가장 잘 알고 있는 객체는 Screening 이다. 따라서, 최종적으로 ScreeningReservation 객체를 생성하여 응답하는 건 적합하다고 볼 수 있다.

구현을 통한 검증


협력을 기반으로 객체마다 책임을 부여 했다. 하지만 이를 구현하면서 예상치 못한 문제들이 발생할 수 있다. 이를 직면할 때 마다 최선의 선택을 해야 한다.

기능이 추가되거나 변경된다면?

설계대로 구현한 코드가 지금 당장 문제가 없어보여도 확장, 유지보수를 고려하여 문제가 발생할 우려가 있는 지점을 잘 찾아내야 한다.

DiscountCondition.java

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();
    }
}

Movie.java

public class Movie {
	private MovieType movieType;
	...

    public Money calculateMovieFee(Screening screening) {
        if (isDiscountable(screening)) {
            return fee.minus(calculateDiscountAmount());
        }

        return fee;
    }

    private boolean isDiscountable(Screening screening) {
        return discountConditions.stream()
                .anyMatch(condition -> condition.isSatisfiedBy(screening));
    }

    private Money calculateDiscountAmount() {
        switch(movieType) {
            case AMOUNT_DISCOUNT:
                return calculateAmountDiscountAmount();
            case PERCENT_DISCOUNT:
                return calculatePercentDiscountAmount();
            case NONE_DISCOUNT:
                return calculateNoneDiscountAmount();
        }

        throw new IllegalStateException();
    }
    
    private Money calculateAmountDiscountAmount() {
        return discountAmount;
    }

    private Money calculatePercentDiscountAmount() {
        return fee.times(discountPercent);
    }

    private Money calculateNoneDiscountAmount() {
        return Money.ZERO;
    }
}

영화마다 적용되는 할인 조건이 다르므로 이를 MovieType 을 통해 표현했다. 이 필드에 따라 예매 금액 계산한다.

만약, 할인 조건이 추가되거나 변경되면 어떨까? 이 코드는 변경에 유연하게 대처할 수 있을까?
정답은 아니다. 새로운 할인 조건이 추가되야 된다면 DiscountCondition에 필드 및 메서드 수정이 요구된다. 기존 할인 조건 수정된다면 Movie 까지 수정해야 된다.

원인은 한 객체(DiscountCondition)에 하나 이상의 변경 이유가 있기 때문이다.

  • 순번 조건 : sequence
  • 날짜 조건 : dayOfWeek, startTime, endTime

순번/기간 2가지의 변경 이유가 존재한다.

이는 한 곳의 변경이 다른 객체까지 전파가 되므로 응집도가 낮다고 볼 수 있다. 이처럼 하나의 클래스에 인스턴스들의 초기화/사용 시점이 다르다면 이에 따라 클래스를 분리하자.

다형성을 활용하자

DiscountCondition 을 인터페이스화 하여 Movie 와 느슨하게 결합되도록 수정하자.

DiscountCondition.java

public interface DiscountCondition {
    boolean isSatisfiedBy(Screening screening);
}

순번/날짜 조건은 이를 상속받아 메서드를 구현하자.

PeriodCondition.java

public class PeriodCondition implements DiscountCondition {
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public PeriodCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
        this.dayOfWeek = dayOfWeek;
        this.startTime = startTime;
        this.endTime = endTime;
    }

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

SequenceCondition.java

public class SequenceCondition implements DiscountCondition {
    private int sequence;

    public SequenceCondition(int sequence) {
        this.sequence = sequence;
    }

    public boolean isSatisfiedBy(Screening screening) {
        return sequence == screening.getSequence();
    }
}

다형성을 활용하여 기존 if/else 를 사용할 필요가 없어졌다. 이러한 패턴을 다형성 패턴(Polymorphism Pattern) 이라 한다.

덕분에 DiscountCondition 의 계층이 구분되어 이와 협력하는 객체들은 느슨하게 결합되어 캡슐화도 강해지고 변경 포인트가 분리되어 확장 및 유지보수에 유리해졌다.

상속보단 합성을 이용하자

아직 Movie에는 DiscountCondition의 변경의 전파 가능성이 존재한다. 마찬가지로 변경 포인트에 따라 객체를 분리할 수 있다.

그러나, MovieDiscountCondition과 달리 다양한 책임을 담당하므로 상속을 활용하기엔 리스크가 존재한다. 이 경우 합성을 사용하자.

DiscountPolicy.java

public interface DiscountPolicy {
    Money calculateDiscountAmount(Screening screening);
}

PeriodDiscountPolicy.java

public class PercentDiscountPolicy extends DefaultDiscountPolicy {
    private double percent;

    public PercentDiscountPolicy(double percent, DiscountCondition... conditions) {
        super(conditions);
        this.percent = percent;
    }

    @Override
    protected Money getDiscountAmount(Screening screening) {
        return screening.getMovieFee().times(percent);
    }
}

Movie.java

public class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private DiscountPolicy discountPolicy;

    public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.discountPolicy = discountPolicy;
    }

    public Money getFee() {
        return fee;
    }

    public Money calculateMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

할인 정책이랑 인터페이스를 도입하여 보다 유연해진 걸 볼 수 있다. 또한, 합성은 상속에 비해 할인 정책이 추가하는데 드는 비용이 적다.

이처럼 변경 가능성이 큰 부분을 인터페이스를 통해 책임을 할당하는 방법을 변경 보호(Protected Variations)라 한다. 덕분에 캡슐화가 강해졌다. 앞서 언급한 "변경될 수 있는 부분까지 캡슐화하자" 가 실현됐다.

이는 기존 도메인 설계와 다르단 걸 알 수 있다. 그러나, 앞서 언급한 것 처럼 도메인 개념은 코드의 흐름에 따라 달라지므로 이러한 변경을 적극 수용하자.

메서드 응집도

아래 코드를 살펴보자.

ReservationAgency.java

public class ReservationAgency {
    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.getWhenScreened().getDayOfWeek().equals(condition.getDayOfWeek()) &&
                        condition.getStartTime().compareTo(screening.getWhenScreened().toLocalTime()) <= 0 &&
                        condition.getEndTime().compareTo(screening.getWhenScreened().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).times(audienceCount);
        } else {
            fee = movie.getFee().times(audienceCount);
        }

        return new Reservation(customer, screening, fee, audienceCount);
    }
}

reserve() 메서드 본문이 너무 길어보인다. 이는 소프트웨어의 목적 3가지를 2개를 만족하지 못한다.

  1. 직관성이 떨어져 한 눈에 코드가 읽히지 않는다. 팀원에게 이 코드를 소개한다면 주석이 필수일 것이다.
  2. 여러 기능이 하나의 메서드에 몰려 변경 포인트가 집중되었다. 이는 변경을 두렵게 한다.

이 외에 재사용성이 떨어진다는 단점도 있다.

참고
이러한 메서드를 몬스터 메서드(Monster Method)라고 한다.

메서드를 최대한 가볍게 설계하자. 작은 메서드로 구성시 위에서 언급한 단점을 모두 해소할 수 있다.

  1. 변경을 위해 어떤 메서드를 수정해야 하는지 쉽게 파악 가능
  2. 재사용성이 좋아진다.
  3. 직관성이 높아진다.

변경된 ReservationAgency.java

public class ReservationAgency {
    public Reservation reserve(Screening screening, Customer customer,
                               int audienceCount) {
        boolean discountable = checkDiscountable(screening);
        Money fee = calculateFee(screening, discountable, audienceCount);
        return createReservation(screening, customer, audienceCount, fee);
    }

    private boolean checkDiscountable(Screening screening) {
        return screening.getMovie().getDiscountConditions().stream()
                .anyMatch(condition -> condition.isDiscountable(screening));
    }

    private Money calculateFee(Screening screening, boolean discountable,
                               int audienceCount) {
        if (discountable) {
            return screening.getMovie().getFee()
                    .minus(calculateDiscountedFee(screening.getMovie()))
                    .times(audienceCount);
        }

        return  screening.getMovie().getFee();
    }

    private Money calculateDiscountedFee(Movie movie) {
        switch(movie.getMovieType()) {
            case AMOUNT_DISCOUNT:
                return calculateAmountDiscountedFee(movie);
            case PERCENT_DISCOUNT:
                return calculatePercentDiscountedFee(movie);
            case NONE_DISCOUNT:
                return calculateNoneDiscountedFee(movie);
        }

        throw new IllegalArgumentException();
    }

    private Money calculateAmountDiscountedFee(Movie movie) {
        return movie.getDiscountAmount();
    }

    private Money calculatePercentDiscountedFee(Movie movie) {
        return movie.getFee().times(movie.getDiscountPercent());
    }

    private Money calculateNoneDiscountedFee(Movie movie) {
        return movie.getFee();
    }

    private Reservation createReservation(Screening screening,
                                          Customer customer, int audienceCount, Money fee) {
        return new Reservation(customer, screening, fee, audienceCount);
    }
}

만약, 메서드를 추출했을 때 총 코드의 길이가 더 길어진다면 어떻게 해야할까? 전혀 상관없다. 메서드를 추출하는 이유는 코드 길이에서 이득을 보기 위함이 아니다. 재사용성, 직관성을 얻기 위함이다. 코드 길이가 길어질까 망설인다면 주저하지 말고 메서드를 기능별로 추출하자.

0개의 댓글