모종닷컴

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

독서나 해볼까

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

모종 2023. 1. 8. 15:27
반응형

클린 아키텍처 - 소프트웨어  구조와 설계의 원칙 (로버트 C. 마틴)

7장에서 11장까지는 설계 원칙에 대한 내용들을 다루고 있습니다. 양이 많아 따로 정리를 하려고 할까 했었는데 5장이 모두 서로 관련이 있는 내용들이기에 포스트 하나에 정리하였습니다.

7장: SRP(단일책임원칙)

더보기

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

  • ‘액터’란 변경을 요청하는 한 명 이상의 사람들을 가리킨다.
  • ‘모듈’을 가장 단순하게 정의하면 소스 파일이며, 단순히 함수와 데이터 구조로 구성된 응집된 집합이라고도 일컬을 수 있다.
    • ‘응집된’이라는 단어가 SRP를 암시한다. 단일 액터를 책임지는 코드를 함께 묶어주는 힘이 바로 응집성이다.

이것만 들었을 때 와닿지 않는 부분들이 많다. 그래서 SRP 원칙을 위반하는 징후들을 살펴보면서 이해하는 게 가장 좋은 방법이다.

징후 1: 우발적 중복

급여 애플리케이션의 Employee라는 클래스를 보도록 하겠다. 이 클래스는 세 사지 메서드 calculatePay(), reportHours(), save()를 가진다.

  • calculatePay 메서드는 회계팀에서 기능을 정의하며, CFO 보고를 위해 사용한다.
  • reportHours 메서드는 인사팀에서 기능을 정의하고 사용하며, COO 보고를 위해 사용한다.
  • save 메서드는 데이터베이스 관리자가 기능을 정의하고, CTO 보고를 위해 사용한다.

이 클래스는 SRP를 위반하는데, 이들 세 가지 메서드가 서로 매우 다른 세 명의 액터를 책임지기 때문이다. 개발자가 이 세 메서드를 Employee라는 단일 클래스에 배치하여 세 액터가 서로 결합되어 버렸다. 이 결합으로 인해 CFO 팀에서 결정한 조치가 COO팀이 의존하는 무언가에 영향을 줄 수 있다.

예로 calculatePay메서드와 reportHours 메서드가 코드 중복을 피하기 위해 일부 코드를 공유하고 있는 상황에서 CFO 팀의 요구로 인해 이 공유 코드가 수정이 되면 COO팀도 동일한 코드 수정의 영향을 받게 되기 때문이다.

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

징후 2: 병합

소스 파일에 다양하고 많은 메서드를 포함하면 병합이 자주 발생하리라고 짐작하기는 어려운 일이 아니다. 특히 이들 메서드가 서로 다른 액터를 책임진다면 병합이 발생할 가능성은 더 높다.

예를 들어 DBA가 속한 CTO 팀에서 데이터베이스의 Employee 테이블 스키마를 수정하기로 결정하였다. 동시에 인사 담당자가 속한 COO 팀에서는 reportHours() 메서드의 보고서 포맷을 변경하기로 결정했다고 해보자.

두 명의 서로 다른 개발자가 Employee 클래스를 체크아웃받은 후 변경사항을 적용하였을 때 이들 변경사항은 서로 충돌하게 된다.

병합 징후는 서로 다른 목적으로 동일한 소스 파일을 변경하는 경우에 발생한다. 이 문제를 벗어나는 방법은 서로 다른 액터를 뒷받침하는 코드를 서로 분리하는 것이다.

해결책

이러한 문제의 해결책은 다양한데 핵심은 메서드를 각기 다른 클래스로 이동시키는 방식이다. 데이터와 메서드를 분리하는 방식인데 아무런 메서드가 없는 간단한 데이터 구조인 EmployeeData 클래스를 만들어, 세 개의 클래스가 공유하도록 한다.

각 클래스는 자신의 메서드에 반드시 필요한 소스 코드만을 포함한다. 세 클래스는 서로의 존재를 몰라야 한다. 이 해결책은 개발자가 세 가지 클래스를 인스턴스화하고 추적해야 한다는 게 단점이다. 이런 단점을 위해 흔히 쓰는 기법은 퍼사드 패턴을 적용하는 것이다.

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

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

결론

단일 책임 원칙은 메서드와 클래스 수준의 원칙이다.

8장: OCP(개방-폐쇄 원칙)

더보기

“소프트웨어 개체(아티팩트)는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다”

위를 다시 말하자면 소프트웨어 개체의 행위는 확장할 수 있어야 하지만, 개체를 변경해서는 안 된다는 것과 같다.

소프트웨어 설계를 공부하기 시작한 지 얼마 안 된 사람들 대다수는 OCP를 클래스와 모듈을 설계할 때 도움 되는 원칙이라고 알고 있다. 하지만 아키텍처 컴포넌트 수준에서 OCP를 고려할 때 훨씬 중요한 의미를 가진다.

사고 실험

재무제표를 웹 페이지로 보여주는 시스템이 있다고 생각해 보자. 웹 페이지에 표시되는 데이터는 스크롤할 수 있으며, 음수는 빨간색으로 출력한다. 이제 이해관계자가 동일한 정보에 대해 확장하는 개념의 요청을 했다고 가정해 보자. 요구사항은 아래와 같다.

  • 흑백 프린터로 출력해야 한다.
  • 보고서의 페이지 번호가 제대로 매겨져 있어야한다.
  • 페이지마다 적절한 머리글과 바닥글이 있어야 한다.
  • 표의 각 열에는 레이블이 있어야 한다
  • 음수는 괄호로 감싸야 한다

기존에 없던 요구사항 및 기능들이기 때문에 당연히 코드를 새로 작성해야 한다. 이때 원래 코드를 얼마나 많이 수정해야 할까? 이상적인 변경량은 0이다. 아키텍처가 훌륭하다면 변경되는 코드의 양이 가능한 한 최소화될 것이다.

서로 다른 목적으로 변경되는 요소를 적절하게 분리하고(단일 책임 원칙), 이들 요소 사이의 의존성을 체계화함으로써(의존성 역전 원칙) 변경량을 최소화할 수 있다. 단일 책임 원칙을 적용하면 데이터 흐름을 아래와 같은 형태로 만들 수 있다.

재무 데이터를 검사한 후 보고서용 데이터를 생성한다. 이후 필요에 따라 두 가지 보고서 생성 절차 중 하나를 거쳐 적절히 포매팅한다. 여기서 얻을 수 있는 가장 중요한 영감은 보고서 생성이 두 개의 책임으로 분리된다는 사실이다. 하나는 보고서용 데이터를 계산하는 책임이며, 나머지 하나는 이 데이터를 웹으로 보여주거나 종이로 프린터 하기에 적합한 형태로 표현하는 책임이다.

이처럼 책임을 분리했다면, 두 책임 중 하나에서 변경이 발생하더라도 다른 하나는 변경되지 않도록 소스 코드 의존성도 확실히 조직화해야 한다. 또한 행위를 확장시킬 때 변경이 발생하지 않음을 보장해야 한다. 위를 클래스 단위로 분할했을 때 클래스 구조 예시를 책에서는 아래와 같이 만들었다.

FinancialDataMapper는 구현 관계를 통해 FinancialDataGateway를 알고 있지만, FinancialDataGateway는 FinancialDataMapper에 대해 아무것도 알지 못한다. 이와 같은 구조를 컴포넌트 시점에서 바라보면 모든 컴포넌트 관계는 단방향으로 이루어진다.

A컴포넌트에서 발생한 변경으로부터 B컴포넌트를 보호하려면 A컴포넌트가 B컴포넌트에 의존해야 한다. 위의 사진을 예시로 Presenter에서 발생한 변경으로부터 Controller를 보호할 수 있다.

특히 Interactor는 OCP를 가장 잘 준수하고 있다. Database, Controller, Presenter, View에서 발생하는 어떠한 변경도 Interactor에 영향을 주지 않는다. Interactor는 업무 규칙을 포함한 가장 높은 수준의 정책을 포함하기 때문에 이러한 위치에 두어야 한다. View는 가장 낮은 수준의 개념이다. 따라서 View의 경우 거의 보호를 받지 못한다. Presenter는 View보다는 높고 Controller나 Interactor보다는 낮은 수준에 위치한다.

이것이 아키텍처 수준에서 OCP가 동작하는 방식이다. 아키텍트는 기능이 어떻게, 왜, 언제 발생하는지에 따라서 기능을 분리하고, 분리한 기능을 컴포넌트의 계층구조로 조직화한다. 이처럼 조직화하면 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있다.

방향성 제어

FinancialDataGateway 인터페이스는 FinancialReportGenerator와 FinancialDataMapper 사이에 위치하는 데, 이는 의존성을 역전시키기 위해서다. FinancialDataGateway 인터페이스가 없었다면 Interactor 컴포넌트에서 Database 컴포넌트로 바로 향하게 된다.

정보 은닉

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

추이 종속성을 가지게 되면, 엔티티는 자신이 ‘자신이 직접 사용하지 않는 요소에는 절대로 의존해서는 안 된다’는 소프트웨어 원칙을 위반하게 된다.

결론

OCP는 시스템 아키텍처를 떠받치는 원동력 중 하나이다. OCP의 목표는 시스템을 확장하기 쉬운 동시에 변경으로 인해 시스템이 많은 영향을 받지 않도록 하는데 있다.

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

더보기

리스코프 치환 원칙으로 알려진 개념은 아래와 같다.

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

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

위는 LSP 원칙을 준수한 형태의 클래스이다. 애플리케이션의 행위가 License 하위 타입 중 무엇을 사용하는지에 전혀 의존하지 않고, 하위 타입은 모두 License 타입을 치환할 수 있기 때문이다.

정사각형 / 직사각형 문제

Square = 정사각형, Rectangle = 직사각형일 때 위는 LSP 원칙을 준수하지 못하고 있다. 특히 Square는 높이와 너비가 서로 독립적으로 변경될 수 없기 때문에 Rectangle 하위 타입으로는 적합하지 못하다. 책의 예시에서 이를 잘 보여주고 있다.

val r = Rectangle()
r.apply {
	this.w = 5
	this.h = 2
}

assert(r.area() == 10)

이런 형태의 LSP 위반을 막기 위한 유일한 방법은 Rectangle이 실제로는 Square인지를 검사하는 메커니즘을 User에 추가하는 것이다. 그렇지만 이렇게 하면 User의 행위가 사용하는 타입에 의존하게 되므로, 결국 타입을 서로 치환할 수 없게 된다.

LSP와 아키텍처

아키텍처 관점에서 LSP를 이해하는 최선의 방법은 이 원칙을 어겼을 때 시스템 아키텍처에서 무슨 일이 일어나는지 관찰하는 것이다.

결론

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

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

더보기

위와 같은 구조로 되어있는 클래스에서 User1은 op1, User2는 op2, User3은 op3 메서드만을 사용한다고 가정하자. User1는 op2, op3 메서드를 전혀 사용하지 않음에도 불구하고 op2 혹은 op3 가 수정되었을 때 User1도 다시 컴파일한 후 새로 배포해야 한다. 이러한 문제는 오퍼레이션을 인터페이스 단위로 분리하여 해결할 수 있다.

이처럼 분리하게 되면 User1의 코드는 U1Ops에는 의존하지만 OPS에는 의존하지 않게 된다.

ISP와 언어

앞선 사례들은 언어 타입에 의존한다. 정적 타입 언어는 사용자가 import, use, include와 같은 타입 선언문을 사용하도록 강제한다. 이처럼 소스 코드의 선언문으로 인해 소스 코드 의존성이 발생하고, 이로 인해 재컴파일 또는 재배포가 강제되는 상황이 초래된다.

루비나 파이썬과 같은 동적 타입 언어에서는 소스 코드에 이러한 선언문이 존재하지 않는다. 대신 런타임에 추론이 발생한다. 따라서 소스 코드 의존성이 아예 없으며, 재컴파일 재배포가 필요 없다. 동적 타입 언어를 사용하면 정적 타입 언어를 사용할 때보다 유연하며 결합도가 낮은 시스템을 만들 수 있는 이유는 이 때문이다.

ISP와 아키텍처

고수준인 아키텍처 수준에서도 마찬가지인 상황이 발생한다. 예를 들어 S시스템 구축에 참여하고 있는 아키텍트가 있다고 해보자. 아키텍트는 F라는 프레임워크를 시스템에 도입하기를 원한다. F 프레임워크 개발자는 특정한 D 데이터베이스를 반드시 사용하도록 만들었다고 가정해 보자. 이렇게 되면 시스템 S는 F에 의존하게 되는데 그러면 결국 S 시스템이 D 데이터베이스에 의존하는 꼴이 된다.

만약 F, S에서 전혀 사용하지 않는 기능이 D에 변경이 되었다면 F, S까지 재배포해야 할지 모르며, 전혀 사용하지 않는 기능에서 문제가 발생해도 F, S에 영향을 준다.

결론

불필요한 짐을 실은 무언가에 의존하면 예상치 못한 문제에 빠진다

11장: DIP(의존성 역전 원칙)

더보기

의존성 역전 원칙에서 말하는 ‘유연성이 극대화된 시스템’이란 소스 코드 의존성이 추상에 의존하며 구체에는 의존하지 않는 시스템이다. 자바와 같은 정적 타입 언어에서는 import 구문은 오직 인터페이스나 추상 클래스 같은 추상적인 선언만을 참조해야 하고 구체적인 대상에는 절대로 의존해서는 안된다.

DIP를 논할 때 운영체제나 플랫폼 같이 안정성이 보장된 환경에 대해서는 무시하는 편이다. 예로 String은 구체 클래스인데 String 클래스가 변경되는 일은 거의 없으며, 있더라도 엄격하게 통제되기 때문이다.
그래서 의존하지 않도록 피하고자 하는 것은 변동성이 큰 구체적인 요소이다. 그리고 이 구체적인 요소는 계속 개발하는 중이라 자주 변경될 수밖에 없는 모듈들이다.

안정된 추상화

추상 인터페이스에 변경이 생기면 구체화된 구현체들도 따라서 수정이 된다. 반대로 구현체에 변경이 생겼을 때는 인터페이스의 경우 변경될 필요가 없다. 인터페이스는 구현체보다 변동성이 낮다. 안정된 소프트웨어 아키텍처란 변동성이 큰 구현체에 의존하는 일은 지양하고, 안정된 추상 인터페이스를 선호하는 아키텍처이다.

DIP는 아래와 같은 코딩 실천법으로 요약할 수 있다.

  • 변동성이 큰 구체 클래스를 참조하지 말라
  • 변동성이 큰 구체 클래스로부터 파생하지 말라
  • 구체 함수를 오버라이드 하지 말라.
  • 구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라

팩토리

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

위는 추상 팩토리를 사용한 구조이다. Application은 Service 인터페이스를 통해 ConcreteImpl 인스턴스를 생성해야 한다. 그러나 얘기했듯이 객체를 생성하는 부분을 Application에서 하게 되면 소스 코드 의존성이 발생하기 때문에 Application은 ServiceFactory의 makeSvc를 이용하여 객체를 생성하도록 만들었다.

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

구체 컴포넌트

그림을 보면 ServiceFactoryImpl가 ConcreteImpl에 의존하고 있다. 이는 소스 코드가 구체화된 클래스에 의존하고 있으므로 DIP에 위배되는 상황이다. 하지만 DIP를 위배하는 클래스들은 적은 수의 구체 컴포넌트 내부로 모을 수 있고, 이를 통해 시스템의 나머지 부분과는 분리할 수 있다. (이거 약간 “하지만 빨랐죠?” 같은 느낌이에요 ㅋㅋ)

반응형

'독서나 해볼까' 카테고리의 다른 글

카프카 책 추천  (0) 2023.10.14
[클린 아키텍처] 5장 ~ 6장  (0) 2022.12.28
[클린 아키텍처] 3장 ~ 4장  (0) 2022.12.17
[클린 아키텍처] 챕터 1  (0) 2022.12.11