3부(7, 8, 9, 10, 11 장) 설계 원칙

Posted by yunki kim on May 28, 2022

  좋은 소프트웨어 시스템은 깔끔한 코드로 부터 시작한다. 코드가 깔끔하지 않으면 좋은 아키텍처는 무용지물이다. 깔끔한 코드를 작성하는 원칙인 SOLID를 살펴보자.

  SOLID는 함수와 데이터로 이루어진 집합을 배치하는 방법과 이들을 서로 결합하는 방법을 설명한다. SOLID의 목적은 중간 수준의 소프트웨어 구조가 아래와 같은 점들을 만족하게 하는 것이다.

    1. 변경에 유연하다.

    2. 이해하기 쉽다.

    3. 많은 소프트웨어 시스템에 사용될 수 있는 컴포넌트 기반이 된다.

  여기서 중간 수준이란 모듈 수준을 의미한다. 즉, 코드 수준보다는 조금 상위에 적용되는 원칙이며 모듈과 컴포넌트 내부에서 사용되는 소프트웨어 구조를 정의하는 데 도움을 준다.

7장. SRP: 단일 책임 원칙

  많은 프로그래머들은 SRP를 단 하나의 일만 해야 한다는 의미로 받아들인다. 하지만 단 하나의 일만 해야 한다는 원칙은 함수는 반드시 하나의 일만 해야 한다는 원칙이다. 즉, SRP와는 별도의 원칙이다. 이 원칙은 커다란 함수를 작은 함수들로 리팩터링하는 더 저수준에서 사용된다.

  소프트웨어 시스템은 사용자와 이해관계자를 만족시키기 위해 변경된다. 그리고 특정 목적을 가지고 소프트웨어를 변경시키려 하는 사람들을 하나의 그룹으로 묶어보자. 이 그룹의 개개인은 목적이 같기에 변경하려는 부분이 같을 것이다. 그리고 이 그룹을 액터(actor)라 하자. 그러면 "단일 모듈은 변경의 이유가 하나, 오직하나뿐이여야 한다"라는 정의보다 더 명확한 정의를 만들 수 있다.

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

  여기서의 모듈은 단순히 함수와 데이터 구조로 구성된 응집된 집합(cohesive)이다. 단일 액털르 책임지는 코드를 함께 묶어주는 힘이 응집성(cohesion)이다.

  SRP를 이해하기 위해 이 원칙을 위반하는 징후들을 살펴보자.

징후1: 우발적 중복

  다음과 같은 급여 애플리케이션이 존재하고 3 개의 액터가 이를 공유한다 해보자.

  여기서 calculatePay() 메서드와 reportHours() 메서드가 내부적으로 regularHours()라는 메서드를 호출한다 하자. 이 상황에서 CFO 액터가 필요에 의해 regularHours()를 수정했다면 COO는 알지 못한다. 이로 인해 COO는 원치 않는 결과를 얻을 수 있다.

  이런 문제는 결국 서로 다른 액터가 의존하는 코드를 너무 가깝게 배치해 발생한 일이다. SRP를 지키며 서로 다른 액터가 의존하는 코드를 서로 분리해야 한다.

징후 2: 병합

  소스 파일에 다양하고 많은 메서드가 포함되 있고 이들이 서로 다른 액터들을 책임진다면 병합이 발생할 가능성이 높다. 위 예시에서 두 액터가 Employee 클래스를 체크아웃 받고 변경을 했다 하자. 그러면 이들의 변경사항은 서로 충돌하여 병합이 발생한다.

  많은 사람이 서로 다른 목적으로 동일한 소스파일을 변경할 때 병합이 발생한다. 따라서 서로 다른 액터를 뒷받침하는 코드를 서로 분리해야 한다.

해결책

  이 문제를 해결하는 방법 중 가장 확실한 해결책은 데이터와 메서드를 분리하는 방식이다. 그러면 각 클래스는 서로의 존재를 모르기에 '우연한 중복'을 피할 수 있다.

  이로 인해 개발자가 세 가지 클래스를 인스턴스화하고 추적해야 한다는 단점이 발생했다. 이 문제는 퍼사드(Facade) 패턴으로 해결할 수 있다.

  8장. OCP: 개방-폐쇄 원칙

    OCP는 다음과 같은 정의를 가진다.

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

  OCP는 아키텍처 컴포넌트 수준에서 고려할 때 훨씬 중요한 의미를 가진다. 더 명확한 이해를 위해 사고 실험(thought experiment)을 해보자.

사고 실험

  재무재표를 웹 페이지로 보여주는 시스템이 있다 하자. 그 후, 이 재무재표를 보고서 형태로 변환해 프린터로 출력해야 한다는 요구사항이 추가됬다 해보자. 이 때, 변경해야 하는 코드의 이상적인 변경량은 0이다.

  이상을 최대한 달성하기 위해서는 서로 다른 목적으로 변경되는 요소를 적절하게 분리하고(SRP), 이들 요소 사이의 의존성을 체계화해서(DIP) 변경량을 최소화할 수 있다.

  이들을 종합하면 다음과 같은 데이터 흐름이 나온다.

  이처럼 책임을 분리했다면, 두 책임 중 하나에서 변경이 발생해도 다른 하나는 변경이 되지 않게 소스 코드 의존성도 확실히 조직화해야 한다. 또 한, 새로 조직화한 구조에서는 행위가 확장될 떄 변경이 발생하지 않아야 한다.

  이런 목적을 달성하기 위해선 처리 과정을 클래스 단위로 분할하고, 클래스를 컴포넌트 단위로 구분해야 한다.

  이 다이어그램에서 <I>는 인터페이스, <DS>는 데이터 구조다. 화살표가 열러 있으면 사용 관계, 닫혀 있으면 구현 또는 상속 관계다.

  여기서 주목해야 할 점은 의존성이 단방향으로 흐른다는 것이다. 이 화살표는 변경으로 부터 보호하려는 컴포넌트를 향하게 그려진다. 즉, A 컴포넌트의 변경으로 부터 B 컴포넌트를 보호하고 싶다면, A 컴포넌트가 B 컴포넌트에 의존해야 한다.

  위 다이어그램에선 Interactor가 OCP를 가장 잘 준수하는 위치에 존재한다. 어떠한 변경도 Interactor에 영향을 주지 않는다. Interactor가 이런 특별한 위치에 존재하는 이유는 비즈니스 로직을 포함하고 있기 때무니다. Interfactor가 애플리케이션에서 가장 높은 수준의 정책을 포함하고 다른 컴포넌트들은 주변적인 문제를 처리한다.

  보호의 계층구조가 수준(level)이라는 개념을 바탕으로 어떻게 생성되는지 주목하자. Interactor는 가장 높은 수준의 개념이며, 최고의 보호를 받는다. View는 가장 낮은 수준의 개념이며, 거의 보호를 받지 못한다.

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

방향성 제어

  방향성 제어를 위해 인터페이스를 사용할 수 있다. 이를 통해 의존성이 제대로된 방향으로 향하게 한다. 예를 들어 FinancialDataGateway 인터페이스는 FinancialReportGenerator와 FinancialDataMapper 사이에 위치한다. 이를 통해 의존성을 역전시켰다. 만약 FinancialDataGateway 인터페이스가 존재하지 않았다면 의존성이 Interfactor 컴포넌트에서 Database 컴포넌트로 바로 향하게 된다.

정보 은닉

  FinancialReportRequestor 인터페이스는 방향성 제어가 아닌 정보 은닉을 위해 사용된다. 이를 통해 FinancialReportController가 Interactor 내부에 대해 너무 많이 알지 못하게 한다. 해당 인터페이스가 없었다면 Controller는 FinalcialEntities에 대한 추이 종속성(transitive dependency)을 가지게 된다. 따라서 '자신이 직접 사용하지 않는 요소에는 절대로 의존해선 안된다'는 소프트웨어 원칙을 위반하게 된다.

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

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

  리스코프 치환 원칙의 개념은 다음과 같다.

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

  리스코프치환 원칙을 이해하기 위해 예시를 보자.

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

 

    위 예제에서는 Billing의 행위가 License 하위 행위에 의존하지 않기 때문에 하위 타입은 모두 License 타입을 치환할 수 있다.

정사각형/직사각형 문제

   그에 반해 정사각형/직사각형 문제는 리스코프치환원칙을 위배한다. Rectangle은 높이와 너비가 서로 독립적인 반면, Square는 서로 독립적이지 않다. 또 한, User는 대화하고 있는 상대가 Rectangle이기에 혼동이 생길 수 있다. 물론 Rectangle과 Square를 구분하는 로직을 User에 추가할 수 있다. 하지만 이는 User의 행위가 사용하는 타입에 의존하기 떄문에 타입을 서로 치환할 수 없다.

LSP와 아키텍처

  객체지향 등장 초기, LSP는 상속을 사용하도록 가이드하는 방법 정도로 간주되었다. 시간이 지나면서 LSP는 인터페이스와 구현체에도 적용되는 더 광범위한 소프트웨어 설계 원칙으로 변모해 왔다.

  아키텍처 관점에서 LSP를 이해하기 위해 이 원칙을 어긴 시스템 아키텍처를 살펴보자.

LSP 위배 사례

  다양한 택시 파견 서비스를 통합하는 애플리케이션이 있다고 하자. 해당 서비스는 파견에 필요한 정보를 다음과 같이 호출한다.

1
2
3
4
purplecab.com/driver/Bob
    /pickupAddress/24-Maple-st
    /pickupTime/153
    /destination/ORD
cs

  그 후, 만약 영향력 있는 다른 택시 업체에서 서비스 사양서를 신중히 읽지 않고 destination을 dest로 축약해 호출한다 치자. 그러면 나머지 업체와 해당 택시 업체를 구분하는 로직을 추가해야만 한다.

1
if (driver.getDispatchUri().startWith("acme.com")) ...
cs

  이런 일이 반복된다면 어떨까? 더 많은 조건문을 추가해야 할까? "acme"라는 단어를 코드에 추가함으로써 이해할 수 없는 온작 종류의 에러가 발생할 여지를 만들게 되었다.

  이같은 문제에서 시스템을 격리해야 한다. 이때 파견 URI를 key로 사용하는 설정용 데이터베이스(configuration database)를 이용하는 파견 명령 생성 모듈을 만들어야 할 수도 있다.

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

  위와 같은 구조가 있다 해보자. 이 때, User1은 oeprate1 메서드만, User2는 operate2 메서드만 사용한다. 그럼에도 User1과 User2는 서로가 사용하지 않는 오퍼레이션을 알고 있다. 이 때, operate2 메서드의 로직이 변경된다면, User1 도 다시 컴파일한 후 새로 배포해야 한다. 이런 문제는 오퍼레이션을 인터페이스 단위로 분리해 해결할 수 있다.



ISP와 언어

  위 사례는 언어 타입

에 의존한다. 정적 타입 언언느 사용자가 import 같은 타입 선언문을 사용하게 강제한다. 이로 인해 소스코드 의존성이 발생하고, 재컴파일 또는 재배포가 강제된다.

  반면, 동적타입 언어는 런타임에 추론이 발생한다. 따라서 소스코드 의존성이 없기에 재컴파일, 재배포가 필요없다. 동적 타입 언어를 사용하면 정적 타입 언어를 사용할 때보다 유연하며 결합도가 낮은 시스템을 만들 수 있다.

  따라서 ISP를 아키텍처가 아닌 언어와 관련된 문제라 결론내릴 여지가 있다.

ISP와 아키텍처

  모듈 뿐만 아니라 더 고수준인 아키텍처에서도 위 문제와 같은 상황이 발생할 수 있다. 예를 들어 시스템, 프레임워크, 데이터베이스가 다음과 같은 의존 관계를 갖는다 해보자.

  이 때, F에서는 불필요한 기능이 D에 포함된다 해보자, 그 기능 때문에 D가 변경된다면 F를, 더 나아가 S까지 재배포 해야 할지도 모른다. 또 한, D의 불필요한 기능에 문제가 발생했다면 System과 Framework에 까지 영향을 준다. 

11장. DIP: 의존성 역전 원칙

  DIP에서 말하는 '유연성이 극대화된 시스템'은 소스 코드 의존성이 추상(abstraction)에 의존하며 구체(concretion)에는 의존하지 않는 시스템이다.

  정적 타입 언어에서는 import 같은 구문은 오직 인터페이스나 추상 클래스 같은 추상적인 선언만을 참조해야 한다는 의미다.

  동적 타입 언어에서는 소스코드 의존 관계에서 구체 모듈은 참조하면 안된다. 하지만, 동적 타입 언어에서는 구체 모듈이 무엇인지를 정의하기 어렵다.

  실제 소프트웨어 시스템은 구체적인 많은 장치에 의존한다. 따라서 DIP를 규칙으로 보기는 어렵다. 예를 들어 자바의 String은 구체 클래스다. 하지만 String은 매우 안정적이라 변경에 대한 염려가 거의 없다. 있더라도 엄격히 통제된다.

  이런 이유로 안전성이 보자오딘 환경에서는 DIP를 무시한다. 변동성이 큰 구체적인 요소에 대해서만 DIP를 적용한다. 

안정된 추상화

  인터페이스는 구현체 보다 변동성이 낮다. 인터페이스를 변경하지 않고도 구현체에 기능을 추가 할 수 있는 방법을 찾는 것이 소프트웨어 설계의 기본이다.

  안정화된 소프트웨어 아키텍처는 변동성이 큰 구현체에 의존하는 일은 지양하고, 안정된 추상 인터페이스를 선호하는 아키텍처다. 이 원칙이 전달하고자 하는 내용은 다음과 같다.

- 변동성이 큰 구체 클래스를 참조하지 말라.

  대신 추상 인터페이스를 참조하라.

- 변동성이 큰 구체 클래스로부터 파생하지 말라.

  정적 타입 언어에서 상속은 의존관계를 가장 강하게 결합시킨다. 따라서 신중히 사용해야 한다.

- 구체 함수를 오버라이드 하지 말라.

  구체 함수는 소스코드 의존성을 필요로 한다. 구체 함수를 오버라이드 하면 의존성을 제거할 수 없게 되고, 의존성을 상속하게 된다. 이런 의존성을 제거하기 위해선 차라리 추상 함수로 선언하고 구현체들에서 각자의 용도에 맞게 구현해야 한다.

- 구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라.

팩토리

  위 규칙들을 준수하기 위해선 변동성이 큰 구체적인 객체 생성에 주의해야 한다. 거의 모든 언어에서 객체를 생성하기 위해 해당 객체를 구쳊거으로 저의한 코드에 대해 의존성이 발생한다.

  객체지향 언어에서 이런 의존성을 없애기 위해 추상 팩토리를 사용한다.

  위 다이어그램에서 Application은 Service 인터페이스를 통해 ConcreteImpl를 사용하기 때문에 Application이 ConcreteImpl 인스턴스를 생성해야 한다. ConcreteImpl에 대한 의존성을 만들지 않고 이 목적을 달성하기 위해서 Application은 ServiceFactory의 makeSvc 메서드를 호출한다. 그러면 ServiceFactoryImpl 구현체가 ConcreteImpl 인스턴스를 생성하고 Service 타입으로 반환한다.

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

  추상 컴포넌트는 애플리케이션의 모든 고수준 업무 규칙을 포함한다. 구체 컴포넌트는 업무 규칙을 다루기 위해 필요한 모든 세부사항을 포함한다.

  제어흐름은 소스코드 의존성과는 정반대 방향으로 곡선을 가로지른다. 즉, 소스코드 의존성은 제어흐름과는 반대 방향으로 역전된다. 이 원칙이 의존성 역전(Dependency Inversion)이다.

구체 컴포넌트

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

 

출처 - 클린 아키텍처