로버트 C. 마틴의 클린 아키텍처: 소프트웨어 구조와 설계의 원칙을 읽으며 기록하고 싶은 부분만 발췌해 각색하여 작성한 글입니다. 하여 실제 글쓴이의 의도와 다르게 작성될 수 있음을 알립니다.
SOLID 원칙의 목적은 중간 수준의 소프트웨어 구조가 아래와 같도록 만드는 데 있다.
- 변경에 유연하다.
- 이해하기 쉽다.
- 많은 소프트웨어 시스템에 사용될 수 있는 컴포넌트의 기반이된다.
SRP(Single Responsibility Principle) 단일 책임 원칙
소프트웨어 모듈은 변경의 이유가 단 하나여야만 한다.
하나의 모듈은 오직 하나의 액터에 대해서만 책임져야 한다.
이 원칙을 이해하는 가장 좋은 방법은 이 원칙을 위반하는 징후들을 살펴보는 일일 게다.
징후 1: 우발적 중복
Employee 클래스는 단일 책임 원칙을 위반한다. 이 클래스의 세 가지 메서드가 서로 매우 다른 세 명의 액터를 책임지기 때문이다.
- calculatePay()는 회계팀에서 기능을 정의하며 CFO 보고를 위해 사용한다.
- reportHours()는 인사팀에서 기능을 정의하며 COO 보고를 위해 사용한다.
- save()는 DBA가 기능을 정의하며 CTO 보고를 위해 사용한다.
개발자가 이 세 메서드를 단일 클래스에 배치하여 세 액터가 서로 결합되어 버렸다. 이 결합으로 인해 CFO 팀에서 결정한 조치가 COO 팀이 의존하는 무언가에 영향을 줄 수 있다. 이러한 문제는 서로 다른 액터가 의존하는 코드를 너무 가까이 배치했기 때문에 발생하며, SRP는 서로 다른 액터가 의존하는 코드를 서로 분리하라고 말한다.
징후 2: 병합
한 메서드가 서로 다른 액터를 책임진다면 병합이 발생할 가능성은 확실히 더 높다. 예를 들어 서로 다른 팀에 속한 팀원들이 동시에 한 메서드를 수정하고자 했을 때, 두 명의 서로 다른 개발자가 변경사항을 적용하기 시작한다. 안타깝게도 이들 변경사항은 서로 충돌한다. 결과적으로 병합이 발생하는 것이다.
병합에는 위험이 따른다고 굳이 말하지 않아도 될 것이다. 최근 도구는 굉장히 뛰어나지만, 어떤 도구도 병합이 발생하는 모든 경우를 해결할 수는 없다. 결국 병합에는 항상 위험이 뒤따르게 된다. 이 문제를 벗어나는 방법은 징후 1에서와 마찬가지로, 서로 다른 액터를 뒷받침하는 코드를 서로 분리하는 것이다.
# 해결책
이 문제의 해결책은 다양한데, 그 모두가 메서드를 각기 다른 클래스로 이동시키는 방식이다. 아마도 가장 확실한 해결책은 데이터와 메서드를 분리하는 방식일 것이다. 즉, 아무런 메서드가 없는 간단한 데이터 구조인 EmployeeData 클래스를 만들어, 세 개의 클래스가 공유하도록 한다. 각 클래스는 자신의 메서드에 반드시 필요한 소스 코드만을 포함한다. 세 클래스는 서로의 존재를 몰라야 한다. 따라서 '우연한 중복'을 피할 수 있다.
반면 이 해결책은 개발자가 세 가지 클래스를 인스턴스화하고 추적해야 한다는 게 단점이다. 이러한 난관에서 빠져나올 때 흔히 쓰는 기법으로 퍼사드 Facade 패턴이 있다.
EmployeeFacade 코드는 거의 없다. 이 클래스는 세 클래스의 객체를 생성하고, 요청된 메서드를 가지는 객체로 위임하는 일을 책임진다.
# 결론
단일 책임 원칙은 메서드와 클래스 수준의 원칙이다. 하지만 이보다 상위의 두 수준에서도 다른 형태로 다시 등장한다. 컴포넌트 수준에서는 공통 폐쇄 원칙이 된다. 아키텍처 수준에서는 아키텍처 경계의 생성을 책임지는 변경의 축이 된다.
OCP(Open-Closed Principle) 개방-폐쇄 원칙
소프트웨어 개체 artifact는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
기존 코드를 수정하기 보다는 반드시 새로운 코드를 추가하는 방식으로 시스템의 행위를 변경할 수 있도록 설계해야만 소프트웨어 시스템을 쉽게 변경할 수 있다.
소프트웨어 아키텍처가 훌륭하다면 소프트웨어에 새로운 요구사항이 생겼을 때, 변경되는 코드의 양이 가능한 한 최소화 되어야한다. 이상적인 변경량은 0이다. 어떻게 하면 될까? 서로 다른 목적으로 변경되는 요소를 적절하게 분리(단일 책임 원칙)하고, 이들 요소 사이의 의존성을 체계화함(의존성 역전 원칙)으로써 변경량을 최소화할 수 있다. 책임을 분리했다면, 분리된 두 책임 중 하나에서 변경이 발생하더라도 다른 하나는 변경되지 않도록 소스 코드 의존성도 확실히 조직화해야 한다. 또한, 새로 조직화한 구조에서는 행위가 확장될 때 변경이 발생하지 않음을 보장해야 한다.
이러한 목적을 달성하려면 처리 과정을 클래스 단위로 분할하고, 이들 클래스를 컴포넌트 단위로 구분해야 한다.
화살표가 열려있다면 사용 using 관계이며, 닫혀 있다면 구현 implement 관계 또는 상속 inheritance 관계다. 여기서 주목해야 할 점 하나는 모든 의존성이 소스 코드 의존성을 나타낸다는 사실이다. 주목해야 할 또 다른 점은 이중선은 화살표와 오직 한 방향으로만 교차한다는 사실이다. 이는 모든 컴포넌트 관계는 단방향으로만 이루어진다는 뜻이다. 이들 화살표는 변경으로부터 보호하려는 컴포넌트를 향하도록 그려진다.
다시 한번 말하자면, A 컴포넌트에서 발생한 변경으로부터 B 컴포넌트를 보호하려면 반드시 A 컴포넌트가 B 컴포넌트에 의존해야 한다.
이 예제의 경우, Interactor는 OCP를 가장 잘 준수할 수 있는 최고수준에 위치한다. Database, Controller, Presenter, View에서 발생한 어떤 변경도 Interactor에 영향을 주지 않는다. Interactor는 업무 규칙을 포함하기 때문에 특별한 위치를 차지해야만 한다. Interactor는 애플리케이션에서 가장 높은 수준의 정책을 포함한다.
보호의 계층 구조가 '수준 level'이라는 개념을 바탕으로 어떻게 생성되는지 주목하자.
Interactor는 가장 높은 수준의 개념이며, 따라서 최고의 보호를 받는다.
View는 가장 낮은 수준의 개념 중 하나이며, 따라서 거의 보호를 받지 못한다.
Presenter는 View보다는 높고 Controller나 Interactor보다는 낮은 수준에 위치한다.
이것이 바로 아키텍처 수준에서 OCP가 동작하는 방식이다. 아키텍트는 기능이 어떻게, 왜, 언제 발생하는지에 따라서 기능을 분리하고, 분리한 기능을 컴포넌트의 계층구조로 조직화한다. 컴포넌트 계층구졸르 이와 같이 조직화하면 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있다.
# 방향성 제어
Figure 8.2의 클래스 설계는 컴포넌트 간 의존성이 제대로 된 방향으로 향하고 있음을 확실히 보여준다.
예를 들어 FinancialDataGateway 인터페이스는 FinancialReportGenerator와 FinancialDataMapper 사이에 위치하는데, 이는 의존성을 역전시키기 위해서다. FinancialDataGateway 인터페이스가 없었다면, 의존성이 Interactor 컴포넌트에서 Database 컴포넌트로 바로 향하게 된다. FinancialReportPresenter 인터페이스와 2개의 View 인터페이스도 같은 목적을 가진다.
# 정보 은닉
FinancialReportRequester 인터페이스는 방향성 제어와는 다른 목적을 가진다. 이 인터페이스는 FinancialReportController가 Interactor 내부에 대해 너무 많이 알지 못하도록 막기 위해서 존재한다. 만약 이 인터페이스가 없었다면, Controller는 FinancialEntities에 대해 추이 종속성 transitive dependency을 가지게 된다.
추이 종속성을 가지게 되면, 소프트웨어 엔티티는 '자신이 직접 사용하지 않는 요소에는 절대로 의존해서는 안 된다'는 소프트웨어 원칙을 위반하게 된다. 다시 말해서, Controller에서 발생한 변경으로부터 Interactor를 보호하는 일의 우선순위가 가장 높지만, 반대로 Interactor에서 발생한 변경으로부터 Controller도 보호되기를 바란다. 이를 위해 Interactor 내부를 은닉한다.
# 결론
OCP의 목표는 시스템을 확장하기 쉬운 동시에 변경으로 인해 시스템이 너무 많은 영향을 받지 않도록 하는 데 있다. 이러한 목표를 달성하려면 시스템을 컴포넌트 단위로 분리하고, 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있는 형태의 의존성 계층구조가 만들어지도록 해야 한다.
LSP (Liskov Substitution Principle) 리스코프 치환 원칙
S 타입의 객체 o1 각각에 대응하는 T 타입 객체 o2가 있고, T 타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위 타입이다.
상호 대체 가능한 구성요소를 이용해 소프트웨어 시스템을 만들 수 있으려면, 이들 구성요소는 반드시 서로 치환 가능해야 한다.
# 상속을 사용하도록 가이드하기
License 클래스는 calcFee()라는 메서드를 가지며, Billing 애플리케이션에서 이 메서드를 호출한다. License에는 PersonalLicense와 BusinessLicense라는 두 가지 '하위 타입'이 존재한다. 이들 두 하위 타입은 서로 다른 알고리즘을 이용해서 라이선스 비용을 계산한다.
이 설계는 LSP를 준수하는데, Billing 애플리케이션의 행위가 License 하위 타입 중 무엇을 사용하는지에 전혀 의존하지 않기 때문이다. 이들 하위 타입은 모두 License 타입을 치환할 수 있다.
# 정사각형/직사각형 문제
LSP를 위반하는 전형적인 문제로는 악명높은 정사각형/직사각형 문제가 있다.
Sqaure는 Rectangle의 하위 타입으로는 적합하지 않은데, Rectangle의 높이와 너비는 서로 독립적으로 변경될 수 있는 반면, Square의 높이와 너비는 반드시 함께 변경되기 때문이다. 이런 형태의 LSP 위반을 막기 위한 유일한 방법은 Rectangle이 실제로 Square인지를 검사하는 메커니즘을 추가하는 것인데, 이렇게 하면 행위가 타입에 의존하게 되므로 결국 타입을 서로 치환할 수 없게 된다.
# LSP와 아키텍처
초창기 LSP는 상속을 사용하도록 가이드하는 방법 정도로 간주되었지만, 시간이 지나면서 인터페이스와 구현체에도 적용되는 광범위한 소프트웨어 설계 원칙으로 변모해 왔다. 자바스러운 언어라면 인터페이스는, 인터페이스 하나와 이를 구현하는 여러 개의 클래스로 구성된다. 또는 동일한 REST 인터페이스에 응답하는 서비스 집단일 수도 있다. 아키텍처 관점에서 LSP를 이해하는 최선의 방법은 이 원칙을 어겼을 때 시스템 아키텍처에서 무슨 일이 일어나는지 관찰하는 것이다.
# 결론
LSP는 아키텍처 수준까지 반드시 확장해야 한다. 치환 가능성을 조금이라도 위배하면 시스템 아키텍처가 오염되어 상당량의 별도 메커니즘을 추가해야 할 수 있기 때문이다.
ISP (Interface Segregation Principle) 인터페이스 분리 원칙
소프트웨어 설계자는 사용하지 않은 것에 의존하지 않아야 한다.
다수의 사용자가 OPS 클래스의 오퍼레이션을 사용한다. User [n]은 오직 op[n]만을 사용한다고 가정해 보자. 이 경우 User1에서는 op2, op3를 전혀 사용하지 않음에도 User1의 소스 코드는 이 두 메서드에 의존하게 된다. 이러한 의존성으로 인해 OPS 클래스에서 op2의 소스 코드가 변경되면, 코드가 전혀 변경되지 않은 User1까지도 다시 컴파일 후 새로 배포해야 한다.
이러한 문제는 그림 10.2와 같이 오퍼레이션을 인터페이스 단위로 분리하여 해결할 수 있다. 이제 User1의 소스 코드는 U1Ops와 op1에는 의존하지만, OPS에는 의존하지 않게 된다.
# ISP와 언어
방금 예제는 언어 타입에 의존한다. 정적 타입 언어는 사용자가 import, user 또는 include와 같은 타입 선언문을 사용하도록 강제한다. 이처럼 소스 코드에 포함된 선언문으로 인해 소스 코드 의존성이 발생하고, 이로 인해 재컴파일 또는 재배포가 강제되는 상황이 무조건 초대뢴다.
루비나 파이썬과 같은 동적 타입 언어에서는 소스 코드에 이러한 선언문이 존재하지 않는 대신 런타임에 추론이 발생한다. 따라서 소스 코드 의존성이 아예 없으며, 결국 재컴파일과 재배포가 필요없다. 동적 타입 언어를 사용하면 정적 타입 언어를 사용할 때보다 유연하며 결합도가 낮은 시스템을 만들 수 있는 이유는 바로 이 때문이다.
이러한 사실로 인해 ISP를 아키텍처가 아니라, 언어와 관련된 문제라고 결론내릴 여지가 있다.
# ISP와 아키텍처
필요 이상으로 많은 걸 포함하는 모듈에 의존하는 것은 해로운 일이다. 소스 코드 의존성의 경우는 앞서 살펴봤고, 이는 더 고수준인 아키텍처 수준에서도 마찬가지로 발생한다.
예를 들어 시스템 S를 구축하는데 프레임워크 F를 도입하기를 원한다. 그리고 개발자는 F 프레임워크를 특정한 데이터베이스 D를 반드시 사용하도록 만들었다. 따라서 S는 F에 의존하며, F는 다시 D에 의존한다.
프레임워크 F에는 시스템 S와는 전혀 관계없는 불필요한 기능이 포함된다. 그 기능 때문에 데이터베이스 D 내부가 변경되면, F를 재배포해야 할 수도 있고, 따라서 시스템 S까지 재배포해야 할지 모른다. 더 심각한 문제는 데이터베이스 D 내부의 기능 중 프레임워크 F와 시스템 S에서 불필요한 그 기능에 문제가 발생해도 프레임워크 F와 시스템 S에 영향을 준다는 사실이다.
DIP (Dependency Inversion Principle) 의존성 역전 원칙
고수준 정책을 구현하는 코드는 저수준 세부사항을 구현하는 코드에 절대로 의존해서는 안 된다. 대신 세부사항이 정책에 의존해야 한다.
의존성 역전 원칙에서 말하는 '유연성이 극대화된 시스템'이란 소스 코드 의존성이 추상 abstraction에 의존하며 구체 concretion에는 의존하지 않는 시스템이다.
# 안정된 추상화
추상 인터페이스에 변경이 생기면 이를 구체화한 구현체들도 따라서 수정해야 한다. 반대로 구체적인 구현체에 변경이 생기더라도 그 구현체가 구현하는 인터페이스는 항상, 좀 더 정확히 말하면 대다수의 경우 변경될 필요가 없다. 따라서 인터페이스는 구현체보다 변동성이 낮다.
실제로 뛰어난 소프트웨어 설계자라면 인터페이스의 변동성을 낮추기 위해 애쓴다. 인터페이스를 변경하지 않고도 구현체에 기능을 추가할 수 있는 방법을 찾기 위해 노력한다. 이는 소프트웨어 설계의 기본이다.
즉, 안정된 소프트웨어 아키텍처란 변동성이 큰 구현체에 의존하는 일은 지양하고, 안정된 추상 인터페이스를 선호하는 아키텍처라는 뜻이다.
- 변동성이 큰 구체 클래스를 참조하지 말라.
대신 추상 인터페이스를 참조하라. 이 규칙은 언어가 정적이든 동적이든 관계없이 모두 적용된다.
또한 이 규칙은 객체 생성 방식을 강하게 제약하며, 일반적으로 추상 팩토리 Abstract Factory를 사용하도록 강제한다. - 변동성이 큰 구체 클래스로부터 파생하지 말라.
정적 타입 언어에서 상속은 소스 코드에 존재하는 모든 관계 중에서 가장 강력한 동시에 뻣뻣해서 변경하기 어렵다. - 구체 함수를 오버라이드 하지 말라.
대체로 구체 함수는 소스 코드 의존성을 필요로 한다. 따라서 구체 함수를 오버라이드하면 이러한 의존성을 제거할 수 없게 되며, 실제로는 그 의존성을 상속하게 된다. 이러한 의존성을 제거하려면, 차라리 추상 함수로 선언하고 구현체들에서 각자의 용도에 맞게 구현해야 한다. - 구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라.
# 팩토리
이 규칙을 준수하려면 변동성이 큰 구체적인 객체는 특별히 주의해서 생성해야 한다. 사실상 모든 언어에서 객체를 생성하려면 해당 객체를 구체적으로 정의한 코드에 대해 소스 코드 의존성이 발생하기 때문이다. 대다수의 객체 지향 언어에서 이처럼 바람직하지 못한 의존성을 처리할 때 추상 팩토리를 사용하곤 한다.
Application은 Service 인터페이스를 통해 ConcreteImpl을 사용하지만, Application에서는 어떤 식으로든 ConcreteImpl의 인스턴스를 생성해야 한다. ConcreteImpl에 대해 소스 코드 의존성을 만들지 않으면서 이 목적을 이루기 위해 Application은 ServiceFactory로부터 파생된 ServiceFactoryImpl에서 구현된다. 그리고 ServiceFactoryImpl 구현체가 ConcreteImpl의 인스턴스를 생성한 후 Service 타입으로 반환한다.
그림 11.1의 곡선은 아키텍처 경계를 뜻한다. 이 곡선은 구체적인 것들로부터 추상적인 것들을 분리한다. 소스 코드 의존성은 해당 곡선과 교차할 때 모두 한 방향, 즉 추상적인 쪽으로 향한다.
곡선은 시스템을 추상 컴포넌트와 구체 컴포넌트로 분리한다. 추상 컴포넌트는 애플리케이션의 모든 고수준 업무 규칙을 포함한다. 구체 컴포넌트는 업무 규칙을 다루기 위해 필요한 모든 세부사항을 포함한다.
제어흐름은 소스 코드 의존성과는 정반대 방향으로 곡선을 가로지른다는 점에 주목하자. 다시 말해 소스 코드 의존성은 제어흐름과는 반대 방향으로 역전된다. 이러한 이유로 이 원칙을 의존성 역전이라고 부른다.
# 구체 컴포넌트
그림 11.1의 구체 컴포넌트에는 ServiceFactoryImpl 구체 클래스가 ConcreteImple 구체 클래스에 의존하는 하나의 구체적인 의존성이 있으므로 DIP에 위배된다. 이는 일반적이다. DIP 위배를 모두 없앨 수는 없다. 하지만 DIP를 위배하는 클래스들은 적은 수의 구체 컴포넌트 내부로 모을 수 있고, 이를 통해 시스템의 나머지 부분과는 분리할 수 있다.