시스템 아키텍처는 일련의 SW 컴포넌트와 그 컴포넌트들을 분리하는 경계에 의해 정의된다.
이러한 형태 중 가장 흔한 몇 가지를 살펴보려고 한다.

경계 횡단하기

"런타임에 경계를 횡단한다" 함은 그저 경계 한쪽에 있는 기능에서 반대편 기능을 호출하여 데이터를 전달하는 일에 불과하다.
적절한 위치에서 경계를 횡단하게 하는 비결은 소스 코드 의존성 관리에 있다.
-> 소스 코드 모듈 하나가 변경되면, 이에 의존하는 다른 소스 코드 모듈도 변경해야할 수도 있기 떄문이다.

두려운 단일체

아키텍처 경계 중에서 가장 단순하며 가장 흔한 형태는 물리적으로 엄격히 구분되지 않는 형태다.
이 형태에서는 함수와 데이터가 단일 프로세서에서 같은 주소 공간을 공유하며 그저 나름의 규칙에 따라 분리되어 있을 뿐이다.
이전 장까지는 소스 수준 분리 모드라고 불렀다.

 

배포 관점에서 보면 이는 소위 단일체(모노리틱)라고 불리는 단일 실행 파일에 지나지 않는다.

 

이처럼 배포 관점에서 볼 때 단일체는 경계가 드러나지 않는다.
-> 단일체는 컴포넌트 수준으로 분리되지 않으므로, 배포할 때 개별 컴포넌트를 배포하는 대신 하나의 파일을 배포한다.
그렇다고 단일체에 경계가 없는 것은 아니다.
-> 단일 실행 파일을 만들더라도, 그 안에 다양한 컴포넌트를 개발하는 과정을 독립적으로 수행할 수 있게 한다.

이러한 아키텍처는 거의 모든 경우에 특정한 동적 다형성에 의존하여 내부 의존성을 관리한다.
이 때문에 OOP가 중요한 패러다임이 될 수 있었다.


OOP, 다형성에 해당하는 메커니즘이 없었다면, 결합도를 분리하기 위해 함수를 가리키는 포인터라는 위험한 방법을 썼을 것이다.

가장 단순한 형태의 경계 횡단은

  • 저수준 클라이언트에서 고수준 서비스로 향하는 함수 호출이다.
    이 경우 런타임 의존성과 컴파일타임 의존성은 모두 같은 방향, 즉 저수준 컴포넌트에서 고수준 컴포넌트로 향한다.

그림의 제어흐름은 왼쪽에서 오른쪽으로 횡단한다.
Client는 Service의 함수 f()를 호출한다. 이때 Client는 Data 인스턴스를 전달한다.
Data는 함수의 인자로 전달할 수도 있고, 더 정교한 다름 기법을 통해 전달할 수도 있다.
주목할 점은 경계에서 호출되는 쪽에 Data에 대한 정의가 위치한다는 사실이다.

 

고수준 클라이언트가 저수준 서비스를 호출해야 한다면 동적 다형성을 사용하여 제어흐름과는 반대 방향으로 의존성을 역전시킬 수 있다.
이렇게 하면 런타임 의존성은 컴파일 타임 의존성과는 반대가 된다.

 

 

 

제어흐름은 이전과 마찬가지로 읜쪽에서 오른쪽으로 경계를 횡단한다.

고수준 Client는 Service 인터페이스를 통해 저수준인 ServiceImpl의 함수 f()를 호출한다.

주목할 점은 경계를 횡단할 때 의존성은 모두 오른쪽에서 왼쪽으로, 즉 고수준 컴포넌트를 향한다는 점이다.

또한 데이터 구조의 정의가 호출하는 쪽에 위치한다는 점도 주목하자.

 

모노리틱 구조도 이처럼 규칙적인 방식으로 구조를 분리하면 프로젝트를 개발, 테스트, 배포하는 작업에 큰 도움이 된다.

팀들은 서로의 영역에 침범하지 않은 채 자신만의 컴포넌트를 독립적으로 작업할 수 있다.

 

단일체에서의 컴포넌트 간 통신은 값싸다. 함수 호출로 이뤄지기 때문이다.

 

배포형 컴포넌트

아키텍처의 경계가 물리적으로 드러날 수도 있는데 그중 가장 단순한 형태는 동적 링크 라이브러리다.

자바 jar 파일, 루비 젬, .NET DLL 등이 있다.

이 형태로 배포하면 따로 컴파일하지 않고 바로 사용할 수 있다.

 

배포 과정만 다를 뿐, 배포 수준의 컴포넌트는 단일체와 동일하다.

함수 호출을 사용하여 통신한다.

스레드

단일체와 배포형 컴포넌트는 모두 스레드를 활용할 수 있다.

스레드는 아키텍처 경계도 아니며 배포 단위도 아니다.

스레드는 실행 계획과 순서를 체계화하는 방법에 가깝다.

 

로컬 프로세스

훨씬 강한 물리적 형태를 띠는 아키텍처 경계로는 로컬 프로세스가 있다.

로컬 프로세스들은 동일한 프로세서 또는 하나의 멀티코어 시스템에 속한 여러 프로세서들에서 실행되지만,

각각이 독립된 주소 공간에서 실행된다.

종종 공유 메모리 파티션을 사용하지만, 일반적으로는 메모리 보호를 위해 공유하지 못하게 한다.

 

대게 로컬 프로세스는 소켓, 메일박스, 메세지 큐와 같이 OS에서 제공하는 통신 기능을 이용한다.

 

로컬 프로세스 간 분리 전략도 앞과 같다.

따라서 로컬 프로세스에서는 고수준 프로세스의 소스 코드가 저수준 프로세스의 이름, 물리 주소, 레지스트리 조회 키를 포함해서는 안된다.

 

경계 횡단은 OS 호출, Context Switching 등이 있고, 비싼 작업이다.

 

서비스

물리적인 형태를 띠는 가장 강력한 경계는 바로 서비스다.

서비스는 프로세스로 일반적으로 명령행 또는 그와 동등한 시스템 호출을 통해 구동된다.

서비스는 자신의 물리적 위치에 구애받지 않는다.

서로 통신하는 두 서비스는 물리적으로 동일한 프로세서나 멀티코어에서 동작할 수도 있고, 아닐 수도 있다.

서비스들은 모두 네트워크를 통해 이뤄진다고 가정한다.

 

서비스 경계를 지나는 통신은 함수 호출에 비해 매우 느리다. -> 네트워크를 사용하므로

이 수준의 통신에서는 지연에 따른 문제를 고수준에서 처리할 수 있어야 한다.

 

결론

단일체를 제외한 대다수의 시스템은 한 가지 이상의 경계 전략을 사용한다.

서비스 경계를 활용하는 시스템이라면 로컬 프로세스 경계도 일부 포함하고 있을 수 있다.

실제로 서비스는 상호작용하는 일련의 로컬 프로세스 퍼사드에 불과할 때가 많다.

또한 개별 서비스 또는 로컬 프로세스는 언제나 소스 코드 컴포넌트로 구성된 단일체이거나, 동적으로 링크된 배포형 컴포넌트들의 집합이다

 

즉, 대체로 한 시스템 안에서도 통신이 빈번한 로컬 경계와 지연을 중요하게 고려해야 하는 경계가 혼합되어 있다.

 

앞서 서술한 바와 같이 좋은 아키텍처는 다음을 지원해야 한다.

  • 시스템의 유스케이스
  • 시스템의 운영
  • 시스템의 개발
  • 시스템의 배포

유스케이스

시스템의 아키텍처는 시스템의 의도를 지원해야 한다는 뜻이다.
만약 시스템이 장바구니 App이라면, 이 아키텍처는 장바구니와 관련된 유스케이스를 지원해야 한다.
실제로 아키텍트의 최우선 관심사는 유스케이스이며, 아키텍처에서도 유스케이스가 최우선이다.
아키텍처는 반드시 유스케이스를 지원해야 한다.

하지만 아키텍처는 시스템의 행위에 그다지 큰 영향을 주지 않는다.
행위와 관련하여 아키텍처가 열어 둘 수 있는 선택사항은 별로 없다.
하지만 영향력이 전부가 아니다. 아키텍ㅊ처가 행위를 지원하기 위해 할 수 있는 일 중에서 가장 중요한 사항은

  1. 행위를 명확히 하고 외부로 드러내며
  2. 이를 통해 시스템이 지닌 의도를 아키텍처 수준에서 알아볼 수 있게 만드는 것

장바구니 App이 좋은 아키텍처를 갖춘다면, 이 App은 장바구니 애플리케이션처럼 보일 것이다.
해당 시스템의 유스케이스는 시스템 구조 자체에서 한눈에 드러날 것이다.
이들 행위는 "일급 요소" 이며 시스템의 최상위 수준에서 알아볼 수 있으므로, 개발자가 일일이 찾아 헤매지 않아도 된다.
이들 요소는 클래스이거나 함수 또는 모듈로서 아키텍처 내에서 핵심적인 자리를 차지할 뿐만 아니라, 자신의 기능을 분명하게 설명하는 이름을 갖는다
-> 이후에 다시 설명

운영

시스템의 운영 지원 관점에서 볼 때 아키텍처는 더 실질적이며 덜 피상적인 역할을 맡는다.
IF) 초당 100,000명의 고객을 처리해야 한다면, 아키텍처는 이 요구와 관련된 각 유스케이스에 걸맞은 처리량과 응답시간을 보장해야 한다.
만약 시스템에서 수 밀리초 안에 3차원의 빅데이터 테이블에 질의해야 한다면, 이러한 운영 작업을 허용할 수 있는 아키텍처를 구조화해야 한다.

이러한 형태를 지원한다는 말은 시스템에 따라 다양한 의미를 지닌다.
어떤 시스템에서는 시스템의 처리 요소를 일련의 작은 서비스들로 배열하여, 서로 다른 많은 서버에서 병렬로 실행할 수 있게해야 한다.
다른 시스템은 경량의 수많은 스레드가 단일 프로세서에서 같은 주소 공간을 공유하도록 만들어야 한다.
또는 독립된 주소 공간에서 실행되는 소수의 프로세스만으로도 충분한 시스템도 있을 것이다.

이상하게 보일 수도 있지만, 이러한 결정은 ㄷ항상 열어 두어야 하는 선택사항 중 하나다.
-> 시스템/App에 어떤 선택을 해야할 지 모르기에?
IF) 시스템이 이미 모노리틱 구조를 갖는다면, 다중 프로세스/스레드/MSA가 필요해질 때 개선하기 어렵다.
그에 비해 아키텍처에서 각 컴포넌트를 적절히 격리하고 유지하고 컴포넌트 간 통신 방식을 특정 형태로 제한하지 않는다면, 시간이 지나 운영에 필요한 요구사항이 바뀌더라도 쓰레드/프로세스/서비스로 구성된 기술 스펙트럼 사이를 전황하는 일이 쉬워질 것이다.

개발

아키텍처는 개발환경을 지원하는 데 있어 핵심적인 역할을 수행한다.
Conway 법칙이 작용하는 지점이 이곳이다.

시스템을 설계하는 조직이라면 어디든지 그 조직의 의사소통 구조와 동일한 구조의 설계를 만들어 낼 것이다.

많은 팀으로 구성되며 관심사가 다양한 조직에서 어떤 시스템을 개발해야 한다면,
각 팀이 독립적으로 행동하기 편한 아키텍처를 반드시 확보하여 개발하는 동안 팀끼리 서로를 방해하지 않아야 한다.
이러한 아키텍처를 만들려면 잘 격리되어 독립적으로 개발 가능한 컴포넌트 단위로 시스템을 분할할 수 있어야 한다.
그래야만 이들 컴포넌트를 독립적으로 작업할 수 있는 팀에 할당할 수 있다.

선택사항 열어놓기

좋은 아키텍처는 컴포넌트 구조와 관련된 이 관심사들 사이에서 균형을 맞추고, 각 관심사 모두를 만족시킨다.
말은 쉽다.
현실에서는 이러한 균형을 잡기가 매우 어렵다.
대부분의 경우 모든 유스케이스를 알 수는 없으며, 운영하는 데 따르는 제약사항, 팀 구조 등을 알지 못한다.
이러한 사항들을 알더라도 시스템이 생명주기의 단계를 거쳐감에 따라 이 사항들도 변할 것이다.
우리가 도달하려는 목표는 시시각각으로 변한다

그러나 사라지지 않는 것도 있다.
몇몇 아키텍처 원칙은 구현하는 비용이 비싸지 않으며, 관심사들 사이에서 균형을 잡는 데 도움이 된다.
심지어 균형을 맞추려는 목표점을 명확히 그릴 수 없는 경우에도 도움이 된다.
시스템을 제대로 격리된 컴포넌트 단위로 분할할 때 도움이 되며, 이를 통해 선택사항을 가능한 많이, 가능한 오랫동안 열어야 한다.

계층 결합 분리

유스케이스 측면을 보자, 아키텍트는 필요한 모든 유스케이스를 지원할 수 있는 시스템 구조를 원하지만, 유스케이스 전부를 알지는 못한다.
하지만 아키텍터는 시스템의 기본적인 의도는 분명히 알고 있다.
-> 시스템이 장바구니인지 주문 처리 인지 안다는 뜻이다.
따라서 아키텍트는 단일 책임 원칙과 공통 폐쇄원칙을 적용하여,
그 의도의 맥락에 따라서 다른 이유로 변경되는 것들은 분리하고, 동일한 이유로 변경되는 것들은 묶는다.

서로 다른 이유로 변경되는 것은 무엇일까? 몇가지 분명한 것이 있다.

  • UI는 업무 규칙과는 아무런 관련이 없다.
  • 유스케이스가 두가지 요소를 모두 포함한다면
    만약 유스케이스가 두자기 요소를 모두 포함한다면, 뛰어난 아키텍트는 유스케이스에서 UI부분과 업무 규칙 부분을 서로 분리하고자 할 것이다.
    이렇게 함으로써 두 요소를 서로 독립적으로 변경할 수 있을 뿐만 아니라, 유스케이스는 여전히 가시적이며 분명하게 유지할 수 있다.
    (UI, 비즈니스, 도메인 로직을 구분하는 것?)

업무 규칙은 그 자체가 App과 밀접한 관련이 있거나, 혹은 더 범용적일 수도 있다.
EX)

  • 입력 필드 유효성 검사는 App 자체와 밀접하게 관련된 업무 규칙이다.
  • 반대로 계좌의 이자 계산이나 재고품 집계는 업무 도메인에 더 밀접하게 연관된 업무 규칙이다.
    이들 서로 다른 두 유형의 규칙은 각자 다른 속도로, 다른 이유로 변경될 것이다.
    따라서 이들 규칙은 서로 분리하고, 독립적으로 변경할 수 있도록 만들어야 한다.

DB, 쿼리 언어, 스키마조차도 기술적인 세부사항이며, 업무 규칙이나 UI와는 아무런 관련이 없다.
시스템의 다른 측면과는 다른 속도로, 다른 이유로 변경된다.
결론적으로 이들은 시스템의 나머지 부분으로부터 분리하여 독립적으로 변경할 수 있도록 해야 한다.

이제 우리는 시스템을 서로 결합되지 않은 수평적인 계층으로 분리하는 방법을 알게 되었다.
UI, App에 특화된 업무, App과는 독립적인 업무 규칙, DB

유스케이스 결합 분리

서로 다른 이유로 변경되는 것에는 또 무엇이 있을까? -> 유스케이스
주문 입력 시스템에서 주문을 추가하는 유스케이스는 주문을 삭제하는 유스케이스와는 틀림없이 다른 이유로 변경된다.

이와 동시에 유스케이스는 시스템의 수평적인 계층을 가로지르도록 자른 조각이기도 하다.
각 유스케이스는 UI의 일부, App 특화 업무 규칙의 일부, App 독립적 업무 규칙의 일부, DB 기능 일부를 사용한다.
따라서 우리는 시스템을 수평적 계층으로 분할하면서 동시에 해당 계층을 가로지르는 얇은 수직적인 유스케이스로 시스템을 분할할 수 있다.

  • 이와 같이 결합을 분리하려면 주문 추가 유스케이스의 UI와 주문 삭제 유스케이스의 UI를 분리해야 한다.
  • 유스케이스의 업무 규칙과 DB 부분도 마찬가지다.
    이런 식으로 시스템의 맨 아래 계층까지 수직으로 내려가며 유스케이스들이 각 계층에서 서로 겹치지 않게 한다.

-> 여기서 패턴을 볼 수 있다.
시스템에서 서로 다른 이유로 변경되는 요소들의 결합을 분리하면 기존 요소에 지장을 주지 않고도 새로운 유스케이스를 추가할 수 있다.
또한, 유스케이스를 뒷받침하는 UI와 DB를 묶어서 각 유스케이스가 UI와 DB의 서로 다른 관점을 사용하게 되면,
새로운 유스케이스를 추가하더라도 기존 유스케이스에 영향을 주지 않는다.

결합 분리 모드

이렇게 결합을 분리하면 두 번째 항목인 운영 관점에서 어떤 의미가 있는지 살펴보자.
유스케이스에서 서로 다른 관점이 분리되었다면, 높은 처리량을 보장해야 하는 유스케이스와
낮은 처리량으로도 충분한 유스케이스는 이미 분리되어 있을 가능성이 높다.
UI와 DB가 업무 규칙과 분리되어 있다면, UI와 DB는 업무 규칙과는 다른 서버에서 실행될 수 있다.

간단히 말해 유스케이스를 위해 수행하는 그 작업(결합 분리)들은 운영에도 도움이 된다.
하지만 운영 측면에서 이점을 살리기 위해선 결합을 분리할 때 적절한 모드를 선택해야 한다.
예를 들어 분리된 컴포넌트를 서로 다른 서버에서 실행해야 하는 상황이라면, 이들 컴포넌트가 단일 프로세서의 동일한 주소 공간에 함께 상주하면 안된다.
분리된 컴포넌트는 반드시 독립된 서비스가 되어야 하고, 네트워크를 통해 통신해야 한다.

이러한 컴포넌트를 '서비스' 또는 마이크로서비스라고 하는데 그 구분은 모호하다.
실제로 서비스에 기반한 아키텍처를 흔히들 서비스 지향 아키텍처(SOA)라고 부른다.

개발 독립성

세 번째 항목은 개발이었다.
컴포넌트가 완전히 분리되면 팀 사이의 간섭은 줄어든다.
업무 규칙이 UI를 알지 못하면 UI에 중점을 둔 팀은 업무 규칙에 중점을 둔 팀에 영향을 줄 수 없다.
유스케이스 자체도 서로 결합이 분리되면 ㅁddOrder 유스케이스에 중점을 둔 팀이 ㅇeleteOrder 유스케이스에 중점을 둔 팀에 개입하지 못한다.

배포 독립성

유스케이스와 계층의 결합이 분리되면 배포 측면에서도 고도의 유연성이 생긴다.
실제로 결합을 제대로 분리했다면 운영 중인 시스템에서도 계층과 유스케이스를 교체할 수 있다.

중복

SW에서 중복은 일반적으로 나쁜 것이다.
우리는 중복된 코드를 줄이고 제거하기 위해 많은 노력을 한다.
하지만 중복에도 종류가 있다. 그 중 하나는 진짜 중복이다.
이 경우 한 인스턴스가 변경되면, 동일한 변경을 그 인스턴스의 모든 복사본에 반드시 적용해야 한다.

또 다른 중복은 거짓된 중복이다.
중복으로 보이는 두 코드 영역이 각자의 경로로 발전한다면, 즉 서로 다른 속도와 다른 이유로 변경된다면 이 두 코드는 진짜 중복이 아니다.

예를 들어 두 유스케이스의 화면 구조가 매우 비슷하다고 가정해보자.
아키텍트는 이 구조에 사용할 코드를 통합하고 싶은 유혹을 강하게 느낄 것이다.
하지만 이는 진짜 중복일까? 가짜 중복일까?
-> 거짓된 중복일 가능성이 높다.
시간이 지나면서 두 화면은 서로 다른 방향으로 분기하며, 결국에는 다른 모습을 가질 가능성이 높다.

유스케이스를 수직으로 분리할 때 이러한 문제와 마주칠테고, 이 유스케이스를 통합하고 싶다는 유혹을 받게 될 것이다.
-> 이 유스케이스들이 서로 비슷한 화면, 비슷한 알고리즘, 비슷한 DB 쿼리와 스키마를 갖고있기 때문이다.
하지만 이 중복이 진짜 중복인지 확인하라.

마지막으로 계층을 수평으로 분리하는 경우, 특정 DB 레코드의 데이터 구조가 특정 화면의 데이트 구조와 비슷하다는 점을 발견할 수 있다.
이때 DB 레코드와 동일한 형태의 View Model을 만들어서 각 항목을 복사하는 것이 아니라,
DB 레코드를 있는 그대로 UI까지 전달하고 싶다는 유혹을 받을 수 있다.
-> ViewModel을 만들어라.

결합 분리 모드(다시)

결합 분리 모드로 다시 돌아가자. 계층과 유스케이스의 결합을 분리하는 방법은 다양하다.

  1. 소스 코드 수준에서 분리
  2. 바이너리 코드에서 분리
  3. 실행 단위(서비스) 수준에서 분리
  • 소스 코드 수준에서 분리

    • 소스 코드 모듈 사이의 의존성을 제어할 수 있다.
    • 이를 통해 하나의 모듈이 변하더라도 다른 모듈을 변경하지 않아도 됨
    • 이 모드에서는 모든 컴포넌트가 같은 주소 공간에서 실행되고, 서로 통신할 때는 함수 호출을 사용한다.
    • 흔히 모노리틱 구조라 부른다.
  • 배포 수준 분리 모드

    • jar 파일, DLL 처럼 배포 가능한 단위들 사이의 의존성을 제어 가능
    • 모듈이 변해도 다른 모듈을 재빌드하지 않아도 됨
    • 많은 컴포넌트가 여전히 같은 주소 공간에 상주하며, 단순한 함수 호출을 통해 통신할 수 있다.
  • 서비스 수준 분리 모드

    • 의존하는 수준을 데이터 구조 단위까지 낮출 수 있고, 네트워크 패킷을 통해서만 통신한다.

어떤 것이 좋은가?
-> 프로젝트 초기에는 어떤 것이 최선인지 알 수 없다.
-> 또한 프로젝트가 커져감에 따라 최적인 모드가 달라질 수 있다.

한가지 해결책은 단순히 서비스 수준에서의 분리를 기본 정책으로 삼는 것이다.(MSA)
이 모드는 비용이 많이 들고, 결합이 큰 단위에서 분리된다는 문제가 있다.

또한, 서비스 수준의 결합 분리는 개발 시간 뿐만 아니라 시스템 자원에서도 비용이 많이 든다.
필요치도 않은 서비스 경계를 처리하는 데 드는 작업은 노력, 메모리, 계산량 측면에서 모두 낭비다.

초기에는 소스 코드 분리 모드를 사용하다가 개발/배포/운영 문제가 증가하면 그때, 서비스 수준으로 전환할지 고려한다.

좋은 아키텍처는 시스템이 모노리틱으로 태어나서 단일 파일로 배포되더라도, 이후에는 독립적으로 배포 가능한 단위들의 집합으로 성장하고,
또 독립적인 서비스나 마이크로 서비스 수준까지 성장해야 한다.
또한 반대로 마이크로서비스를 모노리틱 구조로 되돌릴 수도 있어야 한다.

결론

물론 이렇게 하기는 매우 어렵다.
그리고 결합 분리 모드를 변경하기가 설정값 하나 바꾸는 것처럼 쉽지 않다.
결국 시스템의 결합 분리 모드는 시간이 지나면서 바뀌기 쉬우며, 이러한 변경을 예측해서 변경에 무리가 없도록 만들어야 한다는 점이다.

아키텍처

아키텍처라는 단어는 권력과 신비로움을 연상케 한다.
SW 아키텍처는 기술적 성취의 정점에 서 있따.

그렇다면 SW 아키텍처란 무엇인가?

  • SW 아키텍트 또한 프로그래머이다.
  • 다른 프로그래머들의 생산성을 극대화할 수 있는 설계를 하도록 방향을 이끌어 준다.

시스템 아키텍처는 시스템의 동작 여부와는 관련이 없다.
형편없는 아키텍처를 갑춘 시스템도 잘 동작한다. 이러한 시스템들은 배포, 유지보수, 이어지는 개발 단계에서 어려움을 겪는다.

-> 아키텍처가 시스템이 제대로 동작하도록 지원하는 데는 아무런 역할을 하지 않는다는 말은 아니다.
아키텍처는 시스템의 생명주기를 지원한다.
좋은 아키텍처는 시스템을 쉽게 이해하고, 쉽게 개발하며, 쉽게 유지보수한다.

아키텍처의 주된 목적은 프로그래머의 생산성을 최대화하는 데 있다.

개발

개발하기 힘든 시스템이라면 수명도 짧고 건강하지 않을 것이다.
사실 팀 단위(5명)가 작다면, 잘 정의된 컴포넌트나 인터페이스가 없더라도 서로 효율적으로 협력하여 개발할 수 있다.
이러한 팀에서는 아키텍처 관련 제약들이 오히려 방해가 된다고 생각할 것이다.

반대로 총 다섯 팀이 시스템을 개발하고 있다면 안정된 인터페이스, 잘 설계된 컴포넌트 단위로 분리하지 않으면 개발이 진척되지 않는다.
다른 요소를 고려하지 않는다면 이 시스템의 아키텍처는 다섯개의 컴포넌트로 발전될 가능성이 높다.

배포

SW 시스템이 사용될 수 있으려면 반드시 배포할 수 있어야 한다.
배포 비용이 높을수록 시스템의 유용성은 떨어진다.
따라서 SW 아키텍처는 시스템을 단 한번에 배포할 수 있도록 만드는 데 목표를 두어야 한다.

하지만 개발 초기 단계에서는 배포 전략을 거의 고려하지 않는다.
이로 인해 개발은 쉽지만, 배포하기는 어려운 아키텍처가 만들어진다.

EX
개발 초기 단계에서 "MSA" 를 사용하자고 결정할 수 있다.

  • 컴포넌트 경계가 뚜렷해지고
  • 인터페이스가 대체로 안정화되므로
    시스템을 쉽게 개발할 수 있다고 판단했을지도 모른다.
    하지만 배포할 시기가 되면 너무 많은 마이크로서비스를 발견하게 될지도 모른다.
    서로를 연결하고 설정하고 순서를 결정하는 과정에서 오작동이 발생할 원천이 스며들 수도 있다.

IF) 배포 문제를 초기에 고려했다면 이와는 다른 결정을 내렸을 것이다.

운영

아키텍처가 시스템 운영에 미치는 영향은 "개발, 배포, 유지보수"에 비해 적다.
대다수의 어려움은 더 많은 하드웨어를 투입해서 해결할 수 있다.

  • 아키텍처가 비효율 적이라면 -> 스토리지와 서버를 추가

시스템 아키텍처는 "유스케이스, 기능, 시스템의 필수 행위" 를 일급 엔티티로 격상시키고,
이들 요소가 개발자에게 주요 목표로 인식되도록 해야 한다.

유지보수

유지보수는 모든 측면에서 봤을 때 SW 시스템에서 비용이 가장 많이 든다.
새로운 기능은 끝도없이 생성되고, 그에 따른 결함도 피할 수 없으며, 결함을 수정하기 위한 인력이 소모된다.

유지보수의 가장 큰 비용은 탐사이며 이로인한 위험부담에 있다.
탐사

  • 기존 SW에 새로운 기능을 추가하거나 결함을 수정할 때, SW를 파헤쳐서 어디를 고치는 게 최선인지, 어떤 전략을 쓰는게 최적일지 결정할 때 드는 비용

선택사항 열어두기

SW의 두 종류의 가치 "행위적 가치, 구조적 가치"
SW를 부드럽게 만드는 것은 구조적 가치이다.

SW를 만든 이유는 기계의 행위를 빠르고 쉽게 변경하는 방법이 필요했기 때문이다.
하지만 이러한 유연성은 시스템의 형태, 컴포넌트의 배치 방식, 컴포넌트가 서로 연결되는 방식에 크기 의존한다.
그렇다면 열어둬야하는 선택사항은 무엇일까?
-> 중요치 않은 세부 사항

모든 SW 시스템은 주요한 2가지 구성요소로 분해할 수 있다.

  • 정책
    • 모든 업무 규칙과 업무 절차를 구체화 한다
  • 세부사항
    • 사람, 외부 시스템, 프로그래머가 정책과 소통할 때 필요한 요소
    • 하지만 정책이 가진 행위에는 조금도 영향을 미치지 않는다.
    • 이러한 세부 사항에는 입출력 장치, DB, 웹 시스템, 서버, 프레임워크 등

아키텍트의 목표는 시스템에서 정책을 가장 핵심적인 요소로 식별하고, 동시에 세부사항은 정책에 무관하게 만들 수 있는 형태의 시스템을 구축하는 데 있다.

  • 개발 초기에는 DB 시스템을 선택할 필요가 없다. 고 수준의 정책은 어떤 종류의 DB를 사용하는 지 신경쓰지 않는다. 신중한 아키텍트라면 관계형 DB인지 분산형인지, 계층형인지는 관련이 없도록 아키텍트를 설계해야 한다.

  • 개발 초기에는 웹 서버를 선택할 필요가 없다. 고수준의 정책은 자신이 웹을 통해 전달된다는 사실을 알아서도 안된다. HTML, AJAX, JSF 같은 웹 개발 기술들에 대해 고수준의 정책이 전혀 알지 못하게 만들면, 프로젝트 후반까지는 어떤 종류의 웹 시스템을 사용할지 결정하지 않아도 된다. 심지어는 시스템을 웹을 통해 전송할 것인지조차도 결정할 필요가 없다.

  • 개발 초기에는 REST를 적용할 필요가 없다. 고수준의 정책은 외부 세계로의 인터페이스에 대해 독립적이어야 하기 때문이다. 마이크로서비스 프레임워크 또는 SOA 프레임워크도 적용할 필요가 없다. 다시 한번 말하지만 고수준의 정책은 이러한 것들에 신경 써서는 안 된다.

  • 개발 초기에는 DI 프레임워크를 적용할 필요가 없다. 고수준의 정책은 의존성을 해석하는 방식에 대해 신경 써서는 안 된다.

요점

  • 세부사항에 몰두하지 않은 고수준의 정책을 만들 수 있다면, 이러한 세부사항에 대한 결정을 오랫동안 미루거나 연기
  • 이러한 결정을 늦게 할수록, 더 많은 정보를 얻고 제대로 된 결정을 내릴 수 있다.
    이를 통해 다양한 실험을 시도해볼 수 있는 선택지도 열어 둘 수 있다.
    현재 동작하고 있는 일부 고수준 정책이 있고, 이들 정책이 DB에 독립적이라면 다양한 DB를 후보로 두고 성능을 검토해 볼 수 있다.

이미 다른 누군가가 결정을 내렸다면?
또는 회사에서 특정 프레임워크, 웹 서버, DB에 기여해왔다면?
-> 그렇다 하더라도 최대한 결정하지 않는 것이 좋다.

결론

좋은 아키텍트는 세부사항을 정책으로부터 신중하게 가려내고, 정책이 세부사항과 결합되지 않도록 분리한다.
이를 통해 정책은 세부사항에 관한 어떤 지식도 갖지 못하며, 의존하지 않는다.

DIP

  • 유연성이 극대화된 시스템 : 소스 코드 의존성이 추상(Abstraction)에 존재하며 구체(Concretion)에는 의존하지 않는 시스템

자바와 같은 정적 타입 언어에서 이 말은 user, import, include 구문은 오직 인터페이스나 추상 클래스 같은 추상적인 선언만 참조해야 한다는 뜻이다.

루비나 파이썬과 같은 동적 타입 언어에도 동일한 규칙이 적용된다.

BUT 이 아이디어를 규칙으로 보기는 확실히 비현실적이다.
SW 시스템이라면 구체적인 많은 장치에 반드시 의존하기 때문이다.
-> EX) 자바에서는 String은 구체 클래스이며, 이를 추상클래스로 만들지도 못한다.
java.lang.String 구체 클래스에 대한 소스 코드 의존성은 벗어날 수 없고, 벗어나서도 안된다.

반면 String 클래스는 매우 안정적이다.
String 클래스가 변경되는 일은 거의 없으며, 있더라도 엄격하게 통제된다.
프로그래머는 String 클래스에서 변경을 고려하지 않아도 된다.

*이러한 이유로 DIP를 논할 때 OS나 플랫폼 같이 안정성이 보장된 환경에 대해서는 무시하는 편이다.
-> 우리가 의존하지 않도록 하는 것은 변동성이 큰 구체적인 요소다.
-> 이 구체적인 요소는 우리가 열심히 개발하는 중이라 자주 변경될 수밖에 없는 모듈이다. *

안정된 추상화

추상 인터페이스에 변경이 생기면 이를 구현한 구현체들도 수정해줘야 한다.
반대로 구현체에 변경이 생기더라도 인터페이스는 대다수 변경될 필요가 없다.
-> 인터페이스는 구현체보다 변동성이 낮다.

안정된 SW 아키텍처란 변동성이 큰 구현체에 의존하는 일은 지양하고
안정된 추상 인터페이스를 선호하는 아키텍처라는 뜻이다.

구체적인 코딩 실천법

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

팩토리

이 규칙들을 준수하려면 변동성이 큰 구체적인 객체는 특별히 주의해서 생성해야 한다.
모든 언어에서 객체를 생성하려면 해당 객체를 구체적으로 정의한 코드에 대해 소스 코드 의존성이 발생하기 때문이다.

추상 팩토리 사용 구조

Application은 Service 인터페이스를 통해 ConcreteImpl을 사용하지만,
Application에서는 어떤 식으로든 ConcreteImpl의 인스턴스를 생성해야 한다.

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

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

곡선은 시스템을 두 가지 컴포넌트로 분리한다. -> 추상/구체 컴포넌트
추상 컴포넌트는 애플리케이션의 모든 고수준 업무 규칙을 포함한다.
구체 컴포넌트는 업무 규칙을 다루기 위해 필요한 모든 세부사항을 포함한다.
제어 흐름은 소스 코드 의존성과는 정반대 방향으로 곡선을 가로지른다는 점에 주목하자.

구체 컴포넌트

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

대다수의 시스템은 이러한 구체 컴포넌트를 최소한 하나는 포함할 것이다.
흔히 컴포넌트를 Main이라 부르는데, main 함수를 포함하기 때문이다.
위의 그림에서 main 함수는 ServiceFactoryImpl의 인스턴스를 생성한 후, 이 인스턴스를 ServiceFactory 타입으로 저장
그 후, Application은 이 변수를 이용해서 ServiceFactoryImpl 인스턴스에 접근할 것이다.

결론

앞으로 고수준의 아키텍처를 다루면서 DIP는 자주 나온다.
그리고 DIP는 아키텍처 다이어그램에서 가장 눈에 띄는 원칙이 될 것이다.
그리고 의존성은 위의 곡선을 경계로 더 추상적인 엔티티가 있는 쪽으로만 향한다.

+ Recent posts