개방-폐쇄 원칙: "소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다."
키워드는 '확장'과 '수정'
확장에 대해 열려 있다: 애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 '동작'을 추가해서 애플리케이션의 기능을 확장할 수 있다.
수정에 대해 닫혀 있다: 기존의 '코드'를 수정하지 않고도 애플리케이션의 동작을 추가하거나 변경할 수 있다.
개방-폐쇄 원칙에서 유연한 설계란 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 확장할 수 있는 설계라고 말한다.
일반적으로 애플리케이션의 동작을 확장하기 위해서 코드를 수정하지 않는 거 아닌가?
어떻게 코드를 수정하지 않으면서 새로운 동작을 추가할 수 있는 걸까?
컴파일타임 의존성을 고정시키고 런타임 의존성을 변경하라
사실 개방-폐쇄 원칙은 런타임 의존성과 컴파일타임 의존성에 관한 이야기다.
개방-폐쇄 원칙을 수용하는 코드는 컴파일타임 의존성을 수정하지 않고도 런타임 의존성을 쉽게 변경할 수 있다.
의존성 관점에서 개방-폐쇄 원칙을 따르는 설계란 컴파일타임 의존성은 유지하면서 런타임 의존성의 가능성을 확장하고 수정할 수 있는 구조라고 할 수 있다.
추상화가 핵심이다
개방-폐쇄 원칙의 핵심은 추상화에 의존하는 것이다.
추상화된 부분은 수정에 닫혀 있고, 추상화를 통해 생략된 부분은 확장의 여지를 남긴다.
이것이 추상화가 개방-폐쇄 원칙을 가능하게 만드는 이유다.
언제라도 추상화의 생략된 부분을 채워넣음으로써 새로운 문맥에 맞게 기능을 확장할 수 있다. => 추상화는 설계의 확장을 가능하게 한다.
단순히 어떤 개념을 추상화했다고 해서 수정에 대해 닫혀있는 설계를 만들 수 있는 것은 아니다.
개방-폐쇄 원칙에서 폐쇄를 가능하게 하는 것은 의존성의 방향이다.
수정에 대한 영향을 최소화하기 위해서는 모든 요소가 추상화에 의존해야 한다.
올바른 추상화를 설계하고 추상화에 대해서만 의존하도록 관계를 제한함으로써 설계를 유연하게 확장할 수 있다.
2. 생성 사용 분리
객체 생성에 대한 지식은 코드에 과도한 결합도를 초래하는 경향이 있다.
객체의 타입과 생성자에 전달해야 하는 인자에 대한 과도한 지식은 코드를 특정한 컨텍스트에 강하게 결합시킨다.
객체 생성 자체가 문제인 것이 아니라, 부적절한 곳에 객체를 생성한다는 것이 문제다.
동일한 클래스 안에서 객체 생성과 사용이라는 두 가지 이질적인 목적을 가진 코드가 공존하는 것이 문제인 것이다.
DiscountPolicy 객체 생성과 사용의 책임을 함께 맡고 있는 Movie 클래스
public class Movie {
...
private DiscountPolicy discountPolicy;
public Movie(String title, Duration duration, Money fee){
...
this.dicountPolicy = new AmountDiscountPolicy(...); // 객체 생성
}
public Money calculateMovieFee(Screening screening) {
// 객체 사용
return fee.minus(discountPolicy.calculateDiscountAmount(screeening));
}
}
유연하고 재사용 가능한 설계를 원한다면 객체와 관련된 두 가지 책임을 서로 다른 객체로 분리해야 한다.
생성과 사용을 분리해야 한다.
"소프트웨어 시스템은 응용 프로그램 객체를 제작하고 의존성을 서로 연결하는 시작 단계와 시작 단계 이후 이어지는 실행 단계를 분리해야 한다."
사용으로부터 생성을 분리하는 데 사용되는 가장 보편적인 방법은 객체를 생성할 책임을 클라이언트로 옮기는 것이다.
Movie의 클라이언트가 적절한 DiscountPolicy 인스턴스를 생성한 후 Movie에게 전달하게 한다.
현재의 컨텍스트에 관한 결정권을 가지고 있는 클라이언트로 컨텍스트에 대한 지식을 옮김으로 Movie는 특정한 클라이언트에 결합되지 않고 독립적일 수 있다.
public class Client {
public Money getAvatarFee() {
Movie avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(),
new AmountDiscountPolicy(...));
// AmountDiscountPolicy 객체 생성 책임 수행
return avatar.getFee();
}
}
AmountDiscountPolicy의 인스턴스를 생성하는 책임을 클라이언트에게 맡긴다.
이로써 구체적인 컨텍스트와 관련된 정보는 클라이언트로 옮기고 Movie는 오직 DiscountPolicy의 인스턴스를 사용하는 데만 주력한다.
Movie의 의존성을 추상화인 DiscountPolicy로만 제한하기 때문에 확장에 대해서는 열려있고 수정에 대해서는 닫혀있는 코드를 만들 수 있는 것이다.
Factory 추가하기
생성 책임을 Client로 옮긴 배경에는 Movie는 특정 컨텍스트에 묶여서는 안 되지만 Client는 묶여도 상관 없다는 전제가 깔려 있다.
Movie를 사용하는 Client도 특정한 컨텍스트에 묶이지 않길 바란다고 가정해보자.
기존의 코드에서 Client는 Movie의 인스턴스를 생성하는 동시에 getFee 메시지도 함께 전송한다. => Client 역시 생성과 사용의 책임을 함께 지닌다.
우선 Movie에서 DiscountPolicy 객체의 생성 책임을 Client로 분리해낸 것과 마찬가지로 Client에서 Movie 객체의 생성 책임을 Client와 협력하는 클라이언트로 옮길 수 있을 것이다.
하지만 객체 생성과 관련된 지식이 Client와 협력하는 클라이언트에게까지 새어나가기를 원치 않는다면
이 경우엔 객체 생성과 관련된 책임만 전담하는 별도의 객체를 추가하고 Client가 이 객체를 사용하도록 할 수 있다.
이처럼 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체를 Factory라고 부른다.(enw 연산을 도맡아준다.)
public class Factory {
public Movie createAvatarMovie() {
return new Movie("아바타", // Movie 객체 생성 책임 수행
Duration.ofMinutes(120),
Money.wons(1000),
new AmountDiscountPolicy(...));
// AmountDiscountPolicy 객체 생성 책임 수행행
}
}
public class Client {
private Factory factory;
public Client(Factory factory) {
this.factory = factory;
}
public Money getAvatarFee() {
Movie avatar = factory.createAvatarMovie(); // Factory 객체 사용
return avatar.getFee(); // Movie 객체 사용
}
}
Factory를 사용하면 Movie와 AmountDiscountPolicy를 생성하는 책임을 모두를 Factory로 이동할 수 있다.
이제 Client에는 사용과 관련된 책임만 남게 된다.
Client는 오직 사용과 관려된 책임만 지고 생성과 관련된 어떤 지식도 가지지 않을 수 있다.
순수한 가공물에게 책임 할당하기
어떤 책임을 할당하고 싶다면 제인 먼저 도메인 모델 안의 개념 중에서 적절한 후보가 존재하는지 찾아봐야 한다.
Factory는 도메인 모델에 속하지 않는다.
Factory를 추가한 이유는 순수하게 기술적인 결정: 전체적인 결합도를 낮추고 응집도를 높이기 위해 도메인 개념에 할당돼 있던 객체 생성 책임을 도메인 개념과는 아무런 상관이 없는 가공의 객체로 이동시킨 것
시스템을 객체로 분해하는 두 가지 방식
표현적 분해: 도메인에 담겨있는 개념과 관계를 따르면서 도메인과 소프트웨어 사이의 표현적 차이를 최소화하는 것
행위적 분해: 도메인 안의 개념을 표현하는 객체가 아닌 설계자가 편의를 위해 임의로 만들어낸 가공의 객체에게 책임을 할당하여 소프트웨어 문제를 해결하는 것
책임을 할당하기 위해 창조되는 도메인과는 무관한 인공적인 객체를 Pure Fabrication, 순수한 가공물이라고 부른다.
어떤 행동을 추가하려고 하는데 이 행동을 책임질만한 마땅한 도메인 개념이 존재하지 않는다면 Pure Fabrication을 추가하고 이 객체에게 책임을 할당한다.
도메인 추상화를 기반으로 애플리케이션 로직을 설계하는 동시에, 품질의 측면에서 균형을 맞추는 데 필요한 객체들을 창조하는 것이 설계자의 역할이다.
Pure Fabrication 패턴
Pure Fabrication 패턴, 순수한 가공물 패턴은 정보 전묹가 패턴에 따라 책임을 할당한 결과가 바람직하지 않을 경우 대안으로 사용된다.
어떤 객체가 책임을 수행하는 데 필요한 많은 정보를 가졌지만 해당 책임을 할당할 경우 응집도가 낮아지고 결합도가 높아진다면 가공의 객체를 추가해서 책임을 옮기는 것을 고민해야 한다.
3. 의존성 주입
사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해서 의존성을 해결하는 방법을 의존성 주입이라고 부른다.
의존성 주입에서 의존성을 해결하는 세 가지 방법을 가리키는 별도의 용어
생성자 주입(constructor injection): 객체를 생성하는 시점에 생성자를 통한 의존성 해결
setter 주입(setter injection): 객체 생성 후 setter 메서드를 통한 의존성 해결
메서드 주입(method injecttion): 메서드 실행 시 인자를 이용한 의존성 해결
setter 주입의 장점은 의존성의 대상을 런타임에 변경할 수 있다는 것이다.
setter 주입의 단점은 객체가 올바로 생성되기 위해 어떤 의존성이 필수적인지를 명시적으로 표현할 수 없다는 것이다.
setter 메서드는 객체가 생성된 후에 호출돼야 하기 때문에 setter메서드 호출을 누락한다면 객체는 비정상적인 상태로 생성될 것이다.
메서드 주입은 메서드가 의존성을 필요로 하는 유일한 경우일 때 사용할 수 있다.
생성자 주입의 통해 의존성을 전달받은면 객체가 올바른 상태로 생성되는 데 필요한 의존성을 명확하게 표현할 수 있다는 장점이 있지만 주입된 의존성이 한 두 개의 메서드에서만 사용된다면 각 메서드의 인자로 전달하는 것이 더 나은 방법일 수 있다.
숨겨진 의존성은 나쁘다
의존성 주입 외에도 의존성을 해결할 수 있는 다양한 방법이 존재한다.
그 중 대표적인 방법은 Service Locator 패턴이다.
Service Locator는 의존성을 해결할 객체들을 보관하는 일종의 저장소
외부에서 객체에게 의존성을 전달하는 의존성 주입과는 달리 Service Locator의 경우 객체가 직접 Service Locator에게 의존성을 해결해줄 것을 요청한다.
Service Locator 패턴은 서비스를 사용하는 코드로부터 서비스가 누구인지, 어디에 있는지를 몰라도 되게 해준다.
pulbic 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() {
}
}
public class Movie {
...
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.provide(new AmountDiscountPolicy(...));
Movie avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000));
ServiceLocator.provide(new PercentDiscountPolicy(...));
Movie avatar = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000));
Service Locator 패턴의 가장 큰 단점은 의존성을 감춘다는 것이다.
Movie는 DiscountPolicy에 의존하고 있지만 Movie의 퍼블릭 인터페이스 어디에도 이 의존성에 대한 정보가 표시돼 있지 않다.
이 경우 의존성은 암시적이며 코드 깊숙한 곳에 숨겨져 있다.
숨겨진 의존성이 나쁜 이유는 디버깅하기가 어렵기 때문이다.
의존성을 구현 내부로 감출 경우 의존성과 관련된 문제가 컴파일타임이 아닌 런타임에 가서야 발견된다
숨겨진 의존성이 이해하기 어렵고 디버깅하기 어려운 이유는 문제점을 발견할 수 있는 시점을 코드 작성 시점이 아니라 실행 시점으로 미루기 때문이다.
숨겨진 의존성은 의존성의 대상을 설정하는 시점과 의존성이 해결되는 시점을 멀리 떨어트려 놓아 코드를 이해하고 디버깅하기가 어렵게 만든다.
의존성을 숨기는 코드는 단위 테스트 작성도 어렵다.
숨겨진 의존성이 가지는 가장 큰 문제는 의존성을 이해하기 위해 코드의 내부 구현을 이해할 것을 강요한다는 것 => 숨겨진 의존성은 캡슐화를 위반한다.
숨겨진 의존성보다 명시적인 의존성이 좋다!
가급적 의존성을 객체의 퍼블릭 인터페이스에 노출하라!
의존성 주입은 의존성을 명시적으로 명시할 수 있는 방법 중 하나이다.
4. 의존성 역전 원칙
추상화와 의존성 역전
public class Movie {
private AmountDiscountPolicy discountPolicy;
}
이 설계가 변경에 취약한 이유는 요금을 계산하는 상위 정책이 요금을 계산하는 데 필요한 구체적인 방벙에 의존하기 때문이다.
상위 수준의 클래스가 하위 수준의 클래스에 의존한다면 하위 수준의 변경에 의해 상위 수준 클래스가 영향을 받게 될 것이다.
위 설계의 문제점은 의존성의 방향이 잘못됐다.
상위 수준의 클래스는 어떤 식으로든 하위 수준의 클래스에 의존해서는 안 된다.
이 설계는 재사용성에도 문제가 있다.
상위 수준의 클래스가 하위 수준의 클래스에 의존하면 상위 수준의 클래스를 재사용할 때 하위 수준의 클래스도 필요하기 때문에 재사용하기가 어려워진다.
추상화로 이 문제를 해결할 수 있다.
상위 수준의 클래스와 하위 수준의 클래스 모두가 추상화에 의존하도록 수정하면 하위 수준 클래스의 변경으로 인해 상위 수준의 클래스가 영향을 받는 것을 막을 수 있다.
유연하고 재사용 가능한 설계를 원한다면 모든 의존성의 방향이 추상 클래스나 인터페이스와 같은 추상화를 따라야 한다.
구체 클래스는 의존성의 시작점이어야 한다. 의존성의 목적지가 되서는 안 된다.
의존성 역전 원칙
상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 된다. 두 모두 추상화에 의존해야 한다.
추상화는 구체적인 사항에 의존해서는 안 된다. 구체적인 사항이 추상화에 의존해야 한다.
의존성 역전 원칙과 패키지
역전은 의존성의 방향뿐만 아니라 추상화, 즉 인터페이스의 소유권에도 적용된다.
객체지향 프로그래밍 언어에서 인터페이스의 소유권을 결정하는 것은 모듈이고 자바는 패키지를 이용해 모듈을 구현하고 C#이나 C++은 네임스페이스를 이용해 모듈을 구현한다.
인터페이스가 서버 모듈(패키지)에 위치하는 전통적인 모듈 구조
인터페이스의 소유권을 서버 모듈이 갖는 기존의 모듈 구조에서는 DiscountPolicy가 포함된 패키지 안의 어떤 클래스가 수정되더라도 패키지 전체가 재배포돼야 한다.
이로 인해 이 패키지에 의존하는 Movie 클래스가 포함된 패키지 역시 재컴파일해야 한다.
Moive에 의존하는 또 다른 패키지가 있다면 컴파일은 의존성의 그래프를 타고 애플리케이션 코드 전체로 번져갈 것이다.
AmountDiscountPolicy와 PercentDiscountPolicy 같은 하위 수준의 클래스에서의 변경이 코드 수준에서는 상위 수준의 클래스로 영향을 미치지는 않지만 컴파일타임에는 그 변경의 영향이 미치고 있는 것이다.
이 모듈 구조에서는 Movie와 DiscountPolicy의 협력을 다른 컨텍스트에서 재사용하기 위해 컴파일한다고 할 때, 다른 컨텍스트에서 사용하지도 않은 불필요한 클래스인 AmountDiscountPolicy와 PercentDiscountPolicy까지 컴파일되어 배포해야 한다.
따라서 불필요한 클래스들을 같은 패키지에 두는 것은 전체적인 빌드 시간을 가파르게 상승시킨다..
인터페이스의 소유권을 클라이언트 모듈로 역전시킨 객체지향적인 모듈 구조
DiscountPolicy를 Movie와 같은 패키지로 모으고 AmountDiscountPolicy와 PercentDiscountPolicy를 별도의 패키지에 위치시켜 의존성 문제를 해결할 수 있다.
추상화를 별도의 독립적인 패키지가 아니라 클라이언트가 속한 패키지에 포함시켜야 하고 함께 재사용될 필요가 없는 클래스들은 별도의 독립적인 패키지에 모아야 한다.(Seperated Interface 패턴)
Movie와 추상 클래스인 DiscountPolicy를 하나의 패키지로 모으는 것은 Movie를 특정한 컨텍스트로부터 완벽하게 독립시킨다.
Movie를 다른 컨텍스트에서 재사용하기 위해서는 단지 Movie와 DiscountPolicy가 포함된 패키지만 재사용하면 된다.
새로운 할인 정책을 위해 새로운 패키지를 추가하고 새로운 DiscountPolicy 자식 클래스를 구현하기만 하면 상위 수준의 협력 관계를 재사용할 수 있다.
불필요한 AmountDiscountPolicy와 PercentDiscountPolicy 클래스를 함께 배포할 필요가 없는 것이다.
의존성 역전 원칙에 따라 상위 수준의 협력 흐름을 재사용하기 위해서는 추상화가 제공하는 인터페이스의 소유권 역시 기존의 서버 모듈에서 클라이언트 모듈로 역전시켜야 한다.
5. 유연성에 대한 조언
유연한 설계는 유연성이 필요할 때만 옳다
유연성은 항상 복잡성을 수반한다.
유연하지 않은 설계는 단순하고 명확하다.
설계의 유연함은 단순성과 명확성의 희생 위에서 자라난다.
불필요한 유연성은 불필요한 복잡성을 낳는다.
단순하고 명확한 해법이 그런대로 만족스럽다면 유연성을 제거하라.
변경은 예상이 아니라 현실이어야 한다.
아직 일어나지 않은 변경은 변경이 아니다.
복잡성에 대한 걱정보다 유연하고 재사용 가능한 설계의 필요성이 더 크다면 코드의 구조와 실행 구조를 다르게 만들어라(컴파일타임 의존성과 런타임 의존성을 다르게 하라, 즉 추상화를 이용하라, 동적 바인딩을 이용하라, 객체지향적으로 분해하라.)
협력과 책임이 중요하다.
설계를 유연하게 만들기 위해서는 먼저 역할, 책임, 협력에 초점을 맞춰야 한다.
협력 안에서 객체의 역할과 책임이 자리를 잡기 전에 너무 성급하게 객체 생성에 집중해서는 안 된다.
객체를 생성할 책임을 담당할 객체나 객체 생성 메커니즘을 결정하는 시점은 책임 할당의 마지막 단계로 미뤄야만 한다.
책임 관점에서 객체들 간에 균형이 잡혀 있는 상태라면 생성과 관련된 책임을 지게 될 객체를 선택하는 것은 간단한 책임이 된다.