자율적인 객체를 향해
스스로 자신의 데이터를 책임지는 객체
객체를 설계할 때 “이 객체가 어떤 데이터를 포함해야 하는가?” 라는 질문은 다음과 같은 두개의 개별적인 질문으로 분리해야 한다.
- 이 객체가 어떤 데이터를 포함해야 하는가?
- 이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?
DiscountCondition (할인조건) 클래스에 대한 개선
public class DiscountCondition {
private DiscountConditionType type;
private int sequence;
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
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 클래스에 대한 개선
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountConditions;
private MovieType movieType;
private Money discountAmount;
private double discountPercent;
public MovieType getMovieType() {
return movieType;
}
public Money calculateAmountDiscountedFee() {
if (movieType != MovieType.AMOUNT_DISCOUNT) {
throw new IllegaArgumentExcepiton();
}
return fee.minus(discountAmount);
}
public Money calculatePercentDiscountedFee() {
if (movieType != MovieType.PERCENT_DISCOUNT) {
throw new IllegalArgeumentException();
}
return fee.minus(fee.times(discountPercent));
}
public Money calculateNoneDiscountedFee() {
if (movieType != MovieType.NONE_DISCOUNT) {
throw new IllegalArgumentException();
}
return fee;
}
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;
// constructor..
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.calculateNoneDiscountedFee().times(audienceCount);
}
return movie.calculateNoneDiscountedFee().times(audienceCount);
}
}
ReservationAgency 클래스에 대한 개선
public class ReservationAgency {
public Reservation reverse(Screening screening, Customer customer, int audienceCount) {
Movie movie = screening.getMovie();
return new Reservation(customer, screening, fee, audienceCount);
}
}
하지만 여전히 부족하다
캡슐화 위반
DiscountCondition
- 자신의 데이터를 이용해 할인 가능 여부를 스스로 판단하게 되었지만,
isDiscounable()
파라미터를 보면 여전히 외부에 인스턴스 변수를 노출하고 있다. - 클래스의 속성이 변경되면 내부 구현도 변경된다. 내부 구현의 변경이 외부로 퍼져나가는 파급효과는 캡슐화가 부족하다는 증거다.
Movie
calculateAmountDiscountedFee()
,calculatePercentDiscountedFee()
,calculateNoneDiscountedFee()
역시 마찬가지로 내부 구현을 인터페이스에 노출시키고 있다.
캡슐화는 변경될 수 있는 어떤 것이라도 감추는 것을 의미한다. 내부 속성을 외부로부터 감추는 것은 ‘데이터 캡슐화’ 라고 불리는 캡슐화의 한 종류일 뿐이다.
캡슐화란 변할 수 있는 어떤 것이라도 감추는 것이다. 속성의 타입이건, 비즈니스 로직이건 상관 없이 내부 구현의 변경으로 인해 외부의 객체가 영향을 받는다면 캡슐화를 위반한 것이다. 설계에서 변하는 것이 무엇인지 고려하고 변하는 개념을 캡슐화해야 한다.
높은 결합도
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;
}
}
DiscountCondition
의 기간 할인 조건의 명칭이 PERIOD에서 다른 값으로 변경경된다면Movie
를 수정해야 한다.DiscountCondition
의 종류가 추가되거나 삭제된다면Movie
안의if
절을 수정해야 한다.- 각
DiscountCondition
의 만족 여부를 판단하는 데 필요한 정보가 변경된다면Movie
의isDiscountable()
메서드로 전달된 파라미터를 수정해야 한다. 이로 인해Movie.isDiscountable()
시그니처도 함께 변경될 것이고 결과적으로 메소드에 의존하는Screening
에 대한 변경을 초래할 것이다.
인터페이스가 아니라 구현을 변경하는 경우에도 의존하는 곳의 변경도 피할 수 없다는 것은 객체 사이 결합도가 높다는 것이다.
낮은 응집도
public class Screening {
// ...
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.calculateNoneDiscountedFee().times(audienceCount);
}
return movie.calculateNoneDiscountedFee().times(audienceCount);
}
}
- 할인 조건의 종류를 변경하기 위해서는
DiscountCondition
,Movie
,Screening
을 수정해야 한다. 하나의 변경을 수용하기 위해 코드의 여러곳을 동시에 변경해야 한다는 것은 설계의 응집도가 떨어진다는 것이다.
데이터 중심 설계의 문제점
데이터 중심의 설계가 변경에 취약한 이유는 아래 두개와 같다
데이터 중심 설계는 객체의 행동보다는 상태에 초점을 맞춘다
데이터 중심 설계 방식에 익숙한 개발자들은 일반적으로 데이터와 기능을 분리하는 절차적 프로그래밍 방식을 따른다. 이것은 캡슐화를 위반한다. 데이터 중심의 관점에서 객체는 그저 단순한 데이터의 집합체일뿐이다. 이로 인해 접근자와 수정자를 과도하게 추가하게되고 이 데이터 객체를 사용하는 절차를 분리된 별도의 객체 안에 구현하게 된다.
- 데이터를 처리하는 작업과 데이터를 같은 객체 안에 두더라도 데이터에 초점이 맞춰져 있다면 만족스러운 캡슐화를 얻기 어렵다. 데이터를 먼저 결정하고 데이터를 처리하는 데 필요한 오퍼레이션을 나중에 결정하는 방식은 데이터에 관한 지식이 객체의 인터페이스에 고스란히 드러나게 된다.
데이터 중심 설계 방식에 익숙한 개발자들은 일반적으로 데이터와 기능을 분리하는 절차적 프로그래밍 방식을 따른다. 이것은 상태와 행동을 하나의 단위로 캡슐화하는 객체지향 패러다임에 반하는 것이다 데이터 중심의 관점에서 객체는 그저 단순한 데이터의 집한체일뿐이다. 이로 인해 접근자와 수정자를 과도하게 추가하게되고 이 데이터 객체를 사용하는 절차를 분리된 별도의 객체 안에 구현하게 된다. 앞에서 설명한 것처럼 접근자와 수정자는 public
속성과 큰 차이가 없기 때문에 객체의 캡슐화는 완전히 무너질 수밖에 없다. 이것이 첫번째 설계가 실패한 이유다.
비록 데이터를 처리하는 작업과 데이터를 같은 객체 안에 두더라도 데이터에 초점이 맞춰져 있다면 만족스러운 캡슐화를 얻기 어렵다. 데이터를 먼저 결정하고 데이터를 처리하는 데 필요한 오퍼레이션을 나중에 결정하는 방식은 데이터에 관한 지식이 객체의 인터페이스에 고스란히 드러나게 된다. 결과적으로 객체의 인터페이스는 구현을 캡슐화 하는 데 실패하고 코드는 변경에 취약해진다. 이것이 두번째 설계가 실패한 이유다.
데이터 중심 설계는 객체를 고립시킨 채 오퍼레이션을 정의하도록 만든다
데이터 중심 설계에서 초점은 객체의 외부가 아니라 내부로 향한다. 실행 문맥에 대한 깊이있는 고민 없이 객체가 관리할 데이터의 세부 정보를 먼저 결정한다. 객체의 구현이 이미 결정된 상태에서 다른 객체와의 협력을 고민하기 때문에 이미 구현된 객체의 인터페이스를 억지로 끼워맞출 수밖에 없다.
- 객체지향 어플리케이션을 구현한다는 것은 협력하는 객체들의 공동체를 구축한다는 것을 의미한다.
- 협력이라는 문맥 안에서 필요한 책임을 결정하고 이를 수행할 적절한 객체를 결정하는 것이 가장 중요하다.
- 설계는 항상 객체의 내부가 아니라 외부에 맞춰져 있어야 한다. 중요한 것은 객체가 다른 객체와 협력하는 방법이다.
- 객체의 인터페이스에 구현이 노출되어 있으면 협력이 구현 세부사항에 종속되고 그에 따라 객체의 내부 구현이 변경됐을 때 협력하는 객체 모두가 영향을 받을 수밖에 없다.
객체지향 어플리케이션을 구현한다는 것은 협력하는 객체들의 공동체를 구축한다는 것을 의미한다. 따라서 협력이라는 문맥 안에서 필요한 책임을 결정하고 이를 수행할 적절한 객체를 결정하는 것이 가장 중요하다. 올바른 객체지향 설계의 무게 중심은 항상 객체의 내부가 아니라 외부에 맞춰져 있어야 한다. 객체가 외부에 어떤 상태를 가지고 그 상태를 어떻게 관리하는가는 부가적인 문제다. 중요한 것은 객체가 다른 객체와 협력하는 방법이다.
안타깝게도 데이터 중심 설계에서 초점은 객체의 외부가 아니라 내부로 향한다. 실행 문맥에 대한 깊이있는 고민 없이 객체가 관리할 데이터의 세부 정보를 먼저 결정한다. 객체의 구현이 이미 결정된 상태에서 다른 객체와의 협력을 고민하기 때문에 이미 구현된 객체의 인터페이스를 억지로 끼워맞출 수밖에 없다.
두번째 설계가 변경에 유현하게 대처하지 못했던 이유가 바로 이 때문이다. 객체의 인터페이스에 구현이 노출돼 있었기 때문에 협력이 구현 세부사항에 종속돼있고 그에 따라 객체의 내부 구현이 변경됐을 때 협력하는 객체 모두가 영향을 받을 수밖에 없었던 것이다.