객체지향 설계란 올바른 객체에게 올바른 책임을 할당하면서 낮은 결합도와 높은 응집도를 가진 구조를 만드는 것이다.
- 관점1: 객체지향 설계의 핵심은 책임
- 관점2: 책임을 할당하는 작업은 응집도/결합도 같은 설계 품질과 깊이 연관돼 있음
1. 데이터 중심의 영화 예매 시스템
- 데이터 중심 관점: 객체의 상태에 초점을 맞춤. 객체를 독립된 데이터 덩어리로 바라봄.
객체의 상태는 구현에 속한다. 구현은 불안정하기 때문에 변하기 쉽다.
캡슐화가 약해지며 변경에 취약하다. - 책임 중심 관점: 객체의 행동에 초점을 맞춤. 객체를 협력하는 공동체의 일원으로 바라봄.
객체의 책임은 인터페이스에 속한다. 변경에 안정적이다.
데이터 중심으로 영화 예매 시스템을 구현해보자.
Movie에 저장될 데이터부터 결정해보자.
class Movie {
private title: string;
private runningTime: number;
private fee: Money;
private discountConditions: DiscountCondition[];
private movieType: MovieType;
private discountAmount: MutationRecordType;
private discountPercent: number;
}
책임 주도 개발과의 차이점
- discountConditions를 인스턴스 변수로 직접 정의하고 있다.
- 할인 정책을 별도의 클래스로 분리하지 않고, 금액 할인 정첵에 사용되는 discountAmount와 비율 할인 정책에 사용되는 discountPercent를 직접 정의하고 있다.
movieType은 할인 정책의 종류를 결정한다.
// 금액 할인 정책 | 비율 할인 정책 | 미적용
type MovieType = "AMOUNT_DISCOUNT" | "PERCENT_DISCOUNT" | "NONE_DISCOUNT";
데이터 중심 설계에서는 객체가 포함해야 하는 데이터에 집중한다. 이 객체가 포함해야 하는 데이터는 무엇인가?
데이터 중심 설계에서 흔히 볼 수 있는 패턴
- 객체의 종류를 저장하는 인스턴스 변수(movieType)와 인스턴스의 종류에 따라 배타적으로 사용될 인스턴스 변수(discountAmount, discountPercent)를 하나의 클래스 안에 포함시킴
캡슐화를 위한 가장 간단한 방법은 접근자와 수정자를 추가하는 것이다.
public getMovieType(): MovieType {}
public setMovieType(movieType: MovieType): void {}
public getFee(): Money {}
public setFee(fee: Money): void {}
public getDiscountConditions(): DiscountCondition[] {}
public setDiscountConditions(discountConditions: DiscountCondition[]): void {}
public getDiscountAmount(): Money {}
public setDiscountAmount(discountAmount: Money): void {}
public getDiscountPercent(): number {}
public setDiscountPercent(discountPercent: number): void {}
이제 할인 조건을 구현해보자. 먼저 현재의 할인 조건의 종류를 저장할 데이터가 필요하다.
// 순번 조건 | 기간 조건
type DiscountConditionType = "SEQUENCE" | "PERIOD";
class DiscountCondition {
private type: DiscountConditionType;
private sequence: number;
private dayOfWeek: number;
private startTime: number;
private endTime: number;
}
캡슐화를 위한 메서드도 추가하자.
public getType(): DiscountConditionType {}
public setType(type: DiscountConditionType): void {}
public getDayOfWeek(): number {}
public setDayOfWeek(dayOfWeek: number): void {}
public getStartTime(): number {}
public setStartTime(startTime: number): void {}
public getEndTime(): number {}
public setEndTime(endTime: number): void {}
public getSequence(): number {}
public setSequence(sequence: number): void {}
Screening 클래스, Reservation 클래스, Customer 클래스도 동일한 방식으로 구현하자.
class Screening {
private movie: Movie;
private sequence: number;
private whenScreened: LocalDateTime;
public getMovie(): Movie {}
public setMovie(movie: Movie): void {}
public getWhenScreened(): LocalDateTime {}
public setWhenScreened(whenScreened: number): void {}
public getSequence(): number {}
public setSequence(sequence: number): void {}
}
class Reservation {
private customer: Customer;
private screening: Screening;
private fee: Money;
private audienceCount: number;
constructor(
customer: Customer,
screening: Screening,
fee: MutationRecordType,
audienceCount: number
) {
this.customer = customer;
this.screening = screening;
this.fee = fee;
this.audienceCount = audienceCount;
}
public getCustomer(): Customer {}
public setCustomer(customer: Customer): void {}
public getScreening(): Screening {}
public setScreening(screening: Screening): void {}
public getFee(): Money {}
public setFee(fee: Money): void {}
public getAudienceCount(): number {}
public setAudienceCount(audienceCount: number): void {}
}
class Customer {
private name: string;
private id: string;
constructor(name: string, id: string) {
this.name = name;
this.id = id;
}
}
데이터 클래스들을 조합해서 영화 예매 절차를 구현하는 ReservationAgency 클래스를 구현하자.
class ReservationAgency {
public reserve(
screening: Screening,
customer: Customer,
audienceCount: number
): Reservation {
const movie: Movie = screening.getMovie();
// 할인 가능 여부 확인
let discountable: boolean = false;
for (const condition of movie.getDiscountConditions()) {
if (condition.getType() === "PERIOD") {
discountable =
condition.getDayOfWeek() === screening.getWhenScreened().dayOfWeek &&
condition.getStartTime() <= screening.getWhenScreened().localTime &&
condition.getEndTime() >= screening.getWhenScreened().localTime;
} else {
discountable = condition.getSequence() === screening.getSequence();
}
if (discountable) {
break;
}
}
// 적절한 할인 정책에 따라 예매 요금 계산
let fee: Money;
if (discountable) {
let discountAmount: Money = new Money(0).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 = new Money(0).ZERO;
}
fee = movie.getFee().minus(discountAmount);
} else {
fee = movie.getFee();
}
return new Reservation(customer, screening, fee, audienceCount);
}
}
이제 책임 중심 설계 방법과 비교해보자. 비교 기준부터 살펴보자.
2. 설계 트레이드오프
캡슐화
- 구현: 변경될 가능성이 높은 부분
- 인터페이스: 상대적으로 안정적인 부분
캡슐화는 분안정한 구현 세부사항을 안정적인 인터페이스 뒤로 숨기는 것이다.
변경될 수 있는 어떤 것이라도 캡슐화해야 한다.
응집도와 결합도
- 응집도: 모듈에 포함된 내부 요소들이 연관돼 있는 정도
- 결합도: 의존성의 정도. 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도.
좋은 설계란 높은 응집도와 낮은 결합도를 가진 모듈로 구성된 설계다.
응집도와 결합도는 변경과 관련이 깊다.
응집도가 높다
- 하나의 변경에 대해 모듈 전체가 함께 변경된다.
- 하나의 변경에 대해 하나의 모듈만 변경된다.
결합도가 낮다
- 하나의 모듈을 수정할 때 함께 변경해야 하는 모듈의 수가 적다.
- 내부 구현의 변경에 대해 다른 모듈이 영향을 받지 않는다.
- 퍼블릭 인터페이스의 변경에 대해서만 다른 모듈이 영향을 받는다.
한 모듈이 변경되기 위해 다른 모듈의 변경을 요구하는 정도가 작다면 결합도가 낮은 것이다.
캡슐화를 지키면 응집도는 높아지고 결합도는 낮아진다.
3. 데이터 중심의 영화 예매 시스템의 문제점
- 데이터 중심: 캡슐화를 위반하고 객체 내부 구현을 인터페이스의 일부로 만든다.
- 책임 중심: 객체 내부 구현을 인터페이스 뒤로 캡슐화한다.
캡슐화 위반
메서드를 통해서만 객체의 내부 상태에 접근할 수 있다.
class Movie {
private fee: Money;
public getFee(): Money {
return this.fee;
}
public setFee(fee: Money): void {
this.fee = fee;
}
}
그러나 접근자/수정자 메서드는 객체 내부의 상태에 대한 어떤 정보도 캡슐화하지 못한다.
Movie 내부에 Money 타입의 fee라는 이름의 인스턴스 변수가 존재한다는 사실을 퍼블릭 인터페이스에 드러낸다.
협력에 관해 고민하지 않으면 캡슐화를 위반하는 과도한 접근자/수정자를 가지게 된다.
이를 추측에 의한 설계 전략이라 한다.
높은 결합도
객체 내부의 구현이 인터페이스에 드러나면 클라이언트는 구현에 강하게 결합된다.
class ReservationAgency {
public reserve(
screening: Screening,
customer: Customer,
audienceCount: number
): Reservation {
...
let fee: Money;
if (discountable) {
fee = movie.getFee().minus(discountAmount);
} else {
fee = movie.getFee();
}
...
}
}
fee의 타입을 변경하면 협력하는 클래스도 변경되기 때문에 getFee 메서드는 fee를 정상적으로 캡슐화하지 못한다.
fee의 가시성을 private에서 public으로 변경하는 것과 거의 동일하다.
결합도 측면에서도, 여러 데이터 객체들을 사용하는 제어 로직이 특정 객체 안에 집중된다.
하나의 제어 객체가 다수의 데이터 객체에 강하게 결합되는 것이다.
대부분의 제어 로직을 가지고 있는 제어 객체인 ReservationAgency가 모든 데이터 객체에 의존한다.
시스템 안의 어떤 변경도 ReservationAgency의 변경을 유발한다.
데이터 중심 설계는 전체 시스템을 하나의 거대한 의존성 덩어리로 만들어 버린다.
낮은 응집도
서로 다른 이유로 변경되는 코드가 하나의 모듈 안에 공존할 때 모듈의 응집도가 낮다고 한다.
ReservationAgency에 수정이 발생하는 원인을 나열해보자.
- 할인 정책이 추가될 경우
- 할인 정책별로 할인 요금을 계산하는 방법이 변경될 경우
- 할인 조건이 추가되는 경우
- 할인 조건별로 할인 여부를 판단하는 방법이 변경될 경우
- 예매 요금을 계산하는 방법이 변경될 경우
낮은 응집도는 두 가지 측면에서 문제를 일으킨다.
- 변경의 이유가 서로 다른 코드들을 하나의 모듈 안에 뭉쳐놓았기 때문에 변경과 상관 없는 코드들이 영향을 받는다.
- 하나의 요구사항 변경을 반영하기 위해 동시에 여러 모듈을 수정해야 한다.
단일 책임 원칙: 클래스는 단 한 가지의 변경 이유만 가져야 한다.
(역할, 책임, 협력에서의 책임과는 다른 개념)