1부. 소개
설계와 아키텍처란?
설계란 무엇인가? 아키텍처는? 둘 사이에는 어떤 차이가 있는가?
아키텍처는 저수준의 세부사항과는 분리된 고수준의 무언가를 가리킬 때 흔히 사용되는 반면, 설계는 저수준의 구조 또는 결정사항 등을 의미할 때가 많다.
하지만 이러한 구분은 무의미하며 저수준의 세부사항과 고수준의 구조는 모두 소프트웨어 전체 설계의 구성요소다.
목표는?
소프트웨어 아키텍처의 목표는 필요한 시스템을 만들고 유지보수하는데 투입되는 인력을 최소화하는데 있다.
설계 품질을 재는 척도는 고객의 요구를 만족시키는데 드는 비용을 재는 척도와 다름없다.
사례 연구
새로운 기능을 출시할 때마다 엔지니어링 직원 수는 증가하지만 코드 라인 수는 증가가 더뎌진다.
이걸 통해 코드 라인당 비용이 증가한다는걸 알 수 있고 이러한 비용 곡선은 사업 모델의 수익을 엄청나게 고갈시키며, 회사의 성장을 멈추게 하거나 심지어는 완전히 망하게 만든다.
개발자의 생산성은 거의 100%로 시작하지만, 출시할 때마다 하락하며 결국에는 0으로 수렴한다.
개발자의 노력은 기능 개발보다는 엉망이 된 상황에 대처하는데 소모되기 시작한다.
개발자는 “코드는 나중에 정리하면 돼. 당장은 시장에 출시하는게 먼저야”라는 흔해 빠진 거짓말에 속는다.
이렇게 속아 넘어간 개발자라면 나중에 코드를 정리하는 경우는 한 번도 없는데, 시장의 압박은 절대로 수그러들지 않기 때문이다.
개발자가 속는 더 잘못된 거짓말은 “지저분한 코드를 작성하면 단기간에는 빠르게 갈 수 있고, 장기적으로 볼 때만 생산성이 낮아진다”는 견해다.
이 거짓말을 받아들인 개발자는 엉망인 코드를 만드는 태세에서, 나중에 기회가 되면 엉망이 된 코드를 정리하는 태세로 전환할 수 있다고 자신의 능력을 과신하게 된다.
엉망으로 만들면 깔끔하게 유지할 때보다 항상 더 느리며 빨리 가는 유일한 방법은 제대로 가는 것이다.
개발자는 처음부터 다시 시작하여 전체 시스템을 재설계하는 것이 해답이라고 생각할지도 모른다.
하지만 자신을 과신한다면 재설계하더라도 원래의 프로젝트와 똑같이 엉망으로 내몰린다.
결론
어떤 경우라도 개발 조직이 할 수 있는 최고의 선택지는 조직에 스며든 과신을 인지하여 방지하고, 소프트웨어 아키텍처의 품질을 심각하게 고민하기 시작하는 것이다.
비용은 최소화하고 생산성은 최대화할 수 있는 설계와 아키텍처를 가진 시스템을 만들려면, 이러한 결과로 이끌어줄 시스템 아키텍처가 지닌 속성을 알고 있어야 한다.
두 가지 가치에 대한 이야기
모든 소프트웨어 시스템은 이해관계자에게 서로 다른 두 가지 가치를 제공하는데 행위와 구조가 바로 그것이다.
불행하게도 개발자는 한 가지 가치에만 집중하고 나머지 가치는 배제하곤 한다.
더 안타까운 일은 대체로 개발자가 둘 중 덜 중요한 가치에 집중하여 결국에는 소프트웨어 시스템이 쓸모없게 된다는 사실이다.
행위
프로그래머를 고용하는 이유는 이해관계자를 위해 기계가 수익을 창출하거나 비용을 절약하도록 만들기 위해서다.
이를 위해 프로그래머는 이해관계자가 기능 명세서나 요구사항 문서를 구체화할 수 있도록 돕고 이러한 요구사항을 만족하도록 코드를 작성한다.
많은 프로그래머가 이러한 활동이 자신이 해야 할 일의 전부라고 생각하지만 그들은 틀렸다.
아키텍처
소프트웨어는 부드러움을 지니도록 만들어졌고 그 이유는 기계의 행위를 쉽게 변경할 수 있도록 하기 위해서다.
이해관계자가 기능에 대한 생각을 바꾸면, 이러한 변경사항을 간단하고 쉽게 적용할 수 있어야 한다.
이러한 변경사항을 적용하는데 드는 어려움은 변경되는 범위에 비례해야 하며, 변경사항의 형태와는 관련이 없어야 한다.
소프트웨어 개발 비용의 증가를 결정짓는 주된 요인은 바로 이 변경사항의 범위와 형태의 차이에 있기 때문에 개발 비용은 요청된 변경사항의 크기에 비례한다.
새로운 요청사항이 발생할 때마다 바로 이전의 변경사항을 적용하는 것보다 조금 더 힘들어지는데, 시스템의 형태와 요구사항의 형태가 서로 맞지 않기 때문이다.
문제는 시스템 아키텍처이며 아키텍처가 특정 형태를 다른 형태보다 선호하면 할수록, 새로운 기능을 이 구조에 맞추는게 더 힘들어진다.
따라서 아키텍처는 형태에 독립적이어야 하고, 그럴수록 더 실용적이다.
더 높은 가치
소프트웨어 시스템이 동작하도록 만드는 것이 더 중요한가? 아니면 소프트웨어 시스템을 더 쉽게 변경할 수 있도록 하는 것이 더 중요한가?
업무 관리자에게 변경이 가능한 시스템을 원하는지 묻는다면, 당연히 그렇다고 답할 것이다.
물론 현재 기능의 동작 여부가 미래의 유연성보다 더 중요하다는 언급을 빼놓지 않을 것이다.
하지만 추후 업무 관리자의 변경 요청에 변경 비용이 너무 커서 현실적으로 적용할 수 없다고 대답하면, 실질적으로 변경이 불가능한 상태에 처할 때까지 시스템을 방치했다며 당신에게 화를 낼 가능성이 높다.
아이젠하워 매트릭스
“내겐 두 가지 유형의 문제가 있습니다. 하나는 긴급하며, 다른 하나는 중요합니다. 긴급한 문제는 중요하지 않으며, 중요한 문제는 절대 긴급하지 않습니다.”
아이젠하워 미국 대통령의 격언에는 엄청나게 중요한 진실이 담겨있다.
긴급한 문제가 아주 중요한 문제일 경우는 드물고, 중요한 문제가 몹시 긴급한 경우는 거의 없다는 사실이다.
소프트웨어의 첫 번째 가치인 행위는 긴급하지만 매번 높은 중요도를 가지는 것은 아니다.
소프트웨어의 두 번째 가치인 아키텍처는 중요하지만 즉각적인 긴급성을 필요로 하는 경우는 절대 없다.
최종적으로 이들 네 가지 경우에 다음과 같이 우선순위를 매길 수 있다.
1.
긴급하고 중요한
2.
긴급하지는 않지만 중요한
3.
긴급하지만 중요하지 않은
4.
긴급하지도 중요하지도 않은
아키텍처는 가장 높은 두 순위를 차지하는 반면, 행위는 첫 번째와 세 번째에 위치한다는 점을 주목하자.
긴급하지만 중요하지 않은 기능과 진짜로 긴급하면서 중요한 기능을 구분하지 못하면 시스템에서 중요도가 높은 아키텍처를 무시한 채 중요도가 떨어지는 기능을 선택하게 된다.
업무 관리자는 보통 아키텍처의 중요성을 평가할 만한 능력을 겸비하지 못하기 때문에 개발자는 딜레마에 빠진다.
소프트웨어 개발자를 고용하는 이유는 바로 이 딜레마를 해결하기 위해서다.
따라서 기능의 긴급성이 아닌 아키텍처의 중요성을 설득하는 일은 소프트웨어 개발팀이 마땅히 책임져야 한다.
아키텍처를 위해 투쟁하라
효율적인 소프트웨어 개발팀은 뻔뻔함을 무릅쓰고 다른 이해관계자들과 동등하게 논쟁한다.
소프트웨어 개발자인 당신도 이해관계자면서 소프트웨어를 안전하게 보호해야 할 책임이 있다.
당신이 소프트웨어 아키텍트라면 시스템이 제공하는 특성이나 기능보다는 시스템의 구조에 더 중점을 둬야 하므로 이러한 특성과 기능을 개발하기 쉽고, 간편하게 수정할 수 있으며, 확장하기 쉬운 아키텍처를 만들어야 한다.
아키텍처가 후순위가 되면 시스템을 개발하는 비용이 더 많이 들고, 일부 또는 전체 시스템에 변경을 가하는 일이 현실적으로 불가능해진다.
2부. 벽돌부터 시작하기: 프로그래밍 패러다임
패러다임 개요
구조적 프로그래밍
에츠허르 비버 데이크스트라는 무분별한 점프(goto 문장)는 프로그램 구조에 해롭다는 사실을 제시했다.
데이크스트라는 이러한 점프들을 if/then/else와 do/while/until과 같이 더 익숙한 구조로 대체했다.
구조적 프로그래밍은 제어흐름의 직접적인 전환에 대해 규칙을 부과한다.
객체 지향 프로그래밍
올레 요한 달과 크리스텐 니가드는 ALGOL 언어의 함수 호출 스택 프레임을 힙으로 옮기면, 함수 호출이 반환된 이후에도 함수에서 선언된 지역 변수가 오랫동안 유지될 수 있음을 발견했다.
바로 이러한 함수가 클래스의 생성자가 되었고, 지역 변수는 인스턴스 변수, 그리고 중첩 함수는 메서드가 되었다.
함수 포인터를 특정 규칙에 따라 사용하는 과정을 통해 필연적으로 다형성이 등장하게 되었다.
객체 지향 프로그래밍은 제어 흐름의 간접적인 전환에 대해 규칙을 부과한다.
함수형 프로그래밍
알론조 처치는 앨런 튜링도 똑같이 흥미를 느꼈던 어떤 수학적 문제를 해결하는 과정에서 람다 계산법을 발명했는데, 함수형 프로그래밍은 이러한 연구 결과에 직접적인 영향을 받아 만들어졌다.
람다 계산법의 기초가 되는 개념은 불변성으로 심볼의 값이 변경되지 않는다는 개념이며, 이는 함수형 언어에는 할당문이 전혀 없다는 뜻이기도 하다.
사실 대다수의 함수형 언어가 변수 값을 변경할 수 있는 방법을 제공하기는 하지만, 굉장히 까다로운 조건 아래에서만 가능하다.
함수형 프로그래밍은 할당문에 대해 규칙을 부과한다.
생각할 거리
각 패러다임은 프로그래머에게서 권한을 박탈하고 새로운 권한을 부여하지 않는다.
각 패러다임은 부정적인 의도를 가지는 일종의 추가적인 규칙을 부과한다.
즉, 패러다임은 무엇을 해야할지를 말하기보다는 무엇을 해서는 안 되는지를 말해준다.
세 가지 패러다임 각각은 우리에게서 goto문, 함수 포인터, 할당문을 앗아간다.
우리에게서 가져갈 수 있는게 더 남아있지 않기 때문에 프로그래밍 패러다임은 앞으로도 세 가지 밖에 없을 것이다.
결론
우리는 아키텍처 경계를 넘나들기 위한 메커니즘으로 다형성을 이용한다.
우리는 함수형 프로그래밍을 이용하여 데이터의 위치와 접근 방법에 대해 규칙을 부과한다.
우리는 모듈의 기반 알고리즘으로 구조적 프로그래밍을 사용한다.
세 가지 패러다임과 아키텍처의 세 가지 큰 관심사(함수, 컴포넌트 분리, 데이터 관리)가 어떻게 서로 연관되는지에 주목하자.
구조적 프로그래밍
데이크스트라는 증명이라는 수학적인 원리를 적용하여 이 문제를 해결하고자 했다.
이 연구를 진행하면서 goto 문장이 모듈을 더 작은 단위로 재귀적으로 분해하는 과정에 방해가 되는 경우가 있다는 사실을 발견했다.
반면 goto 문장을 사용하더라도 모듈을 분해할 때 문제가 되지 않는 좋은 사용 방식은 if/then/else와 do/while과 같은 분기와 반복이라는 단순한 제어 구조에 해당한다는 사실을 발견했다.
그는 모듈이 이러한 종류의 제어 구조만을 사용한다면 증명 가능한 단위로까지 모듈을 재귀적으로 세분화하는 것이 가능하고 순차 실행과 결합했을 때 특별하는 것을 깨달았다.
뵘과 야코피니는 모든 프로그램을 순차, 분기, 반복이라는 세 가지 구조만으로 표현할 수 있다는 사실을 증명했다.
즉, 모듈을 증명 가능하게 하는 바로 그 제어 구조가 모든 프로그램을 만들 수 있는 제어 구조의 최소 집합과 동일하다는 사실이었다.
구조적 프로그래밍은 이렇게 탄생했다.
기능적 분해
구조적 프로그래밍을 통해 모듈을 증명 가능한 더 작은 단위로 재귀적으로 분해할 수 있게 되었고, 이는 결국 모듈을 기능적으로 분해할 수 있음을 뜻했다.
즉, 거대한 문제 기술서를 받더라도 문제를 고수준의 기능들로 분해할 수 있고, 이들 각 기능은 다시 저수준의 함수들로 분해할 수 있고, 이러한 분해 과정을 끝없이 반복할 수 있다.
게다가 이렇게 분해한 기능들은 구조적 프로그래밍의 제한된 제어 구조를 이용하여 표현할 수 있다.
엄밀한 증명은 없었다
수학적 방법으로 증명은 이루어지지 않았지만 상당히 성공한 또 다른 전략으로 과학적 방법이 존재했다.
과학은 서술된 내용이 사실임을 증명하는 방식이 아니라 서술이 틀렸음을 증명하는 방식으로 동작한다.
각고의 노력으로도 반례를 들 수 없는 서술이 있다면 목표에 부합할 만큼은 참이라고 본다.
결론적으로 수학은 증명 가능한 서술이 참임을 입증하는 원리라고 볼 수 있다.
반면 과학은 증명 가능한 서술이 거짓임을 입증하는 원리라고 볼 수 있다.
테스트
“테스트는 버그가 있음을 보여줄 뿐, 버그가 없음을 보여줄 수는 없다.”
다시 말해 프로그램이 잘못되었음을 테스트를 통해 증명할 수는 있지만, 프로그램이 맞다고 증명할 수는 없다.
구조적 프로그래밍은 프로그램을 증명 가능한 세부 기능 집합으로 재귀적으로 분해할 것을 강요한다.
그러고 나서 테스트를 통해 증명 가능한 세부 기능들이 거짓인지를 증명하려고 시도한다.
이처럼 거짓임을 증명하려는 테스트가 실패한다면, 이 기능들은 목표에 부합할 만큼은 충분히 참이라고 여기게 된다.
결론
구조적 프로그래밍이 오늘날까지 가치 있는 이유는 프로그래밍에서 반증 가능한 단위를 만들어 낼 수 있는 바로 이 능력 때문이다.
가장 작은 기능에서부터 가장 큰 컴포넌트에 이르기까지 모든 수준에서 소프트웨어는 과학과 같고, 따라서 반증 가능성에 의해 주도된다.
소프트웨어 아키텍트는 모듈, 컴포넌트, 서비스가 쉽게 반증 가능하도록(테스트하기 쉽도록) 만들기 위해 분주히 노력해야 한다.
객체 지향 프로그래밍
좋은 아키텍처를 만드는 일은 객체 지향 설계 원칙을 이해하고 응용하는 데서 출발한다.
객체 지향의 본질을 설명하기 위한 세 가지 개념인 캡슐화, 상속, 다형성에 기대는 부류도 있다.
이들은 객체 지향이 이 세 가지 개념을 적절하게 조합한 것이거나 또는 최소한 세 가지 요소를 반드시 지원해야 한다고 말한다.
캡슐화?
객체 지향은 데이터와 함수를 쉽고 효과적으로 캡슐화하는 방법을 제공한다.
이를 통해 데이터와 함수가 응집력 있게 구성된 집단을 서로 구분 짓는 선을 긋고, 구분선 바깥에서 데이터는 은닉되며 일부 함수만이 외부에 노출된다.
이 개념들이 실제 객체 지향 언어에서는 각각 클래스의 private 멤버 데이터와 public 멤버 함수로 표현된다.
이전에도 C언어에서는 먼저 데이터 구조와 함수를 헤더 파일에 선언하고 구현 파일에서 이들을 구현함으로써 완벽한 캡슐화를 만들어냈다.
자바와 C#은 헤더와 구현체를 분리하는 방식을 모두 버렸기 때문에 객체 지향이 강력한 캡슐화에 의존한다는 정의는 받아들이기 힘들다.
객체 지향 프로그래밍은 프로그래머가 충분히 올바르게 행동함으로써 캡슐화된 데이터를 우회해서 사용하지 않을 거라는 믿음을 기반으로 한다.
상속?
객체 지향 언어가 더 나은 캡슐화를 제공하지는 못했지만, 상속만큼은 객체 지향 언어가 확실히 제공했다.
하지만 상속이란 단순히 어떤 변수와 함수를 하나의 유효 범위로 묶어서 재정의하는 일에 불과하다.
객체 지향 언어가 있기 훨씬 이전에도 C언어는 타입 캐스팅을 통해 상속을 흉내내는 요령이 존재했다.
따라서 객체 지향 언어가 완전히 새로운 개념을 만들지는 못했지만, 데이터 구조에 가면을 씌우는 일을 상당히 편리한 방식으로 제공했다고 볼 수는 있다.
다형성?
1940년대 후반 폰 노이만 아키텍처가 처음 구현된 이후 프로그래머는 다형적 행위를 수행하기 위해 함수를 가리키는 포인터를 사용해왔다.
함수에 대한 포인터를 직접 사용하여 다형적 행위를 만드는 이 방식은 포인터를 초기화하는 관례를 준수해야 하기 때문에 위험하다.
객체 지향 언어는 이러한 관례를 없애주기 때문에 다형성을 좀 더 안전하고 더욱 편리하게 사용할 수 있게 해준다.
이러한 이유로 객체 지향은 제어 흐름을 간접적으로 전환하는 규칙을 부과한다고 결론지을 수 있다.
다형성이 가진 힘
새로운 장비에서도 복사 프로그램이 동작하도록 만들려면 어떻게 수정해야 하는가?
입출력 드라이버가 FILE에 정의된 다섯 가지 표준 함수를 구현한다면, 복사 프로그램에서는 이 입출력 드라이버를 얼마든지 사용할 수 있다.
다시 말해 입출력 드라이버가 복사 프로그램의 플러그인이 된 것이다.
플러그인 아키텍처는 이처럼 입출력 장치 독립성을 지원하기 위해 만들어졌고, 등장 이후 거의 모든 운영체제에서 구현되었다.
의존성 역전
다형성을 안전하고 편리하게 적용할 수 있는 메커니즘이 등장하기 전 소프트웨어는 main 함수가 고수준 함수를 호출하고, 고수준 함수는 다시 저수준 함수를 호출하는 전형적인 호출 트리를 가졌다.
이러한 호출 트리에서 제어 흐름은 시스템의 행위에 따라 결정되며, 소스 코드 의존성은 제어 흐름에 따라 결정된다.
이러한 소스 코드 의존성은 소스 코드 사이에 인터페이스를 추가함으로써 방향을 역전시킬 수 있다.
객체 지향 언어가 다형성을 안전하고 편리하게 제공한다는 사실은 소스 코드 의존성을 어디에서든 역전시킬 수 있는 절대적인 권한을 갖는다는 것이다.
결과적으로 분리된 컴포넌트 또는 배포 가능한 단위로 컴파일할 수 있고, 이 배포 단위들의 의존성 역시 소스 코드 사이의 의존성과 같다.
즉, 이들 컴포넌트를 독립적으로 개발할 수 있으며, 독립적으로 배포할 수 있다.
함수형 프로그래밍
1930년대 알론조 처치가 발명한 함수형 프로그래밍은 람다 계산법이 핵심이다.
불변성과 아키텍처
객체 지향 언어인 자바에서는 가변 변수를 사용하는데, 함수형 언어에서의 변수는 변경되지 않는다.
동시성 어플리케이션에서 다수의 스레드와 프로세스를 사용할 때 마주치는 race condition, deadlock, concurrent update 문제들은 가변 변수가 없다면 절대로 생기지 않는다.
하지만 불변성은 저장 공간이 무한하고 프로세서의 속도가 무한히 빠르다고 전제할 때만 실현 가능하다.
가변성의 분리
가변성과 불변성의 주요한 타협 중 하나는 어플리케이션 또는 어플리케이션 내부의 서비스를 가변 컴포넌트와 불변 컴포넌트로 분리하는 일이다.
불변 컴포넌트에서는 순수하게 함수형 방식으로만 작업이 처리되며 어떤 가변 변수도 사용되지 않고, 변수의 상태를 변경할 수 있는 하나 이상의 다른 컴포넌트와 서로 통신한다.
상태 변경은 컴포넌트를 갖가지 동시성 문제에 노출하는 꼴이므로, 흔히 트랜잭션 메모리와 같은 실천법을 사용하여 여러 문제들로부터 가변 변수를 보호한다.
현명한 아키텍트라면 가능한 한 많은 처리를 불변 컴포넌트로 옮겨야 하고, 가변 컴포넌트에서는 가능한 한 많은 코드를 빼내야 한다.
이벤트 소싱
이벤트 소싱은 상태가 아닌 트랜잭션을 저장하자는 전략이다.
상태가 필요해지면 단순히 상태의 시작점부터 모든 트랜잭션을 처리한다.
데이터 저장소에서 삭제되거나 변경되는 것이 하나도 없기 때문에 저장 공간과 처리 능력이 충분하면 어플리케이션이 완전한 불변성을 갖도록 만들 수 있고, 따라서 완전한 함수형으로 만들 수 있다.
소스 코드 버전 관리 시스템이 정확히 이 방식으로 동작한다.
결론
•
구조적 프로그래밍은 제어흐름의 직접적인 전환에 부과되는 규율이다.
•
객체 지향 프로그래밍은 제어 흐름의 간접적인 전환에 부과되는 규율이다.
•
함수형 프로그래밍은 변수 할당에 부과되는 규율이다.
이들 세 패러다임은 모두 우리에게서 무언가를 앗아갔다.
각 패러다임은 우리가 코드를 작성하는 방식의 형태를 한정시킨다.
어떤 패러다임도 우리의 권한이나 능력에 무언가를 보태지는 않는다.
소프트웨어, 즉 컴퓨터 프로그램은 순차, 분기, 반복, 참조로 구성되며 그 이상도 이하도 아니다.
3부. 설계 원칙
좋은 벽돌(코드)을 사용하지 않으면 빌딩의 구조(아키텍처)가 좋고 나쁨은 그리 큰 의미가 없는 것과 같다.
반대로 좋은 벽돌(코드)을 사용하더라도 빌딩의 구조(아키텍처)를 엉망으로 만들 수 있다.
그래서 좋은 벽돌(코드)로 좋은 구조(아키텍처)를 정의하는 원칙이 필요한데, 그게 바로 SOLID다.
SOLID 원칙은 함수와 데이터 구조를 클래스로 배치하는 방법, 그리고 이들 클래스를 서로 결합하는 방법을 설명해준다.
여기에서 클래스는 단순히 함수와 데이터를 결합한 집합을 가리키므로 객체 지향 소프트웨어에만 적용된다는 뜻은 아니다.
SOLID 원칙의 목적은 모듈 수준의 소프트웨어 구조가 아래와 같도록 만드는데 있다.
•
변경에 유연하다.
•
이해하기 쉽다.
•
많은 소프트웨어 시스템에 사용될 수 있는 컴포넌트의 기반이 된다.
SRP: 단일 책임 원칙
SRP 원칙은 하나의 모듈이 변경돼야 하는 이유는 오직 하나의 액터여야 하며 이 액터에 대해서만 책임져야 한다는 것이다.
모듈이란 소스 파일 혹은 단순히 함수와 데이터 구조로 구성된 응집된 집합이다.
액터란 시스템이 동일한 방식으로 변경되기를 원하는 사용자나 이해관계자를 가리킨다.
단일 액터를 책임지는 코드를 함께 묶어주는 힘이 바로 응집성이다.
징후 1: 우발적 중복
급여 어플리케이션의 Employee 클래스는 calculatePay(), reportHours(), save() 메서드를 가진다.
•
calculatePay() 메서드는 회계팀에서 기능을 정의하며, CFO 보고를 위해 사용된다.
•
reportHours() 메서드는 인사팀에서 기능을 정의하고 사용하며, COO 보고를 위해 사용한다.
•
save() 메서드는 데이터베이스 관리자가 기능을 정의하고, CTO 보고를 위해 사용한다.
예를 들어 calculatePay() 메서드와 reportHours() 메서드가 초과 근무를 제외한 업무 시간을 계산하는 알고리즘을 공유한다고 해보자.
이제 CFO 팀에서 업무 시간을 계산하는 방식을 약간 수정한다면 COO 팀에서는 엉터리 수치들을 포함한 보고서를 받아볼 것이다.
이러한 문제는 서로 다른 액터가 의존하는 코드를 너무 가까이 배치했기 때문에 발생한다.
징후 2: 병합
CTO 팀에서 데이터베이스의 Employee 테이블 스키마를 약간 수정하기로 결정하고, COO 팀에서는 reportHours() 메서드의 보고서 포맷을 변경하기로 결정했다고 해보자.
두 명의 서로 다른 개발자가 Employee 클래스에 변경사항을 적용하기 시작하고 이들 변경사항은 서로 충돌한다.
이 문제를 벗어나는 방법은 서로 다른 액터를 뒷받침하는 코드를 서로 분리하는 것이다.
해결책
아마도 가장 확실한 해결책은 데이터와 메서드를 분리하는 방식일 것이다.
아무런 메서드가 없는 간단한 데이터 구조인 EmployeeData 클래스를 만들어, 세 개의 클래스가 공유하도록 한다.
각 클래스는 자신의 메서드에 반드시 필요한 소스 코드만을 포함하며 서로의 존재를 모르기 때문에 우연한 중복을 피할 수 있다.
반면 이 해결책은 개발자가 세 가지 클래스를 인스턴스화하고 추적해야 한다는게 단점이다.
이러한 난관에서 빠져나올 때 쓰는 기법으로 Facade 패턴이 있다.
EmployeeFacade 클래스는 세 클래스 객체를 생성하고, 요청된 메서드를 가지는 객체로 위임하는 일을 책임진다.
이처럼 여러 메서드가 하나의 가족을 이루고, 메서드의 가족을 포함하는 각 클래스는 하나의 유효범위가 된다.
해당 유효범위 바깥에서는 이 가족에게 감춰진 식구(private 멤버)가 있는지를 전혀 알 수 없다.
결론
단일 책임 원칙은 메서드와 클래스 수준의 원칙이다.
하지만 이보다 상위의 두 수준에서도 다른 형태로 다시 등장한다.
컴포넌트 수준에서는 공통 폐쇄 원칙이 된다.
아키텍처 수준에서는 아키텍처 경계의 생성을 책임지는 변경의 축이 된다.
OCP: 개방-폐쇄 원칙
OCP 원칙은 소프트웨어 개체가 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다는 것이다.
다시 말해 소프트웨어 개체의 행위는 확장할 수 있어야 하지만, 이때 개체를 변경해서는 안 된다.
재무제표를 웹 페이지로 보여주는 시스템이 있다고 생각해보자.
이제 이해관계자가 동일한 정보를 보고서 형태로 변환해서 흑백 프린터로 출력해 달라고 요청했다고 해보자.
당연히 새로운 코드를 작성해야 하며 원래 코드는 얼마나 많이 수정해야 할까?
서로 다른 목적으로 변경되는 요소를 적절하게 분리하고(SRP), 이들 요소 사이의 의존성을 체계화함으로써(DIP) 변경량을 최소화할 수 있다.
재무 데이터를 검사한 후 보고서용 데이터를 생성한 다음, 필요에 따라 두 가지 보고서 생성 절차 중 하나를 거쳐 적절히 포매팅한다.
이처럼 책임을 분리했다면, 두 책임 중 하나에서 변경이 발생하더라도 다른 하나는 변경되지 않도록 소스 코드 의존성도 확실히 조직화해야 한다.
이러한 목적을 달성하려면 처리 과정을 클래스 단위로 분할하고, 이들 클래스를 컴포넌트 단위로 구분해야 한다.
<I>로 표시된 클래스는 인터페이스이며, <DS>로 표시된 클래스는 데이터 구조다.
화살표가 열려 있다면 사용 관계이며, 닫혀 있다면 구현 관계 또는 상속 관계다.
화살표가 A 클래스에서 B 클래스로 향한다면, A 클래스에서는 B 클래스를 호출하지만 B 클래스에서는 A 클래스를 전혀 호출하지 않으며 아무것도 알지 못한다.
화살표는 변경으로부터 보호하려는 컴포넌트를 향하도록 그려진다.
계층구조
보호의 계층구조가 수준이라는 개념을 바탕으로 어떻게 생성되는지 주목하자.
Interactor는 가장 높은 수준의 개념이며, 따라서 최고의 보호를 받는다.
View는 가장 낮은 수준의 개념 중 하나이며, 따라서 거의 보호를 받지 못한다.
Presenter는 View보다는 높고 Controller나 Interactor보다는 낮은 수준에 위치한다.
아키텍트는 기능이 어떻게, 왜, 언제 발생하는지에 따라서 기능을 분리하고, 분리한 기능을 컴포넌트의 계층구조로 조직화한다.
컴포넌트 계층구조를 이와 같이 조직화하면 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있다.
방향성 제어
FinancialDataGateway 인터페이스는 FinancialReportGenerator와 FinancialDataMapper 사이에 위치하는데, 이는 의존성을 역전시키기 위해서다.
FinancialDataGateway 인터페이스가 없었다면, 의존성이 Interactor 컴포넌트에서 Database 컴포넌트로 바로 향하게 된다.
정보 은닉
FinancialReportRequester 인터페이스는 방향성 제어와는 다른 목적을 가진다.
이 인터페이스는 FinancialReportController가 Interactor 내부에 대해 알지 못하도록 막기 위해 존재한다.
이 인터페이스가 없었다면 Controller는 FinancialEntities에 대해 추이 종속성을 가지게 된다.
추이 종속성을 가지게 되면 소프트웨어 엔티티는 ‘자신이 직접 사용하지 않는 요소에는 절대로 의존해서는 안 된다’는 소프트웨어 원칙을 위반하게 된다.
다시 말해서, Controller에서 발생한 변경으로부터 Interactor를 보호하면서 Interactor에서 발생한 변경으로부터 Controller도 보호되기를 바라기 때문에 Interactor 내부를 은닉한다.
결론
OCP의 목표는 시스템을 확장하기 쉬운 동시에 변경으로 인해 시스템이 너무 많은 영향을 받지 않도록 하는데 있다.
이러한 목표를 달성하려면 시스템을 컴포넌트 단위로 분리하고, 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있는 형태의 의존성 계층구조가 만들어지도록 해야 한다.
LSP: 리스코프 치환 원칙
LSP 원칙은 S 타입의 객체 o1 각각에 대응하는 T 타입 객체 o2가 있고, T 타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위 타입이라는 것이다.
Billing 어플리케이션에서 호출하는 calcFee() 메서드를 가진 License라는 클래스가 있다고 해보자.
License에는 PersonalLicense와 BusinessLicense라는 두 가지 하위 타입이 존재한다.
이 설계는 LSP를 준수하는데, Billing 어플리케이션의 행위가 License 하위 타입 중 무엇을 사용하는지에 전혀 의존하지 않기 때문이다.
이들 하위 타입은 모두 License 타입을 치환할 수 있다.
LSP를 위반하는 전형적인 문제로는 정사각형/직사각형 문제가 있다.
Square는 Rectangle의 하위 타입으로는 적합하지 않은데, Rectangle의 높이와 너비는 서로 독립적으로 변경될 수 있는 반면, Square의 높이와 너비는 반드시 함께 변경되기 때문이다.
이런 형태의 LSP 위반을 막기 위한 방법은 Rectangle이 실제로는 Square인지를 검사하는 메커니즘을 추가하는 것이다.
하지만 이렇게 하면 행위가 사용하는 타입에 의존하게 되므로 결국 타입을 서로 치환할 수 없게 된다.
LSP와 아키텍처
객체 지향이 혁명처럼 등장한 초창기에 LSP는 상속을 사용하도록 가이드하는 방법 정도로 간주되었다.
하지만 시간이 지나면서 LSP는 인터페이스와 구현체에도 적용되는 더 광범위한 소프트웨어 설계 원칙으로 변모해 왔다.
다양한 택시 파견 서비스를 통합하는 어플리케이션을 만들고 있다고 해보자.
고객이 이용할 택시를 찾고 결정하면, 시스템은 REST 서비스를 통해 선택된 택시를 고객 위치로 파견한다.
택시 파견 REST 서비스의 URI가 운전기사 데이터베이스에 저장되어 있다고 가정해 보자.
기사가 선택되면 해당 기사의 레코드로부터 URI 정보를 얻은 다음, 그 URI 정보에 필요한 정보를 덧붙인 후 PUT 방식으로 호출한다.
다양한 택시업체에서 동일한 REST 인터페이스를 반드시 준수하도록 만들어야 하지만 일부 택시업체가 인터페이스를 준수하지 않는 경우에 우리 회사의 시스템 아키텍처에는 무슨 일이 벌어질까?
뻔한 일이지만, 우리는 이 예외 사항을 처리하는 로직을 추가해야 할 것이다.
가장 간단한 방법은 파견 명령어를 구성하는 모듈에 if문과 특정 택시 업체를 의미하는 단어를 통해 분기처리를 하는 것이다.
하지만 이런 방법은 온갖 종류의 에러가 발생할 여지와 보안 침해를 만들게 된다.
아키텍트는 이 같은 버그로부터 시스템을 격리해야 한다.
REST 서비스들의 인터페이스가 서로 치환 가능하지 않다는 사실을 처리하기 위해 파견 URI를 키로 사용하는 설정용 데이터베이스를 이용하는 파견 명령 생성 모듈을 만들어야 할 수도 있다.
결론
LSP는 아키텍처 수준까지 확장할 수 있고, 반드시 확장해야만 한다.
치환 가능성을 조금이라도 위배하면 시스템 아키텍처가 오염되어 상당량의 별도 메커니즘을 추가해야 할 수 있기 때문이다.
ISP: 인터페이스 분리 원칙
다수의 사용자가 OPS 클래스의 오퍼레이션을 사용한다.
User1은 오직 op1을, User2는 op2만을, User3는 op3만을 사용한다고 가정해보자.
그리고 OPS가 정적 타입 언어로 작성된 클래스라고 해보자.
이 경우 User1에서는 op2와 op3를 전혀 사용하지 않음에도 User1의 소스 코드는 이 두 메서드에 의존하게 된다.
이러한 의존성으로 인해 OPS 클래스에서 op2의 소스 코드가 변경되면 User1도 다시 컴파일한 후 새로 배포해야 한다.
이러한 문제는 오퍼레이션을 인터페이스 단위로 분리하여 해결할 수 있다.
이번에도 마찬가지로 정적 타입 언어로 이 다이어그램을 구현했다고 가정하면, User1의 소스 코드는 U10ps와 op1에는 의존하지만 OPS에는 의존하지 않게 된다.
따라서 OPS에서 발생한 변경이 User1과는 전혀 관계없는 변경이라면, User1을 다시 컴파일하고 새로 배포하는 상황은 초래되지 않는다.
ISP와 언어
정적 타입 언어는 사용자가 import, user 또는 include와 같은 타입 선언문을 사용하도록 강제한다.
이처럼 소스 코드에 포함된 선언문으로 인해 소스 코드 의존성이 발생하고, 이로 인해 재컴파일 또는 재배포가 강제되는 상황이 무조건 초래된다.
동적 타입 언어에서는 소스 코드에 이러한 선언문이 존재하지 않으며 대신 런타임에 추론이 발생한다.
따라서 소스 코드 의존성이 아예 없으며, 결국 재컴파일과 재배포가 필요 없다.
동적 타입 언어를 사용하면 정적 타입 언어를 사용할 때보다 유연하며 결합도가 낮은 시스템을 만들 수 있는 이유는 바로 이 때문이다.
자바는 정적 타입 언어지만 왜 영향받는 정도가 다를까?
자바는 비-final, 비-private 인스턴스 변수에 대해서는 호출할 정확한 메서드를 런타임에 결정하는 늦은 바인딩을 수행한다.
컴파일타임에는 호환되는 시그니처의 메서드가 타입 계층구조 어딘가에 존재하는지까지만 확인할 뿐이다.
따라서 시그니처는 그대로인 채 구현 코드만 변경됐다면 다시 컴파일할 필요가 없다.
ISP와 아키텍처
일반적으로 필요 이상으로 많은걸 포함하는 모듈에 의존하는 것은 해로운 일이다.
예를 들어 S 시스템을 구축할 때 F 프레임워크를 시스템에 도입하기를 원하고 F 프레임워크는 D 데이터베이스를 반드시 사용하도록 만들어졌다고 가정해보자.
따라서 S는 F에 의존하며, 다시 D에 의존하게 된다.
F에 S와는 전혀 관계없는 불필요한 기능이 포함된다면 그 기능 때문에 D 내부가 변경 됐을 때 F를 재배포해야 할 수도 있고, 따라서 S까지 재배포해야 할지 모른다.
더 심각한 문제는 D 내부의 기능 중 F와 S에서 불필요한 기능에 문제가 발생해도 F와 S에 영향을 준다는 사실이다.
결론
불필요한 짐을 실은 무언가에 의존하면 예상치도 못한 문제에 빠진다.
DIP: 의존성 역전 원칙
DIP 원칙에서 말하는 ‘유연성이 극대화된 시스템’이란 소스 코드 의존성이 추상에 의존하며 구체에는 의존하지 않는 시스템이다.
이 아이디어를 규칙으로 보기는 소프트웨어 시스템이라면 구체적인 많은 장치에 반드시 의존하기 때문에 확실히 비현실적이다.
예를 들어 자바에서 String은 구체 클래스이며, 이를 애써 추상 클래스로 만들려는 시도는 현실성이 없다.
반면 String 클래스는 변경되는 일이 거의 없으며 있더라도 엄격하게 통제되기 때문에 매우 안정적이다.
이러한 이유로 DIP를 논할 때 운영체제나 플랫폼 같이 안정성이 보장된 환경에 대해서는 무시하는 편이다.
우리가 의존하지 않도록 피하고자 하는 것은 바로 변동성이 큰 구체적인 요소다.
그리고 이 구체적인 요소는 우리가 열심히 개발하는 중이라 자주 변경될 수 밖에 없는 모듈들이다.
안정된 추상화
추상 인터페이스에 변경이 생기면 이를 구체화한 구현체들도 따라서 수정해야 한다.
반대로 구체적인 구현체에 변경이 생기더라도 그 구현체가 구현하는 인터페이스는 대다수의 경우 변경될 필요가 없다.
인터페이스를 변경하지 않고도 구현체에 기능을 추가할 수 있는 방법을 찾기 위해 노력해야 하며 이는 소프트웨어 설계의 기본이다.
즉, 안정된 소프트웨어 아키텍처란 변동성이 큰 구현체에 의존하는 일은 지양하고, 안정된 추상 인터페이스를 선호하는 아키텍처라는 뜻이다.
•
변동성이 큰 구체 클래스를 참조하지 말라
대신 추상 인터페이스를 참조하라.
이 규칙은 객체 생성 방식을 강하게 제약하며, 일반적으로 추상 팩토리를 사용하도록 강제한다.
•
변동성이 큰 구체 클래스로부터 파생하지 말라
정적 타입 언어에서 상속은 소스 코드에 존재하는 모든 관계 중에서 가장 강력한 동시에 뻣뻣해서 변경하기 어렵다.
따라서 상속은 아주 신중하게 사용해야 한다.
•
구체 함수를 오버라이드 하지 말라
대체로 구체 함수는 소스 코드 의존성을 필요로 한다.
따라서 구체 함수를 오버라이드 하면 이러한 의존성을 제거할 수 없게 되며, 실제로는 그 의존성을 상속하게 된다.
차라리 추상 함수로 선언하고 구현체들에서 각자의 용도에 맞게 구현해야 한다.
•
구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라
사실 이 실천법은 DIP 원칙을 다른 방식으로 풀어쓴 것이다.
팩토리
사실상 모든 언어에서 객체를 생성하려면 해당 객체를 구체적으로 정의한 코드에 대해 소스 코드 의존성이 발생한다.
대다수의 객체 지향 언어에서 이처럼 바람직하지 못한 의존성을 처리할 때 추상 팩토리를 사용하곤 한다.
Application은 Service 인터페이스를 통해 ConcreteImpl의 인스턴스를 사용하지만, Application에서는 어떤 식으로든 ConcreteImpl의 인스턴스를 생성해야 한다.
ConcreteImpl에 대해 소스 코드 의존성을 만들지 않으면서 이 목적을 이루기 위해 Application은 ServiceFactory 인터페이스의 makeSvc 메서드를 호출한다.
이 메서드는 ServiceFactory로부터 파생된 ServiceFactoryImpl에서 구현된다.
그리고 ServiceFactoryImpl 구현체가 ConcreteImpl의 인스턴스를 생성한 후 Service 타입으로 반환한다.
곡선은 아키텍처 경계를 뜻하며 구체적인 것들로부터 추상적인 것들을 분리한다.
소스 코드 의존성은 해당 곡선과 교차할 때 모두 한 방향, 즉 추상적인 쪽으로 향한다.
곡선은 시스템을 두 가지 컴포넌트로 분리한다.
하나는 추상 컴포넌트이며, 다른 하나는 구체 컴포넌트다.
추상 컴포넌트는 어플리케이션의 모든 고수준 업무 규칙을 포함한다.
구체 컴포넌트는 업무 규칙을 다루기 위해 필요한 모든 세부사항을 포함한다.
제어흐름은 소스 코드 의존성과는 정반대 방향으로 곡선을 가로지르며 역전된다.
이러한 이유로 이 원칙을 의존성 역전이라고 부른다.
구체 컴포넌트
ServiceFactoryImpl 구체 클래스가 ConcreteImpl 구체 클래스에 의존하고, 따라서 DIP에 위배된다.
DIP 위배를 모두 없앨 수는 없지만 DIP를 위배하는 클래스들은 적은 수의 구체 컴포넌트 내부로 모을 수 있고, 이를 통해 시스템의 나머지 부분과는 분리할 수 있다.
대다수의 시스템은 이러한 구체 컴포넌트를 최소한 하나는 포함할 것이고 흔히 이 컴포넌트를 메인이라고 부른다.
결론
고수준의 아키텍처 원칙을 다루게 되면서 DIP는 몇 번이고 계속 등장할 것이다.
그리고 DIP는 아키텍처 다이어그램에서 가장 눈에 드러나는 원칙이 될 것이다.
곡선은 이후의 장에서는 아키텍처 경계가 될 것이고 의존성은 이 곡선을 경계로 더 추상적인 엔티티가 있는 쪽으로만 향한다.
추후 이 규칙은 의존성 규칙이라 부를 것이다.
4부. 컴포넌트 원칙
컴포넌트
컴포넌트는 시스템의 구성 요소로 배포할 수 있는 가장 작은 단위다.
여러 컴포넌트를 서로 링크하여 실행 가능한 단일 파일로 생성할 수 있고 여러 컴포넌트를 서로 묶어서 단일 아카이브로 만들 수도 있다.
또는 컴포넌트 각각을 동적으로 로드할 수 있는 플러그인이나 .exe 파일로 만들어서 독립적으로 배포할 수도 있다.
컴포넌트가 마지막에 어떤 형태로 배포되든, 잘 설계된 컴포넌트라면 반드시 독립적으로 배포 가능한, 따라서 독립적으로 개발 가능한 능력을 갖춰야 한다.
컴포넌트의 간략한 역사
요즘의 프로그래머는 프로그램을 메모리의 어느 위치에 로드할지 고민할 필요가 거의 없다.
하지만 프로그래밍 초창기에는 프로그램을 로드할 메모리의 위치를 정하는 일이 프로그래머가 가장 먼저 결정해야 하는 사항 중 하나였다.
이 시절에는 프로그램의 위치가 한번 결정되면, 재배치가 불가능했다.
이러한 구시대에는 라이브러리는 바이너리가 아니라 소스 코드의 형태로 어플리케이션 코드에 직접 포함시켜 단일 프로그램으로 컴파일했다.
이 시대의 장치는 느리고 메모리는 너무 비싸서 자원이 한정적이었기에, 이러한 접근법은 문제가 있었다.
컴파일러는 느린 장치를 이용해서 소스 코드를 여러 차례 읽어야만 했기에 라이브러리가 크면 클수록 컴파일은 오래 걸렸다.
컴파일 시간을 단축시키기 위해 라이브러리를 개별적으로 컴파일하고, 컴파일된 바이너리를 메모리의 특정 위치에 로드했다.
라이브러리에 대한 심벌 테이블을 생성한 후, 이를 이용해 어플리케이션 코드를 컴파일했다.
그리고 어플리케이션을 실행해야 한다면 바이너리 라이브러리를 로드한 다음 어플리케이션을 로드했다.
하지만 어플리케이션은 점점 커졌고 결국 할당된 공간을 넘어서게 되었다.
어플리케이션을 두 개의 주소 세그먼트로 분리하여 라이브러리 공간을 사이에 두고 오가며 동작하게 배치해야 했다.
프로그램과 라이브러리가 사용하는 메모리가 늘어날수록 이와 같은 단편화는 계속될 수 밖에 없었다.
재배치성
해결책은 재배치가 가능한 바이너리였다.
지능적인 로더를 사용해서 메모리에 재배치할 수 있는 형태의 바이너리를 생성하도록 컴파일러를 수정하자는 것이었다.
로더는 여러 개의 바이너리를 입력받은 후, 단순히 하나씩 차례로 메모리로 로드하면서 재배치하는 작업을 처리했다.
또한 컴파일러는 재배치 가능한 바이너리 안의 함수 이름을 메타데이터 형태로 생성하도록 수정되었다.
만약 프로그램이 라이브러리 함수를 호출한다면 컴파일러는 라이브러리 함수 이름을 외부 참조로 생성했다.
반면 라이브러리 함수를 정의하는 프로그램이라면 컴파일러는 해당 이름을 외부 정의로 생성했다.
이렇게 함으로써 외부 정의를 로드할 위치가 정해지기만 하면 로더가 외부 참조를 외부 정의에 링크시킬 수 있게 된다.
이렇게 링킹 로더가 탄생했다.
링커
링킹 로더의 등장으로 프로그래머는 프로그램을 개별적으로 컴파일하고 로드할 수 있는 단위로 분할할 수 있게 되었다.
1970년대가 되었고 훨씬 커진 프로그램으로 인해 링킹 로더가 너무 느려졌고 로드와 링크가 두 단계로 분리되었다.
링커라는 별도의 어플리케이션으로 링크가 완료된 재배치 코드를 만들어 주었고, 그 덕분에 로더의 로딩 과정이 아주 빨라졌다.
비록 느린 링커를 사용해서 실행 파일을 만들었지만, 한번 만들어둔 실행 파일은 언제라도 빠르게 로드할 수 있게 되었다.
1980년대가 되었고 프로그램 코드가 수십만 라인을 넘어서는게 별일도 아니게 되었다.
각 모듈을 컴파일하는 과정은 상대적으로 빨랐지만, 전체 모듈을 컴파일하는 일은 꽤 시간이 걸렸다.
이후에 링커에서는 더 많은 시간이 소요되어 결국 전체 소요 시간은 또 다시 늘어났다.
1990년대 후반 디스크는 작아지기 시작했고, 놀랄 만큼 빨라졌으며 프로그램을 성장시키는 속도보다 링크 시간이 줄어드는 속도가 더 빨라지기 시작했다.
컴퓨터와 장치가 빨라져서 또다시 로드와 링크를 동시에 할 수 있게 되었다.
다수의 .jar 파일 또는 다수의 공유 라이브러리를 순식간에 서로 링크한 후, 링크가 끝난 프로그램을 실행할 수 있게 되었다.
이렇게 컴포넌트 플러그인 아키텍처가 탄생했다.
컴포넌트 응집도
REP: 재사용/릴리즈 등가 원칙
소프트웨어 컴포넌트가 릴리즈 절차를 통해 추적 관리되지 않거나 릴리즈 번호가 부여되지 않는다면 해당 컴포넌트를 재사용하고 싶어도 할 수도 없고, 하지도 않을 것이다.
릴리즈 절차에는 적절한 공지와 함께 릴리즈 문서 작성도 포함되어야 개발자가 충분한 정보를 바탕으로 새 릴리즈를 통합할지, 한다면 언제 할지를 결정할 수 있다.
이 원칙을 소프트웨어 설계와 아키텍처 관점에서 보면 단일 컴포넌트는 응집성 높은 클래스와 모듈들로 구성되어야 함을 뜻한다.
컴포넌트를 구성하는 모든 모듈은 서로 공유하는 중요한 테마나 목적이 있어야 하며 하나의 컴포넌트로 묶인 클래스와 모듈은 반드시 함께 릴리즈할 수 있어야 한다.
CCP: 공통 폐쇄 원칙
동일한 이유로 동일한 시점에 변경되는 클래스를 같은 컴포넌트로 묶어라.
서로 다른 시점에 다른 이유로 변경되는 클래스는 다른 컴포넌트로 분리하라.
이 원칙은 SRP 원칙을 컴포넌트 관점에서 다시 쓴 것으로 단일 컴포넌트는 변경의 이유가 여러 개 있어서는 안 된다는 것이다.
대다수의 어플리케이션에서 유지보수성은 재사용성보다 훨씬 중요하다.
어플리케이션에서 코드가 반드시 변경되어야 한다면, 이러한 변경이 여러 컴포넌트 도처에 분산되어 발생하기보다는, 차라리 변경 모두가 단일 컴포넌트에서 발생하는 편이 낫다.
만약 변경을 단일 컴포넌트로 제한할 수 있다면, 해당 컴포넌트만 재배포하면 된다.
CRP: 공통 재사용 원칙
CRP 원칙에서는 같이 재사용되는 경향이 있는 클래스와 모듈들은 같은 컴포넌트에 포함해야 한다고 말한다.
개별 클래스가 단독으로 재사용되는 경우는 거의 없다.
대체로 재사용 가능한 클래스는 재사용 모듈의 일부로써 해당 모듈의 다른 클래스와 상호작용하는 경우가 많기 때문에 동일한 컴포넌트에 포함되어야 한다.
CRP 원칙은 동일한 컴포넌트로 묶어서는 안되는 클래스가 무엇인지도 말해준다.
어떤 컴포넌트가 다른 컴포넌트를 사용하면, 두 컴포넌트 사이에는 의존성이 생겨난다.
사용하는 컴포넌트가 사용되는 컴포넌트에서 단 하나의 클래스만 사용한다고 해서 의존성은 약해지지 않고 여전히 의존한다.
따라서 의존하는 컴포넌트가 있다면 해당 컴포넌트의 모든 클래스에 대해 의존함을 확실히 인지해야 한다.
바꿔 말하면, 한 컴포넌트에 속한 클래스들은 더 작게 그룹지을 수 없다.
따라서 CRP 원칙은 어떤 클래스를 한데 묶어도 되는지보다는, 어떤 클래스를 한데 묶어서는 안 되는지에 대해서 강하게 결합되지 않은 클래스들을 동일한 컴포넌트에 위치시켜서는 안 된다고 말한다.
컴포넌트 응집도에 대한 균형 다이어그램
아마도 응집도에 관한 세 원칙이 서로 상충된다는 사실을 눈치챘을 거라고 본다.
REP와 CCP는 포함 원칙이며, 두 원칙은 컴포넌트를 더욱 크게 만든다.
CRP는 배제 원칙이며, 컴포넌트를 더욱 작게 만든다.
오로지 REP와 CRP에만 중점을 두면 사소한 변경이 생겼을 때 너무 많은 컴포넌트에 영향을 미친다.
반대로 CCP와 REP에만 과도하게 집중하면 불필요한 릴리즈가 너무 빈번해진다.
일반적으로 프로젝트는 CCP부터 시작하는 편이며, 이때는 오직 재사용성만 희생하면 된다.
프로젝트가 성숙하고, 그 프로젝트로부터 파생된 또 다른 프로젝트가 시작되면, 프로젝트는 점차 REP로 이동해 간다.
즉, 프로젝트의 컴포넌트 구조는 시간과 성숙도에 따라 변한다는 뜻이다.
결론
과거에는 응집도를 ‘모듈은 단 하나의 기능만 수행해야 한다’는 속성 정도로 단순하게 이해한 적도 있었다.
하지만 컴포넌트 응집도에 관한 세 가지 원칙은 응집도가 가질 수 있는 훨씬 복잡한 다양성을 설명해 준다.
어느 클래스들을 묶어서 컴포넌트로 만들지를 결정할 때, 재사용성과 개발 가능성이라는 상충하는 힘을 반드시 고려해야 한다.
이 균형점은 거의 항상 유동적이므로 시간이 흐름에 따라 프로젝트의 초점이 개발가능성에서 재사용성으로 바뀌고, 그에 따라 컴포넌트를 구성하는 방식도 조금씩 흐트러지고 또 진화한다.
컴포넌트 결합
ADP: 의존성 비순환 원칙
숙취 증후군은 무언가를 작동하게 만들었는데 당신이 의존하고 있던 무언가를 수정해서 작동하지 않게 되는 현상을 말한다.
이런 현상은 많은 개발자가 동일한 소스 파일을 수정하는 환경에서 발생한다.
이 문제의 해결책으로 주 단위 빌드와 의존성 비순환 원칙이 발전되어 왔다.
먼저 모든 개발자는 일주일의 첫 4일 동안은 코드를 개인적으로 복사하여 작업한 후 금요일이 되면 변경된 코드를 모두 통합하여 시스템을 빌드한다.
안타깝게도 프로젝트 규모가 성장하면서 통합에 드는 시간은 계속해서 늘어나고 개발보다 통합에 드는 시간이 늘어나면서 팀의 효율성도 서서히 나빠진다.
이 문제의 해결책은 개발 환경을 릴리즈 가능한 컴포넌트 단위로 분리하는 것이다.
이를 통해 컴포넌트는 개별 개발자 또는 단일 개발팀이 책임질 수 있는 작업 단위가 되고 다른 개발자가 사용할 수 있도록 만든다.
컴포넌트가 새로 릴리즈되어 사용할 수 있게 되면, 다른 팀에서는 새 릴리즈를 당장 적용할지를 결정해야 한다.
따라서 특정 컴포넌트가 변경되더라도 다른 팀에 즉각 영향을 주지는 않으며 통합은 작고 점진적으로 이뤄진다.
하지만 이 절차가 성공적으로 동작하려면 컴포넌트 사이의 의존성 구조를 반드시 관리해야 하며 순환이 있어서는 안 된다.
시스템 전체를 릴리즈해야 할 때가 오면 릴리즈 절차는 상향식으로 진행된다.
먼저 종단에 있는 컴포넌트를 컴파일하고, 테스트하고, 릴리즈한다.
그러고 나서 해당 컴포넌트를 의존하는 컴포넌트에 대해서도 동일한 과정을 거치며 최종적으로 Main을 처리한다.
만약 순환 의존성이 발생한다면 거대한 하나의 컴포넌트가 되어 버리며 컴포넌트를 분리하기가 상당히 어려워진다.
단위 테스트를 하고 릴리즈를 하는 일도 굉장히 어려워지며 컴포넌트를 어떤 순서로 빌드해야 올바를지 파악하기가 상당히 힘들어진다.
컴포넌트 사이의 순환을 끊고 의존성을 다시 비순환 방향 그래프로 원상복구 하는 방법은 두 가지가 있다.
1.
필요로 하는 메서드를 제공하는 인터페이스를 생성하여 의존성 역전 원칙을 적용한다.
2.
순환되는 두 컴포넌트가 모두 의존하는 클래스들을 새로운 컴포넌트를 만들어 이동시킨다.
두 번째 해결책에서 시사하는 바는 요구사항이 변경되면 컴포넌트 구조도 변경될 수 있다는 사실이다.
실제로 어플리케이션이 성장함에 따라 컴포넌트 의존성 구조는 서서히 흐트러지며 또 성장한다.
즉, 컴포넌트 구조는 하향식으로 설계될 수 없다.
컴포넌트 의존성 다이어그램은 어플리케이션의 기능적인 구조를 기술하기보다는 빌드 가능성과 유지보수성을 보여주는 지도와 같다.
바로 이러한 이유 때문에 컴포넌트 구조는 프로젝트 초기에 설계할 수 없다.
프로젝트 초기에 모듈들이 점차 쌓이기 시작하고 성장함에 따라 우리는 재사용 가능한 요소를 만드는 일에 관심을 기울이기 시작한다.
따라서 컴포넌트 의존성 구조는 시스템의 논리적 설계에 발맞춰 조금씩 흐트러지고 성장하며 또 진화해야 한다.
SDP: 안정된 의존성 원칙
설계는 결코 정적일 수 없다.
설계를 유지하다 보면 변경은 불가피하며 컴포넌트 중 일부는 변동성을 지니도록 설계된다.
변경이 쉽지 않은 컴포넌트가 변동이 예상되는 컴포넌트에 의존하게 만들어서는 절대로 안 된다.
한번 의존하게 되면 변동성이 큰 컴포넌트도 결국 변경이 어려워진다.
안정된 의존성 원칙을 준수하면 변경하기 어려운 모듈이 변경하기 쉽게 만들어진 모듈에 의존하지 않도록 만들 수 있다.
안정성은 변화가 발생하는 빈도와는 직접적인 관련이 없으며, 변경을 만들기 위해 필요한 작업량과 관련된다.
소프트웨어 컴포넌트를 변경하기 어렵게 만드는 확실한 방법 하나는 수많은 다른 컴포넌트가 해당 컴포넌트에 의존하게 만드는 것이다.
컴포넌트 안쪽으로 들어오는 의존성이 많아지면 상당히 안정적이라고 볼 수 있는데, 사소한 변경이라도 의존하는 모든 컴포넌트를 만족시키면서 변경하려면 상당한 노력이 들기 때문이다.
X는 안정된 컴포넌트다.
세 컴포넌트가 X에 의존하며, 따라서 X 컴포넌트는 변경하지 말아야 할 이유가 세 가지나 되기 때문이다.
이 경우 X는 세 컴포넌트를 책임진다라고 말한다.
반대로 X는 어디에도 의존하지 않으므로 X가 변경되도록 만들 수 있는 외적인 영향이 전혀 없다.
이 경우 X는 독립적이다라고 말한다.
Fan-in과 Fan-out을 통해 특정 컴포넌트 내부의 클래스에 의존하는 컴포넌트 외부에 위치한 클래스의 개수를 통해 불안정성인 I를 계산할 수 있다.
•
Fan-in
컴포넌트 내부의 클래스에 의존하는 컴포넌트 외부의 클래스 개수를 나타낸다.
•
Fan-out
컴포넌트 외부의 클래스에 의존하는 컴포넌트 내부의 클래스 개수를 나타낸다.
•
I
I = Fan-out / (Fan-in + Fan-out)
I = 0이면 다른 컴포넌트를 책임지고 독립적이며 최고로 안정된 컴포넌트라는 뜻이다.
I = 1이면 이 컴포넌트는 책임성이 없고 의존적이며 최고로 불안정한 컴포넌트라는 뜻이다.
SDP 원칙에서 컴포넌트의 I 지표는 그 컴포넌트가 의존하는 다른 컴포넌트들의 I보다 커야 한다고 말한다.
즉, 의존성 방향으로 갈수록 I 지표 값이 감소해야 한다.
하지만 모든 컴포넌트가 최고로 안정적인 시스템이라면 변경이 불가능하므로 바람직한 상황은 아니다.
오로지 인터페이스만을 포함하는 컴포넌트는 의존성을 끊기 위해 사용하는 방식으로 이상하게 보일 수도 있다.
이러한 추상 컴포넌트는 상당히 안정적이며, 따라서 덜 안정적인 컴포넌트가 의존할 수 있는 이상적인 대상이다.
SAP: 안정된 추상화 원칙
시스템에는 자주 변경해서는 절대로 안 되는 소프트웨어도 있다.
시스템에서 고수준 정책을 캡슐화하는 소프트웨어는 반드시 안정된 컴포넌트에 위치해야 한다.
불안정한 컴포넌트는 반드시 변동성이 큰 소프트웨어, 즉 쉽고 빠르게 변경할 수 있는 소프트웨어만을 포함해야 한다.
하지만 고수준 정책을 안정된 컴포넌트에 위치시키면 그 정책을 포함하는 소스 코드는 수정하기가 어려워진다.
이로 인해 시스템 전체 아키텍처가 유연성을 잃는다.
컴포넌트가 최고로 안정된 상태이면서도 동시에 변경에 충분히 대응할 수 있을 정도로 유연하게 만드려면 추상 클래스를 사용할 수 있다.
안정된 추상화 원칙은 안정성과 추상화 정도 사이의 관계를 정의한다.
이 원칙은 한편으로는 안정된 컴포넌트는 추상 컴포넌트여야 하며, 이를 통해 안정성이 컴포넌트를 확장하는 일을 방해해서는 안 된다고 말한다.
다른 한편으로는 불안정한 컴포넌트는 반드시 구체 컴포넌트여야 한다고 말하는데, 컴포넌트가 불안정하므로 컴포넌트 내부의 구체적인 코드를 쉽게 변경할 수 있어야 하기 때문이다.
따라서 안정적인 컴포넌트라면 반드시 인터페이스와 추상 클래스로 구성되어 쉽게 확장할 수 있어야 한다.
안정된 컴포넌트가 확장이 가능해지면 유연성을 얻게 되고 아키텍처를 과도하게 제약하지 않게 된다.
안정된 추상화 원칙과 안정된 의존성 원칙을 결합하면 컴포넌트에 대한 의존성 역전 원칙이나 마찬가지가 된다.
안정된 의존성 원칙에서는 의존성이 반드시 안정성의 방향으로 향해야 한다고 말하며, 안정된 추상화 원칙에서는 안정성이 결국 추상화를 의미한다고 말하기 때문이다.
따라서 의존성은 추상화의 방향으로 향하게 된다.
컴포넌트의 클래스 총 수 대비 인터페이스와 추상 클래스의 개수를 통해 컴포넌트의 추상화 정도인 A를 계산할 수 있다.
•
Nc
컴포넌트의 클래스 개수
•
Na
컴포넌트의 추상 클래스와 인터페이스의 개수
•
A
A = Na / Nc
A = 0이면 컴포넌트에는 추상 클래스가 하나도 없다는 뜻이다.
A = 1이면 컴포넌트는 오로지 추상 클래스만을 포함한다는 뜻이다.
이제 안정성(I)과 추상화 정도(A) 사이의 관계를 정의해야 할 때가 왔다.
모든 컴포넌트가 이 두 지점에 위치하는 것은 아닌데, 대체로 컴포넌트는 추상화와 안정화의 정도가 다양하기 때문이다.
추상 클래스는 흔히 또 다른 추상 클래스로부터 파생해서 만들고 이러한 파생 클래스는 추상적이면서도 의존성을 가지기 때문이다.
(0, 0) 주변 구역에 위치한 컴포넌트는 매우 안정적이며 구체적이다.
이러한 컴포넌트는 추상적이지 않으므로 확장할 수 없고, 안정적이므로 변경하기도 상당히 어렵기 때문에 바람직한 상태가 아니다.
따라서 (0, 0) 주변 영역은 배제해야 할 구역이며, 고통의 구역이라고 부른다.
하지만 변동성이 없는 컴포넌트는 고통의 구역에 위치했더라도 변동될 가능성이 거의 없기 때문에 해롭지 않다.
이러한 이유로 고통의 구역에서 문제가 되는 경우는 변동성이 있는 소프트웨어 컴포넌트다.
(1, 1) 주변 구역에 위치한 컴포넌트는 최고로 추상적이지만, 누구도 그 컴포넌트에 의존하지 않는다.
이러한 컴포넌트는 쓸모가 없기 때문에 쓸모없는 구역이라고 부른다.
이 영역에 존재하는 소프트웨어 엔티티는 누구도 구현하지 않은 채 남겨진 추상 클래스인 경우가 많으므로 폐기물과도 같다.
이같이 쓸모없는 엔티티가 존재한다는 사실은 바람직한 상황이 아님은 분명하다.
따라서 변동성이 큰 컴포넌트 대부분은 두 배제 구역으로부터 가능한 한 멀리 떨어뜨려야 한다.
각 배제 구역으로부터 최대한 멀리 떨어진 점의 궤적은 (1, 0)과 (0, 1)을 잇는 선분이며 주계열이라고 부른다.
주계열에 위치한 컴포넌트는 자신의 안정성에 비해 너무 추상적이지도 않고, 추상화 정도에 비해 너무 불안정하지도 않다.
컴포넌트가 위치할 수 있는 가장 바람직한 지점은 주계열의 두 종점이며 주계열 바로 위에 또는 가깝게 위치할 때 가장 이상적이다.
이 같은 이상적인 상태로부터 컴포넌트가 얼마나 떨어져 있는지 측정하는 지표를 만들어 볼 수 있다.
•
D = | A + I - 1 |
D가 0이면 커뫂넌트가 주계열 바로 위에 위치한다는 뜻이며, 1이면 주계열로부터 가장 멀리 위치한다는 뜻이다.
설계에 포함된 모든 컴포넌트에 대해 D 지표의 평균과 분산을 구하여 설계를 통계적으로 분석하는 일 또한 가능해진다.
5부. 아키텍처
아키텍처란?
소프트웨어 시스템의 아키텍처의 형태는 시스템을 컴포넌트로 분할하는 방법, 분할된 컴포넌트를 배치하는 방법, 컴포넌트가 서로 의사소통하는 방식에 따라 정해진다.
그리고 그 형태는 아키텍처 안에 담긴 소프트웨어 시스템이 쉽게 개발, 배포, 운영, 유지보수되도록 만들어진다.
이러한 일을 용이하게 만들기 위해서는 가능한 한 많은 선택지를, 가능한 한 오래 남겨두는 전략을 따라야 한다.
아키텍처의 주된 목적은 시스템의 생명주기를 지원하는 것이다.
좋은 아키텍처는 시스템을 쉽게 이해하고, 쉽게 개발하며, 쉽게 유지보수하고, 또 쉽게 배포하게 해준다.
아키텍처의 궁극적인 목표는 시스템의 수명과 관련된 비용은 최소화하고, 프로그래머의 생산성은 최대화하는 데 있다.
개발
개발하기 힘든 시스템이라면 수명이 길지도 않고 건강하지도 않을 것이다.
따라서 시스템 아키텍처는 개발팀(들)이 시스템을 쉽게 개발할 수 있도록 뒷받침해야만 한다.
팀이 개발자 다섯 명으로 구성될 정도로 작다면 잘 정의된 컴포넌트나 인터페이스가 없더라도 서로 효율적으로 협력하여 모놀리틱 시스템을 개발할 수 있다.
다른 한편으로 일곱 명씩으로 구성된 총 다섯 팀이 시스템을 개발하고 있다면 시스템을 신뢰할 수 있고 안정된 인터페이스를 갖춘, 잘 설계된 컴포넌트 단위로 분리하지 않으면 개발이 진척되지 않는다.
이러한 ‘팀별 단일 컴포넌트’ 아키텍처가 시스템을 배포, 운영, 유지보수하는 데 최적일 가능성은 거의 없다.
그럼에도 여러 팀이 순전히 일정에만 쫓겨서 일한다면, 결국 이 아키텍처로 귀착될 것이다.
배포
소프트웨어 시스템이 사용될 수 있으려면 반드시 배포할 수 있어야 하고 배포 비용이 높을수록 시스템의 유용성은 떨어진다.
따라서 소프트웨어 아키텍처는 시스템을 단 한 번에 쉽게 배포할 수 있도록 만드는 데 그 목표를 두어야 한다.
개발 초기 단계에 마이크로서비스 아키텍처를 사용한다면 컴포넌트 경계가 매우 뚜렷해지고 인터페이스가 대체로 안정화되므로 시스템을 매우 쉽게 개발할 수 있다고 판단했을지도 모른다.
하지만 배포할 시기가 되면 위협적일 만큼 늘어난 수많은 마이크로서비스를 서로 연결하기 위해 설정하고 작동 순서를 결정하는 과정에서 오작동이 발생할 원천이 스며들 수도 있다.
만약 아키텍트가 배포 문제를 초기에 고려했다면 더 적은 서비스를 사용하고, 서비스 컴포넌트와 프로세스 수준의 컴포넌트를 하이브리드 형태로 융합하며, 좀 더 통합된 도구를 사용하여 상호 연결을 관리했을 것이다.
운영
아키텍처가 시스템 운영에 미치는 영향은 개발, 배포, 유지보수에 미치는 영향보다는 덜 극적이며 대다수의 어려움은 단순히 하드웨어를 더 투입해서 해결할 수 있다.
하드웨어는 값싸고 인력은 비싸다는 말이 뜻하는 바는 운영을 방해하는 아키텍처가 개발, 배포, 유지보수를 방해하는 아키텍처보다는 비용이 덜 든다는 뜻이다.
하지만 시스템을 운영할 때 아키텍처가 맡는 또 다른 역할이 있다.
좋은 소프트웨어 아키텍처는 시스템의 운영 방식을 잘 드러내주며 유스케이스, 기능, 시스템의 필수 행위를 일급 엔티티로 격상시키고, 이들 요소가 개발자에게 주요 목표로 인식되도록 해준다.
이를 통해 시스템을 이해하기 쉬워지며, 따라서 개발과 유지보수에 큰 도움이 된다.
유지보수
유지보수는 모든 측면에서 봤을 때 소프트웨어 시스템에서 비용이 가장 많이 든다.
유지보수의 가장 큰 비용은 탐사와 이로 인한 위험부담에 있다.
탐사란 기존 소프트웨어에 새로운 기능을 추가하거나 결함을 수정할 때, 소프트웨어를 파헤쳐서 어디를 고치는 게 최선인지, 그리고 어떤 전략을 쓰는게 최적일지를 결정할 때 드는 비용이다.
주의를 기울여 신중하게 아키텍처를 만들면 이 비용을 크게 줄일 수 있다.
시스템을 컴포넌트로 분리하고, 안정된 인터페이스를 두어 서로 격리하여 미래에 추가될 기능에 대한 길을 밝혀 둘 수 있을 뿐만 아니라 의도치 않은 장애가 발생할 위험을 크게 줄일 수 있다.
선택사항 열어 두기
소프트웨어를 부드럽게 유지하는 방법은 중요치 않은 세부사항을 가능한 한 많이, 그리고 가능한 한 오랫동안 열어 두는 것이다.
모든 소프트웨어 시스템은 주요한 두 가지 구성요소인 정책과 세부사항으로 분해할 수 있다.
정책 요소는 모든 업무 규칙과 업무 절차를 구체화하며 시스템의 진정한 가치가 살아 있는 곳이다.
세부사항은 사람, 외부 시스템, 프로그래머가 정책과 소통할 때 필요한 요소지만, 정책이 가진 행위에는 조금도 영향을 미치지 않는다.
아키텍트의 목표는 시스템에서 정책을 가장 핵심적인 요소로 식별하고, 동시에 세부사항은 정책에 무관하게 만들 수 있는 형태의 시스템을 구축하는데 있다.
이를 통해 세부사항을 결정하는 일은 미루거나 연기할 수 있게 되며 더 많은 정보를 얻을 수 있고, 이를 기초로 제대로 된 결정을 내릴 수 있다.
사례1. 장치 독립성
개인화된 광고를 멋지게 인쇄하는 프로그램을 작성하고 있다고 해보자.
의뢰인이 고객의 이름과 주소를 포함하는 단위 레코드가 기록된 자기 테이프를 보내주면 이름, 주소, 그리고 기타 항목을 추출하여, 해당 항목들을 편지 양식의 정확한 위치에 인쇄하도록 프로그램을 작성했다.
프로그램을 작성하던 중 대표적인 실수는 코드를 입출력 장치와 직접 결합해버린 일이었다.
프린터로 인쇄할 일이 있다면, 해당 프린터를 제어하는 입출력 명령어를 직접 사용해서 코드를 작성했다.
이러한 코드는 장치 종속적이었다.
이러한 실수를 해결하기 위해 운영체제의 입출력 추상화를 사용하여 어떤 장치를 사용할지 전혀 모른채, 그리고 고려하지 않고도 프로그램을 다시 작성하였다.
이러한 프로그램에는 형태가 있으며 이 형태는 정책을 세부사항으로부터 분리했다.
이 경우 정책은 이름과 주소 레코드에 대한 서식이었고, 세부사항은 장치였다.
우리는 어떤 장치를 사용할지에 대한 결정을 연기시켰다.
사례2. 물리적 주소 할당
지역 트럭 운전수 조합을 위한 대규모 회계 시스템을 만들고 있다고 해보자.
디스크 드라이브에 크기가 제각각인 여러 Agent, Employer, Member의 레코드를 저장했다.
우리는 디스크에서 실린더들을 포맷하여 각 실린더가 헤드별로 수십 개의 섹터로 구성되고 단일 Agent, Employer, Member 레코드와 크기가 같도록 만들었다.
그리고 특정 레코드를 탐색할 수 있도록 디스크에 색인을 저장했다.
그렇게 우리는 소프트웨어의 업무 규칙이 디스크의 상세 구조를 알도록 만들었고 이러한 정보가 모두 하드코딩되었으며 이 코드는 도처에 산재했다.
어느 날 조직에 합류한 노련한 프로그래머는 주소 할당 체계가 상대 주소를 사용하도록 변경할 것을 충고하며 디스크를 섹터로 구성된 하나의 거대한 선형 배열로 취급할 것을 제안했다.
그래서 우리는 디스크의 물리적 구조를 알고 이를 통해 상대 주소가 필요하면 즉시 실린더/헤드/섹터 번호로 변환할 수 있는 간단한 변환 루틴을 작성하였다.
다행히도 우리는 그의 조언을 받아들여, 시스템에서 고수준의 정책이 디스크의 물리적 구조로부터 독립되도록 수정할 수 있게 되었다.
독립성
좋은 아키텍처는 다음을 지원해야 한다.
•
시스템의 유스케이스
시스템 아키텍처는 시스템의 의도를 지원해야 한다는 뜻이다.
가장 중요한 사항은 행위를 명확히 하고 외부로 드러내며, 이를 통해 시스템이 지닌 의도를 아키텍처 수준에서 알아볼 수 있게 만드는 것이다.
해당 시스템의 유스케이스는 시스템 구조 자체에서 한눈에 드러날 것이다.
이들 행위는 일급 요소이며 시스템의 최상위 수준에서 알아볼 수 있으므로, 찾아 헤매지 않아도 된다.
이들 요소는 클래스이거나 함수 또는 모듈로서 아키텍처 내에서 핵심적인 자리를 차지할 뿐만 아니라, 자신의 기능을 분명하게 설명하는 이름을 가질 것이다.
•
시스템의 운영
시스템이 초당 100,000명의 고객을 처리해야 한다면, 아키텍처는 이 요구와 관련된 각 유스케이스에 걸맞은 처리량과 응답시간을 보장해야 하며 이러한 운영 작업을 허용할 수 있는 형태로 아키텍처를 구조화해야 한다.
이러한 형태를 지원한다는 말은 시스템에 따라 다양한 의미를 지닌다.
어떤 시스템에서는 시스템의 처리 요소를 일련의 작은 서비스로 배열하여 서로 다른 많은 서버에서 병렬로 실행할 수 있게 만들어야 함을 의미하며, 또 다른 시스템에서는 경량의 수많은 스레드가 단일 프로세서에서 같은 주소 공간을 공유하도록 만들어야 한다는 뜻일 수도 있다.
만약 시스템이 모놀리틱 구조를 갖는다면 다중 프로세스, 다중 스레드, 또는 마이크로 서비스 형태가 필요해질 때 개선하기가 어렵다.
아키텍처가 각 컴포넌트를 적절히 격리하여 유지하고 컴포넌트 간 통신 방식을 특정 형태로 제한하지 않는다면, 시간이 지나 운영에 필요한 요구사항이 바뀌더라도 스레드, 프로세스, 서비스로 구성된 기술 스펙트럼 사이를 전환하는 일이 훨씬 쉬워질 것이다.
•
시스템의 개발
‘시스템을 설계하는 조직이라면 어디든지 그 조직의 의사소통 구조와 동일한 구조의 설계를 만들어 낼 것이다.’
콘웨이의 법칙이 작용하는 지점이 바로 여기이며 아키텍처는 개발환경을 지원하는 데 있어 핵심적인 역할을 수행한다.
많은 팀으로 구성되며 관심사가 다양한 조직에서 어떤 시스템을 개발해야 한다면, 각 팀이 독립적으로 행동하기 편한 아키텍처를 반드시 확보하여 개발하는 동안 팀들이 서로를 방해하지 않도록 해야 한다.
이러한 아키텍처를 만들려면 잘 격리되어 독립적으로 개발 가능한 컴포넌트 단위로 시스템을 분할할 수 있어야 한다.
•
시스템의 배포
좋은 아키텍처라면 시스템이 빌드된 후 즉각 배포할 수 있도록 지원해야 한다.
이러한 아키텍처를 만들려면 시스템을 컴포넌트 단위로 적절하게 분할하고 격리시켜야 한다.
여기에는 메인 컴포넌트도 포함되는데, 메인 컴포넌트는 시스템 전체를 하나로 묶고 각 컴포넌트를 올바르게 구동하고 통합하고 관리해야 한다.
현실에서는 이러한 균형을 잡기가 매우 어렵다.
대부분의 경우 우리는 모든 유스케이스를 알 수 없으며 운영하는데 따르는 제약사항, 팀 구조, 배포 요구사항도 알지 못하기 때문이다.
그러나 몇몇 아키텍처 원칙은 구현하는 비용이 비교적 비싸지 않으며, 균형을 맞추려는 목표점을 명확히 그릴 수 없는 경우더라도 관심사들 사이에서 균형을 잡는데 도움이 된다.
이들 원칙은 시스템을 제대로 격리된 컴포넌트 단위로 분할할 때 도움이 되며, 이를 통해 선택사항을 가능한 한 많이, 그리고 가능한한 오랫동안 열어둘 수 있게 해준다.
아키텍트는 모든 유스케이스를 알지는 못하지만 시스템의 기본적인 의도는 분명히 알고 있다.
따라서 아키텍트는 단일 책임 원칙과 공통 폐쇄 원칙을 적용하여, 그 의도의 맥락에 따라서 다른 이유로 변경되는 것들은 분리하고, 동일한 이유로 변경되는 것들은 묶는다.
유스케이스 결합 분리
서로 다른 이유로 변경되는 것들은 이와 같은 것들이 있을 수 있다.
•
유스케이스에서 UI 부분과 업무 규칙
•
업무 규칙에서 다른 속도나 다른 이유로 변경되는 두 유형
•
UI 부분이나 업무 규칙과는 관련 없는 기술적인 세부사항
그리고 다른 속도나 다른 이유로 변경되는 유스케이스 그 자체가 될 수도 있다.
시스템에서 서로 다른 이유로 변경되는 요소들의 결합을 분리하면 기존 요소에 지장을 주지 않고도 새로운 유스케이스를 계속해서 추가할 수 있게 된다.
또한 유스케이스를 뒷받침하는 UI와 데이터베이스를 서로 묶어서 각 유스케이스가 UI와 데이터베이스의 서로 다른 관점을 사용하게 되면, 새로운 유스케이스를 추가하더라도 기존 유스케이스에 영향을 주는 일은 거의 없을 것이다.
운영 독립성
서로 다른 관점으로 분리하는 작업은 운영에도 도움이 된다.
UI와 데이터베이스가 업무 규칙과 분리되어 있다면, UI와 데이터베이스는 업무 규칙과는 다른 서버에서 실행될 수 있다.
높은 대역폭을 요구하는 유스케이스는 여러 서버로 복제하여 실행할 수 있다.
하지만 운영 측면에서 이점을 살리기 위해선 결합을 분리할 때 적절한 모드를 선택해야 한다.
예를 들어 분리된 컴포넌트를 서로 다른 서버에서 실행해야 하는 상황이라면, 이들 컴포넌트가 단일 프로세서의 동일한 주소 공간에 함께 상주하는 형태로 만들어져서는 안 된다.
분리된 컴포넌트는 반드시 독립된 서비스가 되어야 하고, 일종의 네트워크를 통해 서로 통신해야 한다.
우리는 때때로 컴포넌트를 서비스 수준까지도 분리해야 하며 좋은 아키텍처는 선택권을 열어주고 결합 분리는 이러한 선택지 중 하나이다.
개발 독립성
컴포넌트가 완전히 분리되면 팀 사이의 간섭은 줄어든다.
기능 팀, 컴포넌트 팀, 계층 팀, 혹은 또 다른 형태의 팀이라도 계층과 유스케이스의 결합이 분리되는 한 시스템의 아키텍처는 그 팀 구조를 뒷받침해 줄 것이다.
배포 독립성
유스케이스와 계층의 결합이 분리되면 배포 측면에서도 고도의 유연성이 생긴다.
실제로 결합을 제대로 분리했다면 운영 중인 시스템에서도 계층과 유스케이스를 교체할 수 있다.
중복
소프트웨어 중복은 일반적으로 나쁜 것이며 우리는 중복된 코드를 좋아하지 않고 줄이거나 제거한다.
하지만 중복에도 여러 종류가 있다.
•
진짜 중복
한 인스턴스가 변경되면 동일한 변경을 그 인스턴스의 모든 복사본에 반드시 적용해야 한다.
•
우발적인 중복
중복으로 보이는 두 코드 영역이 서로 다른 속도와 다른 이유로 변경된다면 이 두 코드는 진짜 중복이 아니다.
유스케이스를 수직으로 분리할 때 이러한 문제와 마주칠 테고, 이들 유스케이스를 통합하고 싶다는 유혹을 받게 될 것이다.
자동반사적으로 중복을 제거해버리는 잘못을 저지르는 유혹을 떨쳐내고 중복이 진짜 중복인지 확인해야 한다.
결합 분리 모드
계층과 유스케이스의 결합을 분리하는 방법은 다양하다.
•
소스 수준 분리 모드
소스 코드 모듈 사이의 의존성을 제어하여 하나의 모듈이 변하더라도 다른 모듈을 변경하거나 재컴파일하지 않도록 만들 수 있다.
이 모드에서는 모든 컴포넌트가 같은 주소 공간에서 실행되고, 서로 통신할 때는 간단한 함수 호출을 사용한다.
컴퓨터 메모리에는 하나의 실행 파일만이 로드된다.
이러한 구조를 흔히 모놀리틱 구조라고 부른다.
•
배포 수준 분리 모드
jar 파일, DLL, 공유 라이브러리와 같이 배포 가능한 단위들 사이의 의존성을 제어할 수 있다.
이를 통해 한 모듈의 소스 코드가 변하더라도 다른 모듈을 재빌드하거나 재배포하지 않도록 만들 수 있다.
많은 컴포넌트가 여전히 같은 주소 공간에 상주하며, 단순한 함수 호출을 통해 통신할 수 있다.
어떤 컴포넌트는 동일한 프로세서의 다른 프로세스에 상주하고 프로세스 간 통신, 소켓, 또는 공유 메모리를 통해 통신할 수 있다.
이 모드의 중요한 특징은 결합이 분리된 컴포넌트가 독립적으로 배포할 수 있는 단위로 분할되어 있다는 점이다.
•
서비스 수준 분리 모드
의존하는 수준을 데이터 구조 단위까지 낮출 수 있고, 순전히 네트워크 패킷을 통해서만 통신하도록 만들 수 있다.
이를 통해 모든 실행 가능한 단위는 소스와 바이너리 변경에 대해 서로 완전히 독립적이게 된다.
예를 들어 마이크로 서비스가 있다.
시스템이 한 서버에서 실행되는 동안은 결합을 소스 수준에서 분리하는 것만으로도 충분하다.
하지만 시스템이 성장하면서 배포 가능한 단위, 심지어는 서비스 수준까지 분리해야 할 수도 있다.
필자는 컴포넌트가 서비스화될 가능성이 있다면 컴포넌트 결합을 분리하되 서비스가 되기 직전에 멈추는 방식을 선호한다.
이 방식을 사용하면 초기에는 컴포넌트가 소스 코드 수준에서 분리된다.
배포나 개발에서 문제가 생기면 일부 결합을 배포 수준까지 분리해 대응하면 된다.
개발, 배포, 운영적인 문제가 증가하면 서비스 수준으로 전환할 배포 단위들을 신중하게 선택한 후, 점차적으로 서비스화 하는 방향으로 시스템을 변경해 나간다.
시간이 흐르면 서비스 수준까지 분리해야 했던 것들이 이제 배포 수준, 심지어 소스 수준의 결합 분리만으로 충분할 수도 있다.
좋은 아키텍처는 시스템이 모놀리틱 구조로 태어나서 독립적인 서비스 수준까지 성장할 수 있도록 만들어져야 하며, 나중에 상황이 바뀌었을 때 이 진행 방향을 거꾸로 돌려 원래 형태인 모놀리틱 구조로 되돌릴 수도 있어야 한다.
경계: 선 긋기
아키텍트의 목표는 필요한 시스템을 만들고 유지하는데 드는 인적 자원을 최소화하는 것이다.
인적 자원의 효율을 떨어뜨리는 요인은 결합이며, 특히 너무 일찍 내려진 유스케이스와 아무런 관련이 없는 결정에 따른 결합이다.
아키텍처는 이런 결정에 의존하지 않고 가능한 한 최후의 순간에 내릴 수 있게 해주며, 결정에 따른 영향이 크지 않게 만든다.
이걸 가능하게 하는 것이 선을 긋는 기술이며, 이러한 선은 경계이다.
경계는 소프트웨어 요소를 서로 분리하고, 경계 한편에 있는 요소가 반대편에 있는 요소를 알지 못하도록 막는다.
데이터베이스는 업무 규칙이 간접적으로 사용할 수 있는 도구이며 업무 규칙은 스키마, 쿼리 언어, 또는 데이터베이스와 관련된 나머지 세부사항에 대해 어떤 것도 알아서는 안 된다.
업무 규칙이 알아야 할 것은 데이터를 가져오고 저장할 때 사용할 수 있는 함수 집합이 있다는 사실이 전부다.
이러한 함수 집합을 통해서 우리는 데이터베이스를 인터페이스 뒤로 숨길 수 있다.
DatabaseAccess에서 출발하는 두 화살표는 DatabaseAccess 클래스로부터 바깥쪽으로 향한다.
즉, 이 도표에서 DatabaseAccess가 존재한다는 사실을 알 고 있는 클래스는 없다는 뜻이다.
BusinessRules는 Database에 관해 알지 못한다.
이는 DatabaseInterface 클래스는 BusinessRules 컴포넌트에 속하며, DatabaseAccess 클래스는 Database 컴포넌트에 속한다는 사실을 의미한다.
두 컴포넌트 사이에 이러한 경계선을 그리고 화살표 방향이 BusinessRules를 향하도록 만들었으므로, BusinessRules에서는 어떤 종류의 데이터베이스도 사용할 수 있음을 알 수 있다.
플러그인 아키텍처
조금 멀리서 바라본다면 컴포넌트 추가와 관련된 일종의 패턴이 만들어진다.
이 패턴에서 사용자 인터페이스는 플러그인 형태로 고려되었기에, 수많은 종류의 사용자 인터페이스를 플러그인 형태로 연결할 수 있게 된다.
수많은 종류의 사용자 인터페이스로부터 핵심적인 업무 규칙은 분리되어 있고, 또한 독립적이다.
예를 들어 누군가가 웹 페이지의 포맷을 변경하거나 데이터베이스 스키마를 변경하더라도 업무 규칙은 깨지지 않기를 바란다.
마찬가지로 시스템에서 한 부분이 변경되더라도 관련 없는 나머지 부분이 망가지길 원치 않는다.
시스템을 플러그인 아키텍처로 배치함으로써 변경이 전파될 수 없는 방화벽을 생성할 수 있다.
경계는 변경의 축이 있는 지점에 그어진다.
경계의 한쪽에 위치한 컴포넌트는 경계 반대편의 컴포넌트와는 다른 속도로, 그리고 다른 이유로 변경된다.
경계는 의존성 역전 원칙과 안정된 추상화 원칙을 응용한 것이며 단일 책임 원칙은 어디에 경계를 그어야 할지를 알려준다.
의존성 화살표는 저수준 세부사항에서 고수준의 추상화를 향하도록 배치된다.
경계 해부학
시스템 아키텍처는 일련의 소프트웨어 컴포넌트와 그 컴포넌트들을 분리하는 경계에 의해 정의된다.
‘런타임에 경계를 횡단한다’함은 그저 경계 한쪽에 있는 기능에서 반대편 기능을 호출하여 데이터를 전달하는 일에 불과하다.
적절한 위치에서 경계를 횡단하게 하는 비결은 소스 코드 의존성 관리에 있다.
왜냐하면 소스 코드 모듈 하나가 변경되면, 이에 의존하는 다른 소스 코드 모듈도 변경하거나, 다시 컴파일해서 새로 배포해야 할지도 모르기 때문이다.
경계는 이러한 변경이 전파되는 것을 막는 방화벽을 구축하고 관리하는 수단으로써 존재한다.
모놀리틱
아키텍처 경계 중에서 가장 단순하며 가장 흔한 형태는 물리적으로 엄격하게 구분되지 않은 형태로 배포 관점에서 보면 이는 소위 모놀리틱이라고 불리는 단일 실행 파일이다.
이 형태는 소스 수준 분리모드이며, 함수와 데이터가 단일 프로세서에서 같은 주소 공간을 공유하고 그저 나름의 규칙에 따라 분리되어 있을 뿐이다.
모놀리틱은 경계가 드러나지 않지만 실제로 경계가 존재하지 않거나, 경계 자체가 무의미하다는 뜻은 아니다.
거의 모든 경우에 특정한 동적 다형성에 의존하여 내부 의존성을 관리한다.
객체 지향이 없었다면, 또는 다형성에 해당하는 메커니즘이 없었다면, 아키텍트는 결합도를 적절히 분리하기 위해 함수를 가리키는 포인터라는 위험한 옛 관행에 기대야만 했을 것이다.
이러한 모놀리틱에서 컴포넌트 간 통신은 전형적인 함수 호출에 지나지 않기 때문에 매우 빠르고 값싸다.
배포형 컴포넌트
아키텍처의 경계가 물리적으로 드러날 수도 있는데 그중 가장 단순한 형태는 동적 링크 라이브러리다.
이 형태는 배포 수준 분리모드이며, 따로 컴파일하지 않고 곧바로 사용할 수 있는 대신 컴포넌트는 바이너리와 같이 배포 가능한 형태로 전달된다.
배포 작업은 단순히 이들 배포 가능한 단위를 좀 더 편리한 형태로 묶는 일에 지나지 않으며, 배포 과정에서만 차이가 나고 컴포넌트는 모놀리틱과 동일하다.
일반적으로 모든 함수가 동일한 프로세스와 주소 공간에 위치하며, 컴포넌트를 분리하거나 컴포넌트 간 의존성을 관리하는 전략도 모놀리틱과 동일하다.
또한 모놀리틱과 마찬가지로 배포형 컴포넌트의 경계를 가로지르는 통신은 동적 링크와 런타임 로딩으로 인해 최초의 함수 호출은 오래 걸릴 수 있지만 대체로 매우 값싸다.
스레드
모놀리틱과 배포형 컴포넌트는 모두 스레드를 활용할 수 있다.
스레드는 아키텍처 경계나 배포 단위가 아니며, 이보다 실행 계획과 순서를 체계화하는 방법에 가깝다.
모든 스레드가 단 하나의 컴포넌트에 포함될 수도 있고, 많은 컴포넌트에 걸쳐 분산될 수도 있다.
로컬 프로세스
훨씬 강한 물리적 형태를 띠는 아키텍처 경계로는 로컬 프로세스가 있다.
로컬 프로세스들은 동일한 프로세서 또는 하나의 멀티코어 시스템에 속한 여러 프로세서들에서 실행되지만, 각각이 독립된 주소 공간에서 실행된다.
각 로컬 프로세스는 여러 모놀리틱 프로세스가 같은 컴포넌트들을 가지고 있을 수 있으며, 동적으로 링크된 배포형 컴포넌트들을 서로 공유할 수도 있다.
일반적으로는 메모리 보호를 통해 프로세스들이 메모리를 공유하지 못하게 하며, 대개의 경우 소켓이나 메일박스, 메시지 큐와 같은 통신 기능을 이용하여 서로 통신한다.
로컬 프로세스 경계를 지나는 통신에는 운영체제 호출, 데이터 마샬링 및 언마샬링, 프로세스 간 컨텍스트 스위칭 등이 있으며, 이들은 제법 비싼 작업에 속한다.
서비스
물리적인 형태를 띠는 가장 강력한 경계는 바로 서비스다.
서비스는 모든 통신이 네트워크를 통해 이뤄진다고 가정하기 때문에 자신의 물리적 위치에 구애받지 않으며 서로 통신하는 두 서비스는 물리적으로 동일한 프로세서나 멀티코어에서 동작할 수도 있고 아닐수도 있다.
서비스 경계를 지나는 통신은 함수 호출에 비해 매우 느리기 때문에 가능하다면 빈번하게 통신하는 일을 피해야 하며 지연에 따른 문제를 고수준에서 처리할 수 있어야 한다.
또한 저수준 서비스는 반드시 고수준 서비스에 플러그인되어야 하며 저수준 서비스를 특정 짓는 어떤 물리적인 정보도 포함해서는 안 된다.
정책과 수준
소프트웨어 시스템이란 정책을 기술한 것이다.
대다수의 주요 시스템에서 하나의 정책은 이 정책을 서술하는 여러 개의 조그만 정책들로 쪼갤 수 있다.
소프트웨어 아키텍처를 개발하는 기술에는 이러한 정책을 신중하게 분리하고, 정책이 변경되는 양상에 따라 정책을 재편성하는 일도 포함된다.
동일한 이유로 동일한 시점에 변경되는 정책은 동일한 수준에 위치하며, 동일한 컴포넌트에 속해야 한다.
서로 다른 이유로, 혹은 다른 시점에 변경되는 정책은 다른 수준에 위치하며, 반드시 다른 컴포넌트로 분리해야 한다.
좋은 아키텍처라면 각 컴포넌트를 연결할 때 의존성의 방향이 컴포넌트의 수준을 기반으로 저수준 컴포넌트가 고수준 컴포넌트에 의존하도록 만들어야 한다.
시스템의 입력과 출력 모두로부터 멀리 위치할수록 정책의 수준은 높아지며, 입력과 출력을 다루는 정책이라면 시스템에서 최하위 수준에 위치한다.
모든 소스 코드 의존성의 방향이 고수준 정책을 향할 수 있도록 정책을 분리했다면 변경의 영향도를 줄일 수 있다.
시스템의 최저 수준에서 중요하지 않지만 긴급한 변경이 발생하더라도, 보다 높은 위치의 중요한 수준에 미치는 영향은 거의 없게 된다.
업무 규칙
어플리케이션을 업무 규칙과 플러그인으로 구분하려면 업무 규칙이 실제로 무엇인지를 잘 이해해야만 한다.
업무 규칙은 컴퓨터상으로 구현했는지와 상관없이 사업적으로 수익을 얻거나 비용을 줄일 수 있는 규칙 또는 절차이며 핵심 업무 규칙이라고 부른다.
핵심 업무 규칙은 보통 데이터를 요구하며 이러한 데이터는 핵심 업무 데이터이다.
핵심 규칙과 핵심 데이터는 본질적으로 결합되어 있기 때문에 객체로 만들 좋은 후보이며 엔티티라고 한다.
엔티티
엔티티는 핵심 업무 데이터를 직접 포함하거나 쉽게 접근할 수 있으며, 이 데이터를 기반으로 동작하는 일련의 조그만 핵심 업무 규칙을 구체화한다.
엔티티의 클래스는 업무의 대표자로서 독립적으로 존재하며 데이터베이스, 사용자 인터페이스, 프레임워크에 대한 고려사항들로 인해 오염되어서는 절대 안 된다.
이 클래스는 어떤 시스템에서도 업무를 수행할 수 있으며, 언어에 관계없이 핵심 업무 데이터와 핵심 업무 규칙을 하나로 묶은 별도의 소프트웨어 모듈이다.
유스케이스
유스케이스는 시스템이 사용되는 방법을 설명하며, 사용자가 제공해야 하는 입력과 출력 그리고 해당 출력을 생성하기 위한 처리 단계를 기술한다.
유스케이스는 엔티티가 어떻게 춤을 출지 제어하는데, 엔티티 내부의 핵심 업무 규칙을 어떻게 그리고 언제 호출할지를 명시하는 규칙을 담는다.
유스케이스는 시스템에서 데이터가 들어오고 나가는 방식은 무관하며, 이보다는 어플리케이션에 특화된 규칙을 통해 사용자와 엔티티 사이의 상호작용을 규정한다.
유스케이스는 엔티티에 의존하는 반면 엔티티는 유스케이스에 의존하지 않는다.
왜냐하면 유스케이스는 단일 어플리케이션에 특화되어 시스템의 입력과 출력에 보다 가깝게 위치하고, 엔티티는 수많은 다양한 어플리케이션에서 사용될 수 있도록 일반화되어 시스템의 입력과 출력에서 멀리 떨어져 있기 때문이다.
요청 및 응답 모델
유스케이스는 단순한 요청 데이터 구조를 입력으로 받아들이고, 단순한 응답 데이터 구조를 출력으로 반환한다.
이들 데이터 구조는 어떤 것에도 의존하지 않는다.
요청 및 응답 모델이 독립적이지 않다면, 그 모델에 의존하는 유스케이스도 결국 해당 모델이 수반하는 의존성에 간접적으로 결합되어 버린다.
또한 엔티티와 요청 및 응답 모델의 목적은 완전히 다르다.
시간이 지나면 두 객체는 완전히 다른 이유로 변경될 것이고, 따라서 두 객체를 어떤 식으로든 함께 묶는 행위는 공통 폐쇄 원칙과 단일 책임 원칙을 위배하게 된다.
소리치는 아키텍처
소프트웨어 어플리케이션의 아키텍처는 유스케이스에 대해 소리쳐야 한다.
프레임워크는 사용하는 도구일 뿐, 아키텍처가 준수해야 할 대상이 아니기 때문에 아키텍처를 프레임워크로부터 제공받아서는 절대 안 된다.
웹 또한 전달 메커니즘이라는 세부 사항이며 시스템 구조를 지배해서는 절대 안 된다.
또한 프레임워크를 전혀 준비하지 않더라도 필요한 유스케이스 전부에 대해 단위 테스트를 할 수 있어야 한다.
그러기 위해서 엔티티 객체는 반드시 POJO여야 하며 유스케이스 객체가 엔티티 객체를 조작해야 한다.
클린 아키텍처
지난 수십 년간 우리는 시스템 아키텍처와 관련된 여러 가지 아이디어를 봐 왔다.
이들은 모두 소프트웨어를 계층으로 분리함으로써 관심사의 분리라는 목표를 달성할 수 있었다.
각 아키텍처는 최소한 업무 규칙을 위한 계층 하나와, 사용자와 시스템 인터페이스를 위한 또 다른 계층 하나를 반드시 포함한다.
이들 아키텍처는 모두 시스템이 다음과 같은 특징을 지니도록 만든다.
•
프레임워크 독립성
아키텍처는 다양한 기능의 라이브러리를 제공하는 소프트웨어, 즉 프레임워크의 존재 여부에 의존하지 않는다.
이를 통해 이러한 프레임워크를 도구로 사용할 수 있으며, 프레임워크가 지닌 제약사항 안으로 시스템을 욱여 넣도록 강제하지 않는다.
•
테스트 용이성
업무 규칙은 UI, 데이터베이스, 웹 서버, 또는 여타 외부 요소가 없이도 테스트할 수 있다.
•
UI 독립성
시스템의 나머지 부분을 변경하지 않고도 UI를 쉽게 변경할 수 있다.
•
데이터베이스 독립성
업무 규칙은 데이터베이스에 결합되지 않으며 Oracle, MSSQL, MongoDB 등으로 교체할 수 있다.
•
모든 외부 에이전시에 대한 독립성
실제로 업무 규칙은 외부 세계와의 인터페이스에 대해 전혀 알지 못한다.
의존성 규칙
각각의 동심원은 소프트웨어에서 서로 다른 영역을 표현한다.
이러한 아키텍처가 동작하도록 하는 가장 중요한 규칙은 의존성 규칙이다.
바깥쪽 원은 메커니즘이고, 안쪽 원은 정책이며 안으로 들어갈수록 고수준의 소프트웨어가 된다.
내부의 원에 속한 요소는 외부의 원에 속한 어떤 것도 알지 못하고, 특히 내부의 원에 속한 코드는 외부의 원에 선언된 어떤 것에 대해서도 그 이름을 언급해서는 절대 안 된다.
같은 이유로 외부의 원에 선언된 데이터 형식도 내부의 원에서 절대로 사용해서는 안 되며, 특히 프레임워크가 생성한 것이라면 더더욱 사용해서는 안 된다.
우리는 외부 원에 위치한 어떤 것도 내부의 원에 영향을 주지 않기를 바란다.
엔티티
엔티티는 전사적인 핵심 업무 규칙을 캡슐화하며 메서드를 가지는 객체이거나 일련의 데이터 구조와 함수의 집합일 수도 있다.
기업의 다양한 어플리케이션에서 엔티티를 재사용할 수만 있다면, 그 형태는 그다지 중요하지 않다.
특정 어플리케이션에 무언가 변경이 필요하더라도 엔티티 계층에는 절대로 영향을 주어서는 안 된다.
유스케이스
유스케이스 계층의 소프트웨어는 어플리케이션에 특화된 업무 규칙을 포함한다.
또한 유스케이스 계층의 소프트웨어는 시스템의 모든 유스케이스를 캡슐화하고 구현한다.
유스케이스는 엔티티로 들어오고 나가는 데이터 흐름을 조정하며, 엔티티가 자신의 핵심 업무 규칙을 사용해서 유스케이스의 목적을 달성하도록 이끈다.
이 계층에서 발생한 변경이 엔티티에 영향을 줘서는 안되며 데이터베이스, UI, 또는 외부 요소에서 발생한 변경이 이 계층에 영향을 줘서도 안 된다.
인터페이스 어댑터
인터페이스 어댑터 계층은 일련의 어댑터들로 구성된다.
어댑터는 데이터를 유스케이스와 엔티티에게 가장 편리한 형식에서 데이터베이스나 웹 같은 외부 에이전시에게 가장 편리한 형식으로 변환한다.
또한 이 계층에는 데이터를 외부 서비스와 같은 외부적인 형식에서 유스케이스나 엔티티에서 사용되는 내부적인 형식으로 변환하는 또 다른 어댑터가 필요하다.
원은 네 개여야만 하나?
네 개보다 더 많은 원이 필요할 수도 있으며 항상 네 개만 사용해야 한다는 규칙은 없다.
하지만 어떤 경우에도 의존성 규칙은 적용되며 소스 코드 의존성은 항상 안쪽을 향한다.
가장 바깥쪽 원은 저수준의 구체적인 세부사항으로 구성되고, 안쪽으로 이동할수록 소프트웨어는 점점 추상화되며 더 높은 수준의 정책들을 캡슐화한다.
따라서 가장 안쪽 원은 가장 범용적이며 높은 수준을 가진다.
경계 횡단하기
제어흐름은 컨트롤러에서 시작해서, 유스케이스를 지난 후, 프레젠터에서 실행되면서 마무리 된다.
각 의존성은 유스케이스를 향해 안쪽을 가리킨다.
이처럼 제어흐름과 의존성의 방향이 반대여야 하는 경우, 대체로 의존성 역전 원칙을 사용하여 해결한다.
예를 들어 유스케이스에서 프레젠터를 호출해야 한다면 직접 호출해 버리면 의존성 규칙을 위배한다.
따라서 유스케이스가 내부 원의 인터페이스를 호출하고 외부 원의 프레젠터가 그 인터페이스를 구현하도록 만든다.
경계를 가로지르는 데이터는 흔히 기본적인 구조체나 간단한 데이터 전송 객체처럼 간단한 데이텉 구조로 이루어져 있다.
우리는 데이터 구조가 어떤 의존성을 가져 의존성 규칙을 위배하게 되는 일은 바라지 않는다.
이 구조가 경계를 넘어 내부로 그대로 전달되면 내부의 원에서 외부 원의 무언가를 알아야만 하기 때문에 의존성 규칙을 위배하게 된다.
따라서 경계를 가로질러 데이터를 전달할 때, 데이터는 항상 내부의 원에서 사용하기에 가장 편리한 형태를 가져야만 한다.
프레젠터와 험블 객체
험블 객체 패턴은 디자인 패턴으로, 테스트하기 어려운 행위와 테스트하기 쉬운 행위를 단위 테스트 작성자가 분리하기 쉽게 하는 방법으로 고안되었다.
행위들을 두 개의 모듈 또는 클래스로 나누면 이들 모듈 중 하나가 험블이 되고, 가장 기본적인 본질은 남긴채 테스트하기 어려운 행위를 모두 험블 객체로 옮긴다.
예를 들어 GUI의 경우 화면의 각 요소가 필요한 위치에 적절히 표시되었는지 검사하는 테스트는 매우 어렵지만 GUI에서 수행하는 행위의 대다수는 쉽게 테스트할 수 있다.
험블 객체 패턴을 사용하면 두 부류의 행위를 분리하여 프레젠터와 뷰라는 서로 다른 클래스로 만들 수 있다.
프레젠터와 뷰
뷰는 데이터를 GUI로 이동시키지만 데이터를 직접 처리하지는 않으며, 험블 객체이고 테스트하기 어렵기 때문에 이 객체에 포함된 코드는 가능한 한 간단하게 유지한다.
프레젠터는 어플리케이션으로부터 데이터를 받아 화면에 표현할 수 있는 포맷으로 만들며, 테스트하기 쉬운 객체이다.
데이터베이스 게이트웨이
유스케이스 인터랙터와 데이터베이스 사이에는 데이터베이스 게이트웨이가 위치한다.
유스케이스 계층은 SQL을 허용하지 않기 때문에 필요한 메서드를 제공하는 게이트웨이 인터페이스를 호출한다.
그리고 인터페이스의 구현체는 데이터베이스 계층에 위치하며 험블 객체이다.
이와 달리 인터랙터는 어플리케이션에 특화된 업무 규칙을 캡슐화하기 때문에 험블 객체가 아니며 테스트하기 쉽다.
따라서 게이트웨이는 스텁이나 테스트 더블로 적당히 교체할 수 있다.
데이터 매퍼
객체는 데이터 구조가 아니기 때문에 객체 관계 매퍼 같은건 사실 존재하지 않는다.
객체는 사용하는 사람 관점에서는 데이터가 모두 private으로 선언되어 볼 수 없기 때문에 단순히 오퍼레이션 집합이다.
이러한 ORM 시스템은 데이터베이스 계층의 게이트웨어 인터페이스와 데이터베이스 사이에서 일종의 또 다른 험블 객체 경계를 형성한다.
서비스 리스너
외부 서비스로 데이터를 전송하는 서비스의 경우, 어플리케이션은 데이터를 간단한 데이터 구조 형태로 로드하여 이 데이터 구조를 경계를 가로질러서 특정 모듈로 전달한다.
그러면 해당 모듈은 데이터를 적절한 포맷으로 만들어서 외부 서비스로 전송한다.
반대로 외부로부터 데이터를 수신하는 서비스의 경우, 서비스 리스너가 서비스 인터페이스로부터 데이터를 수신하고 데이터를 어플리케이션에서 사용할 수 있게 간단한 구조로 포맷을 변경한다.
그런 후 이 데이터 구조는 서비스 경계를 가로질러서 내부로 전달된다.
여기서 데이터를 받아오는 외부 서비스와의 인터페이스나 서비스 리스너는 테스트하기 어렵기 때문에 험블 객체가 된다.
부분적 경계
아키텍처 경계를 완벽하게 만드는 데는 비용이 많이 든다.
쌍방향의 다형적 Boundary 인터페이스와 Input과 Output을 위한 데이터 구조를 만들어야 할 뿐만 아니라, 두 영역을 독립적으로 컴파일하고 배포할 수 있는 컴포넌트로 격리하는데 필요한 모든 의존성을 관리해야 한다.
뛰어난 아키텍트라면 이러한 경계를 만드는 비용이 너무 크다고 판단하면서도, 한편으로는 나중에 필요할 수도 있으므로 이러한 경계에 필요한 공간을 확보하기 원할 수도 있다.
만약 필요하다면 부분적 경계를 구현해볼 수 있다.
부분적 경계를 생성하는 방법 하나는 독립적으로 컴파일하고 배포할 수 있는 컴포넌트를 만들기 위한 작업을 모두 수행한 후, 단일 컴포넌트로 그대로 모은 후 컴파일해서 배포한다.
추후 완벽한 형태의 경계로 확장할 수 있는 공간을 확보하고자 할 때 활용할 수 있는 방법으로 전략 패턴이 있다.
Client를 ServiceImpl로부터 격리시키는데 필요한 의존성 역전이 이미 적용되었기 때문에 미래에 필요할 아키텍처 경계를 위한 무대를 마련한다는 점은 명백하다.
하지만 점선 화살표에서 보듯이 이러한 분리는 매우 빠르게 붕괴될 수 있다.
이보다 훨씬 더 단순한 경계는 파사드 패턴이다.
의존성 역전을 희생한채로 경계는 Facade 클래스로만 간단히 정의되지만 Client는 이 모든 서비스 클래스에 대해 추이 종속성을 가지게 된다.
계층과 경계
간단한 시스템의 경우 클린 아키텍처 접근법을 적용해서 유스케이스, 경계, 엔티티, 그리고 관련된 데이터 구조를 모두 만드는 일은 쉬운 일이다.
하지만 중요한 아키텍처 경계를 정말로 모두 발견한 것일까?
다양한 가능성이 존재하며 이 변경의 축에 의해 정의되는 아키텍처 경계가 잠재되어 있을 수 있다.
점선 테두리는 변경의 축으로 API를 정의하는 추상 컴포넌트가 되고, 이러한 추상 컴포넌트를 구현하는 다형적 Boundary 인터페이스가 존재한다.
API 컴포넌트에 집중해서 단순화한다면 가장 위에 최상위 수준의 정책을 가지는 컴포넌트를 배치해서 의존성의 흐름을 잘 나타내는 다이어그램을 그릴 수 있다.
이 구성은 데이터 흐름을 두 개의 흐름으로 효과적으로 분리할 수 있다.
왼쪽의 흐름은 사용자와 통신에 관여하며, 오른쪽의 흐름은 데이터 영속성에 관여한다.
두 흐름은 상단의 최상위 수준의 정책에서 서로 만나며, 두 흐름이 모두 거치게 되는 데이터에 대한 최종적인 처리기가 된다.
데이터 흐름은 항상 두 가지일까?
만약 여러 사람이 함께 플레이할 수 있는 시스템을 만들고자 한다면 네트워크 컴포넌트를 추가해야 한다.
이제 데이터 흐름은 세 개의 흐름으로 분리되며, 이들 흐름은 모두 최상위 수준의 정책이 제어한다.
모든 흐름은 결국에는 상단의 단일 컴포넌트에서 서로 만난다고 생각할 수 있다.
하지만 이보다 더 높은 수준에는 또 다른 정책 집합이 존재한다.
결론
이 모든 것이 의미하는 것은 아키텍처 경계가 어디에나 존재한다는 사실이다.
우리는 아키텍처 경계가 언제 필요한지를 신중하게 파악해내야 한다.
또한 우리는 이러한 경계를 제대로 구현하려면 비용이 많이 든다는 사실도 인지하고 있어야 한다.
이와 동시에 이러한 경계가 무시되었다면 나중에 다시 추가하는 비용이 크다는 사실도 알아야 한다.
하지만 프로젝트 초반에는 구현할 경계가 무엇인지와 무시할 경계가 무엇인지를 쉽게 결정할 수 없다.
대신 시스템이 발전함에 따라 주의를 기울이며 지켜봐야 한다.
메인 컴포넌트
모든 시스템에는 최소한 하나의 메인 컴포넌트가 존재하고 이 컴포넌트가 나머지 컴포넌트를 생성하고, 조정하며, 관리한다.
메인 컴포넌트는 운영체제를 제외하면 그 어떤 것도 메인에 의존하지 않으며, 궁극적인 세부사항과 가장 낮은 수준의 정책 그리고 시스템의 초기 진입점이다.
메인을 어플리케이션의 플러그인이라고 생각하자.
메인은 초기 조건과 설정을 구성하고, 외부 자원을 모두 수집한 후, 제어권을 어플리케이션의 고수준 정책으로 넘기는 플러그인이다.
메인은 플러그인이므로 메인 컴포넌트를 어플리케이션의 설정별로 하나씩 두도록 하여 둘 이상의 메인 컴포넌트를 만들 수도 있다.
메인을 플러그인 컴포넌트로 여기고, 그래서 아키텍처 경계 바깥에 위치한다고 보면 설정 관련 문제를 훨씬 쉽게 해결할 수 있다.
‘크고 작은 모든’ 서비스들
서비스 지향 ‘아키텍처’와 마이크로서비스 ‘아키텍처’가 큰 인기를 끌고 있는 이유는 다음과 같다.
•
서비스를 사용하면 상호 결합이 철저하게 분리되는 것처럼 보인다.
•
서비스를 사용하면 개발과 배포 독립성을 지원하는 것처럼 보인다.
이들은 일부만 맞는 말이다.
서비스 아키텍처?
시스템의 아키텍처는 의존성 규칙을 준수하며 고수준의 정책을 저수준의 세부사항으로부터 분리하는 경계에 의해 정의된다.
단순히 어플리케이션의 행위를 분리할 뿐인 서비스라면 값비싼 함수 호출에 불과하며, 아키텍처 관점에서 꼭 중요하다고 볼 수는 없다.
그러나 모든 서비스가 반드시 아키텍처 관점에서 중요해야 하지는 않으며, 기능을 프로세스나 플랫폼에 독립적이 되게끔 서비스들을 생성하면 의존성 규칙 준수 여부와 상관없이 큰 도움이 될 때가 많다.
아키텍처를 정의하는 요소는 바로 의존성 규칙을 따르며 아키텍처 경계를 넘나드는 함수 호출들이다.
결합 분리의 오류
시스템을 각 서비스로 분리함으로써 서로 다른 프로세스 혹은 프로세서에서 실행되며 다른 서비스의 변수에 직접 접근할 수 없다.
하지만 프로세서 내의 또는 네트워크 상의 공유하는 데이터에 의해 이들 서비스는 강력하게 결합되어 버린다.
개발 및 배포 독립성의 오류
전담팀에서 각 서비스를 작성하고 유지보수하며 운영하는 책임을 지는 개발 및 배포 독립성은 확장 가능한 것으로 간주된다.
하지만 대규모 엔터프라이즈 시스템은 서비스 기반 시스템 이외에도 모놀리틱 시스템이나 컴포넌트 기반 시스템으로도 구축할 수 있다는 사실은 역사적으로 증명되어 왔기 때문에 서비스 기반 시스템이 확장 가능한 시스템을 구축하는 유일한 선택지가 아니다.
또한 결합 분리의 오류에 따르면 서비스라고 해서 항상 독립적으로 개발하고 배포하며 운영할 수 있는 것은 아니다.
횡단 관심사
서비스 기반 아키텍처에서 서비스를 기능 단위로 분해한다면 새로운 기능이 기능적 행위를 횡단하는 상황에 매우 취약하다.
이 문제는 SOLID 설계 원칙을 통해 다형적으로 확장할 수 있는 클래스 집합을 생성해 새로운 기능을 처리하도록 해결할 수 있다.
기존 기능과 새로운 기능을 각 컴포넌트로 추출하고, 이 두 컴포넌트는 기존 컴포넌트들에 있는 추상 기반 클래스를 템플릿 메서드나 전략 패턴 등을 이용해서 오버라이드한다.
두 개의 신규 컴포넌트에서 기능들을 구현하는 클래스는 호출자의 제어하에 팩토리가 생성한다.
서비스는 하나 이상의 jar 파일에 포함되는 추상 클래스들의 집합이 되고, 새로운 기능 추가 혹은 기능 확장은 기존 jar 파일에 정의된 추상 클래스들을 확장해서 새로운 jar 파일로 만든다.
그러면 새로운 기능 배포는 서비스를 재배포하는 문제가 아니라, 서비스를 로드하는 경로에 단순히 새로운 jar 파일을 추가하는 문제가 된다.
이 전략을 따라 새로운 기능을 추가한다면 호출자는 어쩔 수 없이 변경하더라도 추상 기반 클래스를 갖고 있는 기존 컴포넌트는 변경할 필요가 없다.
따라서 기능들은 결합이 분리되며, 독립적으로 개발하여 배포할 수 있다.
결론
아키텍처 경계는 서비스 사이에만 있지 않다.
오히려 서비스를 관통하며, 서비스를 컴포넌트 단위로 분할한다.
서비스는 단 하나의 아키텍처 경계로 둘러싸인 단일 컴포넌트로 만들 수 있다.
혹은 여러 아키텍처 경계로 분리된 다수의 컴포넌트로 구성할 수도 있다.
드물게는 클라이언트와 서비스가 강하게 결합되어 아키텍처적으로 아무런 의미가 없을 때도 있다.
테스트 경계
테스트는 시스템의 일부이며, 아키텍처에도 관여한다.
아키텍처 관점에서 모든 테스트는 동일하며, 세부적이고 구체적인 것으로 의존성은 항상 테스트 대상이 되는 코드를 향하기 때문에 의존성 규칙을 따른다.
또한 테스트는 독립적으로 배포 가능하기 때문에 테스트 시스템에만 배포하며, 상용 시스템에는 배포하지 않는다.
테스트를 고려한 설계
개발자는 종종 테스트가 시스템의 설계 범위 밖에 있다고 여긴다.
테스트가 시스템의 설계와 잘 통합되지 않으면 시스템은 뻣뻣해져서 변경하기가 어려워진다.
하지만 시스템에 강하게 결합된 테스트라면 시스템이 변경될 때 함께 변경되어야만 하기 때문에 깨지기 쉬운 테스트가 된다.
이 문제를 해결하려면 테스트를 고려해서 설계해야 하며 변동성이 있는 것에 의존하지 않고 업무 규칙을 테스트할 수 있게 해야 한다.
그러기 위해선 테스트가 모든 업무 규칙을 검증하는데 사용할 수 있도록 특화된 API를 만들면 된다.
테스트 API
테스트 API는 사용자 인터페이스가 사용하는 인터랙터와 인터페이스 어댑터들의 상위 집합이 될 것이다.
보안 제약사항을 무시할 수 있으며, 데이터베이스와 같은 값비싼 자원은 건너뛰고, 시스템을 테스트 가능한 특정 상태로 강제하는 강력한 힘을 지녀야만 한다.
또한 테스트 구조를 어플리케이션 구조로부터 결합을 분리하여 어플리케이션의 구조를 테스트로부터 숨겨야 한다.
이렇게 만들면 상용 코드를 리팩터링하거나 진화시키더라도 테스트에는 전혀 영향을 주지 않는다.
또한 테스트를 리팩터링하거나 진화시킬 때도 상용 코드에는 전혀 영향을 주지 않는다.
클린 임베디드 아키텍처
소프트웨어는 긴 시간 유용하게 쓸 수 있는 것인 반면, 펌웨어는 하드웨어가 발전할수록 낡아 갈 것이다.
펌웨어는 저장되는 위치보단 무엇에 의존하는지, 그리고 하드웨어 발전에 맞춰 수정하기가 얼마나 어려운지에 따라 정의된다.
소프트웨어일지라도 코드 전반에 플랫폼 의존성을 퍼뜨려 놓거나, 하드 코딩을 하거나, 업무 로직을 격리하지 않는다면 펌웨어를 작성하는 셈이다.
계층
소프트웨어와 펌웨어가 서로 섞이는 일은 안티 패턴이다.
무엇을 어디에 위치시킬지, 그리고 한 모듈이 다른 모듈에 대해 어디까지 알게 할지를 신중하게 처리하지 않는다면, 소프트웨어와 펌웨어가 서로 섞이게 되며 변경하기가 매우 어렵게 된다.
하드웨어는 기술의 발전에 따라 변하기 때문에 코드와 하드웨어 사이의 경계는 뚜렷하지만 소프트웨어와 펌웨어 사이의 경계는 정의하기가 대체로 힘들다.
임베디드 소프트웨어 개발자가 해야 할 일 하나는 이 경계를 분명하게 만드는 것이다.
하드웨어는 세부사항이다.
소프트웨어와 펌웨어 사이의 경계는 하드웨어 추상화 계층 HAL 이라고 부른다.
클린 임베디드 아키텍처로 설계된 소프트웨어는 타겟 하드웨어에 관계없이 테스트가 가능하다.
HAL을 제대로 만들었다면, HAL은 타겟에 상관없이 테스트할 수 있는 경계층 또는 일련의 대체 지점을 제공한다.
프로세서는 세부사항이다.
모든 소프트웨어는 반드시 프로세서에 독립적이어야 함이 분명하지만, 모든 펌웨어가 그럴 수는 없다.
클린 임베디드 아키텍처라면 장치 접근 레지스터를 직접 사용하는 코드는 소수의, 순전히 펌웨어로만 한정시켜야 한다.
이들 레지스터를 알고 있는 것은 모두 펌웨어가 되어야 하며, 따라서 프로세서에 종속된다.
펌웨어가 저수준 함수들을 프로세스 추상화 계층 PAL의 형태로 격리하면 상위에 위치하는 펌웨어는 하드웨어에 관계없이 테스트할 수 있게 되며 펌웨어 자체도 덜 딱딱해질 수 있다.
운영체제는 세부사항이다.
임베디드 시스템에서 실시간 운영체제를 사용하거나, 임베디드 버전의 리눅스/윈도우를 사용한다면 운영체제를 세부사항으로 취급하여 의존하는 일을 막아야 한다.
운영체제는 소프트웨어를 펌웨어로부터 분리하는 계층이며 OS를 직접 사용하면 문제가 된다.
클린 임베디드 아키텍처는 운영체제 추상화 계층 OSAL을 통해 소프트웨어를 운영체제로부터 격리시킨다.
또한 OSAL은 테스트 지점을 만드는데 도움이 되며, 소프트웨어 계층의 귀중한 어플리케이션 코드를 하드웨어나 OS에 관계없이 테스트할 수 있게 된다.
6부. 세부사항
데이터베이스는 세부사항이다
아키텍처 관점에서볼 때 데이터베이스는 엔티티가 아니다.
즉, 데이터베이스는 세부사항이라서 아키텍처의 구성요소 수준으로 끌어올릴 수 없다.
어플리케이션 내부 데이터의 구조는 시스템 아키텍처에서 대단히 중요하지만 데이터베이스는 데이터 모델이 아니다.
데이터베이스 시스템은 왜 이렇게 널리 사용되는가?
데이터베이스 시스템이 소프트웨어 시스템과 소프트웨어 기업을 장악할 수 있었던 이유는 디스크 때문이었다.
디스크 기술은 수천 파운드나 되는 디스크가 겨우 20MB 밖에 담을 수 없었지만 지금은 단 몇 그램짜리 얇은 원판으로 1TB 이상을 저장할 정도로 계속 발전해왔다.
하지만 디스크 기술이 지닌 느리다는 치명적인 특성으로 인해 괴롭힘을 당했다.
시간 지연이라는 점을 완화하기 위해 색인, 캐시, 쿼리 계획 최적화 같은 데이터 접근 및 관리 시스템이 필요해졌다.
이러한 시스템은 크게 파일 시스템과 관계형 데이터베이스 관리 시스템으로 분리되었다.
•
파일 시스템
문서 기반으로 문서 전체를 자연스럽고 편리하게 저장하는 방법을 제공한다.
일련의 문서를 이름을 기준으로 저장하거나 조회할 때는 잘 동작하지만, 내용을 기준으로 검색할 때는 그리 크게 도움되지 않는다.
•
관계형 데이터베이스 관리 시스템
데이터베이스 시스템은 내용을 기반으로 레코드를 자연스럽고 편리하게 찾는 방법을 제공한다.
레코드가 서로 공유하는 일부 내용에 기반해서 다수의 레코드를 연관 짓는데 매우 탁월하지만 정형화되지 않은 문서를 저장하고 검색하는 데는 대체로 부적합하다.
이들 두 시스템은 데이터를 디스크에 체계화해서, 각 시스템에 특화된 방식으로 접근해야 할 때 가능한 한 효율적으로 데이터를 저장하고 검색할 수 있도록 한다.
디스크가 없다면 어떻게 될까?
디스크는 RAM으로 대체되고 있다.
디스크가 사라지고 모든 데이터가 RAM에 저장된다면 데이터를 어떻게 체계화할 것인가?
데이터들을 연결 리스트, 트리, 해시 테이블, 스택, 큐 혹은 무수히 많은 데이터 구조로 체계화할 것이며, 데이터에 접근할 때는 포인터나 참조를 사용할 것이다.
데이터가 데이터베이스나 파일 시스템에 있더라도 RAM으로 읽은 후에는 다루기 편리한 형태로 그 구조를 변경한다.
결론
데이터베이스가 세부사항이라고 말하는 이유는 바로 이러한 현실 때문이다.
체계화된 데이터 구조와 데이터 모델은 아키텍처적으로 중요하다.
반면, 그저 데이터를 회전식 자기 디스크 표면에서 이리저리 옮길 뿐인 기술과 시스템은 아키텍처적으로 중요치 않다.
데이터를 테이블 구조로 만들고 SQL로만 접근하도록 하는 관계형 데이터베이스 시스템은 전자보다는 후자와 훨씬 관련이 깊다.
웹은 세부사항이다
UI와 어플리케이션 사이에는 추상화가 가능한 또 다른 경계가 존재한다.
업무 로직은 다수의 유스케이스로 구성되며, 각 유스케이스는 사용자를 대신해서 일부 함수를 수행하는 것으로 볼 수 있다.
각 유스케이스는 입력 데이터, 수행할 처리 과정, 출력 데이터를 기반으로 기술할 수 있다.
UI와 어플리케이션이 함께 춤추는 동안, 어떤 시점이 되면 입력 데이터가 완전히 구성될 것이고, 그러면 유스케이스를 실행할 수 있게 된다.
유스케이스가 종료되면 해당 입력 데이터에 따른 결과 데이터는 UI와 어플리케이션이 함께 추는 춤으로 다시 되돌려줄 수 있다.
완전히 입력 데이터와 그에 따른 출력 데이터는 데이터 구조로 만들어서 유스케이스를 실행하는 처리 과정의 입력 값과 출력 값으로 사용할 수 있다.
이 방식을 따르면 각 유스케이스가 장치 독립적인 방식으로 UI라는 입출력 장치를 동작시킨다고 간주할 수 있다.
프레임워크는 세부사항이다
프레임워크 제작자는 당신을 알지 못하며, 당신이 풀어야 할 문제도 알지 못하기 때문에 당신과 프레임워크 제작자 사이의 관계는 비대칭적이다.
사실상 프레임워크 제작자는 당신에게 프레임워크와 혼인하기를 요구하는 것이다.
즉, 프레임워크에 대해 장기간에 걸친 막대한 헌신을 요청하는 것이다.
그럼에도 프레임워크 제작자는 어떠한 경우에도 그에 상응하는 헌신을 당신에게 하지는 않을 것이다.
이 혼인 관계는 일방적이며, 모든 위험과 부담은 오롯이 당신이 감수할 뿐 제작자가 감수하는 건 아무것도 없다.
당신이 고려해야 할 위험 요인들은 다음과 같다.
•
프레임워크의 아키텍처는 그다지 깔끔하지 않은 경우가 많다.
프레임워크는 의존성 규칙을 위반하는 경향이 있다.
업무 객체를 만들 때, 프레임워크 제작자는 자신의 코드를 상속할 것을 요구한다.
•
프레임워크는 어플리케이션의 초기 기능을 만드는 데는 도움이 될 것이다.
하지만 제품이 성숙해지면서 프레임워크가 제공하는 기능과 틀을 벗어나게 될 것이다.
•
프레임워크는 당신에게 도움되지 않는 방향으로 진화할 수도 있다.
도움도 되지 않는 신규 버전으로 업그레이드하느라 다른 일을 못할 수도 있다.
심지어 사용 중이던 기능이 사라지거나 반영하기 힘든 형태로 변경될 수도 있다.
•
새롭고 더 나은 프레임워크가 등장해서 갈아타고 싶을 수도 있다.
프레임워크를 사용할 수는 있다.
다만 프레임워크와 결합해서는 안 되며 적당히 거리를 둬야 한다.
업무 객체를 만들 때 프레임워크가 자신의 기반 클래스로부터 파생하기를 요구한다면, 대신 프록시를 만들고 업무 규칙에 플러그인할 수 있는 컴포넌트에 프록시를 위치시켜라.
프레임워크가 핵심 코드 안으로 들어오지 못하게 하기 위해, 핵심 코드에 플러그인 할 수 있는 컴포넌트에 프레임워크를 통합하고 의존성 규칙을 준수하라.
스프링을 사용해서 의존성을 연결할 때 @Autowired 어노테이션이 업무 객체 도처에 산재해서는 안 되며, 메인 컴포넌트에서 스프링을 사용해서 의존성을 주입하라.
예를 들어 자바를 사용한다면 표준 라이브러리와 반드시 결혼해야 한다.
이러한 관계는 정상이지만 선택적이어야 한다.
사례 연구: 비디오 판매
제품
웹 사이트에서 비디오를 판매하는 소프트웨어를 생각해보자.
판매하길 원하는 비디오들이 있고, 그걸 개인과 기업에게 웹을 통해 판매한다.
개인은 단품 가격을 지불해 스트리밍으로 보거나, 더 높은 가격을 내고 비디오를 다운로드해서 영구 소장할 수도 있다.
기업용 라이센스는 스트리밍 전용이며, 대량 구매를 하면 할인을 받을 수 있다.
일반적으로 개인은 시청자인 동시에 구매자다.
반면 기업은 다른 사람들이 시청할 비디오를 구매하는 사람이 따로 있다.
비디오 제작자는 비디오 파일과 비디오에 대한 설명서, 부속 파일을 제공해야 한다.
관리자는 신규 비디오 시리즈물을 추가하거나 기존 시리즈물에 비디오를 추가 또는 삭제하며, 다양한 라이센스에 맞춰 가격을 책정한다.
시스템의 초기 아키텍처를 결정하는 첫 단계는 액터와 유스케이스를 식별하는 일이다.
유스케이스 분석
네 개의 주요 액터는 분명하다.
단일 책임 원칙에 따르면 이들 네 액터가 시스템이 변경되어야 할 네 가지 주요 근원이 된다.
신규 기능으 추가하거나 기존 기능을 변경해야 한다면, 그 이유는 반드시 이들 액터 중 하나에게 해당 기능을 제공하기 위해서다.
따라서 우리는 시스템을 분할하여, 특정 액터를 위한 변경이 나머지 액터에게는 전혀 영향을 미치지 않게 만들고자 한다.
점선으로 된 유스케이스는 추상 유스케이스로 범용적인 정책을 담고 있으며, 다른 유스케이스에서 이를 더 구체화한다.
컴포넌트 아키텍처
이중으로 된 선은 아키텍처 경계를 나타낸다.
View, Presenter, Interactor, Controller로 분리된 전형적인 분할 방법을 확인할 수 있다.
또한 대응하는 액터에 따라 카테고리를 분리했다는 사실도 확인할 수 있다.
각 컴포넌트는 자신에게 할당된 View, Presenter, Interactor, Controller를 포함한다
특수한 컴포넌트인 Catalog View와 Catalog Presenter는 컴포넌트 내부에 추상 클래스로 코드화될 것이며, 상속받는 컴포넌트에서는 이들 추상 클래스로부터 상속받은 View와 Presenter 클래스들을 포함한다.
이러한 컴포넌트들의 컴파일과 빌드 환경은 각 컴포넌트를 독립적으로 나눌 수도 있고 아닐 수도 있다.
그림처럼 총 다섯 개의 jar 파일로 나눌 수도 있고, View+Presenter와 Interactor+Controller+Utility로 나눌 수도 있다.
이처럼 선택지를 열어 두면, 후에 시스템이 변경되는 양상에 맞춰 시스템 배포 방식을 조정할 수 있다.
빠져 있는 장
온라인 서점을 구축하고 있으며, 고객이 주문 상태를 조회할 수 있어야 한다는 유스케이스를 구현해야 한다고 해 보자.
클린 아키텍처는 잠시 한쪽으로 제쳐 놓고, 설계나 코드 조직화와 관련된 몇 가지 접근법을 살펴보자.
계층 기반 패키지
가장 단순한 첫 번째 설계 방식은 전통적인 수평 계층형 아키텍처다.
이 전형적인 계층형 아키텍처에는 웹, 업무 규칙, 영속성 코드를 위한 계층이 각각 하나씩 존재한다.
코드는 계층이라는 얇은 수평 조각으로 나뉘며, 각 계층은 유사한 종류의 것들을 묶는 도구로 사용된다.
엄격한 계층형 아키텍처의 경우 계층은 반드시 바로 아래 계층에만 의존해야 한다.
이 아키텍처는 엄청난 복잡함을 겪지 않고도 무언가를 작동시켜 주는 아주 빠른 방법이다.
문제는 소프트웨어가 커지고 복잡해지기 시작하면, 머지 않아 큰 그릇 세개만으로는 모든 코드를 담기엔 부족하다는 사실을 깨닫고, 더 잘게 모듈화해야 할지를 고민하게 될 것이다.
또한 계층형 아키텍처는 업무 도메인에 대해 아무것도 말해주지 않으며 나란히 놓고 보면 웹, 서비스, 레포지터리로 구성된 비슷한 모습이 보일 것이다.
기능 기반 패키지
이는 서로 연관된 기능, 도메인 개념, 또는 DDD의 루트 애그리거트에 기반하여 수직의 얇은 조각으로 코드를 나누는 방식이다.
전형적인 구현에서는 모든 타입이 하나의 패키지에 속하며, 패키지 이름은 그 안에 담긴 개념을 반영해 짓는다.
Controller, Service, Repository가 동일하게 등장하지만 모두가 단 하나의 패키지에 속하게 된다.
이제 코드의 상위 수준 구조가 업무 도메인에 대해 무언가를 알려주게 되며, 유스케이스가 변경될 경우 변경해야 할 코드를 모두 찾는 작업이 더 쉬워진다.
포트와 어댑터
엉클 밥은 이러한 접근법들이 업무/도메인에 초점을 둔 코드가 프레임워크나 데이터베이스 같은 기술적인 세부 구현과 독립적이며 분리된 아키텍처를 만들기 위해서라고 한다.
내부 영역은 도메인 개념을 모두 포함하는 반면, 외부 영역은 외부 세계와의 상호작용을 포함한다.
여기서 주요 규칙은 바로 외부가 내부에 의존하며, 절대 그 반대로는 안 된다는 점이다.
또한 내부에 존재하는 모든 것의 이름은 반드시 유비쿼터스 도메인 언어 관점에서 기술하라고 조언한다.
컴포넌트 기반 패키지
이 접근법에서는 업무 로직과 영속성 관련 코드를 하나로 묶는데, 이 묶음을 나는 컴포넌트라고 부른다.
컴포넌트에 대한 내 정의는 엉클 밥과는 약간 다르다.
‘컴포넌트는 멋지고 깔끔한 인터페이스로 감싸진 연관된 기능들의 묶음으로, 어플리케이션과 같은 실행 환경 내부에 존재한다’
이 방법론에서 소프트웨어 시스템은 하나 이상의 컨테이너로 구성되며, 각 컨테이너는 하나 이상의 컴포넌트를 포함한다.
또한 각 컴포넌트는 하나 이상의 클래스(또는 코드)로 구현된다.
이때 각 컴포넌트가 개별 jar 파일로 분리될지 여부는 직교적인 관심사다.
이 컴포넌트 내부에서 관심사 분리는 여전히 유효하며, 따라서 업무 로직은 데이터 영속성과 분리되어 있다.
조직화 vs 캡슐화
public 타입을 코드 베이스 어디에서도 사용할 수 있다면 패키지를 사용하는데 따르는 이점이 거의 없다.
패키지를 무시해 버리면 (캡슐화나 은닉을 하는데 아무런 도움도 되지 않으므로) 최종적으로 어떤 아키텍처 스타일로 만들려고 하는지는 아무런 의미가 없어진다.
접근 지시자를 적절하게 사용하면, 타입을 패키지로 배치하는 방식에 따라서 각 타입에 접근할 수 있는 정도(또는 접근 불가능한 정도)가 실제로 크게 달라질 수 있다.
모놀리틱 어플리케이션을 구축 중이라면 아키텍처 원칙을 강제할 때 자기 규율이나 컴파일 후처리 도구를 이용하지 말고, 반드시 컴파일러에 의지할 것을 권장한다.
다른 결합 분리 모드
다른 선택지로는 소스 코드 수준에서 의존성을 분리하는 방법도 있다.
정확하게는 서로 다른 소스 코드 트리로 분리하는 방법이다.
구현 관점에서 이렇게 분리하려면 빌드 도구를 사용해서 모듈이나 프로젝트가 서로 분리되도록 구성해야 한다.
이상적으로는 이러한 형태를 반복적으로 적용하여 어플리케이션을 구성하는 모든 컴포넌트 각각을 개별적인 소스 코드 트리로 구성해야 한다.
하지만 이는 너무 이상적인 해결책이며, 이처럼 나누다 보면 성능, 복잡성, 유지보수 문제가 생기기 때문이다.
빠져 있는 조언
최적의 설계를 꾀했더라도, 구현 전략에 얽힌 복잡함을 고려하지 않으면 설계가 순식간에 망가질 수도 있다는 사실을 강조한다.
설계를 어떻게 해야만 코드 구조로 매핑할 수 있을지, 그 코드를 어떻게 조직화할지, 런타임과 컴파일 타임에 어떤 결합 분리 모드를 적용할지를 고민하라.
가능하다면 선택사항을 열어두되, 실용주의적으로 행하라.
그리고 팀의 규모, 기술 수준, 해결책의 복잡성을 일정과 예산이라는 제약과 동시에 고려하라.
또한 선택된 아키텍처 스타일을 강제하는데 컴파일러의 도움을 받을 수 있을지를 고민하며, 데이터 모델과 같은 다른 영역에 결합되지 않도록 주의하라.
구현 세부사항에는 항상 문제가 있는 법이다.