Computer Science/객체지향

설계 품질과 트레이드오프 - 코드로 이해하는 객체지향 프로그래밍

sh1mj1 2023. 7. 25. 23:33

이전 글 https://sh1mj1-log.tistory.com/133 에서 객체지향 설계의 핵심은 역할, 책임, 협력 이라고 했습니다. 

 

협력: 애플리케이션의 기능을 구현하기 위해 메시지를 주고받는 객체들 사이의 상호작용.

책임: 객체가 다른 객체와 협력하기 위해 수행하는 행동.

역할: 대체 가능한 책임의 집합.

 

책임이 객체지향 애플리케이션 전체의 품질을 결정합니다. 책임 할당은 응집도와 결합도와 같은 설계 품질과 깊이 연관되어 있습니다. 

설계는 변경을 위해 존재하고 변경에는 비용이 발생합니다. 좋은 설계는 비용을 최소화하는 것이지요. 응집도가 높고 결합도가 낮은 것이 좋은 설계입니다. 그를 위해서 객체의 상태가 아닌 행동에 집중합니다. 또 그를 위해 객체의 책임에 초점을 맞추는 것이 좋습니다. 

 

이번 장에서 책은 영화 예매 시스템을 책임이 아닌 상태를 표현하는 데이터 중심의 설계를 살펴보고 객체 지향적으로 설계한 구조와 어떤 차이점이 있는지 보여줍니다.

 

설계 품질과 트레이드오프

데이터 중심의 영화 예매 시스템

여기서는 데이터와 상태를 같은 의미로 하여 설명합니다.

 

데이터 중심의 관점에서 객체는 자신이 가진 데이터를 조작하는데 필요한 오퍼레이션을 정의합니다. 객체의 상태에 초점을 맞춥니다 데이터.

책임 중심의 관점에서는 다른 객체가 요청할 수 있는 오퍼레이션을 위해 필요한 상태를 보관합니다. 객체의 행동에 초점을 맞춥니다.

 

데이터 중심의 관점 책임 중심의 관점
객체는 자신이 가진 데이터를 조작하는데 필요한 오퍼레이션을 정의 객체는 다른 객체가 요청할 수 있는 오퍼레이션을 위해 필요한 상태를 보관
객체의 상태에 초점을 맞춤 객체의 행동에 초점을 맞춤
객체를 독립된 데이터 덩어리로 바라봄 객체를 협력하는 공동체의 일원으로 봄.

 

객체의 상태는 불안정하고 변하기 쉬운 구현에 속합니다. 상태를 객체 분할의 중심으로 하면 캡슐화가 무너집니다. 구현의 세부사항이 객체의 인터페이스의 변경을 초래하고 이 인터페이스에 의존하는 모든 객체에게 변경의 영향을 주게 되지요.

 

반면에 객체의 책임은 안정적인 인터페이스에 속합니다. 책임을 수행하는데 필요한 상태를 캡슐화함으로써 구현 변경에 대한 파장이 외부로 퍼져나가는 것을 방지합니다. 책임에 초점을 맞추면 상대적으로 변경에 안정적인 설계를 얻을 수 있습니다.

 

이제 데이터 중심의 설계를 직접 해봅시다.

 

데이터 준비

데이터 중심 설계는 객체 내부에 저장되는 데이터를 기반으로 시스템을 분할하는 방법입니다. 그 데이터를 먼저 찾아내야 겠죠.

 

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

    private MovieType movieType;
    private Money discountAmount;
    private double discpuntPercent;

}

두드러지는 차이점은 할인조건의 목록(discountConditions) 이 인스턴스 변수로 Movie 안에 직접 포함되어 있습니다. 또 할인정책을 기존에는 DiscountPolicy 라는 별도 클래스로 분리했던 것과 달리 금액 할인정책에서의 할인 금액(discountAmount) 와 비율 할인정책에서의 할인 비율(discountPercent) 을 직접 정의하고 있습니다.

 

할인 정책은 영화별로 오직 하나만 지정할 수 있기 때문에 discountAmount 와 discountPercent 중 하나의 값만 사용될 수 있습니다. 영화에 사용된 할인 정책의 종류는 movieType 이 결정합니다. 이는 열거형 MovieType 의 인스턴스입니다.

public enum MovieType {
    AMOUNT_DISCOUNT, // 금액 할인 정책
    PERCENT_DISCOUNT, // 비율 할인 정책
    NONE_DISCOUNT // 미적용
}

이것이 말 그대로 데이터 중심 접근 방법입니다.

Movie 가 할인 금액을 계산하는데에는 금액 할인 정책의 경우 할인 금액이 필요하고, 비율 할인 정책의 경우 할인 비율이 필요합니다. 이 데이터는 discountAmount 와 discountPercent 라는 값으로 표현합니다. 이 때 할인 정책이 무엇인지 알기 위해 MovieType 을 정의하고 이 타입의 인스턴스를 Movie 클래스의 속성으로 포함시켜서 이 값에 따라서 어떤 데이터를 사용할지 결정합니다.

 

데이터 중심의 설계에서는 객체가 포함해야 하는 데이터에 집중합니다. 객체의 책임을 결정하기도 전에 이런 질문이 계속 반복된다면 데이터 중심의 설계를 하고 있을 확률이 높습니다. 특히 Movie 클래스의 경우처럼 객체의 종류를 저장하는 인스턴스 변수(movieType) 와 인스턴스의 종류에 따라 사용될 인스턴스 변수(discountAmount, discountPercent) 를 하나의 클래스 안에 포함시키는 방식은 데이터 중심 설계에서 흔히 보이는 패턴입니다.

 

데이터를 준비했으니 캡슐화를 위해 내부의 데이터를 반환하는 접근자(accessor) 와 데이터를 변경하는 수정자(mutator) 을 추가합니다.

public class Movie {
    ...

    public String getTitle() {
        return title;
    }
    ...

    public void setFee(Money fee) {
        this.fee = fee;
    }

    public List<DiscountCondition> getDiscountConditions() {
        return Collections.unmodifiableList(discountConditions);
    }
    
    ...

    public void setDiscpuntPercent(double discpuntPercent) {
        this.discpuntPercent = discpuntPercent;
    }
}

 

이제 할인 조건을 만듭시다. 할인 조건을 구현하기 위해서 필요한 데이터는?

먼저 현재의 할인 조건의 종류를 저장할 데이터가 필요합니다. 열거형 DiscountConditionType 을 정의합니다.

public enum DiscountConditionType {
    SEQUENCE, // 순번 조건
    PERIOD // 기간 조건
}

 

 

 

할인 조건을 구현하는 DiscountCondition 은 할인 조건의 타입을 지정할 DiscountConditionType 타입의 변수 type 을 인스턴스 변수로 가집니다. 또 movieType 과 마찬가지로 순번 할인 조건일 때 필요한 sequence 와 기간 할인 조건일 때 필요한 dayOfWeek 등을 인스턴스 변수로 가집니다. 또한 캡슐화를 위한 접근자, 수정자 메소드를 가집니다.

public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;
	
    // getter & setter ... 
}

 

이어서 Screening 클래스입니다. 지금까지 했던 것과 똑같이 어떤 데이터가 필요할지를 고민하고 구현합니다.

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

    // getter & setter
    ...
}

 

 

 

 

영화 예매를 하는 Reservation 클래스를 추가합니다.

public class Reservation {
    private Customer customer;
    private Screening screening;
    private Money fee;
    private int audienceCount;

    public Reservation(Customer customer, Screening screening, Money fee, int audienceCount) {
        this.customer = customer;
        this.screening = screening;
        this.fee = fee;
        this.audienceCount = audienceCount;
    }

    // getter & setter
    ...
}

고객 Customer 클래스

public class Customer {
    private String name;
    private String id;

    public Customer(String name, String id) {
        this.name = name;
        this.id = id;
    }
}

 

이제 영화 예매 시스템을 위해 필요한 모든 데이터를 클래스로 구현했습니다. 

영화 예매 시스템 구현을 위한 데이터 클래스

 

영화 예매

ReservationAgency 는 데이터 클래스들을 조합해서 영화 예매 절차를 구현하는 클래스입니다.

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();
                }
                case PERCENT_DISCOUNT -> {
                    discountAmount = movie.getFee().times(movie.getDiscpuntPercent());
                }
                case NONE_DISCOUNT -> {
                    discountAmount = Money.ZERO;
                }
            }
            fee = movie.getFee().minus(discountAmount);
        } else {
            fee = movie.getFee();
        }
        return new Reservation(customer, screening, fee, audienceCount);
    }
}

 

reserve 메서드는 Movie 에 설정된 DiscountCondition 목록을 차례대로 탐색하면서 영화의 할인 여부를 판단합니다. discountable 이 true 이면 만족하는 할인 조건이 존재하는 것, false 이라면 존재하지 않는 것입니다. 할인이 안된다면 영화의 기본 금액을 예매 금액으로 사용하고, 가능하다면 계산 후 기본 금액에서 차감합니다.

 

이렇게 영화 예매 시스템을 데이터 중심으로 설계하는 방법을 살펴보았습니다. 이 두 설계를 책임 중심의 설계 방법과 비교해 가면서 두 방법의 장단점을 알아봅시다.

 

설계 트레이드오프

데이터 중심 설계와 책임 중심 설계의 장단점을 비교하기 위해 캡슐화, 응집도, 결합도 를 사용합니다.

 

캡슐화

객체지향은 한 곳에서 일어난 변경이 전체 시스템에 영향을 끼치지 않도록 파급효과를 적절히 조절할 수 있는 장치를 제공하기 때문에 강력하다. 변경될 가능성이 높은 부분은 구현, 상대적으로 안정적인 부분은 인터페이스 라고 했죠?

객체 설계는 구현과 인터페이스를 분리하고 외부에서는 인터페이스에만 의존하도록 관계를 조절하는 것입니다. 불안정한 구현 세부사항을 인터페이스 뒤로 캡슐화합니다.

 

요구사항이 계속해서 변경되기 때문에 설계가 필요하고, 구현과 인터페이스를 분리해서 변경의 영향을 줄일 수 있기 때문에 캡슐화가 중요합니다. 객체 내부에는 변경될 수 있는 어떤 것이라도 캡슐화해야 합니다.

 

응집도, 결합도

응집도는 모듈에 포함된 내부 요소들이 연관되어 있는 정도입니다. 요소들이 긴밀히 협력하면 높은 응집도를 가집니다. 객체 지향에서는 객체 또는 클래스에 관련 높은 책임들을 할당하면 높은 응집도를 가집니다.

결합도는 의존성의 정도를 나타내며 다른 모듈에 대해 얼마나 '알고 있는지' 입니다. 두 모듈이 꼭 필요한 지식만 알고 있다면 낮은 결합도를 가집니다. 객체 지향에서는 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계만을 유지하고 있다면 낮은 결합도를 가집니다.

좋은 설계는 높은 응집도, 낮은 결합도를 가집니다. 응집도와 결합도는 변경과 관련된 것입니다.

 

변경의 관점에서 응집도는 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도로 측정할 수 있습니다. 하나의 변경에서 하나의 모듈 전체가 함께 변경되고, 다른 모듈은 변경되지 않으면 높은 응집도를 가지는 것입니다.

위 그림의 왼쪽처럼 높은 응집도의 설계에서는 어떤 단일의 기능을 수정해야 할 때 해당 기능을 책임지고 있는 모듈이 명확해서 한 가지 모듈만 변경됩니다. 하지만 오른쪽의 낮은 응집도 설계에서는 단일의 기능을 수정하는데 여러 모듈을 동시에 수정해야 합니다. 

응집도가 높을수록 변경의 대상과 범위가 명확해지기 때문에 코드를 변경하기 쉬워집니다. 

 

변경의 관점에서 결합도는 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도입니다. 하나의 모듈을 수정할 때 얼마나 많은 모듈을 함께 수정해야 하는지이지요. 

위 그림의 왼쪽처럼 낮은 결합도에서는 모듈 A 를 변경했을 때 오직 하나의 모듈만 영향을 받습니다. 반면에 오른쪽의 높은 결합도에서는 모듈 A를 변경했을 때 4개의 모듈을 동시에 변경해야 합니다.

계속해서 반복하지만 클래스의 구현이 아닌 인터페이스에 의존하도록 코드를 작성해야 낮은 결합도를 얻을 수 있습니다.

 

물론 결합도가 높아도 문제 없는 경우도 있습니다. 변경될 확률이 매우 적은 안정적인 모듈의 경우, 표준 라이브러리에 포함된 모듈이나 성숙 단계에 접어든 프레임워크에 의존하는 경우입니다.

 

당연히 직접 작성한 코드의 경우에는 항상 불안정하며 언제라도 변경될 수 있습니다. 코드 안에 버그가 있을 수도, 갑자기 요구사항이 변경될 수도 있습니다. 우리는 낮은 결합도를 유지해야 합니다.

 

캡슐화를 잘 지키는 것이 모듈안의 응집도를 높이고, 모듈 사이의 결합도를 낮추는 것입니다. 응집도, 결합도를 고려하기 전에 먼저 캡슐화를 향상시키기 위해 노력해야 합니다.

 

데이터 중심의 영화 예매 시스템의 문제점

기능적인 면에서는 이번 글에서의 데이터 중심 설계와 이전 글에서의 책임 중심 설계는 완전히 동일합니다. 설계 관점에서는 완전히 다릅니다. 데이터 중심 설계는 캡슐화를 위반하고 객체 내부 구현을 인터페이스의 일부로 만듭니다. 반면에 책임 중심 설계는 객체 내부 구현이 인터페이스 뒤로 캡슐화합니다.

 

요약하면 데이터 중심 설계는 캡슐화 위반, 높은 결합도낮은 응집도 의 문제를 가집니다. 코드를 통해 각 문제점을 자세히 봅시다.

 

캡슐화 위반

Movie 클래스는 오직 메서드를 통해서만 객체의 내부 상태에 접근할 수 있습니다. 인스턴스 변수의 값을 읽거나 수정하려면 getFee 메서드와 setFee 메서드를 사용해야만 합니다.

public class Movie{
    private Money fee;
    ...
    public Money getFee(){
    	return fee;
    }
    public void setFee(Money fee){
    	this.fee = fee;
    }
    ...
}

직접 객체의 내부에 접근할 수 없기 때문에 캡슐화를 잘 하고 잇는 거서럼 보이지만 접근자, 수정자 메서드는 객체 내부의 상태에 대해 어떤 정보도 캡슐화하지 못합니다. getFee 메서드와 setFee 메서드가 Movie 내부에 Money 타입의 fee 라는 인스턴스 변수가 존재한다는 사실을 퍼블릭 인터페이스에 노출하고 있습니다.

 

캡슐화를 어기게된 원인은 객체가 수행할 책임이 아니라 내부에 저장할 데이터에 집중했기 때문입니다. 객체에게 중요한 것은 책임입니다.

설계할 때 협력이라는 문맥을 고민하지 않으면 캡슐화를 위반하는 과도한 접근자와 수정자를 가지게 되는 경향이 있습니다. 이러게 접근자와 수정자에 과도하게 의존하는 설계 방식은 추측에 의한 설계 전략(desin-by-guessing strategy) 라고 합니다. 정확히 객체의 협력을 모르기 때문에 객체가 다양한 상황에서 사용될 수 있을 것이라는 추측으로 내부 상태를 드러내는 메서드를 최대한 많이 추가하게 되는 것입니다. 

결국 내부 구현이 퍼블릭 인터페이스에 그대로 노출되어 캡슐화를 어기고 변경에 취약해집니다.

 

높은 결합도

캡슐화를 위반하면 객체 내부 구현이 객체의 인터페이스에 드러나서 클라이언트가 구현에 강하게 결합됩니다. 그 결과로 단지 객체의 내부 구현을 변경해도 이 인터페이스에 의존하는 모든 클라이언트들도 함께 변경해야 합니다.

public class ReservationAgency {
	public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
    	...
        Money fee;
        if(discountable) {
        	...
            fee = movie.getFee().minus(discountAmount).times(audienceCount);
        } else {
            fee = movie.getFee();
        }
        ...
    }
}

위 코드에서 fee 타입을 변경한다고 해봅시다. 그렇다면 getFee 메서드의 반환 타입도 함께 수정해야 합니다. 그리고 getFee 메서드를 호출하는 ReservationAgency 의 구현도 변경해야 합니다.

 

fee 타입 변경으로 협력하는 클래스가 변경되기 때문에 getFee  는 fee 를 캡슐화하지 못하고 있습니다. 클라이언트가 객체의 구현에 강하게 결합되고 있습니다. 

 

또 여러 데이터 객체들을 사용하는 제어 로직이 특정 객체 안에서 집중되기 때문에 하나의 제어 객체가 다수의 데이터 객체에 강하게 결합됩니다. 이 결합도 때문에 어떤 데이터 객체를 변경해도 제어 객체를 함께 변경할 수 밖에 없습니다.

 

ReservationAgency 가 너무 많은 대상에 의존하고 있어 변경에 취약함.

위 다이어그램처럼 대부분의 제어 로직을 가진 제어 객체 ReservationAgency 가 모든 데이터 객체에 의존하고 있습니다. DiscountCondition 의 데이터가 변경되면 DiscountCondition 뿐 아니라 ReservationAgency 도 함께 수정해야 합니다. ReservationAgency 는 모든 의존성이 모입니다. 어떤 변경을 해도 ReservationAgency의 변경이 일어납니다.

데이터 중심 설계는 전체 시스템을 하나의 거대한 의존성 덩어리로 만들어 버립니다.

 

낮은 응집도

각 모듈의 응집도를 살펴보기 위해서는 코드를 수정하는 이유가 무엇인지를 살펴보아야 합니다.

 

만약 아래 수정 사항이 발생한다고 합시다. 그렇다면 ReservationAgency 의 코드를 수정해야 합니다.

  • 할인 정책이 추가됨.
  • 할인 정책별로 할인 요금을 계산하는 방법이 변경됨.
  • 할인 조건이 추가됨.
  • 할인 조건별로 할인 여부를 판단하는 방법이 변경됨.
  • 예매 요금을 계산하는 방법이 변경됨.

 

낮은 응집도는 두 측면에서 문제를 일으킵니다.

 

1. 변경의 이유가  다른 코드들을 하나의 모듈 안에 뭉쳐놓아서 변경과 상관없는 코드들이 영향을 받습니다.

ReservationAgency 안에는 할인 정책을 선택하는 코드와 할인 조건을 판단하는 코드가 함께 존재하기 때문에 새로운 할인 정책을 추가하는 작업이 할인 조건에도 영향을 미칩니다. 어떤 코드를 수정한 후, 아무 상관없는 코드에 문제가 발생하는 일은 모듈의 응집도가 낮을 때 발생합니다.

 

2. 하나의 요구사항을 변경하기 위해 동시에 여러 모듈을 수정해야 합니다.

응집도가 낮으면 다른 모듈에 있어야 할 책임이 엉뚱한 곳에 위치하게 됩니다. 만약 새로운 할인 정책이 추가된다면 이를 위해 MovieType 에 새로운 할인 정책을 표현하는 열거형 값을 추가한 뒤 ReservationAgency 의 reserve 메서드의 switch 구문에도 새로운 case 절이 추가되어야 합니다. 또 새로운 할인 요금을 계산하기 위해 Movie 에도 데이터를 추가해야 합니다. 한 가지 요구사항이 변경했는데 세 가지 클래스를 모두 수정해야 하게 되었습니다.

 

현재 설계는 새로운 할인 정책이나 할인 조건을 추가하기 위해(요구사항 변경을 위해) 둘 이상의 클래스를 동시에 수정해야 합니다. 이는 설계의 응집도가 낮다는 뜻입니다.

 

단일 책임 원칙(Single Responsibility Principle, SRP)

단일 책임 원칙은 '클래스는 단 한가지의 변경 이유만 가져야 한다' 라는 의미입니다. 단일 책임이라는 맥락에서 '책임'은 '변경의 이유'입니다. 변경과 관련된 더 큰 개념을 가리킵니다. 이 원칙은 객체 지향 설계의 5대 원칙 SOLID 중 S 에 해당합니다.

 

자율적인 객체를 위해서

캡슐화를 지켜라

캡슐화는 설계의 제 1 원리입니다. 캡슐화를 위반하면 낮은 응집도, 높은 결합도를 유발합니다. 객체는 내부에 데이터를 캡슐화하여 스스로의 상태를 책임져야 하며 외부에서는 인터페이스에 정의된 메서드를 통해서만 상태에 접근해야 합니다.

 

만약 객체가 자율성을 잃고 그 객체가 해야 할 일을 다른 객체가 하고 있다면 '코드 중복' 이 발생할 확률이 높아집니다.

그리고 '변경에 취약' 해집니다. 

public class Rectangle {
    private int left;
    private int top;
    private int right;
    private int bottom;

    public Rectangle(int left, int top, int right, int bottom) {
        this.left = left;
        this.top = top;
        this.right = right;
        this.bottom = bottom;
    }

    public void setLeft(int left) {
        this.left = left;
    }
    //...
    public int getBottom() {
        return bottom;
    }
}

Rectangle 의 크기를 몇 배 늘리는 책임을 Rectangle 이 아닌 다른 클래스가 하고 있다고 합시다.

public class RectangleAgency {
    void someMethod(Rectangle rectangle, int multiple) {
        rectangle.setRight(rectangle.getRight() * multiple);
        rectangle.setRight(rectangle.getRight() * multiple);
    }
}

이 경우 계속해서 getter & setter 메소드를 호출해야 해서 코드가 중복됩니다.

또한 만약 Rectangle 이 right 와 bottom 이 아닌 length 와 height 로 사각형을 표현하도록 수정된다고 하면 Rectangle 클래스 뿐 아니라 RectangleAgency 클래스도 변경해야 합니다.

 

Rectangle 의 객체의 크기는 Rectangle 이 변경하게 한다면 아래처럼 수정하면 됩니다.

class Rectanble {
    public void enlarge(int multiple){
        right *= multiple;
        bottom *= multiple;
    }
}

우리는 객체가 자기 자신을 책임지도록 '책임을 이동' 시켰습니다.

 

스스로 자신의 데이터를 책임지는 객체

객체를 설계할 때는 두 가지를 고민해야 합니다. 

 

1. 이 객체가 어떤 데이터를 포함해야 하는가?

2. 이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?

 

예제로 돌아가서 ReservationAgency 로 나가버린 데이터에 대한 책임을 실제 데이터를 포함하고 있는 객체로 옮겨봅시다.

 

DiscountCondition 부터 봅시다. DiscountCondition 은 어떤 데이터를 관리해야 하나요?

public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;
}

그렇다면 이 데이터에 대해 수행할 수 있는 오퍼레이션은 무엇인가요?

할인 조건을 판단할 수 있도록 두 개의 isDiscountable 메소드가 필요할 것입니다. 

public class DiscountCondition {

    public DiscountConditionType getType() {
        return type;
    }

    public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime time) {
        if (type != DiscountConditionType.PERIOD) {
            throw new IllegalArgumentException();
        }
        return this.dayOfWeek.equals(dayOfWeek) &&
                this.startTime.compareTo(time) <= 0 &&
                this.endTime.compareTo(time) >= 0;
    }

    public boolean isDiscountable(int sequence) {
        if (type != DiscountConditionType.SEQUENCE) {
            throw new IllegalArgumentException();
        }
        return this.sequence == sequence;
    }
}

 

이제 Movie 을 봅시다. Movie 는 어떤 데이터를 포함해야 하나요?

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

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

그리고 어떤 오퍼레이션이 필요한가요?

데이터를 보면 영화 요금을 계산하는 오퍼레이션과 할인 여부를 판단하는 오퍼레이션이 필요할 것입니다.

할인 정책의 타입을 반환하는 getMovieType 메서드와 정책별로 요금을 계산하는 세가지 메서드를 구현해야 합니다.

public class Movie {

    public MovieType getMovieType() {
        return movieType;
    }
    
    public Money calculateAmountDiscountedFee(){
        if (movieType != MovieType.AMOUNT_DISCOUNT) {
            throw new IllegalArgumentException();
        }
        return fee.minus(discountAmount);
    }
    public Money calculatePercentDiscountedFee(){
        if (movieType != MovieType.PERCENT_DISCOUNT) {
            throw new IllegalArgumentException();
        }
        return fee.minus(fee.times(discpuntPercent));
    }
    
    public Money calculateNoneDiscountFee(){
        if (movieType != MovieType.NONE_DISCOUNT) {
            throw new IllegalArgumentException();
        }
        return fee;
    }
}

그리고 Movie 는 DiscountCondition 의 목록을 포함하기 때문에 할인 여부를 판단하는 오퍼레이션 역시 포함해야 합니다.

isDiscountable 메서드를 추가합니다.

 

public class Movie {
       public boolean isDiscountable(LocalDateTime whenScreened, int sequence) {
        for (DiscountCondition condition : discountConditions) {
            if (condition.getType() == DiscountConditionType.PERIOD) {
                if (condition.isDiscountable(whenScreened.getDayOfWeek(), whenScreened.toLocalTime())) {
                    return true;
                }
            } else {
                if (condition.isDiscountable(sequence)) {
                    return true;
                }
            }
        }
        return false;
    }
}

 

이제 Screening 을 봅시다.  필요한 데이터와 메서드는 아래와 같습니다.

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

    public Screening(Movie movie, int sequence, LocalDateTime whenScreened) {
        this.movie = movie;
        this.sequence = sequence;
        this.whenScreened = whenScreened;
    }

    public Money calculateFee(int audienceCount) {
        switch (movie.getMovieType()) {
            case AMOUNT_DISCOUNT -> {
                if (movie.isDiscountable(whenScreened, sequence)) {
                    return movie.calculateAmountDiscountedFee().times(audienceCount);
                }
            }
            case PERCENT_DISCOUNT -> {
                if (movie.isDiscountable(whenScreened, sequence)) {
                    return movie.calculatePercentDiscountedFee().times(audienceCount);
                }
            }
            case NONE_DISCOUNT -> {
                return movie.calculateNoneDiscountFee().times(audienceCount);
            }
        }
        return movie.calculateNoneDiscountFee().times(audienceCount);
        
    }
}

Screening 은 Movie 가 할인 정책을 지원하면 Movie 의 isDiscountable 메서드를 호출해서 할인이 가능한지 판단하고 적절한 Movie 메서드를 호출해서 요금을 계산합니다.

 

마지막으로 ReservationAgency 는 Screening 의 calculateFee 메서드를 호출해서 예매 요금을 계산하고 계산된 요금으로 Reservation 을 생성합니다.

public class ReservationAgency {

    public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
        Money fee = screening.calculateFee(audienceCount);
        return new Reservation(customer, screening, fee, audienceCount);
    }
}

 

 

 

모든 작업은 끝났습니다. 결합도 측면에서는 ReservationAgency 에 의존성이 몰려있던 설계보다 개선된 것 같네요.

확실히 첫번째 설계보다 내부 구현을 더 면밀히 캡슐화하고 있습니다. 두 번째 설계에서는 데이터를 처리하는 데 필요한 메서드를 데이터를 가지고 있는 객체 스스로 구현하고 있습니다. 이 객체들은 스스로를 책임지고 있습니다.

 

하지만 이 설계도 여전히 부족합니다.

캡슐화 관점에서 설계가 더 나아졌지만 아직도 문제가 발생합니다.

 

캡슐화 위반

DiscountCondition 의 isDiscountable 메서드를 봅시다.

public class DiscountCondition {
    private DiscountConditionType type;
    private int sequence;
    private DayOfWeek dayOfWeek;
    private LocalTime startTime;
    private LocalTime endTime;

    public DiscountConditionType getType() {... }

    public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime time) {... }
    //...
    public boolean isDiscountable(int sequence) {... }
}

여기서 isDiscountable(DayOfWeek dayOfWeek, LocalTime time) 메서드는 파라미터로 DiscountCondition 에 속성으로 있는 정보를 가집니다. 즉, 이 메서드는 객체 내부에 DayOfWeek 타입의 요일과 LocalTime 타입의 시간 정보를 인스턴스 변수로 갖고 있다는 사실을 외부에 노출합니다. isDiscountable(int sequence) 메서드 또한 sequence 을 노출시키고 있지요.

 

그렇다면 어떻게 DiscountCondition 의 속성을 변경해야 한다면 어떻게 할까요? 그렇다면 두 개의 isDiscountable 메서드의 파라미터를 수정하고 해당 메서드를 사용하는 모든 클라이언트도 함께 수정해야 합니다. 

내부 구현의 변경이 외부로 퍼져나가는 파급 효과(ripple effect)는 캡슐화가 부족하다는 증거입니다. 

 

Movie 클래스 또한 마찬가지입니다.

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

    private MovieType movieType;
    private Money discountAmount;
    private double discpuntPercent;

    public MovieType getMovieType() { ... }
    public Money calculateAmountDiscountedFee(){ ...}
    public Money calculatePercentDiscountedFee(){...}
    public Money calculateNoneDiscountFee(){...}
}

여기서 어떤 메소드도 파라미터로 객체의 속성을 노출시키고 있지 않지만, 할인 정책의 종류를 노출시키고 있습니다.

calculateAmountDisocuntedFee, calculatePercentDiscountedFee, calculateNoneDiscountedFee 라는 세 개의 메소드는 할인 정책에는 금액 할인, 비율 할인, 미적용 이렇게 세가지가 존재하다는 것을 드러내고 있습니다!

 

만약 새로운 할인 정책이 추가되거나 제거된다면 이 메서드들에 의존하는 모든 클라이언트가 영향을 받을 것입니다. 따라서 Movie 또한 세 가지 할인 정책을 포함하고 있다는 내부 구현을 캡슐화하지 못합니다.

 

캡슐화의 진짜 의미

캡슐화는 단순히 객체 내부의 데이터를 외부로부터 감추는 것 이상의 의미를 가진다는 것을 보여줍니다.
캡슐화는 변경될 수 있는 어떤 것이라도 감추는 것입니다. 내부 속성을 외부로부터 감추는 것은 '데이터 캡슐화' 로 단지 캡슐화의 한 종류일 뿐입니다.

 

높은 결합도

캡슐화 위반으로 DiscountConditon 의 내부 구현이 외부로 노출되었기 때문에 Movie 와 DiscountCondition 사이의 결합도는 높을 수 밖에 없습니다. 

MovieisDiscountable 메서드를 봅시다.

public boolean isDiscountable(LocalDateTime whenScreened, int sequence) {
    for (DiscountCondition condition : discountConditions) {
        if (condition.getType() == DiscountConditionType.PERIOD) {
            if (condition.isDiscountable(whenScreened.getDayOfWeek(), whenScreened.toLocalTime())) {
                return true;
            }
        } else {
            if (condition.isDiscountable(sequence)) {
                return true;
            }
        }
    }
    return false;
}

Movie 와 DiscountCondition 사이의 결합도를 볼 것입니다. DiscountConditon 에 대한 어떤 변경이 Movie 에게까지 영향을 미칠까요?

 

1. DiscountCondition 의 할인 조건의 명칭이 PERIOD 에서 다른 값이 되면 Movie 을 수정해야 함.

2. DiscountCondiiton 의 종류가 추가되거나 삭제되면 Movie 내의 if-else 구문이 변경되어야 함.

3. 각 DiscountCondition 의 만족 여부 판단에 필요한 정보가 변경되면 MovieisDiscountable 메서드로 전달된 파라미터를 변경해야 함. 그렇다면 MovieisDiscountable 메서드 시그니처도 변경되고 결국 이 메서드에 의존하는 Screening 도 변경되어야 함.

 

DiscountCondition condition: discountConditions 이 요소들이 DiscountCondition 의 구현에 속한다는 것에 집중해야 합니다.그리고 심각한 것은 DiscountCondition 이 변경했을 때 Movie 만 영향을 미치는 것이 아닌 시스템의 모든 객체들에도 영향을 미친다는 것입니다.

 

낮은 응집도

이번에는 Screening 을 살펴봅시다. 앞서 설명한 것처럼 DiscountCondition 이 할인 여부를 판단하는 데 필요한 정보가 변경된다면 Movie 의 isDiscountable 메서드로 전달해야 하는 파라미터의 종류를 변경해야 하고, 이로 인해 Screening 에서 Movie 의 isDiscountable 메서드를 호출하는 부분도 함께 변경해야 합니다.

 

Screening 의 calculateFee 메서드입니다.

 

public Money calculateFee(int audienceCount) {
    switch (movie.getMovieType()) {
        case AMOUNT_DISCOUNT -> {
            if (movie.isDiscountable(whenScreened, sequence)) {
                return movie.calculateAmountDiscountedFee().times(audienceCount);
            }
        }
        case PERCENT_DISCOUNT -> {
            if (movie.isDiscountable(whenScreened, sequence)) {
                return movie.calculatePercentDiscountedFee().times(audienceCount);
            }
        }
        case NONE_DISCOUNT -> {
            return movie.calculateNoneDiscountFee().times(audienceCount);
        }
    }
    return movie.calculateNoneDiscountFee().times(audienceCount);

}

 

할인 조건의 종류를 변경하기 위해서는 DiscountCondtion, Movie, Screening 을 함께 수정해야 합니다. 하나의 기능 변경을 위해 여러 곳을 변경해야 하는 것은 응집도가 낮다는 것입니다.

 

데이터 중심 설계의 문제점

두 번째 설계가 변경에 유연하지 못한 이유는 캡슐화를 위반했기 때문입니다.

데이터 중심 설계가 변경에 취약한 이유는

 

1. 데이터 중심 설계는 설계 시점에서 너무 이른 시기에 데이터에 관해서 결정하도록 강요함.

2. 데이터 중심 설계에서는 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정.

 

데이터 중심 설계는 객체의 행동보다는 상태에 초점을 맞춘다.

데이터 주도 설계는 설계를 시작하는 처음부터 데이터에 관해 결정하도록 강요하기 때문에 너무 이른 시기에 내부 구현에 초점을 맞추게 합니다. 이 방식은 일반적으로 데이터와 기능을 분리하는 절차적 프로그래밍 방식을 따릅니다. 이는 상태와 행동을 하나의 단위로 캡슐화하는 객체지향 패러다임에 반하는 것입니다.

데이터 중심 관점에서는  객체도 그저 단순한 데이터 집합입니다. 그래서 접근자, 수정자를 너무 많이 추가하게 되고, 객체의 자율성도 당연히 없어지게 됩니다.  

 

비록 데이터와 데이터를 처리하는 작업을 같은 객체에 두더라도 데이터에 초점이 맞춰져 있다면 좋은 캡슐화를 하기 어렵습니다.

데이터를 먼저 결정하고 데이터를 처리하는 오퍼레이션을 나중에 결정하면 오퍼레이션에 데이터가 드러날 수 밖에 없습니다. 그래서 객체의 인터페이스는 구현을 캡슐화하는데 실패하고 코드는 변경에 취약해집니다.

 

데이터 중심 설계는 객체를 고립시킨 채 오퍼레이션을 정의하도록 만든다.

객체 지향 애플리에키션을 구축한다는 것은 협력하는 객체의 공동체를 만든다는 것을 의미합니다.

협력이라는 문맥에서 책임을 결정하고 이를 적절한 객체에게 할당하는 것이 가장 중요합니다. 항상 객체 내부가 아니라 외부에 집중합시다.

객체가 내부에 어떤 상태를 가지는지, 어떻게 관리하는지는 나중의 문제입니다. 중요한 것은 협력하는 방법입니다.

 

데이터 중심 설계는 객체의 내부에 집중합니다. 협력이라는 문맥을 고민하지 않고 객체가 관리할 데이터의 정보부터 먼저 결정하지요. 이미 객체의 구현이 결정되어 버리면 객체의 인터페이스에 구현이 고스란히 드러날 수 밖에 없습니다.