이전 글 : 아키텍처 [소리치는 아키텍처, 클린 아키텍처, 프레젠터와 험블 객체]
2023.03.22 - [Engineering/SW Architecture] - 클린 아키텍쳐 (4) - 아키텍처 [소리치는 아키텍처, 클린 아키텍처, 프레젠터와 험블 객체]
< 부분적 경계 >
아키텍처 경계를 완벽하게 만드는 데는 비용이 많이든다.
쌍방향의 다형적 boundary 인터페이스와 input/output을 위한 별도 데이터 구조를 만들어야 할 뿐만 아니라, 두 영역을 독립적으로 컴파일 하고 배포할 수 있는 컴포넌트로 격리하는데 필요한 모든 의존성을 관리해야한다.
SA는 이러한 경계를 만드는데 드는 비용과, 향후에 필요할 수 있는 경계를 미리 만들어 두자는 생각의 ROI 를 따져보며 항상 고민한다.
애자일 사상가들의 YAGNI (You are not going to need it) 원칙에 따르면 사용되지 않는 경계는 만들어 두지 않음이 맞으나, 미래를 바라보는 아키텍트라면 “그래 어쩌면 이 경계는 필요할 지도 몰라” 라는 생각이 들 수 도 있다.
이런 상황에서 경계를 긋긴 긋되 비용을 간소화 시킨 부분적 경계(partial boundary) 를 고려 해볼 수 있다.
첫번째 방법 : 마지막 단계를 뛰어넘기
독립적으로 컴파일하고 배포할 수 있는 컴포넌트를 위한 작업은 모두 수행한 후, 단일 컴포넌트에 그대로 모아 두는 것이다.
즉, 쌍방향 인터페이스도 해당 컴포넌트 안에 있고, 입/출력 데이터 구조도 그곳에 있으며, 모든 것이 완전히 준비되어 있다.
하지만 이 모두를 단일 컴포넌트로 컴파일 해서 배포한다.
이는, 완벽한 경계를 만들때와 별반 다를바 없는 코드량과 사전설계가 필요하지만, 다수의 컴포넌트를 관리하는 작업은 하지 않아도 된다.
배포 관리 부담도 없다.
두번째 방법 : 일차원 경계
완벽한 형태의 아키텍처 경계는 양방향으로 격리된 상태를 유지해야 하므로 쌍방향 boundary 인터페이스를 사용한다.
따라서 양방향으로 격리된 상태를 유지하려면 초기 설정할 때나 지속적으로 유지할 때도 비용이 더 많이 든다.
전략 패턴을 사용하면, 추후 완벽한 형태의 경계로 확장할 수 있는 공간을 미리 확보해 둘 수 있다.
ServiceBoundary 인터페이스는 클라이언트가 사용하며 ServiceImpl 클래스가 구현한다.
이는 Client를 ServiceImpl 로 부터 격리시키는데 필요한 의존성 역전이 이미 적용되었기 때문에, 미래에 필요할 아키텍처 경계를 위한 무대를 미리 마련한다는 점에서 의미가 있다.
반면에, 이러한 분리는 매우 빠르게 붕괴될 수도 있다. 쌍방향 인터페이스가 없고 개발자와 아키텍트가 근면 성실하고 제대로 훈련되어 있지 않다면, 이 점선과 같은 비밀 통로가 생기는 일은 막을 방법이 없기 때문이다.
세번째 방법 : 퍼사드
퍼사드 패턴을 활용하면 더 단순한 경계를 그을 수 있다.
이 경우에는 심지어 의존성 역전까지도 희생하여(Facade 가 service를 직접 호출하므로), 경계는 Facade 클래스로만 간단히 정의 된다. Facade 클래스에서는 모든 서비스 클래스를 메서드 형태로 정의하고, 서비스 호출이 발생하면 해당 서비스를 클래스로 호출을 전달한다. 클라이언트는 물론 이들 서비스 클래스에 직접 접근할 수 없다.
하지만 client는 이 모든 서비스 클래스에 대해 추이 종속성을 가지게 된다. 정적 언어였다면 서비스 클래스 중 하나에서 소스코드가 변경되면 client도 무조건 재컴파일 해야 할 것이며, 비밀통로 또한 정말 쉽게 만들 수 있게 된다.
아키텍처 경계를 부분적으로 구현하는 방법은 이 세가지 이외에도 많다.
이러한 접근법 각각은 나름의 비용과 장점을 지닌다.
각 접근법은 완벽한 형태의 경계를 담기 위한 공간으로써, 적절하게 사용할 수 있는 상황이 서로 다르며, 각 접근 법은 해당 경계가 실제로 구체화 되지 않으면 가치가 떨어질 수 있다.
아키텍처 경계가 언제, 어디에 존재해야 할지, 그리고 그 경계를 완벽하게 구현할지 아니면 부분적으로 구현할지를 결정하는 일 또한 아키텍트의 역할이다.
< 계층과 경계 >
흔히들 시스템은 세가지 컴포넌트 (UI, 업무 규칙, DB) 로만 구성된다고 생각하기 쉽다.
물론 몇몇 단순한 시스템에서는 이 정도로도 충분하지만 대다수의 시스템에서의 컴포넌트 개수는 이보다 훨씬 많다.
옴파스 사냥 게임
간단한 올드게임을 예로 들어보자.
텍스트를 기반으로 하는 이 게임은 GO EAST, SHOOT WEST 와 같은 매우 단순한 명령어를 사용한다. 플레이어는 명령어를 입력하고, 컴퓨터는 플레이어가 보고/냄새 맡고/듣고/경험 할 것들로 응답한다. 이를 텍스트 기반 UI는 그대로 유지하되, 게임 규칙과 UI를 분리해서 우리 이 제품을 여러 시장에서 다양한 언어로 발매할 수 있게 만든다고 가정해보자.
게임 규칙은 언어 독립적인 API를 사용해서 UI 컴포넌트와 통신할 것이고, UI는 API를 사람이 이해할 수 있는 언어로 변환할 것이다.
또, 게임의 상태를 영속적인 다양한 저장소에 저장한다고 했을때, 메모리, 클라우드, RAM 등 다양하게 저장할 수 있게 한다고 해보자.
어떤 경우라도 게임 규칙이 이러한 저장장치에 대한 세부사항을 알지 않기를 바라므로, 이번에도 역시 API를 생성하여 게임 규칙이 데이터 저장소 컴포넌트와 통신할 때 사용하도록 만든다.
언어 모듈이건, 저장소 모듈이건 우리는 의존성 규칙을 준수할 수 있도록 의존성이 적절한 방향을 가리키게 만들어야 한다.
클린 아키텍처?
위 예제의 맥락이라면 클린 아키텍처 접근법을 적용해서 유스케이스, 경계, 엔티티, 관련된 데이터 구조를 모두 만드는 일도 쉬운이다.
그런데 중요한 아키텍처 경계를 정말 모두 발견한 것일까?
예를 들어 UI에서 언어는 유일한 변경의 축은 아니다 또한 텍스트를 주고받는 메커니즘을 다양하게 만들고 싶을 수도 있다. (ex, shell, 텍스트메시지, 채팅어플리케이션 등)
따라서 이 변경의 축에 의해 정의되는 아키텍처 경계가 잠재되어 있을 수도 있다. 아마도 해당 경계를 가로지르는, 그래서 언어를 통신 메커니즘으로부터 격리하는 API를 생성해야 할 수도 있다.
점선 테두리는 API를 정의하는 추상 컴포넌트를 의미하며, 해당 API는 추상 컴포넌트 위나 아래의 컴포넌트가 구현한다.
- Language API는 English와 Spanish 가 구현한다.
- GameRules는 GameRules 가 스스로 정의하고, Language가 구현하는 API를 이용해 Language 와 통신한다.
- Language는 Language가 스스로 정의하고 Text Delivery 가 구현하는 API를 이용해 TextDelivery와 통신한다.
- API는 구현하는 쪽이 아닌 사용하는 쪽에 정의되고 소속된다.
GameRules 를 살펴보면, GameRules 내부 코드에서 사용하고 Language 내부 코드에서 구현하는 다형적 Boundary 인터페이스를 발견할 수 있다. 또한 Language에서 사용하고 GameRules 내부 코드에서 구현하는 Boundary 인터페이스도 발견할 수 있다.
Language 에서도 동일한 구조를 발견할 수 있다.
TextDelivery 내부의 코드에서 구현하는 다형적 boundary 인터페이스와, TextDelivery에서 사용하고 Language가 구현하는 다형적 Boundary 인터페이스를 발견할 수 있다.
모든 경우에 있어 해당 Boundary 인터페이스가 정의하는 API는 의존성 흐름의 상위에 위치한 컴포넌트에 속한다. English, SMS, CloudData 같은 변형들은 추상 API 컴포넌트가 정의하는 다형적 인터페이스를 통해 제공되고, 실제로 서비스하는 구체 컴포넌트가 해당 인터페이스를 구현한다. (Language 가 정의하는 다형적 인터페이스를 English와 Spanish 가 구현하는..)
이러한 변형들을 모두 제거하고 순전히 API 컴포넌트로만 집중해서 다이어그램을 정리하면 다음과 같다.
모든 화살표가 위를 향하도록 맞추어 졌다. GameRules는 최상위 수준의정책을 가지는 컴포넌트이므로 최상위에 놓여졌다.
정보가 흐르는 방향을 생각해보자.
모든 입력은 사용자로부터 전달받아 좌측 하단의 TextDelivery 컴포넌트로 전달된다.
이 정보는 Language컴포넌트를 거쳐서 위로 올라가며, GameRules에 적합한 명령어로 번역된다.
GameRules는 비로소 사용자 입력을 처리하고, 우측하단의 DataStorage로 적절한 데이터를 내려보낸다.
그 후 GameRules는 Language로 출력을 되돌려 보내고, Language는 API를 다시 적절한 언어로 번역한 후,
번역된 언어를 TextDelivery를 통해 사용자에게 전달한다.
이 구성은 데이터 흐름을 두개의 흐름으로 효과적으로 분리하는데,
- 왼쪽의 흐름은 사용자와의 통신에 관여하며
- 오른쪽의 흐름은 데이터의 영속성에 관여한다.
두 흐름은 상단의 GameRules에서 서로 만나며, GameRules는 두 흐름이 모두 거치게 되는 데이터에 대한 최종적 처리기가 된다.
흐름 횡단하기
위 예제처럼 데이터 흐름은 항상 두가지일까? 위 움퍼스 사냥게임을 네트워크상에서 멀티 플레이를 한다고 생각만 해봐도 Network 라는 새 컴포넌트를 추가해야 하며 이 구성은 이제 데이터 흐름을 세개의 흐름으로 분리되는 문제로 바뀌게 된다.
물론 이들 흐름은 모두 GameRules가 제어한다는 것은 동일하다.
주목할 점은 시스템이 복잡해 질수록 컴포넌트 구조는 더 많은 흐름으로 분리된다는 점이다.
흐름 분리하기
모든 흐름이 결국 상단의 단일 컴포넌트에서 만난다고 생각 되어질 수도 있겠지만 현실은 훨씬 복잡하다.
다시 GameRules 컴포넌트로 돌아가 보자.
게임 규칙 중 일부는 지도와 관련된 메커니즘을 처리한다.
이 규칙들은 동굴이 서로 어떻게 연결될지, 각 동굴에 어떤 물체를 위치할지, 플레이어의 이동방법 등이 정의된다.
하지만 이보다 더 높은 수준에는 또다른 정책 집합이 있을 수 있다. 예를들어 플레이어의 생명력 따위와 같은 것들이다.
이러한 상위 수준의 정책은 플레이어의 생명력이 지속적으로 줄어들도록 하거나, 식량을 발견하면 생명력을 늘어나게 한다던지의 하위 정책에 영향을 준다.
저수준 메커니즘에서의 정책은 이러한 고수준 정책에서 FoundFood 나, FellInFit 과 같은 사건이 발생했음을 알린다. 그러면 그 이벤트에 따라 고수준의 정책에서는 플레이어의 상태를 관리 한다.
그렇다면 이것이 아키텍처 경계일까? PlayerManagement 와 MoveManagement를 분리하는 API가 필요할까?
아직 모호하니 여기에 마이크로 서비스를 추가하기 위해 대규모 플레이어가 동시에 같은 게임을 한다고 고려해보자.
MoveManagement는 플레이어의 컴퓨터에서 직접 처리되지만, PlayerManagement는 서버에서 처리 된다.
PlayerManagement는 접속된 모든 MoveManagement 컴포넌트에 마이크로 서비스 API를 제공한다.
Network 요소는 그려진 것보다는 훨씬 더 복잡하지만 간결하게 표현했다. 여기서 주목할 점은 바로 경계이다.
PlayerManagement 와 MoveManagement 사이에는 완벽한 형태의 아키텍처 경계가 존재한다.
SW아키텍트로서 우리는 아키텍처 경계가 언제 필요한지를 신중하게 파악해내야 한다.
또한 이러한 경계를 제대로 구현하려면 비용이 많이 든다는 사실도 인지하고 있어야 한다.
이와 동시에 이러한 경계가 무시되었다면 나중에 다시 추가하는 비용이 크다는 사실도 알아야 한다.
아키텍트로서 미래를 내다봐야 하기도 하고, 오버 엔지니어링이 언더 엔지니어링보다 더 나쁠때가 많으므로 YAGNI 원칙을 지켜야 할때도 있다.
따라서 현명하게 추측해야만 한다.
어디에 아키텍처 경계를 둬야할지? 완벽하게 구현할 경계는 무엇일지와 부분적으로 구현할 경계와 무시해야할 경계는 무엇일지를 결정해야만 한다.
이는 일회성 결정은 아니며 당연히 시스템이 발전함에 따라 조정할 수 있다.
프로젝트 초반에는 구현할 경계가 무엇인지와 무시할 경계가 무엇인지를 쉽게 결정 할 수 없으므로, 결정된 사항을 자주 검토하여 경계의 구현비용이 그걸 무시해서 생기는 비용보다 적어지는 바로 그 변곡점에서 경계를 구현한다.
이 목표를 달성하려면 빈틈없이 지켜보고 마찰의 어렴풋한 첫 조짐을 신중하게 관찰하는 수 밖에 없다.
< 메인 컴포넌트 >
모든 시스템에는 최소한 하나의 컴포넌트가 존재하고, 이 컴포넌트가 나머지 컴포넌트를 생성하고, 조정하며 관리한다.
이 컴포넌트를 메인 컴포넌트라 부른다.
궁극적인 세부사항
메인 컴포넌트는 궁극적인 세부사항으로, 가장 낮은 수준의 정책이다.
Main 은 시스템의 초기 진입점이며, OS를 제외하면 그 어던 것도 Main 에 의존하지 않는다.
Main은 모든 Factory와 Strategy, 그리고 시스템 전반을 담당하는 나머지 기반 설비를 생성한 후, 시스템에서 더 높은 수준을 담당하는 부분으로 제어권을 넘기는 역할을 맡는다.
의존성 주입 프레임워크를 이용해 의존성을 주입하는 일은 바로 이 메인 컴포넌트에서 이루어져야한다.
메인에 의존성이 일단 주입되고 나면, 메인은 프레임워크를 사용하지 않고도 일반적인 방식으로 의존성을 분배할 수 있어야 한다.
Main을 지전분한 컴포넌트 중에서도 가장 지저분한 컴포넌트라고 생각하자.
Main 함수에서는 입력 스트림 생성부, 게임의 메인 루프처리, 간단한 입력 명령어 해석등이 이루어지지만, 명령어를 실제로 직접 처리하는 일은 다른 고수준 컴포넌트로 위임된다.
결론적으로 Main은 클린 아키텍처에서 가장 바깥 원에 위치하는, 지저분한 저수준 모듈이라는 점이다.
즉, 고수준의 시스템을 위한 모든 것을 로드 한 후, 제어권을 고수준의 시스템에게 넘긴다.
결국 Main은 어플리케이션의 플러그인 과 같은 존재라고 생각하면 편하다. 이는 플러그인과 같이 동작하므로 Main 컴포넌트를 어플리케이션의 설정별로 하나식 두도록 하여 둘 이상의 Main 컴포넌트를 만들 수도 있다.
(ex, 개발용 메인 플러그인, 테스트용 메인 플러그인, 운영용 메인 플러그인)
또는 배포하려는 국가별로, 고객 군집 별로 메인 플러그인을 만들 수도 있다.
메인을 플러그인 컴포넌트로 여기고 아키텍처 경계 바깥에 위치한다고 보면 설정 관련 문제를 훨씬 쉽게 해결할 수 있다.
< ‘크고 작은 모든’ 서비스들 >
서비스 지향 아키텍처와 마이크로 서비스 아키텍처가 대세가 된 이유는 두가지가 있다.
- 서비스를 사용하면 상호 결합이 철저히 분리되는 것처럼 보인다.
- 서비스를 사용하면 개발과 배포 독립성을 지원하는 것 처럼 보인다.
하지만 사실 이 이유들은 일부만 맞는 말이다.
서비스 아키텍처?
‘서비스를 사용한다’ 라는 사실이 본질적으로 아키텍처링에 해당할까? 아니다.
시스템의 아키텍처는 ‘의존성 규칙을 준수하며 고수준의 정책을 저수준의 세부사항으로부터 분리하는 경계’에 의해 정의된다.
즉, 단순히 어플리케이션의 행위를 분리할 뿐인 서비스라면 이는 값비싼 그냥 함수 호출에 불과하며, 아키텍처 관점에서 꼭 중요하다고는 볼 수없다.
따라서 서비스 그자체로는 아키텍처를 정의하지 않는다.
함수들의 구성 형태도 이와 비슷하다. 아키텍처적으로 즁요한 함수들 (의존성 규칙을 따르며 아키텍처 경계를 넘나드는 함수 호출들)이 있는 반면, 나머지 많은 함수들은 행위를 서로 분리할 뿐 아키텍처 적으로 전혀 중요치 않은 것들도 있다.
서비스 역시 결국 프로세스나 플랫폼 경계를 가로지르는 함수 호출에 지나지 않는다.
즉, 아키텍처적으로 중요한 서비스가 있는 반면 중요하지 않은 서비스도 존재한다.
지금은 바로 아키텍처에서 중요한 범주에 있는 서비스에 주목한다.
서비스의 이점?
서비스 아키텍처가 정답 처럼 통용되고, 서비스 단위로 고려되는 현재의 인기 아키텍처에 대해서 몇가지 이의를 제기해 본다.
결합 분리의 오류
시스템을 서비스들로 분리함으로써 얻게 되리라 예상되는 큰 이점 하나는 서비스 사이의 결합이 확실히 분리된다는 점이다.
서비스는 서로 다른 프로세스, 심지어 다른 프로세서에서 실행 되므로 서비스는 다른 서비스의 변수에 직접 접근할 수 없고, 모든 서비스의 인터페이스는 반드시 잘 정의되어있어야 한다.
사실 이 말은 일리가 있지만 꼭 그런것은 아니다.
서비스는 개별 변수 수준에서는 각각 결합이 분리 됨이 맞으나, 프로세서 내 또는 네트워크 상의 공유 자원 때문에 결합될 가능성이 여전히 존재한다. 더욱이 서로 공유하는 데이터에 의해 이들 서비스는 강력하게 결합되어 버린다.
예를 들어, 서비스 사이를 오가는 데이터 레코드에 새로운 필드를 추가한다면, 이 필드를 사용하여 동작하는 모든 서비스는 반드시 변경되어야 한다. 따라서 서비스들은 이 데이터 레코드에 강하게 결합되고, 서비스들 사이는 서로 간접적으로 결합되어 버린다.
개발 및 배포 독립성의 오류
서비스를 사용함에 따라서 예측되는 또 다른 이점은 전담팀이 서비스를 소유하고 운영한다는 점이다. 이는 devops 전략의 일환으로 전담 팀에서 각 서비스를 작성하고, 유지보수하며, 운영하는 책임을 질 수 있다. 이러한 개발 및 배포 독립성은 확장 가능한것으로 간주된다. 대규모 엔터프라이즈급 시스템 또한 독립적으로 개발하고 수백 수천 개의 서비스들을 쉽게 이용하여 만들 수 있을 것이라 믿고, 개발/유지보수/운영 또한 비슷한 수의 독립적인 팀 단위로 분할 할 수 있다고 여긴다.
사실 이 말 역시 일리가 있지만 이는 극히 일부일 뿐이다.
첫째로, 대규모 엔터프라이즈급 시스템은 서비스 기반 시스템 말고도 기존 모노리틱 시스템이나 컴포넌트 기반 시스템으로도 구축할 수 있다는 사실이 역사적으로 증명되어 왔으므로, 서비스는 확장 가능성을 보장하는 유일한 선택지가 아니다.
둘째로, 결합 분리의 오류에 따르면 서비스라고 해서 항상 독립적으로 개발/배포/운영 할 수 있는 것이 아니다. 데이터나 행위에서 어느정도 결합 되어 있다면 결합된 정도에 맞게 개발/배포/운영을 조정해야만 한다.
야옹이 문제
택시 배차 앱에서 “고양이 운반” 기능을 추가할 경우 모든 서비스들이 결합되어 있어 결국 독립적으로 배포하거나 유지될 수 없다.
이는 횡단 관심사 (cross cutting concern)가 지닌 문제이다. *횡단 관심사 (관심사가 여러 객체에 흩어져있는 기능, 관심들)
모든 SW시스템은 서비스 지향이든 아니든 이 문제에 직면하기 마련이다.
기능적 컴포넌트 분해는 새로운 기능이 기능적 행위를 횡단하는 상황에 매우 취약하기 때문이다.
객체가 구출하다
컴포넌트 기반 아키텍처에서는 이 문제를 어떻게 해결 했을까? SOLID 설계 원칙을 들여다 보면,
다형적으로 확장할 수 있는 클래스 집합을 생성해 새로운 기능을 처리하도록 함을 알 수 있다.
위 다이어그램의 클래스들은 바로 직전 야옹이 문제 예제에서의 서비스들과 거의 일치 한다.
하지만 경계를 주목하고 의존성들이 의존성 규칙을 준수한다는 점에 주목해보자.
원래 서비스의 로직 중 대다수가 이 객체 모델의 기반 클래스들 내부로 녹아들었다.
하지만 배차에 특화된 로직은 Rides 컴포넌트로 추출되고, 야옹이에 대한 신규기능은 Kittens 컴포넌트에 들어갔다.
이 두 컴포넌트는 기존 컴포넌트들에 있는 추상 기반 클래스를 템플릿 메서드나 전략 패턴등을 이용해서 오버라이드 한다.
기능적으로 분해된 기존 다이어그램에서는 새로운 야옹이 라는 기능이 기능적 행위를 횡단하는 상황이었지만, 다형적으로 확장 할 수있는 클래스 집합을 생성하여 새로운 야옹이 기능을 처리한 것이다. 따라서 야옹이 기능은 결합이 분리되며, 독립적으로 개발 및 배포 할 수 있다.
컴포넌트 기반 서비스
서비스에도 이렇게 적용할 수 있을까? 서비스가 반드시 소규모 단일체여야 할 이유는 없다. 서비스 또한 SOLID 원칙대로 설계할 수 있으며 컴포넌트 구조를 갖출 수 있어 기존 컴포넌트를 변경하지 않고도 새로운 컴포넌트를 추가할 수 있다.
Java 의 경우, 서비스를 하나 이상의 jar 파일에 포함되는 추상 클래스들의 집합이라고 생각하자.
새로운 기능 추가 혹은 기능 확장은 새로운 jar 파일로 만든다. 이때 새로운 jar파일을 구성하는 클래스들은 기존 jar 파일에 정의된 추상 클래스들을 확장해서 만들어진다.
그러면 새로운 기능 배포는 서비스를 재배포하는 문제가 아니라, 서비스를 로드하는 경로에 단순히 새로운 jar 파일을 추가하는 문제가 된다.
다시 말해 새로운 기능을 추가하는 행위가 곧 OCP 를 준수하게 된다는 것이다.
위 그림의 서비스 다이어그램에서 서비스들의 존재는 이전과 달라진것이 없지만, 각 서비스 내부는 자신만의 컴포넌트 설계로 되어 있어서 파생 클래스를 만드는 방식으로 신규 기능을 추가할 수 있다. 파생 클래스들은 각자의 컴포넌트 내부에 놓인다.
횡단 관심사
아키텍처 경계는 서비스 사이에 있지 않고, 오히려 서비스를 관통하며, 서비스를 컴포넌트 단위로 분할한다.
모든 주요 시스템이 직면하는 횡단 관심사를 처리하려면, 아래 다이어그램처럼 서비스 내부의 의존성 규칙도 준수하는 컴포넌트 아키텍처로 설계해야 한다.
이 서비스들은 시스템의 아키텍처 경계를 정의하지 않는다. 아키텍처 경계를 정의하는 것은 서비스 내에 위치한 컴포넌트다.
서비스는 시스템의 확장성과 개발 가능성 측면에서는 유용하지만, 그 자체로 아키텍처적으로는 그리 중요한 요소가 아니다.
시스템의 아키텍처는 시스템 내부에 그어진 경계를 넘나드는 의존성에 의해 정의된다.
서비스는 단 하나의 아키텍처 경계로 둘러싸인 단일 컴포넌트로 만들 수도있고, 여러 아키텍처 경계로 분리된 다수의 컴포넌트로 구성할 수도 있다. 드물게는 클라이언트와 서비스가 강하게 결합되어 아키텍처 적으로 아무런 의미가 없을때도 있다.
< 테스트 경계 >
테스트는 시스템의 일부이며, 아키텍처에도 관여한다.
시스템 컴포넌트인 테스트
테스트는 시스템의 일부인가? 별개인가? 어떤 종류의 테스트를 취급하는가? 단위 / 통합테스트의 차이는? 인수 테스트, 기능 테스트, TDD, 컴포넌트 테스트는 어떤가? 테스트에 관련한 주제는 여러가지 혼동 사항들이 있지만 아키텍처 관점에서는 다행히 그럴 필요가 없다.
TDD로 생성한 작은 테스트이든, 대규모의 테스트이든 이들 테스트는 아키텍처적으로 모두 동등하기 때문이다.
- 테스트는 태생적으로 의존성 규칙을 따른다.
- 테스트는 세부적이며 구체적인 것으로, 의존성은 항상 테스트 대상이 되는 코드를 향한다. 실제로 테스트는 아키텍처에서 항상 테스트 대상이 되는 코드를 향한다. 실제로 테스트는 아키텍처에서 가장 바깥쪽 원으로 생각할 수 있다.
- 테스트는 독립적으로 배포가능하다.
- 사실 대다수의 경우 테스트는 테스트 시스템에만 배포하며, 상용 시스템에는 배포하지 않는다. 따라서 심지어 배포 독립성이 달리 필요치 않은 시스템에서도 테스트는 독립적으로 배포 될 것이다.
- 테스트는 시스템 컴포넌트 중에서 가장 고립되어 있다.
- 테스트가 시스템 운영에 꼭 필요치는 않다. 어떤 사용자도 테스트에 의존하지 않는다. 테스트의 역할은 운영이 아니라 개발을 지원하는데 있기 때문이다.
테스트를 고려한 설계
테스트가 지닌 극단적인 고립성은 테스트가 배포되지 않는다는 사실과 어우러지면서, 개발자는 종종 테스트가 시스템 설계 범위 밖에 있다고 여기는데 이것은 치명적이다.
테스트가 시스템의 설계와 잘 통합되지 않으면, 테스트는 깨지기 쉬워지고, 시스템은 뻣뻣해져서 변경하기가 어려워진다.
물론 문제는 결합이며, 시스템에 강하게 결합된 테스트라면 시스템이 변경될 때 함께 변경되어야 한다.
GUI를 사용하여 업무규칙을 사용하는 시스템이 있다고 가정했을때, 작은 화면요소 조금의 변경이 수백 수천개의 테스트를 망가뜨리는 부작용이 나타나기도 한다.
이 문제를 해결하려면 테스트를 고려해서 설계해야 한다. SW 설계의 첫 번째 규칙은 언제나 같은데 바로 변동성에 의존하지 말라는 점이다.
GUI는 변동성이 대체로 매우 크다. 따라서 GUI로 시스템을 조작하는 테스트는 분명 깨지기 쉽다.
따라서 시스템과 테스트를 설계할때 GUI를 사용하지 않고 업무 규칙 그 자체를 테스트 할 수 있게 해야한다.
테스트 API
이 목표를 달성하려면 테스트가 모든 업무 규칙을 검증하는 데 사용할 수 있도록 특화된 API를 만들면 된다.
이러한 API는 보안 제약사항을 무시할 수 있으며, DB접근 같은 값비싼 자원은 건너뛰고 시스템을 테스트 가능한 특정 상태로 강제하는 강력한 힘을 지녀야만 한다.
이 API는 사용자 인터페이스가 사용하는 인터랙터 Interactor 와 인터페이스 어댑터 들의 상위집합이 될 것이다.
테스트 API는 테스트를 어플리케이션으로부터 분리할 목적으로 사용한다.
이는 단순히 테스트를 UI에서 분리하는 것 뿐만아닌, 테스트 구조를 어플리케이션 구조로부터 결합을 분리하는 것이 목표이다.
구조적 결합
구조적 결합은 테스트 결합 중에서 가장 강하며, 가장 은밀하게 퍼져 나가는 유형이다.
모든 상용 클래스에 테스트 클래스가 각각 존재하고, 또 모든 상용 메서드에 테스트 메서드 집합이 각각 존재하는 테스트 스위트가 있다고 가정해 보자. (이러한 테스트 스위트는 어플리케이션 구조에 매우 강하게 결합되어 있는 상태이다.)
이러한 구조에서는 상용 클래스나 메서드 중 하나라도 변경되면 딸려 있는 다수의 테스트가 변경되어야 한다.
결과적으로 테스트는 깨지기 쉬워지고, 이로 인해 상용 코드를 뻣뻣하게 만든다.
테스트 API의 역할은 애플리케이션의 구조를 테스트로부터 숨기는 데 있다.
이렇게 만들면 상용 코드를 리팩터링하거나 진화시키더라도 테스트에는 전혀 영향을 주지 않는다.
또한 테스트를 리팩터링하거나 진화시킬 때도 상용 코드에는 전혀 영향을 주지 않는다.
이처럼 따로다로 진화할 수 있다는 점은 필수적인데, 시간이 지날 수록 테스트는 계속해서 더 구체적이고 더 특화된 형태로 변할 것이고, 반대로 상용코드는 더 추상적이고 더 범용적인 형태로 변할 것이기 때문이다.
하지만 구조적 결합이 강해 버리면 필수적 진화 과정을 방해할 뿐만아니라 상용 코드의 범용성과 유연성이 충분히 좋아지지 못하게 막는다.
보안
테스트 API가 지닌 강력한 힘을 운영 시스템에 배포하면 위험에 처할 수 있다.
이를 피하고 싶다면, 테스트 API 자체와 테스트 API 중 위험한 부분의 구현부는 독립적으로 배포할 수 있는 컴포넌트로 분리해야 한다.
테스트는 시스템 외부에 있지않고 오히려 시스템의 일부라고 생각해야한다.
따라서 테스트에서 기대하는 안정성과 회귀의 이점을 얻을 수 있으려면 테스트는 잘 설계되어야만 한다.
테스트를 시스템의 일부로 설계하지 않으면 테스트는 깨지기 쉽고 유지보수 하기 어려워지는 경향이 있으므로, 결국 휴지처럼 버려지는 최후를 맡곤 한다.
'Engineering > SW Architecture' 카테고리의 다른 글
클린 아키텍쳐 (7) - 사례 연구 : 비디오 판매 (0) | 2023.04.04 |
---|---|
클린 아키텍쳐 (6) - 세부사항 (0) | 2023.03.31 |
클린 아키텍쳐 (4) - 아키텍처 [소리치는 아키텍처, 클린 아키텍처, 프레젠터와 험블 객체] (0) | 2023.03.22 |
클린 아키텍쳐 (3) - 아키텍처 [독립성 / 선긋기 / 경계 해부학 / 정책과 수준/ 업무 규칙] (0) | 2023.03.19 |
클린 아키텍쳐 (2) - 컴포넌트 원칙 (0) | 2023.03.16 |
댓글