09 유연한 설계
01 개방-폐쇄 원칙
개방-폐쇄 원칙은 다음과 같은 문장으로 요약할 수 있다.
소프트웨어 개체(클래스,모듈,함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다.
여기서 키워드는 '확장'과 '수정'이다. 이 둘은 순서대로 애플리케이션의 '동작'과 '코드'의 관점을 반영한다.
* 확장에 대해 열려 있다. 애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 '동작'을 추가해서 애플리케이션의 기능을 확장할 수 있다.
* 수정에 대해 닫혀 있다. 기존의 '코드'를 수정하지 않고도 애플리케이션의 동작을 추가하거나 변경할 수 있다.
개방-폐쇄 원칙은 유연한 설계란 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 확장할 수 있는 설계라고 이야기한다.
컴파일타임 의존성을 고정시키고 런타임 의존성을 변경하라
개발-폐쇄 원칙은 런타임 의존성과 컴파일타임 의존성에 관한 이야기다.
런타임 의존성은 실행시에 협력에 참여하는 객체들 사이의 관계다.
컴파일타임 의존성은 코드에서 드러나는 클래스들 사이의 관계다.
컴파일 의존성 관점에서 Movie 클래스는 추상 클래스인 DiscountPolicy에 의존한다.
런타임 의존성 관점에서 Movie 인스턴스는 AmountDiscountPolicy와 PercentDiscountPolicy 인스턴스에 의존한다.
두 경우 모두 기존 클래스는 전혀 수정하지 않은 채 애플리케이션의 동작을 확장했다. 단순히 새로운 클래스를 추가하는 것만으로 Movie를 새로운 컨텍스트에 사용되도록 확장할 수 있었던 것이다.
현재의 설계는 새로운 할인 정책을 추가해서 기능을 확장할 수 있도록 허용한다. 따라서 '확장에 대해서는 열려 있다.'. 현재의 설계는 기존 코드를 수정할 필요 없이 새로운 클래스를 추가하는 것만으로 새로운 할인 정책을 확장할 수 있다.
추상화가 핵심이다.
개방-폐쇄 원칙의 핵심은 추상화에 의존하는 것이다. 여기서 '추상화'와 '의존'이라는 두 개념 모두가 중요하다.
추상화란 핵심적인 부분만 남기고 불필요한 부분은 생략함으로써 복잡성을 극복하는 기법이다.
개방-폐쇄 원칙의 관점에서 생략되지 않고 남겨지는 부분은 다양한 상황에서의 공통점을 반영한 추상화의 결과물이다.
공통적인 부분은 문맥이 바뀌더라도 변하지 않아야 한다. 다시 말해서 수정할 필요가 없어야 한다. 따라서 추상화 부분은 수정에 대해 닫혀 있다.
이해를 돕기 위해 DiscountPolicy의 코드를 살펴보자
public abstract class DiscountPolicy {
private List<DiscountCondition> conditions = new ArrayList<>();
public DiscountPolicy(DiscountCondition ... conditions) {
this.conditions = Arrays.asList(conditions);
}
public Money calculateDiscountAmount(Screening screening) {
for(DiscountCondition each : conditions) {
if (each.isSatisfiedBy(screening)) {
return getDiscountAmount(screening);
}
}
return screening.getMovieFee();
}
abstract protected Money getDiscountAmount(Screening Screening);
}
DiscountPolicy는 할인 여부를 판단해서 요금을 계산하는 calculateDiscountAmount 메서드와 조건을 만족할 때 할인된 요금을 계산하는 추상 메서드인 getDiscountAmount메서드로 구성돼 있다. 여기서 변하지 않는 부분은 할인 여부를 판단하는 로직이고 변하는 부분은 할인된 요금을 계산하는 방법이다.
따라서 DiscountPolicy는 추상화다. 추상화 과정을 통해 생략된 부분은 할인 요금을 계산하는 방법이다. 우리는 상속을 통해 생략된 부분을 구체화함으로써 할인 정책을 확장할 수 있는 것이다.
수정에 대한 영향을 최소화하기 위해서는 모든 요소가 추상화에 의존해야 한다. Movie 클래스를 살펴보자
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountPolicy = discountPolicy;
}
public Money getFee() {
return fee;
}
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
Movie는 할인 정책을 추상화한 DiscountPolicy에 대해서만 의존한다. 의존성은 변경의 영향을 의미하고 DiscountPolicy는 변하지 않는 추상화라는 사실에 주목하라.
Movie는 안정된 추상화인 DiscountPolicy에 의존하기 때문에 할인 정책을 추가하기 위해 DiscountPolicy의 자식 클래스를 추가하더라도 영향을 받지 않는다. 따라서 Movie와 DiscountPolicy는 수정에 대해 닫혀 있다.
02 생성 사용 분리
Movie가 오직 DiscountPolicy라는 추상화에만 의존하기 위해서는 Movie 내부에서 AmountDiscountPolicy 같은 구체 클래스의 인스턴스를 생성해서는 안 된다. AmountDiscountPolicy의 인스턴스를 생성하는 부분을 PercentDiscountPolicy의 인스턴스를 생성하도록 직접 코드를 수정하는 것뿐이다. 이것은 동작을 추가하거나 변경하기 위해 기존의 코드를 수정하도록 만들기 때문에 개방-폐쇄 원칙을 위반한다.
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountPolicy = discountPolicy;
}
public Money getFee() {
return fee;
}
public Money calculateMovieFee(Screening screening) {
return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}
}
객체 생성이 문제가 아니라 부적절한 곳에서 객체를 생성한다는 것이 문제다. Movie의 코드를 살펴보면 생성자 안에서는 DiscountPolicy의 인스턴스를 생성하고, calculateMovieFee 메서드 안에서는 이 객체에게 메시지를 전송한다는 것을 알 수 있다.
메시지를 전송하지 않고 객체를 생성하기만 한다면 아무런 문제가 없었을 것이다. 또는 객체를 생성하지 않고 메시지를 전송하기만 했다면 괜찮았을 것이다. 동일한 클래스 안에서 객체 생성과 사용이라는 두 가지 이질적인 목적을 가진 코드가 공존하는 것이 문제인 것이다.
유연하고 재사용 가능한 설계를 원한다면 객체와 관련된 두 가지 책임을 서로 다른 객체로 분리해야 한다. 하나는 객체를 생성하는 것이고, 다른 하나는 객체를 사용하는 것이다. 한 마디로 말해서 객체에 대한 생성과 사용을 분리해야 한다.
사용으로부터 생성을 분리하는 데 사용되는 가장 보편적인 방법은 객체를 생성할 책임을 클라이언트로 옮기는 것이다. 다시 말해서 Movie의 클라이언트가 적절한 DiscountPolicy 인스턴스를 생성한 후 Movie에게 전달하게 하는 것이다.
조금만 생각해보면 이 방법이 타당하다는 사실을 알 수 있는데, Movie에게 금액 할인 정책을 적용할지, 비율 할인 정책을 적용할지를 알고 있는 것은 그 시점에 Movie와 협력할 클라이언트이기 때문이다.
public class Client {
public Money getAvatarFee() {
Movie avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(
Money.wons(800),
new SequenceCondition(1),
new SequenceCondition(10)));
return avatar.getFee();
}
}
Movie의 의존성을 추상화인 DiscountPolicy로만 제한하기 때문에 확장에 대해서는 열려 있으면서도 수정에 대해서는 닫혀 있는 코드를 만들 수 있는 것이다.
FACTORY 추가하기
생성 책임을 Client로 옮긴 배경에는 Movie는 특정 컨텍스트에 묶여서는 안 되지만 Client는 묶여도 상관이 없다는 전제가 깔려 있다. 하지만 Movie를 사용하는 Client도 특정한 컨텍스트에 묶이지 않기를 바란다고 가정하자
Client의 코드를 다시 살펴보면 Movie의 인스턴스를 생성하는 동시에 getFee 메시지도 함께 전송한다는것을 알 수 있다. Client 역시 생성과 사용의 책임을 함께 지니고 있는 것이다.
객체 생성과 관련된 지식이 Client와 협력하는 클라이언트에게까지 세어나가기를 원하지 않는다고 가정해보자.
이 경우 객체 생성과 관련된 책임만 전담하는 별도의 객체를 추가하고 Client는 이 객체를 사용하도록 만들 수 있다. 이처럼 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체를 FACTORY라고 부른다.
public class Factory {
public Movie createAvatarMovie() {
return new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(
Money.wons(800),
new SequenceCondition(1),
new SequenceCondition(10)));
}
}
이제 Client는 Factory를 사용해서 생성된 Movie의 인스턴스를 반환받아 사용하기만 하면 된다.
public class Client {
private Factory factory;
public Client(Factory factory) {
this.factory = factory;
}
public Money getAvatarFee() {
Movie avatar = factory.createAvatarMovie();
return avatar.getFee();
}
}
FACTORY를 사용하면 Movie와 AmountDiscountPolicy를 생성하는 책임 모두를 FACTORY로 이동할 수 있다.
이제 Client에는 사용과 관련된 책임만 남게 되는데 하나는 FACTORY를 통해 생성된 Movie 객체를 얻기 위한 것이고 다른 하나는 Movie를 통해 가격을 계산하기 위한 것이다.
순수한 가공물에게 책임 할당하기
책임 할당의 가장 기본이 되는 원칙은 책임을 수행하는 데 필요한 정보를 가장 많이 알고 있는 INFORMATION EXPORT에게 책임을 할당하는 것이다. 도메인 모델은 INFORMATION EXPERT를 찾기 위해 참조할 수 있는 일차적인 재료다.
크레이그 라만은 시스템을 객체로 분해하는 데는 크게 두 가지 방식이 존재한다고 설명한다. 하나는 표현적 분해이고 다른 하나는 행위적 분해다.
표현적 분해는 도메인이 존재하는 사물 또는 개념을 표현하는 객체들을 이용해 시스템을 분해하는 것이다.
도메인 개념을 표현하는 객체에게 책임을 할당하는 것만으로는 부족한 경우가 발생한다.
도메인 모델은 설계를 위한 중요한 출발점이지만 단지 출발점이라는 사실을 명심해야 한다.
책임을 할당하기 위해 창조되는 도메인과 무관한 인공적인 객체를 PURE FABRICATION(순수한 가공물)이라고 부른다.
어떤 행동을 추가하려고 하는데 이 행동을 책임질 마땅한 도메인 개념이 존재하지 않는다면 PURE FABRICATION을 추가하고 이 객체에게 책임을 할당하라. PURE FABRICATION은 표현적 분해보다는 행위적 분해에 의해 생성되는 것이 일반적이다.
03 의존성 주입
생성과 사용을 분리하면 Movie에는 오로지 인스턴스를 사용하는 책임만 남게 된다. 이것은 외부의 다른 객체가 Movie에게 생성된 인스턴스를 전달해야 한다는 것을 의미한다. 이처럼 사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해서 의존성을 해결하는 방법을 의존성 주입이라고 부른다.
의존성 주입에서는 의존성을 해결하는 세 가지 방법을 가리키는 별도의 용어를 정의한다.
* 생성자 주입 : 객체를 생성하는 시점에 생성자를 통한 의존성 해결
* setter 주입 : 객체 생성 후 setter 메서드를 통한 의존성 해결
* 메서드 주입 : 메서드 실행 시 인자를 이용한 의존성 해결
다음은 Movie 생성자의 인자로 AmountDiscountPolicy의 인스턴스를 전달해서 DiscountPolicy 클래스에 대한 컴파일타임 의존성을 런타임 의존성으로 대체하는 예를 나타낸 것이다. 이 예에서는 Movie의 생성자를 이용해 의존성을 주입하기 때문에 생성자 주입이라고 부른다.
public class Factory {
public Movie createAvatarMovie() {
return new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(
Money.wons(800),
new SequenceCondition(1),
new SequenceCondition(10)));
}
}
setter 주입은 이미 생성된 Movie에 대해 setter 메서드를 이용해 의존성을 해결한다. setter 주입의 장점은 의존성의 대상을 런타임에 변경할 수 있다는 것이다.
avatar.setDiscountPolicy(new AmountDiscountPolicy());
메서드 주입은 메서드 호출 주입(method call injection)이라고도 부르며 메서드가 의존성을 필요로하는 유일한 경우일 때 사용할 수 있다. 생성자 주입을 통해 의존성을 전달받으면 객체가 올바른 상태로 생성되는 데 필요한 의존성을 명확하게 표현할 수 있다는 장점이 있지만 주입된 의존성이 한 두개의 메서드에서만 사용된다면 각 메서드의 인자로 전달하는 것이 더 나은 방법일 수 있다.
avatar.calculateMovieFee(screening,new AmountDiscountPolicy())
숨겨진 의존성은 나쁘다.
의존성 주입 외에도 의존성을 해결할 수 있는 다양한 방법이 존재한다. 그중에서 가장 널리 사용되는 대표적인 방법은 SERVICE LOCATOR 패턴이다. SERVICE LOCATOR는 의존성을 해결할 객체들을 보관하는 일종의 저장소다. 외부에서 객체에게 의존성을 전달하는 의존성 주입과 달리 SERVICE LOCATOR의 경우 객체가 직접 SERVICE LOCATOR에게 의존성을 해결해줄 것을 요청한다.
SERVICE LOCATOR 패턴은 서비스를 사용하는 코드로부터 서비스가 누구인지(서비스를 구현한 구체 클래스의 타입이 무엇인지), 어디에 있는지(클래스 인스턴스를 어떻게 얻을지)를 몰라도 되게 해준다.
SERVICE LOCATOR 버전의 Movie는 직접 ServiceLocator의 메서드를 호출해서 DiscountPolicy에 대한 의존성을 해결한다.
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private DiscountPolicy discountPolicy;
public Movie(String title, Duration runningTime, Money fee) {
this.title = title;
this.runningTime = runningTime;
this.fee = fee;
this.discountPolicy = ServiceLocator.discountPolicy();
}
}
ServiceLocator는 DiscountPolicy의 인스턴스를 등록하고 반환할 수 있는 메서드를 구현한 저장소다.
ServiceLocator는 DiscountPolicy의 인스턴스를 등록하기 위한 provide 메서드와 인스턴스를 반환하는 discountPolicy 메서드를 구현한다.,
public class ServiceLocator {
private static ServiceLocator soleInstance = new ServiceLocator();
private DiscountPolicy discountPolicy;
public static DiscountPolicy discountPolicy() {
return soleInstance.discountPolicy;
}
public static void provide(DiscountPolicy discountPolicy) {
soleInstance.discountPolicy = discountPolicy;
}
private ServiceLocator() {
}
}
Movie의 인스턴스가 AmountDiscountPolicy의 인스턴스에 의존하기를 원한다면 다음과 같이 ServiceLocator에 인스턴스를 등록한 후 Movie를 생성하면 된다.
ServiceLocator.provide(new AmountDiscountPolicy());
Movie avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000));
ServiceLocator에 PercentDiscountPolicy의 인스턴스를 등록하면 이후에 생성되는 모든 Movie는 비율 할인 정책을 기반으로 할인 요금을 계산한다.
ServiceLocator.provide(new PercentDiscountPolicy());
Movie avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000));
여기까지만 보면 SERVICE LOCATOR 패턴은 의존성을 해결할 수 있는 가장 쉽고 간단한 도구인 것처럼 보인다.
개인적으로 SERVICE LOCATOR 패턴을 선호하지 않는다. SERVICE LOCATOR 패턴의 가장 큰 단점은 의존성을 감춘다는 것이다.
숨겨진 의존성이 나쁜 이유를 이해하기 위해 다음과 같이 Movie를 생성하는 코드와 마추쳤다고 가정해보자
Movie avatar = new Movie("아바타",
Duration.of(120),
Money.wons(10000));
아래 코드를 실행해보면 NullPointerException 에외가 던져진다.
avatar.calculateMovieFee(screening);
Movie의 인스턴스를 생성하기 바로 전에 다음과 같은 코드를 추가해서 문제를 해결할 것이다.
ServiceLocator.provide(new PercentDiscountPolicy());
Movie avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000));
의존성을 구현 내부로 감출 경우 의존성과 관련된 문제가 컴파일타임이 아닌 런타임에 가서야 발견된다는 사실을 알 수 있다.
숨겨진 의존성이 이해하기 어렵고 디버깅하기 어려운 이유는 문제점을 발견할 수 있는 시점을 코드 작성 시점이 아니라 실행 시점으로 미루기 때문이다.
의존성 주입이 SERVICE LOCATOR 패턴보다 좋다가 아니라 명시적인 의존성이 숨겨진 의존성보다 좋다는 것이다.
어쩔 수 없이 SERVICE LOCATOR 패턴을 사용해야 하는 경우도 있다. 의존성 주입을 지원하는 프레임워크를 사용하지 못하는 경우나 깊은 호출 계층에 걸쳐 동일한 객체를 계속해서 전달해야 하는 고통을 견디기 어려운 경우에는 어쩔 수 없이 SERVICE LOCATOR 패턴을 사용하는 것도 고려하라.
04 의존성 역전 원칙
추상화와 의존성 역전
Movie는 구체 클래스에 대한 의존성으로 인해 결합도가 높아지고 재사용성과 유연성이 저해된다.
public class Movie {
private DiscountPolicy discountPolicy;
}
상위 클래스 Movie가 하위 수준 클래스인 AmountDiscountPolicy에 의존한다.
상위 수준의 클래스가 하위 수준의 클래스에 의존한다면 하위 수준의 변경에 의해 상위 수준 클래스가 영향을 받게 될 것이다.
하위 수준의 AmountDiscountPolicy를 PercentDiscountPolicy로 변경한다고 해서 상위 수준의 Movie가 영향을 받아서는 안된다. 상위 수준의 Movie의 변경으로 인해 하위 수준의 AmountDiscountPolicy가 영향을 받아야 한다.
해결사는 추상화다. Movie는 추상 클래스인 DiscountPolicy에 의존한다. AmountDiscountPolicy도 추상 클래스인 DiscountPolicy에 의존한다. 다시 말해서 상위 수준의 클래스와 하위 수준의 클래스 모두 추상화에 의존한다.
추상화에 의존하라
지금까지 살펴본 내용들을 정리해보자.
1. 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다.
2. 추상화는 구체적인 사항에 의존해서는 안된다. 구체적인 사항은 추상화에 의존해야 한다.
이를 의존성 역전 원칙이라고 부른다.
의존성 역전 원칙과 패키지
의존성 역전 원칙과 관련해서 한 가지 더 언급할 가치가 있는 내용이 있다. 역전은 의존성의 방향뿐만 아니라 인터페이스의 소유권에도 적용된다. 객체지향 프로그래밍 언어에서 어떤 구성 요소의 소유권을 결정하는 것은 모듈이다. 자바는 패키지를 이용해 모듈을 구현한다.
재사용될 필요가 없는 클래스들은 별도의 독립적인 패키지에 모아야 한다.
Movie와 추상 클래스인 DiscountPolicy를 하나의 패키지로 모으는 것은 Movie를 특정한 컨텍스트로부터 완벽하게 독립시킨다.
휼륭한 객체지향 설계를 위해서는 의존성을 역전시켜야 한다. 그리고 의존성을 역전시켜야만 유연하고 재사용 가능한 설계를 얻을 수 있다. 이것이 핵심이다.
05 유연성에 대한 조언
유연하고 재사용 가능한 설계란 런타임 의존성과 컴파일타임 의존성의 차이를 인식하고
동일한 컴파일 타임 의존성으로부터 런타임 의존성을 만들 수 있는 코드 구조를 가진 설계를 의미한다.
하지만 유연하고 재사용 가능한 설계가 항상 좋은 것은 아니다.
설계의 미덕은 단순함과 명확함으로부터 나온다. 단순하고 명확한 설계를 가진 코드는 읽기 쉽고 이해하기도 편하다.
변경하기 쉽고 확장하기 쉬운 구조를 만들기 위해서는 단순함과 명확함을 버리게 된다.
유연한 설계 = 복잡한 설계
사실 유연한 설계 = 복잡한 설계 이다.
변경에 대비 하기 위해 유연한 설계를 하고 복잡한 구조를 만든다.
하지만 변경은 예상이 아니라 현실이어야 한다. 미래에 일어날지도 모른다는 막연한 불안감은 불필요하게 복잡한 설계를 낳는다.
아직 일어나지 않은 변경은 변경이 아니다.
유연성은 항상 복잡성을 수반한다
유연하지 않은 설계는 단순하고 명확하다. 유연한 설계는 복잡하고 암시적이다.
객체지향에 입문한 개발자들이 가장 이해하기 어려워하는 부분이 바로 코드 상에 표현된 정적인 클래스 구조와 실행 시점의 동적인 구조가 다르다는 사실이다.
절차적 프로그래밍 방식은 코드의 구조가 곧 실행구조이다.
하지만 객체지향 프로그래밍 방식에서 클래스의 구조는 발생 가능한 모든 객체 구조를 담는 틀일 뿐이다.
특정 시점의 객체 구조를 파악하는 유일한 방법은 클래스를 사용하는 클라이언트 코드 내에서 객체를 생성하거나 변경하는 부분을 직접 살펴보는 것이다.
불필요한 유연성은 불필요한 복잡성을 낳는다.
단순하고 명확한 해법이 그런대로 만족스럽다면 유연성을 제거하라
유연성은 코드를 읽는 사람들이 복잡함을 수용할 수 있을 때만 가치가 있다.
하지만 복잡성에 대한 걱정보다 유연하고 재사용 가능한 설계의 필요성이 더 크다면 코드의 구조와 실행 구조를 다르게 만들어야 한다.
협력과 책임이 중요하다.
설계를 유연하게 만들기 위해서는 먼저 역할, 책임, 협력에 초점을 맞춰야 한다.
다양한 컨텍스트에서 재사용할 필요가 없다면 설계를 유연하게 만들 당위성도 함께 사라진다.
중요한 비지니스 로직을 처리하기 위해 책임을 할당하고 협력의 균형을 맞추는 것이 객체 생성에 관한 책임을 할당하는 것 보다 우선이다.
객체를 생성하는 방법에 대한 결정은 모든 책임이 자리를 잡은 후 가장 마지막 시점에 내리는 것이 적절하다.
의존성을 관리해야 하는 이유는 역할, 책임, 협력의 관점에서 설계가 유연하고 재사용 가능해야 하기 때문이다.
따라서 역할, 책임, 협력에 먼저 집중해야 한다.