본문 바로가기
Engineering/SW Architecture

클린 아키텍쳐 (1) - 오버뷰, 패러다임, 설계원칙 SOLID

by 쿨쥰 2023. 3. 12.

로버트 마틴 형님의 클린 아키텍쳐 정리. 

경험이 부족했던 주니어 시절 완독 했지만, 다시 손에 잡아 읽어 보니 더 보이는게 많아 놓치고 싶지 않은 마음에 기록으로 남겨두는 컴퓨터 쟁이들의 명도서.


< 오버뷰 >

설계와 아키텍쳐란?

  • 아키텍쳐(고수준)에서 설계(저수준)으로 향하는 의사결정의 연속성 만이 있을 뿐. 두 개념은 경계도 없고 차이도 없다.
  • 소프트 아키텍쳐 품질을 심각하게 고민하자. 빨리 가는 유일한 방법은 제대로 가는 것이다.
  • 코드 정리는 나중에 하면 돼. 일단 시장 출시가 먼저야! 라는 마인드는 현실에서 제대로 동작할 수 없다.
  • 비용을 최소화 하고 생산성을 최대화 할 수 있는 설계와 아키텍쳐를 가진 시스템을 만들자.

두 가지 가치

  • 모든 sw시스템은 두가지 가치를 제공하며, sw개발자는 두가지 가치를 반드시 높게 유지해야 한다.
    • 행위 (behavior)- 비즈요건을 반영하기 위해 코드를 작성하고, 디버깅을 하여 machine 이 적절한 행위를 하게 만드는 것.
      • 즉, 시스템이 동작 하게 만든다는 관점. 긴급하지만 중요하지 않은….
    • 구조 (structure)- sw는 부드러움을 지니도록 만들어졌다(software). 즉, 요건의 변화에 따른 machine의 행위 변화를 쉽게 담아 낼 수 있도록 변경하기 쉬운 아키텍쳐를 의미. 변경 하더라도 범위에 어려움이 비례해야하며, 형태와 무관해야 함.
      • 즉, 시스템이 변화에 유연하게 만든다는 관점. 긴급하지 않지만 중요한…
  • 올바른 SW개발팀은 뻔뻔함을 무릅쓰고 시스템의 아키텍쳐를 위해 다른 이해관계자들과 동등하게 논쟁해야 한다. 이게 책무다.
  • SA는 시스템이 제공하는 특성이나 기능 보다는 시스템의 구조에 더 중점을 둔다. 옳은 가치를 위해 투쟁하자.

 

 

< 프로그래밍 패러다임 >

구조적 프로그래밍

초기 프로그래밍 세계관에서 이미 모든 논리적 흐름은 순차/분기/반복 3가지 구조만으로 표현 할 수 있다는 사실이 증명 되었다.

기능을 계층적인 구조로 break down 해나가다보니 모듈과 그 모듈을 세부 기능적으로 분해 할 수 있다는 사실 역시 도출 되었다.

증명이란? 수학은 증명 가능한 서술이 참임을 입증, 과학은 증명가능한 서술이 거짓임을 입증 하는 과정이다.

테스트는 버그가 있다는 것을 보여줄 뿐, 버그가 없음을 보여줄 순 없다. 즉, 프로그램의 잘못됨을 증명하는 것은 가능 하지만, 프로그램의 올바름을 증명하는 것은 불가능 하다.

구조적 프로그래밍은 프로그램을 증명가능한 세부 기능집합으로 재귀적으로 분해할 것을 강요하고, 테스트를 통해 증명가능한 세부 기능들이 거짓인지를 입증하려고 시도한다. 따라서 프로그래밍은 과학이다.

결론적으로 SA는 모듈, 컴포넌트, 서비스가 쉽게 반증(테스트) 가능하도록 기능적 분해를 최고의 실천법으로 여겨야한다.

 

객체지향 프로그래밍

캡슐화 : 사실 c → c++ → c# 으로 발전해오면서 더욱 완벽한 캡슐화에서 멀어졌다. OOP는 따라서 언어적으로는 캡슐화를 강제하지 않으므로 프로그래머가 충분히 올바르게 행동함으로써 캡슐화된 데이터를 우회해서 사용하지 않을 것이라는 믿음으로 동작한다.

상속 : OOP 에서의 상속은 데이터 구조에 가면의 씌워 쓰는일을 굉장히 편하게 제공한 다는 점에서는 의미를 부여 할 수 있다. 단, 상속 역시 c에서도 공공연히 사용되던 방식이므로 (include를 통해..), 상속이라는 개념 자체가 OOP를 설명하는 주요 기준이 될 수는 없다.

다형성 : 엄밀히 말하면 c의 포인터가 곧 OOP의 다형성의 근원이 되는 개념이다. 단, OOP의 등장으로 포인터를 구구절절히 사용하지 않더라도 매우 손쉽게 강력한 생산성을 확보할 수 있게 되었다. 즉, 언제 어디서든 플러그인 아키텍쳐를 쓸 수 있게 된 것이다.

의존성 역전 : 전형적인 호출트리에서는 소스코드 의존성의 방향은 반드시 고수준에서 저수준 모듈로의 제어흐름을 따르지만, 여기에 인터페이스(런타임에 올라가지 않은) 가 끼게 된다면 의존성 역전이 일어난다. 이는 프로그래머의 설계에 따라 원하는 방향으로 의존성을 세팅 할 수 있다는 것을 의미한다. 이는 곧 업무규칙 UI나 DB에 의존하게 만들어, 소스코드는 업무규칙에 유연하게 만드는것을 가능하게 하고, 이는 곧 배포 독립성, 개발 독립성으로 이어지게 되는 주요한 key 가 된다.

제어흐름과 반대인 소스 코드 의존성 역전

 

결론적으로 OOP란?

즉, SA관점에서보면 OOP란 다형성을 이용하여 전체 시스템의 소스코드 의존성에 대한 절대적인 제어 권한을 획득 할수 있는 개발 패러다임을 의미한다. 플러그인 아키텍쳐를 구성할 수 있고, 고수준의 정책을 포함하는 모듈은 저수준의 세부사항을 포함하는 모듈에 대해 독립성을 보장 할 수 있다.

 

함수형 프로그래밍

함수형 프로그래밍은 변수 할당에 부과되는 규율에서 출발한다.

java 는 가변변수 기반으로 동작한다. 사실 변수의 가변성은 경합, 교착상태, 동시업데이트 문제를 유발하므로 동시성 제어에도 문제를 가져올 수 있다.

어플리케이션을 제대로 구조화하려면 변수를 변경하는 가변 컴포넌트와, 변경하지 않는 불변 컴포넌트를 엄밀히 분리해야하고 가능한 많은 처리를 불변 컴포넌트로 옮겨야 한다.

저장공간을 무한에 가깝게 사용할 수 있는 시대에서 우리는 상태를 저장하기보다는 트랜잭션 자체를 저장해도 된다. 즉, 어플리케이션은 CRUD가 아니라 CR만 수행하게 한다면, 종국에는 어플리케이션을 완전한 불변성을 갖도록 만들 수 잇다. *이벤트 소싱.

 

결론적으로, 프로그래밍의 3가지 패러다임을 참조 해보았을때, 도구는 달라졌고, 하드웨어도 변했지만 SW핵심은 여전히 그대로다.

컴퓨터 프로그램은 순차/분기/반복/참조로 만 구성되고 그 이상도 이하도 아니다.

 

 

 

< 설계 원칙 - SOLID >

좋은 벽돌은 곧 클린코드다. 이 좋은 벽돌로 좋은 구조를 만들어 내는 원칙이 그 유명한 SOLID 다.

SOLID는 함수와 데이터 구조를 클래스로 배치하는 방법과 이들 클래스를 서로 결합하는 방법을 설명해준다.

키워드는 다음과 같다.

  • 변경에 유연하다
  • 이해하기 쉽다
  • 많은 sw시스템에서 사용될 수 있는 컴포넌트의 기반이 된다.

SRP 단일 책임 원칙 (Sing Responsibility Principle)

콘웨이 법칙 : sw 시스템이 가질 수 있는 최적의 구조는 시스템을 만드는 조직의 사회적 구조에 큰 영향을 받는다.

따라서 각 소프트웨어 모듈은 오직 하나의 액터에 대해서면 책임 져야 한다.

모듈은 협의의 의미로는 소스 파일을 의미하지만, 사실 함수와 데이터 구조로 구성된 응집된 집합이다. 바로 이 단일 액터를 책임지는 코드를 함께 묶어주는 힘이 바로 응집성 이다.

이를 위반하는 징후들은 다음과 같다.

징후1 : 우발적 중복

SRP 를 제대로 위반

하나의 클래스 안에 들어있는 세가지 각각의 메서드는 매우 목적이 다른 세 종류의 액터를 책임지기 위해 SRP 를 위반한채로 묶여있다.

이 결합으로 인하여 CFO 팀에서 결정한 조치가 COO팀에서 의존하는 무언가에 영향을 줄 수 있다.

예를들어, calculatePay() 와 reportHours() 가 초과근무를 제외한 일반 업무시간을 계산하는 알고리즘 regularHours()를 공유한다면, 두 메서드가 하나의 알고리즘을 사용하므로 재앙이 발생할 수 있다. 이 문제는 서로 다른 액터가 의존하는 코드를 너무 가까이 배치했기 때문에 나타난다. 따라서 SRP는 서로다른 액터가 의존하는 코드를 서로 분리하라고 말한다.

징후2 : 병합

소스 코드가 다양하고 많은 메서드를 포함하다보면 운영 중에 각자의 팀에 속한 개발자들의 개발코드들을 하나의 형상으로 병합하는 일이 비일비재로 발생한다. 위 예시에서 역시 CFO 팀의 DBA가 스키마를 변경하고, COO 팀의 개발자는 다른 요구사항을 구현하기위해 포맷을 일부 변경했다면, 클랫 파일 내 소스코드에서 병합이 발생하게 되고 이는 곧 risk 를 유발한다.

이 문제를 벗어나는 방법은 하나다. 다른 액터를 뒷바딤하는 코드를 서로 분리하는 것이다.

해결책

모두가 메서드를 가기 다른 클래스로 이동시켜야한다. 가장 빠른 방법은 데이터와 메서드를 분리하는 방식이고, 아무런 메서드가 없는 data class 로서 EmployeeData 를 선언하여, 세개의 클래스가 이를 공유하도록 만드는 방법이다.

데이터 영역과 메서드영역을 분리하여 SRP 위반을 해결

단, 이는 개발자가 세가지 클래스를 인스턴스화 하고 추적해야하는 단점이 있으므로, 이를 해결하기 위해 파사드 패턴을 쓰기도 한다.

파사드 패턴으로 SRP 위반을 해결

Facade에 코드는 거의 없지만, 이 클래스는 세개의 클래스의 객체를 생성하고 요청된 메서드를 가지는 객체로 위임하는 일을 책임진다. 또는 가장 중요한 메서드는 기존 Employee 클래스에 employeData 와 함께 유지하되, 덜 중요한 reportHours(), saveEmployee() 같은 메서드는 별도 클래스로 분리하여 부분적 파사드 역할을 맡겨도 된다.

결론

SRP는 메서드와 클래스 수준의 원칙이지만, 이보다 상위 수준에서도 이 아이디어는 적용된다.

이 원칙이 컴포넌트 수준에서 적용되면 공통 폐쇄 원칙 (common closure principle)이 되며, 아키텍쳐 수준에서는 아키텍쳐 경계의 생성을 책임지는 변경의 축이 될 수 도 있다.

 

OCP 개방-폐쇄 원칙 (Open-Closed Principle)

시스템의 행위를 변경하기 위해서는 기존 코드를 수정하기 보다는 반드시 새로운 코드를 추가하는 방식으로 구현해야한다.

즉, sw개체는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다. 다시 말해 행위는 확장할 수 있어야 하지만 이대 개체를 변경해서는 안된다는 의미이다.

일단 OCP 원칙의 궁극적 목표는 변경되는 코드의 양이 가능한한 최소화 시키는 것으로, 이를 달성 하기 위해서는 우선 서로 다른 목적으로 변경되는 요소를 적절하게 분리 (SRP)시키고, 이들 요소 사이의 의존성을 체계화 (DIP)함이 필요하다.

예를 들어 재무재표를 웹 페이지로 보여주는 시스템을 생각해보면, 두개의 책임으로 분리 될 수 있다.

  • 보고서용 데이터를 계산 하는 책임
  • 이 데이터를 웹으로 보여 주거나 종이로 프린트 하기위해 적합한 형태로 표현하는 책임

이렇게 책임을 분리 했다면, 두 책임 중 하나에서 변경이 발생하더라도 다른 하나는 변경되지 않도록 소스 코드 의존성도 확실히 조직화 해야한다.

마지막으로, 새로 조직화한 구조에서는 행위가 확장될 때 변경이 발생하지 않음을 보장해야 한다.

이를 종합하면, 처리과정이 클래스 단위로 분할되고 이는 곧 아래와 같은 컴포넌트 단위로 구분 된 그림이 나오게 된다.

구조화된 아키텍쳐

  • <I> : 인터페이스 /// <DS> : 데이터 구조 /// 열린 화살표 : 사용 관계 /// 닫힌 화살표 : 구현체 or 상속
  • 화살표가 A클래스에서 B클래스로 향한다면, A에서는 B를 호출하지만, B는 A를 전혀 호출 못함을 의미 한다.
  • 컴포넌트 경계를 뜻하는 이중선은 화살표와 오직 한 방향 으로만 교차한다. 즉, 컴포넌트 관계 또한 단방향이다.

마지막 줄에 특히 의미가 있다. A컴포넌트에서 발생한 변경으로부터 B컴포넌트를 보호하려면 반드시 A 가 B에 의존해야한다. presenters 에서 발생한 변경으로 부터 controller를 보호하고, view 에서 발생한 변경으로부터 presenter를 보호, interactor는 다른 모든 것에서 부터 발생한 변경 으로부터 보호하는 것이다.

따라서 Interactor는 OCP를 가장 잘 준수 할 수 있는 곳에 위치하며, 그 어떤 컴포넌트에서 발생한 변경도 interactor에 영향을 못준다.

  • 이 Interactor는 바로 업무 규칙을 포함하며, app 에서 가장 높은 수준의 정책을 포함한다. 따라서 가장 최고의 보호를 받는다.
  • view는 가장 낮은 수준의 개념으로 거의 보호를 못 받는다. 즉, 자주 변경이 발생할 수있다.
  • presenter는 view보다는 높고 controller나 interactor 보다는 낮은 수준에 위치한다.

이것이 바로 아키텍쳐 수준에서 OCP가 동작하는 방식이고, SA는 기능은 어떻게, 왜, 언제 발생 하는지에 따라서 기능을 분리하고, 분리한 기능을 컴포넌트 계층구조로 조직화 해야한다.

그 외에도 OCP를 활용하여 컴포넌트 계층화를 진행하면서 적절히 interface를 배치하여 interactor가 바로 database 컴포넌트에 붙지않도록 의존성을 역전시켜주는 방향성 제어 (FinalcialDataGateway),

controller가 interactor 내부에 대해 너무 많이 알지 못하도록 막기 위한 정보은닉 (FinancialReportRequester) 이 고려 될 수 있다.

 

LSP 리스코프 치환 원칙 (Liskov Substitution Principle)

하위 타입에 관한 원칙으로 상호 대체 가능한 구성요소를 이용해 sw 시스템을 만들 수 있으려면, 이들 구성요소는 반드시 서로 치환 가능해야 한다.

OOP 초기에는 LSP는 단순히 ‘상속’을 사용하도록 가이드 하는 방법 정도로 간주되었지만, 시간이 지나면서 인터페이스와 구현체에도 적용되는 더 광범위한 sw 설계원칙으로 변모해 왔다. 즉, 이는 인터페에스 1개와 N개의 클래스, REST API 1개와 N개의 응답 서비스집단 등으로 확장해서 생각해 볼 수 있다.

올바른 예시

Billing 어플리케이션에서 calcFee() 를 호출하고, License는 PersonalLicense 와 BusinessLicense 라는 두가지 하위 타입을 가지고 있으며, 이들 두 하위 타입은 서로 다른 알고리즘으로 라이선스 비용을 계산 한다고 치자.

이 설계는 Billing 어플리케이션의 행위가 License 하위 타입중 무엇을 사용하는지에 대해 전혀 의존하지 않는다. 이 예시가 바로 하위 타입들이 모두 License 타입을 치환 할 수 있는 케이스이다.

위반 사례

위 예제에서 Squere 는 Rectangle 의 하위타입으로는 적합하지 않다. Rectangle의 높이와 너비는 서로 독립적으로 변경될 수 있는 반면 Square 의 높이와 너비는 반드시 함께 변경되기 때문이다.

따라서 User는 Rectangle 과 대화할때 에러를 내지 않으려면, if문으로 Rectangle이 실제로는 Square인지를 검사하는 메커니즘을 User에 추가하는 방법이 있지만, 이는 User의 행위가 사용하는 타입에 의존하게 되므로 LSP 를 지킬 수 없게 된다.

택시 플랫폼 예시에서도 중계 플랫폼의 app caller 는 1개인데 N 개의 택시 사업자를 연계 한다고 쳤을때, N개의 택시 사업자 서비스를 하위 타입으로 치환할 수 있는 구조로 처리해야한다. 택시 사업자 별로 스펙 대로 개발하지 않는 케이스가 있을 수 있다고 하더라도, db에서 패턴화를 해두는 식으로 들쭉날쭉 할 수 있는 정보들을 모두 분리해버리고, 공통 요소만 코드에 남겨 LSP를 달성해야 한다.

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

 

ISP 인터페이스 분리 원칙 (Interface Segregation Principle)

SW 설계자는 사용하지 않은것에 의존하지 않아야 한다.

다수의 사용자가 OPS의 클래스의 오퍼레이션 op1, op2, op3 를 각각 사용하는 아래의 예제를 보면, User1 의 코드는 자신이 전혀 사용하지도 않는 op2, op3 메서드에 의존하게 되고, 이러한 의존성으로 인해 OPS 클래스 내 op2 가 변경 되더라도 User1 역시 컴파일 후 새로 배포 해야만 한다.

사용자 클래스가 메서드에 의존성 발생!!

하지만 이를 아래와 같이 오퍼레이션을 인터페이스 단위로 분리해서 배치하면 해결 할 수 있는데, 이것이 ISP 의 기본 사상이다.

사용성 기준의 메서드 별로 인터페이스를 달아서 분리

일반적으로 필요 이상으로 많은 것을 포함하는 모듈에 의존하는 것은 해로운 일이다. 소스코드 의존성의 경우 불필요한 재컴파일과 재배포를 강제하며, 코드 레벨보다 고수준인 아키텍쳐 레벨에서도 동일한 결합도가 발생한다.

 

DIP 의존성 역전 원칙 (Dependency Inversion Principle)

고수준 정책을 구현하는 코드는 저수준 세부사항을 구현하는 코드에 절대 의존해서는 안되고 세부사항이 정책에 의존해야한다. DIP에서 말하는 유연성이 극대화된 시스템이란 소스코드 의존성이 추상(abstract)에 의존하며 구체(concretion)에는 의존하지 않는 시스템을 뜻한다.

이를 쉽게 말하면, using, import, include 같은 구문은 오직 인터페이스나 추상클래스 같은 추상선언만을 참조해야 하며 구체적인 imple 을 참조하면 안된다는 이야기다. 왜냐하면 구체 클래스는 매우 변동성이 큰 요소이기 때문이다.

SA라면 인터페이스의 변동성을 낮추기 위해 애쓰고, 인터페이스를 변경하지 않고도 구현체에 기능을 추가할 수 있는 구조를 쌓기 위해 노력해야한다. 이는 아래의 3가지 코딩 실천법으로 요약된다.

  • 변동성이 큰 구체 클래스를 참조하지 말라.
  • 변동성이 큰 구체 클래스로 부터 파생하지 말라.
  • 구체 함수를 오버라이드 하지 말라.

추상 팩토리 패턴

하지만 실상에서는 사실 거의 대부분의 언어에서 객체를 생성하려면, 해당 객체를 구체적으로 정의한 코드에 대해 소스 코드의존성이 발생할 수 밖에 없다. 이러한 불가피한 의존성을 처리할 때 추상 팩토리 패턴을 사용하곤 한다.

아키텍쳐 경계 / 의존성 역전 적용

곡선은 아키텍쳐 경계를 뜻하며, 윗부분의 고수준 영역(추상적인 것)과 아랫부분의 저수준 영역(구체적인 것)을 분리한다.

전체적인 소스코드 의존성은 해당 곡선과 교차 할 때 모두 한 방향, 즉 추상적인 쪽으로 향한다.

하지만, 실제 컴퓨팅의 제어 흐름은 위 ‘의존성 역전’ 정리에서와 같이, 본 아키텍쳐에서의 소스코드 의존성과는 정 반대의 방향으로 곡선을 가로지른다.

종합하면, 소스코드 의존성은 제어흐름과는 반대 방향으로 역전되어 효율적인 구체 컴포넌트와 추상 컴포넌트를 분리해준다. 이 규칙을 의존성 규칙 이라고 한다.

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

댓글