DIP

  • 유연성이 극대화된 시스템 : 소스 코드 의존성이 추상(Abstraction)에 존재하며 구체(Concretion)에는 의존하지 않는 시스템

자바와 같은 정적 타입 언어에서 이 말은 user, import, include 구문은 오직 인터페이스나 추상 클래스 같은 추상적인 선언만 참조해야 한다는 뜻이다.

루비나 파이썬과 같은 동적 타입 언어에도 동일한 규칙이 적용된다.

BUT 이 아이디어를 규칙으로 보기는 확실히 비현실적이다.
SW 시스템이라면 구체적인 많은 장치에 반드시 의존하기 때문이다.
-> EX) 자바에서는 String은 구체 클래스이며, 이를 추상클래스로 만들지도 못한다.
java.lang.String 구체 클래스에 대한 소스 코드 의존성은 벗어날 수 없고, 벗어나서도 안된다.

반면 String 클래스는 매우 안정적이다.
String 클래스가 변경되는 일은 거의 없으며, 있더라도 엄격하게 통제된다.
프로그래머는 String 클래스에서 변경을 고려하지 않아도 된다.

*이러한 이유로 DIP를 논할 때 OS나 플랫폼 같이 안정성이 보장된 환경에 대해서는 무시하는 편이다.
-> 우리가 의존하지 않도록 하는 것은 변동성이 큰 구체적인 요소다.
-> 이 구체적인 요소는 우리가 열심히 개발하는 중이라 자주 변경될 수밖에 없는 모듈이다. *

안정된 추상화

추상 인터페이스에 변경이 생기면 이를 구현한 구현체들도 수정해줘야 한다.
반대로 구현체에 변경이 생기더라도 인터페이스는 대다수 변경될 필요가 없다.
-> 인터페이스는 구현체보다 변동성이 낮다.

안정된 SW 아키텍처란 변동성이 큰 구현체에 의존하는 일은 지양하고
안정된 추상 인터페이스를 선호하는 아키텍처라는 뜻이다.

구체적인 코딩 실천법

  • 변동성이 큰 구체 클래스를 참조하지 말라
    • 대신 추상 인터페이스를 참조하라
    • 이 규칙은 언어가 정적/동적 관계없이 적용된다.
    • 객체 생성 방식을 강하게 제약하며, 일반적으로 추상 팩토리를 사용하도록 강제한다
  • 변동성이 큰 구체 클래스로부터 파생하지 말라
    • 이전 규칙의 이어진 정리
    • 상속은 소스 코드에 존재하는 모든 관계 중 가장 강력한 동시에 뻣뻣해서 변경이 어렵다.
    • 따라서 상속은 아주 신중하게 사용해야 한다.
  • 구체 함수를 오버라이드 하지 말라
    • 대체로 구체함수는 소스 코드 의존성을 필요로 한다.
    • 따라서 구체 함수를 오버라이드하면 이러한 의존성을 제거할 수 없고, 그 의존성을 상속하게 된다.
    • 의존성을 제거하려면, 추상 함수로 선언하고 구현체들에서 각자의 용도에 맞게 구현해야 한다.
  • 구체적이며 변동성이 크다면 그 이름을 사용하지 말라

팩토리

이 규칙들을 준수하려면 변동성이 큰 구체적인 객체는 특별히 주의해서 생성해야 한다.
모든 언어에서 객체를 생성하려면 해당 객체를 구체적으로 정의한 코드에 대해 소스 코드 의존성이 발생하기 때문이다.

추상 팩토리 사용 구조

Application은 Service 인터페이스를 통해 ConcreteImpl을 사용하지만,
Application에서는 어떤 식으로든 ConcreteImpl의 인스턴스를 생성해야 한다.

ConcreteImpl에 대해 소스 코드 의존성을 만들지 않으면서 목적을 이루기 위해 Application은 ServiceFactory 인터페이스의 makeSvc 메서드를 호출한다.
이 메서드는 ServiceFactory로부터 파생된 ServiceFactoryImpl에서 구현된다.
그리고 ServiceFactoryImpl 구현체가 ConcreteImpl의 인스턴스를 생성한 후 Service 타입으로 반환한다.

곡선은 아키텍처 경계를 뜻한다. 이 곡선은 구체적인 것들로부터 추상적인 것들을 분리한다.
소스 코드 의존성은 해당 곡선과 교차할 때, 모두 한 방향, 즉 추상적인 쪽으로 향한다.

곡선은 시스템을 두 가지 컴포넌트로 분리한다. -> 추상/구체 컴포넌트
추상 컴포넌트는 애플리케이션의 모든 고수준 업무 규칙을 포함한다.
구체 컴포넌트는 업무 규칙을 다루기 위해 필요한 모든 세부사항을 포함한다.
제어 흐름은 소스 코드 의존성과는 정반대 방향으로 곡선을 가로지른다는 점에 주목하자.

구체 컴포넌트

구체 컴포넌트에슨 구체적인 의존성이 하나 있고 -> ServiceFactoryImpl 구체 클래스가 ConcreteImpl 구체 클래스에 의존
따라서 DIP에 위배된다.
하지만 DIP 위배를 모두 없앨 수는 없다.
DIP를 위배하는 클래스들은 적은 수의 구체 컴포넌트 내부로 모을 수 있고, 이를 통해 시스템의 나머지 부분과는 분리할 수 있다.

대다수의 시스템은 이러한 구체 컴포넌트를 최소한 하나는 포함할 것이다.
흔히 컴포넌트를 Main이라 부르는데, main 함수를 포함하기 때문이다.
위의 그림에서 main 함수는 ServiceFactoryImpl의 인스턴스를 생성한 후, 이 인스턴스를 ServiceFactory 타입으로 저장
그 후, Application은 이 변수를 이용해서 ServiceFactoryImpl 인스턴스에 접근할 것이다.

결론

앞으로 고수준의 아키텍처를 다루면서 DIP는 자주 나온다.
그리고 DIP는 아키텍처 다이어그램에서 가장 눈에 띄는 원칙이 될 것이다.
그리고 의존성은 위의 곡선을 경계로 더 추상적인 엔티티가 있는 쪽으로만 향한다.

다수의 사용자가 OPS 클래스의 오퍼레이션을 사용한다.
User1 2 3은 오직 op1 2 3만을 사용한다고 가정하자.

그리고 OPS가 정적타입 언어로 작성된 클래스라고 해보자
이 경우 User1에서는 op2, op3을 전혀 사용하지 않음에도 User1의 소스코드는 두 메서드에 의존하게 된다.

이러한 의존성으로 OPS 클래스에서 op2의 소스코드가 변경되면 User1도 다시 컴파일 후 새로 배포해야 한다.
-> User1과 관련된 코드는 전혀 변경되지 않았더라도!

오퍼레이션을 인터페이스 단위로 분리하여 해결

ISP와 언어

앞의 예제는 언어 타입에 의존한다.
정적 타입 언어는 사용자가 import use include와 같은 타입 선언문을 사용한다.
이처럼 소스 코드에 포함된 선언문으로 인해 소스코드 의존성이 발생하고, 재컴파일해야 하는 상황이 무조건 온다.

파이썬/루비 같은 동적 타입 언어에서는 소스 코드에 이러한 선언문이 존재하지 않는다.
대신 런타임에 추론이 발생한다. 따라서 소스코드 의존성이 없고 재컴파일해야 하는 상황도 없다.

ISP와 아키텍처

ISP를 사용하는 근본적인 동기를 살펴보면, 다른 우려사항을 볼 수 있다.
일반적으로, 필요 이상으로 많은 걸 포함하는 모듈에 의존하는 것은 해롭다.
소스 코드 의존성의 경우 분명한데, 불필요한 재컴파일을 강제하기 때문이다.

저수준의 파일, 모듈 뿐만 아니라 고수준의 아키텍처 수준에서도 마찬가지다

System -> Framework -> Database

의 의존성을 가진다면, S는 D에 의존하게 된다.

D의 내부에서 사용하지 않는 기능이 변경되더라도, F를 재배포해야 할 수 있다.
F를 재배포하게 되면 S 역시 재배포 가능성이 존재한다.

하위 타입

여기에서 필요한 것은 다음과 같은 치환 원칙이다.
S 타입의 객체 o1 각각에 대응하는 T 타입 객체 o2가 있고,
T 타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면,
-> S는 T의 하위 타입이다.

상속을 사용하도록 가이드

License라는 클래스가 있다고 해보자
calcFee() 메서드를 가지며 Billing Application이 메서드를 호출한다.
License는 PersonalLicense와 BusinessLicense라는 두가지 '하위 타입' 을 갖는다.
두 하위 타입은 서로 다른 알고리즘을 이용해서 메서드를 오버라이드 한다.

이 설계는 LSP를 준수한다.
-> Billing App의 행위가 License 하위타입중 무엇을 사용하는 지에 전혀 의존하지 않기 때문이다.

정사각형/직사각형 문제

LSP를 위반하는 전형적인 문제

Square는 Rectangle의 하위 타입으로는 적합하지 않은데,
Rectangle의 높이와 너비는 서로 독립적으로 변경될 수 있는 반면,
Square의 높이와 너비는 반드시 함께 변경되기 때문이다.
User는 대화하고 있는 상대가 Rectangle이라고 생각하므로 혼동이 생길 수 있다.

Rectangle r = ...
r.setW(5);
r.setH(2);
assert(r.area() == 10);

...코드에서 Square를 생성한다면 assert문은 실패하게 된다.
-> W든 H든 변경될 때, 정사각형은 W와 H를 모두 바꾸기 때문
이런 형태의 LSP 위반을 막기 위한 유일한 방법은
if문과 같은 조건을 이용해서 Rectangle이 실제로는 Square인지를 검사하는 메커니즘을 User에 추가하는 것이다.
-> 하지만 이렇게 하면 Userdㅢ 행위가 사용하는 타입에 의존하게 되므로 타입을 서로 치환할 수 없게 된다.

LSP와 아키텍처

객체 지향이 등장한 초창기에는 LSP는 상속을 사용하도록 가이드되었다.
하지만 시간이 지나면서 LSP는 인터페이스와 구현체에도 적용되는 더 광범위하게 변했다.

여기서 말하는 인터페이스는 다양한 형태로 나타난다.
자바스러운 언어라면 인터페이스 하나와 이를 구현하는 여러개의 클래스로 구성
잘 정의된 인터페이스와 그 구현체끼리의 상호 치환 가능성에 기대는 사용자들이 존재하기 때문이다.

LSP 위배 사례

  1. 다양한 택시 파견 서비스를 통합하는 App을 만들고 있다고 가정하자.

  2. 고객은 어느 택시업체인지는 모르지만 자신의 상황에서의 적절한 택시를 찾는다.

  3. 고객이 이용할 택시를 결정하면, 시스템은 REST 서비스를 통해 선택된 택시를 고객 위치로 파견한다.

  4. 택시 파견 REST 서비스의 URI가 운전기사 DB에 저장되어 있다고 가정해보자

  5. 시스템이 고객에게 알맞은 기사흫 선택하면, 해당 기사의 레코드로부터 URI를 얻어 해당 기사를 고객 위치로 파견한다.

EX
택시기사 Bob의 파견 URI = purplecab.com/driver/Bob
시스템은 기사 URI에 정보를 붙힌다.
purplecab.com/driver/Bob/pickupAddress/24 Maple St./pickupTime/153/destination/ORD

이 예제에서 분명한 것은 파견 서비스를 만들 때, 다양한 택시업체에서 동일한 REST 인터페이스를 반드시 준수하도록 만들어야 한다는 사실이다.
서로 다른 택시업체가 pickupAddress/pickupTime/destination 을 동일하게 처리해야 한다.

이제 택시업체(kakaoTaxi)에서 프로그래머를 몇 명 고용했는데, 서비스 사양서를 신중히 읽지 않았다고 가정하자.
destination 필드를 dest로 축약했다.
그렇다면 파견 서비스에서는 kakaoTaxi만을 위해 모든 모듈에

if(driver.getDispatchUri().startsWith("kakaoTaxi")).....

를 추가해야 할 것이다.

결론

LSP는 아키텍처 수준까지 확장할 수 있고, 반드시 확장해야만 한다.
치환 가능성을 조금이라도 위배하면 시스템 아키텍처가 오염되어 별도의 코드/메커니즘이 필요할 수도 있다.

OCP

SW 개체는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.

개체의 행위는 확장할 수 있어야 하지만, 개체를 변경해서는 안된다.
만약, 요구사항을 살짝 확장하는 데 SW를 엄청 수저앻야 한다면, 그 SW 시스템 설계는 실패다.

SW 설계를 공부한 지 얼마 안되는 사람들은 OCP를 클래스와 모듈을 설계할 때 도움되는 원칙이라고 알고 있다.
-> (나 역시... 마찬가지)
하지만 아키텍처 컴포넌트 수중에서 OCP를 고려하면 훨씬 중요한 의미를 갖는다.

사고 실험

  1. 재무제표를 Web으로 보여주는 시스템이 있다고 해보자.
  2. 표시되는 데이터는 스크롤할 수 있으며, 음수는 빨간색으로 출력한다.
  3. 보고서 형태로 변환해서 흑백 프린터로 출력해 달라고 요청
    • 페이지마다 번호
    • 페이지마다 머리글/꼬리글
    • 표의 각 열에는 레이블
    • 음수는 괄호로 묶음

새로운 코드를 작성해야 하는 것은 맞다.
하지만 SW 아키텍처가 좋다면 변경되는 코드의 양이 최소화될 것이다.

HOW?

  • SRP : 서로 다른 목적으로 변경되는 요소를 적절하게 분리
  • DIP : 요소 사이의 의존성을 체계화

SRP를 적용하면 그림은 아래와 같다.

웹/프린트 의 책임을 분리시킨다.
이처럼 책임을 분리한다면, 두 책임 중 하나에서 변경이 발생하더라도 다른 하나는 변경되지 않도록 소스 코드 의존성을 확실히 조직화해야 한다.
또한, 새로 조직화한 구조에서는 행위가 확장될 때 변경이 발생하지 않음을 보장해야 한다.

-> 이러한 목적을 달성하려면 처리 과정을 클래스 단위로 분할하고, 이들 클래스를 컴포넌트 단위로 구분해야 한다.

열린 화살표는 사용
닫힌 화살표는 구현/상속 이다.

여기서 모든 의존성이 소스 코드 의존성을 나타낸다는 사실이다.
A 클래스 -> B 클래스 라면, A 클래스에서는 B 클래스에서는 호출하지만, B 클래스는 A 클래스를 모른다.

다른 중요한 점은 이중 선은 화살표와 오직 한 방향으로만 교차한다는 사실이다.

A 컴포넌트에서 발생한 변경으로부터 B 컴포넌트를 보호하려면 반드시 A 컴포넌트가 B 컴포넌트에 의존( A -> B)해야 한다.
그림에서는 Presenter의 변경으로부터 Controller를 보호하고
View의 변경으로부터 Presenter를 보호한다.
Interactor는 모든 것으로부터 보호되는데, 그 이유는 interactor가 업무 규칙을 포함하기 때문이다.
Interactor = 주요 업무, 나머지 = 주변 업무

BUT, Controller는 Presenter와 View에 비해서는 중요한 업무를 한다.
마찬가지로 Presenter 또한 View 보다는 더 중요한 업무를 한다.
-> 이것이 바로 아키텝처 수준에서 OCP가 동작하는 방식이다.
아키텍트는 HOW, WHY, WHEN 발생하는지에 따라서 기능을 분리하고, 분리한 기능을 컴포넌트의 계층구조로 조직화한다.
컴포넌트를 계층구조로 조직화하므로써 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있다.

방향성 제어

IF) FinancialDataGateway 인터페이스는 FinancialReportGenerator와 FinancialDataMapper 사이에 위치하는데,
이는 의존성을 역전시키기 위해서다
만약, FinancialDataGateway 인터페이스가 없었다면, 의존성은 Interactor 컴포넌트에서 DB 컴포넌트로 바로 향하게 된다.
FinancialReportPresenter 인터페이스와 2개의 View 인터페이스도 같은 목적을 갖는다.

정보 은닉

FinancialReportRequester 인퍼테이스는 방향성 제어와는 다른 목적을 가진다.
이 인터페이스는 FinancialReportController가 Interactor 내부에 대해 너무 많이 알지 못하도록 막기 위한 목적을 갖는다.
만약 이 인터페이스가 없었다면 Controller는 FinancialEntities에 대해 추이 종속성을 가지게 된다.

추이 종속성
- A 클래스가 B 클래스에 의존하고, B 클래스가 C 클래스에 의존한다면 -> A 클래스는 C 클래스에 의존하게 된다.

추이 종속성을 가지면, SW 엔티티는 '자신이 직접 사용하지 않는 요소에는 절대 의존해서는 안된다.'라는 원칙을 위반한다.
-> 이 원칙은 ISP와 CRP를 설명할 때 다시 설명한다.

다시 말해, Controller에서 발생한 변경으로부터 Interactor를 보호하는 일의 우선순위가 가장 높지만,
반대로 Interactor에서 발생한 변경으로부터 Controller도 보호되기를 바란다.
이를 위해 Interactor 내부를 은닉한다.

결론

OCP는 시스템의 아키텍처를 떠받치는 원동력 중 하나다.
OCP의 목표는 시스템을 확장하기 쉬운 동시에, 변경으로 인해 너무 많은 영향을 받지 않도록 함에 있다.
이러한 목표를 달성하려면 시스템을 컴포넌트 단위로 분리하고, 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있는 형태의 의존성 계층구조가 만들어져야 한다.

+ Recent posts