목차
SOLID가 뭔데?
클린 코드(Clean Code)로 좋은 코드를 작성하여 좋은 소프트웨어를 만들어보고자 시작을 하지만, 생각처럼 되지 않는 경우가 많다. 좁게 봤을때 클린 코드를 지켰고, 좋은 벽돌을 만들었다고 할지라도, 건물의 구조가 엉망이라면 이 건물은 좋은 벽돌로 만든 엉망인 건물인 것이다.
그렇기에 좋은 벽돌로 좋은 건물을 짓기 위해서 구조를 정의하는 원칙이 필요한데 이러한 원칙들의 앞 글자를 따서 만든게 SOLID 원칙이다.
이러한 SOLID 원칙은 함수와 데이터 구조를 클래스로 어떻게 배치하는지, 클래스를 어떻게 서로 결합하는지에 대해 설명해준다.
여기서 ‘클래스’ 라고 지칭하지만, 객체 지향 소프트웨어에만 적용되는건 아니다. 여기서 클래스는 단순히 함수와 데이터를 결합한 집합을 가리킨다고 생각하면 된다. 모든 소프트웨어 시스템은 이러한 집합을 포함하고, SOLID 원칙은 이러한 집합에 적용된다.
SOLID 원칙이 추구하는 방향
•
변경에 유연한 아키텍트
•
이해하기 쉬운 아키텍트
•
많은 소프트웨어 시스템에 사용될 수 있는 컴포넌트 기반의 아키텍트
짧게 보는 SOLID 원칙
SRP(Single Responsibility Principle)
단일 책임 원칙
: 소프트웨어 시스템이 가질 수 있는 최적의 구조는 시스템을 만드는 조직의 사회적 구조에 커다란 영향을 받는다. 따라서 각 소프트웨어 모듈은 변경의 이유가 단 하나여야만 한다.
OCP(Open-Closed Principle)
개방-폐쇄 원칙
: 코드를 수정하기보단 반드시 새로운 코드를 추가하는 방식으로 시스템의 행위를 변경할 수 있도록 설계해야만 소프트웨어 시스템을 쉽게 변경할 수 있다는 원칙이다.
LSP(Liskov Subsititution Principle)
리스코프 치환 원칙
: 상호 대체 가능한 구성요소를 이용해 소프트웨어 시스템을 만들 수 있으려면, 이들 구성요소는 서로 치환 가능해야 한다.
ISP(Interface Segregation Principle)
인터페이스 분리 원칙
: 소프트웨어 설계자는 사용하지 않는 것에 의존하지 않아야 한다.
DIP(Dependency Inversion Principle)
의존성 역전 원칙
: 고수준 정책을 구현하는 코드는 저수준 세부사항을 구현하는 코드에 절대로 의존해선 안된다.
대신 세부사항이 정책에 의존해야 한다.
길게보는 SOLID 원칙
1. SRP(Single Responseibility Principle)
: 흔히들 모듈이 하나의 일만 해야 한다고 착각할 수 있으나 사실은 그렇지 않다.
단 하나의 일만 해야하는건 함수에 해당하는 내용이다. (함수는 반드시 하나의 일만 해야 하는게 맞다.) 그럼 SRP는 무엇일까? 보통 소프트웨어 시스템은 사용자와 이해관계자를 만족시키기 위해 변경된다. SRP에서 말하는 변경의 이유는 바로 사용자와 이해관계자를 가르키며 이렇게 표현할 수 있다.
하나의 모듈은 오직 하나의 사용자 또는 이해관계자에 대해서만 책임져야 한다.
하지만, 시스템이 동일한 방식으로 변경되기를 바라는 사용자 혹은 이해관계자가 하나가 아닌 둘 이상일 수 있다. 그렇기에 사용자, 이해관계자라는 표현은 적절치 않고, 좀 더 적절하게 집단(actor)라 표현하면 이렇게 정리할 수 있을 것 같다.
하나의 모듈은 하나의 액터에 대해서만 책임져야 한다.
여기서 모듈은 쉽게 말해 소스파일을 생각하면 되는데, 일부 언어나 개발 환경에선 코드를 소스 파일에 저장하지 않고, 모듈은 단순히 함수와 데이터 구조로 구성된 응집된 집합이 된다.
그리고 이런 응집성이 단일 액터를 책임지고 코드를 묶어주는 힘이 된다.
SRP 위반 상황을 보며 이해하기
SRP원칙을 위반하는 경우를 보며 SRP가 무엇인지 이해해보자.
징후 1 : 우발적 중복
이처럼 세 명의 액터가 서로 결합된 상황은 과연 어떤 문제를 야기시킬까?
회계팀에서 결정한 조치가 인사팀이 의존하는 기능에 영향을 줄 수 있지 않을까?
공유된 알고리즘
만약 위와 같이 calculatePay와 reportHours 메서드가 초과 근무를 제외한 업무시간을 계산하는 알고리즘은 regularHours라는 메서드를 공유한다고 생각해보자. (따로 따로 존재하면 코드 중복이 생길 수 있기에 regularHours 메서드에 들어간 상황이다.) 여기서 회계팀이 업무 시간을 계산하는 방식을 약간 수정하기로 결정했는데, 인사팀에서는 이런 변경을 원하지 않는다고 하자. 이런 상황에서 인사팀에게 연락을 받은 개발자가 업무시간을 계산하는 regularHours 메서드를 수정해야 한다고 할 때 이 메서드가 인사팀에서도 사용하고 호출된다는 사실을 알지 못 할 수 있다.
이렇게 개발자가 요청된 변경사항을 적용하고 테스트 케이스까지 다 작성을해서 회계팀에서 만족하는 상황이 왔고, 시스템이 배포가 되었다.
이런 상황에서 인사팀이 이런 변경여부를 모르고 보고서를 작성하기위해 해당 기능을 사용한다면 예상과 다른 엉터리 값들이 나오게 될 것이고, 그 때 가서는 이미 심각한 상황이다.
그렇기에 SRP는 서로 다른 액터가 의존하는 코드를 서로 분리하라고 말한다.
징후 2: 병합
소스 파일에 많은 메서드가 포함될수록 병합이 발생 할 확률은 높아지고, 이 메서드들이 서로 다른 액터를 책임진다면 병합이 발생할 가능성은 더 높아진다. 다음과 같은 상황이 생긴다고 생각해보자.
DBA가 속한 CTO팀에서 데이터베이스의 Employee 테이블 스키마를 약간 수정하기로 결정했고, 동시에 인사팀에서는 reportHours() 메서드의 보고서 포맷을 변경하기로 결정했다고 해보자.
각 팀의 개발자가 Employee 클래스를 체크아웃 받아서 변경사항을 적용한다면, 당연하게도 이 변경사항은 서로 충돌하고 병합이 발생할 것이다. 요즘 도구들은 이런 부분들을 많이 해결해주고 있지만, 문제가 생길 여지가 충분하다.
이런 징후들말고도 더 살펴볼만한 징후들은 많지만, 공통적으로 많은 사람이 서로 다른 목적으로 동일한 소스 파일을 변경하는 경우에 해당한다. 이 문제를 해결하기 위해서는 서로 다른 액터를 뒷받침하는 코드를 서로 분리하는 것이다.
해결책
여러 해결책들이 있지만, 결국 메서드를 다른 클래스로 이동시키는 방식이다.
가장 확실한 방식은 아무런 메서드가 없는 구조체 EmployeeData 클래스를 만들어 세 개의 클래스가 공유하도록 만드는 것이다. 각 클래스는 자신의 메서드에 필수적인 소스코드만을 포함한다. 그렇게 함으로써 클래스간에는 서로의 존재를 알 수 없고, 우연한 중복을 피할 수 있다.
클래스 분리
여기서 개발자가 세 클래스를 인스턴스화 하고 추적해야 한다는 단점이 있는데, 이를 해결하기 위해서 퍼사드 패턴을 이용할 수도 있다.
퍼사드(Facade) 패턴
EmployeeFacade에서는 코드는 거의 없고 세 클래스의 객체를 생성 및 요청된 메서드를 가지는 객체로 위임하는 일을 책임진다.
혹은 도메인 주도 개발이라 해서 업무 규칙들을 데이터와 가깝게 배치하는 방식을 선호할 수 있는데, 이런 경우 가장 중요한 메서드는 Employee 클래스에 유지하되 덜 중요하고 변경가능성이 있는 메서드들은 퍼사드로 사용할 수 있다.
결론
단일 책임 원칙은 메서드와 클래스 수준의 원칙이지만, 이보다 상위의 두 수준에서도 형태만 다를 뿐 다시 등장한다. 컴포넌트 수준에서는 공통 폐쇄 원칙(Common Closure Principle)이 되고, 아키텍처 수준에서는 아키텍처 경계(Architectural Boundary)의 생성을 책임지는 변경의 축(Axis of Change)이 된다.
2. OCP(Open-Closed Principle)
소프트웨어 개체(artifact)가 확장에는 열려있고, 변경에는 닫혀 있어야 한다는 이 원칙은 소프트웨어 개체의 행위는 확장할 수 있어야 하지만, 이 때 산출물을 변경해서는 안된다고 생각하면 된다.
이러한 OCP 원칙은 아키텍처 컴포넌트 수준에서 더 중요한 의미를 가지는데 사고 실험을 해보면서 확인해보자.
사고 실험(thought experiment)
기존에 재무제표를 웹 페이지로 보여주는 시스템이 있다고 생각해보자.
데이터는 스크롤이 가능하며, 음수는 빨간색으로 출력한다.
여기서 이해관계자의 요청으로 동일한 정보에 대한 포맷이 다음과 같이 변경된다고 하자.
•
흑백 프린터로 출력
•
페이지 번호가 매겨져 있어야 한다.
•
머리글과 바닥글이 있어야 한다.
•
표의 각 열에는 레이블이 있어야 한다.
•
음수는 괄호로 감싸야 한다.
이러한 변경사항을 위해 코드를 얼마나 수정해야 할까?
아키텍처가 훌륭할수록 변경되는 코드의 양은 최소화 될 것이고 이상적인 변경양은 0이 될 것이다.
그럼 어떻게 변경되는 코드의 양을 최소화 할 수 있을까? 서로 다른 목적으로 변경되는 요소를 적절히 분리하고, 요소 사이의 의존성을 역전시킴으로써 변경량을 최소화 할 수 있다.
SRP 원칙을 적용하면 이러한 데이터 흐름을 다음과 같이 만들 수 있다.
SRP 적용하기
여기서 중요한 점은 보고서 생성이 두 개의 책임(웹, 프린터)으로 분리된다는 점이다.
이렇게 책임이 분리되었다면, 두 책임 중 하나에서 변경이 발생해도 다른 하나는 변경되지 않도록 소스 코드 의존성도 확실히 조직화해야 한다. 그리고 새로 조직화한 구조에선 행위가 확장될 때 변경이 발생하지 않음을 보장해야 한다.
이러한 목적을 달성하기 위해서 처리 과정을 클래스 단위로 분할하고, 클래스를 컴포넌트 단위로 구분해야 한다.
좌측 상단부터 컨트롤러, 인터랙터, 좌측 하단부터 프레젠터와 뷰, 데이터베이스 컴포넌트가 위치한다. 위 다이어그램을 보면 화살표를 통해 소스 코드의 의존성이 보이는데, 모두 단방향으로 예를 들어 FinancialDataMapper는 구현 관계를 통해 FinancialDataGateway를 알고 있지만, FinancialDataGateway는 FinancialDataMapper에 대해 아무것도 알지 못한다. 그리고 각각의 컴포넌트들은 화살표와 오직 한 방향으로만 교차한다는 점을 알 수 있는데, 이는 변경으로부터 보호하려는 컴포넌트를 향하도록 그려진다.
다이어그램을 잘 보면 인터랙터는 가장 상위의 계층으로 다른 모든 것에서 발생한 변경으로부터 보호하고자 한다. 그래서 다른 모든 계층에서 발생한 변화가 인터랙터에 영향을 주지 않는데, 그래야 하는 이유는 바로 인터랙터가 업무 규칙(Business Logic)을 포함하기 때문인데, 인터랙터는 애플리케이션에서 가장 높은 수준의 정책을 포함한다.
그리고 컨트롤러는 인터랙터보단 부수적이지만 프레젠터나 뷰에 비해서는 중심적인 문제를 담당하고, 프레젠터 역시 컨트롤러보단 부수적이지만 뷰 보다는 중심적이다. 이처럼 보호의 계층 구조는 수준(level)이라는 개념을 바탕으로 생성된다.
이것이 아키텍처 수준에서 OCP가 동작하는 방식으로 아키텍트는 기능이 어떻게, 왜, 언제 발생하는지에 따라 기능을 분리하고, 분리한 기능을 컴포넌트의 계층구조로 조직화한다.
이처럼 컴포넌트 계층구조를 조직화 하면 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있다.
참고: 의존성 역전
위 클래스 다이어그램을 보면 컴포넌트간의 의존성을 어떻게 제어하는지도 볼 수 있다.
FinancialDataGateway인터페이스만 하더라도 FinancialReportGenerator와 FinancialDataMapper사이에 위치하는데, 이는 의존성의 방향을 역전시키기 위해서다. 해당 인터페이스가 없었더라면 인터랙터 컴포넌트에서 데이터베이스 컴포넌트로 의존성이 바로 흐르게 된다. 그럼 커플링이 발생해서 보호수준이 떨어지게 될 것이다.
참고: 정보 은닉
FinancialReportRequester 인터페이스는 방향성 제어가 아닌 정보 은닉을 위해 존재하는 인터페이스다. FinancialReportController가 인터랙터 내부에 대해 너무 많이 알지 못하도록 존재한다. 이러한 인터페이스가 없다면 컨트롤러는 FinancialEntities에 대해 추이 종속성(transitive dependency)을 가지게 되는데, 이러한 추이 종속성을 가지게 되면 소프트웨어 엔티티는 자신이 직접 사용하지 않는 요소에도 의존하게 되어 소프트웨어 원칙을 위반하게 된다. (ISP, CRP)
즉 인터랙터에서 발생한 변경으로부터 컨트롤러도 보호되기를 바래서 인터랙터 내부를 은닉하는 것이다.
결론
시스템을 확장하기 쉽고 변경으로 인한 사이드이펙트를 최소화 하는것이 OCP의 목표로써 이러한 목표를 달성하기 위해 시스템을 컴포넌트 단위로 분리하고, 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있도록 의존성 계층구조를 만들어야 한다.
3. LSP(Liskov Substitution Principle)
하위 타입(subtype)은 다음과 같은 치환(substitution) 원칙이 필요하다.
•
S 타입의 객체 o1 각각에 대응하는 T 타입 객체 o2가 있다.
•
T타입을 이용해 정의한 모든 프로그램 P에서 o2자리에 o1을 치환할 때 P의 행위가 변화하지 않는다면 S는 T의 하위 타입이다.
몇 가지 예제를 통해 이러한 개념을 이해해보자.
LSP를 준수하는 설계 - 상속을 사용하도록 가이드하기
License와 파생 클래스는 LSP를 준수한다.
위와 같이 License 클래스가 있다고 할 때 이 클래스는 calcFee() 메서드를 가지고 Billing 애플리케이션에서 이 메서드를 호출한다. 그리고 License는 PersonalLicense와 BusinessLicense라는 두 하위 타입이 존재하는데 이 두 하위 타입은 서로 다른 알고리즘을 이용해 라이선스 비용을 계산한다.
이 설계는 LSP를 준수하는 설계이다. Billing 애플리케이션은 calcFee 메서드를 호출함에 있어 해당 행위가 License의 하위 타입 중 무엇을 사용하는지 의존하지 않기 때문이다.
LSP를 위반하는 설계 - 정사각형/직사각형 문제
정사각형/직사각형 문제
LSP를 위반하는 예제로 유명한 정사각형/직사각형(square/rectangle)문제를 살펴보자.
정사각형(Square)는 직사각형(Rectangle)의 하위 타입으로는 적절하지 않다.
그 이유는 직사각형은 높이와 너비가 서로 독립적인 변경이 가능한 반면 정사각형은 높이와 너비가 반드시 함께 변경되야 하기 때문이다. User는 소통하는 상대가 직사각형이라고 생각하면 혼동이 생길 수 있다. 그렇기에 이런 LSP 위반을 막기 위해서는 분기문을 통해 Rectangle이 실제론 Square인지 검사하는 메커니즘을 User에 추가해야하는데, 이 경우엔 User의 행위가 사용하는 타입에 의존하게 되기에 결국 타입을 서로 치환할 수 없게 된다.
LSP를 위반하는 설계 - 택시 파견 서비스
다양한 택시 파견 서비스(taxi dispatch service)를 통합하는 애플리케이션을 만들고 있다고 가정하자.
고객은 택시업체가 어딘지는 관심이 없고 자신의 상황에 가장 적절한 택시를 찾는다. 고객이 이용할 택시를 결정하면 시스템은 REST 서비스를 통해 선택된 택시를 고객 위치로 파견한다.
해당 서비스에서 URI가 운전기사 데이터베이스에 저장되어 있다고 한다면 해당 정보를 이용해 해당 기사를 찾아낸 뒤 고객 위치로 파견할 수 있다. 예를들어 택시기사 캣비의 파견 URI는 다음과 같다.
catsbitaxi.com/driver/catsbi
시스템은 이 URI에 파견에 필요한 정보를 덧붙혀 아래와 같이 PUT방식으로 호출한다.
catsbitaxi.com/driver/catsbi
/pickupAddress/ansan jungang 929
/pickupTime/153
/destination/pangyo
여러 택시 업체가 동일한 REST 인터페이스를 준수해야하기에 서로 다른 택시업체들도 모두 pickupAddress, pickupTime, destination 필드 포맷을 맞춰줘야 한다. 그런데 여기서 ACME(애크미)라는 택시 회사 소속 개발자가 이런 프로토콜 포맷을 제대로 읽지 않고 API를 만들어서 destination 필드를 dest로 축약해서 사용했다고 하고, 해당 회사가 지역에서 가장 큰 업체이고, 우리회사와 긴밀한 관계라고 생각하자. 어떤 일이 생기게 될까?
가장 간단한 해결책은 파견 명령어를 구성하는 모듈에 분기문(if)을 추가해서 ACME(애크미)일 경우 분기를 하는 것이다.
if(driver.getDispatchUri().startsWith("acme.com"))...
Java
복사
하지만, 이런 식의 해결방식은 추가적인 동일 상황마다 같은 분기문을 추가하면서 코드를 지저분하고 가독성도 떨어지게 할 수 있고, 수많은 오류의 원인이 될 수 있다.
아키텍트는 이러한 버그로부터 시스템을 격리해야 하는데, 이 때 파견 URI를 키(key)로 사용하는 설정용 데이터베이스(configuration database)를 이용하는 파견 명령 생성 모듈을 만들어야 할 수도 있다.
결론
LSP는 아키텍처 수준까지 확장할 수 있고 반드시 확장해야만 한다.
치환 가능성을 조금이라도 위배하면 시스템 아키텍처가 오염되어 상당량의 별도 메커니즘을 추가해야 할 수 있기 때문이다.
4. ISP(Interface Segregation Principle)
인터페이스 분리 원칙
위 그림을 보면 다수의 사용자가 OPS 클래스의 오퍼레이션을 사용한다.
여기서 User1은 op1, User2는 op2, User3은 op3만을 사용한다고 가정하고, OPS가 정적 타입 언어로 작성된 클래스라고 가정했을 때, User1의 소스 코드는 op2, op3을 전혀 사용하지 않더라도 두 메서드에 의존하게 된다. 이러한 의존성 때문에 OPS 클래스에서 op2의 소스 코드가 변경되면 User1도 다시 컴파일한 후 새로 배포해야 한다.
이러한 문제는 오퍼레이션을 인터페이스 단위로 분리하여 해결할 수 있다.
분리된 오퍼레이션
위와 같이 OPS를 추상화한 U1Ops, U2Ops, U3Ops 인터페이스로 각 오펴레이션을 분리해서 해결할 수 있다. User1의 소스 코드는 U10ps와 op1에는 의존하지만 OPS에는 의존하지 않게 되면서 OPS에서 발갱한 변경이 User1과는 전혀 관계없는 변경이라면 User1을 다시 컴파일하고 새로 배포하지 않아도 된다.
참고: ISP와 언어
예제를 통해 ISP에 대해 알아봤는데, 이러한 사례들은 모두 언어 타입에 의존한다.
정적 타입 언어들은 사용자가 import, user, include와 같은 타입 선언문을 사용하는데, 이런 선언문으로 인해 소스 코드 의존성이 발생하고, 이런 의존성은 재컴파일 또는 재배포가 강제되는 상황이 초래된다. 반면, 루비나 파이썬같은 동적 타입 언어에서는 소스 코드에 이런 선언문이 존재하지 않고, 런타임에 추론이 발생한다. 따라서 소스 코드 의존성이 없고, 이는 재컴파일과 재배포가 필요없다는 의미가 된다. 그렇기에 동적 타입 언어를 사용하면 정적 타입 언어를 사용할 때보다 유연하고 결합도가 낮은 시스템을 만들 수 있다.
그렇기에 ISP가 아키텍처보단 언어와 관련된 문제라고 생각할 수도 있다.
ISP와 아키텍처
ISP를 사용하는 이유를 잘 살펴보면, 결국 필요 이상으로 많은 걸 포함하는 모듈에 대한 경계라 볼 수 있다. 모듈에서 많은 내용이나 기능을 포함하면 포함할수록 모듈의 변경에 영향을 받을 모듈들이 많아질 것이고, 이는 필연적으로 많은 에러를 직면하게 될 것이다.
이는 소스 코드 레벨 뿐 아니라 더 고수준의 아키텍처 수준에서도 마찮가지이다.
S 시스템 구축에 참여하고 있는 아키텍트가 F라는 프레임워크를 시스템에 도입하길 원한다고 할 때 F 프레임워크 개발자가 D 라는 데이터베이스를 F 프레임워크에서 무조건 사용하게 만들었다면 S는 F에 의존하고, F는 D에 의존하게 된다. 그런데 F에 불필요한 기능이고, 따라서 S와 관계없는 기능이 D에 포함된다면, 해당 기능이 D 내부에서 변경이 발생한다면 F를 재배포해야하고 S까지 재배포 해야 할 가능성이 생긴다.
결론
불필요한 기능들이 많이 포함될수록 의존하는 대상은 예상하지 못한 문제에 빠질 수 있다.
5. DIP(Dependency Inversion Principle)
의존성 역전 원칙(DIP)에서 말하는 유연성이 극대화된 시스템은 소스 코드 의존성이 추상(abstraction)에 의존하고 구체(concretion)에는 의존하지 않는 시스템을 말한다.
자바와 같은 정적 타입언어에서 이 말은 use, import, include와 같은 선언문을 추상적인 선언만을 참조해야한다는 의미이기도 하다.
동적 타입 언어에서도 동일한 규칙이 적용되는데 소스 코드 의존 관계에서 구체 모듈을 참조해서는 안된다. 그런데 이러한 생각은 몹시 비현실적이다.
소프트웨어 시스템은 구체적인 많은 장치에 의존하는데, 대표적으로 자바의 String은 구체 클래스이지만, 이를 애써 추상 클래스로 만들려는 시도는 비현실적이다. java.lang.String 구체 클래스에 대한 소스 코드의존성은 벗어날 수 없고, 벗어나서도 안 된다.
이러한 이유로 DIP를 논할 때 운영체제나 플랫폼같은 안정성이 보장된 환경에 대해서는 무시하는 편이다. 우리가 의존하지 않도록 피하고자 하는 것은 변동성이 큰 구체적인 요소이다.
안정된 추상화
추상 인터페이스에 변경이 생기면 이를 구체화한 구현체들도 모두 수정해야한다.
반대로, 구체적인 구현체에 변경이 생기더라도 그 구현체가 인터페이스의 규약(행위와 결과)을 지킨다면 인터페이스는 변경될 필요가 없다. 따라서 인터페이스는 구현체보다 변동성이 낮다.
그리고 우리는 이처럼 인터페이스의 변동성을 낮추기위해 노력해야 한다.
즉, 안정된 소프트웨어 아키텍처란 변동성이 낮은 추상 인터페이스를 선호하는 아키텍처라는 의미인데, 다음과 같은 코딩 실천법으로 낮은 변동성의 아키텍처를 노릴 수 있다.
•
변동성이 큰 구체 클래스를 참조하지 말고, 추상 인터페이스를 참조하라.
: 이 규칙은 언어가 정적이든 동적이든 관계없으며, 객체 생성 방식을 강하게 제약하기에 일반적으로 추상 팩토리(Abstract Factory)를 사용하도록 강제한다.
•
변동성이 큰 구체 클래스로부터 파생하지 말라.
: 정적 타입 언어에서 상속은 소스 코드에 존재하는 모든 관계중 가장 강력하면서도 뻣뻣하기에 변경이 어렵다. 따라서 상속은 아주 신중하게 사용해야 한다.
•
구체 함수를 오버라이드 하지 말라.
: 대체로 구체 함수는 소스 코드 의존성을 필요로 하는데, 그렇기에 구체 함수를 오버라이드 하면 이러한 의존성을 제거할 수 없게 되며, 의존성을 상속하게 된다. 그렇기에 차라리 추상 함수로 선언하고 구현체들에서 각자에 용도에 맞게 구현하는게 의존성을 제거하는 방법이다.
•
구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말아라.
:DIP를 다른 방식으로 풀어 쓴 내용으로 구체 클래스가 아닌 추상 인터페이스를 참조하라는 의미다.
팩토리
안정된 추상화를 위해 나온 설명 중 추상 팩토리(Abstract Factory)라는 키워드가 나왔는데, 자바와 같은 객체 지향 언어에서는 바람직하지 못한 의존성(객체를 생성하기 위해 객체를 구체적으로 정의한 코드에 대한 소스 코드 의존성)을 처리할 때 추상 팩토리를 사용하고는 한다.
추상 팩토리 패턴
위 그림은 추상 팩토리를 사용한 구조 다이어그램으로 Application은 Service 인터페이스를 통해 ConcreteImpl을 사용하지만 Application에서는 어떤식으로든 ConcreteImpl의 인스턴스를 만들어야 한다. ConcreteImpl에 대한 소스 코드 의존성을 만들지 않고 인스턴스를 만들기 위해서 Application은 ServiceFactory 인터페이스의 makeSvc 메서드를 호출하고, 이 메서드는 ServiceFatory로부터 파생된 ServiceFactoryImpl에서 구현된다. 그리고 이 serviceFactoryImpl 구현체가 ConcreteImpl의 인스턴스를 생성한 후 Service 타입으로 반환한다.
다이어그램의 곡선은 아키텍처 경계(Architectural Boundary)를 의미한다. 이 곡선은 구체적인 것들로부터 추상적인 것들을 분리한다. 소스 코드 의존성은 해당 곡선과 교차할 때 모두 좀 더 추상적인 방향으로 향한다.
이처럼 곡선은 시스템을 두 가지 컴포넌트로 분리하는데, 하나는 추상 컴포넌트, 다른 하나는 구체 컴포넌트다. 추상 컴포넌트에선 애플리케이션의 모든 고수준 업무 규칙을 포함하고, 구체 컴포넌트는 업무 규칙을 다루기 위해 필요한 모든 세부사항을 포함한다.
제어 흐름은 소스 코드 의존성과는 정반대 방향으로 곡선을 가로지른다는 점을 기억하자.
즉, 소스 코드 의존성은 제어흐름과 반대 방향으로 역전되는데 이러한 이유로 이 원칙을 의존성 역전 원칙(Dependency Inversion Principle)이라 부른다.
참고: 구체 컴포넌트
위 다이어그램에선 구체 컴포넌트에서 ServiceFactoryImpl에서 ConcreteImpl 구체 클래스를 의존하는 구체적인 의존성이 하나 있고, 이는 DIP에 위배된다. 하지만, 이는 문제가 아니다.
DIP 위배를 모두 없애는 것은 불가능하다. 하지만, DIP를 위배하는 클래스를 적은 수의 구체 컴포넌트 내부로 모을 수 있고, 이를 통해 시스템의 나머지 부분과는 분리할 수 있다.
대부분의 시스템은 이러한 구체 컴포넌트를 최소 하나는 포함할 것이고, 이러한 컴포넌트를 메인( Main)이라 부르는데, main함수(애플리케이션이 처음 구동될 때 운영체제가 호출하는 함수로 부트스트랩이라고도 한다.) 를 포함하기 때문이다.
위 다이어그램에서는 main함수는 ServiceFactoryImpl의 인스턴스를 생성한 뒤 ServiceFactory 타입으로 전역 변수에 저장할 것이고, Application은 이 전역 변수를 이용해 ServiceFactoryImpl의 인스턴스에 접근할 것이다.
결론
고수준의 아키텍처 원칙을 다루게 되면서 DIP는 계속해서 나오는 원칙이다.
아키텍처 경계를 기준으로 항상 더 추상적인 엔티티가 있는 쪽으로 의존성이 흐르게 될 것이고, 이는 제어흐름과는 반대의 흐름으로 흐를 것이다.