목차

오브젝트; 변경과 유연성과 리팩토링

🗓️

구현을 통한 검증

메시지를 처리할 책임과 책임을 수행하는데 필요한 변수 지정을 먼저 한다.

public class Screening {

    // 1. '예매하라' 는 메시지에 응답하는 메서드
    public Reservation reserver(Customer customer, int audienceCount) {
    }

    // 2. 책임을 수행하는데 필요한 인스턴스 변수
    private Movie movie;
    private int sequence;
    private LocalDateTime whenScreened;
}

다른 객체의 구현을 고려할 필요 없이 필요한 메시지를 결정하면 다른 객체의 내부 구현을 깔끔하게 캡슐화 할 수 있다.

public class Screening {
    private Movie movie;
    private int sequence;
    private LocalDateTime whenScreened;

    public Reservation reserver(Customer customer, int audienceCount) {
        return new Reservation(customer, this, calculateFee(audienceCount), audienceCount);
    }

    private Money calculateFee(int audienceCount) {
        return movie.calculateMovieFee(this).times(audienceCount);
    }
}

메시지가 변경되지 않는 한 객체에 어떤 수정을 가하더라도 호출하는 쪽에는 영향을 미치지 않는다. 메시지를 기반으로 협력을 구성하면 객체 사이 결합도를 느슨하게 유지할 수 있다. – 책임 주도 설계

calculateFee()를 위한 객체 설계를 한다.

public class Movie {

    // 1. 메시지
    public Money calculateMovieFee(Screening screening) {
    }

    // 2. 인스턴스 변수
    private String title;
    private Duration runningTime;
    private Money fee;
    private List<DiscountCondition> discountConditions;

    private MovieType movieType;
    private Money discountAmount;
    private double discountPercent;
}

public enum MovieType {
    AMOUNT_DISCOUNT,
    PERCENT_DISCOUNT,
    NONE_DISCOUNT
}

상세 구현을 한다.

public class Movie {

    // 맴버 생략..

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

    private boolean isDiscountable(Screening screening) {
        return discountConditions.stream().antMatch(c -> c.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 calculateAmountDiscounAmount() {
        return discountAmount;
    }

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

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

4장과 다른점은 switch 문 내에 또 다른 분기가 없다는 점 정도..

첫번째 메시지 처리 메서드, 두번째 인스턴스 맴버, 세번째 구현을 차례대로 진행한다.

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.isAfter(screening.getWhenScreened().toLocalTime()) >= 0;
    }

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

}

public enum DiscountConditionType {
    SEQUENCE,
    PERIOD
}

public class Screening {
    public LocalDateTime getWhenScreened() {
        return whenScreened;
    }

    public int getSequence() {
        return sequence;
    }
}

DiscountCondition 개선하기

DiscountCondition은 아래 3가지 이유로 변경의 가능성이 있다.

새로운 할인 조건 추가

  • isSatisfiedBy() 내의 if 구문을 수정해야 한다. 새로운 할인 조건이 새로운 데이터를 요구한다면 DiscountCondition에 속성을 추가하는 작업도 필요하다.

순번 조건을 판단하는 로직 변경

  • isSatisfiedBySequence()의 내부 구현을 수정해야 한다. 순번 조건을 판단하는데 필요한 데이터가 변경된다면 DiscountCondition.sequence 역시 변경해야 할 것이다.

기간 조건을 판단하는 로직이 변경되는 경우

  • isSatisfiedByPeriod()의 내부 구현을 수정 해야 한다. 기간 조건을 판단하는 데 필요한 데이터가 변경된다면 DiscountCondition의 시간 속성 필드 역시 변경해야 할 것이다.

하나의 변경 이유를 가지기 때문에 이 객체는 응집도가 낮다. 이것은 서로 연관성이 없는 기능이나 데이터가 하나의 클래스 안에 뭉쳐져 있다는 것을 의미한다. 변경의 이유에 따라 클래스를 분리할 필요가 있다.

코드를 통해 변경의 이유를 파악할 수 있는 첫번째 방법은 인스턴수 변수가 초기화 되는 시점을 살펴보는 것이다. 응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께 초기화한다. 반면 응집도가 낮은 클래스는 객체의 속성 중 일부만 초기화 하고 일부는 초기화 되지 않은 상태로 남겨진다. (빌더 패턴의 또 다른 단점..?!?!?)

두번째 방법은 메서드들이 인스턴스 변수를 사용하는 방식을 살펴보는 것이다. 모든 메서드가 객체의 모든 속성을 사용한다면 클래스의 응집도는 높다고 본다. 반면 메서드들이 사용하는 속성에 다라 그룹이 나뉜다면 클래스의 응집도는 낮다고 볼 수 있다. 속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리해야 한다.

클래스 응집도 판단하기

클래스가 다음과 같은 징후로 몸살을 앓고 있다면 클래스의 응집도는 낮은 것이다.

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

타입 분리하기

DiscountCondition을 분리한다.

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

    // constructor...

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

public class SequenceCondition {
    private int sequence;

    // constructor...

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

다형성을 통해 분리하기

역할을 사용하면 객체의 구체적인 타입을 추상화할 수 있다. 자바에서는 역할을 구현하기 위해서 추상 클래스나 인터페이스를 사용한다.

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

public class PeriodCondition implements DiscountCondition {}

public class SequenceCondition implements DiscountCondition {}

의존하는 곳 역시 메시지를 이해할 수 있다는 사실만 알고 있어도 충분해진다.

public class Movie {

    private List<DiscountCondition> discountConditions;

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

    private boolean isDiscountable(Screening screening) {
        return discountConditions.stream()
            .anyMatch(c -> c.isSatisfiedBy(screening));
    }
}
POLYMORPHISM 패턴

객체의 타입에 따라 변하는 로직이 있을 때 변하는 로직을 담당할 책임을 어떻게 할당해야 하는가? 타입을 명시적으로 정의하고 각 타입에 다형적으로 행동하는 책임을 할당하라.
POLYMORPHISM 패턴은 객체의 타입을 검사해서 타입에 따라 여러 대안들을 수행하는 조건적인 논리를 사용하지 말라고 경고한다. 대신 다형성을 이용해 새로운 변화를 다루기 쉽게 확장하고 권고한다.

변경으로부터 보호하기

  • 앞서 DiscountConditionPeriodConditionSequenceCondition를 감춘다.
  • 이는 Movie 관점에서 DiscountCondition가 캡슐화 된다는 것이며, DiscountCondition에 타입을 추가하더라도 Movie는 영향을 받지 않는다.
  • 변경을 캡슐화 하도록 책임을 할당 하는 것을 PROTECTED VARIATIONS 변경 보호 패턴이라 한다.
PROTECTED VARIATIONS 패턴

객체, 서브시스템, 그리고 시스템을 어떻게 설계해야 변화와 불안정성이 다른 요소에 나쁜 영향을 미치지 않도록 방지할 수 있을까? 변화가 예상되는 불안정한 지점들을 식별하고 그 주위에 안정된 인터페이스를 형성하도록 책임을 할당하라.
PROTECTED VARIATIONS 패턴은 책임 할당의 관점에서 캡슐화를 설명하는 것이다. “설계에서 변하는 것이 무엇인지 고려하고 변하는 개념을 캡슐화 하라” 라는 객치향의 오랜 격언은 패턴의 본질을 잘 설명한다.

Movie 클래스 개선하기

public class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private List<DiscountCondition> discountConditions;

    // constructor....

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

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

    abstract protected Money calculateDiscountAmount(); // <<<<<<
}
  • 추상 메서드를 추가했다.
  • 이로써 서브클래스에서 비즈니스 로직을 원하는대로 오버라이딩 할 수 있다.
public class AmountDiscountMovie extends Movie {
    private Money discountAmount;

    // constructor...

    @Override
    protected Money calculateDiscountAmount() {
        return duscountAmount;
    }
}

public class PercentDiscountMovie extends Movie {
    private double percent;

    // constructor...

    @Override
    protected Money calculateDiscountAmount() {
        return getFee().times(percent);
    }
}

public class NoneDiscountMovie extends Movie {
    //constructor...

    @Override
    protected Money calculateDiscountAmount() {
        return Money.ZERO;
    }  
}

변경과 유연성

  • 설계를 주도하는 것은 변경이다.
  • 변경에 대비할 수 있는 방법이 두가지가 있다.
    1. 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계하는 것이다.
    2. 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만드는 것이다.
  • 상속은 실행 중에 구현을 변경하기 위해서는 새로운 인스턴스를 생성한 후 필요한 정보를 복사해야 한다.
  • 변경 전후의 인스턴스가 개념적으로는 동일한 객체를 가리키지만 물리적으로는 서로 다른 객체이기 때문에 식별자의 관점에서 혼란스러울 수 있다.
  • 해결 방법은 상속 대신 합성을 사용하는 것이다.
  • 유연성은 의존성 관리의 문제다. 요소들 사이의 의존성의 정도가 유연성의 정도를 결정한다.
코드의 구조가 도메인의 구조에 대한 새로운 통찰력을 제공한다

코드의 구조가 바뀌면 도메인에 대한 관점도 함께 바뀐다. 할인 정책을 자유롭게 변경할 수 있다는 것은 도메인에 포함된 중요한 요구사항이다. 이 요구사항을 수용하기 위해 할인 정책이라는 개념을 코드 상에 명시적으로 드러냈다면 도메인 모델 역시 코드의 관점에 따라 바뀌어야 한다. 따라서 도메인 모델은 코드의 구조에 따라 수정된다.
도메인 모델은 도메인에 포함된 개념과 관계 뿐만 아니라 도메인이 요구하는 유연성도 정확하게 반영한다. 도메인 모델은 도메인의 관계만을 모아 놓은 것이 아니다. 도메인 모델은 구현과 밀접한 관계를 맺어야 한다.

책임 주도 설계의 대안

  • 리팩토링 Refactoring : 소프트웨어를 개선하기 위해 겉으로 보이는 동작은 바꾸지 않은 채 내부 구조를 변경하는 것.

메서드 응집도

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, audience);
    }
}

reserve()는 길이가 너무 길고 이해하기도 어렵다. 긴 메서드는 다양한 측면에서 코드의 유지보수에 부정적인 영향을 미친다.

  • 어떤 일을 수행하는지 한눈에 파악하기 어렵기 때문에 코드를 전체적으로 이해하는 데 너무 많은 시간이 걸린다.
  • 하나의 메서드 안에서 너무 많은 작업을 처리하기 때문에 변경이 필요할 때 수정해야 할 부분을 찾기 어렵다.
  • 메서드 내부의 일부 로직만 수정하더라도 메서드의 나머지 부분에서 버그가 발생할 확률이 높다.
  • 로직의 일부만 재사용하는 것이 불가능하다.
  • 코드를 재사용하는 유일한 방법은 원하는 코드를 복사해서 붙여넣는 것뿐이므로 코드 중복을 초래하기 쉽다.

이러한 코드를 몬스터 메서드 라고 부른다.

클래스의 응집도와 마찬지로 메서드의 응집도를 높이는 이유도 변경과 관련이 깊다. 응집도가 높은 메서드는 변경되는 이유가 단 하나여야 한다.

  • 클래스가 작고 목적이 명확한 메서드로 구성되어 있다면 변경을 처리하기 위해 어떤 메서드를 수정해야 하는지 판단이 쉽다.
  • 메서드 크기가 작고 목적이 분명하기 때문에 재사용이 쉽다.

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

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()
            .antMatch(c -> isDiscountable(condition, screening));
    }

    private boolean isDiscountable(DiscountCondition condition, Screening screening) {
        if (condition.getType() == DiscountConditionType.PERIOD) {
            return isSatisfiedByPeriod(condition, screening);
        }
        return isSatisfiedBySequence(condition, screening);
    }

    private boolean isSatisfiedByPeriod(DiscountCondition condition, Screening screening) {
        return screening.getWhenScreened().getDayOfWeek.equals(condition.getDayOfWeek()) &&
            condition.getStartTime.compareTo(screening.getWhenScreened().toLocalTime()) <=0 &&
            condition.getEndTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
    }

    private boolean isSatisfedBySequence(DiscountCondition condition, screening screening) {
        return condition.getSequence() == screening.getSequence();
    }

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

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

        throw new IllegalStateException();
    }

        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 Money.ZERO;
    }

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

객체를 자율적으로 만들자

  • 자신이 소유하고 있는 데이터를 자기 스스로 처리하도록 만드는 것이 자율적인 객체를 만드는 지름길이다.
  • 메서드가 사용하는 데이터를 저장하고 있는 클래스로 메서드를 이동시키면 된다.
public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public boolean isDiscountable(Screening screening) {
        if (type == DiscountConditoinType.PERIOD) {
            return siSatisfiedByPeriod(screening);
        }

        return isSatisfiedBySequence(screening);
    }

    private boolean isSatisfiedByPeriod(DiscountCondition condition, Screening screening) {
        return screening.getWhenScreened().getDayOfWeek.equals(condition.getDayOfWeek()) &&
            condition.getStartTime.compareTo(screening.getWhenScreened().toLocalTime()) <=0 &&
            condition.getEndTime.compareTo(screening.getWhenScreened().toLocalTime()) >= 0;
    }

        private boolean isSatisfedBySequence(DiscountCondition condition, screening screening) {
        return sequence() == screening.getSequence();
    }


}

public class ReservationAgency {
    private boolean checkDiscountable(Screening screening) {
        return screening.getMovie().getDiscountConditions().stream()
            .anyMatch(c -> c.isDiscountable(screening));
    }
}

책임 주도 설계 방법에 익숙하지 않다면 일단 데이터 중심으로 구현한 후 이를 리팩토링하더라도 유사한 결과를 얻을 수 있다. 동작하는 코드를 작성한 후에 리팩토링 하는것이 더 훌륭한 결과물을 낳을 수 있다. 책임 주도 설계 방법의 단계에 매몰될 것이 아니라 캡슐화, 결합도, 응집도를 이해하고 객체지향 원칙을 적용하는 것이 더 중요하다.