객체지향 설계란 올바른 객체에서 올바른 책임을 할당하면서 낮은 결합도와 높은 응집도를 가진 구조를 창조하는 활동이다.
책임을 할당하는 작업은 응집도와 결합도 같은 설계 품질과 깊이 연관돼 있다.
훌륭한 설계란 합리적인 비용 안에서 변경을 수용할 수 있는 구조를 만드는 것
적절한 비용 안에서 쉽게 변경할 수 있는 설계는 응집도가 높고 서로 느슨하게 결합돼 있는 요소로 구성된다.
합리적인 수준의 응집도와 결합도를 유지하기 위해서는 객체의 상태가 아니라 행동에 초점을 맞춰야 한다.
좋은 설계와 나쁜 설계를 살펴보면서 통찰을 얻을 수 있다.
영화 예매 시스템을 책임이 아닌 상태를 표현하는 데이터 중심의 설계를 살펴보고 객체지향적으로 설계한 구조와 어떤 차이점이 있는지 살펴본다.
1. 데이터 중심의 영화 예매 시스템
데이터 중심의 관점에서 객체는 자신이 포함하고 있는 데이터를 조작하는 데 필요한 오퍼레이션을 정의한다.
책임 중심의 관점에서 객체는 다른 객체가 요청할 수 있는 오퍼레이션을 위해 필요한 상태를 보관한다.
훌륭한 객체지향 설계는 데이터가 아니라 책임에 초점을 맞춰야 한다.
그 이유는 변경과 관련된다.
객체의 상태는 구현에 속하는데 구현은 불안정하기 때문에 변하기 쉽다.
상태를 객체 분할의 중심축으로 삼으면 구현에 관한 세부 사항이 객체의 인터페이스에 스며들게 되어 캡슐화의 원칙이 무너진다.
객체의 책임은 인터페이스에 속한다.
객체는 책임을 드러내는 안정적인 인터페이스 뒤로 책임을 수행하는 데 필요한 상태를 캡슐화 => 구현 변경에 대한 파장이 외부로 퍼져나가는 것을 방지
데이터를 기준으로 분할한 영화 예매 시스템의 설계를 살펴본다.
데이터를 준비하자
데이터 중심의 설계는 객체가 내부에 저장해야 하는 '데이터가 무엇인가'를 묻는 것으로 시작한다.
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;
}
데이터 중심의 Movie 클래스와 책임 중심의 Movie 클래스의 두드러지는 차이점은 할인 조건의 목록이 인스턴스 변수로 Movie 안에 직접 포함돼 있다는 것
또한 할인 정책을 DiscountPolicy라는 별도의 클래스로 분리했던 책임 중심의 분할과 달리 금액 할인 정책에 사용되는 할인 금액(discountAmount)와 비율 할인 정책에 사용되는 할인 비율(discountPercent)을 Movie 안에 직접 정의하고 있다.
할인 정책은 영화별로 오직 하나만 지정할 수 있기 때문에 한 시점에 discountAmount와 discountPercent 중 하나의 값만 사용 가능
할인 정책의 종류를 결정하는 것이 movieType, movieType은 현재 영화에 설정된 할인 정책의 종류를 결정하는 열거형 타입인 MovieType의 인스턴스
public enum MoneyType {
AMOUNT_DISCOUNT, // 금액 할인 정책
PERCENT_DISCOUNT, // 비율 할인 정책
NONE_DISCOUNT // 미적용
}
데이터 중심의 설계에서는 객체가 포함해야 하는 데이터에 집중한다.
Movie 클래스의 경우처럼 객체의 종류를 저장하는 인스턴스 변수와 인스턴스의 종류에 따라 배타적으로 사용될 인스턴스 변수(discountAmount, discountPercent)를 하나의 클래스 안에 함께 포함시키는 방식은 데이터 중심의 설계 안에서 흔히 볼 수 있는 패턴이다.
캡슐화를 위해 접근자와 수정자를 추가
public class Movie {
public MovieType getMovieType() {
return movieType;
}
public void setMovieType(MovieType movieType) {
this.movieType = movieType;
}
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 Money getDiscountAmount() {
return discountAmount;
}
public void setDiscountAmount(Money discountAmount) {
this.discountAmount = discountAmount;
}
public Money getDiscountPercent() {
return discountPercent;
}
public void setDiscountPercent(double discountPercent) {
this.discountPercent = discountPercent;
}
}
할인 조건(DiscountCondition)을 구현해보자
할인 조건을 구현하는데 필요한 데이터는 무엇인가?
현재의 할인 조건의 종류를 저장할 데이터가 필요하다.
할인 조건의 타입을 저장할 DiscountConditionType 선언
public enum DiscountConditionType {
SEQUENCE, // 순번 조건
PERIOD // 기간 조건
}
public class DiscountCondition {
private DiscountConditionType type;
private int sequence;
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
public getSequence() {
return sequence;
}
public setSequence(int sequence) {
this.sequence = sequence;
}
public DiscountConditionType getType(){
return type;
}
punblic void setType(DiscountConditionType type) {
this.type = type;
}
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 클래스 구현
상영을 구현하기 위해 필요한 데이터는 무엇인가?
영화(Movie), 상영 순번(sequence), 상영 시간(whenScreened)이 필요하다.
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 LocalDateTime getWhenScreened(){
return whenScreened;
}
public void setWhenScreened(LocalDateTime whenScreened){
this.whenScreened = whenScreened;
}
public int getSequence() {
return sequence;
}
public void setSequence(int sequence) {
this.sequence = sequence;
}
}
영화 예매 시스템의 목적은 영화를 예매하는 것
Reservation 클래스를 추가
예약을 위해 필요한 데이터는 무엇인가?
예약을 위해선 고객(customer), 상영(screening), 요금(fee), 관람 인원(audienceCount) 데이터가 필요하다.
public class Reservation {
private Customer customer;
private Screening screening;
private Money fee;
private int audienceCount;
public Reservation(Customer customer, Screening screening,
Money fee, int sequence) {
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(Monet fee){
this.fee = fee;
}
public int getAudienceCount() {
return audienceCount;
}
public void setAudienceCount(int audienceCount) {
this.audienceCount = audienceCount;
}
}
public class Customer {
private String name;
private String id;
public Customer(String name, String id){
this.name = name;
this.id = id;
}
}
영화 예매 시스템 구현을 위한 데이터 클래스
영화를 예매하자
데이터 클래스들을 조합해서 영화 예매 절차를 구현하자
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(codition.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();
}
return new Reservation(customer, screening, fee, audienceCount);
}
}
reserve 메서드는 DiscountCondition에 대해 루프를 돌면서 할인 가능 여부를 확인하는 for문과 discountable 변수의 값을 체크하고 적절한 할인 정책에 따라 예매 요금을 계산하는 if문으로 구성된다.
2. 설계 트레이드오프
데이터 중심 설계와 책임 중심 설계의 장단점을 비교하기 위해 설계의 세 가지 품질 척도인 캡슐화, 응집도, 결합도를 사용
캡슐화
객체를 사용하면 변경 가능성이 높은 부분은 내부에 숨기고 외부에는 상대적으로 안정적인 부분만 공개함으로써 변경의 여파를 통제할 수 있다.
변경될 가능성이 높은 부분은 구현, 상대적으로 안정적인 부분은 인터페이스
객체 설계를 위한 가장 기본적인 아이디어: 변경의 정도에 따라 구현과 인터페이스를 분리, 외부에서는 인터페이스에만 의존하도록 관계 조절
객체지향에서 가장 중요한 원리는 캡슐화, 객체지향 설계의 가장 중요한 원리는 불안정한 구현 세부사항을 안정적인 인터페이스 뒤로 캡슐화하는것
캡슐화는 외부에서 알 필요가 없는 부분을 감춤으로써 대상을 단순화하는 추상화의 종류
객체지향 언어를 사용한다고 해서 애플리케이션의 복잡성이 잘 캡슐화될 것이라고 보장할 수는없다.
객체지향 프로그래밍을 통해 전반적으로 얻을 수 있는 장점은 오직 설계 과정 동안 캡슐화를 목표로 인식할 때만 달성될 수 있다.
변경될 수 있는 어떤 것이라도 캡슐화해야 한다.
캡슐화는 유지 보수성이 목표다.
응집도와 결합도
응집도는 모듈에 포함된 내부 요소들이 연관돼 있는 정도를 나타낸다.
객체지향의 관점에서 응집도는 객체 또는 클래스에 얼마나 관련 높은 책임들을 할당했는지를 나타낸다.
결합도는 의존성의 정도를 나타내며 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도
어떤 모듈이 다른 모듈에 대해 너무 자세한 부분까지 알고 있다면 두 모듈은 높은 결합도를 갖는다.
객체지향의 관점에서 결합도는 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계만을 유지하고 있는지를 나타낸다.
좋은 설계 = 높은 응집도와 낮은 결합도를 가진 모듈로 구성된 설계
좋은 설계의 애플리케이션 = 애플리케이션을 구성하는 각 요소의 응집도가 높고 서로 느슨하게 결합
좋은 설계는 변경과 관련되기 때문에 설계의 품질을 결정하는 응집도와 결합도도 자연스레 변경과 관련된 것이다.
높은 응집도와 낮은 결합도는 설계를 변경하기 쉽게 만든다.
응집도가 높은 설계에서는 하나의 요구사항 변경을 반영하기 위해 오직 하나의 모듈만 수정하면된다.
응집도가 낮은 설계에서는 하나의 원인에의해 변경해야 하는 부분이 다수의 모듈에 분산돼있기 때문에 여러 모듈을 동시에 수정해야 한다.
결합도 역시 변경의 관점에서 설명할 수 있다.
결합도는 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도로 측정할 수 있다.
결합도가 높으면 높을수록 함께 변경해야 하는 모듈의 수가 늘어나기 때문에 변경하기가 어려워진다.
영향을 받는 모듈의 수 외에도 변경의 원인을 이용해 결합도의 개념을 설명할 수도 있다.
내부 구현을 변경했을 때 이것이 다른 모듈에 영향을 미치는 경우에는 두 모듈 사이의 높다고 표현할 수 있다.
퍼블릭 인터페이스를 수정했을 때만 다른 모듈에 영향을 미치는 경우에는 결합도가 낮다고 표현
클래스의 구현이 아닌 인터페이스에 의존하도록 코드를 작성해야 낮은 결합도를 얻을 수 있다.
"인터페이스에 대해 프로그래밍하라"
캡슐화의 정도가 응집도와 결합도에 영향을 미친다.
캡슐화를 시키면 모듈 안의 응집도는 높아지고 모듈 사이의 결합도는 낮아진다.
3. 데이터 중심의 영화 예매 시스템의 문제점
데이터 중심의 설계는 캡슐화를 위반하고 객체의 내부 구현을 인터페이스의 일부로 만든다.
책임 중심의 설계는 객체의 내부 구현을 안정적인 인터페이스 뒤로 캡슐화한다.
데이터 중심의 설계가 가진 대표적인 문제점: 캡슐화 위반, 높은 결합도, 낮은 응집도
캡슐화 위반
데이터 중심으로 설계한 Movie 클래스
오직 메서드를 통해서만 객체의 내부 상태에 접근할 수 있다.
public class Movie {
private Money fee;
public Money getFee(){
return fee;
}
public void setFee(Money fee){
this.fee = fee;
}
}
Movie 클래스 타입의 객체의 내부에 직접 접근할 수 없기 때문에 캡슐화의 원칙을 지키고 있는 것처럼 보이지만 접근자와 수정자 메서드는 객체 내부의 상태에 대한 어떤 정보도 캡슐화하지 못한다.
getFee 메서드와 setFee 메서드는 Movie 내부에 Money 타입의 fee라는 이름의 인스턴스 변수가 존재한다는 사실을 퍼블릭 인터페이스에 노골적으로 드러낸다.
설계할 때 협력에 관해 고민하지 않으면 캡슐화를 위반하는 과도한 접근자와 수정자를 가지게 되는 경향이 있다.
접근자와 수정자에 과도하게 의존하는 설계 방식 = 추측에 의한 설계 전략
높은 결합도
객체 내부의 구현이 객체의 인터페이스에 드러난다는 것은 클라이언트가 구현에 강하게 결합된다는 것을 의미한다. => 객체의 내부 구현 변경은 이 인터페이스에 의존하는 모든 클라이언트들도 함께 변경해야 한다.
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();
}
...
}
}
Movie의 인스턴스 변수 fee의 타입을 변경한다고 해보자
Movie의 getFee 메서드의 반환 타입도 함께 수정해야 하고, getFee 메서드를 호출하는 ReservationAgency의 구현도 변경된 타입에 맞게 함께 수정해야 한다.
Movie의 fee 타입 변경으로 인해 협력하는 클래스가 변경되기 때문에 getFee 메서드는 fee를 정상적으로 캡슐화하지 못한다.
결합도 측면에서 데이터 중심 설계가 가지는 또 다른 단점은 여러 데이터 객체들을 사용하는 제어 로직이 특정 객체 안에 집중되기 때문에 하나의 제어 객체가 다수의 데이터 객체에 강하게 결합된다는 것
너무 많은 대상에 의존하여 변경에 취약한 ReservationAgency
낮은 응집도
서로 다른 이유로 변경되는 코드가 하나의 모듈 안에 공존할 때 모듈의 응집도가 낮다고 말한다.
ReservationAgency의 변경 이유가 될 수 있는 수정사항
할인 정책이 추가되는 경우
할인 정책 별로 할인 요금을 계산하는 방법이 변경될 경우
할인 조건이 추가되는 경우
할인 조건별로 할인 여부를 판단하는 방법이 변경될 경우
예매 요금을 계산하는 방법이 변경될 경우
낮은 응집도는 두 가지 측면에서 설계에 문제를 일으킨다.
변경의 이유가 서로 다른 코드들을 하나의 모듈 안에 뭉쳐놓았기 때문에 변경과 아무 상관이 없는 코드들이 영향을 받게 된다.
응집도가 낮을 경우 다른 모듈에 위치해야 할 책임의 일부가 엉뚱한 곳에 위치하게 되기 때문에 하나의 요구사항 변경을 반영하기 위해 동시에 여러 모듈을 수정해야 한다.
현재의 설계는 새로운 할인 정책을 추가하거나 새로운 할인 조건을 추가하기 위해 하나 이상의 클래스를 동시에 수정해야 한다.
단일 책임 원칙
단일 책임 원칙은 클래스는 단 한 가지의 변경 이유만 가져야 한다는 원칙이다.
단일 책임 원칙이 클래스의 응집도를 높일 수 있다.
단일 책임 원칙이라는 맥락에서 '책임'이라는 말은 '변경의 이유'라는 의미로 사용된다.
단일 책임 원칙에서의 책임은 지금까지 살펴본 역할, 책임, 협력에서의 책임과는 다른 개념이다.
4. 자율적인 객체를 향해
캡슐화를 지켜라
객체는 스스로의 상태를 책임져야 하며 외부에서는 인터페이스에 정의된 메서드를 통해서만 상태에 접근할 수 있어야 하는데 메서드는 단순히 속성 하나의 값을 반환하거나 변경하는 접근자나 수정자를 의미하는 것이 아니다.
객체에게 의미 있는 메서드는 객체가 책임져야 하는 무언가를 수행하는 메서드다.
속성의 가시성을 private으로 설정했다고 해도 접근자와 수정자를 통해 속성을 외부로 제공하고 있다면 캡슐화를 위반하는 것이다.
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 int getLeft() { return left; }
public void setLeft(int left) { this.left = left; }
public int getTop() { return top; }
public void setTop() { this.top = top; }
public int getRight() { return right; }
public void setRight(int right) { this.right = right; }
public int getBottom() { return bottom; }
public void setBottom(int bottom) { this.bottom = bottom; }
}
Rectangle 클래스를 이용하는 외부 클래스 AnyClass
class AnyClass {
void anyMethod(Rectangle rectangle, int multiple){
rectangle.setRight(rectangle.getRight * multiple);
rectangle.setBottom(rectangle.getBottom * multiple);
...
}
}
첫 번째 문제점 : 코드 중복이 발생할 확률이 높다.
다른 곳에서도 사각형의 너비와 높이를 증가시키는 코드가 필요하면 그곳에서도 getRight와 getBottom 메서드를 호출하고 수정자 메서드를 이용해 값을 설정하는 유사한 코드가 존재할 것
두 번째문제점 : 변경에 취약하다.
Rectangle이 right와 bottom 대신 length와 height를 이용해서 사각형을 표현하도록 수정한다고 가정
getRight, setRight, getBottom, setBottom 메서드를 getLength, setLength, getHeight, setHeight로 변경해야 하고, 이 변경은 기존의 접근자 메서드를 사용하던 모든 코드에 영향을 미친다.
해결 방법 : 캡슐화 강화
Rectangle을 변경하는 주체를 외부의 객체에서 Rectangle로 이동시킨다.
자신의 크기를 Rectangle 스스로 증가시키도록 책임을 이동시키는 것이다. => 객체가 자기 스스로를 책임진다
class Rectangle {
public void enlarge(int multiple) {
right *= multiple;
bottom *= multiple;
}
}
스스로 자신의 데이터를 책임지는 객체
상태와 행동을 객체라는 하나의 단위로 묶는 이유는 객체 스스로 자신의 상태를 처리할 수 있게 하기 위해서다.
객체는 단순한 데이터 제공자가 아니다.
객체 내부의 데이터보다 객체가 협력에 참여하면서 수행할 책임을 정의하는 오퍼레이션이 더 중요하다.
객체 설계 시 "이 객체가 어떤 데이터를 포함해야 하는가?"라는 질문은 두 개의 개별적인 질문으로 분리해야 한다.
이 객체가 어떤 데이터를 포함해야 하는가?
이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?
ReservationAgency로 새어나간 데이터에 대한 책임을 실제 데이터를 포함하고 있는 객체로 옮겨보자
할인 조건을 표현하는 DiscountCondition에서 시작해보자
첫 번째 질문 : DiscountCondition은 어떤 데이터를 관리해야 하는가?
public class DiscountCondition {
private DiscountConditionType type;
private int sequence;
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
}
두 번째 질문 : 이 데이터에 대해 수행할 수 있는 오퍼레이션이 무엇인가?
DiscountCondition은 DiscountConditionType이 순번 조건일 경우에는 sequence를 이용해서 할인 여부를 결정하고, 기간 조건일 경우에는 dayOfWeek, startTime, endTime을 이용해 할인 여부를 결정한다.
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 discountPercent;
}
두번째 질문 : 포함한 데이터를 처리하기 위해 어떤 오퍼레이션이 필요한가?
영화 요금을 계산하는 오퍼레이션과 할인 여부를 판단하는 오퍼레이션이 필요하다.
할인 정책의 타입을 반환하는 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(discountPercent));
}
public Money calculateNoneDiscountedFee() {
if(movieType != MovieType.NONE_DISCOUNT) {
throw new IllegalArgumentException();
}
return fee;
}
}
할인 여부를 판단하는 isDiscountable 메서드
기간 조건을 판단하기 위해 필요한 whenScreened와 순번 조건의 만족 여부를 판단하는 데 필요한 sequence를 isDiscountable 메서드의 파라미터로 전달
public class Movie {
public boolean isDiscountable(LocalDateTime whenScreened, int sequence){
for(DiscountConditoin 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);
}
}
ReservationAgency 구현
public class ReservationAgency {
public Reservation reserve(Screening screening, Customer customer,
int audienceCount) {
Money fee = screening.calculateFee(audienceCount);
return new Reservation(customer, screening, fee, audienceCount);
}
}
개선된 설계에서는 데이터를 처리하는 데 필요한 메서드를 데이터를 가지고 있는 객체 스스로 구현한다.
5. 하지만 여전히 부족하다
두 번째 설계 역시 본질적으로 데이터 중심의 설계 방식에 속한다
아직 해결되지 못한 부분이 있다.
캡슐화 위반
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) 메서드와 isDiscountable(int sequence) 메서드는 DayOfWeek, LocalTime, int 타입의 데이터가 인스턴스 변수로 포함돼 있다는 사실을 인터페이스를 통해 외부에 노출한다.
getType 메서드를 통해 내부에 DiscountConditionType을 포함하고 있다는 정보 역시 인터페이스에 노출
DiscountCondition의 속성을 변경해야한다면 두 isDiscountable 메서드의 파라미터를 수정하고 해당 메서드를 사용하는 모든 클라이언트도 함께 수정해야 한다.
Movie 역시 캡슐화가 부족, 내부 구현을 인터페이스에 노출시키고 있다.
Movie 요금 계산 메서드들은 객체의 파라미터나 반환 값으로 내부에 포함된 속성에 대한 어떤 정보도 노출하지 않는다
하지만 calculateAmountDiscountedFee, calculatePercentDiscountedFee, calculateNoneDiscountedFee라는 세 개의 메서드는 이름에서 할인 정책의 종류를 노출시키고 있다.
새로운 할인 정책이 추가되거나 기존 할인 정책이 제거된다면 이 메서드들에 의존하는 모든 클라이언트가 영향을 받을 것이다.
캡슐화의 진정한 의미
캡슐화는 단순히 객체 내부의 데이터를 외부로부터 감추는 것 이상의 의미를 갖는다.
캡슐화의 진정한 의미는 변할 수 있는 어떤 것이라도 감추는 것을 말한다.
높은 결합도
캡슐화 위반으로 인해 DiscountCondition의 내부 구현이 외부로 노출됐기 때문에 Movie와 DiscountCondition 사이의 결합도는 높을 수 밖에 없다.
Movie에게까지 영향을 미치는 DiscountCondition에 대한 변경
DiscountCondition의 기간 할인 조건의 명칭이 PERIOD에서 다른 값으로 변경된다면 Movie를 수정해야 한다.
DiscountCondition의 종류가 추가되거나 삭제된다면 Movie안의 if ~ else 구문을 수정해야 한다.
각 DiscountCondition의 만족 여부를 판단하는 데 필요한 정보가 변경된다면 Movie의 isDiscountable 메서드로 전달된 파라미터를 변경해야 한다. 이는 Movie의 이 메서드에 의존하는 Screening에 대한 변경을 초래한다.
캡슐화의 원칙을 지키지 않았기 때문에 결합도가 높아지는 문제가 발생하는 것이다.
낮은 응집도
DiscountCondition이 할인 여부를 판단하는데 필요한 정보가 변경된다면 Movie의 isDiscountable 메서드의 파라미터 종류 뿐만 아니라 Screening에서 Movie의 isDiscountable 메서드를 호출하는 부분도 함께 변경
하나의 변경을 수용하기 위해 코드의 여러 곳을 동시에 변경해야 한다는 것은 설계의 응집도가 낮다는 증거다.
DiscountCondition이나 Movie에 위치해야하는 로직이 Screening으로 새어나왔기 때문에 DiscountCondition과 Movie의 내부 구현이 인터페이스에 그대로 노출되고 Screening은 그런 노출된 구현에 직접적으로 의존하고 있다.
6. 데이터 중심 설계의 문제점
캡슐화를 위반하는 데이터 중심의 설계가 변경에 취약한 두 가지 이유
데이터 중심의 설계는 본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요
데이터 중심의 설계에서는 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정
데이터 중심 설계는 객체의 행동보다는 상태에 초점을 맞춘다
데이터는 구현의 일부이고 데이터 주도 설계는 너무 이른 시기에 이런 내부 구현에 초점을 맞추게 한다.
데이터 중심의 관점에서 객체는 그저 단순한 데이터의 집합체 => 접근자와 수정자를 과도하게 추가, 데이터 객체를 사용하는 절차를 분리된 별도의 객체 안에 구현하게 된다. => 첫 번째 설계가 실패한 이유
데이터를 먼저 결정하고, 데이터를 처리하는 데 필요한 오퍼레이션을 나중에 결정하는 방식은 데이터에 관한 지식이 객체의 인터페이스에 고스란히 드러나게 된다.
인터페이스는 구현을 캡슐화하는 데 실패 => 변경에 취약한 코드 => 두 번째 설계가 실패한 이유
데이터 중심 설계는 객체를 고립시킨 채 오퍼레이션을 정의하도록 만든다
데이터 중심 설계에서 초점은 객체의 외부가 아니라 내부로 향한다.
실행 문맥에 대한 깊이 있는 고민 없이 객체가 관리할 데이터의 세부 정보를 먼저 결정한다.
구현이 결정된 상태에서 다른 객체와의 협력 방법을 고민하기 때문에 이미 구현된 객체의 인터페이스를 협력에 억지로 끼워맞출 수밖에 없는 것이다.
두 번째 설계에서 객체의 인터페이스에 구현이 노출돼 있었기 때문에 협력이 구현 세부 사항에 종속되고 그에 따라 객체 내부 구현의 변경이 발생할 때 협력하는 객체 모두가 영향을 받았던 것