Posts [클린아키텍처] 7 ~ 11장
Post
Cancel

[클린아키텍처] 7 ~ 11장

clean-architecture-book

‘클린 아키텍처’ 기술 서적에 대해 학습했던 내용을 정리하기 위한 목적의 TIL 포스팅입니다.🙆‍♂️

3부 설계 원칙

  • SOLID 원칙은 함수와 데이터 구조를 클래스로 배치하는 방법, 그리고 이들 클래스를 서로 결합하는 방법을 설명해준다.
    • ‘클래스’ 라는 단어를 사용했다고 해서 SOLID 원칙이 객체 지향 소프트웨어에만 적용된다는 뜻은 아니다.
    • 여기서 클래스는 단순히 함수와 데이터를 결합한 집합을 가리킨다.
    • 소프트웨어 시스템은 모두 이러한 집합을 포함하는데, SOLID 원칙은 이러한 집합에 적용된다.
  • SOLID 원칙의 목적은 다음과 같다.
    • 변경에 유연하도록
    • 이해하기 쉽도록
    • 많은 소프트웨어 시스템에 사용될 수 있는 컴포넌트의 기반이 될 수 있도록
  • SOLID 원칙은 코드 수준보단 조금 상위(모듈 수준)에서 적용되며 모듈과 컴포넌트 내부에서 사용되는 소프트웨어 구조를 정의하는데 도움을 준다.
  • 아래 세부 쳅터에선 SOLID 원칙을 다 설명 후 컴포넌트 세계에서 SOLID 원칙에 대응하는 원칙들을 설명하고, 이어서 고수준의 아키텍처 원칙까지 설명할 것이다.
  • SOLID 원칙을 간략하게 요약하면 다음과 같다.
    • SRP(단일 책임 원칙): 각 소프트웨어 모듈은 변경의 이유가 단 하나여야 한다.
    • OCP(개방 폐쇄 원칙): 기존 코드를 수정하기 보단 반드시 새로운 코드를 추가하는 방식으로 시스템의 행위를 변경할 수 있도록 설계해야만 소프트웨어 시스템을 쉽게 변경할 수 있다.
    • LSP(리스코프 치환 원칙): 상호 대체 가능한 구성요소를 이용해 소프트웨어 시스템을 만들 수 있으려면, 이들 구성요소는 반드시 서로 치환 가능해야 한다는 계약을 지켜야 한다.
    • ISP(인터페이스 분리 원칙): 사용하지 않는 것에 의존하지 않아야 한다.
    • DIP(의존성 역전 원칙): 고수준 정책을 구현하는 코드는 저수준 세부사항을 구현하는 코드에 절대 의존해선 안된다. 대신 세부사항이 고수준 정책에 의존해야 한다.
  • 아래 내용에선 이들 원칙이 아키텍처 관점에서 지닌 의미에 집중하여 논의하고자 한다.

7장 - SRP: 단일 책임 원칙

  • 의미가 가장 잘 전달되지 못한 원칙
  • 함수는 반드시 단 하나의 일만 해야 한다는 원칙이 아니다.
    • 이 원칙은 커다란 함수를 작은 함수들로 리팩터링하는 더 저수준에서 사용된다.
    • SOLID 원칙도 SRP도 아니다.
  • 역사적으론 아래와 같이 기술되어 있다.

단일 모듈은 변경의 이유가 하나, 오직 하나뿐이어야 한다.

  • 소프트웨어 시스템은 사용자와 이해관계자를 만족시키기 위해 변경된다.
  • SRP가 말하는 ‘변경의 이유’란 바로 이들 사용자와 이해관계자를 가리키는데 사용자는 여럿이 될 수 있다.
  • 여기에서의 ‘사용자’는 해당 변경을 요청하는 한 명 이상의 사람들을 가리키는데 이러한 집단을 ‘액터’라 부른다.
  • 그랬을 경우 다음과 같이 최종 정리할 수 있다.

하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다.

  • 모듈’이란 무엇인가? 가장 단순한 정의는 소스 파일이다.
  • 하지만 일부 언어와 개발 환경에서는 코드를 소스 파일에 저장하지 않는다.
  • 이러한 경우 모듈은 단순히 함수와 데이터 구조로 구성된 응집된 집합이다.
  • ‘응집된(cohesive)’ 라는 단어가 SRP를 암시한다. 단일 액터를 책임지는 코드를 함께 묶어주는 힘이 바로 응집성(cohesion)이다.
  • 이 원칙을 위반하는 다음 징후들을 살펴보자.

징후1: 우발적 중복

image

  • 위 Employee 클래스는 세 가지 메서드 calulatePay(), reportHours(), save()를 가진다.
  • 이 클래스는 SRP를 위반하는데, 이들 세 가지 메서드가 서로 매우 다른 세 명의 액터를 책임지기 때문이다.
    • calculatePay() 메서드는 회계팀에서 기능을 정의하며, CFO 보고를 위해 사용한다.
    • reportHours() 메서드는 인사팀에서 기능을 정의하고 사용하며, COO 보고를 위해 사용한다.
    • save() 메서드는 데이터베이스 관리자(DBA)가 기능을 정의하고, CTO 보고를 위해 사용한다.
  • 개발자가 이 세 메서드를 Employee라는 단일 클래스에 배치하여 세 액터가 서로 결합되어 버렸다.
  • 이로 인해 예를 들어 CFO 팀에서 결정한 조치가 COO팀이 의존하는 무언가에 영향을 줄 수 있다.

image

  • 예를 들어, CFO 팀에서 초과 근무를 제외한 업무 시간을 계산하는 방식(regularHours 함수)을 약간 수정하기로 결정했다고 해보자.
  • 반면, 인사를 담당하는 COO 팀에선 이러한 변경사항을 원치 않는다고 할때 개발자는 다른 곳에서도 (reportHours 함수) 이를 참조하고 있다는 것을 눈치채지 못할 것이다. 이때 COO 팀에서 의도치 않은 큰 문제가 발생할 것이다. (잘못된 계산으로 수백만 달러 예산이 지출되는…)

  • 서로 다른 액터가 의존하는 코드를 너무 가까이 배치했기 때문에 발생한 문제고 SRP 는 서로 다른 액터가 의존하는 코드를 서로 분리하라고 말한다.

징후2: 병합

  • 소스파일에 다양하고 많은 메서드를 포함하면 병합이 자주 발생할 수 있다.
  • 특히 이들 메서드가 서로 다른 액터를 책임진다면 병합이 발생할 가능성은 높아진다.
  • 예를 들어, DBA가 속한 CTO 팀에서 데이터베이스의 Employee 테이블 스키마를 수정하기로 결정했고, 동시에 인사 담당자가 속한 COO 팀에서는 reportHours() 메서드의 보고서 포맷을 변경하기로 결정했다고 해보자.
  • 두 명의 서로 다른 개발자가 Employee 클래스를 체크아웃받은 후 변경사항을 적용한다.
  • 이들 변경사항은 서로 충돌할 수 밖에 없다. 결과적으로 병합이 발생한 것이다.
  • 이러한 문제를 벗어나는 방법은 서로 다른 액터를 뒷받침하는 코드를 서로 분리하는 것이다.

해결책

  • 해결책은 다양하지만, 그 모두가 메서드를 가기 다른 클래스로 이동시키는 방식이다.
  • 아마 가장 확실한 해결책은 데이터와 메서드를 분리하는 방식일 것이다.
  • 즉, 아무런 메서드가 없는 간단한 데이터 구조인 EmployeeData 클래스를 만들어, 세 개의 클래스가 공유하도록 한다.

image

  • 각 클래스는 자신의 메서드에 반드시 필요한 소스 코드만을 포함한다.
  • 세 클래스는 서로의 존재를 몰라야 한다. 따라서 ‘우연한 중복’을 피할 수 있다.

  • 반면 이 해결책은 개발자가 세 가지 클래스를 인스턴스화하고 추적해야 한다는 단점이 있다.
  • 이럴때 흔히 쓰는 기법으론 퍼사드 패턴이 있다.

image

  • EmployeeFacade에 코드는 거의 없다.
  • 이 클래스는 세 클래스의 객체를 생성하고, 요청된 메서드를 가지는 객체로 위임하는 일을 책임진다.

  • 또한, 어떤 개발자는 아래와 같이 가장 중요한 업무 규칙을 데이터와 가깝게 배치하는 방식을 선호한다.
  • 이 경우라면 가장 중요한 메서드는 기존의 Employee 클래스에 그대로 유지하되, Employee 클래스를 덜 중요한 나머지 메서드들에 대한 퍼사드로 사용할 수도 있다.

image

  • 모든 클래스는 반드시 단 하나의 메서드를 가져야 한다는 주장에 근거하면 앞의 해결책에 반대할 수도 있다.
  • 하지만 실제로 각 클래스에서는 다수의 private 메서드를 포함할 것이다. (현실적으로)
  • 여러 메서드가 하나의 가족을 이루고, 메서드의 가족을 포함하는 각 클래스는 하나의 유효범위가 된다.
  • 해당 유효범위 바깥에서는 이 가족에게 감춰진 식구(private 멤버)가 있는지를 전혀 알 수 없다.

결론

  • SRP 는 메서드와 클래스 수준의 원칙이다.
  • 하지만 이보다 상위의 두 수준에서도 다른 형태로 다시 등장한다.
    • ‘컴포넌트 수준’에서는 공통 폐쇄 원칙(Common Closure Principle)이 된다.
    • ‘아키텍처 수준’에서는 아키텍처 경계(Architectural Boundary)의 생성을 책임지는 변경의 축이 된다.
  • 이러한 개념들은 이후 장에서 살펴본다.

8장 - OCP: 개방 폐쇄 원칙

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

  • 소프트웨어 개체의 행위는 확장할 수 있어야 하지만, 이때 개체를 변경해서는 안 된다는 뜻이다.
  • 만약 요구사항을 확장하는 데 소프트웨어를 많이 수정해야 한다면, 그 소프트웨어 시스템을 설계한 아키텍트는 엄청난 실패를 한 것이다.
  • 소프트웨어 설계를 공부하기 시작한지 얼마 안된 사람들 대다수는 OCP를 클래스와 모듈을 설계할 때 도움되는 원칙이라 알고 있다.
    • 하지만 아키텍처 컴포넌트 수준에서 OCP 를 고려할 때 훨씬 중요한 의미를 가진다.

사고 실험

  • 재무제표를 웹 페이지로 보여주는 시스템이 있다고 생각해보자.
  • 웹 페이지에 표시되는 데이터는 스크롤할 수 있으며, 음수는 빨간색으로 출력한다.
  • 이것을 이해관계자가 보고서 형태로 변환해서 흑백 프린터로 출력해 달라고 요청했다고 해보자.

  • 변경되는 부분은 다음과 같다.
    • 데이터 : 재무 데이터 -> 보고서용 재무 데이터
    • 출력 방식 :
      • 1)웹에 표시
      • 2)프린터 출력
  • 소프트웨어 아키텍처가 훌륭하다면 변경되는 코드의 양이 가능한 한 최소화될 것이다. 이상적인 변경량은 0이다.
    • 서로 다른 목적으로 변경되는 요소를 적절하게 분리하고(단일 책임 원칙, SRP), 이들 요소 사이의 의존성을 체계화함으로써(의존성 역전 원칙, DIP) 변경량을 최소화할 수 있다.

image

  • 여기서 얻을 수 있는 가장 중요한 영감은 보고서 생성이 두 개의 책임으로 분리된다는 사실이다.
    • 하나는 보고서용 데이터를 계산하는 책임
    • 또 다른 하나는 이 데이터를 웹으로 보여주거나 종이로 프린트하기에 적합한 형태로 표현하는 책임
  • 위와 같이 두 책임을 분리했다면, 두 책임 중 하나에서 변경이 발생하더라도 다른 하나는 변경되지 않도록 소스 코드 의존성도 확실히 조직화해야 한다.
    • 또한, 새로 조직화한 구조에선 행위가 확장될 때 변경이 발생되지 않음을 보장해야 한다.
  • 위 목적을 달성하려면 처리 과정을 클래스 단위로 분할하고, 이들 클래스를 아래 그림처럼 컴포넌트 단위로 구분해야 한다.

image

  • Controller - 좌측 상단
  • Interactor - 우측 상단
  • Dababase - 우측 하단
  • Presenter & View - 좌측 하단

  • <I>로 표시된 클래스는 인터페이스이며, <DS>로 표시된 클래스는 데이터 구조다.
  • 화살표가 열려 있다면 사용 관계이며, 닫혀 있다면 구현관계 또는 상속 관계다

  • 여기에서 모든 의존성이 소스 코드 의존성을 나타내고 있다.
  • 예를 들어 화살표가 A 클래스에서 B클래스로 향한다면, A 클래스 에서는 B 클래스를 호출하지만 B 클래스에서는 A 클래스를 전혀 호출하지 않음을 뜻한다.

  • 여기서 주목해야 할 또 다른 점은 이중선은 화살표와 오직 한 방향으로만 교차한다는 사실이다.
  • 아래 이미지처럼 모든 컴포넌트 관계는 단방향으로만 이루어진다는 뜻이다. 이들 화살표는 변경으로부터 보호하려는 컴포넌트를 향하도록 그려진다.

image

  • A 컴포넌트에서 발생한 변경으로부터 B 컴포넌트를 보호하려면 A 컴포넌트가 B 컴포넌트를 의존해야 한다.
    • 이 예제의 경우엔 Presenter 에서 발생한 변경으로부터 Controller 를 보호하고, View 에서 발생한 변경으로부터 Presenter 를 보고하고자 한다. Interactor 는 다른 모든 것에서 발생한 변경으로부터 보호하고자 한다.
  • Interactor 는 OCP를 가장 잘 준수할 수 있는 곳에 위치한다.
    • Database, Controller, Presenter, View 에서 발생한 어떤 변경도 Interactor 에 영향을 주지 않는다.
    • 왜 Interactor 가 이처럼 특별한 위치를 차지해야만 할까?
    • 그 이유는 바로 Interactor 가 업무 규칙을 포함하기 때문이다. 또한 애플리케이션에서 가장 높은 수준의 정책을 포함하기 때문이다.
    • Interactor 주변 컴포넌트는 주변적인 문제를 처리한다. 가장 중요한 문제는 Interactor 가 담당한다.
  • 여기서 보호의 계층구조가 ‘수준(Level)’이라는 개념을 바탕으로 어떻게 생성되는지 주목하자.
    • Interactor 는 가장 높은 수준의 개념이며, 따라서 최고의 보호를 받는다.
    • View 는 가장 낮은 수준의 개념이며, 따라서 거의 보호를 받지 못한다.
    • Presenter는 View 보단 높고 Controller 나 Interceptor 보단 낮은 수준에 위치한다.
  • 이것이 바로 아키텍처 수준에서 OCP가 동작하는 방식이다.
    • 아키텍트는 기능이 어떻게, 왜, 언제 발생하는지에 따라 기능을 분리하고, 분리한 기능을 컴포넌트의 계층 구조로 조직화한다.
    • 컴포넌트 계층구조를 이와 같이 조직화하면 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있다.

방향성 제어

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

정보 은닉

  • FinancialReportRequester 인터페이스는 방향성 제어와는 다른 목적을 가진다.
  • 이 인터페이스는 FinancialReportController가 Interactor 내부에 대해 너무 많이 알지 못하도록 막기 위해서 존재한다.
    • 만약 이 인터페이스가 없었다면, Controller는 FinancialEntities에 대해 추이 종속성을 가지게 된다.
  • 추이 종속성을 가지게 되면, 소프트웨어 엔티티는 ‘자신이 직접 사용하지 않는 요소에는 절대로 의존해서는 안 된다’는 소프트웨어 원칙을 위반하게 된다.
    • 이 원칙은 인터페이스 분리 원칙(ISP)와 공통 재사용 원칙(CRP)을 설명할 때 다시 한번 설명한다.
  • 다시 말해, Controller에서 발생한 변경으로부터 Interactor를 보호하는 일의 우선순위가 가장 높지만, 반대로 Interactor에서 발생한 변경으로부터 Controller도 보호되기를 바란다. 이를 위해 Interactor 내부를 은닉한다.

Note: 클래스 A가 클래스 B를 의존하고, 다시 B가 C를 의존한다면 클래스 A는 C에 의존하게 되는데 이를 추이 종속성이라 한다. 클래스 이외의 소프트웨어의 모든 엔티티에도 동일하게 적용된다. 만약 클래스 의존성이 순환적이라면, 모든 클래스가 서로 의존하게 되는 문제가 있다.

결론

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

9장 - LSP: 리스코프 치환 원칙

  • 1988년 바바라 리스코프는 하위 타입을 아래와 같이 정의했다.

S 타입의 객체(o1), 각각에 대항하는 T 타입 객체(o2)가 있고, T 타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위 타입이다.

상속을 사용하도록 가이드하기

image

  • 위 License라는 클래스는 calcFee()라는 메서드를 가지며, Billing 애플리케이션에서 이 메서드를 호출한다.
  • License에는 PersonalLicense와 BusinessLicense라는 두 가지 ‘하위 타입’이 존재한다.
    • 이들 두 하위 타입은 서로 다른 알고리즘을 이용해서 라이선스 비용을 계산한다.
  • 위 설계는 LSP를 준수한다.
    • Billing 애플리케이션의 행위가 License 하위 타입 중 무엇을 사용하는지에 전혀 의존하지 않기 때문이다.
    • 이들 하위 타입은 모두 License 타입을 치환할 수 있다.

정사각형/직사각형 문제

image

  • 이 예제에서 Square(정사각형)는 Rectangle(직사각형)의 하위 타입으로는 적합하지 않다.
  • Rectangle의 높이와 너비는 서로 독립적으로 변경될 수 있는 반면, Square의 높이와 너비는 반드시 함께 변경되기 때문이다.
  • User는 대화하고 있는 상대가 Rectangle이라고 생각하므로 혼동이 생길 수 있다. 아래의 코드를 보자
1
2
3
4
Rectangle r = ...
r.setW(5)
r.setH(2)
assert(r.area() == 10);
  • … 코드에서 Square를 생성한다면 assert 문은 실패하게 된다.
  • 이런 형태의 LSP 위반을 막기 위한 유일한 방법은 Rectangle이 실제로는 Square인지를 검사하는 메커니즘을 User에 추가하는 것이다.
  • 하지만 그럴 경우에는 User의 행위가 사용하는 타입에 의존하게 되므로, 서로 치환할 수 없게 된다.
    • User에 Rentangle과 Square라는 타입에 따라 바뀌는 행위를 정의함으로써 Rentangle과 Square를 치환할 경우 정상적인 동작이 불가능해진다.

LSP와 아키텍처

  • 앞서 본 것처럼 LSP는 객체 지향에서의 상속을 사용하도록 가이드하는 방법 정도로 가이드되었다.
  • 하지만 시간이 지나면서 LSP는 인터페이스와 구현체에도 적용되는 더 광범위한 소프트웨어 설계 원칙으로 변모해 왔다.
  • 여기에서 말하는 인터페이스는 다양한 형태로 나타난다.
    • 자바스러운 언어라면 인터페이스 하나와 이를 구현하는 여러 개의 클래스로 구성된다.
    • 루비라면 동일한 메서드 시그니처를 공유하는 여러 개의 클래스로 구성된다.
    • 또는 동일한 REST 인터페이스에 응답하는 서비스 집단일 수도 있다.
  • 잘 정의된 인터페이스와 그 인터페이스의 구현체끼리의 상호 치환 가능성에 기대는 사용자들이 존재하기 떄문이다.

LSP 위반 사례

  • 다양한 택시 파견 서비스들을 통합하는 애플리케이션을 만들고 있다고 해보자.
  • 고객은 어느 택시업체인지는 신경쓰지 않고 자신의 상황에 가장 적합한 택시를 찾는다.
  • 고객이 이용할 택시를 결정하면, 시스템은 REST 서비스를 통해 선택된 택시를 고객 위치로 파견한다.
  • 택시 파견 REST 서비스의 URI가 운전기사 데이터베이스에 저장되어 있다고 가정해 보자.
  • 시스템이 고객에게 알맞은 기사를 선택하면, 해당 기사의 레코드로 부터 URI 정보를 얻은 다음, 그 URI 정보를 이용하여 해당 기사를 고객 위치로 파견한다.
1
purplecab.com/driver/Bob
  • 시스템은 이 URI에 파견에 필요한 정보를 덧붙인 후, 아래와 같이 PUT 방식으로 호출한다.
1
2
3
4
purplecab.com/driver/Bob
	/pickupAddress/24 Maple St.
    /pickupTime/153
    /destination/ORD
  • 이 예제에서 분명한 점은 파견 서비스를 만들 때 다양한 택시업체에서 동일한 REST 인터페이스를 반드시 준수하도록 만들어야 한다는 사실이다.
    • 즉, 서로 다른 택시업체가 pickupAddress, pickupTime, destination 필드를 모두 동일한 방식으로 처리해야 한다.
  • 만약 택시업체 애크미(ACME)의 프로그래머들이 destination 필드를 dest로 축약해서 사용했다고 해보자.
    • 애크미는 이 지역에서 가장 큰 택시업체이다. 그리고 가장 영향력 높은 회사이다.
    • 이럴 경우 우리 회사의 시스템 아키텍처에는 무슨 일이 벌어질까?
    • 우리는 이 예외 사항을 처리하는 로직을 추가해야만 할 것이다.
  • 애크미 소속 택시 기사를 파견하는 요청은 나머지 업체의 기사를 파견할 때 와는 다른 규칙을 이용하여 구성해야만 한다.
1
if (driver.getDispatchUri().startsWith("acme.com").....
  • 위와 같은 if 문을 추가하여 예외처리를 해야 한다.
  • 하지만 “acme.com”라는 단어를 코드 자체에 추가하는 것은 그 자체로 끔찍할 뿐만 아니라 이해할 수 없는 온갖 종류의 에러가 발생할 확률이 높아진다.

  • 예를 들어 애크미사가 퍼플사를 인수한다면 어떻게 될까?
  • 합병된 퍼플사의 브랜드와 웹 사이트는 애크미와는 독립적으로 유지하되, 회사 시스템은 통합할 경우 퍼플사를 위해 “purple”을 처리하는 또 다른 if문을 추가해야 할 것이다.
  • 아키텍트는 위 같은 버그로 부터 시스템을 격리해야 한다.
  • 이때 파견 URI를 키로 사용하는 설정용 데이터베이스를 이용하는 파견 명령 생성 모듈을 만들어야 할 수도 있다.
  • 설정 정보는 대체로 아래와 같을 것이다.

스크린샷 2023-07-18 오후 9 50 06

  • 또한 아키텍트는 REST 서비스들의 인터페이스가 서로 치환 가능하지 않다는 사실을 처리하는 중요하고 복잡한 매커니즘을 추가해야 한다.

결론

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

10장 - ISP: 인터페이스 분리 원칙

image

  • ISP 는 위 다이어그램에서 유래했다.
  • 다수의 사용자가 OPS 클래를 사용하는데, User1은 오직 op1을 User2는 오직 op2를 User3은 오직 op3만을 사용한다고 가정해보자.
  • 그리고 OPS가 정적 타입 언어로 작성된 클래스라고 가정해보자.
  • 이 경우 User1에서는 op2와 op3를 전혀 사용하지 않음에도 User1의 소스 코드는 이 두 메서드에 의존하게 된다.
  • 이러한 의존성으로 인해 User1과 관련된 코드는 변경되지 않았어도 OPS 클래스에서 op2의 소스 코드가 변경되면 User1도 다시 컴파일 후 새로 배포해야 한다.
  • 이는 아래와 같이 오퍼레이션을 인터페이스 단위로 분리하여 해결 가능하다.

image

  • User1의 소스 코드는 U1Ops에 의존하지만 OPS에는 전혀 의존하지 않게 된다.
  • 따라서 OPS에서 발생한 변경이 User1과는 전혀 관계없는 변경이라면, User1은 다시 컴파일하고 새로 배포하지 않아도 된다.

ISP 와 언어

  • 위의 예제에서 본 사례는 언어 타입에 의존한다.

정적 타입 언어

  • import, use, include 같은 타입 선언문을 사용하도록 강제한다.
  • 이로 인한 소스 코드 의존성이 발생하고, 재컴파일 또는 재배포가 강제되는 상황이 무조건 초래된다.

동적 타입 언어

  • 루비나 파이썬 같은 언어들..
  • 소스 코드에 import, use 와 같은 선언문이 존재하지 않는 대신 런타임에 추론이 발생한다.
  • 따라서 소스 코드 의존성이 아예 없으며, 결국 재컴파일과 재배포가 필요없다.
  • 즉, 정적 타입 언어를 사용할 때보다 유연하며 결합도가 낮은 시스템을 만들 수 있는 이유는 바로 이 때문이다.
  • 이러한 사실로 인해 ISP 를 아키텍처가 아닌, 언어와 관련된 문제라 결론 내릴 여지가 있다.

ISP 와 아키텍처

  • ISP를 사용하는 근본적인 동기로 보았을때 필요 이상으로 많은 걸 포함하는 모듈에 의존하는 것은 해롭다.
  • 불필요한 재컴파일과 재배포를 강제하기 때문이다.
  • 하지만 더 고수준인 아키텍처 수준에서도 마찬가지 상황이 발생한다.

image

  • 위 이미지에서 시스템 S 는 F 프레임워크에 의존하고 F 프레임워크는 D 데이터베이스에 의존한다.
  • 만일, F에서 S와는 전혀 관계없는 기능이 D에 포함된다고 가정해보자.
  • 그 기능 때문에 D 내부가 변경되면, F 를 재배포해야 할 수도 있고, 따라서 S까지 재배포해야 할지 모른다.
  • 더 심각한 문제는 D 내부의 기능중 F와 S에서 불필요한 그 기능에 문제가 발생시 F와 S에 영향을 준다는 사실이다.

결론

  • 여기서 배울 수 있는 교훈은 불필요한 짐을 실은 무언가에 의존하면 예상치 못한 문제에 빠진다는 사실이다.
  • 이 아이디어는 13장 “컴포넌트 응집도” 에서 공통 재사용 원칙을 논할 때 더 자세히 다루겠다.

11장 - DIP: 의존성 역전 법칙

  • 의존성 역전 원칙에서 말하는 ‘유연성이 극대화된 시스템’이란 소스코드 의존성이 추상에 의존하며 구체에는 의존하지 않는 시스템이다.
  • 자바와 같은 정적 타입 언어에서 이 말은 use, import, include 구문은 오직 인터페이스나 추상 클래스 같은 추상적인 선언만을 참조해야 한다는 뜻이다. 구체적인 대상에는 절대로 의존해서는 안 된다.
  • 루비나 파이썬 같은 동적 타입 언어에도 동일한 규칙이 적용된다.
    • 소스 코드 의존 관계에서 구체 모듈은 참조해서는 안 된다. 하지만 이들 언어의 경우 구체 모듈이 무엇인지를 정의하기가 힘들다.
    • 호출할 함수가 구현된 모듈이라면 참조하지 않기가 특히 어렵다.
  • 이 아이디어는 확실히 비현실적이다.
  • 소프트웨어 시스템이라면 구체적인 많은 장치에 의존하기 때문이다.
    • 예를 들어 자바의 String은 구체 클래스이며, 이를 애써 추상 클래스로 만들려는 것은 현실성이 없다.
    • java.lang.String 구체 클래스에 대한 소스 코드 의존성은 벗어날 수도 없고, 벗어나서도 안 된다.
  • 하지만 String 클래스는 매우 안정적이다. 클래스가 변경되는 일은 거의 없으며, 있더라도 매우 엄격하게 통제된다.
    • 프로그래머와 아키텍트는 String 클래스에서 변덕스러운 변경이 자주 발생하리라고 염려할 필요가 없다.
  • 이러한 이유로 DIP를 논할 때 운영체제나 플랫폼 같이 안정성이 보장된 환경에 대해서는 무시하는 편이다.
  • 우리는 이들 환경에 대한 의존성은 용납하는데, 변경되지 않는다면 의존할 수 있다는 사실을 이미 알고 있기 때문이다.
  • 우리가 의존하지 않도록 피하고자 하는 것은 바로 변동성이 큰 구체적인 요소다.
    • 그리고 이 구체적인 요소는 우리가 열심히 개발하는 중이라 자주 변경될 수 밖에 없는 모듈들이다.

안정된 추상황

  • 인터페이스는 구현체보다 변동성이 낫다.
    • 구현체에 변경이 생기더라도 구현하는 인터페이스는 변경될 필요가 대체로 변경될 필요가 없다.
  • 실제로 뛰어난 소프트웨어 설계자와 아키텍트라면 인터페이스의 변동성을 낮추기 위해 노력한다.
    • 인터페이스를 변경하지 않고도 구현체에 기능을 추가할 수 있는 방법을 찾기 위해 노력한다.
    • 이는 소프트웨어 설계의 기본이다.
  • 즉, 안정된 소프트웨어 아키텍처란 변동성이 큰 구현체에 의존하는 일은 지양하고, 안정된 추상 인터페이스를 선호하는 아키텍처라는 뜻이다.

DIP 를 위한 코딩 실천법

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

팩토리

  • 이 규칙들을 준수하려면 변동성이 큰 구체적인 객체는 특별히 주의해서 생성해야 한다.
  • 하지만 사실상 모든 언어에서 객체를 생성하려면 해당 객체를 구체적으로 정의한 코드에 대해 소스 코드 의존성이 발생하기 때문이다.
  • 자바 등 대다수의 객체 지향 언어에선 이처럼 바람직하지 못한 의존성을 처리할 때 추상 팩토리를 사용하곤 한다.

image

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

  • 위 이미지에의 곡선은 아키텍처 경계를 뜻한다.
  • 이 곡선은 구체적인 것들로부터 추상적인 것들을 분리한다.
  • 소스 코드 의존성은 해당 곡선과 교차할 때 모두 한 방향, 즉 추상적인 쪽으로 향한다.
  • 곡선은 시스템을 두 가지 컴포넌트로 분리한다.
    • 하나는 추상 컴포넌트. 추상 컴포넌트는 애플리케이션의 모든 고수준 업무 규칙을 포함한다.
    • 또 다른 하나는 구체 컴포넌트. 구체 컴포넌트는 업무 규칙을 다루기 위해 필요한 모든 세부사항을 포함한다.
  • 소스 코드 의존성은 제어흐름과는 반대 방향으로 역전된다. 이런 이유로 이 원칙을 의존성 역전 원칙이라 부른다.

구체 컴포넌트

  • 위 이미지의 구체 컴포넌트에는 구체적인 의존성이 하나 있고(ServiceFactoryImpl 구체 클래스가 ConcreteImpl 구체 클래스에 의존), 따라서 DIP에 위배된다.
  • 이는 일반적인 일이다. DIP 위배를 모두 없앨 수는 없다.
  • 하지만 DIP를 위배하는 클래스들은 적은 수의 구체 컴포넌트 내부(ServiceFactoryImpl)로 모을 수 있고, 이를 통해 시스템의 나머지 부분과는 분리할 수 있다.
  • 대다수의 시스템은 이러한 구체 컴포넌트를 최소한 하나는 포함할 것이다.
    • 흔히 이 컴포넌트를 메인(Main)이라 부르는데, main 함수를 포함하기 때문이다.
    • 위 이미지의 경우라면 main 함수는 ServiceFactoryImpl 의 인스턴스를 생성 후, 이 인스턴스를 ServiceFactory 타입으로 전역 변수에 저장할 것이다.
    • 그런 다음 Application 은 이 전역 변수를 통해 ServiceFactoryImpl의 인스턴스에 접근할 것이다.

결론

  • 앞으로 고수준의 아키텍처 원칙을 다루게 되면서 DIP는 몇 번이고 계속 등장할 것이다.
  • DIP는 아키텍처 다이어그램에서 가장 눈에 드러나는 원칙이 될 것이며, 위 이미지의 곡선은 이후의 장에선 아키텍처 경계가 될 것이다.
  • 그리고 의존성은 이 곡선을 경계로, 더 추상적인 엔티티가 있는 쪽으로만 향한다.
  • 추후 이 규칙은 의존성 규칙(Dependency Rule)이라 부를 것이다.

Reference

This post is licensed under CC BY 4.0 by the author.

[클린아키텍처] 1 ~ 6장

[클린아키텍처] 12 ~ 14장