객체지향 설계란 올바른 객체에게 올바른 책임을 할당하면서 낮은 결합도와 높은 응집도를 가진 구조를 창조하는 활동
- 객체지향 설계의 핵심은 책임
- 책임을 할당하는 작업이 응집도와 결합도 같은 설계 품질과 깊이 연관되어 있음
📖 4.1 데이터 중심의 영화 예매 시스템
객체지향 설계에서는 두 가지 방법을 이용해 시스템을 객체로 분할할 수 있다.
- 상태(데이터)를 분할의 중심축으로 삼는 방법
- 객체의 상태는 객체가 저장해야 하는 데이터의 집합
- 변경에 취약하다.
- 책임을 분할의 중심축으로 삼는 방법
- 이 방법이 훌륭한 객체지향 설계 방법이다.
- 변경에 안정적이다.
🔖 4.1.1 데이터를 준비하자
데이터 중심의 설계란 객체 내부에 저장되는 데이터를 기반으로 시스템을 분할하는 방법
/**
* 데이터 중심 설계 영화 Class
*/
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 enum MovieType {
/**
* 금액 할인 정책
*/
AMOUNT_DISCOUNT,
/**
* 비율 할인 정책
*/
PERCENT_DISCOUNT,
/**
* 미적용
*/
NONE_DISCOUNT
}
- 데이터 중심의 설계에서는 객체가 포함해야 하는 데이터에 집중한다.
- 내부 데이터가 객체의 엷은 막을 빠져나가 외부의 다른 객체들을 오염시키는 것을 막아야 한다.
- 내부의 데이터 반환하는 접근자(accessor)
- 데이터를 변경하는 수정자(mutator)
/**
* 데이터 중심 설계 영화 Class
*/
public class Movie {
/**
* 제목
*/
private String title;
/**
* 상영 시간
*/
private Duration runningTime;
/**
* 기본 요금
*/
@Getter
@Setter
private Money fee;
/**
* 할인 조건 목록
*/
@Getter
@Setter
private List<DiscountCondition> discountConditions;
/**
* 할인 정책의 종류를 결정하는 변수
*/
@Getter
@Setter
private MovieType movieType;
/**
* 할인 금액
*/
@Getter
@Setter
private Money discountAmount;
/**
* 할인 비율
*/
@Getter
@Setter
private double discountPercent;
}
@Setter
@Getter
public enum DiscountConditionType {
/**
* 순번 조건
*/
SEQUENCE,
/**
* 기간 조건
*/
PERIOD
}
/**
* 할인 조건 class
*/
public class DiscountCondition {
/**
* 할인 조건 타입
*/
private DiscountConditionType type;
/**
* 상영 순번
*/
private int sequence;
/**
* 요일
*/
private DayOfWeek dayOfWeek;
/**
* 시작 시간
*/
private LocalTime startTime;
/**
* 종료 시간
*/
private LocalTime endTime;
}
/**
* 상영 Class
*/
@Setter
@Getter
public class Screening {
/**
* 영화
*/
private Movie movie;
/**
* 순번
*/
private int sequence;
/**
* 상영 시작 시간
*/
private LocalDateTime whenScreened;
}
/**
* 예매 정보 Class
*/
@Setter
@Getter
@AllArgsConstructor
public class Reservation {
/**
* 고객
*/
private Customer customer;
/**
* 상영
*/
private Screening screening;
/**
* 예매 요금
*/
private Money fee;
/**
* 인원 수
*/
private int audienceCount;
}
/**
* 고객 class
*/
@AllArgsConstructor
public class Customer {
private String name;
private String id;
}
🔖 4.1.2 영화를 예매하자
/**
* 영화 예매 절차 구현 Class
*/
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().isAfter(screening.getWhenScreened().toLocalTime()) &&
!condition.getEndTime().isBefore(screening.getWhenScreened().toLocalTime());
} 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.getDiscountPercent());
case NONE_DISCOUNT -> discountAmount = Money.ZERO;
}
fee = movie.getFee().minus(discountAmount);
} else {
fee = movie.getFee();
}
return new Reservation(customer, screening, fee, audienceCount);
}
}
📖 4.2 설계 트레이드오프
데이터 중심 설계와 책임 중심 설계의 장단점을 비교하기 위해 캡슐화, 응집도, 결합도 를 사용하겠다.
🔖 4.2.1 캡슐화
객체를 설계하기 위한 가장 기본적인 아이디어는 변경의 정도에 따라 구현과 인터페이스를 분리하고 외부에서는 인터페이스에만 의존하도록 관계를 조절
- 구현: 변경될 가능성이 높은 부분
- 인터페이스: 상대적으로 안정적인 부분
설계가 필요한 이유는 요구사항이 변경되기 때문이고, 캡슐화가 중요한 이유는 불안정한 부분과 안정적인 부분을 분리해서 변경의 영향을 통제할 수 있기 때문이다.
- 변경의 관점에서 설계의 품질을 판단하기 위해 캡슐화를 기준으로 삼을 수 있다.
🔖 4.2.2 응집도와 결합도
응집도
- 모듈에 포함된 내부 요소들이 연관돼 있는 정도
- 객체 또는 클래스에 얼마나 관련 높은 책임들을 할당했는지를 나타냄
- 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도
결합도
- 의존성의 정도를 나타냄
- 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도
- 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계만을 유지하고 있는지
- 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도
일반적으로 좋은 설계란 높은 응집도와 낮은 결합도를 추구
- 설계를 변경하기 쉽게 만들기 때문
- 결합도가 높아도 상관 없는 경우가 있음
- 변경될 확률이 매우 적은 안정적은 모듈에 의존하는 것
- ex) java의
String
,ArrayList
📖 4.3 데이터 중심의 영화 예매 시스템의 문제점
- 데이터 중심의 설계는 캡슐화를 위반하고 객체의 내부 구현을 인터페이스의 일부로 만든다.
- 캡슐화 위반
- 높은 결합도
- 낮은 응집도
- 책임 중심의 설계는 객체의 내부 구현을 안정적인 인터페이스 뒤로 캡슐화
🔖 4.3.1 캡슐화 위반
데이터 중심으로 설계한 Movie
클래스를 보면 오직 메서드를 통해서만 객체의 내부 상태에 접근할 수 있다.
- 이 메서드들은 어떤 인스턴스 변수가 존재한다는 사실을 퍼블릭 인터페이스에 노골적으로 드러낸다.
- 캡슐화의 원칙을 어기게 된 근본적인 원인은 객체가 내부에 저장할 데이터에 초점을 맞췄기 때문
- 이처럼 접근자와 수정자에 과도하게 의존하는 설계 방식을 추측에 의한 설계 전략(design-by-guessing strategy) 라고 한다.
🔖 4.3.2 높은 결합도
ReservationAgency
의 fee 타입을 변경한다고 가정해보면 협력하는 클래스가 변경된다. 즉, getFee
메서드를 사용하는 것은 인스턴스 변수 public 제어자를 쓰는 것과 거의 동일하다.
ReservationAgency
는 모든 의존성이 모이는 결합도의 집결지- 데이터 중심의 설계는 전체 시스템을 하나의 거대한 의존성 덩어리로 만들어 버리기 때문에 어떤 변경이라도 일단 발생하고 나면 시스템 전체가 요동칠 수밖에 없다.
🔖 4.3.3 낮은 응집도
낮은 응집도는 두 가지 측면에서 설계에 문제를 일으킨다.
- 변경의 이유가 서로 다른 코드들을 하나의 모듈 안에 뭉쳐놓았기 때문에 변경과 아무 상관이 없는 코드들이 영향을 받게 된다.
- 하나의 요구사항 변경을 반영하기 위해 동시에 여러 모듈을 수정해야 한다. 다른 모듈에 위치해야 할 책임의 일부가 엉뚱한 곳에 위치하게 되기 때문
어떤 요구사항 변경을 수용하기 위해 하나 이상의 클래스를 수정해야 하는 것은 설계의 응집도가 낮다는 증거
단일 책임 원칙(Single Respojnsibility Principle, SRP)
- 클래스는 단 한 가지의 변경 이유만 가져야 한다는 것
📖 4.4 자율적인 객체를 향해
🔖 4.4.1 캡슐화를 지켜라
객체는 스스로의 상태를 책임져야 하며 외부에서는 인터페이스에 정의된 메서드를 통해서만 상태에 접근할 수 있어야 한다.
- 여기서 메서드는 객체가 책임져야 하는 무언가를 수행하는 메서드
@Setter
@Getter
@AllArgsConstructor
public class Rectangle {
private int left;
private int top;
private int right;
private int bottom;
}
이 사각형의 너비와 높이를 증가시키는 코드가 필요하다면?
public class AnyClass {
void anyMethod(Rectangle rectangle, int multiple) {
rectangle.setRight(rectangle.getRight() * multiple);
rectangle.setBottom(rectangle.getBottom() * multiple);
...
}
}
위 코드는 문제가 많다.
- 코드 중복이 발생할 확률 ⬆️
- 변경에 취약
public class Rectangle {
public void enlarge(int multiple) {
right *= multiple;
bottom *= multiple;
}
}
- 변경 주체를 외부의 객체에서 Rectangle로 이동
🔖 4.4.2 스스로 자신의 데이터를 책임지는 객체
객체는 단순한 데이터 제공자가 아니다. 객체 내부에 저장되는 데이터보다 객체가 협력에 참여하면서 수행할 책임을 정의하는 오퍼레이션이 더 중요하다.
따라서 객체를 설계할 때 아래와 같은 두 개의 질문을 던져야 한다.
- 이 객체가 어떤 데이터를 포함해야 하는가?
- 이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?
ReservationAgency
로 새어나간 데이터에 대한 책임을 실제 데이터를 포함하고 있는 객체로 옮겨보자.
/**
* 할인 조건 class
*/
@Setter
@Getter
public class DiscountCondition {
/**
* 할인 조건 타입
*/
private DiscountConditionType type;
/**
* 상영 순번
*/
private int sequence;
/**
* 요일
*/
private DayOfWeek dayOfWeek;
/**
* 시작 시간
*/
private LocalTime startTime;
/**
* 종료 시간
*/
private LocalTime endTime;
}
- 첫 번째 질문은
DiscountCondition
이 관리해야 하는 데이터를 결정해 놓았다.
public class DiscountCondition {
public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime time) {
if (type != DiscountConditionType.PERIOD) {
throw new IllegalArgumentException();
}
return this.dayOfWeek.equals(dayOfWeek) &&
!this.startTime.isAfter(time) &&
!this.endTime.isBefore(time);
}
public boolean isDiscountable(int sequence) {
if (type != DiscountConditionType.SEQUENCE) {
throw new IllegalArgumentException();
}
return this.sequence == sequence;
}
}
- 두 번째 질문은 할인 조건을 판단할 수 있게 두 개의
isDiscountable
메서드가 필요할 것이다.
public class Movie {
/**
* 제목
*/
private String title;
/**
* 상영 시간
*/
private Duration runningTime;
/**
* 기본 요금
*/
@Getter
@Setter
private Money fee;
/**
* 할인 조건 목록
*/
@Getter
@Setter
private List<DiscountCondition> discountConditions;
/**
* 할인 정책의 종류를 결정하는 변수
*/
@Getter
@Setter
private MovieType movieType;
/**
* 할인 금액
*/
@Getter
@Setter
private Money discountAmount;
/**
* 할인 비율
*/
@Getter
@Setter
private double discountPercent;
}
- 첫 번째 질문은
Movie
가 어떤 데이터를 포함해야 하는가를 묻는 것이다.
public class Movie {
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;
}
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;
}
}
- 두 번째 질문에 대한 오퍼레이션들이다.
/**
* 상영 Class
*/
@Setter
@Getter
@AllArgsConstructor
public class Screening {
/**
* 영화
*/
private Movie movie;
/**
* 순번
*/
private int sequence;
/**
* 상영 시작 시간
*/
private LocalDateTime 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.calculateNoneDiscountedFee().times(audienceCount);
}
}
return movie.calculateNoneDiscountedFee().times(audienceCount);
}
}
/**
* 영화 예매 절차 구현 Class
*/
public class ReservationAgency {
public Reservation reserve(Screening screening, Customer customer, int audienceCount) {
Money fee = screening.calculateFee(audienceCount);
return new Reservation(customer, screening, fee, audienceCount);
}
}
- 최소한 결합도 측면에서 개선되었다.
📖 4.5 하지만 여전히 부족하다
사실 본질적으로 두 번째 설계 역시 데이터 중심의 설계 방식에 속한다.
🔖 4.5.1 캡슐화 위반
DiscountCondition
에 구현된 두 개의 isDiscountable
메서드를 자세히 살펴보면 이상한 점이 있다.
- 시간 정보가 인스턴스 변수로 포함돼 있다는 사실을 인터페이스를 통해 외부에 노출하고 있음.
DiscountCondition
의 속성을 변경해야 한다면 해당 메서드를 사용하는 모든 client도 함께 수정되어야 한다.- 내부 구현의 변경이 외부로 퍼져나가는 파급 효과(ripple effect) 는 캡슐화가 부족하다는 명백한 증거
Movie
또한 문제가 있다.
- 할인 정책의 종류를 노출시키고 잇다.
- 새로운 할인 정책이 ㅜㅊ가되거나 제거된다면 모든 client가 영향을 받는다.
캡슐화란 변하는 어떤 것이든 감추는 것이다. 내부 속성을 외부로부터 감추는 것은 '데이터 캡슐화'라고 불리는 캡슐화의 한 종류일 뿐이다.
🔖 4.5.2 높은 결합도
두 객체 사이에 결합도가 높을 경우 한 객체의 구현을 변경할 때 다른 객체에게 변경의 영향이 전파될 확률이 높아진다.
DiscountCondition
의 기간 할인 조건의 명칭이 변경된다면Movie
를 수정해야 한다.DiscountCondition
의 종류가 추가되거나 삭제된다면Movie
를 수정해야 한다.DiscountCondition
의 만족 여부를 판단하는 데 필요한 정보가 변경된다면Movie
를 수정해야 한다.
더 심각한 것은 변경의 여파가 두 객체들로 한정되지 않는 것이다.
🔖 4.5.3 낮은 응집도
Screening
또한 할인 조건의 종류를 변경하기 위해서는 수정이 불가피하다.
- 하나의 변경을 수용하기 위해 코드의 여러 곳을 동시에 변경해야 한다는 것은 설계의 응집도가 낮다는 증거
두 번째 설계가 첫 번째 설계보다 개선된 것은 사실이지만 데이터 중심의 설계가 가지는 문제점으로 인해 몸살을 앓고 있다는 점에는 변함이 없다.
📖 4.6 데이터 중심 설계의 문제점
- 본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요
- 데이터 중심의 설계에서는 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정
🔖 4.6.1 데이터 중심 설계는 객체의 행동보다는 상태에 초점을 맞춘다
첫 번째 설계 실패 이유
- 데이터 중심의 관점에서 객체는 그저 단순한 데이터의 집합체일 뿐이다.
- 접근자와 수정자를 과도하게 추가
- 데이터 객체를 사용하는 절차를 분리된 별도의 객체 안에 구현
두 번째 설계 실패 이유
- 데이터에 관한 지식이 객체의 인터페이스에 고스란히 드러나게 된다.
- 객체의 인터페이스는 구현을 캡슐화하는 데 실패하고 코드는 변경에 취약
결론적으로 데이터 중심의 설계는 너무 이른 시기에 데이터에 대해 고민하기 때문에 캡슐화에 실패하게 된다.
🔖 4.6.2 데이터 중심 설계는 객체를 고립시킨 채 오퍼레이션을 정의하도록 만든다
올바른 객체지향 설계의 무게 중심은 항상 객체의 내부가 아니라 외부에 맞춰져 있어야 한다.
- 데이터 중심 설계에서 초점은 객체의 외부가 아니라 내부로 향한다.
- 실행 문맥에 대한 깊이 있는 고민 없이 객체가 관리할 데이터의 세부 정보를 먼저 결정