4. 자율적인 객체를 향해
캡슐화를 지켜라
캡슐화는 설계의 제1원리다.
private으로 설정했다고 해도 접근자와 수정자를 통해 속성을 외부로 제공하고 있다면 캡슐화를 위반하는 것이다.
Rectangle 클래스를 보자.
class Rectangle {
private left: number;
private top: number;
private right: number;
private bottom: number;
constructor(left: number, top: number, right: number, bottom: number) {
this.left = left;
this.top = top;
this.right = right;
this.bottom = bottom;
}
public getLeft(): number {
return this.left;
}
public setLeft(left: number): void {
this.left = left;
}
public getTop(): number {
return this.top;
}
public setTop(top: number): void {
this.top = top;
}
public getRight(): number {
return this.right;
}
public setRight(right: number): void {
this.right = right;
}
public getBottom(): number {
return this.bottom;
}
public setBottom(bottom: number): void {
this.bottom = bottom;
}
}
사각형의 너비와 높이를 증가시키는 코드를 추가해보자.
class Anyclass {
public anyMethod(rectangle: Rectangle, multiple: number) {
rectangle.setRight(rectangle.getRight() * multiple);
rectangle.setBottom(rectangle.getBotsetBottom() * multiple);
...
}
}
이 코드에는 문제점이 있다.
- 코드 중복이 발생할 확률이 높다.
다른 곳에서도 사각형의 너비와 높이를 증가시켜야 한다면 유사한 코드가 생겨날 것이다. - 변경에 취약하다.
right/bottom 대신 length/height를 사용하게 된다면 모든 코드에 영향을 미치게 된다.
Rectangle 내부에 로직을 캡슐화하면 문제를 해결할 수 있다.
class Rectangle {
public enlarge(multiple: number): void {
this.right *= multiple;
this.bottom *= multiple;
}
}
스스로 자신의 데이터를 책임지는 객체
이 객체가 어떤 데이터를 포함해야 하는가?
- 이 객체가 어떤 데이터를 포함해야 하는가?
- 이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?
DiscountCondition이 포함해야 하는 데이터
class DiscountCondition {
private type: DiscountConditionType;
private sequence: number;
private dayOfWeek: number;
private startTime: number;
private endTime: number;
}
DiscountCondition이 포함해야 하는 오퍼레이션 (두 가지 할인 조건을 판단해야 함)
public getType(): DiscountConditionType {
return this.type;
}
public isPeriodDiscountable(dayOfWeek: number, time: number): boolean {
if (this.type !== "PERIOD") {
throw new Error();
}
return (
this.dayOfWeek === dayOfWeek &&
this.startTime <= time &&
this.endTime >= time
);
}
public isSequenceDiscountable(sequence: number): boolean {
if (this.type !== "SEQUENCE") {
throw new Error();
}
return this.sequence === sequence;
}
Movie가 포함해야 하는 데이터
class Movie {
private title: string;
private runningTime: number;
private fee: Money;
private discountConditions: DiscountCondition[];
private movieType: MovieType;
private discountAmount: MutationRecordType;
private discountPercent: number;
}
Movie가 포함해야 하는 오퍼레이션 (영화 요금 계산, 할인 여부 판단)
// 영화 요금 계산
public calculateAmountDiscountedFee(): Money {
if (this.movieType !== "AMOUNT_DISCOUNT") {
throw new Error();
}
return this.fee.minus(this.discountAmount);
}
public calculatePercentDiscountedFee(): Money {
if (this.movieType !== "PERCENT_DISCOUNT") {
throw new Error();
}
return this.fee.minus(this.discountAmount);
}
public calculateNoneDiscountedFee(): Money {
if (this.movieType !== "NONE_DISCOUNT") {
throw new Error();
}
return this.fee.minus(this.discountAmount);
}
// 할인 여부 판단
public isDiscountable(
whenScreened: LocalDateTime,
sequence: number
): boolean {
for (const condition of this.discountConditions) {
if (condition.getType() === "PERIOD") {
if (
condition.isPeriodDiscountable(
whenScreened.dayOfWeek,
whenScreened.localTime
)
) {
return true;
}
} else {
if (condition.isSequenceDiscountable(sequence)) {
return true;
}
}
}
return false;
}
Screening도 동일하게 구현하자.
class Screening {
private movie: Movie;
private sequence: number;
private whenScreened: LocalDateTime;
constructor(movie: Movie, sequence: number, whenScreened: LocalDateTime) {
this.movie = movie;
this.sequence = sequence;
this.whenScreened = whenScreened;
}
public calculateFee(audienceCount: number): Money {
switch (this.movie.getMovieType()) {
case "AMOUNT_DISCOUNT":
if (this.movie.isDiscountable(this.whenScreened, this.sequence)) {
return this.movie.calculateAmountDiscountedFee() * audienceCount;
}
break;
case "PERCENT_DISCOUNT":
if (this.movie.isDiscountable(this.whenScreened, this.sequence)) {
return this.movie.calculatePercentDiscountedFee() * audienceCount;
}
break;
case "NONE_DISCOUNT":
return this.movie.calculateNoneDiscountedFee() * audienceCount;
}
return this.movie.calculateNoneDiscountedFee() * audienceCount;
}
}
마지막으로 ReservationAgency를 구현하자.
class ReservationAgency {
public reserve(
screening: Screening,
customer: CustomElementRegistry,
audienceCount: number
): Reservation {
const fee: Money = screening.calculateFee(audienceCount);
return new ReservationAgency(customer, screening, fee, audienceCount);
}
}
결합도 측면에서 ReservationAgency에 몰려있던 의존성이 개선되었다.
5. 하지만 여전히 부족하다
그럼에도 두 번째 설계 역시 본질적으로는 데이터 중심의 설계 방식에 속한다.문제점은 여전히 발생한다.
캡슐화 위반
분명히 수정된 객체들은 자신의 데이터를 스스로 처리한다.
하지만 DiscountCondition의 isDiscountable을 보자.
public isPeriodDiscountable(dayOfWeek: number, time: number): boolean {
...
}
dayOfWeek와 time이 인스턴스 변수로 포함돼 있다는 사실을 인터페이스를 통해 외부에 노출하고 있다.
getType 메서드는 내부의 DiscountConditionType 정보를 노출시킨다.
Movie역시 할인 정책에 따른 세 가지 메서드를 통해 할인 정책을 외부로 드러내고 있다.
캡슐화는 단순히 객체 내부의 데이터를 외부로부터 감추는 것 이상의 의미를 가진다.
이는 데이터 캡슐화로 캡슐화의 한 종류일 뿐이다.
캡슐화는 변경될 수 있는 어떤 것이라도 감추는 것을 의미한다.
높은 결합도
따라서 Movie와 DiscountCondition 사이의 결합도는 높을 수밖에 없다.
- DiscountCondition의 기간 할인 조건의 명칭이 변경되면 Movie를 수정해야 함
- DiscountCondition의 종류가 변경된다면 Movie 안의 if구문을 수정해야 함
- DiscountCondition의 만족 여부를 판단하는 데 필요한 정보가 변경되면 Movie의 isDiscountable이 변경되고, Screening도 수정해야 함
낮은 응집도
결과적으로 할인 조건의 종류가 변경되면 DiscountCondition, Movie, Screening을 모두 수정해야 한다.
모든 문제의 원인은 캡슐화 원칙을 지키지 않았기 때문이다.
유연한 설계를 위해서는 캡슐화를 설계의 첫 번째 목표로 삼아야 한다.
6. 데이터 중심 설계의 문제점
데이터 중심 설계는 변경에 취약하다.
본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요한다.
데이터 중심 설계는 데이터와 기능을 분리하는 절차적 프로그래밍 방식을 따른다.
이는 상태와 행동을 하나의 단위로 캡슐화하는 객체지향 패러다임에 반하는 것이다.
데이터 중심 관점에서 객체는 단순한 데이터의 집합체일 뿐이다.
접근자/수정자를 과도하게 추가하게 되고, 객체를 사용하는 절차를 분리된 별도의 객체에 구현한다.
데이터를 먼저 결정하고 오퍼레이션을 나중에 결정하면 데이터 지식이 인터페이스에 드러나게 된다.
결과적으로 캡슐화가 실패하게 된다.
협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정한다.
올바른 객체지향 설계의 무게 중심은 객체의 내부가 아닌 외부에 맞춰져 있어야 한다.
중요한 것은 객체가 다른 객체와 협력하는 방법이다.
데이터 중심 설계에서 초점은 객체의 내부로 향한다.
객체의 구현이 이미 결정된 상태에서 협력 때문에 인터페이스를 억지로 끼워맞추게 된다.
캡슐화를 위반하기 쉽고, 요소들 사이의 결합도가 높아지며, 코드를 변경하기 어려워진다.