오브젝트 - 4. 설계 품질과 트레이드오프
협력
메시지를 주고받는 객체들 사이의 상호작용
책임
객체가 다른 객체와 협력하기 위해 수행하는 행동
역할
대체가능한 책임의 집합
객체지향 설계란
올바른 객체에게 올바른 책임을 할당하면서 낮은 결합도와 높은 응집도를 가진 구조를 창조하는 활동
설계는 변경을 위해 존재, 변경은 어떤식으로든 비용이 발생한다. 훌륭한 설계란 합리적인 비용안에서 변경을 수용할 수 있는 구조를 만드는 것. (낮은 결합도와 높은 응집도). 이를 위해서는 객체의 상태가 아닌 행동에 초점을 둬야한다(설계의 중심을 객체의 상태 -> 행동 -> 상호작용 으로 이동).
데이터 중심의 영화 예매 시스템
객체의 상태는 구현에 한다. 구현은 불안정하기 때문에 변하기 쉽다.
상태를 객체 분할의 중심축으로 삼으면 구현에 관한 세부사항이 객체의 인터페이스에 스며들게 되어 캡슐화의 원칙이 무너진다.
반면 객체의 책임은 인터페이스에 속한다.
반면교사로 삼을 예시 소개.
데이터 중심의 설계란 객체 내부에 저장되는 데이터를 기반으로 시스템을 분할하는 방법
- 캡슐화를 위반하고 객체의 내부 구현을 인터페이스의 일부로 만든다. (책임 중심 설계는 내부 구현을 안정적인 인터페이스 뒤로 캡슐화한다.)
Movie Class
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountConditions; // 할인 조건의 목록이 인스턴스 변수로 Movie 안에 직접 포함
// 할인 정책: 할인 금액과 할인 비율을 직접정의
private MovieType movieType;
private Money discountAmount; // movieType 이 AMOUNT_DISCOUNT 인 경우 사용
private double discountPercent; // movieType 이 PERCENT_DISCOUNT 인 경우 사용
// 객체지향의 가장 중요한 원칙은 캡슐화이므로 내부 데이터가 객체의 엷은 막을 빠져나가 외부의 다른 객체들을 오염시키는것을 막아야한다.
// 접근자(accessor) 와 수정자(mutator) 를 추가하는 방법이 있다.
public MovieType getMovieType () {
return this.movieType;
}
public void setMovieType(MovieType movieType) {
this.movieType = movieType;
}
public Money getFee() {
return this.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 this.discountAmount;
}
public void setDiscountAmount (Money discountAmount) {
this.discountAmount = discountAmount;
}
public double getDiscountPercent() {
return discountPercent;
}
public void setDiscountPercent(double discountPercent) {
this.discountPercent = discountPercent;
}
}
public enum MovieType {
AMOUNT_DISCOUNT, // 금액 할인 정책
PERCENT_DISCOUNT, // 비율 할인 정책
NONE_DISCOUNT // 미적용
}
캡슐화 위반
직접 객체 내부에 접근할 수는 없지만
- getFee, setFee 메서드는 Movie 내부에 Money 타입의 fee 라는 인스턴스가 존재한다는 사실을 퍼블릭 인터페이스에 드러낸다.
DiscountCondtion Class
할인조건은 순번 조건, 기간 조건이 존재
public enum DiscountConditionType {
SEQUENCE, // 순번 조건
PERIOD, // 기간 조건
}
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 DayOfWeek getDayOfWeek() {
return dayOfWeek;
}
public void setDayOfWeek(DayOfWeek dayOfWeek) {
this.dayOfWeek = dayOfWeek;
}
public LocalTime getStartTime() {
return this.startTime;
}
public void setStartTime (LocalTime startTime) {
this.startTime = startTime;
}
public LocalTime getEndTime() {
return this.endTime;
}
public void setEndTime(LocalTime endTime) {
this.endTime = endTime;
}
public int getSequence () {
return this.sequence;
}
public void setSequence (int sequence) {
this.sequence = sequence;
}
}
Screening Class
public class Screening {
private Movie movie;
private int sequence;
private LocalDateTime whenScreened;
public Movie getMovie() {
return this.movie
};
public void setMovie(Movie movie) {
this.movie = movie;
}
public LocalDateTime getWhenScreened () {
return this.whenScreened;
}
public void setWhenScreened (LocalDateTime whenScreened) {
this.whenScreened = whenScreened;
}
public int getSequence () {
return this.sequence;
}
public void setSequence(int sequence) {
this.sequence = sequence;
}
}
Reservation Class
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 this.customer;
}
public void setCustomer( Customer customer) {
this.customer = customer;
}
public Screening getScreening() {
return this.screening;
}
public void setScreening ( Screening screening ) {
this.screening = screening;
}
public Money getFee() {
return this.fee;
}
public void setFee(Money 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.id = id;
this.name = name;
}
}
ReservationAgency Class
데이터클래스들을 조합해서 영화예매 절차를 구현하는 클래스
public class ReservationAgency {
// Movie 에 설정된 DiscountCondition 목록을 차례대로 탐색하면서 영화의 할인 여부를 판단.
public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
Movie movie = screening.getMovie();
// 할인 조건 판단, 할인 정책 선택 코드가 공존
// 할인 조건을 판단하는 코드. 할인 조건을 새로 추가한다면, DiscountConditionType 에 새로운 할인 조건 값을 추가,
// 조건 판단에 필요한 데이터들을 DiscountCondition 에 추가, if 조건식 수정 필요
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();
}
}
Money fee;
if(discountable) {
Money discountAmount = Money.ZERO;
// 할인 정책을 선택하는 코드. 할인정책을 추가해야된다면 MovieType 에 열거형 값을 추가하고, switch 구문에 새로운 타입에 대한 분기를 추가해야한다.
// 또한 새로운 할인 정책에 따라 할인 요금을 계산하기 위해 필요한 데이터도 Movie 에 추가해야한다.
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 = move.getFee();
}
return new Reservation(customer, screening, fee, audienceCount);
}
}
높은 결합도
객체 내부 구현인 인터페이스에 드러난다는 것은 클라이언트가 구현에 강하게 결합된다는 것을 의미
- 한명의 예매 요금을 계산하기 위해 Movie getFee 를 호출하며 Money 타입의 fee 에 저장한다. 만약 fee 의 타입을 변경하면, getFee 메서드의 반환타입을 함께 수정하며, ReservationAgency 의 구현도 변경된 타입에 맞게 수정 필요. 즉 클라이언트가 객체의 구현에 강결합.
- 여러 데이터 객체를 사용하는 제어로직이 특정 객체 안에 집중-> 하나의 제어 객체가 다수의 데이터 객체에 강결합된다. 어떤 데이터 객체를 변경하더라도 제어객체를 함께 변경할 수 밖에 없다.
낮은 응집도
서로다른 이유로 변경되는 코드가 하나의 모듈안에 공존
- 할인 정책이 추가되는 경우
- 할인 정책 별로 할인요금을 계산하는 방법이 변경될 경우
- 할인 조건이 추가되는 경우
- 할인 조건 별로 할인 여부를 판단하는 방법이 변경되는 경우
- 예매 요금을 계산하는 방법이 변경될경우
변경의 이유가 서로다른 코드들을 하나의 모듈안에 뭉쳐놓았기 때문에 변경과 아무 상관이 없는 코드들이 영향을 받게된다.
설계 트레이드 오프
설계의 품질을 측정하기 위한 척도: 캡슐화, 응집도, 결합도
캡슐화
- 외부에서 알 필요가 없는 부분을 외부로부터 감춤으로서 대상을 단순화하는 추상화 기법
- 외부로부터 알 필요가 없는 부분: 객체의 내부 구현
구현이란?
- 나중에 변경될 가능성이 높은 어떤 것
-
인터페이스
- 상대적으로 안정적인 부분
객체를 설계하기 위한 가장 기본적인 아이디어는 변경의 정도에 따라 구현과 인터페이스를 분리하고 외부에서는 인터페이스에만 의존하도록 관계를 조절하는 것이다.
객체를 사용하면 변경 가능성이 높은 부분은 내부에 숨기고 외부에는 상대적으로 안정적인 부분만 공개함으로서 변경의 여파를 통제
유지보수성
- 두려움 없이, 주저함 없이 저항감 없이 코드를 변경할 수 있는 능력
응집도와 결합도
응집도
- 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도
- 모듈 내의 요소들이 하나의 목적을 위해 긴밀하게 협력한다면 그 모듈은 높은 응집도를 가진다.
- 응집도가 높은 설계에서는 하나의 요구사항 변경을 반영하기 위해 오직 하나의 모듈만 수정하면된다. 변경의 대상과 범위가 명확
결합도
- 한 모듈이 변경되기 위해 다른 모듈의 변경을 요구하는 정도
- 어떤 모듈이 다른 모듈에 대해 너무 자세한 부분까지 알고 있다면 두모듈은 높은 결합도를 가진다. 즉 내부 구현을 변경했을때 이것이 다른 모듈에 영향을 미치는 경우
- 퍼블릭 인터페이스를 수정했을 때만 다른 모듈에 영향을 미치는 경우에는 결합도가 낮다.
- 변경될 확률이 매우 적은 안정적인 모듈에 의존하는 것은 아무런 문제가 되지 않는다.
일반적으로 좋은 설계란
- 높은 응집도와 낮은 결합도를 가진 모듈로 구성된 설계
- 오늘의 기능을 수행하면서 내일의 변경을 수용할 수 있는 설계
캡슐화의 정도가 응집도와 결합도에 영향을 미친다.
단일 책임의 원칙
책임이라는 말은 변경의 이유라는 의미로 사용된다.
자율적인 객체를 향해
캡슐화는 설계의 제 1원리이다.
데이터 중심의 설계는 캡슐화를 위반한다.
객체는 자신이 어떤 데이터를 가지고 있는지를 내부에 캡슐화하고 외부에 공개해서는 안된다.
속성의 가시성을 private 으로 설정했다고 해도 접근자나 수정자를 통해 외부로 제공하고 있다면 캡슐화를 위반하는 것이다.
우리가 상태와 행동을 객체라는 하나의 단위로 묶는 이유는 객체 스스로 자신의 상태를 처리할 수 있게 하기 위함.
객체는 단순히 데이터 제공자가 아니다. 협력에 참여하면서 수행할 책임을 정의하는 오퍼레이션이 더 중요
데이터 중심의 설계이지만, 조금더 개선된 예시
- 객체가 어떤 데이터를 포함해야하는가?
- 객체가 데이터에 대해 수행해야하는 오퍼레이션은 무엇인가?
DiscountCondition Class
public enum DiscountConditionType {
SEQUENCE, // 순번 조건
PERIOD, // 기간 조건
}
public class DiscountCondition {
// 포함해야 되는 데이터가 무엇인가?
private DiscountConditionType type;
private int sequence; // 순번 조건에서만 사용되는 상영순번
// 기간 조건에서만 사용
private DayOfWeek dayOfWeek; // 요일
private LocalTime startTime;
private LocalTime endTime;
// 수행해야하는 오퍼레이션이란 무엇인가?
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;
}
}
캡슐화 위반
- isDiscountable 의 시그니쳐를 보면, DayofWeek, LocalTime 정보를 인자로 받는다. 객체 내부에 DayOfWeek 타입의 요일과 LocalTime 타입의 시간정보가 인스턴스 변수로 포함돼 있다는 사실을 인터페이스를 통해 외부에 노출. int sequence 도 마찬가지
- DiscountCondtion 의 속성을 변경해야한다면 isDiscountable 의 파라미터를 수정하고 메서드를 사용하는 모든 클라이언트를 수정
Movie Class
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountConditions; // 할인 조건의 목록이 인스턴스 변수로 Movie 안에 직접 포함
// 할인 정책: 할인 금액과 할인 비율을 직접정의
private MovieType movieType;
private Money discountAmount; // movieType 이 AMOUNT_DISCOUNT 인 경우 사용
private double discountPercent; // movieType 이 PERCENT_DISCOUNT 인 경우 사용
// 요금을 계산하는 오퍼레이션. 할인정책을 염두에 둬야한다.(금액할인, 비율할인, 미적용)
public MovieType getMovieType() {
return movieType;
}
public Money calculateAmountDiscountedFee() {
if(this.movieType != MovieType.AMOUNT_DISCOUNT) {
throw new IllegalArgumentException();
}
return fee.minus(discountAmount);
}
public Money calculatePercentDiscountFee() {
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;
}
// DiscountCondition 목록을 포함하기 때문에 할인 여부를 판단하는 오퍼레이션 역시 포함
// 기간 할인 조건의 명칭이 바뀐다면 Movie를 수정해야한다
// DiscountCondition 의 종류가 추가, 삭제되면 if else 문을 변경해야한다.
// DiscountCondition 의 만족여부를 판단하는데 필요한정보(ex dayOfWeek) 가 변경된다면 파라미터를 변경해야한다.
// => DiscountCondition 의 인터페이스가 아닌 구현을 변경하는 경우에도 DiscountCondition 에 의존하는 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;
}
}
}
public enum MovieType {
AMOUNT_DISCOUNT, // 금액 할인 정책
PERCENT_DISCOUNT, // 비율 할인 정책
NONE_DISCOUNT // 미적용
}
캡슐화 위반
- 요금을 계산하는 세가지 메서드는 할인 정책에 대한 구현을 외부로 그대로 드러내고 있다. (금액할인 ,비율할인, 미적용)
- 만약 새로운 할인정책이 추가되거나 제거된다면?
높은 결합도
- DiscountCondition 의 내부 구현 (isDiscountable 의 시그니쳐를 보았을때 내부 구현을 알게된다)이 외부로 노출. Movie와 DiscountCondition 간의 결합도는 높을 수 밖에없다.
Screening Class
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);
}
}
낮은 응집도
- DiscountCondition 이 할인 여부를 판단하는데 필요한 정보(ex dayOfWeek, sequence) 가 변경된다면 Movie의 isDiscountable 로 전달해야하는 파라미터 종류도 변경해야한다.
- 캡슐화를 위반했기 때문. DiscountConition 과 Movie 내부 구현이 인터페이스에 그대로 노출되고 있고, Screening 은 노출된 구현에 직접 의존
ReservationAgency Class
public class ReservationAgency {
// Movie 에 설정된 DiscountCondition 목록을 차례대로 탐색하면서 영화의 할인 여부를 판단.
public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
Money fee = screening.calcuateFee(audienceCount);
return new Reservation(customer, screening, fee, audienceCount);
}
}
데이터 중심 설계의 문제점
- 너무 이른 시기에 데이터에 관해 결정
데이터는 구현의 일부이다. 설계를 시작하는 처음부터 데이터에 관해 결정하도록 강요
- 데이터와 기능을 분리하는 절차적 프로그래밍 방식
- 상태와 행동을 하나의 단위로 캡슐화하는 객체지향 패러다임에 반하는 것
오펴레이션을 나중에 결정하는 방식은 데이터에 관한 지식이 객체의 인터페이스에 고스란히 드러난다.
- 협력이라는 문맥을 고려하지 않고 객체를 고립시킨채 오퍼레이션을 결정
객체지향 설계는 곧 협력하는 객체들의 공동체를 구축
객체가 내부에 어떤 상태를 가지고 그 상태를 어떻게 관리하는 가는 부가적인 문제
객체의 구현이 이미 결정된 상태에서 다른 객체와의 협력 방법을 고민 - 이미 구현된 객체의 내부 구현이 변경되었을때 협력하는 객체 모두 영향