8장에서 살펴본 의존성 관리 기법들을 원칙이라는 관점에서 정리해보자.
1. 개방-폐쇄 원칙
소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다.
- 확장에 대해 열려 있다
애플리케이션의 요구사항이 변경될 때 새로운 '동작'을 추가해서 기능을 확장할 수 있다. - 수정에 대해 닫혀 있다
기존 '코드'를 수정하지 않고도 동작을 추가하거나 변경할 수 있다.
컴파일타임 의존성을 고정시키고 런타임 의존성을 변경하라
- 컴파일타임 의존성: 코드에서 드러나는 클래스들 사이의 관계
- 런타임 의존성: 실행 시에 협력에 참여하는 객체들 사이의 관계
할인 정책 설계의 경우,
- 확장에 대해 열려 있다
새로운 할인 정책을 추가해서 기능을 확장할 수 있도록 허용한다. - 수정에 대해 닫혀 있다
기존 코드를 수정할 필요 없이 새로운 클래스를 추가하는 것만으로 새로운 할인 정책을 확장할 수 있다.
개방-폐쇄 원칙을 수용하는 코드는 컴파일타임 의존성을 수정하지 않고도 런타임 의존성을 쉽게 변경할 수 있다.
새로운 할인 정책 클래스를 추가하더라도 Movie는 DiscountPolicy에만 의존하며 새로운 클래스와 협력할 수 있다.
추상화가 핵심이다
개방-폐쇄 원칙의 핵심은 추상화에 의존하는 것이다.
추상화란 핵심적인 부분만 남기고 불필요한 부분은 생략함으로써 복잡성을 극복하는 기법이다.
개방-폐쇄 원칙의 관점에서 생략되지 않고 남겨진 추상화의 결과물은 수정에 대해 닫혀있고,
추상화를 통해 생략된 부분은 확장의 여지를 남긴다.
abstract class DiscountPolicy {
private conditions: DiscountCondition[] = new Array();
constructor(...conditions: DiscountCondition[]) {
this.conditions = conditions;
}
public calculateDiscountAmount(screening: Screening): number {
for (const each of this.conditions) {
if (each.isSatisfiedBy(screening)) {
return this.getDiscountAmount(screening);
}
}
return screening.getMovieFee();
}
protected abstract getDiscountAmount(screening: Screening): number;
}
DiscountPolicy는 추상화다. 추상화 과정을 통해 생략된 부분은 할인 요금을 계산한느 방법이다.
상속을 통해 생략된 부분을 구체화함으로써 할인 정책을 확장할 수 있다.
- 할인 여부를 판단해서 요금을 계산하는 메서드 calculateDiscountAmount => 변하지 않는 부분
- 할인된 요금을 계산하는 추상 메서드 getDiscountAmount => 변하는 부분
변하지 않는 부분을 고정하고 변하는 부분을 생략하는 추상화 메커니즘이 개방-폐쇄 원칙의 기반이 된다.
단순히 추상화를 했다고 해서 수정에 닫힌 설계를 만들 수 있는 것은 아니다.
폐쇄를 가능하게 하는 것은 의존성의 방향이다. 모든 요소가 추상화에 의존해야 한다.
Movie는 DiscountPolicy에만 의존한다. 의존성은 변경의 영향이며 DiscountPolicy는 변하지 않는 추상화다.
개방-폐쇄 원칙의 핵심은 추상화다.
변하는 것과 변하지 않는 것이 무엇인지를 이해해야 하고 올바른 추상화를 주의 깊게 선택해야 한다.
2. 생성 사용 분리
결합도가 높아질수록 개방-폐쇄 원칙을 따르기 어려워진다. 알아야 하는 지식이 많으면 결합도도 높아진다.
특히 객체 생성에 대한 지식은 과도한 결합도를 초래한다. 부적절한 곳에서 객체를 생성하는 것이 문제다.
class Movie {
private discountPolicy: DiscountPolicy;
constructor(title: string, runnintTime: number, fee: number) {
this.discountPolicy = new AmountDiscountPolicy(...);
}
public calculateMovieFee(screening: Screening) {
return this.fee.minus(this.discountPolicy.calculateDiscountAmount(screening));
}
}
동일한 클래스 안에서 객체 생성과 사용이라는 두 이질적인 목적을 가진 코드가 공존하는 것이 문제다.
객체에 대한 생성과 사용을 분리해야 한다.
class Client {
public getAvatarFee(): number {
const avatar = new Movie("아바타", 120, 10000, new AmountDiscountPolicy(...));
return avatar.getFee();
}
}
객체 생성의 책임을 클라이언트로 옮기면, 현재의 컨텍스트에 관한 결정권을 가지고 있는 클라이언트로 컨텍스트에 대한 지식이 옮겨진다.
Movie는 특정 클라이언트에 결정되지 않고 독립적일 수 있다.
Movie의 의존성을 추상화인 DiscountPolicy로만 제한하기 때문에 확장에 열려 있고 수정에 닫혀 있는 코드를 만들 수 있다.
FACTORY 추가하기
생성 책임을 Client로 옮긴 배경에는 Movie는 특정 컨텍스트에 묶여서는 안 되지만 Client는 묶여도 상관 없다는 전제가 깔려 있다.
Client는 Movie의 인스턴스를 생성하는 동시에 getFee 메시지도 함께 전송한다.
Movie를 생성하는 책임을 Client의 인스턴스를 사용할 문맥을 결정할 클라이언트로 옮기자.
하지만 객체 생성과 관련된 지식이 Client와 협력하는 클라이언트에게까지 새어나가기를 원하지 않는다고 해보자.
객체 생성과 관련된 책임만 전담하는 별도의 객체를 추가하고 Client는 이 객체를 사용하도록 만들자.
이처럼 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체를 FACTORY라고 부른다.
class Factory {
public createAvatarMovie(): Movie {
return new Movie("아바타", 120, 10000, new AmountDiscountPolicy());
}
}
class Client {
private factory: Factory;
constructor(factory: Factory) {
this.factory = factory;
}
public getAvatarFee(): number {
const avatar = this.factory.createAvatarMovie();
return avatar.getFee();
}
}
FACTORY를 사용하면 Movie와 AmountDiscountPolicy를 생성하는 책임 모두를 FACTORY로 옮길 수 있다.
Client는 오직 사용과 관련된 책임만 지고 생성과 관련된 어떤 지식도 가지지 않을 수 있다.
순수한 가공물에게 책임 할당하기
GRASP 패턴에서 책임 할당의 가장 기본이 되는 원칙은 정보 전문가에게 책임을 할당하는 것이다.
정보 전문가를 찾기 위해 도메인 모델 내 개념 중 적절한 후보를 찾아야 한다.
그러나 FACTORY는 도메인 모델에 속하지 않는다.
도메인 개념에게 할당돼 있던 객체 생성 책임을 도메인 개념과는 상관 없는 가공의 객체로 이동시킨 것이다.
시스템을 객체로 분해하는 데는 크게 두 가지 방식이 있다.
1. 표현적 분해
도메인에 존재하는 개념을 표현하는 객체들을 이용해 시스템을 분해하는 것이다.
객체지향 설계를 위한 가장 기본적인 접근법이다.
그러나 도메인 객체들에게만 책임을 할당하는 것만으로는 부족한 경우가 발생한다. 도메인 모델은 단지 출발점이다.
2. 행위적 분해
행위적 분해에 의해 생성되는 것이 순수한 가공물이다.
모든 책임을 도메인 객체에게 할당하면 낮은 응집도, 높은 결합도, 재사용성 저하가 발생할 수 있다.
이 경우 임의로 만들어낸 가공의 객체에게 책임을 할당해야 한다. 이 객체를 순수한 가공물이라 한다.
행동을 책임질 마땅한 도메인 개념이 없다면 순수한 가공물을 추가하고 책임을 할당하라.
객체지향 애플리케이션은 도메인 개념뿐만 아니라 임의로 창조한 인공적인 추상화들을 포함한다.
우리는 도메인 추상화를 기반으로 로직을 설계하는 동시에 품질의 측면에서 균형을 맞추는 데 필요한 객체들을 창조해야 한다.
- 도메인의 본질적인 개념을 표현하는 추상화를 이용해 애플리케이션 구축 시작
- 도메인 개념이 만족스럽지 못하다면 인공적인 객체 창조
FACTORY는 순수한 가공물이다. 대부분의 디자인 패턴은 순수한 가공물을 포함한다.
3. 의존성 주입
Movie는 외부의 다른 객체로부터 생성된 인스턴스를 전달받아야 한다.
사용하는 객체가 아닌 외부의 객체가 인스턴스를 생성한 후 전달해서 의존성을 해결하는 방법을 의존성 주입이라 한다.
의존성 해결은 컴파일타임 의존성과 런타임 의존성의 차이점을 해소하기 위한 다양한 메커니즘을 포괄한다.
의존성 주입은 의존성을 퍼블릭 인터페이스 드러내서 외부에서 런타임 의존성을 전달받을 수 있도록 만드는 방법을 포괄한다.
의존성 주입에는 의존성을 해결하는 세 가지 방법이 있다.
- 생성자 주입: 객체를 생성하는 시점에 생성자를 통한 의존성 해결
생성자 주입으로 설정된 인스턴스는 객체의 생명주기 전체에 걸쳐 관계를 유지한다.
const avatar = new Movie("아바타", 120, 10000, new AmountDiscountPolicy(...));
- setter 주입: 객체 생성 후 setter 메서드를 통한 의존성 해결
언제라도 의존 대상을 교체할 수 있지만, 객체가 올바로 생성되기 위해 어떤 의존성이 필수적인지 명시적으로 표현할 수밖에 없다.
avatar.setDiscountPolicy(new AmountDiscountPolicy(...));
- 메서드 주입 (메서드 호출 주입): 메서드 실행 시 인자를 이용한 의존성 해결
객체가 올바로 생성되기 위해 필요한 의존성을 명확하게 표현할 수 있지만, 주입된 의존성이 한 두 개의 메서드에서만 사용된다면 각 메서드의 인자로 전달하는 게 낫다.
avatar.calculateDiscountAmount(screening, new AmountDiscountPolicy(...));
인터페이스 주입도 있다. 주입할 의존성을 명시하기 위해 인터페이스를 사용하는 것이다.
interface DiscountPolicyInjectable {
inject(discountPolicy: DiscountPolicy): void;
}
class Movie implements DiscountPolicyInjectable {
private discountPolicy: DiscountPolicy;
public inject(discountPolicy: DiscountPolicy): void {
this.discountPolicy = discountPolicy;
}
}
근본적으로는 setter나 프로퍼티 주입과 동일하지만, 어떤 대상을 어떻게 주입할지를 명시적으로 선언한다.
숨겨진 의존성은 나쁘다
의존성 주입 외에도 의존성을 해결할 수 있는 방법은 다양하다.
SERVICE LOCATOR 패턴
service locator는 의존성을 해결할 객체들을 보관하는 저장소다. 객체가 직접 저장소에게 의존성을 해결해줄 것을 요청한다.
이 패턴은 서비스를 사용하는 코드로부터 서비스를 구현한 구체 클래스의 타입이 무엇인지, 클래스 인스턴스를 어떻게 얻을지를 몰라도 되게 해준다.
class Movie {
private title: string;
private runningTime: number;
private fee: number;
private discountPolicy: DiscountPolicy | null;
constructor(title: string, runningTime: number, fee: number) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountPolicy = ServiceLocator.discountPolicy();
}
}
class ServiceLocator {
private static soleInstance: ServiceLocator = new ServiceLocator();
private discountPolicy?: DiscountPolicy;
public static discountPolicy(): DiscountPolicy | null {
return this.soleInstance.discountPolicy || null;
}
public static provide(discountPolicy: DiscountPolicy): void {
this.soleInstance.discountPolicy = discountPolicy;
}
}
Movie는 직접 ServiceLocator의 메서드를 호출해서 DiscountPolicy에 대한 의존성을 해결한다.
ServiceLocator에 인스턴스를 등록하면 이후에 생성되는 모든 Movie는 해당 할인 정책을 기반으로 계산한다.
그러나 이 패턴의 가장 큰 단점은 의존성을 감춘다는 것이다.
Movie는 DiscountPolicy에 의존하고 있지만 어디에도 이 의존성에 대한 정보가 표시돼 있지 않다.
ServiceLocator에 인스턴스를 등록하지 않고 Movie를 생성하면 Movie는 온전히 생성된 것처럼 보이지만 discountPolicy가 null이다.
1. 의존성을 구현 내부로 감추면 관련 문제가 컴파일타임이 아닌 런타임에 발견된다.
문제점을 발견할 수 있는 시점을 코드 실행 시점으로 미루게 되는 것이다.
2. 각 단위 테스트는 서로 고립돼야 한다는 단위 테스트의 기본 원칙을 위반한다.
단위 테스트의 경우에도 테스트 케이스 단위로 테스트에 사용될 객체들을 새로 생성해야 하는데,
ServiceLocator는 내부적으로 정적 변수를 사용해 객체들을 관리하기 때문에 모든 단위 테스트 케이스에 걸쳐 ServiceLocator의 상태를 공유하게 된다.
숨겨진 의존성은 캡슐화를 위반한다.
클래스의 퍼블릭 인터페이스만으로 사용 방법을 이해할 수 있는 코드가 캡슐화의 관점에서 훌륭한 코드다.
service locator는 캡슐화를 위반할 수밖에 없다.
- 의존성 주입이 service locator 패턴보다 좋다 (X)
- 명시적인 의존성이 숨겨진 의존성보다 좋다 (O)
4. 의존성 역전 원칙
추상화와 의존성 역전
class Movie {
private discountPolicy: AmountDiscountPolicy;
}
위 설계는 변경에 취약하다. 요근을 계산하는 상위 정책이 요금을 계산하는 데 필요한 구체적인 방법에 의존한다.
상위 수준 클래스인 Movie가 하위 수준 클래스인 AmountDiscountPolicy에 의존하고 있다.
객체 사이의 협력의 본질을 담고 있는 것은 상위 수준의 정책이다. 본질은 영화의가격 계산이지 할인 금액 계산이 아니다.
상위 수준의 클래스의 변경으로 하위 수준의 클래스가 영향을 받아야 하지 그 반대는 안 된다. 의존성의 방향이 잘못됐다.
상위 수준의 클래스를 재사용할 때 하위 수준의 클래스도 필요하기 때문에 재사용성도 좋지 않다.
DiscountPolicy 추상 클래스를 두면 상위 수준 클래스와 하위 수준 클래스 모두 추상화에 의존하게 된다.
모든 의존성의 방향은 추상 클래스나 인터페이스와 같은 추상화를 따라야 한다.
구체 클래스는 의존성의 시작점이지 목적지점이 아니다.
의존성 역전 원칙
- 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다.
- 추상화는 구체적인 사항에 의존해서는 안 된다. 구체적인 사항은 추상화에 의존해야 한다.
이는 전통적인 절차형 프로그래밍과는 의존성이 반대로 된 것이다.
의존성 역전 원칙과 패키지: SEPERATED INTERFACE 패턴
역전은 의존성의 방향뿐만 아니라 인터페이스의 소유권에도 적용된다. 소유권을 결정하는 것은 모듈이다.
위 설계는 개방-폐쇄 원칙과 의존성 역전 원칙을 따르기 때문에 유연하고 재사용 가능한 것처럼 보인다.
하지만 Movie를 다양한 컨텍스트에서 재사용하기 위해서는 불필요한 클래스들이 Movie와 함께 배포돼야만 한다.
Movie의 재사용을 위해 필요한 것이 DiscountPolicy라면 둘을 모으자.
추상화를 별도의 독립적인 패키지가 아니라 클라이언트가 속한 패키지에 포함시키고,
함께 재사용될 필요가 없는 클래스들은 별도의 패키지에 모아야 한다.
전통적인 설계 패러다임은 인터페이스의 소유권을 클라이언트 모듈이 아닌 서버 모듈에 위치시키는 반면
객체지향 애플리케이션은 인터페이스의 소유권을 서버가 아닌 클라이언트에 위치시킨다.
5. 유연성에 대한 조언
유연하고 재사용 가능한 설계란,
- 런타임 의존성과 컴파일타임 의존성의 차이를 인식하고
- 동일한 컴파일타임 의존성으로부터 다양한 런타임 의존성을 만들 수 있는 코드 구조를 가지는 설계
하지만 유연하고 재사용 가능한 설계는 단순함과 명확함으로부터 멀어진다. 유연성은 복잡성을 수반한다.
설계가 유연할수록 클래스 구조와 객체 구조 사이의 거리는 점점 멀어진다.
단순하고 명확해야 한다면 유연성을 제거하고, 유연성과 재사용성이 중요하다면 코드의 구조와 실행 구조를 다르게 만들자.
설계를 유연하게 만들기 위해서는 역할, 책임, 협력에 초점을 맞춰야 한다.
객체의 역할과 책임이 자리 잡기 전에 성급하게 객체 생성에 집중하지 말자.
(불필요한 싱글톤 패턴은 객체 생성에 관해 너무 이른 시기에 고민할 때 도입되곤 한다.)