유연한 설계 - 코드로 이해하는 객체지향
이전 글에서의 다양한 의존성 관리 기법을 원칙 이라는 관점에서 정리해봅시다.
https://sh1mj1-log.tistory.com/154
개방-폐쇄 원칙
개방-폐쇄 원칙(Open-Closed Principle, OCP): 소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다.
여기서 확장 은 애플리케이션(이하 앱)의 동작의 관점, 수정 은 코드의 관점 을 반영합니다.
- 확장에 대해 열려 있다: 앱의 요구사항이 변경될 때 이 변경에 맞게 새로운 동작을 추가 해서 앱의 기능을 확장할 수 있다.
- 수정에 대해 닫혀 있다: 기존의 '코드' 를 수정하지 않고도 앱의 동작을 추가 하거나 변경할 수 있다.
즉, 개방-폐쇄 원칙은 기존의 코드를 수정하지 않고도 앱의 동작을 확장할 수 있도록 하는 원칙입니다.
컴파일 의존성을 고정하고 런타임 의존성을 변경하자.
사실 이전 글에서 이미 이 원칙을 지키면서 설계를 완성했습니다.
중복 할인 정책을 추가하기 위해 기존 클래스 AmountDiscountPolicy, PercentDiscountPolicy 는 전혀 수정하지 않고 NonDiscountPolicy 을 추가하는 것만으로 할인 정책이 없는 영화를 구현해냈습니다.
의존성 관점에서 개방-폐쇄 원칙을 따르는 설계는 컴파일 의존성은 유지하면서 런타임 의존성의 가능성을 확장하고 수정할 수 있는 구조입니다.
추상화가 핵심
변하지 않는 부분은 고정하고 변하는 부분을 생략하는 추상화 매커니즘이 개방- 폐쇄 원칙의 기반이 됩니다.
이전 글에서는 명시적 의존성과 의존성 해결 방법을 통해 컴파일 의존성을 런타임 의존성으로 대체해서 런타임에서의 객체의 행동을 확장할 수 있었습니다. 이를 의존성 해결이라고 했었죠. 이런 기법들이 개방-폐쇄 원칙을 따르는 코드를 작성하는데 중요하지만 핵심은 추상화입니다.
올바른 추상화를 설계하고 추상화에 대해서만 의존하도록 관계를 제한해야 설계를 유연하게 확장할 수 있습니다.
생성과 사용을 분리하자
위 Movie 클래스처럼 동일한 클래스 안에서 객체 생성과 사용이라는 두 가지 이질적인 목적을 가진 코드가 있으면 문제가 됩니다. 당연히 여기서 객체는 표준 클래스의 객체(String 이나 List 같은 것들)가 아닌 것입니다.
유연, 재사용 가능한 설계를 위해서는 객체와 관련된 두 가지 책임을 서로 다른 객체로 분리해야 합니다. 하나는 객체를 생성하는 것, 다른 하나는 객체를 사용하는 것입니다.
즉, 객체에 대한 생성과 사용을 분리(seperating use from creation) 해야 합니다.
그래서 우리는 이전 글에서 객체를 생성하는 책임을 클라이언트로 옮겼습니다. Movie 에게 금액 할인 정책을 적용할지, 비율 할인 정책을 적용할지 아는 것은 그 시점에 Movie 와 협력하는 클라이언트 입니다. 컨텍스트에 대한 지식을 현재 컨텍스트에 대한 결정권을 가지고 있는 클라이언트로 옮겨서 Movie 는 특정한 클라이언트에 결합되지 않고 독립적일 수 있습니다.
이렇게 Movie 의존성을 추상화인 DiscountPolicy 로만 제한하여 확장에는 열려있고 수정에는 닫힌 코드를 만들 수 있습니다.
FACTORY 추가
🙋 그런데 Client 도 사용 책임과 생성 책임을 두 개 다 가지게 되었습니다. 이렇게 되면 동일한 클래스에서 객체 생성과 사용이라는 두가지 다른 책임을 가지게 되는 거 아닐까요???
사실 이전 설계에서는 Movie 는 특정 컨텍스트에 묶여서는 안되지만 Client 는 묶여도 된다는 전제가 깔려 있었습니다. 그렇다면 Client 도 특정 컨텍스트에 묶이지 않아야 한다고 해봅시다.
객체 생성과 관련된 책임만 가지는 별도의 객체를 추가하고 Client 가 이 객체를 사용하도록 만들 수 있습니다.
이렇게 생성과 사용을 분리하기 위해서 객체 생성에 특화된 객체 를 FACTORY 라고 부릅니다.
public class Factory {
public Movie createAvatarMovie() {
return new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(
...));
}
}
이제 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 는 도메인 모델에 속하지 않습니다. 순수 기술적인 결정이지요. 전체 결합도를 낮추기 위해서 도메인 개념에 할당되어 있던 객체 생성 책임을 도메인 개념과는 관련 없는 가상의, 가공의 객체로 이동시켰습니다.
시스템을 객체로 분해하는 데는 크게 표현적 분해(representational decomposition), 행위적 분해(behavioral decomposition) 으로 2가지 있습니다.
표현적 분해는 도메인 모델에 담겨 있는 개념과 관계를 따르며, 도메인 ~ 소프트웨어 사이의 표현 차이를 줄이는 것이 목적입니다. 객체 지향 설계에서 가장 기본적인 접근법이죠.
PURE FABRICATION
하지만 종종 도메인 개념을 표현하는 객체에게 책임 할당하는 것만으로는 부족한 경우가 많습니다. 모든 책임을 도메인 객체에게 할당하면 낮은 응집도, 높은 결합도, 재사용성 저하와 같은 문제를 겪을 것입니다. 이 경우, 편의를 위해 만든 가공의 객체에게 책임을 할당해서 문제를 해결 해야 합니다. 이 인공적인 객체 를 PURE FABRICATION(순수한 가공물) 이라고 합니다.
보통 PURE FABRICATION 은 보통 특정한 행동을 표현하는 것이 일반적이어서 보통 행위적 분해에 의해 생성됩니다.
PURE FABRICATION 은 INFORMATION EXPERT 패턴에 따라 책임을 할당한 결과가 바람직하지 않을 경우 대안으로 사용됩니다.
이 때문에 객체 지향이 실세계의 모방이 아니라고 합니다. 객체 지향 앱은 도메인 개념 뿐 아니라 인공적인 추상화를 가지고 있기 때문입니다. 우리는 도메인 개념을 표현하는 객체, 인공적으로 창조된 객체들이 모여 자신의 역할, 책임을 하고 조화롭게 협력하는 앱을 설계해야 합니다.
먼저 도메인의 본질 개념을 표현하는 추상화를 이용해 앱 구축을 시작하고, 만족스럽지 않다면 인공 객체를 창조합시다! 대부분의 디자인 패턴은 PURE FABRICATION 을 가집니다.
의존성 주입
위처럼 생성과 사용을 분리하면 Movie 에는 오직 인스턴스를 사용하는 책임만 남습니다. 즉, 외부의 다른 객체가 Movie 에게 생성된 인스턴스를 전달해주어야 합니다.
사용하는 객체가 아닌 밖에서 인스턴스를 만들고 이를 전달해서 의존성을 해결 하는 방법을 의존성 주입(Dependency Injection) 이라고 합니다. 외부에서 의존성의 대상을 해결하고 이를 사용하는 객체로 주입하기 때문입니다.
의존성 주입: 사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해서 의존성을 해결하는 방법.
의존성 주입은 의존성 해결을 위해 의존성을 객체의 퍼블릭 인터페이스에 명시적으로 드러내서 외부에서 필요한 런타임 의존성을 전달할 수 있도록 만드는 방법들입니다. 의존성 주입에는 세 가지 별도의 용어를 사용합니다.
- 생성자 주입(constructor injection): 객체 생성 시점에 생성자를 통한 의존성 해결
- setter 주입(setter injection): 객체 생성 후 setter 메서드를 통한 의존성 해결
- 메서드 주입(method injection): 메서드 실행 시 인자를 이용한 의존성 해결
생성자 주입(constructor injection)
Movie avater = new Movie("아바타",
Duration.ofMinutes(120),
Money.wons(10000),
new AmountDiscountPolicy(...));
생성자 주입으로 설정된 인스턴스는 객체의 생명주기 전체에 걸쳐 관계를 유지합니다.
setter 주입(setter injection)
avater.setDiscountPolicy(new AmountDiscountPolicy(...));
의존성의 대상을 런타임에 변경할 수 있다는 장점이 있습니다. 생성자 주입에 비해서 언제라도 의존 대상을 교체할 수 있는 장점을 가집니다.
하지만 단점은 제대로 생성되기 위해서 어떤 의존성이 필수적인지를 명시적으로 표현할 수가 없습니다. setter 메서드는 객체 생성 후에 호출되어야 하기 때문입니다. (C# 진영에서는 setter 주입 대신 프로퍼티 주입(property injection) 이라는 용어를 사용합니다.
메서드 호출 주입(method call injection)
avater.calculateDiscountAmount(screening, new AmountDiscountPolicy(...));
오직 한,두 개의 메서드만 의존성을 필요로 할 때 사용합니다. 메서드 주입은 의존성 주입이라고 볼지, 아닐지에 대해 논란이 조금 있다고 하네요
인터페이스 주입
인터페이스 주입(interface injection)은 주입할 의존성을 명시하기 위해서 인터페이스를 사용 하는 것입니다.
예를 들어서 Movie 에 DiscountPolicy 의 인스턴스를 주입하고 싶다면 아래처럼 DiscountPolicy 를 주입하기 위한 인터페이스를 정의해야 합니다.
public interface DiscountPolicyInjectable {
public void inject(DiscountPolicy discountPolicy)
}
DiscountPolicy 을 주입받기 위해서 Movie 는 이 인터페이스를 구현해야 합니다.
public class Movie implements DiscountPolicyInjectable {
private DiscountPolicy discountPolicy;
@Override
public void inject(DiscountPolicy discountPolicy) {
this.discountPolicy = discountPolicy;
}
}
인터페이스 주입은 근본적으로 setter 주입이나 프로퍼티 주입과 동일합니다. 단지 어떤 대상을 어떻게 주입할 것인지를 인터페이스를 통해서 명시적으로 선언한다는 차이가 있는 것입니다.
숨겨진 의존성을 피하자.
SERVICE LOCATOR 패턴
SERVICE LOCATOR 는 의존성을 해결할 객체들을 보관하는 일종의 저장소 입니다. 외부에서 객체에게 의존성을 전달하는 의존성 주입과 달리 SERIVCE LOCATOR 의 경우 객체가 직접 SERVICE LOCATOR 에게 의존성을 해결해 줄 것을 요청합니다.
예를 들어 ServiceLocator 라는 클래스가 SERIVCE 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();
}
}
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() {
}
}
ServiceLocator 는 :DiscountPolicy 를 등록하고 반환할 수 있는 메서드를 가집니다.
클라이언트에서 :Movie 가 :AmountDiscountPolicy 에게 의존하기를 원한다면 아래처럼 ServiceLocator 에 인스턴스를 등록한 후에 Movie 을 생성하면 됩니다.
ServiceLocator.provide(new AmountDiscountPolicy(...));
Movie avatar = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000));
그러면 이후에 생성되는 모든 Movie 는 비율 할인 정책을 기반으로 할인 요금을 계산합니다.
이렇게 SERVICE LOCATOR 패턴은 의존성을 해결할 수 있는 간단한 도구입니다.
하지만 SERVICE LOCATOR 패턴은 의존성을 감춥니다. Movie 는 DiscountPolicy 에 의존하고 있지만 Movie 의 퍼블릭 인터페이스 어디에도 이 의존성에 대한 정보가 표시되어 있지 않습니다. 암시적으로 의존성이 숨겨져 있는 것이죠.
만약 협업하는 다른 개발자가 아래와 같은 코드를 마주쳤다고 합시다.
Movie avator = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000));
겉으로 보기에 :Movie 의 모든 인자가 생성자에 전달되고 있기 때문에 문제가 없어보입니다. 하지만 사실 위 코드는 ServiceLocator 를 이용해서 Movie 에게 discountPolicy 을 넣어주지 않고 있습니다. 의존성을 해결하고 있지 않는 코드이죠. 그래서 NullPointerException 예외가 던져집니다.
결국 시간을 들여 디버깅한 후에 아래처럼 해야 하는 것을 깨닫게 되겠죠.
ServiceLocator.provide(new PercentDiscountPolicy(...));
Movie avator = new Movie("아바타", Duration.ofMinutes(120), Money.wons(10000));
이렇게 의존성을 구현 내부로 감추면 의존성 관련 문제를 컴파일 타임에 알아채지 못하고 런타임에서 발견하게 됩니다.
즉, 숨겨진 의존성은 이해하기 어렵고 디버깅하기도 어려워집니다.
또 의존성을 숨기는 코드는 단위 테스트 작성하기도 어렵습니다. 일반적인 단위 테스트 프레임워크는 테스트케이스 단위로 테스트에 사용되는 객체를 새로 생성합니다. 하지만 위의 ServiceLocator 는 static 변수를 사용해서 객체를 관리하기 때문에 모든 테스트 케이스들이 같은 ServiceLocator 의 상태(정적 static 변수)를 공유합니다. 이는 각 단위 테스트는 서로 고립되어야 한다는 단위 테스트의 기본 원칙을 위반한 것입니다.
이 문제의 주요한 원인은 무엇일까요? 바로 숨겨진 의존성이 캡슐화를 위반한 것입니다. 인스턴스 변수를 단순히 private 으로 선언해서 숨겼다고 해서 캡슐화가 지켜지는 것은 아닙니다. 훌륭한 캡슐화는 퍼블릭 인터페이스만으로 사용 방법을 이해할 수 있는 코드입니다.
결국 숨겨진 의존성은 의존성을 이해하기 위해서 코드 내부 구현을 이해해야 한다는 것, 그래서 캡슐화를 위반한 것입니다. SERVICE LOCATOR 패턴은 의존성을 구현 내부에 감춰서 캡슐화를 위반했습니다.
하지만 의존성 주입은 필요한 의존성이 클래스의 퍼블릭 인터페이스에 명시적으로 드러납니다. 의존성을 이해하기 위해서 코드 내부 구현을 읽을 필요가 없습니다. 훌륭한 캡슐화가 만들어지고, 의존성 문제도 최대한 컴파일 타임에 잡을 수 있습니다. 단위 테스트도 고립되게 진행할 수 있지요.
핵심은 명시적인 의존성이 숨겨진 의존성보다 좋다는 것입니다.
최대한 의존성을 객체의 퍼블릭 인터페이스에 노출합시다.
의존성 역전 원칙
의존성 역전 원칙(Dependency Inversion Principle, DIP) 는 아래 두 내용을 말합니다.
- 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 된다. 둘 모드 추상화에 의존해야 한다.
- 추상화는 구체적인 사항에 의존해서는 안 된다. 구체적인 사항은 추상화에 의존해야 한다.
의존성 '역전' 이라고 하는 이유는 의존성의 방향이 전통적인 절차형 프로그래밍과는 반대 방향으로 나타나기 때문입니다.
의존성 역전 원칙과 패키지
역전은 의존성의 방향뿐 아니라 인터페이스의 소유권에도 적용되어야 합니다. 개게지향 언어에서 어떤 구성요소의 소유권을 결정하는 것은 모듈입니다. 자바는 패키지를 이요, C# 이나 C++ 은 네임스페이스를 이용해서 모듈을 구현합니다.
위는 개방-폐쇄 원칙(OCP)을 준수할 뿐 아니라 의존성 역전 원칙도 따르고 있습니다. 이 설계가 유연하고 재사용 가능하다고 볼 수 있죠. 하지만 Movie 를 다양한 컨텍스트에 재사용하려면 불필요한 클래스들이 Movie 와 함께 배포되어야 합니다.
Movie 는 DiscountPolicy 에 의존하고 있는데 DiscountPolicy 클래스에 의존하기 위해서는 반드시 같은 패키지에 포함된 AmountDiscountPolicy 클래스와 PercentDiscountPolicy 클래스도 함께 존재해야 합니다.
의존성의 정의에 따르면 Movie 는 DiscountPolicy 을 수정하지 않을 경우에는 영향을 받지 말아야 합니다. 하지만 현재 상황은 코드 수정에 있어서는 영향을 받지 않지만 컴파일 측면에서는 그렇지 않습니다. DiscountPolicy 가 포함된 패키지 안의 어떤 클래스가 수정되더라도 패키지 전체가 재배포되어야 하기 때문입니다. 그렇다면 이 패키지에 의존하는 Movie 클래스가 있는 패키지도 재컴파일되어야 하지요. 또 Movie 에 의존하는 또 다른 패키지가 있다면 역시 재컴파일되어야 합니다. 즉, 불필요한 클래스들을 같은 패키지에 두는 것은 전체적인 빌드 시간을 엄청나게 늘립니다.
그렇다면 아래처럼 해결할 수 있을 것입니다.
SEPARATED INTERFACE 패턴
이렇게 추상화를 별도의 독립적인 패키지가 아닌 클라이언트가 속한 패키지에 포함시켜야 합니다. 이 기법을 SEPARATED INTERFACE 패턴 이라고 합니다.
이렇게 Movie 와 추상 클래스인 DiscountPolicy 을 하나의 패키지로 모아서 Movie 을 특정 컨텍스트로부터 완벽히 독립시킵니다. 만약 Movie 를 다른 컨텍스트에서 재사용하기 위해서는 단지 Movie 와 DiscountPolicy 가 포함된 패키지만 재사용하면 됩니다.
새로운 할인 정책을 위해서 새로운 패키지를 추가하고 새로운 DiscountPolicy 의 자식 클래스를 구현하기만 하면 상위 수준의 협력 관계를 재사용할 수 있습니다. 불필요한 AmountDiscountPolicy, PercentDiscountPolicy 클래스를 함께 배포할 필요가 없지요.
위처럼 기존 할인 정책 변경이 없으니 기존 AmountDiscountPolicy, PercentDiscountPolicy 는 재배포를 하지 않아도 됩니다. DiscountPolicyImplVer1 에 NoneDiscountPolicy, CompoundDiscountPolicy 를 추가하면 해당 패키지 전체를 재배포해야 해서 빌드 시간이 굉장히 늘어납니다..
만약 기존 DiscountPolicyImpVer1 에 굉장히 많은 구현체 클래스가 있다면 단순 두 개의 클래스를 추가하는데 빌드 시간이 굉장히 많이 들게 되기 때문에 새로운 패키지(DiscountPolicyImpVer2) 를 만들어서 해당 패키지만 재배포하도록 할 수 있습니다.
의존성 역전 원칙에 따라 상위 수준의 협력을 재사용하기 위해서는 추상화가 제공하는 인터페이스의 소유권 역시 역전시켜야 합니다. 인터페이스의 소유권을 서버가 아닌 클라이언트에 위치시키는 것이지요. 이는 객체지향 프레임 워크의 모듈 구조 설계의 핵심입니다.