협력은 필수적이지만 과도한 협력은 과도한 의존성을 낳아 애플리케이션을 수정하기 어렵게 만든다.
1. 의존성 이해하기
변경과 의존성
의존성은 실행 시점과 구현 시점에 서로 다른 의미를 가진다.
- 실행 시점: 의존하는 객체가 정상적으로 동작하기 위해서는 실행 시에 의존 대상 객체가 반드시 존재해야 함
- 구현 시점: 의존 대상 객체가 변경될 경우 의존하는 객체도 함께 변경됨
class PeriodCondition implements DiscountCondition {
private dayOfWeek: DayOfWeek;
private startTime: LocalTime;
private endTime: LocalTime;
public isSatisfiedBy(screening: Screening): boolean {
return (
screening.getStartTime() === this.dayOfWeek &&
this.startTime <= screening.getStartTime() &&
this.endTime >= screening.getStartTime()
);
}
}
PeriodCondition이 의존하는 것
- Screening
실행 시점에 Screening의 인스턴스가 존재해야 한다. - DayOfWeek, LocalTime
DayOfWeek과 LocalTime의 인스턴스를 속성으로 포함한다. - DiscountCondition
인터페이스에 정의된 오퍼레이션들을 퍼블릭 인터페이스의 일부로 포함시킨다.
의존성은 방향성을 가지며 항상 단방향이다.
두 요소 사이의 의존성은 의존되는 요소가 변경될 때 의존하는 요소도 함께 변경될 수 있다.
의존성은 변경에 의한 영향의 전파 가능성을 암시한다.
의존성 전이
PeriodCoundition이 Screening에 의존할 경우 Screening이 의존하는 대상에 대해서도 자동적으로 의존하게 된다.
의존성이 실제로 전이될지 여부는 변경의 방향과 캡슐화의 정도에 따라 달라진다.
- 직접 의존성: 한 요소가 다른 요소에 직접 의존하는 경우
- 간접 의존성: 직접적인 관계는 존재하지 않지만 의존성 전이에 의해 영향이 전파되는 경우
의존성의 대상은 객체일 수도, 모듈이나 실행 시스템일 수도 있다.
런타임/컴파일타임 의존성
- 런타임: 애플리케이션이 실행되는 시점
- 컴파일타임: 작성된 코드를 컴파일하는 시점 / 코드 그 자체
(컴파일타임이라는 용어를 볼 때 정말 컴파일이 진행되는 시점인지, 코드를 작성하는 시점인지 파악하자)
객체지향 애플리케이션에서 런타임의 주인공은 객체다.
- 런타임 의존성: 객체 사이의 의존성
- 컴파일타임 의존성: 클래스 사이의 의존성
코드 작성 시점의 Movie와 DiscountPolicy 사이의 컴파일타임 의존성은 다음과 같다.
Movie에서 AmountDiscountPolicy와 PercentDiscountPolicy로 향하는 어떤 의존성도 존재하지 않는다.
Movie는 추상 클래스인 DiscountPolicy에만 의존한다.
그러나 런타임 의존성에서는 달라진다.
실행 시점의 Movie 인스턴스는 AmountDiscountPolicy / PercentDiscountPolicy 인스턴스와 협력할 수 있어야 한다.
Movie는 DiscountPolicy라는 추상 클래스에 컴파일타임 의존성을 가지고,
이를 실행 시에 AmountDiscountPolicy / PercentDiscountPolicy 인스턴스에 대한 런타임 의존성으로 대체한다.
유연하고 재사용 가능한 설계를 위해서는 동일한 소스코드 구조를 가지고 다양한 실행 구조를 만들 수 있어야 한다.
협력할 인스턴스의 구체적인 클래스를 알아서는 안 된다. 실제로 협력할 객체가 어떤 것인지는 런타임에 해결해야 한다.
컴파일타임 구조와 런타임 구조 사이의 거리가 멀수록 설계가 유연해지고 재사용 가능해진다.
컨텍스트 독립성
구체적인 클래스에 의존하는 것은 클래스의 인스턴스가 어떤 문맥에서 사용될 것인지를 구체적으로 명시하는 것과 같다.
클래스가 특정한 문맥에 강하게 결합될수록 다른 문맥에서 사용하기는 더 어려워진다.
의존성 해결하기
컴파일타임 의존성을 실행 컨텍스트에 맞는 적절한 런타임 의존성으로 교체하는 것을 의존성 해결이라고 한다.
1. 객체를 생성하는 시점에 생성자를 통해 의존성 해결
영화에 적용할 할인 정책에 따라, Movie의 생성자에 다른 인자를 전달하게 된다.
const avatar = new Movie("아바타", 120, 10000, new AmountDiscountPolicy(...));
const starWars = new Movie("스타워즈", 180, 11000, new PercentDiscountPlicy(...));
이를 위해 Movie는 두 종류의 인스턴스를 선택적으로 전달받을 수 있도록 DiscountPolicy 타입의 인자를 받는 생성자를 정의한다.
class Movie {
constructor(title: string, runnintTime: number, fee: number, discountPlicy: DiscountPolicy);
}
2. 객체 생성 후 setter 메서드를 통해 의존성 해결
Movie 인스턴스를 생성한 후에 메서드를 이용해서 해결할 수도 있다.
const avatar = new Movie(...);
avatar.setDiscountPolicy(new AmountDIscountPlicy(...));
class Movie {
public setDiscountPolicy(discountPlicy: DiscountPolicy) {
this.discountPolicy = discountPlicy;
}
}
setter를 이용하면 객체를 생성한 이후에도 의존하고 있는 대상을 변경하고 싶을 때 유용하다.
하지만 객체가 생성된 후 협력에 필요한 의존 대상을 설정하기 때문에, 설정하기 전까지는 객체의 상태가 불완전할 수 있다.
위 두 방식을 혼합하면 된다. 안정적이면서도 유연하게 설계할 수 있다.
const avatar = new Movie(..., new PercentDiscountPolicy(...));
avatar.setDiscountPolicy(new AmountDIscountPlicy(...));
3. 메서드 실행 시 인자를 이용해 의존성 해결
Movie가 항상 할인 정책을 알 필요까지는 없고 가격을 계산할 때만 일시적으로 알아도 된다면 이 방법을 사용하면 된다.
class Movie {
public calculateMovieFee(screening: Screening, discountPolicy: DiscountPolicy): number {
return fee.minus(discountPolicy.calculateDiscountAmount(screening))
}
}
메서드가 실행되는 동안만 일시적으로 의존 관계가 존재해도 무방하거나, 실행될 때마다 의존 대상이 매번 달라져야 하는 경우에 유용하다.
2. 유연한 설계
의존성을 관리하는 데 필요한 개념을 바탕으로 유연한 코드를 구현할 수 있는 원칙과 기법을 알아보자.
의존성과 결합도
의존성은 객체들의 협력을 가능하게 하지만 과하면 문제가 될 수 있다.
Movie가 PercentDiscountPolicy에 직접 의존하도록 하면, 다른 종류의 할인 정책이 필요한 문맥에서 Movie를 재사용할 수 없게 된다.
PercentDiscountPolicy에 대한 의존성은 바람직하지 않다. DiscountPolicy에 대한 의존성은 바람직하다.
바람직한 의존성은 재사용성이 높다. 컨텍스트에 독립적인 의존성이 바람직한 의존성이다.
바람직하지 못한 의존성은 구현을 변경하게 만든다.
의존성이 바람직할 때 두 요소는 느슨한/약한 결합도를 가지며, 의존성이 바람직하지 못할 때 단단한/강한 결합도를 가진다.
지식이 결합을 낳는다
결합도의 정도는 의존하는 다른 요소에 대해 알고 있는 정보의 양으로 결정된다. 많이 알수록 두 요소는 강하게 결합된다.
협력하는 대상에 대해 필요한 정보 외에는 최대한 감춰야 한다. 추상화를 해야 한다.
추상화에 의존하라
DiscountPolicy는 할인 정책의 추상화다.
Movie의 관점에서 협력을 위해 알아야 하는 지식의 양은 PercentDiscountPolicy보다 DiscountPolicy가 더 적다.
추상화와 결합도의 관점에서 의존 대상을 다음과 같이 구분하자.
아래로 갈수록 클라이언트가 알아야 하는 지식의 양이 적어지기 때문에 결합도가 느슨해진다.
- 구체 클래스 의존성
- 추상 클래스 의존성
클라이언트가 알아야 하는 지식의 양이 더 적다.
하지만 여전히 클래스 상속 계층이 무엇인지 알아야 한다. - 인터페이스 의존성
상속 계층을 모르더라도 협력이 가능하다.
실행 컨텍스트에 대해 알아야 하는 정보를 줄일수록 결합도가 낮아진다.
명시적인 의존성
느슨한 결합도를 위해서는 인스턴스 변수의 타입을 추상 클래스나 인터페이스로 선언하고,
클래스 안에서 구체 클래스에 대한 모든 의존성을 제거해야 한다.
인스턴스 변수의 타입은 추상 클래스나 인터페이스로 정의하고,
setter 메서드와 메서드 인자로 의존성을 해결할 때는 추상 클래스를 상속받거나 구체 클래스를 전달하자.
- 명시적인 의존성
생성자의 인자로 선언하거나 setter 메시더 또는 메서드 인자를 사용하는 방법은 의존성이 퍼블릭 인터페이스에 노출된다. - 숨겨진 의존성
객체 내부에서 의존하는 객체의 인스턴스를 직접 생성하는 방식은 의존성을 감춘다.
의존성이 명시적이지 않으면 내부 구현을 직접 살펴볼 수밖에 없으며,
클래스를 다른 컨텍스트에서 재사용하기 위해 내부 구현을 직접 변경해야 한다.
의존성은 명시적으로 표현돼야 한다. 그래마얀 퍼블릭 인터페이스를 통해 컴파일타임 의존성을 적절한 런타임 의존성으로 교체할 수 있다.
new는 해롭다
클래스의 인스턴스를 생성하는 new 연산자를 잘못 사용하면 클래스 사이의 결합도가 극단적으로 높아진다.
- new 연산자를 사용하기 위해서는 구체 클래스의 이름을 직접 기술해야 한다.
new를 사용하는 클라이언트는 추상화가 아닌 구체 클래스에 의존할 수밖에 없어진다. - new 연산자는 생성하려는 구체 클래스뿐만 아니라 어떤 인자를 이용해 클래스의 생성자를 호출해야 하는지도 알아야 한다.
클라이언트가 알아야 하는 지식의 양이 늘어나게 된다.
AmountDiscountPolicy의 인스턴스를 직접 생성하는 Movie를 보자.
class Movie {
private discountPolicy: DiscountPolicy;
constructor(title: string, runnintTime: number, fee: number) {
this.discountPolicy = new AmountDiscountPolicy(
800,
new SequenceCondition(1),
new SequenceCondition(10),
new PeriodCondition(1, 10, 11),
new PeriodCondition(2, 10, 20)
);
}
}
Movie는 AmountDiscountPolicy의 생성자에 전달되는 인자를 알고 있어야 한다.
그리고 AmountDiscountPolicy의 생성자에서 잠조하는 SequenceCondition과 PeriodCondition에도 의존하게 만든다.
Movie가 DiscountPolicy에 의존하는 이유는 calculateDiscountAmount 메시지를 전송하기 위해서다.
이 메시지에 대한 의존성 외의 모든 다른 의존성은 불필요하다.
인스턴스를 생성하는 로직과 생성된 인스턴스를 사용하는 로직을 분리하자.
Movie 클래스에는 AmountDiscountPolicy의 인스턴스에 메시지를 전송하는 코드만 남아야 한다.
class Movie {
private discountPolicy: DiscountPolicy;
constructor(title: string, runnintTime: number, fee: number, discountPolicy: DiscountPolicy) {
this.discountPolicy = discountPolicy
}
AmountDiscountPolicy의 인스턴스를 생성하는 책임은 Movie의 클라이언트로 옮겨져야 한다.
const avatar: Movie = new Movie(
"아바타",
120,
10000,
new AmountDiscountPolicy(
800,
new SequenceCondition(1),
new SequenceCondition(10),
new PeriodCondition(1, 10, 11),
new PeriodCondition(2, 10, 20)
)
);
생성의 책임을 클라이언트로 옮김으로써 Movie는 DiscountPolicy의 모든 자식 클래스와 혐력할 수 있게 됐다.
사용과 생성의 책임을 분리하고, 의존성을 생성자에 명시적으로 드러내고, 구체 클래스가 아닌 추상 클래스에 의존하게 만들자.
가끔은 생성해도 무방하다
협력하는 기본 객체를 설정하고 싶은 경우에는 유용할 수도 있따.
Movie가 대부분의 경우에는 AmountDiscountPolicy와 협력하고 가끔씩만 PercentDiscountPolicy와 협력한다고 하자.
이런 상황에서 모든 경우에 인스턴스를 생성하는 책임을 클라이언트로 옮긴다면 클라이언트들 사이에 중복 코드가 늘어날 것이다.
이럴 경우 AmountDiscountPolicy를 생성하는 생성자와 DiscountPolicy의 인스턴스를 인자로 받는 생성자를 체이닝할 수 있다.
첫 번째 생성자의 내부에서 두 번째 생성자를 호출함으로써 생성자가 체인처럼 연결된다.
클라이언트는 AmountDiscountPolicy와 협력하면서도 컨텍스트에 적절한 DiscountPolicy로 의존성을 교체할 수 있다.
메서드를 오버로딩하는 경우에도 사용할 수 있다.
DiscountPolicy의 인스턴스를 인자로 받는 메서드와 AmountDiscountPolicy를 생성하는 메서드를 함께 사용할 수도 있다.
그러나 타입스크립트의 경우 중복 생성자를 금지하고,
메서드 오버로딩의 경우에도 매게변수의 타입만 다른 함수들을 선언만 할 수 있을 뿐 구현은 한 번만 할 수 있기 때문에 불가능한 방법이다.
인자를 nullable하게 받을 수 있게 함으로써 비슷하게 구현해보자.
class Movie {
private discountPolicy?: DiscountPolicy;
constructor(title: string, runningTime: number, fee: number, discountPolicy?: DiscountPolicy) {
this.discountPolicy = discountPolicy || new AmountDiscountPolicy();
}
}
class Movie {
public calculateMovieFee(screening: Screening, discountPolicy?: DiscountPolicy): number {
const policy = discountPolicy || new AmountDiscountPolicy(...);
return this.fee.minus(policy.calculateDiscountAmount(screening));
}
}
표준 클래스에 대한 의존은 해롭지 않다
변경될 확률이 거의 없는 클래스라면 의존성이 문제가 되지 않는다.
그래도 가능한 한 추상적인 타입을 사용하는 것이 확장성 측면에서 유리하다.
의존성에 의한 영향이 적은 경우에도 추상화에 의존하고 의존성을 명시적으로 드러내는 것은 좋은 설계 습관이다.
컨텍스트 확장하기
1. 할인 혜택을 제공하지 않는 영화의 예매 요금 계산
class Movie {
private discountPolicy: DiscountPolicy | null;
constructor(title: string, runningTime: number, fee: number, discountPolicy?: DiscountPolicy) {
this.discountPolicy = discountPolicy || null;
}
public calculateMovieFee(screening: Screening, discountPolicy?: DiscountPolicy): number {
if (!discountPolicy) return this.fee;
return this.fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
const avatar = new Movie("아바타", 120, 10000);
기본값으로 null을 할당하고 있다. 제대로 동작하지만 문제가 있다.
지금까지의 Movied와 DiscountPolicy 사이의 협력 방식에 어긋나는 예외 케이스가 추가된 것이다.
이 예외 케이스를 처리하기 위해 Movie 내부를 직접 수정했다. 어떤 경우는 코드 내부를 직접 수정하는 것은 버그 발생률을 높인다.
할인 정책이 존재하지 않는다는 사실을 예외 케이스로 처리하지 말고 기존 협력 방식을 따르도록 만들자.
NoneDiscountPolicy 클래스를 추가하고 DiscountPolicy의 자식 클래스로 만들면 된다.
NoneDiscountPolicy의 인스턴스를 Movie 생성자에 전달함으로써 해결할 수 있다.
const avatar = new Movie("아바타", 120, 10000, new NoneDiscountPolicy());
2. 중복 적용이 가능한 할인 정책 구현
Movie가 하나 이상의 DiscountPolicy와 협력할 수 있어야 한다.
가장 간단한 방법은 Movie가 DiscountPolicy의 인스턴스들로 구성된 List를 인스턴스 변수로 갖는 것이지만,
이 방법 또한 기존의 할인 정책의 협력 방식과는 다른 예외 케이스를 추가하게 만든다.
중복 할인 정책 또한 할인 정책의 한 가지로 간주하자.
OverlappedDiscountPolicy를 DiscountPolicy의 자식 클래스로 만들면 된다.
class OverlappedDiscountPolicy extends DiscountPolicy {
private discountPolicies: DiscountPolicy[] = new Array();
constructor(...discountPolicies: DiscountPolicy[]) {
super();
this.discountPolicies = discountPolicies;
}
}
const avatar = new Movie(
"아바타",
120,
10000,
new OverlappedDiscountPolicy(new AmountDiscountPolicy(...), new PercentDiscountPolicy(...))
);
우리는 원하는 기능을 구현한 DiscountPolicy의 자식 클래스를 추가하고 이 클래스의 인스턴스를 Movie에 전달하기만 하면 된다.
Movie는 유연하고 재사용 가능하다.
- 설계가 유연한 이유는 다음과 같다.
- Movie는 DiscountPolicy라는 추상화에 의존하고,
- 생성자를 통해 DiscountPolicy에 대한 의존성을 명시적으로 드러냈고,
- new와 같이 구체 클래스를 직접적으로 다뤄야 하는 책임을 Movie 외부로 옮겼다.
결합도를 낮춤으로써 얻게 되는 컨텍스트의 확장이 유연하고 재사용 가능한 설계를 만드는 핵심이다.
조합 가능한 행동
어떤 DiscountPolicy의 인스턴스를 Movie에 연결하느냐에 따라 Movie의 행동이 달라진다.
어떤 객체와 협력하느냐에 따라 객체의 행도이 달라지는 것이다.
유연하고 재사용 가능한 설계는 객체의 how를 장황하게 나열하지 않고도 객체들의 조합을 통해 what을 표현하는 클래스들로 구성된다.
클래스의 인스턴스를 생성하는 코드를 보는 것만으로 객체가 어떤 일을 하는지를 쉽게 파악할 수 있다.
선언적으로 객체의 행동을 정의할 수 있는 것이다.
작은 객체들의 행동을 조합함으로써 새로운 행동을 이끌어낼 수 있도록 설계하자.
객체지향 설계란 객체들의 조합을 선언적으로 표현함으로써 객체들이 무엇을 하는지를 표현하는 설계다.
이런 설계의 핵심은 의존성을 관리하는 것이다.