책임 주도 설계라는 이름에서 알 수 있듯 역할, 책임, 협력 중에서 가장 중요한 것은 책임 !
책임이 객체지향 자체의 품질을 결정하는 것.
객제치향 설계란 올바른 객체에게 올바른 책임을 할당하면서
낮은 결합도와 높은 응집도를 가진 구조를 창조하는 활동 !
설계는 변경을 위해 존재하고 변경에는 어떤 식으로든 비용이 발생한다.
훌륭한 설계란 합리적인 비용 안에서 변경을 수용할 수 있는 구조로 만드는 것이다.
결합도와 응집도를 합리적인 수준으로 유지하려면
객체의 상태가 아니라 객체의 행동에 초점을 맞추어야 한다.
객체를 단순한 데이터의 집합으로 바라보면 객체 내부 구현을
퍼블릭 인터페이스에 노출시키는 결과를 낳기 때문에 설계가 변경에 취약해짐
이번 장에서는 영화 예매 시스템을 책임이 아닌 상태를 표현하는 데이터 중심의 설계를 살펴보고
객체지향적으로 설계한 구조와 어떤 차이점이 있는지 살펴보자 !
데이터 중심의 영화 예매 시스템
객체지향 설계는 두 가지 방법을 이용해 시스템을 객체로 분할할 수 있다.
- 상태를 분할의 중심축으로 삼기
- 책임을 분할의 중심축으로 삼기
데이터 중심의 관점에서 객체는 자신이 포함하고 있는 데이터를 조작하는 데 필요한 오퍼레이션을 정의한다.
책임 중심의 관점에서 객체는 다른 객체가 요청할 수 있는 오퍼레이션을 위해 필요한 상태를 보관한다.
데이터 중심의 관점은 객체의 상태에 초점 을 맞추고,
책임 중심의 관점은 객체의 행동에 초점 을 맞춘다.
시스템을 분할하기 위해 데이터와 책임 중 어떤 것을 선택해야 할까?
훌륭한 객체지향 설계는 데이터가 아니라 책임에 초점을 맞춰야 함 !
객체의 상태는 구현에 속한다. (구현은 변하기 쉬움 !)
상태를 객체 분할의 중심축으로 삼으면 구현에 관한 세부사항이 객체 인터페이스에 스며들게 되어
캡슐화의 원칙이 무너진다.
결과적으로 상태 변경은 인터페이스의 변경을 초래하고,
이 인터페이스에 의존하는 모든 객체에게 변경의 영향이 퍼지게 된다.
하지만 객체의 책임은 인터페이스에 속한다.
객체는 필요한 상태를 캡슐화함으로써 구현 변경에 대한 파장이 외부로 퍼져나가는 것을 방지한다.
따라서 책임에 초점을 맞추면 상대적으로 변경에 안정적인 설계를 얻을 수 있게 된다.
데이터를 준비하자
데이터 중심의 설계는 객체가 내부에 저장해야 하는 데이터가 무엇인가
를 묻는 것으로 시작한다.
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;
}
데이터 중심의 Movie
클래스 역시 책임 중심의 Movie
클래스와 마찬가지로
영화를 표현하는 가장 기본 정보인 영화 제목, 상영시간, 기본 요금을 인스턴스 변수로 포함한다.
가장 큰 차이점은 할인 조건의 목록과 할인 정책에 사용되는 할인 금액, 할인 비율을
Movie
안에서 직접 정의하고 있다는 것이다.
할인 정책은 영화별로 하나만 지정할 수 있기 때문에 한 시점에 discountAmount
와 discountPercent
중 하나의 값만 사용될 수 있다.
따라서 영화에 사용된 할인 정책의 종류를 결정하기 위해 movieType
변수를 추가했다.
public enum MovieType {
AMOUNT_DISCOUNT, // 금액 할인 정책
PERCENT_DISCOUNT, // 비율 할인 정책
NONE_DISCOUNT // 미적용
}
위 예시는 말 그대로 데이터 중심의 접근 방법 이다.
Movie
가 할인 금액을 계산할 때, 금액 할인 정책의 경우 할인 금액이 필요하고
비율 할인 정책의 경우 할인 비율이 필요하다.
예매 가격을 계산하기 위해서는 Movie
에 설정된 할인 정책이 무엇인지를 알아야 한다.
따라서 MovieType
을 정의하고 이 타입의 인스턴스를 속성으로 포함시켜
이 값에 따라 어떤 데이터를 사용할지를 결정한다.
Movie
클래스처럼 객체의 종류를 저장하는 인스턴스 변수 (MovieType
) 와
인스턴스의 종류에 따라 배타적으로 사용될 인스턴스 변수 (discountAmount
, discountPercent
) 를
하나의 클래스 안에 함께 포함시키는 방식은 데이터 중심 설계에서 흔히 볼 수 있는 패턴 !
필요한 데이터는 다 준비했으니 캡슐화를 통해 외부 다른 객체를 오염시키는 것을 막자.
가장 간단한 방법은 내부 데이터를 반환하는 접근자 (accessor) 와 수정자 (mutator) 를 추가하는 것이다.
public class Movie {
public Money getFee() {
return fee;
}
public void setFee(Money fee) {
this.fee = fee;
}
public List<DiscountCondition> getDiscountConditions() {
return Collections.unmodifiableList(discountConditions);
}
public void setDiscountConditions(List<DiscountCondition> discountConditions) {
this.discountConditions = discountConditions;
}
public MovieType getMovieType() {
return movieType;
}
public void setMovieType(MovieType movieType) {
this.movieType = movieType;
}
public Money getDiscountAmount() {
return discountAmount;
}
public void setDiscountAmount(Money discountAmount) {
this.discountAmount = discountAmount;
}
public double getDiscountPercent() {
return discountPercent;
}
public void setDiscountPercent(double discountPercent) {
this.discountPercent = discountPercent;
}
}
Movie
를 구현하는 데 필요한 데이터를 결정했고 메소드를 이용해 내부 데이터 캡슐화도 마쳤다.
이제 할인 조건을 구현해보자.
예제로 살펴보고 있는 영화 예매 도메인에는 순번 조간, 기간 조건이라는 두 종류 할인 조건이 있다.
순번 조건은 상영 순번을 이용해 할인 여부를 판단하고
기간 조건은 상영 시간을 이용해 할인 여부를 판단한다.
데이터 중심 설계를 따르기 때문에 할인 조건을 설계하기 위해
“할인 조건을 구현하는 데 필요한 데이터는 무엇인가?” 란 질문에 답을 해보면,
먼저 현재 할인 조건의 종류를 저장할 데이터가 필요하다.
public enum DiscountConditionType {
SEQUENCE, // 순번 조건
PERIOD // 기간 조건
}
할인 조건을 구현하는 DiscountCondition
은 할인 조건 타입을 저장할 인스턴스 변수 type
을 포함한다.
movieType
과 마찬가지로 순번 조건에서만 사용되는 데이터인 상영 순번과
기간 조건에서만 사용되는 데이터인 요일, 시작 시간, 종료 시간을 함께 포함한다.
또 캡슐화의 원칙에 따라 이 속성들을 외부로 노출하면 안 되기에 메소드를 추가해주자.
public class DiscountCondition {
private DiscountConditionType type;
private int sequence;
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
public DiscountConditionType getType() {
return type;
}
public void setType(DiscountConditionType type) {
this.type = type;
}
public int getSequence() {
return sequence;
}
public void setSequence(int sequence) {
this.sequence = sequence;
}
public DayOfWeek getDayOfWeek() {
return dayOfWeek;
}
public void setDayOfWeek(DayOfWeek dayOfWeek) {
this.dayOfWeek = dayOfWeek;
}
public LocalTime getStartTime() {
return startTime;
}
public void setStartTime(LocalTime startTime) {
this.startTime = startTime;
}
public LocalTime getEndTime() {
return endTime;
}
public void setEndTime(LocalTime endTime) {
this.endTime = endTime;
}
}
이제 Screening
클래스를 구현해보자.
public class Screening {
private Movie movie;
private int sequence;
private LocalDateTime whenScreened;
public Movie getMovie() {
return movie;
}
public void setMovie(Movie movie) {
this.movie = movie;
}
public int getSequence() {
return sequence;
}
public void setSequence(int sequence) {
this.sequence = sequence;
}
public LocalDateTime getWhenScreened() {
return whenScreened;
}
public void setWhenScreened(LocalDateTime whenScreened) {
this.whenScreened = whenScreened;
}
}
영화를 예매하는 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;
}
public Customer getCustomer() {
return customer;
}
public void setCustomer(Customer customer) {
this.customer = customer;
}
public Screening getScreening() {
return screening;
}
public void setScreening(Screening screening) {
this.screening = screening;
}
public Money getFee() {
return fee;
}
public void setFee(Money fee) {
this.fee = fee;
}
public int getAudienceCount() {
return audienceCount;
}
public void setAudienceCount(int audienceCount) {
this.audienceCount = audienceCount;
}
}
고객의 정보를 저장하는 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();
break;
case PERCENT_DISCOUNT:
discountAmount = movie.getFee().times(movie.getDiscountPercent());
break;
case NONE_DISCOUNT:
discountAmount = Money.ZERO;
break;
}
fee = movie.getFee().minus(discountAmount);
} else {
fee = movie.getFee();
}
return new Reservation(customer, screening, fee, audienceCount);
}
}
reserve
메소드는 두 부분으로 나눌 수 있다.
- DiscountCondition 에 대해 루프를 돌며 할인 가능 여부 체크
- discountable 변수 체크, 할인 정책에 따라 예매 요금 계산
지금까지 영화 예매 시스템을 데이터 중심으로 설계하는 방법 을 살펴봤다.
이제 이 설계를 책임 중심의 설계 방법 과 비교해보면서 두 방법의 장단점을 파악해보자.
설계 트레이드오프
데이터 중심 설계와 책임 중심 설계의 장단점을 비교하기 위해
캡슐화, 응집도, 결합도 를 사용해보자.
캡슐화
상태와 행동을 하나의 객체 안에 모으는 이유는 객체의 내부 구현을 외부로부터 감추기 위해서다.
객체지향이 강력한 이유는 한 곳에서 일어난 변경이 전체 시스템에 영향을 끼치지 않도록
파급효과를 적절하게 조절할 수 있는 장치를 제공하기 때문 !
변경이 될 가능성이 높은 부분을 구현 이라고 부르고,
상대적으로 안정적인 부분을 인터페이스 라고 부른다.
객체를 설계하는 가장 기본적인 아이디어는 변경의 정도에 따라
구현과 인터페이스를 분리하고 외부에서는 인터페이스에만 의존하도록 관계를 조절하는 것 !
캡슐화는 외부에서 알 필요가 없는 부분을 감춤으로써 대상을 단순화하는
추상화의 한 종류 이다.
정리하면 캡슐화란, 변경 가능성이 높은 부분을 객체 내부로 숨기는 추상화 기법 이다.
🙋♂️ : 무엇을 캡슐화해야 하나요 ?
💁🏻♂️ : 변경될 수 있는 어떤 것이든 !
응집도와 결합도
응집도는 모듈에 포함된 내부 요소들이 연관돼 있는 정도 를 나타낸다.
모듈 내의 요소들이 하나의 목적을 위해 긴밀하게 협력한다면 그 모듈은 높은 응집도를 가진다.
객체지향의 관점에서 응집도는,
객체 또는 클래스에 얼마나 관련 높은 책임들을 할당했는지를 나타낸다.
결합도는 의존성의 정도 를 나타낸다.
다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도이다.
어떤 모듈이 다른 모듈에 대해 너무 자세한 부분까지 알고 있다면
두 모듈은 높은 결합도를 가진다.
객체지향의 관점에서 결합도는 객체 또는 클래스가
협력에 필요한 적절한 수준의 관계만을 유지하고 있는지를 나타낸다.
일반적으로 좋은 설계란 높은 응집도와 낮은 결합도 를 가진 모듈로 구성된 설계를 의미한다.
🙋♂️ : 왜 높은 응집도와 낮은 결합도의 설계를 추구해야 하나요?
💁🏻♂️ : 설계를 변경하기 쉽게 만들기 때문입니다 !
너무 애매한 것 같다.. 얼마나 강하게 연관되어 있어야 응집도가 높은거지?
어느 정도의 의존성만 남겨야 결합도가 낮다고 할 수 있을까..?
변경의 관점에서 응집도란, 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도 로 측정할 수 있다.
하나의 변경에 대해 하나의 모듈만 변경된다 → 응집도 높음
하나의 변경에 대해 다수의 모듈이 변경된다 → 응집도 낮음
응집도가 높을수록 변경의 대상과 범위가 명확해지기 때문에 코드를 변경하기 쉽다 !
결합도는 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도 로 측정할 수 있다.
결합도가 높을수록 함께 변경해야 하는 모듈의 수가 늘어나기 때문에 변경하기 어려워진다.
캡슐화를 지키면 모듈 안의 응집도는 높아지고 모듈 사이 결합도는 낮아진다.
반면 캡슐화를 위반하면 모듈 안의 응집도는 낮아지고 모듈 사이 결합도는 높아진다.
응집도와 결합도를 고려하기 전에 먼저 캡슐화를 향상시키기 위해 노력하자.
데이터 중심의 영화 예매 시스템의 문제점
기능적인 측면에서 데이터 중심 설계는 챕터2에서 구현한 책임 중심 설계와 완전히 동일하다.
하지만 설계 관점에서는 완전히 다르다 !
근본적인 차이점은 캡슐화를 다루는 방식.
데이터 중심 설계는 캡슐화를 위반하고 객체 내부 구현을 인터페이스의 일부로 만든다.
반면 책임 중심 설계는 객체의 내부 구현을 안정적인 인터페이스 뒤로 캡슐화한다.
데이터 중심 설계는 캡슐화를 위반하기 쉽기 때문에
책임 중심 설계에 비해 응집도가 낮고 결합도가 높은 객체들을 양산할 가능성이 높다 !
데이터 중심 설계의 대표적인 문제점을 요약하자면,
- 캡슐화 위반
- 높은 결합도
- 낮은 응집도
캡슐화 위반
데이터 중심으로 설계한 Movie
클래스를 보면 오직 메소드를 통해서만
객체의 내부 상태에 접근할 수 있다.
예를 들어 fee
의 값을 읽거나 수정하기 위해서는 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
라는 이름의 인스턴스 변수가
존재한다는 사실을 퍼블릭 인터페이스에 노골적으로 드러내고 있다.
설계할 때 협력에 대해 고민하지 않으면 캡슐화를 위반하는 과도한 접근자, 수정자를
가지게 되는 경향이 있다.
“추측에 의한 설계 전략(design-by-guessing-strategy)” - Allen Holub
접근자와 수정자에 과도하게 의존하는 설계 방식
이런 전략은 객체가 사용될 협력을 고려하지 않고, 다양한 상황에서 사용될 수 있을 것이라는
막연한 추측을 기반으로 설계해서 그렇다.
그 결과 대부분의 내부 구현이 퍼블릭 인터페이스에 노출되고
캡슐화의 원칙을 위반하는 변경에 취약한 설계를 얻게 된다.
높은 결합도
객체 내부 구현이 인터페이스에 드러난다는 것은
클라이언트가 구현에 강하게 결합된다는 것을 의미한다.
객체 내부 구현을 단순히 변경하더라도 이 인터페이스에 의존하는 모든 클라이언트들도
함께 변경해야 한다는 것이 최악이다 !
public class ReservationAgency {
public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
...
Money fee;
if (discountable) {
...
fee = movie.getFee().minus(discountAmount);
} else {
fee = movie.getFee();
}
...
}
}
ReservationAgency
는 한 명의 예매 요금을 계산하기 위해 Movie 의 getFee 를 호출하고
계산된 결과를 Money
타입의 fee
에 저장한다.
이 때 fee
의 타입을 변경한다고 가정하자.
이를 위해서는 geeFee
메소드의 반환 타입도 수정해야 하고,
getFee
를 호출하는 ReservationAgency
의 구현도 변경된 타입에 맞게 수정해야 한다.
fee
타입 변경으로 협력하는 클래스도 같이 변경되기 때문에
getFee
는 fee
를 정상적으로 캡슐화하지 못한다.
이처럼 데이터 중심 설계는 객체의 캡슐화를 약화시키기 때문에
클라이언트가 객체의 구현에 강하게 결합된다.
결합도 측면에서 데이터 중심 설계가 가지는 또 다른 단점은,
여러 데이터 객체들을 사용하는 제어 로직이 특정 객체 안에 집중되기 때문에
하나의 제어 객체가 다수의 데이터 객체에 강하게 결합된다는 것 !
영화 예매 시스템을 살펴보면 대부분의 제어 로직을 가지고 있는 제어 객체 ReservationAgency
가
모든 데이터 객체에 의존한다는 것을 알 수 있다.
이렇게 데이터 중심 설계는 전체 시스템을 하나의 거대한 의존성 덩어리 로 만들어버려서,
어떤 변경이라도 발생하면 시스템 전체가 요동친다 🫢
낮은 응집도
서로 다른 이유로 변경되는 코드가 하나의 모듈 안에 공존할 때 모듈의 응집도가 낮다고 말한다.
따라서 모듈의 응집도를 살펴보기 위해서는 코드를 수정하는 이유를 먼저 살펴봐야 한다.
낮은 응집도는 두 가지 측면에서 설계에 문제를 일으킨다.
- 변경에 이유가 서로 다른 코드들을 하나의 모듈 안에 뭉쳐놓았기 때문에
변경과 아무 상관없는 코드들이 영향을 받게 된다.
어떤 코드를 수정한 후에 아무 상관없던 코드에 문제가 발생하는 것 → 낮은 응집도의 대표 증상 - 하나의 요구사항 변경을 반영하기 위해 동시에 여러 모듈을 수정해야 한다.
어떤 요구사항을 변경하기 위해 하나보다 많은 클래스를 수정해야 하는 것은
설계의 응집도가 낮다는 증거 !
단일 책임 원칙 (Single Responsibility Principle, SRP)
단일 책임 원칙을 요약하면, 클래스는 단 한가지의 변경 이유만 가져야 한다 !
자율적인 객체를 향해
캡슐화를 지켜라
캡슐화는 설계의 제 1 원리 !
객체는 스스로의 상태를 책임져야 하며 외부에서는 인터페이스에 정의된 메소드를 통해서만
상태에 접근할 수 있어야 한다.
여기서 메소드는 단순히 Getter
, Setter
가 아니다.
객체에게 의미 있는 메소드는 객체가 책임져야 하는 무언가를 수행하는 메소드 !
스스로 자신의 데이터를 책임지는 객체
상태와 행동을 객체라는 하나의 단위로 묶는 이유는
객체 스스로 자신의 상태를 처리할 수 있게 하기 위해서다.
객체 내부에 저장되는 데이터보다 객체가 협력에 참여하면서 수행할 책임을 정의하는
오퍼레이션이 더 중요하다 !
따라서 객체를 설계할 때 “이 객체가 어떤 데이터를 포함해야 하는가?” 라는 질문은
두 개의 개별적인 질문으로 분리해야 한다.
- 이 객체가 어떤 데이터를 포함해야 하는가?
- 이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?
영화 예매 시스템에서 ReservationAgency
로 새어나간 데이터에 대한 책임을
실제 데이터를 포함하고 있는 객체로 옮겨보자.
할인 조건을 표현하는 DiscountCondition
을 다시 살펴보자.
앞에서 DiscountCondition
이 관리해야 하는 데이터는 이미 결정해 놓았다.
public class DiscountCondition {
private DiscountConditionType type;
private int sequence;
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
}
이제 이 데이터에 대해 수행할 수 있는 오퍼레이션을 생각해보자.
할인 조건에는 순번 조건과 기간 조건, 두 가지 종류가 있다.
순번 조건인 경우 sequence
를 이용해서 할인 여부를 결정하고,
기간 조건인 경우 dayOfWeek
, startTime
, 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
가 포함하는 데이터를 살펴보면 영화 요금을 계산하는 오퍼레이션과,
할인 여부를 판단하는 오퍼레이션이 필요할 것 같다.
요금을 계산할 때 할인 정책에는 금액할인, 비율할인, 미적용의 세 가지 타입이 있다.
따라서 할인 정책의 타입을 반환하는 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(discountAmount);
}
public Money calculateNoneDiscountedFee() {
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);
}
break;
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);
}
}
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
클래스를 살펴보자.
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)
메소드를 보면
DayOfWeek
타입의 요일 정보와 LocalTime
타입의 시간 정보를 파라미터로 받는 것을 알 수 있다.
이 메소드는 객체 내부에 DayOfWeek
타입의 요일과 LocalTime
타입의 시간 정보가
인스턴스 변수로 포함돼 있다는 사실을 인터페이스를 통해 외부로 노출하고 있다.
만약 DiscountCondition
의 속성을 변경해야 한다면 ?
아마 두 isDiscountable
메소드의 파라미터를 수정하고,
해당 메소드를 사용하는 모든 클라이언트도 함께 수정해야 할 것이다.
내부 구현의 변경이 외부로 퍼져나가는 파급효과(ripple effect) 는 캡슐화가 부족하다는 명백한 증거다.
Movie
역시 캡슐화가 부족하다.
영화 요금을 계산하기 위해 금액 할인, 비율 할인, 할인 미적용의 경우 각각에 호출할
세 가지 메소드를 구현하고 있다.
이 메소드들은 객체 내부의 상태를 그대로 노출하지는 않지만,
할인 정책의 종류를 외부에 드러내고 있다는 점에서 캡슐화가 제대로 되어 있지 않다고 볼 수 있다.
만약 새로운 할인 정책이 추가되거나 제거된다면 ?
역시 이 메소드들에 의존하는 모든 클라이언트가 영향을 받을 것이다.
높은 결합도
캡슐화 위반으로 DiscountCondition
의 내부 구현이 외부로 노출됐기 때문에
Movie
와 DiscountCondition
사이의 결합도는 높을 수 밖에 없다.
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;
}
}
Movie
의 isDiscountable
메소드는 DiscountCondition
목록을 순회하면서
할인 조건의 종류에 따라 적절한 isDiscountable
메소드를 호출한다.
여기서 DiscountCondition
에 대한 어떤 변경이 Movie
에 영향을 미치는지 살펴보자.
DiscountCondition
의 기간 할인 조건 명칭이PERIOD
에서 다른 값으로 변경 시Movie
도 수정DiscountCondition
의 종류가 추가/삭제되면Movie
안의if~else
구문도 수정- 각
DiscountCondition
의 만족 여부를 판단하는 데 필요한 정보가 변경되면Movie
의isDiscountable
메소드로 전달된 파라미터도 수정
이 요소들은 모두 DiscountCondition
의 구현에 속한다.
DiscountCondition
의 인터페이스가 아니라 구현 을 변경하는 경우에도
DiscountCondition
에 의존하는 Movie
도 변경해야 한다는 것은
두 객체 사이 결합도가 높다는 것을 의미한다.
낮은 응집도
이번에는 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);
}
break;
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
의 isDiscountable
메소드로 전달해야 하는 파라미터 종류를 변경해야 하고,
이로 인해 Screening
에서 Movie
의 isDiscountable
메소드를 호출하는 부분도 함께 변경해야 한다.
하나의 변경을 위해 코드 여러 곳을 동시에 변경해야 한다 → 응집도가 낮다.
데이터 중심 설계의 문제점
데이터 중심 설계가 변경에 취약한 이유
- 본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요
- 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정
데이터 중심 설계는 객체의 행동보다 상태에 초점
데이터 주도 설계는 설계를 시작하는 처음부터 데이터에 관해 결정하도록 강요하기 때문에
너무 이른 시기에 내부 구현에 초점을 맞추게 한다.
데이터 중심 관점에서 객체는 단순히 데이터의 집합체.
이 때문에 접근자와 수정자를 과도하게 추가하게 되고,
이 접근자와 수정자는 public 속성과 큰 차이가 없으므로 캡슐화는 완전히 무너진다.
또 데이터를 처리하는 작업과 데이터를 같은 객체 안에 두더라도 데이터에 초점이 맞춰져 있다면
캡슐화가 잘 되기 어렵다.
데이터를 먼저 결정하고 오퍼레이션을 나중에 결정하면,
데이터에 관한 지식이 인터페이스에 드러나게 된다.
데이터 중심 설계는 객체를 고립시킨 채 오퍼레이션 정의
객체지향 어플리케이션을 구현한다는 것은 협력하는 객체들의 공동체를 구축하는 것.
객체지향 설계의 무게 중심은 항상 객체의 내부가 아니라 외부에 맞춰져 있어야 한다 !
정리
- 책임에 초점을 맞춰서 설계하자
- 구현과 인터페이스를 분리하고 변경될만한 구현부를 숨기자 (캡슐화!)
- 높은 응집도와 낮은 결합도 ! (High Cohesion, Low Coupling)
- 필드는 private 후, 무분별한 Getter, Setter 가 캡슐화가 아니다
- 객체가 협력 속에서 수행해야 하는 오퍼레이션을 먼저 생각하자
- 객체지향 설계의 무게 중심은 객체 내부가 아니라 외부다 !