Posts 클린아키텍처 내용 정리
Post
Cancel

클린아키텍처 내용 정리

clean-architecture-book

‘클린 아키텍처’ 기술 서적에 대해 학습했던 내용을 정리하기 위한 목적의 TIL 포스팅입니다.🙆‍♂️

1부 - 소개

1장 - 설계와 아키텍처란?

목표는?

  • 소프트웨어 아키텍처의 목표는 필요한 시스템을 만들고 유지보수하는데 투입되는 인력을 최소화하는데 있다.
  • 새로운 기능을 출시할 때 마다 비용이 증가한다면 나쁜 설계라 볼수 있다.

사례 연구 ~ 무엇이 잘못되었나?

  • 한 소프트웨어 개발 회사 몇년 동안 계속 개발 인력을 기하급수적으로 늘려가는데, 그에 비해 생산성 및 비용 그리고 매출이 현저하게 떨어진 사례가 있다.
  • 위 사례의 근본적 원인은 개발자들이 두 가지 거짓말에 속기 때문이다.
    • “코드는 나중에 정리하면 돼. 당장은 시장에 출시하는게 먼저야”
    • “지저분한 코드를 작성하면 단기간에는 빠르게 갈 수 있고, 장기적으로 볼때만 생산성이 낮아진다.”(더 중요한)
  • 여기서 핵심은 엉망으로 개발하면 깔끔하게 유지할 때보다 항상 더 느리다는 것이다.

결론

  • 소프트웨어 아키텍처를 심각하게 고려할 수 있으려면 좋은 소프트웨어 아키텍처가 무엇인지 이해해야 한다.
  • 비용은 최소화하고 생산성은 최대화할 수 있는 설계와 아키텍처를 가진 시스템을 만들려면, 이러한 결과로 이끌어 줄 시스템 아키텍처가 지닌 속성을 알고 있어야 한다.

2장 - 두 가지 가치에 대한 이야기

  • 소프트웨어 시스템은 이해관계자에게 서로 다른 두 가지 가치(행위와 구조)를 제공한다.

행위

  • 단순하게 동작하는 기능을 개발하는 것을 뜻한다.

아키텍처

  • ‘소프트웨어’ 라는 단어는 ‘부드러운(soft)’과 ‘제품(ware)’라는 단어의 합성어이다.
  • 이처럼 소프트웨어는 ‘부드러움을 지니도록’ 만들어졌다.
  • 소프트웨어를 만든 이유는 기계의 행위를 쉽게 변경할 수 있도록 하기 위해서다.
    • 만약 기계의 행위를 변경하는게 어렵게 만들었다면 하드웨어라 불렀을 것이다…
  • 즉, 핵심은 소프트웨어는 변경하기 쉬워야 한다.
    • 변경사항을 적용하는데 드느 어려움은 변경되는 범위(scope)에 비레해야 하며, 변경사항의 형태와는 관련 없어야 한다.
  • 이러한 변경하기 쉬운 소프트웨어를 만드려면 ‘시스템 아키텍처’ 가 중요하다.
    • 아키텍처가 특정 형태를 다른 형태보다 선호하면 할수록, 새로운 기능을 이 구조에 맞추는게 더 힘들어진다.
    • 따라서 아키텍처는 형태에 독립적이어야 하고, 그럴수록 더 실용적이다.

더 높은 가치

  • 필자는 위에서 언급한 ‘행위’와 ‘아키텍처’ 중에 ‘아키텍처’ 라고 주장하는데 그 이유는 다음과 같다.
    • 완벽하게 동작하지만 수정이 아예 불가능한 프로그램은 요구사항 변경시 동작하지 않을 것이고 결국 프로그램이 돌아가도록 만들수 없게 된다.
    • 동작은 하지만 변경이 쉬운 프로그램은 돌아가도록 만들면되고, 변경사항이 발생해도 유지보수 가능하다. 앞으로도 계속 유용할 것이다.
  • 수정이 현실적으로 불가능한 시스템은 존재하기 마련인데, 변경에 드는 비용이 변경으로 창출되는 수익을 초과하는 경우다..

아이젠하워 매트리스

image https://velog.io/@joosing/2-%EB%91%90-%EA%B0%80%EC%A7%80-%EA%B0%80%EC%B9%98%EC%97%90-%EB%8C%80%ED%95%9C-%EC%9D%B4%EC%95%BC%EA%B8%B0-Clean-Architecture-u45rjz6p

  • 긴급한 문제는 중요하지 않으며, 중요한 문제는 절대 긴급하지 않다는게 핵심이다.
  • 소프트웨어의 첫 번째 가치인 행위는 긴급하지만 매번 높은 중요도를 가지는 것은 아니다.
  • 소프트웨어의 두 번째 가치인 아키텍처는 중요하지만 즉각적인 긴급성을 필요로 하는 경우는 절대 없다.
  • 위 네 가지중 우선순위는 다음과 같다.
    • 1)긴급하고 중요한(아키텍처, 동작)
    • 2)긴급하지는 않지만 중요한(아키텍처)
    • 3)긴급하지만 중요치 않은(동작)
    • 4)긴급하지도 않고 중요치도 않은
  • 아키텍처, 즉 중요한 일은 가장 높은 두 순위를 차지하는 반면, 행위는 첫번째와 세번째에 위치한다는 점을 주목해야 한다.
  • 업무 관리자와 개발자가 흔히 저지르는 실수는 세번째 위치한 항목을 첫 번째로 격상시키는 것이다. 그러다 실패로 이어지게 된다..
    • 소프트웨어 개발자를 고용하는 이유는 바로 중요도가 높은 아키텍처를 업무 관리자에게 설득시키기 책임지기 위해서다.
    • 따라서 기능의 긴급성이 아닌 아키텍처의 중요성을 설득하는 일은 소프트웨어 개발팀이 마땅히 책임져야 한다.

아키텍처를 위해 투쟁하라

  • 효율적인 소프트웨어 개발팀은 투쟁에서 정면으로 맞서 싸운다.
    • 개발자는 소프트웨어를 안전하게 보호해야 할 책임이 있으며, 아키텍처를 위해 적극적으로 투쟁해야 한다.
  • 아키텍트는 기능을 개발하기 쉽고, 간편하게 수정 가능하며, 확장이 쉬운 아키텍처를 만들어야 한다.

2부 - 벽돌부터 시작하기: 프로그래밍 패러다임

3장 - 프로그래밍 패러다임 개요

구조적 프로그래밍

  • 간략히 요약하면 제어흐름의 직접적인 전환에 대해 규칙을 부과한다.

객체 지향 프로그래밍

  • 간략히 요약하면 제어흐름의 간접적인 전화에 대해 규칙을 부과한다.

함수형 프로그래밍

  • 간략히 요약하면 할당문에 대해 규칙을 부과한다.

생각할 거리

  • 각 패러다임은 프로그래머에게 권한을 박탈한다.
    • 즉, 패러다임은 무엇을 해야 할지 말하기보단 무엇을 해선 안되는지를 말해준다.</b
  • 세 가지 패러다임 각각은 우리에게서 goto문, 함수 포인터, 할당문을 앗아간다.
  • 우리에게 더 가져갈 수 있는 건 없을 것이다. 따라서 프로그래밍 패러다임은 아프오롣 딱 세가지 밖에 없을 것이다.
    • 중요한건 아니지만 이들 패러다임은 1958~1968년 총 10 년 동안 모두 만들어졌다. 이후 수십년이 지났지만 새롭게 등장한 패러다임은 없다..

결론

  • 세 가지 패러다임과 아키텍처의 세 가지 큰 관심사(함수, 컴포넌트 분리, 데이터 관리)가 어떻게 서로 연관되는지에 주목하자.

4장 - 구조적 프로그래밍

  • 1930년에 태어난 데이스크라는 1957년 결혼을 하는데, 그 당시 네덜란드에선 결혼 의식의 하나로 자신의 직업을 기입해야 했다.
  • 하지만 네덜란드에선 그의 직업인 ‘프로그래머’ 를 인정하지 않으려했고, 프로그래밍의 원리나 과학을 증명해내고자 했다.

증명

  • 데이스크라는 goto 문장이 모듈을 더 작은 단위로 재귀적으로 분해하는 과정에 방해가 되는 경우가 있다는 사실을 발견했다.
  • 반면 goto 문장을 사용하더라도 모듈을 분해할 때 문제 되지 않는 경우도 있었다.
  • 데이스크라는 이런 goto 문의 ‘좋은’ 사용 방식은 if/then/sle 와 do/while 과 같은 분기와 반복이라는 단순한 제어구조에 해당한다는 사실을 발견했다.
    • 모듈이 이러한 종류의 제어 구조만을 사용한다면 증명 가능한 단위로까지 모듈을 재귀적으로 세분화하는 것이 가능해 보였다.
  • 그는 이러한 제어 구조는 순차 실행과 결합했을 때 특별하다는 사실을 깨달았다.
  • 즉 모든 프로그램을 순차, 분기, 반복 이라는 세 가지 구조만으로 표현할 수 있다는 사실을 알게 되었다. (사실 2년 앞서 뵘과 야코피니가 발견했다)

해로운 성명서

  • 1968 년 데이스크라는 CACM 편집자에게 증명해낸 사실을 편지로 보냈고 같은해 3월호에 실렸다.
  • 하지만 비판하는자와 지지하는자들 사이에서 논란이 많았다.
  • 이러한 10년 이상의 논란을 데이스크라는 마침내 이겨냈다.
    • 컴퓨터 언어가 진화하면서 goto 문장은 계속 뒤편으로 밀려났고, 마침내 거의 사라졌다.
    • 대다수 현대적 언어는 goto 문장을 포함하지 않으며, 당연히 LISP 에선 첨부터 없었다.
  • 현재 우리 모두는 구조적 프로그래머이며, 여기엔 선택의 여지가 없다.
    • 제어 흐름을 제약 없이 직접 전화할 수 없는 선택권 자체를 언어에서 제공하지 않기 떄문이다.
    • 자바의 경우 break 문이나 예외가 goto 문과 유사하다볼 수 있찌만, 그래도 이들 구조는 제한적인 부분이 있다.
    • goto 키워드를 지원하는 언어에서도 goto 문의 목적지 범위를 현재 함수 안으로 한정시키는 편이다.

기능적 분해

  • 구조적 프로그래밍을 통해 모듈을 증명 가능한 더 작은 단위로 재귀적으로 분해할 수 있게 되었고, 이는 결국 모듈을 기능적으로 분해할 수 있음을 뜻했다.
  • 즉, 거대한 문제 기술서를 받더라도 문제를 고수준의 기능들로 분해할 수 있다.
  • 그리고 이들 각 기능은 다시 저수준의 함수들로 분해 가능하고, 이러한 분해 과정을 끝없이 반복할 수 있다.
  • 게다가 이렇게 분해한 기능들은 구조적 프로그래밍의 제한된 제어구조를 이용하여 표현할 수 있다.
  • 1970~80년대 몇몇 사람들이 구조적 분석이나 구조적 설계와 같은 기법을 더 디벨롭하여 널리알렸다.
  • 이들 기법을 사용하면 프로그래머는 대규모 시스템을 모듈과 컴포넌트로 나눌 수 있고, 더 나아가 모듈과 컴포넌트는 입증할 수 있는 아주 작은 기능들로 세분화할 수 있다.

엄밀한 증명은 없다.

  • 하지만 끝내 유클리드 계층 구조는 만들어지지 않았고 증명해내지 못했다.
  • 하지만 수학적인 증명만이 있는 것은 아니고 과학적 방법도 있었다.

과학이 구출하다.

  • 과학은 서술된 내용이 사실임을 증명하는 방식이 아니라 서술이 틀렸음을 증명하는 방식으로 동작한다.
  • 각고의 노력으로도 반례를 들 수 없는 서술이 있다면 목표에 부합할만큼은 참이라고 본다.
  • 결론적으로 수학은 증명 가능한 서술이 참임을 입증하는 원리라 볼 수 있는데, 과학은 증명 가능한 서술이 거짓임을 입증하는 원리라 볼 수 있다.

테스트

  • 데이크스트라는 “테스트는 버그가 있음을 보여줄 뿐, 버그가 없음을 보여줄 순 없다” 고 말한 적 있다.
    • 다시 말해 프로그램이 잘못되었음을 테스트를 통해 증명 가능하지만, 맞다고는 증명할 수 없다.
    • 테스트에 충분한 노력을 들였따면 테스트가 보장하는 것은 프로그램이 목표에 부합할만큼은 충분히 참이라고 여길 수 있게 해주는 것이 전부다.
  • 이 같은 사실은 소프트웨어는 수학적인 시도가 아니라 과학임을 알려준다. 최선을 다하더라도 올바르지 않음을 증명하는데 실패함으로써 올바름을 보여주기 때문이다.
  • 구조적 프로그래밍은 프로그램을 증명 가능한 세부 기능 집합으로 재귀적으로 분해할 것을 강요한다.
  • 그러고 나서 테스트를 통해 증명 가능한 세부 기능들이 거짓인지를 증명하려 시도한다.
  • 이처럼 거짓임을 증명하려는 테스트가 실패한다면, 이 기능들은 목표에 부합할 만큼은 충분히 참이라 여기게 된다.

결론

  • 구조적 프로그래밍이 오늘날까지 가치 있는 이유는 프로그래밍에서 반증 가능한 단위를 만들어 낼 수 있는 바로 이 능력 때문이다.
  • 또한 흔히 현대적 언어가 아무런 제약 없는 goto 문을 지원하지 않는 이유이기도 하다.
  • 뿐만 아니라 아키텍처 관점에서 기능적 분해를 최고의 실천법 중 하나로 여기는 이유기도 하다.
  • 가장 작은 기능에서부터 가장 큰 컴포넌트에 이르기까지 모든 수준에서 소프트웨어는 과학과 같고, 따라서 반증 가능성에 의해 주도된다.
  • 소프트웨어 아키텍트는 모듈, 컴포넌트, 서비스가 쉽게 반증 가능하도록(테스트하기 쉽도록) 만들기 위해 분주히 노력해야 한다.
  • 이를 위해 구조적 프로그래밍과 유사한 제한적인 규칙들을 받아들여 활용해야 한다.

5장 - 객체 지향 프로그래밍

  • 좋은 아키텍처를 만드는일은 객체 지향 OO(Object-Oriented) 설계 원칙을 이해하고 응용하는 데서 출발한다. 그렇다면 대체 OO 은 무엇일까?
  • OO 의 본질을 설명하기 위해 세 가지 주문에 기대는 부류도 있는데, 캡슐화, 상속, 다형성이 바로 그 주문이다.
  • 이들은 OO가 이 세 가지 개념을 적절하게 조합한 것이거나, 또는 OO 언어는 최소한 세 가지 요소를 반드시 지원해야 한다고 말한다.

캡슐화?

  • 캡슐화를 통해 데이터와 함수가 응집력 있게 구성된 집단을 서로 구분 짓는 선을 그을 수 있다.
  • 구분선 바깥에서 데이터는 은닉하고, 일부 함수만이 외부에 노출된다.
  • C언어과 같은 OO 가 아닌 언어에서도 충분히 완전한 캡슐화는 가능하다.
  • 다음 c 코드를 보자.

  • point.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// point.h
struct Point;
struct Point* makePoint(double x, double y);
double distance (struct Point *p1, struct Point *p2)

// point.c
#include "point.h"
#include <stdlib.h>
#include <math.h>

struct Point {
	double x,y;
};


struct Point *makePoint(double x, double y) {
	struct Point* p = malloc(sizeof(struct Point));
 	p->x = x;
    p->y = y;
    return p;
}

double distance(struct Point* p1, struct Point* p2) {
	double dx = p1->x - p2->x;
    double dy = p1->y - p2->y;
    return sqrt(dx*dx+dy*dy);
}
  • 위 코드에서 point.h를 사용하는 측에서 struct Point의 멤버에 접근할 방법이 전혀 없다.
  • 사용자는 makePoint() 함수와 distance() 함수를 호출할 수는 있지만, Point 구조체의 데이터 구조체가 어떻게 구현되어 있는지 알 수 없다. 이것이 완벽한 캡슐화이다.

  • 하지만 이후 C++ 이라는 형태로 OO가 등장했고, C가 제공하는 완전한 캡슐화는 깨지게 되었다.
  • C++ 에 public, private, protected 키워드를 도입함으로써 불완전한 캡슐화를 사실상 어느 정도 보완하기는 했다.
  • 하지만 이는 컴파일러가 헤더 파일에서 멤버변수를 볼 수 있어야 했기 때문에 조치한 임시방편일 뿐이다.
  • 자바와 C#은 헤더와 구현체를 분리하는 방식을 모두 버렸고, 이로 인해 캡슐화는 더욱 심하게 훼손되었다.
  • 이 때문에 OO가 강력한 캡슐화에 의존한다는 정의는 받아들이기 힘들다.
    • 실제로 많은 OO 언어가 캡슐화를 거의 강제하지 않는다.
    • OO 프로그래밍은 프로그래머가 충분히 올바르게 행동함으로써 캡슐화된 데이터를 우회해서 사용하지 않을거라는 믿음을 기반으로 한다.
    • 하지만 OO를 제공한다고 주창한 언어들이 실제로 C언어에서 누렸던 완벽한 캡슐화를 약화시켜 온 것은 틀림 없다.

상속?

  • OO 언어가 더 나은 캡슐화를 제공하진 못했으나, 상속만큼은 OO 언어가 확실히 제공했다.
  • 사실상 OO 언어가 있기 훨씬 이전에도 C프로그래머는 언어의 도움 없이 손수 이러한 방식으로 구현할 수 있었다.
  • OO 언어가 고안되기 훨씬 이전에도 상속과 비슷한 기법이 사용되었다고 볼 수 있는데, 이렇게 말하는데는 어폐가 있다.
    • 상속을 흉내내는 요령은 있었지만, 사실상 상속만큼 편리한 방식은 절대 아니기 때문이다.
    • 게다가 이 기법을 이용해서 다중 상속을 구현하기란 훨씬 더 어려운 일이었다.
  • OO 언어가 완전히 새로운 개념을 만들진 못했지만, 데이터 구조에 가면을 씌우는 일을 상당히 편리한 방식으로 제공했다고 볼 수는 있다.
  • 간략히 요약하면, 캡슐화에 대해선 OO에 점수를 줄 수 없고, 상속에 대해서만 0.5 점 정도를 부여할 수 있다.

다형성?

  • OO 언어가 있기 이전에 다형성을 표현할 수 있는 언어가 있었다. C로도 충분했다.
  • c 로 작성된 아래 복사 프로그램을 살펴보자.
1
2
3
4
5
6
7
#include <strdio.h>

void copy() {
	int c;
    while ((c=getchar()) != EOF)
    	putchar(c);
}
  • getchar() 함수는 STDIN에서 문자를 읽는다. 그러면 DTDIN은 어떤 장치인가?
  • putchar() 함수는 STDOUT으로 문자를 쓴다. 그런데 STDOUT은 또 어떤 장치인가?
  • 이러한 함수는 다형적이다. 즉 행위가 STDIN과 STDOUT의 타입에 의존한다.
  • STDIN과 STDOUT은 사실상 자바 형식의 인터페이스로, 자바에서는 각 장치별로 구현체가 있다.
  • C에서는 그렇다면 어떤 방식으로 문자를 읽는 장치 드라이버를 호출할 수 있는가?
    • 유닉스에서는 모든 입출력 장치 드라이버가 다섯 가지 표준 함수를 제공할 것을 요구한다.
    • 열기(open), 닫기(close), 읽기(read), 쓰기(write), 탐색(seek)이 바로 이 표준 함수들이다.
    • FILE 데이터 구조는 이들 다섯 함수를 가리키는 포인터들을 포함한다. 이 예제의 경우라면 다음과 같을 것이다.
1
2
3
4
5
6
7
struct FILE {
	void(*open)(char* name, int mode);
	void(*close)();
	int(*read)();
	void(*write)(char);
	void(*seek)(long index, int mode);
};

콘솔용 입출력 드라이버에서는 이들 함수를 아래와 같이 정의하며, FILE 데이터 구조를 함수에 대한 주소와 함께 로드할 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
 #include "file.h"

void open(char *name, int mode) {/*...*/ }
void close() {/*...*/ }
int read() {
	int c;
	/*...*/
	return c;
}
void write(char c) {/*...*/ }
void seek(long index, int mode} {/*...*/}

struct FILE console = { open, close, read, write, seek };

이제 STDIN을 FILE*로 선언하면, STDIN은 콘솔 데이터 구조를 가리키므로, getchar()는 아래와 같은 방식으로 구현할 수 있다.

1
2
3
4
5
extern struct FILE* STDIN;

int getchar() {
	return STDIN->read();
}
  • 다시 말해 getchar()는 STDIN으로 참조되는 FILE 데이터 구조의 read 포인터가 가리키는 함수를 단순히 호출할 뿐이다.
  • 이처럼 단순한 기법이 모든 OO가 지닌 다형성의 근간이 된다.
  • 예를 들어 C++에서는 클래스의 모든 가상 함수는 vtable이라는 테이블에 포인터를 가지고 있고, 모든 가상 함수 호출은 이 테이블을 거치게 된다.
  • 파생 클래스의 생성자는 생성하려는 객체의 vtable을 단순히 자신의 함수들로 덮어 쓸 뿐이다.

함수를 가리키는 포인터를 응용한 것이 다형성의 핵섬이다.

  • 1940년대 후반 폰 노이만 아키텍처가 처음 구현된 이후 프로그래머는 다형적 행위를 수행하기 위해 함수를 가리키는 포인터를 사용해 왔다. 따라서 OO가 새롭게 만든 것은 전혀 없다.
  • 하지만 이 말이 완전히 옳은 말은 아니다.
  • OO 언어는 다형성을 제공하지는 못했지만, 다형성을 좀 더 안전하고 더욱 편리하게 사용할 수 있게 해준다.
  • 함수에 대한 포인터를 직접 사용하여 다형적 행위를 만드는 이 방식에는 문제가 있는데, 함수 포인터는 위험하다는 사실이다.
    • 프로그래머는 이들 포인터를 초기화하는 관례를 준수해야 한다.
    • 또한 이들 포인터를 통해 모든 함수를 호출하는 관례를 지켜야 한다.
    • 프로그래머가 위 관례들을 망각하게 되면 버그가 발생하고, 이러한 버그는 수정이 어렵다.
  • OO 언어는 이러한 관례를 없애주며, 따라서 실수할 위험이 없다.
  • OO 언어를 사용하면 다형성은 대수럽지 않은 일이 된다.
  • OO 언어는 과거 C 프로그래머가 꿈에서야 볼 수 있던 강력한 능력을 제공한다.
  • 이러한 이유로 OO는 제어흐름을 간접적으로 전환하는 규칙을 부과한다고 결론 지을 수 있다.

다형성이 가진 힘

  • 이전 복사 프로그램의 새로운 입출력 장치가 생긴다면 프로그램엔 아무런 변경이 필요치 않다!!
    • 심지어 다시 복사 프로그램을 다시 커파일할 필요 조차 없다.
  • 왜일까? 복사 프로그램 소스 코드는 입출력 드라이버의 소스 코드에 의존하지 않기 때문이다.
  • 입출력 드라이버가 FILE 에 정의된 다섯 가지 표준함수를 구현한다면, 복사 프로그램에선 이 입출력 드라이버를 얼마든지 사용할 수 있다.
  • 다시말해 입출력 드라이버가 복사 프로그램의 플러그인이 된 것이다.
  • 왜 유닉스 운영체제는 입출력 장치들을 플러그인 형태로 만들었는가?
    • 프로그램이 장치 독립적이어야 하기 때문이다.
    • 프로그램에 다른 장치에서도 동일하게 동작할 수 있도록 하는 것이 우리가 진정 바랐던 일임을 깨달았기 때문이다.
  • 위와 같은 플러그인 아키텍처(Plugin Architecture)는 이처럼 입출력 장치 독립성을 지원하기 위해 만들어졌고, 등장이 후 거의 모든 운영체제에서 구현되었다.
  • 그럼에도 대다수의 프로래머는 직접 작성하는 프로그램에선 이러한 개념을 확장하여 적용치 않았는데, 함수를 가리키는 포인터를 사용하면 위험을 수반하기 때문이었다.
  • OO 의 등장으로 언제 어디서든 플러그인 아키텍처를 적용할 수 있게 되었다.

의존성 역전

image 출처: https://wedonttalknemore.tistory.com/10

  • 위 전형적인 호출 트리는 main 함수가 고수준 함수를 호출하고, 고수준 함수는 다시 중간 수준 함수를 호출하며, 중간 수준 함수는 다시 저수준 함수를 호출한다.
  • 이러한 호출트리에서 소스 코드 의존성 방향은 반드시 제어흐름을 따르게 된다.
  • 즉, 제어 흐름은 시스템의 행위에 따라 결정되며, 소스 코드 의존성은 제어흐름에 따라 결정된다.
  • 하지만 다형성이 끼어들면 얘기가 다르다.

image 출처: https://wedonttalknemore.tistory.com/10

  • 위 이미지에서 HL1 모듈은 ML1 모듈의 F() 함수를 호출한다.
  • 소스코드에서는 HL1 모듈은 인터페이스를 통해 F() 함수를 호출한다.
  • 이 인터페이스는 런타임에는 존재하지 않는다.
  • HL1은 단순히 ML1 모듈의 함수 F()를 호출할 뿐이다.

  • 하지만 ML1과 I 인터페이스 사이의 소스코드 의존성(상속 관계)이 제어 흐름과는 반대이다.
  • 이를 의존성 역전이라 부른다.
  • OO 언어가 다형성을 안전하고 편리하게 제공한다는 것은 소스 코드 의존성을 어디에서든 역전시킬 수 있다는 뜻이기도 하다.
    • 소스 코드 의존성은 소스 코드 사이에 인터페이스를 추가함으로써 방향을 역전시킬 수 있다.
    • OO언어로 개발된 시스템은 소스 코드 의존성 전부에 대해 방향을 결정할 수 있는 절대적인 권한을 갖는다.
    • 즉, 소스 코드 의존성이 제어흐름의 반향과 일치되도록 제한되지 않는다.
  • 아래 이미지처럼 업무 규칙(Business Rules)이 데이터베이스와 사용자 인터페이스에 의존하는 대신에, 시스템의 소스 코드 의존성을 반대로 배치하여 데이터베이스와 UI가 업무 규칙에 의존하게 만들 수 있다.

image 출처: https://wedonttalknemore.tistory.com/10

  • 즉, UI와 데이터베이스가 업무 규칙의 플러그인이 된다는 뜻이다.
  • 다시 말해, 업무 규칙의 소스 코드에서는 UI나 데이터베이스를 호출하지 않는다.
  • 결과적으로 업무 규칙(비즈니스 로직), UI, 데이터베이스는 세 가지로 분리된 컴포넌트 또는 배포 가능한 단위(jar, DLL, Gem 등)로 컴파일 할 수 있고, 이 배포 단위들의 의존성 역시 소스 코드 사이의 의존성과 같다.
  • 따라서 업무 규칙을 UI와 데이터베이스와는 독립적으로 배포할 수 있다.
  • UI나 데이터베이스에서 발생한 변경사항은 업무 규칙에 일절 영향을 미치지 않는다. 즉 이들 컴포넌트는 독립적으로 배포 가능하다.

  • 배포 독립성 - 컴포넌트의 소스 코드가 변경되면, 해당 코드가 포함된 컴포넌트만 다시 배포하면 된다.
  • 개발 독립성 - 배포 독립성이 있으면,서로 다른 팀에서 각 모듈을 독립적으로 개발할 수 있다.

나의 생각

  • 일반적으로 표현 영역 - 응용 서비스 영역 - 도메인 영역 - 인프라 영역으로 구성된 레이어드 아키텍처에서 Repository 인터페이스를 도메인 영역에 두고 인프라 영역에 이에 대한 구현체를 구현하는 것이 위의 한 예시라 볼 수 있을 것 같다.
    • 만약 다른 영역과 관련없는 도메인 영역의 특정 로직만 수정하게 될 경우 해당 모듈만 독립적으로 배포하면 되도록 하는 것이 위에서 설명한 배포 독립성이 될 것 같다.

결론

  • OO란 다형성을 이용하여 전체 시스템의 모든 소스 코드 의존성에 대한 절대적인 제어 권한을 획득할 수 있는 능력이다.
  • OO를 사용하면 아키텍트는 플러그인 아키텍처를 구성할 수 있고, 이를 통해 고수준의 정책을 포함하는 모듈은 저수준의 세부사항을 포함하는 모듈에 대해 독립성을 보장할 수 있다.
  • 저수준의 세부사항은 중요도가 낮은 플러그인 모듈로 만들 수 있고, 고수준의 정책을 포함하는 모듈과는 독립적으로 개발하고 배포할 수 있다.

나의 생각

  • 인프라스트럭쳐 영역과 같은 실제 구현기술을 다루는 저수준 모듈은 플러그인 모듈로서 쉽게 다른 것으로 바꿀 수 있도록 하는게 좋다는 것을 말하는 것 같다.

스크린샷 2023-07-09 오후 8 03 42

  • 만약 위 이미지에서 인프라 영역이 DB 를 통해 데이터를 조회하는 Repository 구현체를 가지고 있다고 했을때,
  • 이를 만약 파일 시스템으로부터 조회하도록 변경하고자 한다면 파일 시스템으로부터 조회해오는 인프라 모듈만 새로 구현에서 교체하면 되도록 독립적으로 개발 후 배포하면 될 것이다.

6장 - 함수형 프로그래밍

  • 함수형 프로그래밍이라는 개념은 프로그래밍 그 자체보다 앞서 등장했다.
  • 이 패러다임에서 핵심이 되는 기반은 람다 계산법으로 알로조 처치가 1930년대에 발명했다.

정수를 제곱하기

  • 아래는 25까지의 정수의 제곱을 출력하는 자바 언어의 예시이다.
1
2
3
4
5
6
public class Squint {
	public static void main(String args[]) {
    	for (int i=0; i<25; i++)
        	System.out.println(i*1);
    }
}
  • 리스프에서 파생한 클로저(Clojure)는 함수형 언어로, 클로저를 이용하면 같은 프로그램을 다음과 같이 구현할 수 있다.
1
2
3
4
(println ;___출력한다. 
	(take 25 ;___ 처음부터 25까지
    	(map (fn [x] (* x x)) ;__ 제곱을 
        	(range)))) ;___ 정수의
  • 리스프에선 함수를 괄호안에 넣는 방식으로 호출한다. (range) 는 range 함수를 호출한다.
  • 표현식 (fn [x] (* x x)) 는 익명 함수로, 곱셈 함수를 호출하면서 입력 인자를 두 번 전달한다. 즉, 입력의 제곱을 계산한다.
  • 전체 코드를 다시 해석해보자.
    • 1)range 함수는 0부터 시작해서 끝이 없는 정수 리스트를 반환한다.
    • 2)반환된 정수 리스트는 map 함수로 전달되고, 각 정수에 대해 제곱을 계산하는 익명 함수를 호출하여, 모든 정수의 제곱에 대해 끝이 없는 리스트를 생성한다.
    • 3)제곱된 리스트는 take 함수로 전달되고, 이 함수는 앞의 25개까지의 항목으로 구성된 새로운 리스트를 반환한다.
    • 4)println 함수는 입력 값을 출력하는데, 이 경우 입력은 앞의 25개의 정수에 대한 제곱 값으로 구성된 리스트다.

클로저(함수형 언어)와 자바의 극단적 차이

  • 자바는 가변 변수(mutable variable)를 사용하는데, 가변 변수는 프로그램 실행중에 상태가 변할 수 있다.(위의 예시에서 반복문을 제어하는 i 변수)
  • 클로저는 가변 변수가 전혀 없다. x 와 같은 변수가 한 번 초기화되면 절대 변하지 않는다.
    • 이는 우리에게 놀라운 사실을 알려준다. 함수형 언어에선 변수는 변경되지 않는 다는 것이다.

불변성과 아키텍처

  • 아키텍트는 왜 변수의 가변성을 고려하는가?
  • 가변 변수로 인해 발생하는 문제는 다음과 같다.
    • 경합(race) 조건
    • 교착상태(deadlock) 조건
    • 동시 업데이트(concurrent update)
  • 다시 말해 우리가 동시성 애플리케이션에서 마주치는 모든 문제, 즉 다수의 스레드와 프로세스가 여러 개인 상황에서도 설계한 시스템이 여전히 강건하길 바란다.
  • 그렇다면 이제 불변성이 정말 실현 가능한지를 스스로에게 반드시 물어봐야 한다.
    • 저장 공간이 무한하고 프로세서 속도가 무한 빠르다고 전제한다면 위 질문에 대한 대답을 긍정적일 것이다./
    • 반대로 자원이 무한대가 아니라면 대답은 조금 미묘할 것이다.
    • 위처럼 불변성은 실현 가능하겠지만, 이처럼 타협이 조금 필요한데 어떤 타협이 필요한지 살펴보자.

가변성의 분리

  • 불변성과 관련하여 가장 주요한 타협 중 하나는 애플리케이션, 또는 애플리케이션 내부의 서비스를 가변 컴포넌트와 불변 컴포넌트로 분리하는 일이다.
  • 불변 컴포넌트에서는 순수하게 함수형 방식으로만 작업이 처리되며, 가변 변수는 사용되지 않는다.
  • 불변 컴포넌트는 변수의 상태를 변경할 수 있는 순수 함수형 컴포넌트가 아닌 하나 이상의 다른 컴포넌트와 서로 통신한다.

image 출처: https://wedonttalknemore.tistory.com/11

  • 상태 변경은 컴포넌트를 갖가지 동시성 문제에 노출하는 꼴이므로, 흔히 트랜잭션 메모리와 같은 실천법을 사용하여 동시 업데이트와 경합 조건 문제로부터 가변 변수를 보호한다.
  • 트랜젝션 메모리는 데이터베이스가 디스크의 레코드를 다루는 방식과 동일한 방식으로 메모리의 변수를 처리한다.
    • 즉, 트랜잭션을 사용하거나 또는 재시도 기법을 통해 이들 변수를 보호한다.
  • 이러한 접근법의 예시로 클로저의 atom 기능을 들 수 있다.
    • 클로저의 atom 기능과 관련된 예시는 여기를 참고바란다.
  • 말하고자하는 요지는, 애플리케이션을 제대로 구조화하려면 변수를 변경하는 컴포넌트와 변경하지 않는 컴포넌트를 분리해야 한다는 것이다.
    • 그리고 이렇게 분리하려면 가변 변수들을 보호하는 적절한 수단을 동원해 뒷받침해야 한다.
  • 현명한 아키텍트라면 가능한 한 많은 처리를 불변 컴포넌트로 옮겨야 하고, 가변 컴포넌트에서는 가능한 한 많은 코드를 빼내야 한다.

이벤트 소싱

  • 기술의 발전으로 인해 저장 공간과 처리 능력의 한계는 우리 시야에서 급격히 사라지고 있다.
  • 더 많은 메모리를 확보할수록, 기계가 더 빨라질수록 필요한 가변 상태는 더 적어진다.
  • 간단한 예로, 고객 계좌를 관리하는 은행 어플리케이션을 생각해보자.
    • 이 애플리케이션에서는 입금 트랜잭션과 출금 트랜잭션이 실행되면 잔고를 변경해야 한다.
    • 여기서 계좌 잔고를 변경하는 대신 트랜잭션 자체를 저장한다고 생각해보자.
    • 누군가 잔고 조회를 요청할 때마다 계좌 개설 시점부터 발생한 모든 트랜잭션을 단순히 더한다.
    • 이 전략에서는 가변 변수가 하나도 필요 없다.
  • 당연하게 위와 같은 접근법은 터무니 없다.
    • 시간이 지날수록 트랜잭션 수는 끝없이 증가하고, 잔고계산에 필요한 컴퓨팅 자원은 걷잡을 수 없이 커진다.
    • 따라서 이 전략이 영원히 실현 가능하려면 무한한 저장 공간과 무한한 처리 능력이 필요하다.
  • 하지만 이 전략이 영원히 동작하게 만들 필요는 없다.
    • 아마도 애플리케이션의 생명주기 동안만 문제없이 동작할 정도의 저장공간과 처리 능력만 있으면 충분할 것이다.
  • 이벤트 소싱에 깔려 있는 기본 발상이 바로 이것이다.
    • 이벤트 소싱은 상태가 아닌 트랜잭션을 저장하자는 전략이다.
    • 상태가 필요해지면 단순히 상태의 시작점부터 모든 트랜잭션을 처리한다.
    • 물론 지름길을 택할 순 있다. 매일 자정에 상태 계산 후 저장한다. 그 후 상태 정보가 필요해지면 자정 이후의 트랜잭션만을 처리하면 된다.
  • 위 전략은 저장 공간을 많이 필요할 것인데 우리 시대는 저장 곤간을 이제 충분히 확보 가능하다.
  • 더 중요한 점은 데이터 저장소에서 삭제되거나 변경되는 것은 하나도 없다는 사실이다. 데이터의 CRUD 가 아닌 CR 만 있는 것이다.
  • 또한 데이터 저장소에서 변경과 삭제가 전혀 발생하지 않으므로 동시 업데이트 문제 또한 일어나지 않는다.
  • 저장 공간과 처리 능력이 충분하면 애플리케이션이 완전한 불변성을 갖도록 만들 수 있고, 따라서 완전한 함수형으로 만들 수 있다.
  • 위 이야기가 여전히 터무늬없게 들린다면, 소스 코드 버전 관리 시스템(ex. git)이 정확히 이 방식으로 동작한다는 사실을 떠올려 보면 도움이 될 것이다.

결론

  • 프로그래밍 패러다임에 대해 요약하면 다음과 같다.
    • 구조적 프로그래밍은 제어흐름의 직접적인 전환에 부과되는 규율이다.
    • 객체 지향 프로그래밍은 제어흐름의 간접적인 전환에 부과되는 규율이다.
    • 함수형 프로그래밍은 변수 할당에 부과되는 규율이다.
  • 세 패러다임 모두 우리에게서 무언가를 앗아갔다.
    • 각 패러다임은 코드를 작성하는 방식의 형태를 한정시킨다.
    • 어떤 패러다임도 우리의 권한이나 능력에 무언가를 보태지는 않는다.
  • 지난 반세기 동안 배운 것은 해서는 안되는 것에 대해서다.
    • 이 사실을 깨닫는다면 소프트웨어는 급격히 발전하는 기술이 아니라는 진실을 마주하게 된다.
    • 1946년 앨런 튜링이 전자식 컴퓨터에서 실행할 거의 최초의 코드를 작성할 때 사용한 소프트웨어 규칙과 지금의 소프트웨어 규칙은 조금도 다르지 않다.
    • 도구, 하드웨어 모두 많이 발전했지만, 소프트웨어의 핵심은 여전히 그대로다.
  • 소프트웨어는 순차(sequence), 분기(selection), 반복(iteration), 참조(indirection)로 구성되며, 그 이상 그 이하도 아니다.

Reference

3부 설계 원칙

  • SOLID 원칙은 함수와 데이터 구조를 클래스로 배치하는 방법, 그리고 이들 클래스를 서로 결합하는 방법을 설명해준다.
    • ‘클래스’ 라는 단어를 사용했다고 해서 SOLID 원칙이 객체 지향 소프트웨어에만 적용된다는 뜻은 아니다.
    • 여기서 클래스는 단순히 함수와 데이터를 결합한 집합을 가리킨다.
    • 소프트웨어 시스템은 모두 이러한 집합을 포함하는데, SOLID 원칙은 이러한 집합에 적용된다.
  • SOLID 원칙의 목적은 다음과 같다.
    • 변경에 유연하도록
    • 이해하기 쉽도록
    • 많은 소프트웨어 시스템에 사용될 수 있는 컴포넌트의 기반이 될 수 있도록
  • SOLID 원칙은 코드 수준보단 조금 상위(모듈 수준)에서 적용되며 모듈과 컴포넌트 내부에서 사용되는 소프트웨어 구조를 정의하는데 도움을 준다.
  • 아래 세부 쳅터에선 SOLID 원칙을 다 설명 후 컴포넌트 세계에서 SOLID 원칙에 대응하는 원칙들을 설명하고, 이어서 고수준의 아키텍처 원칙까지 설명할 것이다.
  • SOLID 원칙을 간략하게 요약하면 다음과 같다.
    • SRP(단일 책임 원칙): 각 소프트웨어 모듈은 변경의 이유가 단 하나여야 한다.
    • OCP(개방 폐쇄 원칙): 기존 코드를 수정하기 보단 반드시 새로운 코드를 추가하는 방식으로 시스템의 행위를 변경할 수 있도록 설계해야만 소프트웨어 시스템을 쉽게 변경할 수 있다.
    • LSP(리스코프 치환 원칙): 상호 대체 가능한 구성요소를 이용해 소프트웨어 시스템을 만들 수 있으려면, 이들 구성요소는 반드시 서로 치환 가능해야 한다는 계약을 지켜야 한다.
    • ISP(인터페이스 분리 원칙): 사용하지 않는 것에 의존하지 않아야 한다.
    • DIP(의존성 역전 원칙): 고수준 정책을 구현하는 코드는 저수준 세부사항을 구현하는 코드에 절대 의존해선 안된다. 대신 세부사항이 고수준 정책에 의존해야 한다.
  • 아래 내용에선 이들 원칙이 아키텍처 관점에서 지닌 의미에 집중하여 논의하고자 한다.

7장 - SRP: 단일 책임 원칙

  • 의미가 가장 잘 전달되지 못한 원칙
  • 함수는 반드시 단 하나의 일만 해야 한다는 원칙이 아니다.
    • 이 원칙은 커다란 함수를 작은 함수들로 리팩터링하는 더 저수준에서 사용된다.
    • SOLID 원칙도 SRP도 아니다.
  • 역사적으론 아래와 같이 기술되어 있다.

단일 모듈은 변경의 이유가 하나, 오직 하나뿐이어야 한다.

  • 소프트웨어 시스템은 사용자와 이해관계자를 만족시키기 위해 변경된다.
  • SRP가 말하는 ‘변경의 이유’란 바로 이들 사용자와 이해관계자를 가리키는데 사용자는 여럿이 될 수 있다.
  • 여기에서의 ‘사용자’는 해당 변경을 요청하는 한 명 이상의 사람들을 가리키는데 이러한 집단을 ‘액터’라 부른다.
  • 그랬을 경우 다음과 같이 최종 정리할 수 있다.

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

  • 모듈’이란 무엇인가? 가장 단순한 정의는 소스 파일이다.
  • 하지만 일부 언어와 개발 환경에서는 코드를 소스 파일에 저장하지 않는다.
  • 이러한 경우 모듈은 단순히 함수와 데이터 구조로 구성된 응집된 집합이다.
  • ‘응집된(cohesive)’ 라는 단어가 SRP를 암시한다. 단일 액터를 책임지는 코드를 함께 묶어주는 힘이 바로 응집성(cohesion)이다.
  • 이 원칙을 위반하는 다음 징후들을 살펴보자.

징후1: 우발적 중복

image

  • 위 Employee 클래스는 세 가지 메서드 calulatePay(), reportHours(), save()를 가진다.
  • 이 클래스는 SRP를 위반하는데, 이들 세 가지 메서드가 서로 매우 다른 세 명의 액터를 책임지기 때문이다.
    • calculatePay() 메서드는 회계팀에서 기능을 정의하며, CFO 보고를 위해 사용한다.
    • reportHours() 메서드는 인사팀에서 기능을 정의하고 사용하며, COO 보고를 위해 사용한다.
    • save() 메서드는 데이터베이스 관리자(DBA)가 기능을 정의하고, CTO 보고를 위해 사용한다.
  • 개발자가 이 세 메서드를 Employee라는 단일 클래스에 배치하여 세 액터가 서로 결합되어 버렸다.
  • 이로 인해 예를 들어 CFO 팀에서 결정한 조치가 COO팀이 의존하는 무언가에 영향을 줄 수 있다.

image

  • 예를 들어, CFO 팀에서 초과 근무를 제외한 업무 시간을 계산하는 방식(regularHours 함수)을 약간 수정하기로 결정했다고 해보자.
  • 반면, 인사를 담당하는 COO 팀에선 이러한 변경사항을 원치 않는다고 할때 개발자는 다른 곳에서도 (reportHours 함수) 이를 참조하고 있다는 것을 눈치채지 못할 것이다. 이때 COO 팀에서 의도치 않은 큰 문제가 발생할 것이다. (잘못된 계산으로 수백만 달러 예산이 지출되는…)

  • 서로 다른 액터가 의존하는 코드를 너무 가까이 배치했기 때문에 발생한 문제고 SRP 는 서로 다른 액터가 의존하는 코드를 서로 분리하라고 말한다.

징후2: 병합

  • 소스파일에 다양하고 많은 메서드를 포함하면 병합이 자주 발생할 수 있다.
  • 특히 이들 메서드가 서로 다른 액터를 책임진다면 병합이 발생할 가능성은 높아진다.
  • 예를 들어, DBA가 속한 CTO 팀에서 데이터베이스의 Employee 테이블 스키마를 수정하기로 결정했고, 동시에 인사 담당자가 속한 COO 팀에서는 reportHours() 메서드의 보고서 포맷을 변경하기로 결정했다고 해보자.
  • 두 명의 서로 다른 개발자가 Employee 클래스를 체크아웃받은 후 변경사항을 적용한다.
  • 이들 변경사항은 서로 충돌할 수 밖에 없다. 결과적으로 병합이 발생한 것이다.
  • 이러한 문제를 벗어나는 방법은 서로 다른 액터를 뒷받침하는 코드를 서로 분리하는 것이다.

해결책

  • 해결책은 다양하지만, 그 모두가 메서드를 가기 다른 클래스로 이동시키는 방식이다.
  • 아마 가장 확실한 해결책은 데이터와 메서드를 분리하는 방식일 것이다.
  • 즉, 아무런 메서드가 없는 간단한 데이터 구조인 EmployeeData 클래스를 만들어, 세 개의 클래스가 공유하도록 한다.

image

  • 각 클래스는 자신의 메서드에 반드시 필요한 소스 코드만을 포함한다.
  • 세 클래스는 서로의 존재를 몰라야 한다. 따라서 ‘우연한 중복’을 피할 수 있다.

  • 반면 이 해결책은 개발자가 세 가지 클래스를 인스턴스화하고 추적해야 한다는 단점이 있다.
  • 이럴때 흔히 쓰는 기법으론 퍼사드 패턴이 있다.

image

  • EmployeeFacade에 코드는 거의 없다.
  • 이 클래스는 세 클래스의 객체를 생성하고, 요청된 메서드를 가지는 객체로 위임하는 일을 책임진다.

  • 또한, 어떤 개발자는 아래와 같이 가장 중요한 업무 규칙을 데이터와 가깝게 배치하는 방식을 선호한다.
  • 이 경우라면 가장 중요한 메서드는 기존의 Employee 클래스에 그대로 유지하되, Employee 클래스를 덜 중요한 나머지 메서드들에 대한 퍼사드로 사용할 수도 있다.

image

  • 모든 클래스는 반드시 단 하나의 메서드를 가져야 한다는 주장에 근거하면 앞의 해결책에 반대할 수도 있다.
  • 하지만 실제로 각 클래스에서는 다수의 private 메서드를 포함할 것이다. (현실적으로)
  • 여러 메서드가 하나의 가족을 이루고, 메서드의 가족을 포함하는 각 클래스는 하나의 유효범위가 된다.
  • 해당 유효범위 바깥에서는 이 가족에게 감춰진 식구(private 멤버)가 있는지를 전혀 알 수 없다.

결론

  • SRP 는 메서드와 클래스 수준의 원칙이다.
  • 하지만 이보다 상위의 두 수준에서도 다른 형태로 다시 등장한다.
    • ‘컴포넌트 수준’에서는 공통 폐쇄 원칙(Common Closure Principle)이 된다.
    • ‘아키텍처 수준’에서는 아키텍처 경계(Architectural Boundary)의 생성을 책임지는 변경의 축이 된다.
  • 이러한 개념들은 이후 장에서 살펴본다.

8장 - OCP: 개방 폐쇄 원칙

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

  • 소프트웨어 개체의 행위는 확장할 수 있어야 하지만, 이때 개체를 변경해서는 안 된다는 뜻이다.
  • 만약 요구사항을 확장하는 데 소프트웨어를 많이 수정해야 한다면, 그 소프트웨어 시스템을 설계한 아키텍트는 엄청난 실패를 한 것이다.
  • 소프트웨어 설계를 공부하기 시작한지 얼마 안된 사람들 대다수는 OCP를 클래스와 모듈을 설계할 때 도움되는 원칙이라 알고 있다.
    • 하지만 아키텍처 컴포넌트 수준에서 OCP 를 고려할 때 훨씬 중요한 의미를 가진다.

사고 실험

  • 재무제표를 웹 페이지로 보여주는 시스템이 있다고 생각해보자.
  • 웹 페이지에 표시되는 데이터는 스크롤할 수 있으며, 음수는 빨간색으로 출력한다.
  • 이것을 이해관계자가 보고서 형태로 변환해서 흑백 프린터로 출력해 달라고 요청했다고 해보자.

  • 변경되는 부분은 다음과 같다.
    • 데이터 : 재무 데이터 -> 보고서용 재무 데이터
    • 출력 방식 :
      • 1)웹에 표시
      • 2)프린터 출력
  • 소프트웨어 아키텍처가 훌륭하다면 변경되는 코드의 양이 가능한 한 최소화될 것이다. 이상적인 변경량은 0이다.
    • 서로 다른 목적으로 변경되는 요소를 적절하게 분리하고(단일 책임 원칙, SRP), 이들 요소 사이의 의존성을 체계화함으로써(의존성 역전 원칙, DIP) 변경량을 최소화할 수 있다.

image

  • 여기서 얻을 수 있는 가장 중요한 영감은 보고서 생성이 두 개의 책임으로 분리된다는 사실이다.
    • 하나는 보고서용 데이터를 계산하는 책임
    • 또 다른 하나는 이 데이터를 웹으로 보여주거나 종이로 프린트하기에 적합한 형태로 표현하는 책임
  • 위와 같이 두 책임을 분리했다면, 두 책임 중 하나에서 변경이 발생하더라도 다른 하나는 변경되지 않도록 소스 코드 의존성도 확실히 조직화해야 한다.
    • 또한, 새로 조직화한 구조에선 행위가 확장될 때 변경이 발생되지 않음을 보장해야 한다.
  • 위 목적을 달성하려면 처리 과정을 클래스 단위로 분할하고, 이들 클래스를 아래 그림처럼 컴포넌트 단위로 구분해야 한다.

image

  • Controller - 좌측 상단
  • Interactor - 우측 상단
  • Dababase - 우측 하단
  • Presenter & View - 좌측 하단

  • <I>로 표시된 클래스는 인터페이스이며, <DS>로 표시된 클래스는 데이터 구조다.
  • 화살표가 열려 있다면 사용 관계이며, 닫혀 있다면 구현관계 또는 상속 관계다

  • 여기에서 모든 의존성이 소스 코드 의존성을 나타내고 있다.
  • 예를 들어 화살표가 A 클래스에서 B클래스로 향한다면, A 클래스 에서는 B 클래스를 호출하지만 B 클래스에서는 A 클래스를 전혀 호출하지 않음을 뜻한다.

  • 여기서 주목해야 할 또 다른 점은 이중선은 화살표와 오직 한 방향으로만 교차한다는 사실이다.
  • 아래 이미지처럼 모든 컴포넌트 관계는 단방향으로만 이루어진다는 뜻이다. 이들 화살표는 변경으로부터 보호하려는 컴포넌트를 향하도록 그려진다.

image

  • A 컴포넌트에서 발생한 변경으로부터 B 컴포넌트를 보호하려면 A 컴포넌트가 B 컴포넌트를 의존해야 한다.
    • 이 예제의 경우엔 Presenter 에서 발생한 변경으로부터 Controller 를 보호하고, View 에서 발생한 변경으로부터 Presenter 를 보고하고자 한다. Interactor 는 다른 모든 것에서 발생한 변경으로부터 보호하고자 한다.
  • Interactor 는 OCP를 가장 잘 준수할 수 있는 곳에 위치한다.
    • Database, Controller, Presenter, View 에서 발생한 어떤 변경도 Interactor 에 영향을 주지 않는다.
    • 왜 Interactor 가 이처럼 특별한 위치를 차지해야만 할까?
    • 그 이유는 바로 Interactor 가 업무 규칙을 포함하기 때문이다. 또한 애플리케이션에서 가장 높은 수준의 정책을 포함하기 때문이다.
    • Interactor 주변 컴포넌트는 주변적인 문제를 처리한다. 가장 중요한 문제는 Interactor 가 담당한다.
  • 여기서 보호의 계층구조가 ‘수준(Level)’이라는 개념을 바탕으로 어떻게 생성되는지 주목하자.
    • Interactor 는 가장 높은 수준의 개념이며, 따라서 최고의 보호를 받는다.
    • View 는 가장 낮은 수준의 개념이며, 따라서 거의 보호를 받지 못한다.
    • Presenter는 View 보단 높고 Controller 나 Interceptor 보단 낮은 수준에 위치한다.
  • 이것이 바로 아키텍처 수준에서 OCP가 동작하는 방식이다.
    • 아키텍트는 기능이 어떻게, 왜, 언제 발생하는지에 따라 기능을 분리하고, 분리한 기능을 컴포넌트의 계층 구조로 조직화한다.
    • 컴포넌트 계층구조를 이와 같이 조직화하면 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있다.

방향성 제어

  • 위 예시에서 FinancialDataGateway 인터페이스는 FinancialReportGenerator와 FinancialDataMapper 사이에 위치하는데, 이는 의존성을 역전시키기 위해서다.
  • FinancialDataGateway 인터페이스가 없었다면, 의존성이 Interactor 컴포넌트에서 Database 컴포넌트로 바로 향하게 된다.
  • FinalcialReportPresenter 인터페이스와 2개의 View 인터페이스도 같은 목적을 가진다.

정보 은닉

  • FinancialReportRequester 인터페이스는 방향성 제어와는 다른 목적을 가진다.
  • 이 인터페이스는 FinancialReportController가 Interactor 내부에 대해 너무 많이 알지 못하도록 막기 위해서 존재한다.
    • 만약 이 인터페이스가 없었다면, Controller는 FinancialEntities에 대해 추이 종속성을 가지게 된다.
  • 추이 종속성을 가지게 되면, 소프트웨어 엔티티는 ‘자신이 직접 사용하지 않는 요소에는 절대로 의존해서는 안 된다’는 소프트웨어 원칙을 위반하게 된다.
    • 이 원칙은 인터페이스 분리 원칙(ISP)와 공통 재사용 원칙(CRP)을 설명할 때 다시 한번 설명한다.
  • 다시 말해, Controller에서 발생한 변경으로부터 Interactor를 보호하는 일의 우선순위가 가장 높지만, 반대로 Interactor에서 발생한 변경으로부터 Controller도 보호되기를 바란다. 이를 위해 Interactor 내부를 은닉한다.

Note: 클래스 A가 클래스 B를 의존하고, 다시 B가 C를 의존한다면 클래스 A는 C에 의존하게 되는데 이를 추이 종속성이라 한다. 클래스 이외의 소프트웨어의 모든 엔티티에도 동일하게 적용된다. 만약 클래스 의존성이 순환적이라면, 모든 클래스가 서로 의존하게 되는 문제가 있다.

결론

  • OCP는 시스템의 아키텍처를 떠받치는 원동력 중 하나다.
  • OCP의 목표는 시스템을 확장하기 쉬운 동시에 변경으로 인해 시스템이 너무 많은 영향을 받지 않도록 하는 데 있다.
  • 이를 위해 시스템을 컴포넌트 단위로 분리하고, 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있는 형태의 의존성 계층구조가 만들어지도록 해야 한다.

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

  • 1988년 바바라 리스코프는 하위 타입을 아래와 같이 정의했다.

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

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

image

  • 위 License라는 클래스는 calcFee()라는 메서드를 가지며, Billing 애플리케이션에서 이 메서드를 호출한다.
  • License에는 PersonalLicense와 BusinessLicense라는 두 가지 ‘하위 타입’이 존재한다.
    • 이들 두 하위 타입은 서로 다른 알고리즘을 이용해서 라이선스 비용을 계산한다.
  • 위 설계는 LSP를 준수한다.
    • Billing 애플리케이션의 행위가 License 하위 타입 중 무엇을 사용하는지에 전혀 의존하지 않기 때문이다.
    • 이들 하위 타입은 모두 License 타입을 치환할 수 있다.

정사각형/직사각형 문제

image

  • 이 예제에서 Square(정사각형)는 Rectangle(직사각형)의 하위 타입으로는 적합하지 않다.
  • Rectangle의 높이와 너비는 서로 독립적으로 변경될 수 있는 반면, Square의 높이와 너비는 반드시 함께 변경되기 때문이다.
  • User는 대화하고 있는 상대가 Rectangle이라고 생각하므로 혼동이 생길 수 있다. 아래의 코드를 보자
1
2
3
4
Rectangle r = ...
r.setW(5)
r.setH(2)
assert(r.area() == 10);
  • … 코드에서 Square를 생성한다면 assert 문은 실패하게 된다.
  • 이런 형태의 LSP 위반을 막기 위한 유일한 방법은 Rectangle이 실제로는 Square인지를 검사하는 메커니즘을 User에 추가하는 것이다.
  • 하지만 그럴 경우에는 User의 행위가 사용하는 타입에 의존하게 되므로, 서로 치환할 수 없게 된다.
    • User에 Rentangle과 Square라는 타입에 따라 바뀌는 행위를 정의함으로써 Rentangle과 Square를 치환할 경우 정상적인 동작이 불가능해진다.

LSP와 아키텍처

  • 앞서 본 것처럼 LSP는 객체 지향에서의 상속을 사용하도록 가이드하는 방법 정도로 가이드되었다.
  • 하지만 시간이 지나면서 LSP는 인터페이스와 구현체에도 적용되는 더 광범위한 소프트웨어 설계 원칙으로 변모해 왔다.
  • 여기에서 말하는 인터페이스는 다양한 형태로 나타난다.
    • 자바스러운 언어라면 인터페이스 하나와 이를 구현하는 여러 개의 클래스로 구성된다.
    • 루비라면 동일한 메서드 시그니처를 공유하는 여러 개의 클래스로 구성된다.
    • 또는 동일한 REST 인터페이스에 응답하는 서비스 집단일 수도 있다.
  • 잘 정의된 인터페이스와 그 인터페이스의 구현체끼리의 상호 치환 가능성에 기대는 사용자들이 존재하기 떄문이다.

LSP 위반 사례

  • 다양한 택시 파견 서비스들을 통합하는 애플리케이션을 만들고 있다고 해보자.
  • 고객은 어느 택시업체인지는 신경쓰지 않고 자신의 상황에 가장 적합한 택시를 찾는다.
  • 고객이 이용할 택시를 결정하면, 시스템은 REST 서비스를 통해 선택된 택시를 고객 위치로 파견한다.
  • 택시 파견 REST 서비스의 URI가 운전기사 데이터베이스에 저장되어 있다고 가정해 보자.
  • 시스템이 고객에게 알맞은 기사를 선택하면, 해당 기사의 레코드로 부터 URI 정보를 얻은 다음, 그 URI 정보를 이용하여 해당 기사를 고객 위치로 파견한다.
1
purplecab.com/driver/Bob
  • 시스템은 이 URI에 파견에 필요한 정보를 덧붙인 후, 아래와 같이 PUT 방식으로 호출한다.
1
2
3
4
purplecab.com/driver/Bob
	/pickupAddress/24 Maple St.
    /pickupTime/153
    /destination/ORD
  • 이 예제에서 분명한 점은 파견 서비스를 만들 때 다양한 택시업체에서 동일한 REST 인터페이스를 반드시 준수하도록 만들어야 한다는 사실이다.
    • 즉, 서로 다른 택시업체가 pickupAddress, pickupTime, destination 필드를 모두 동일한 방식으로 처리해야 한다.
  • 만약 택시업체 애크미(ACME)의 프로그래머들이 destination 필드를 dest로 축약해서 사용했다고 해보자.
    • 애크미는 이 지역에서 가장 큰 택시업체이다. 그리고 가장 영향력 높은 회사이다.
    • 이럴 경우 우리 회사의 시스템 아키텍처에는 무슨 일이 벌어질까?
    • 우리는 이 예외 사항을 처리하는 로직을 추가해야만 할 것이다.
  • 애크미 소속 택시 기사를 파견하는 요청은 나머지 업체의 기사를 파견할 때 와는 다른 규칙을 이용하여 구성해야만 한다.
1
if (driver.getDispatchUri().startsWith("acme.com").....
  • 위와 같은 if 문을 추가하여 예외처리를 해야 한다.
  • 하지만 “acme.com”라는 단어를 코드 자체에 추가하는 것은 그 자체로 끔찍할 뿐만 아니라 이해할 수 없는 온갖 종류의 에러가 발생할 확률이 높아진다.

  • 예를 들어 애크미사가 퍼플사를 인수한다면 어떻게 될까?
  • 합병된 퍼플사의 브랜드와 웹 사이트는 애크미와는 독립적으로 유지하되, 회사 시스템은 통합할 경우 퍼플사를 위해 “purple”을 처리하는 또 다른 if문을 추가해야 할 것이다.
  • 아키텍트는 위 같은 버그로 부터 시스템을 격리해야 한다.
  • 이때 파견 URI를 키로 사용하는 설정용 데이터베이스를 이용하는 파견 명령 생성 모듈을 만들어야 할 수도 있다.
  • 설정 정보는 대체로 아래와 같을 것이다.

스크린샷 2023-07-18 오후 9 50 06

  • 또한 아키텍트는 REST 서비스들의 인터페이스가 서로 치환 가능하지 않다는 사실을 처리하는 중요하고 복잡한 매커니즘을 추가해야 한다.

결론

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

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

image

  • ISP 는 위 다이어그램에서 유래했다.
  • 다수의 사용자가 OPS 클래를 사용하는데, User1은 오직 op1을 User2는 오직 op2를 User3은 오직 op3만을 사용한다고 가정해보자.
  • 그리고 OPS가 정적 타입 언어로 작성된 클래스라고 가정해보자.
  • 이 경우 User1에서는 op2와 op3를 전혀 사용하지 않음에도 User1의 소스 코드는 이 두 메서드에 의존하게 된다.
  • 이러한 의존성으로 인해 User1과 관련된 코드는 변경되지 않았어도 OPS 클래스에서 op2의 소스 코드가 변경되면 User1도 다시 컴파일 후 새로 배포해야 한다.
  • 이는 아래와 같이 오퍼레이션을 인터페이스 단위로 분리하여 해결 가능하다.

image

  • User1의 소스 코드는 U1Ops에 의존하지만 OPS에는 전혀 의존하지 않게 된다.
  • 따라서 OPS에서 발생한 변경이 User1과는 전혀 관계없는 변경이라면, User1은 다시 컴파일하고 새로 배포하지 않아도 된다.

ISP 와 언어

  • 위의 예제에서 본 사례는 언어 타입에 의존한다.

정적 타입 언어

  • import, use, include 같은 타입 선언문을 사용하도록 강제한다.
  • 이로 인한 소스 코드 의존성이 발생하고, 재컴파일 또는 재배포가 강제되는 상황이 무조건 초래된다.

동적 타입 언어

  • 루비나 파이썬 같은 언어들..
  • 소스 코드에 import, use 와 같은 선언문이 존재하지 않는 대신 런타임에 추론이 발생한다.
  • 따라서 소스 코드 의존성이 아예 없으며, 결국 재컴파일과 재배포가 필요없다.
  • 즉, 정적 타입 언어를 사용할 때보다 유연하며 결합도가 낮은 시스템을 만들 수 있는 이유는 바로 이 때문이다.
  • 이러한 사실로 인해 ISP 를 아키텍처가 아닌, 언어와 관련된 문제라 결론 내릴 여지가 있다.

ISP 와 아키텍처

  • ISP를 사용하는 근본적인 동기로 보았을때 필요 이상으로 많은 걸 포함하는 모듈에 의존하는 것은 해롭다.
  • 불필요한 재컴파일과 재배포를 강제하기 때문이다.
  • 하지만 더 고수준인 아키텍처 수준에서도 마찬가지 상황이 발생한다.

image

  • 위 이미지에서 시스템 S 는 F 프레임워크에 의존하고 F 프레임워크는 D 데이터베이스에 의존한다.
  • 만일, F에서 S와는 전혀 관계없는 기능이 D에 포함된다고 가정해보자.
  • 그 기능 때문에 D 내부가 변경되면, F 를 재배포해야 할 수도 있고, 따라서 S까지 재배포해야 할지 모른다.
  • 더 심각한 문제는 D 내부의 기능중 F와 S에서 불필요한 그 기능에 문제가 발생시 F와 S에 영향을 준다는 사실이다.

결론

  • 여기서 배울 수 있는 교훈은 불필요한 짐을 실은 무언가에 의존하면 예상치 못한 문제에 빠진다는 사실이다.
  • 이 아이디어는 13장 “컴포넌트 응집도” 에서 공통 재사용 원칙을 논할 때 더 자세히 다루겠다.

11장 - DIP: 의존성 역전 법칙

  • 의존성 역전 원칙에서 말하는 ‘유연성이 극대화된 시스템’이란 소스코드 의존성이 추상에 의존하며 구체에는 의존하지 않는 시스템이다.
  • 자바와 같은 정적 타입 언어에서 이 말은 use, import, include 구문은 오직 인터페이스나 추상 클래스 같은 추상적인 선언만을 참조해야 한다는 뜻이다. 구체적인 대상에는 절대로 의존해서는 안 된다.
  • 루비나 파이썬 같은 동적 타입 언어에도 동일한 규칙이 적용된다.
    • 소스 코드 의존 관계에서 구체 모듈은 참조해서는 안 된다. 하지만 이들 언어의 경우 구체 모듈이 무엇인지를 정의하기가 힘들다.
    • 호출할 함수가 구현된 모듈이라면 참조하지 않기가 특히 어렵다.
  • 이 아이디어는 확실히 비현실적이다.
  • 소프트웨어 시스템이라면 구체적인 많은 장치에 의존하기 때문이다.
    • 예를 들어 자바의 String은 구체 클래스이며, 이를 애써 추상 클래스로 만들려는 것은 현실성이 없다.
    • java.lang.String 구체 클래스에 대한 소스 코드 의존성은 벗어날 수도 없고, 벗어나서도 안 된다.
  • 하지만 String 클래스는 매우 안정적이다. 클래스가 변경되는 일은 거의 없으며, 있더라도 매우 엄격하게 통제된다.
    • 프로그래머와 아키텍트는 String 클래스에서 변덕스러운 변경이 자주 발생하리라고 염려할 필요가 없다.
  • 이러한 이유로 DIP를 논할 때 운영체제나 플랫폼 같이 안정성이 보장된 환경에 대해서는 무시하는 편이다.
  • 우리는 이들 환경에 대한 의존성은 용납하는데, 변경되지 않는다면 의존할 수 있다는 사실을 이미 알고 있기 때문이다.
  • 우리가 의존하지 않도록 피하고자 하는 것은 바로 변동성이 큰 구체적인 요소다.
    • 그리고 이 구체적인 요소는 우리가 열심히 개발하는 중이라 자주 변경될 수 밖에 없는 모듈들이다.

안정된 추상황

  • 인터페이스는 구현체보다 변동성이 낫다.
    • 구현체에 변경이 생기더라도 구현하는 인터페이스는 변경될 필요가 대체로 변경될 필요가 없다.
  • 실제로 뛰어난 소프트웨어 설계자와 아키텍트라면 인터페이스의 변동성을 낮추기 위해 노력한다.
    • 인터페이스를 변경하지 않고도 구현체에 기능을 추가할 수 있는 방법을 찾기 위해 노력한다.
    • 이는 소프트웨어 설계의 기본이다.
  • 즉, 안정된 소프트웨어 아키텍처란 변동성이 큰 구현체에 의존하는 일은 지양하고, 안정된 추상 인터페이스를 선호하는 아키텍처라는 뜻이다.

DIP 를 위한 코딩 실천법

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

팩토리

  • 이 규칙들을 준수하려면 변동성이 큰 구체적인 객체는 특별히 주의해서 생성해야 한다.
  • 하지만 사실상 모든 언어에서 객체를 생성하려면 해당 객체를 구체적으로 정의한 코드에 대해 소스 코드 의존성이 발생하기 때문이다.
  • 자바 등 대다수의 객체 지향 언어에선 이처럼 바람직하지 못한 의존성을 처리할 때 추상 팩토리를 사용하곤 한다.

image

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

  • 위 이미지에의 곡선은 아키텍처 경계를 뜻한다.
  • 이 곡선은 구체적인 것들로부터 추상적인 것들을 분리한다.
  • 소스 코드 의존성은 해당 곡선과 교차할 때 모두 한 방향, 즉 추상적인 쪽으로 향한다.
  • 곡선은 시스템을 두 가지 컴포넌트로 분리한다.
    • 하나는 추상 컴포넌트. 추상 컴포넌트는 애플리케이션의 모든 고수준 업무 규칙을 포함한다.
    • 또 다른 하나는 구체 컴포넌트. 구체 컴포넌트는 업무 규칙을 다루기 위해 필요한 모든 세부사항을 포함한다.
  • 소스 코드 의존성은 제어흐름과는 반대 방향으로 역전된다. 이런 이유로 이 원칙을 의존성 역전 원칙이라 부른다.

구체 컴포넌트

  • 위 이미지의 구체 컴포넌트에는 구체적인 의존성이 하나 있고(ServiceFactoryImpl 구체 클래스가 ConcreteImpl 구체 클래스에 의존), 따라서 DIP에 위배된다.
  • 이는 일반적인 일이다. DIP 위배를 모두 없앨 수는 없다.
  • 하지만 DIP를 위배하는 클래스들은 적은 수의 구체 컴포넌트 내부(ServiceFactoryImpl)로 모을 수 있고, 이를 통해 시스템의 나머지 부분과는 분리할 수 있다.
  • 대다수의 시스템은 이러한 구체 컴포넌트를 최소한 하나는 포함할 것이다.
    • 흔히 이 컴포넌트를 메인(Main)이라 부르는데, main 함수를 포함하기 때문이다.
    • 위 이미지의 경우라면 main 함수는 ServiceFactoryImpl 의 인스턴스를 생성 후, 이 인스턴스를 ServiceFactory 타입으로 전역 변수에 저장할 것이다.
    • 그런 다음 Application 은 이 전역 변수를 통해 ServiceFactoryImpl의 인스턴스에 접근할 것이다.

결론

  • 앞으로 고수준의 아키텍처 원칙을 다루게 되면서 DIP는 몇 번이고 계속 등장할 것이다.
  • DIP는 아키텍처 다이어그램에서 가장 눈에 드러나는 원칙이 될 것이며, 위 이미지의 곡선은 이후의 장에선 아키텍처 경계가 될 것이다.
  • 그리고 의존성은 이 곡선을 경계로, 더 추상적인 엔티티가 있는 쪽으로만 향한다.
  • 추후 이 규칙은 의존성 규칙(Dependency Rule)이라 부를 것이다.

Reference

4부 컴포넌트 원칙

  • SOLID 원칙이 벽과 방에 벽돌을 배치하는 방법을 알려준다면, 컴포넌트 원칙은 빌딩에 방을 배치하는 방법을 설명해준다.
  • 큰 빌딩과 마찬가지로 대규모 소프트웨어 시스템은 작은 컴포넌트들로 만들어진다.

12장 컴포넌트

컴포넌트가 무엇인지 컴포넌트의 역사에 대해 설명하는 내용이다. 크게 중요한 내용은 많지 않아서 컴포넌트가 무엇인지 그리고 어떻게 소프트웨어가 발전하게 되었는지를 가볍게 보면 좋다.

  • 컴포넌트는 시스템의 구성요소로 배포할 수 있는 가장 작은 단위다.
    • 자바 - jar
    • 루비 - gem 파일
    • 닷넷 - DLL
  • 컴파일형 언어에서 컴포넌트는 바이너리 파일의 결합체다. 인터프리형 언어의 경우 소스 파일의 결합체이다.
  • 모든 언어에서 컴포넌트는 배포할 수 있는 단위 입자다.
  • 여러 컴포넌트를 서로 링크하여 실행 가능한 단일 파일로 생성 가능하다.
  • 또는 여러 컴포넌트를 서로 묶어서 war 파일과 같은 단일 아카이브로 만들 수도 있다.
  • 또는 컴포넌트 각각을 jar 나 dll 같이 동적으로 로드할 수있는 플러그링닝나 exe 파일로 만들어서 독립적으로 배포할 수도 있따.
  • 잘 설계된 컴포넌트라면 반드시 독립적으로 배포 간으한, 따라서 독립적으로 개발 가느한 능력을 갖춰야 한다.

컴포넌트의 간략한 역사

  • 프로그래밍 초창기에는 프로그램을 로드할 메모리의 위치를 정하는 일이 프로그래머가 가장 먼저 결정해야 하는 사항 중 하나였다.
    • 이 시절엔 프로그램 위치가 한 번 결정되면, 재배치가 불가능했다.
  • 이러한 구시대에는 프로그래머가 라이브러리 함수의 소스 코드를 애플리케이션 코드에 직접 포함시켜 단일 프로그램으로 컴파일하여 해당 라이브러리에 접근했다.
    • 라이브러리는 바이너리가 아닌 소스 코드 형태로 유지되었다.
  • 이 시대엔 장치는 느리고 메모리는 너무 비싸서 자원이 한정적이었기에, 이러한 접근법은 문제가 있었다.
    • 이 시대엔 메모리가 너무 작아서 소스 코드 전체를 메모리에 상주시킬 수 없었고, 컴파일러는 소스 코드 전체를 여러번에 걸쳐서 읽어야했다.
    • 이 과정은 엄청오래 걸렸다.
  • 따라서 함수 라이브러리의 소스 코드를 애플리케이션 코드로부터 분리하고, 함수 라이브러리를 개별적으로 컴파일해, 컴파일된 바이너리를 메모리의 특정 위치에 로드했다. (102p-그림12.1 초기의 메모리 배치 이미지 참고)
  • 하지만, 이 방식도 초기에 할당된 메모리보다 애플리케이션이 더 커지게 되자, 애플리케이션을 두 개의 세그먼트로 분리하여 함수 라이브러리 공간을 사이에 두고 오가며 동작하게 배치해야 했다.
  • 하지만 이것은 분명 지속 가능한 방법이 아니었다.

image

재배치성

  • 해결책은 재배치가 가능한 바이너리였다.
  • 바로 로더를 이용해서 메모리에 재배치할 수 있는 형태의 바이너리를 생성하도록 컴파일러를 수정하자는 것이었다.
  • 프로그래머는 함수 라이브러리를 로드할 위치와 애플리케이션을 로드할 위치를 로더에게 지시할 수 있게 되었다.
  • 로더는 바이너리르 입력받은 후, 단순히 하나씩 차례로 메모리로 로드하면서 재배치하는 작업을 처리하였고, 프로그래머는 필요한 함수만을 로드할 수 있게 되었다

  • 컴파일러는 재배치 가능한 바이너리 안의 함수 이름을 메타데이터 형태로 생성하도록 수정되었다.
    • 프로그램이 라이브러리 함수를 호출한다면 외부 참조(external reference)로 생성했다.
    • 프로그램이 라이브러리 함수를 정의한다면 외부 정의(external definition)로 생성했다.
  • 이렇게 함으로써 외부 정의를 로드할 위치가 정해지기만 하면 로더가 외부 참조를 외부 정의에 링크시킬 수 있게 된다. 이를 링킹 로더라고 한다.

링커

  • 1970년대 초가 되자 프로그램이 커지게 되고, 링킹 로더는 프로그램을 로드하는데 매우 긴 시간이 걸렸다.
  • 이를 해결하고자 로드링크가 분리되었다.
  • 프로그래머가 느린 부분, 즉 링크 과정을 맡았는데, 링커라는 별도의 애플리케이션으로 이 작업을 처리하도록 만들었다.
    • 링커는 링크가 완료된 재배치 코드를 만들어 주었고, 그 덕분에 로더의 로딩 과정이 아주 빨라졌다. 그리고 한 번 만들어준 실행 파일은 언제라도 빠르게 로드할 수 있게 되었다.
  • 하지만 또 1980년대가 되어 프로그래머는 C나 또 다른 고수준 언어를 사용하기 시작했고 프로그램도 더 커지게 되었다.
    • 그러다보니 컴파일-링크 시간이 병목 구간이되어 또 다시 전체 모듈을 컴파일하는데 엄청난 시간이 들게 되었다… (끊이지 않는 반복..)
  • 이후 기술이 발전하여 디스크 속도가 증가하고 액티브X와 공유 라이브러리, jar 파일이 등장했으며, 다수의 .jar 또는 공유 라이브러리를 순식간에 서로 링크한 후, 링크가 끝난 프로그램을 실행할 수 있게 되었다.
  • 이렇게 컴포넌트 플러그인 아키텍처가 탄생했다.

결론

  • 런타임에 플러그인 형태로 결합할 수 있는 동적 링크 파일이 이 책에서 말하는 소프트웨어 컴포넌트에 해당한다.

13장 컴포넌트 응집도

  • 어떤 클래스를 어느 컴포넌트에 포함시켜야 할 지는 중요한 결정이다.
  • 이 장에선 컴포넌트 응집도와 관련된 세 가지 원칙을 논의한다.
    • REP(Reuse/Release Equivalence Principle) - 재사용/릴리스 등가 원칙
    • CCP(Common Closure Principle) - 공통 폐쇄 원칙
    • CRP(Common Reuse Principle) - 공통 재사용 원칙

REP: 재사용/릴리스 등가 원칙

재사용 단위는 릴리스 단위와 같다.

  • 소프트웨어 컴포넌트가 릴리스 절차를 통해 추적 관리되지 않거나 릴리스 번호가 부여되지 않는다면 해당 컴포넌트를 재사용하고 싶어도 할 수도 없고, 하지도 않을 것이다.
  • 릴리스 번호가 없다면 재사용 컴포넌트들이 서로 호환되는지 알 수가 없다.
  • 개발자들은 릴리스 변경사항을 통해 기존 버전을 쓸지 아니면 새로운 릴리스 변경사항을 적용할지를 결정 가능하다.
  • 이 원칙을 소프트웨어 설계와 아키텍처 관점에서 보면 단일 컴포넌트는 응집성 높은 클래스와 모듈로 구성되어야 함을 뜻한다.
    • 단순히 뒤죽박죽 임의로 선택된 클래스와 모듈로 구성되선 안된다.
  • 컴포넌트를 구성하는 모든 모듈은 서로 공유하는 중요한 테마나 목적이 있어야 한다.
  • 하나의 컴포넌트로 묶인 클래스와 모듈은 반드시 함께 릴리스 할 수 있어야 한다.
  • 이 원칙의 약점은 다음에 다룰 두 원칙이 지닌 강점을 통해 충분히 보완할 수 있다.
    • 실제로 CCP와 CRP 는 REP를 엄격하게, 하지만 제약을 가하는 측면에서 정의한다.

CCP: 공통 폐쇄 원칙

동일한 이유로 동일한 시점에 변경되는 클래스를 같은 컴포넌트로 묶어라. 서로 다른 시점에 다른 이유로 변경되는 클래스는 다른 컴포넌트로 분리하라.

  • 이 원칙은 단일 책임 원칙(SRP)을 컴포넌트 관점에서 다시 쓴 것이다.
    • 공통 폐쇄 원칙 CCP 에서도 마찬가지로 단일 컴포넌트는 변경의 이유가 여러 개 있어선 안된다고 말한다.
  • 유지보수성은 재상용성보다 훨씬 중요하다.
    • 애플리케이션 코드가 반드시 변경되어야 한다면, 변경 지점들이 여러 컴포넌트에 분산되어 있는 것보단 한 컴포넌트에 존재하는 것이 낫다.
    • 만약 변경 지점을 단일 컴포넌트로 제한할 수 있다면, 해당 컴포넌트만 재배포하면 되고, 변경된 컴포넌트에 의존하지 않는 다른 컴포넌트는 다시 검증하거나 배포할 필요가 없다.
  • CCP는 위와 같은 이유로 변경될 가능성이 존재하는 클래스는 모두 한 곳으로 묶을 것을 권한다.
    • 물리적 또는 개념적으로 강하게 결합되어 항상 함께 변경되는 클래스들은 하나의 컴포넌트에 속해야 한다.
    • 이를 통해 소프트웨어를 릴리스, 재검증, 배포하는 일과 관련된 작업량을 최소화할 수 있다.
  • 이 원칙은 개방 폐쇄 원칙(OCP)과도 밀접하게 관련되어 있다.
    • 실제로 CCP의 폐쇄는 OCP의 폐쇄와 그 뜻이 같다.
    • 100%의 폐쇄는 불가능한데 변경 가능성이 있거나 과거에 발생했던 대다수의 공통적인 변경사항에 대해서 클래스가 닫혀있도록 설계한다.
  • CCP에선 동일한 유형의 변경에 대해 닫혀있는 클래스들을 하나의 컴포넌트로 묶음으로써 OCP에서 얻은 교훈을 확대 적용한다.
    • 따라서 변경이 필요한 요구사항이 발생할 경우, 그 변경이 영향을 주는 컴포넌트들이 최소한으로 한정될 가능성이 확실히 높아진다.

SRP 와의 유사성

  • CCP는 컴포넌트 수준의 SRP다.
  • SRP에선 서로 다른 이유로 변경되는 메서드를 서로 다른 클래스로 분리하라고 말한다.
  • CCP에선 서로 다른 이유로 변경되는 클래스를 서로 다른 컴포넌트로 분리하라고 말한다.
    • 두 원칙은 모두 아래와 같은 교훈으로 요약 가능하다.

동일한 시점에 동일한 이유로 변경되는 것들을 한데 묶어라. 서로 다른 시점에 다른 이유로 변경되는 것들은 서로 분리하라.

CRP: 공통 재사용 원칙

컴포넌트 사용자들을 필요하지 않는 것에 의존하게 강요하지 말라.

  • 공통 재사용 원칙 CRP도 클래스와 모듈을 어느 컴포넌트에 위치시킬지 결정할 때 도움되는 원칙이다.
  • CRP에선 같이 재사용되는 경향이 있는 클래스와 모듈들은 같은 컴포넌트에 포함해야 한다고 말한다.
    • 개별 클래스가 단독으로 재사용되는 경우는 거의 없으며 대체로 재사용 가능한 클래스는 재사용 모듈의 일부로써 해당 모듈의 다른 클래스와 상호작용하는 경우가 많다.
    • CRP 에선 이런 클래스들이 동일한 컴포넌트에 포함되어야 한다고 말한다.
  • 간단한 예시로 container와 iterator 클래스는 서로 강하게 결합되어 있기 때문에 함께 재사용된다. 따라서 동일한 컴포넌트에 위치해야한다.
  • 또한, CPR는 동일한 컴포넌트로 묶어서는 안되는 클래스가 무엇인지도 말해준다.
    • 어떤 컴포넌트가 다른 컴포넌트를 사용하면, 두 컴포넌트 사이에 의존성이 생긴다.
    • 사용하는 클래스에서 사용되는 클래스에서 단 하나의 클래스만 사용한다고 해도 의존성은 약해지지 않는다.
    • 이런 의존성으로 인해 사용되는 컴포넌트가 변경될 때 마다 사용하는 컴포넌트도 변경해야할 가능성이 높다.
    • 따라서 의존하는 컴포넌트가 있다면 해당 컴포넌트의 모든 클래스에 대해 의존함을 확실히 인지해야 한다.
  • 따라서 CRP는 어떤 클래스를 한데 묶어도 되는지보단, 어떤 클래스를 한데 묶어선 안되는지에 대해 훨씬 더 많은 것을 이야기한다.
    • CRP는 강하게 결합되지 않은 클래스들을 동일한 컴포넌트에 위치시켜서는 안 된다고 말한다.

ISP와의 관계

  • CRP는 인터페이스 분리 원칙(ISP)의 포괄적인 버전이다.
  • ISP는 사용하지 않은 메서드가 있는 클래스에 의존하지 말라고 조언한다.
  • CRP는 사용하지 않은 클래스를 가진 컴포넌트에 의존하지 말라고 조언한다.
  • 위 두 조언은 다음 한 문장으로 요약할 수 있다.

필요하지 않는 것에 의존하지 말라.

컴포넌트 응집도에 대한 균형 다이어그램

  • 응집도에 관한 세 원칙은 서로 상충된다.
  • REP와 CCP는 포함(inclusive) 원칙이며, 컴포넌트를 더욱 크게 만든다.
  • CRP는 배제(exclusive)원칙이며, 컴포넌트를 더욱 작게 만든다.
  • 따라서 이 원칙들이 균형을 이루는 방법을 찾아야 한다.

image

  • 위 균형 다이어그램에서 다이어그램의 각 변은 반대쪽 꼭지점에 있는 원칙을 포기했을 때 감수해야 할 비용을 나타낸다.
    • REP와 CRP에만 중점을 두면, 사소한 변경에 너무 많은 컴포넌트에 영향을 미친다.
    • CCP와 REP에만 과도하게 집중하면 불필요한 릴리스가 너무 빈번해진다.
    • CRP와 CCP에만 집중하게 되면 재사용성이 떨어지게 된다.
  • 뛰어난 아키텍트라면 이 균형 삼각형에서 현재 개발팀이 현재 관심을 기울이는 부분을 충족시키는 위치를 찾아야 하며, 또한 시간이 흐르면서 개발팀이 주의를 기울이는 부분 역시 변한다는 사실도 이해하고 있어야 한다.
    • 예를 들어, 프로젝트 초기에는 CCP가 REP보다 훨씬 더 중요한데, 개발 가능성이 재사용성보다 더욱 중요하기 때문이다.
    • 일반적으로 프로젝트는 삼각형의 오른쪽에서 시작하는 편인데, 프로젝트가 성숙하고 그 프로젝트로부터 파생된 또 다른 플조ㅔㄱ트가 시작되면, 점차 왼쪽으로 이동해 간다.
    • 즉, 프로젝트 컴포넌트 구조는 시간과 성숙도에 따라 변한다는 뜻이다.

결론

  • 어느 클래스들을 묶어서 컴포넌트로 만들지를 결정할 때, 재사용성과 개발 가능성이라는 상충하는 힘을 반드시 고려해야 한다.
  • 이들 사이에서 애플리케이션의 요구에 맞게 균형을 잡는 일은 중요하다. 심지어 이 균형점은 항상 유동적이다.
    • 즉, 두 힘을 현재 상황에 맞게 잘 분배했더라도, 내년엔 맞지 않을 수도 있다.
    • 결과적으로 시간이 흐름에 따라 프로젝트의 초점이 개발가능성에서 재사용성으로 바뀌고, 그에 따라 컴포넌트를 구상하는 방식도 조금씩 흐트러지고 또 진화한다.

14장 컴포넌트 결합

  • 지금부터 다루게 될 세 가지 원칙은 컴포넌트 사이의 관계를 설명한다.
  • 이 장에서도 마찬가지로 개발 가능성과 논리적 설계 사이의 균형을 다룬다.

1) ADP: 의존성 비순환 원칙 (Acyclic Dependencies Principle)

컴포넌트의 의존성 그래프에 순환(Cycle)이 있어서는 안 된다.

  • 동일한 소스코드를 여럿이 건들게 되면, 충돌이 자주 발생하게 되고 망가진 부분을 고치느라 애를 먹을 것이다.
  • 이때 해결책으론 두 가지 방법이 있다.
    • 1)주단위 빌드(Weekly Build)
    • 2)의존성 비순환 원칙(Acyclic Dependencies Principle, ADP)

1) 주단위 빌드(Weekly Build)

  • 중간 규모 프로젝트에서 흔히 사용된다.
  • 월-목엔 각자 개발 후 금요일에 통합한다.
    • 장점: 빠른 피드백 및 서로 신경 안쓰고 편하게 개발 가능
    • 단점: 프로젝트 통합시 오래걸림
  • 위의 단점으로 인해 시간이 지날수록 통합에드는 시간이 계속 늘어나고 위기를 초래한다.

2) 순환 의존성 제거하기

  • 의존성 구조에 순환이 절대 생겨선 안된다. 그렇게 되면 숙취증후군이…

image

  • 위 이미지는 전형적인 컴포넌트 다이어그램이다. 컴포넌트 의존성 구조는 방향 그래프 임에 주의하자.
  • 어느 컴포넌트에서 시작하더라도, 의존성 관계를 따라가면서 최초의 컴포넌트로 되돌아갈 수 없게 해야 한다. (비순환 방향 그래프)
    • Presenters를 담당하는 팀에서 이 컴포넌트의 새로운 릴리스를 만들면 무슨일이 벌어질지 생각해보자.
    • 이 릴리스에 영향받는 팀은 쉽게 찾을 수 있다.
    • 의존성 화살표를 거꾸로 따라가면 된다.
    • 즉, View 와 Main 컴포넌트 둘 다 영향 받는다.
    • 이 두 컴포넌트를 작업중인 개발자라면, Presenters의 새로운 릴리스와 자신의 작업물을 언제 통합할지를 반드시 결정해야 한다.
  • 또한 Main은 영향 받는 컴포넌트가 전혀 없어서 쉽게 변경 가능하다.
  • 시스템 전체를 릴리스해야 하면 상향식으로 진행된다.
    • Entities(컴파일, 테스트, 릴리스) → Database, Interators, → …
  • 컴포넌트 구성요소 간 의존성을 파악하고 있으면 시스템을 빌드하는 방법과 순서를 알 수 있다

🙌나의 예시

  • 현재 고객사 인사정보시스템으로부터 코드, 조직, 구성원 데이터를 동기화 시켜주는 람다 애플리케이션을 개발 중에 있다.
  • 멀티 모듈로 구성되어 있으며, 현재 의존성 그래프는 아래와 같다. (실제 app-samsung, app-lg 는 단순히 예시이며, 고객사 모듈을 뜻한다.)

image

  • 의존성 그래프가 단방향으로 구성되어 있으며, 비순환 형태이다. (어느 모듈에서 시작하더라도 최초 컴포넌트로 돌아갈 수 없는)
  • 또한 위와 같이 설계한 이유는 고객사 모듈(ex. app-samgsung, app-lg) 간의 클래스들을 서로 격리시키기 위함이다.
  • 추가적으로 전체 프로세스는 람다 함수의 페이로드 이벤트에 기반하여 매칭되는 고객사 SyncManager 를 팩토리 메서드를 통해 생성하여 실제 synchronize 메서드를 호출하여 동기화가 이뤄지는 방식이다.

순환이 컴포넌트 의존성 그래프에 미치는 영향

image

  • 컴포넌트 간의 의존성으로 인해 순환 컴포넌트와 관련된 개발자들은 모두 서로에게 얽매일 것이다.
    • Database 컴포넌트 릴리스 -> Entities 와 반드시 호환 -> Authorize 와 호환되도록 신경써야 한다.
    • 그렇기에 릴리스하기 훨씬 어려워진다. 그러다보면 숙취증후군이…
  • 테스트할때 또한 의존된 컴포넌트 체인을 따라 모두 빌드하고 통합해야 하는데 문제가 발생하게 되고 받아들이기도 힘들어진다.
  • 이처럼 순환이 생기면 컴포넌트를 분리하기 상당히 어려워지고, 단위 테스트 및 릴리스도 굉장히 어려워지고 에러도 쉽게 발생한다. 게다가 모듈의 개수가 많아짐에 따라 빌드 관련 이슈는 기하급수적으로 증가한다.
  • 추가적으로 컴포넌트에 대한 빌드 순서를 파악하기도 상당히 힘들어진다. 올바른 순서라는 것 자체가 없을 수 있다.

순환 끊기

  • 1)의존성 역전(DIP)을 적용한다. (고수준의 Permissions 인터페이스로 저수준이 고수준을 참조하도록 변경)

image

  • 2)Entities와 Authorizer가 모두 의존하는 새로운 컴포넌트를 만든다. 그리고 모두 의존하는 클래스들을 새로운 컴포넌트로 이동시킨다.
    • 이러한 과정을 통해 요구사항이 변겨오디면 컴포넌트 구조도 변경되게 된다. 그러기에 주기적으로 순환이 발생하는지 관찰 후 끊어줘야 한다.

image

하향식 설계

  • 컴포넌트는 시스템에서 가장 먼저 설계할 수 있는 대상이 아니기 때문에, 하향식으로 설계될 수 없다.
  • 컴포넌트 의존성 다이어그램은 애플리케이션의 기능을 의미하지는 않는다.
  • 오히려 애플리케이션의 빌드 가능성(buuildability)과 유지보수성(maintainability)을 보여주는 지도(map)와 같다.
  • 따라서, 아무런 클래스도 설계하지 않은 상태에서 컴포넌트 의존성 구조를 설계하려고 하면 실패할 것이다.

2) SDP: 안정된 의존성 원칙 (Stable Dependencies Principle)

안정성의 방향으로 (더 안정된 쪽에) 의존하라

  • 설계는 결코 정적일 수 없으며 변경은 불가피하다.
  • 그러다보니 일부는 변동성을 지니도록 설계된다.
  • 그래서 변경이 쉽지 않은 컴포넌트가 변동이 예상되는 컴포넌트에 의존하게 만들어선 절대 안된다. 한 번 의존하게 되면 변동성이 큰 컴포넌트도 결국 변경이 어려워진다.
  • 안정된 의존성 원칙을 준수하면 변경하기 어려운 모듈(백엔드 도메인 모듈, 프론트 위젯 모듈)이 변경하기 쉽게 만들어진 모듈(백엔드 웹 모듈, 프론트 클라이언트 모듈)에 의존하지 않도록 만들수도 있다.

안정성

  • 변경을 만들기 위해 필요한 작업량과 관련된다.
    • 변경하는데 많은 작업이 필요 == 많은 곳에서 의존된다 == 안정적이다.
  • 소프트웨어 컴포넌트를 변경하기 어렵게 만드는 확실한 방법은 수많은 컴포넌트가 해당 컴포넌트에 의존하게 만드는 것이다.

image

  • X는 세 컴포넌트를 책임지고 있다. 그러나, X가 변경되도록 마들 수 있는 외적인 영향은 없기 때문에 독립적이다.

image

  • Y는 상당히 불안정한 컴포넌트이다. Y에 의존하는 컴포넌트는 없기 떄문에, 책임성이 없으며, 의존적이라고 볼 수 있다.

안정성 지표

  • 불안정성 : I = Fan-out / (Fan-in + Fan-out) , I =1이면, 최고로 불안정한 컴포넌트, I = 0이면, 최고로 안정한 컴포넌트다.
    • Fan-in: 안으로 들어오는 의존성. 이 지표는 컴포넌트 내부의 클래스에 의존하는 컴포넌트 외부의 클래스 개수를 나타낸다.
    • Fan-out: 바깥으로 나가는 의존성. 이 지표는 컴포넌트 외부의 클래스에 의존하는 컴포넌트 내부의 클래스 개수를 나타낸다.
  • 의존성 방향으로 갈수록 I지표 값이 감소해야 한다.

🙌나의 예시

image

  • 위에서 언급된 동기화 람다 함수 멀티 모듈의 예시로 보면 위와 같다.
  • 의존성 방향으로 갈수록 I지표 값이 감소하는 것을 볼 수 있다.
    • 즉, 의존성 방향으로 갈 수록 많은 곳에서 의존되는 것을 확인 가능하다.

모든 컴포넌트가 안정적이어야 하는 것은 아니다.

  • 모든 컴포넌트가 안정적이라면, 변경은 불가능하다.
  • 포인트는 의존성 방향 안쪽으로 I지표 값이 감소하지 않도록 구조를 개선해야 한다.
    • DIP 를 도입하여 의존성을 역전시켜서 이를 유지할 수 있다. (p130. 그림 14.11 참고)

추상 컴포넌트

  • 오로지 인터페이스만을 포함하는 컴포넌트를 생성하는 방식인 추상 컴포넌트는 상당히 안정적이며, 따라서 덜 안정적인 컴포넌트가 의존할 수 있는 이상적인 대상이다.

3) SAP: 안정된 추상화 원칙 (Stable Abstractions Principle)

컴포넌트는 안정된 정도만큼만 추상화되어야 한다.

  • 시스템에는 자주 변경해서는 절대로 안되는 소프트웨어도 있다. 고수준 아키텍처나 정책 결정과 관련된 소프트웨어가 그 예다.
  • 따라서 시스템에서 고수준 정책을 캡슐화하는 소프트웨어는 반드시 안정된 컴포넌트(I = 0)에 위치해야 한다.
  • 하지만 고수준 정책을 안정된 컴포넌트에 위치시키면, 그 정책을 포함하는 소스 코드는 수정하기가 어려워진다. 이로 인해 시스템 전체 아키텍처가 유연성을 잃는다.
  • 그렇다면 어떻게 컴포넌트가 최고로 안정된 상태이면서도(I = 0) 동시에 변경에 충분히 대응할 수 있을 정도로 유연하게 만들 수 있을까?
  • 해답은 개방 폐쇄 원칙(OCP)에서 찾을 수 있다. 추상 클래스를 활용해서 말이다.

안정된 추상화 원칙

  • 안정성과 추상화 정도 사이의 관계를 정의한다.
    • 안정된 컴포넌트는 추상 컴포넌트 이며, 이를 통해 안정성이 컴포넌트를 확장하는 일을 방해해선 안된다고 말한다.
    • 불안정한 컴포넌트는 반드시 구체 컴포넌트여야 하며, 컴포넌트 내부의 구체적인 코드를 쉽게 변경할 수 있기 때문이다.
  • SAP와 SDP를 결합하면 컴포넌트에 대한 DIP나 마찬가지가 된다.
    • 실제로 SDP에서는 의존성이 반드시 안정성의 방향으로 향해야 한다고 말하고
    • SDP에서는 안정성이 결국 추상화를 의미한다고 말하기 때문이다.
    • 따라서 의존성은 추상화의 방향으로 향하게 된다.
  • 핵심은 안정적인 컴포넌트라면 반드시 인터페이스와 추상 클래스로 구성되어 쉽게 확장할 수 있어야 한다.

추상화 정도 측정하기

  • 추상화 정도를 지표로 표현한 것이다.
    • NC: 컴포넌트의 클래스 개수
    • Na: 컴포넌트의 추상 클래스와 인터페이스의 개수
    • A: 추상화 정도. A = Na / Nc(A가 0이면 추상 클래스가 한개도 없고, 1이면 오로지 추상 클래스만 있음)

주계열

  • 아래 이미지는 안정성(I)과 추상화 정도(A) 사이의 관계 그래프다.
    • 최고로 안정적이며 추상화된 컴포넌트는 (0, 1)에 위치한다.
    • 최고로 불안정하며 구체화된 컴포넌트는 (1, 0)에 위치한다.

image

  • 모든 컴포넌트가 이 두 지점에 위치하는 것은 아니다.
  • 아래 그림의 궤적은 컴포넌트가 절대로 위치해서는 안 되는 영역, 배제할 구역(Zone of Exclusion)이다.

image

고통의 구역

  • (0, 0) 주변 구역에 위치한 컴포넌트는 매우 안정적이며 구체적이다. 뻣뻣한 상태이다. 추상적이지 않아서 확장할 수 없고, 안정적이므로 변경하기 상당히 어렵다.
  • 하지만 변동성이 없는 컴포넌트는 (0, 0) 구역에 위치하더라고 해롭지 않다. 변동될 가능성이 없기 때문이다.

쓸모없는 구역

  • (1, 1) 주변의 컴포넌트는 최고로 추상적이지만, 누구도 그 컴포넌트에 의존하지 않는다. 이러한 컴포넌트는 쓸모가 없다. 따라서 이 구역은 쓸모없는 구역(Zone of Uselessness)이라 부른다.

배제 구역 벗어나기

  • 변동성이 큰 컴포넌트 대부분은 두 배제 구역으로부터 가능한 한 멀리 떨어뜨려야 한다.
  • 최대한 멀리 떨어진 점의 궤적은 (1, 0)(0, 1)을 잇는 선분이다. 저자는 주계열(Main Sequence)이라 부른다.
  • 주계열 위 또는 가깝게 위치해야 하며, 이렇게 위치하면 ‘너무 추상적’이지도 않고, 추상화 정도에 비해 ‘너무 불안정’하지도 않다.

주계열과의 거리

  • 주계열과의 거리를 지표화한 것으로, 주계열에 대체로 일치하도록 설계되었는지를 분석할 수 있다.

🙌나의 예시

  • 위에서 언급한 동기화 람다 함수를 기반으로 추상화 지표로 A/I 그래프를 그려보면 아래 이미지와 같다.

image

  • app-samsung 컴포넌트는 변동성이 큰 모듈이다보니 두 배제 구역으로부터 멀리 떨어뜨려놓아야 하지만 그렇지 못하다.
    • 추상 클래스 및 인터페이스를 활용하여 추상화 정도를 더 높일 필요가 있다.
  • app-core 컴포넌트는 고통의 구역에 속해있지만, 변동성이 거의 없는 컴포넌트이므로 크게 해롭지 않다.
  • app-main 컴포넌트는 주계열선에 가깝게 위치해있다.

결론

  • 이 장에서 설명하는 의존성 관리 지표 는 설계의 의존성과 추상화 정도가 내가 ‘훌륭한’ 패턴이라고 생각하는 수준에 얼마나 부합하는지를 측정한다.
  • 하지만 이러한 지표는 신이 아니고 아무리 해도 불완전하다는 것을 참고하자.

Reference

5부 - 아키텍처

15장 - 아키텍처란?

  • 소프트웨어 시스템의 아키텍처란 시스템을 구축했던 사람들이 만들어낸 시스템의 형태이다.
  • 그 형태는 아키텍처 안에 담긴 소프트웨어 시스템이 쉽게 개발, 배포, 운영, 유지보수되도록 만들어진다.

이러한 일을 용이하게 만들기 위해서는 가능한 한 많은 선택지를, 가능한 한 오래 남겨두는 전략을 따라야 한다.

  • 형편없는 아키텍처를 갖춘 시스템도 그런대로 작동은 잘 한다.
  • 하지만 운영보단 배포, 유지보스, 개발 과정에서 어려움을 겪는다.
  • 아키텍처의 주된 목적은 시스템의 생명주기를 지원하는 것이다. 좋은 아키텍처는 시스템을 쉽게 이해하고, 쉽게 개발하며, 쉽게 유지보수하고, 또 쉽게 배포하게 해준다. 아키텍처의 궁극적인 목표는 시스템의 수명과 관련된 비용은 최소화하고, 프로그래머의 생산성은 최대화하는데 있다.

🙌나의 생각

  • 현재 개발하고 있는 제품은 MSA로 구성되어 있으며, 최근 제품의 스케줄링 아키텍처를 설계했다.

스크린샷 2023-08-06 오후 10 21 32

  • 위 아키텍처에 대해 간략하게 설명하면 다음과 같다.
    • AWS EventBridge(혹은 CloudWatch) 에서 일단위 이벤트를 람다함수로 발생시킨다.
    • 스케줄링 람다함수는 등록된 payload 에 기반하여 해당되는 마이크로서비스로 http 요청을 대신 호출해준다.
    • 만약 스케줄링이 실패하게 될 경우 별도 람다함수 트리거로 개발자들에게 팀즈 및 메일 알림을 전송한다.

시스템을 쉽게 이해할 수 있는가?

  • 내부적으로 등록된 시간에 http 요청을 대신 호출해주며 동작이 실행되기에 쉽게 이해된다고 볼 수 있다.

쉽게 개발할 수 있는가?

  • 1)위 아키텍처에 대한 노하우를 가지고 있기에(AWS 스케줄링의 가장 기반적인 아키텍처인거 같기도 하다..ㅎㅎ) 빠르게 구축 가능하다.
  • 2)도메인 내부에 구현된 로직들을 재사용 가능하다.
  • 3)JPA & QueryDSL 을 활용하여 빠르게 internal-api 만 구현해서 스케줄링만 등록하면 된다.

쉽게 유지보수 가능한가?

  • 오류 발생시 해당 internal-api 만 직접 http 요청을 보내서 빠르게 복구가능할 것이라 예상한다.

쉽게 배포 가능한가?

  • 기존 서버 배포 파이프라인을 그대로 재사용하여 배포 가능하다.
  • 별도 마이크로서비스별로 별도 스케줄링 서버를 두고 구축하는 것보다 다음과 같은 장점이 있다.
    • 1)마이크로서비스 마다 별도 스케줄링 프로젝트를 일일이 관리하지 않아도 된다.
    • 2)배포 파이프라인을 일일이 만들지 않아도 된다.
    • 3)새로운 마이크로서비스에 대한 스케줄링 요구사항을 쉽게 해결 가능하다.

아직 개발은 안된 상태이기에 추후 운영하며 겪는 이슈들은 따로 정리 예정입니다.

개발

  • 개발하기 힘든 시스템이라면 수명이 길지도 않고, 건강하지도 않을 것이다.
    • 시스템 아키텍처라면 개발팀들이 시스템을 쉽게 개발할 수 있도록 뒷밤침해야만 한다.
  • 팀 구조가 다르면 아키텍처 관련 결정에서도 차이가 난다.
    • 개발자5명 정도의 작은 팀은, 잘 정의된 컴포넌트나 인터페이스가 없더라도 서로 효율적으로 협력하여 모놀리틱 시스템을 개발할 수 있다. -> 아키텍처 관련 제약들이 오히려 방해가 된다 생각할 가능성이 높은데, 좋은 아키텍처가 결여되게 된다.
    • 개발자7명씩으로 구성된 다섯 팀이 시스템을 개발한다면, 시스템을 신뢰할 수 있고, 안정된 인터페이스를 갖춘, 잘 설계된 컴포넌트 단위로 분리하지 않으면 개발이 진척되지 않는다.
  • 이러한 ‘팀별 단일 컴포넌트’ 아키텍처가 시스템을 배포, 운영, 유지;보수하는데 최적일 가능성은 거의 없다.
    • 순정히 일정에만 쫓겨서 일한다면, 결국 이 아키텍처로 귀착될 것이다..

배포

  • 배포 비용이 높을수록 시스템의 유용성은 떨어진다.(배포와 유용성(제품을 사용하기 쉬운 정도)은 어떤 관계가 있을까..?)
  • 따라서 아키텍트는 시스템을 단 한 번에 쉽게 배포할 수 있도록 그 목표를 둬야한다.
  • 예를 들어, 개발 초기 다계에 개발자가 ‘마이크로서비스 아키텍처’를 사용하자고 결정할 수도 있다.
    • 시슽엠을 매우 쉽게 개발할 수 있다고 판단했을텐데 배포할 시기가 되면 위협적일 만큼 늘어난 수많은 마이크로서비스를 발견하게 될지도 모른다. 이는 오작동이 발생할 원천이 스며들 수도 있기 떄문이다.
  • 만약 아키텍트가 배포 문제를 초기에 고려했다면 다른 결정을 내렸을 것이다. 더 적은 서비스를 사용하고, 서비스 컴포넌트와 프로세스 수준의 컴포넌트를 하이브리드 형태로 융합하며, 좀 더 통합된 도구를 사용하여 상호 연결을 관리했을 것이다.

운영

  • 운영에 미치는 영향은 개발, 배포, 유지보수에 미치는 영향보단 덜 극적이다.
    • 운영에서 겪는 대다수의 어려움은 단순히 하드웨어를 더 투입해서 해결 가능하기 때문이다.
  • 좋은 소프트웨어 아키텍처는 시스템을 운영하는데 필요한 요구도 알려준다.
    • 달리 표현하면, 시스템 아키텍처가 개발자에게 시스템의 운영 방식을 잘 드러내 준다고 할 수 있다.
  • 시스템 아키텍처는 유스케이스, 기능, 시스템의 필수 행위를 일급 엔티티로 격상시키고, 이들 요소가 개발자에게 주요 목표로 인식되도록 해야 한다.
  • 이를 통해, 시스템을 이해하기 쉬워지며, 따라서 개발과 유지보수에 큰 도움이 된다.

유지보수

  • 비용이 가장 많이 든다.
  • 유지보수의 가장 큰 비용은 탐사와 이로 인한 위험부담에 있다.
    • 탐사란 기존 소프트웨어에 새로운 기능을 추가하거나 결함을 수정할 때, 소프트웨어를 파헤쳐서 어디를 고치는게 최선인지, 그리고 어떤 전략을 쓰는게 최적일지 결정할 때 드는 비용이다.
    • 이러한 과정 중에 의도치 않은 결함이 발생할 가능성은 항상 존재하며, 이로 인한 위험부담 비용이 추가된다.
  • 주의를 기울여 신중하게 아키텍처를 만들면 이 비용을 크게 줄일수도 있다.
    • 시스템을 컴포넌트로 분리하고, 안정된 인터페이스를 두어 서로 격리한다.
    • 이를 통해 미래에 추가될 기능에 대한 길을 밝혀 둘 수 있을 뿐만 아니라 의도치 않은 장애가 발생할 위험을 크게 줄일 수 있다.

선택사항 열어두기

  • 시스템 유연성은 시스템의 형태, 컴포넌트의 배치 방식, 컴포넌트가 상호 연결되는 방식에 상당히 크게 의존한다.
  • 소프트에어를 부드럽게 유지하는 방법은 선택사항을 가능한 많이, 그리고 가능한 오랫동안 열어두는 것이다.
    • 그렇다면 열어둬야할 선택사항이란 무엇일까? 바로 중요치 않은 세부사항이다.
  • 모든 소프트웨어 시스템은 주요한 두 가지 구성요소로 분해 가능하다.
    • 정책과 세부사항이다.
    • 정책 요소는 모든 업무 규칙과 업무 절차를 구체화한다. 정책이란 시스템의 진정한 가치가 살아 있는 곳이다.
    • 세부사항은 사람, 외부 시스템, 프로그래머가 정책과 소통할 때 필요한 요소지만, 정책이 가진 행위에는 조금도 영향을 미치지 않는다.
    • 이러한 세부사항엔 입출력 장치, 데이터베이스, 웹 시스템, 서버, 프레임워크, 통신 프로토콜 등이 있다.
  • 아키텍트의 목표는 시스템에서 정책을 가장 핵심적인 요소로 식별하고, 동시에 세부사항은 정책과 무관하게 만들 수 있는 형태의 시스템을 구축하는데 있다.
  • 이를 통해 세부사항을 결정하는 일은 미루거나 연기할 수 있게 된다. 예를 들어보자.
    • 1)개발 초기엔 어떤 DB(관계형, 분산형, 계층형, 평범한 플랫 파일)인지 선택할 필요가 없다. 고수준의 정책은 어떤 종류의 DB를 사용하는지 신경써선 안된다.
    • 2)개발 초기엔 웹 서버(HTML, AJAX, JSF)를 선택할 필요가 없다. 고수준 정책은 자신이 웹을 통해 전달된다는 사실을 알아선 안된다.
    • 3)개발 초기엔 REST 를 적용할 필요가 없다. 고수준 정책은 외부 세계로의 인터페이스에 대해 독립적이어야 하기 때문이다.
    • 4)개발 초기엔 의존성 주입 프레임워크(ex. 스프링)를 적용할 필요가 없다. 고수준 정책은 의존성을 해석하는 방식에 대해 신경써선 안된다.
  • 결정을 더 오래 참을 수 있다면, 더 많은 정보를 얻어 이를 기초로 제대로된 결정을 내릴 수 있다.
  • 이를 통해 다양한 실험을 시도해볼 수 있는 선택지도 열어 둘 수있다.
  • 현재 동작하고 있는 일부 고수준 정책이 있고, 이들 정책이 데이터베이스에 독립적이라면 다양한 데이터베이스를 후보로 두고 그 적용 가능성과 성능을 검토해볼 수 있다.
  • 선택사항을 더 오랫동안 열어둘 수 있다면 더 많은 실험이 가능하다.
    • 결정을 더 이상 연기할 수 없는 순간이 이러한 실험 덕분에 더 많은 정보를 획득한 상태일 것이다.(에센셜 스크럼에서 말하는 ‘결정을 내리지 못함으로써 손실이 발생할때가 가장 이익이 높은 결정’ 이라는 얘기가 연상된다..)
    • 뛰어난 아키텍트라면 위에서 설명한 세부사항이 아직 내려지지 않은 것처럼 행동하며, 여전히 결정을 가능한 오랫동안 연기하거나 변경할 수 있는 형태로 시스템을 만든다.

좋은 아키텍트는 결정되지 않은 사항의 수를 최대화 한다.

장치 독립성

  • 동일한 소프트웨어를 아무 변경 없이 다양한 장치에서 읽고 쓸 수 있도록 설계하는 것이 중요하다.

광고 우편

  • 광고 우편을 찍어내는데 프로그램을 장치 독립적으로 만들었더니 새로운 장치를 연결하여 엄청난 생산성을 만들어낼 수 있었다.
    • 운영체제의 입출력 추상화를 사용해서 작성되었기 때문에
  • 어떤 장치를 사용할지 전혀 모른채, 그리고 고려하지 않고도 프로그램 작성이 가능했다.
  • 이런 프로그램은 정책을 세부사항으로부터 분리한 형태를 가지고 있었다.
    • 예제의 경우엔 이름과 주소 레코드에 대한 서식이었다.
    • 세부사항은 장치였다.
    • 저자는 어떤 장치를 사용할지에 대한 결정을 연기시켰다.

물리적 주소 할당

  • 주소 할당 체계를 직접 프로그램이 알도록 개발하다보니, 다른 디스크로 마이그레이션시 너무 큰 고통을 안게되었다.(프로그램이 디스크의 주소 체계를 직접적으로 전부 아는 구조다보니 일일이 대응을해줘야해서..)
  • 새로운 동료의 조언으로 주소 할당 체계를 상대 주소를 사용하도록 바꾸었더니 위와 같은 문제를 해결할 수 있었다.
    • 시스템에서 고수준의 정책이 디스크의 물리적 구조로부터 독립되도록 수정했고, 덕분에 디스크 드라이브 구조에 대한 결정사항을 애플리케이션으로부터 분리할 수 있게 되었다.

결론

  • 좋은 아키텍트는 세부사항을 정책으로부터 신중하게 가려내고, 정책이 세부사항과 결합되지 않도록 엄격하게 분리한다.
  • 이를 통해 정책은 세부사항에 관한 어떠한 지식도 갖지 못하게 되며, 어떤 경우에도 세부사항에 의존하지 않게 된다.
  • 좋은 아키텍트는 세부사항에 대한 결정을 가능한 오랫동안 미룰 수 있는 방향으로 정책을 설계한다.

Reference

16장 - 독립성

유스케이스

  • 시스템 아키텍처는 시스템의 의도를 지원해야 한다.
  • 가장 중요한 사항은 행위를 명확히 하고 외부로 드러내며, 이를 통해 시스템이 지닌 의도를 아키텍처 수준에서 알아볼 수 있게 만드는 것이다.
  • 이들 행위는 일급 요소이며, 시스템의 최상위 수준에서 알아볼 수 있으므로, 개발자가 일일이 찾아 헤매지 않아도 된다.
  • 이들 요소는 클래스이거나 함수 또는 모듈로서 아키텍처 내에서 핵심적인 자리를 차지할 뿐만 아니라, 자신의 기능을 분명하게 설명하는 이름을 가질 것이다.

운영

  • 아키텍처는 더 실질적인이며 덜 피상적인 역할을 맡는다.
    • 만약 시스템에서 수 밀리초 안에 3차원 빅데이터 테이블에 질의해야 한다면, 반드시 이러한 운영 작업을 허용할 수 있는 형태로 아키텍처를 구조화해야 한다.
    • 이는 실질적인 형태가 되어야한다는 것을 뜻한다.
  • 이러한 형태를 지원한다는 말은 시스템에 따라 다양한 의미를 지닌다.
    • 어떤 시스템에선 시스템의 처리 요소를 일련의 작은 서비스들로 배열하여, 서로 다른 많은 서버에서 병렬로 실행할 수 있게 만들어야 함을 의미하고, 또 다른 시스템에선 경량의 수많은 스레드가 단일 프로세서에서 같은 주소 공간을 공유하도록 만든다는 뜻일 수 있다.
  • 이러한 결정은 뛰어난 아키텍트라면 열어두어야 하는 선택사항 중 하나다.
  • 모놀리틱 구조에서 MSA로 개선하는 것은 어렵다.
    • 하지만 아키텍처에서 각 컴포넌트를 적절히 격리하여 유지하고 컴포넌트간 통신 방식을 특정 형태로 제한하지 않는다면 위 예시처럼 MSA 형태로 전환하는 일이 훨씬 쉬워질 것이다.

개발

  • 아키텍처는 개발환경을 지원하는데 있어 핵심적인 역할을 수행한다.
  • 콘웨이의 법칙이 작용하는 지점이 바로 여기다.

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

  • 각 팀이 독립적으로 행동하기 편한 아키텍처를 반드시 확보하여 개발하는 동안 팀들이 서로 방해되지 않도록 해야 한다.
    • 이러한 아키텍처를 만들려면 잘 격리되어 독립적으로 개발 간으한 컴포넌트 단위로 시스템을 분할할 수 있어야 한다.

배포

  • 아키텍처는 배포 용이성을 결정하는데 중요한 역할을 한다.
  • 목표는 즉각적인 배포다.
  • 좋은 아키텍처는 시스템이 빌드된 후 즉각 배포할 수 있도록 지원해야 한다.(CI/CD)
  • 위에서도 언급했지만 시스템을 컴포넌트 단위로 적절하게 분할하고 격리시켜야 한다.
  • 여기서 마스터 컴포넌트(메인 컴포넌트)도 포함되는데, 마스터 컴포넌트는 시스템 전체를 하나로 묶고, 각 컴포넌트를 올바르게 구동하고 통합하고 관리해야 한다.

선택사항 열어놓기

  • 좋은 아키텍처는 컴포넌트 구조와 관련된 이 관심사들 사이에서 균형을 맞추고, 각 관심사 모두를 만족시킨다.
  • 하지만 현실에선 이러한 균형 잡기가 매우 어렵다. 도달하려는 목표는 뚜렷하지 않을뿐만 아니라 시시각각 변한다.
  • 몇몇 아키텍처 원칙은 구현하는 비용이 비교적 비싸지 않으며, 관심사들 사이에서 균형을 잡는데 도움이 된다.
    • 시스템을 제대로 격리된 컴포넌트 단위로 분할할 때 도움이 되며, 이를 통해 선택사항을 가능한 많이, 그리고 가능한 오랫동안 열어 둘 수 있게 해준다.
  • 좋은 아키텍처는 선택사항을 열어둠으로써, 향후 시스템에 변경이 필요할 때 어떤 방향으로든 쉽게 변경할 수 있도록 한다.

계층 결합 분리

  • 하지만 아키텍트는 시스템의 기본적인 의도는 분명히 알고 있다.
  • 따라서 단일 책임 원칙과 공통 폐쇄 원칙을 적용하여, 그 의도의 맥락에 따라 다른 이유로 변경되는 것들은 분리하고, 동일한 이유로 변경되는 것들은 묶는다.
  • 서로 다른 이유로 변경 되는 것은 무엇일까?
    • UI와가 변경되는 이유는 업무 규칙과는 아무런 관련이 없다.
    • 그러기에 유스케이스에서 UI부분과 업무 규칙 부분(도메인 규칙)을 서로 분리하고자 할 것이다.
    • 이렇게 두 요소를 서로 독립적으로 변경할 수 있을 뿐만 아니라, 유스케이스는 여전히 가시적이며 분명하게 유지할 수 있다.
  • 서로 다른 두 유형의 규칙은 각자 다른 속도로, 그리고 다른 이유로 변경될 것이다.
    • 현재 개발중인 제품 또한 프론트 백엔드 둘 다 다른 속도 및 다른 이유로 변경되어 가고 있다.
    • 따라서 이들 규칙은 서로 분리하고, 독립적으로 변경 가능하도록 만들어야 한다.
  • DB, SQL, Schema 조차 기술적 세부사항이고 업무 규칙과는 아무 관련 없다.
    • 이들은 시스템의 다른 측면과는 다른 속도로, 그리고 다른 이유로 변경된다.
    • 결론적으로 아키텍트는 이들을 시스템의 나머지 부분으로부터 분리하여 독립적으로 변경 가능하도록 해야 한다.
  • 이제 우리는 시스템을 서로 결합되지 않은 수평적인 계층으로 분리하는 방법을 알게 되었다.
    • 이러한 계층의 예로는 UI, 애플리케이션에 특화된 업무 규칙, 애플리케이션과는 독립적인 업무 규칙, DB 등을 들 수 있다.

유스케이스 결합 분리

  • 유스케이스도 서로 다른 이유로 변경되는 것들 중 하나이다.
  • 시스템을 수평적 계층으로 분할하면서 동시에 해당 계층을 가로지르는, 얇은 수직적인 유스케이스로 시스템을 분할할 수 있다.
    • 이와 같이 결합을 분리하려면 주문 추가 유스케이스의 UI와 주문 삭제 유스케이스의 UI를 분리해야 한다.
    • 유스케이스의 업무 규칙과 데이터베이스 부분도 마찬가지다.
    • 이런식으로 시스템의 맨 아래 계층까지 수직으로 내려가며 유스케이스들이 각 계층에서 서로 겹치지 않게 한다.

image 출처: https://jandari91.github.io/posts/PPPCleanArchitecture_ch16/

  • 시스템에서 서로 다른 이유로 변경되는 요소들의 결합을 분리하면 기존 요소에 지장을 주지 않고도 새로운 유스케이스를 계속해서 추가할 수 있게 된다.

운영

  • 운영 관점에서 도움이 된다.
  • UI와 DB 는 업무 규칙과는 다른 서버에서 실행될 수 있기에, 높은 대역폭을 요구하는 유스케이스는 여러 서버로 복제하여 실행할 수 있다.
  • 하지만 운영 측면에서 이점을 살리기 위해선 결합을 분리할때 적절한 모드를 선택해야 한다.
  • 예를 들어 분리된 컴포넌트를 서로 다른 서버에서 실행해야 하는 상황이라면, 이들 컴포넌트가 단일 프로세서의 동일한 주소 공간에 함께 상주하는 형태로 만들어져선 안된다.
    • 분리된 컴포넌트는 반드시 독립된 서비스가 되어야 하고, 네트워크를 통해 서로 통신해야 한다. 많은 아키텍트가 이런 컴포넌트를 ‘서비스’ 또는 ‘마이크로서비스’ 라 부른다.
  • 우리는 때때로 컴포넌트를 서비스 수준까지도 분리해야 한다는 것이다.
  • 기억해야 할 점은 좋은 아키텍처는 선택권을 열어둔다는 사실이다. 결합 분리 모든느 이러한 선택지중 하나이다.

개발 독립성

  • 컴포넌트가 완전히 분리되면 팀 사이의 간섭은 줄어들게 되어 개발 독립성을 보장할 수 있게 된다.

배포 독립성

  • 배포 측면에서도 고도의 유연성이 생긴다.
  • 운영중인 시스템에서 계층과 유스케이스를 교체할 수 있으며, 새로운 유스케이스를 추가하는 일은 새로운 jar 파일이나 서비스 몇 개를 추가하는 정도로 단순한 일이 된다.

중복

  • 소프트웨어에서 중복은 나쁜 것인데 중복은 여러 종류가 있다.
    • 하나는 진짜 불필요한 제거해야할 중복이다.
    • 다른 하나는 거짓된 또는 우발적 중복이다. 중복으로 보이는 두 코드 영역이 각자의 경로로 발전한다면, 즉 서로 다른 속도와 다른 이유로 변경된다면 이 두 코드는 진짜 중복이 아니다. 몇 년이 지나 다시 보면 두코드가 매우 다르다는 사실을 알게 될 것이다.
    • 예를 들어 두 유스케이스의 화면 구조가 매우 비슷해서 코드를 통합하게 될 경우 우발적 중복일 가능성이 높다.
    • 시간이 지나면서 두 화면은 서로 다른 방향으로 분기하며, 결국엔 매우 다른 모습을 가질 가능성이 높다.
    • 이러한 이유로, 해당 코드를 통합하지 않도록 유의해야 한다.
    • 그렇지 않으면 나중에 코드를 다시 분리하느라 큰 수고를 감수해야 한다.

나의 생각: 프론트 화면 구현시에 디자인 시스템 위젯 외의 항목들은 모두 따로 구현해야 하는 것일까? 일부 비슷한 컴포넌트는 가젯으로 만들어서 재사용하긴 하지만, 만약 기획&디자인과 협의한 UI들에 대해서도 무조건 별도 분리해야 하는 것일까?

  • DB 레코드와 UI 화면이 비슷해도 그대로 전달하기보단 뷰모델을 통해 계층 간 결합을 적절하게 분리하여 유지해라.

나의 생각: MVVM 아키텍처도 이러한 계층 간의 책임을 나누고 계층 간의 결합을 분리하고자 하는 것이 핵심이 아닐까 싶다. Model 에선 데이터를 관리하는 책임만, ViewModel 에선 View 에 전달하기 위한 데이터 가공 및 연결의 책임만, View 에서 UI의 책임만 가질 수 있도록 말이다.

결합 분리 모드(다시)

  • 계층과 유스케이스의 결합을 분리하는 방법은 다양한다.

1) 소스 수준 분리 모드

  • 소스 코드 모듈 사이의 의존성을 제어할 수 있다.
  • 이를 통해 하나의 모듈이 변하더라도 다른 모듈을 변경하거나 재컴파일하지 않도록 만들 수 있다.(예, 루비 Gem)
  • 모든 컴포넌트가 같은 주소 공간에서 실행되고, 서로 통신할 때는 간단한 함수 호출을 사용한다. 컴퓨터 메모리에는 하나의 실행 파일만이 로드된다.
  • 이러한 구조를 모노리틱 구조라 부른다.
  • 소스 코드 모듈 사이 접근제어자를 통한 분리로 생각하며 될 것 같다.

2) 배포 수준 분리 모드

  • jar 파일, DLL, 공유 라이브러리와 같이 독립적으로 배포 가능한 단위들 사이의 의존성을 제어할 수 있다.
  • 이를 통해 한 모듈의 소스 코드가 변하더라도 다른 모듈을 재빌드하거나 재배포하지 않도록 만들 수 있다.
  • 많은 컴포넌트가 여전히 같은 주소 공간에 상주하며, 단순한 함수 호출을 통해 통신할 수 있다.
  • 멀티 모듈 구조에서 하나의 모듈을 예로 들 수 있을 것이다.

3) 서비스 수준 분리 모드

  • 의존하는 수준을 데이터 구조 단위까지 낮출 수 있고, 순전히 네트워크 패킷을 통해서만 통신하도록 만들 수 있다.(예, 서비스 또는 마이크로서비스)

  • 어떤 모드가 사용하기 좋을까? 프로젝트 초기 다계는 어떤 모드가 최선인지 알기 어려우며 프로젝트가 성숙해갈수록 최적인 모드가 달라질 수 있다.
  • 시스템이 한 서버에서 실행되는 동안은 결합은 소스 수준에서 분리하는 것만으로도 충분하다.
  • 하지만 나중에는 배포 가능한 단위, 심지어는 서비스 수준까지 분리해야 할 수도 있다.
  • (현시점에 가장 인기있어 보이는) 한 가지 해결책은 단순히 서비스 수준에서의 분리를 기본 정책으로 삼는 것이다.
    • 하지만 이 방식은 비용이 많이 들고 결합이 큰 단위에서 분리된다는 문제가 있다.(새로운 레포 생성하고 프로비저닝하고 CI/CD 작업하고 애플리케이션 기반작업해야 하고.., 모든 마이크로서비스의 공통 작업이 있을때마다 전부 챙겨줘야 하고..)
    • 마이크로서비스 가 아무리 작다(micro)하더라도, 충분히 작은 단위에서 분리될 가능성은 거의 없다.
    • 서비스 수준의 결합 분리가 지닌 또 다른 문제점은 개발 시간 측면뿐 아니라 시스템 자원 측면에서도 비용이 많이 든다는 사실이다.
  • 저자는 이처럼 컴포넌트가 서비스화될 가능성이 있따면 컴포넌트 결합을 분리하되 서비스가 되기 직전에 멈추는 방식을 선호한다고 한다. 그러고는 컴포넌트들을 가능한 오랫동안 동일한 주소 공간에 남겨두어 서비스에 대한 선택권을 열어 둔다고 한다.
  • 위 방식을 사용하면 초기엔 컴포넌트가 소스 코드 수준에서 분리된다.
    • 프로젝트 수행 기간엔 넉넉할 것이다.
    • 배포나 개발에서 문제가 생기면 일부 결합을 배포 수준까지 분리해 대응하면 된다.
    • 개발, 배포, 운영적인 문제가 증가하면 서비스 수준으로 전환할 배포 단위들을 신중하게 선택 후, 점차적으로 서비스화하는 방향으로 시스템을 변경해나간다.
  • 시간이 흐르면 시스템에서 운영 요구사항은 감소할 수 있는데, 이때 결합을 서비스 수준까지 분리해야 했던 것들이 이제 배포 수준, 심지어 소스 수준의 결합 분리만으로 충분할 수도 있다.
  • 좋은 아키텍처는 시스템이 모노리틱 구조로 태어나서 단일 파일로 배포되더라도, 이후엔 독립적으로 배포 가능한 단위들의 집합으로 성장하고, 또 독립적인 서비스나 마이크로서비스 수준까지 성장할 수 있도록 만들어져야 한다. 또한 나중에 상황이 바뀌었을때 이 진행 방향을 거꾸로 돌려 원래 형태인 모노리틱 구조로 되돌릴 수도 있어야 한다.
  • 좋은 아키텍처는 이러한 변경으로부터 소스 코드 대부분을 보호한다.
  • 좋은 아키텍처는 결합 분리보드를 선택사항으로 남겨두어 배포 규모에 따라 가장 적합한 모드를 선택해 사용할 수 있게 만들어 준다.

결론

  • 위처럼 하기는 까다롭다. 그리고 결합 분리 모드를 변경하기가 설정 값 하나 바꾸듯 쉬워야 한다는 뜻도 아니다.
  • 시스템의 결합 분리 모드는 시간이 지나면서 바뀌기 쉬우며, 뛰어난 아키텍트라면 이러한 변경을 예측하여 큰 무리 없이 반영할 수 있도록 해야 한다.
    • 소스 수준 분리 -> 배포 수준 분리 -> 서비스 수준 분리
    • 소스 수준 분리 <- 배포 수준 분리 <- 서비스 수준 분리

17장 - 경계 선긋기

  • 소프트웨어 아키텍처는 선을 긋는 기술이다.
  • 이러한 선은 경계라 부르며 소프트웨어 요소를 서로 분리하고, 경계 한 편에 잇는 요소가 반대편에 있는 요소를 알지 못하도록 막는다.
  • 아키텍트의 목표는 필요한 시스템을 만들고 유지하는데 드는 인적 자원을 최소화하는 것이다.
    • 인적 자원의 효율을 떨어뜨리는 요인은 결합이다. 특히 너무 일찍 내려진 결정에 따른 결합이다.
  • 어떤게 이른 결정일까?
  • 시틈에의 업무 요구사항, 즉 유스케이스와 아무 관련 없는 결정이다.
  • 프레임워크, 데이터베이스, 웹 서버, 유틸리티 라이브러리, 의존성 주입에 대한 결정 등이 여기 포함된다.
  • 좋은 시스템 아키텍처란 이러한 결정이 부수적이며, 결정을 연기할 수 있는 아키텍처다.
    • 이러한 결정을 가능한 최후의 순간에 내릴 수 있게 해주며, 결정에 따른 영향이 크지 않게 만든다.

두 가지 슬픈 이야기

  • 1990년대 모 회사에서 서버 팜(서버 클러스터)이 춤추는 이상을 그리며 아키텍처를 설계하고 개발했지만, 실제 이 서버팜은 존재한적이 없게 되었다. 서버 팜을 필요로 하는 시스템을 판매하지 못한 것이다. => 과설계
  • 이 비극은 아키텍트가 너무 이르게 결정을 내림으로써 개발 비용을 엄청나게 가중시킨 사례다.
  • 그리고 또 다른 회사는 첨부터 SOA 로 개발하고자하여 새로운 기능을 추가하려면 서비스들 사이의 결합으로 엄청난 양의 WSDL 을 변경해야 하며, 변경에 영향받는 모든 것을 재배포 해야 했다. 이는 엄청 고통이었다.
    • 이 회사의 실수는 SOA 를 약속하는 일련의 도구들을 너무 일찍 채택하여 적용했다는 것이다.
    • 즉, 거대한 일련의 도메인 객체 서비스를 너무 이르게 채택한 것이다.
    • 이러한 실수로 인적 시간, 그것도 엄청난 양의 인적 시간에 따른 비용이 SOA 의 소용돌이에 휩쓸려 떠내려간 것이다.

FitNesse

  • 저자는 2001년에 Fitnesse 를 만들기 시작했는데, 그 과정에서 두 가지를 잘한일로 생각했다.

1) 웹 서버를 직접 작성하기로 한 일

  • 기본 뼈대만 갖춘 웹 서버는 단순한 단일 소프트웨어이기에 구현이 간단할 뿐 아니라, 어떤 웹 프레임워크를 사용할지에 대한 결정을 훨씬 나중으로 연기할 수 있도록 해주었기 떄문이다.

2) 데이터베이스에 대해 고민하지 않은 것

  • 어떤 DB를 사용하더라도 상관없는 형태로 설계함으로써, 의도적으로 DB에 대한 결정을 미루었다.
  • 모든 데이터 접근 영역과 데이터 저장소 영역 사이에 인터페이스를 추가하는 간단한 설계 방식을 사용했다.
  • 실제 영속성을 구현할 시점이 되자 MySQL을 다시 한 번 고민했지만 단기적으론 필요치 않다는 결정을 내렸다.
  • 왜냐하면 해시 테이블을 플랫 파일에 저장하도록 구현하는 일은 정말 쉬운일이었기 떄문이다.
    • 석달 후 플랫 파일을 사용한 해결책이 적합했단 결론을 내렸다.
    • MySQL 과 관련된 아이디어는 완전히 폐기하기로 했다.
  • MySQL을 선택사항으로 추가하는 것도 쉽게 가능했다.
    • 하지만 아무도 사용하지 않았다…
  • FitNesse 개발 초기에 우리는 업무 규칙과 데이터베이스 사이에 경계선을 그었다.
  • 경계선을 통해 업무 규칙은 데이터 접근 메서드 외에는 데이터 베이스에 관한 어떤 것도 알지 못하게 되었다.
  • 파일 시스템을 선택하여 시도해볼 수도 있었고, 더 나은 해결책이 보이면 방향을 바꿀 수도 있었다.
  • 더군다나 누군가 애초의 방향(MySQL)으로 가기를 원할 때 방해가 되거나 지연이 되지도 않았다.
  • 또한 DB가 없다는 사실은 스키마 관련 문제, 쿼리 문제, DB 서버 문제 등등 여타 모든 고약한 문제가 없게 되었다.
  • 또한 테스트를 느리게 만드는 DB가 없으니 테스트를 더 빨리 돌릴 수 있었다.
  • 간단히 말해서 경계선을 긋는 행위는 결정을 늦추고 연기하는데 도움이 되었고, 궁극적으로 시간을 엄청나게 절약해주었으며, 골치를 썩지 않게 해주었다.

어떻게 선을 그을까? 그리고 언제 그을까?

  • 관련이 있는 것과 없는 것 사이에 선을 긋는다.
    • GUI 는 업무 규칙과는 관련 없기 떄문에, 이 둘 사이에는 반드시 선이 있어야 한다.
    • 데이터 베이스는 GUI와는 관련 없으므로, 이 둘 사이에도 반드시 선이 있어야 한다.
    • 데이터베이스는 업무 규칙과 관련 없으므로, 이 둘 사이에는 반드시 선이 있어야 한다.
  • 데이터베이스는 업무 규칙이 간접적으로 사용할 수 있는 도구다.
    • 업무 규칙은 데이터베이스와 관련된 세부사항에 대해 어떤 것도 알아선 안된다.
  • 업무 규칙이 알아야 할 것은 데이터를 가져오고 저장할 때 사용할 수 있는 함수 집합이 있다는 사실이 전부다.
  • 이러한 함수 집합을 통해 우리는 데이터베이스를 인터페이스 뒤로 숨길 수 있다.
  • 아래 이미지에서 이 점을 분명하게 볼 수 있다.
    • BusinessRules 는 DatabaseInterface 를 사용하여 데이터를 로드하고 저장한다.
    • DatabaseAccess 는 DatabaseInterface 를 구현하며, Database 를 실제로 조작하는 일을 맡는다.

image

  • 경계선은 상속 관계를 횡단하며 DatabaseInterface 바로 아래에 그어진다.
  • DatabaseAccess 에서 출발하는 두 화살표는 DatabaseAccess 클래스로부터 바깥쪽으로 향한다.
  • 즉, 이 도표에서 DatabaseAccess 가 존재한다는 사실을 알고 있는 클래스는 없다는 뜻이다.

image

  • 이제 조금 물러나서 보자.

image

  • 위 이미지의 화살표 방향에 주목하자.
  • Database 는 BuisinessRules 에 대해 알고 있다.
  • BusinessRules 는 Database 에 관해 알지 못한다.
  • 이는 DatabaseInterface 클래스는 BusinessRules 컴포넌트에 속하며, DatabaseAccess 클래스는 Database 컴포넌트에 속한다는 사실을 의미한다.
  • 이 선의 방향이 중요하다. BusinessRules 에 있어 Database 는 문제가 되지 않지만, Database는 BusinessRules 없이는 존재하라 수 없다는 사실을 이 방향을 통해 알 수 있다.
  • 두 컴포넌트 사이에 이러한 경계선을 그리고 화살표의 방향이 BusinessRules 를 향하도록 만들었으므로, BusinessRules 에선 어떤 종류의 데이터베이스도 사용할 수 있음을 알 수 있다.
    • Database 컴포넌트는 다양한 구현체로 교체될 수 있으며, BusinessRules 는 조금도 개의치 않는다.
  • 데이터베이스를 결정하기에 앞서 업무 규칙을 먼저 작성하고 테스트하는데 집중할 수 있음을 의미한다.

입력과 출력은?

  • 입력과 출력은 중요치 않다. 중요한건 업무 규칙이다.
  • 이번에도 마찬가지로 GUI와 BusinessRules 컴포넌트가 경계선에 의해 분할된다는 사실을 볼 수 있다.
  • 관련성이 낮은 컴포넌트(GUI)가 관련성이 높은 컴포넌트(BusinessRules)에 의존한다는 사실을 다시 한 번 살펴볼 수 있다.

image

  • GUI는 다른 종류의 인터페이스로 얼마든지 교체 가능하며, BusinessRules 는 전혀 개의치 않는다는 사실을 알 수 있다.

플러그인 아키텍처

  • 선택적이거나 또는 수많은 다양한 형태로 구현될 수 있는 나머지 컴포넌트로부터 핵심적인 업무 규칙은 분리되어 있고, 또한 독립적이다.

image

  • 위 설계에서 UI는 플러그인 형태로 고려되었기에, 수많은 종류의 UI를 플러그인 형태로 연결할 수 있게 된다.
    • 웹 기반일 수 있고, 클라이언트/서버 기반이거나, SOA, 콘솔 기반, 또는 임의의 어떤 UI도 가능하다..
    • 이는 데이터베이스에도 동일하게 적용가능한데 데이터베이스를 플러그인으로 다루기로 결정했기 떄문에, 임의의 다양한 RDB, NoSQL DB, 파일 시스템 기반 DB 등 다양하게 바꿀 수 있는 것이다.
  • 업무 규칙과 새로운 UI 간 통신 일부는 재작업해야 할 가능성이 높더라도 플러그인 구조를 가정한채 시작함으로써, 최소한 우리는 이러한 변경 작업을 현실성 있도록 만들었다.

플러그인에 대한 논의

  • 시스템을 플러그인 아키텍처로 배치함으로써 변경이 전파될 수 있는 방화벽을 생성 가능하다.
    • GUI 가 업무 규칙에 플러그인 형태로 연결되면 GUI에서 발생한 변경은 절대로 업무 규칙에 영향을 미칠순 없다.
  • 경계는 변경의 축이 있는 지점에 그어진다.
  • 경계의 한쪽에 위치한 컴포넌트는 다른 경계 반대편 컴포넌트와는 다른 속도 및 다른 이유로 변경된다.
  • GUI는 업무 규칙과는 다른 시점에 다른 속도로 변경되므로, 이 둘 사이에는 반드시 경계가 필요하다.
  • 업무 규칙은 의존성 프레임워크와는 다른 시점에 그리고 다른 이유로 변경되므로, 둘 사이에도 반드시 경계가 필요하다.
  • 이 역시도 순전히 단일 책임 원칙에 해당한다. 이는 어디에 경계를 그어야 할지를 알려준다.

결론

  • 경계선을 그리려면 먼저 시스템을 컴포넌트 단위로 분할해야 한다.
  • 일부 컴포넌트는 핵심 업무 규칙에 해당하며 나머지 컴포넌트는 플러그인으로, 핵심 업무 규칙과는 직접적인 관련이 없지만 필수 기능을 포함한다.
  • 그런 다음 컴포넌트 사이의 화살표가 특정 방향, 즉 핵심 업무를 향하도록 이들 컴포넌트의 소스를 배치한다.
  • 이는 의존성 역전 원칙과 안정된 추상화 원칙을 응용한 것임을 눈치챌 수 있어야 한다.
  • 의존성 화살표는 저수준 세부사항에서 고수준의 추상화를 향하도록 배치된다.

Reference

18장 - 경계 해부학

경계 횡단하기

  • ‘런타임에 경계를 횡단한다’ 함은 그저 경계 한 쪽에 있는 기능에서 반대편 기능을 호출하여 데이터를 전달하는 일에 불과하다.
  • 적절한 위치에서 경계를 횡단하는 비결은 소스 코드 의존성 관리에 있다.
  • 왜 소스 코드일까? 소스 코므 모듈 하나가 변경되면, 이에 의존하는 다른 소스 코드 모듈도 변경하거나 다시 컴파일 후 재배포해야할지도 모르기 때문이다.
  • 경계는 이러한 변경이 전파되는 것을 막는 방화벽을 구축하고 관리하는 수단으로써 존재한다.

두려운 단일체

1) 저수준 클라이언트 -> 고수준 서비스 함수 호출

  • 가장 단순한 형태의 경계 횡단이다.
  • 이 경우 런타임 의존성과 컴파일 의존성은 모두 저수준 -> 고수준 컴포넌트로 향한다.

image

  • 위 이미지에서 제어흐름은 왼쪽에서 오른쪽으로 경계를 횡단한다.
  • Client는 Service의 f() 함수를 호출하는데 경계에서 호출되는 쪽에 Data에 정의가 위치한다는 사실이 중요하다.

2) 고수준 클라이언트 -> 저수준 서비스를 호출

  • 의존성을 역전을 이용한다.
  • 이렇게 하면 런타임 의존성은 컴파일 타임 의존성과 반대가 된다.

image

  • 모두 오른쪽에서 왼쪽으로 경계를 횡단한다.
  • 고수준은 저수준(세부사항)으로 부터 독립적으로 유지시켜야 함.

  • 단일체에서 컴포넌트간 통신은 함수 호출이므로 매우 빠르고 값싸다.
    • 소스 수준에서 결합이 분리되면 경계를 가로지르는 통신은 상당히 빈번할 수 있다.

배포형 컴포넌트

  • 동적링크 라이브러리(ex: jar)
    • => 배포시 컴파일 필요 없고 바이너리와같이 배포 가능한 형태로 전달된다.
  • 배포수준 컴포넌트(ex: war)
    • => 단순히 배포 가능 한 단위를 좀 더 편리한 형태로 묶은것.
  • 위의 둘은 배포 과정에서만 차이 날 뿐, 동일하다.
    • 모든 함수가 동일한 프로세서, 주소 공간에 위치하하며,
    • 컴포넌트 분리, 의존성 관리하는 전략도 동일하기 때문이다.

스레드

  • 단일체와 배포형 컴포넌트 모두 스레드를 활용 가능하다.
  • 그러나 스레드는 아키텍처 경계나 배포 단위가 아니며, 단순히 실행 계획과 순서를 체계화하는 방법에 가깝다.

로컬 프로세스

  • 훨씬 강한 물리적 형태를 띠는 아키텍처 경계이다.
  • 주로 명령행이나 그와 유사한 시스템 호출을 통해 생성된다.
  • 각 로컬 프로세스는 정적으로 링크된 단일체이거나 동적으로 링크된 여러 개의 컴포넌트로 구성될 수 있다.
    • 전자는 여러 모노리틱 프로세스가 같은 컴포넌트들을 가지고 있을 수 있다.
    • 후자의 경우 동적으로 링크된 배포형 컴포넌트들을 서로 공유할 수 있다.
  • 로컬프로세스는 컴포넌트 간 의존성을 동적 다형성(의존성역전)을 통해 관리하는 저수준 컴포넌트로 구성된다.
  • 로컬프로세스 간 분리 전략도 단일체나 바이너리 컴포넌트의 경우와 동일하다. 즉 항상 고수준 컴포넌트를 향한다.
  • 즉 저수준의 프로세스가 고수준 프로세스의 플러그인이 되도록 만드는 것이 아키텍처 관점에서의 목표이다.

서비스

  • 물리적인 형태를 띠는 가장 강력한 경계이다.
  • 서비스들은 네트워크를 통해 모든 통신이 이뤄진다. 그러기에 함수 호출보단 느리다.
  • 이 수준 통신에선 지연(latency)에 따른 문제를 고수준에서 처리할 수 있어야 한다.
  • 로컬 프로세스와 마찬가지로 저수준 서비스는 고수준 서비스에 ‘플러그인’되어야 한다.
  • 고수준 서비스의 소스 코드에는 저수준 서비스를 특정짓는 어떤 물리적인 정보(예를 들면, URI)도 절대 퐇마해선 안된다.

결론

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

Reference

19장 - 수준

  • 소프트웨어 아키텍처를 개발하는 기술에는 정책을 신중하게 분리하고, 정책이 변경되는 야상에 따라 정책을 재편성하는 일도 포함된다.
    • 동일한 이유로 동일한 시점에 변경되는 정책은 동일한 수준에 위치하며, 동일한 컴포넌트에 속해야 한다.
    • 반대로, 서로 다른 이유로 다른 시점에 변겨오디는 정책은 다른 수준에 위치하며 반드시 다른 컴포넌트로 분리해야 한다.

수준

  • 수준은 엄밀히 정의하면 ‘입력과 출력까지의 거리’다.
  • 시스템의 입력과 출력 모두로부터 멀리 위치할수록 정책의 수준은 높아진다.
  • 입력과 출력을 다루는 정책이라면 시스템 최하위 수준에 위치한다.

image

  • 위 이미지는 간단한 암호화 프로그램이다.
  • 입력장치에서 문자를 읽어서, 테이블을 참조하여 문자를 번역 후, 번역된 문자를 출력 장치로 기록한다.
  • 데이터 흐름은 굽은 실선 화살표로, 소스 코드 의존성은 곧은 점선으로 표시되어야 한다.
  • 번역 컴포넌트는 이 시스템에서 최고 수준의 컴포넌트인데, 입력과 출력에서부터 가장 멀리 떨어져있기 때문이다.
  • 주목할 점은 데이터 흐름과 소스 코드 의존성이 항상 같은 방향을 가리키지는 않는다는 사실이다.
    • 소스 코드 의존성은 그 수준에 따라 결합되어야 하며, 데이터 흐름을 기준으로 결합되어선 안된다.
1
2
3
4
5
function encrypt() {
  while(true) {
    writeChar(translate(readChar()))
  } 
}
  • 위는 잘못된 아키텍처다.
  • 고수준인 encrypt 함수가 저수준인 readChar 와 writeChar 함수에 의존하기 때문이다.

image

  • 위 이미지는 잘못된 사례를 개선한 아키텍처 모습이다.
  • 주목할점은 Encrypt 클래스, CharWriter와 CharReader 인터페이스를 둘러싸고 있는 점선으로된 경계다.
  • 이 경계를 횡단하는 의존성은 모두 경계 안쪽으로 향한다.
  • 이 경계로 묶인 영역이 이 시스템에서 최고 수준의 구성요소다.
  • ConsoleReader와 ConsoleWriter 는 입력과 출력에 가깝기 때문에 저수준이다.
  • 고수준의 암호화 정책을 저수준의 입력/출력 정책으로부터 분리시킨 방식에 주목하자.
  • 이 암호화 정책을 더 넓은 맥락에서 사용할 수 있다.
  • 입력과 출력에 변화가 생기더라도 암호화 정책은 거의 영향을 받지 않기 때문이다.

  • 정책을 컴포넌트로 묶는 기준은 정책이 변경되는 방식에 달렸있다는 사실을 상기하자.
  • 단일 책임 원칙(SRP)과 공통 폐쇄 원칙(CCP)에 따르면 동일한 이유로 동일한 시점에 변겨오디는 정책은 함께 묶인다.
    • 고수준 정책은 저수준 정책에 비해 덜 빈번하게 변경되고, 보다 중요한 이유로 변경되는 경향이 있다.
    • 저수준 정책은(입출력에 가까이 위치한 정책) 더 빈번하게 변경되며, 보다 긴급성을 요하며, 덜 중요한 이유로 변경되는 경향이 있다.
    • 예를 들어 입출력 장치가 변경될 가능성은 암호화 알고리즘이 변경될 가능성보다 훨씬 높아 보인다.
  • 모든 소스 코드 의존성 방향이 고수준 정책을 향할 수 있도록 정책을 분리했다면 변경의 영향도를 줄일 수 있다.
  • 시스템의 최저 수준에서 중요하지 않지만 긴급한 변경이 발생하더라도, 보다 높은 위치의 중요한 수준에 미치는 영향은 거의 없게 된다.(경계, 방화벽..)
  • 이 논의는 저수준 컴포넌트가 고수준 컴포넌트에 플러그인되어야 한다는 관점으로 바라볼 수도 있다.(p.197 그림 19.3 이미지참고)

결론

  • 이 장에서 설명한 정책에 대한 논의는 단일 책임 원칙, 개방 폐쇄 원칙, 공통 폐쇄 원칙, 의존성 역전 원칙, 안정된 의존성 원칙, 안정된 추상화 원칙을 모두 포함한다.

나의 생각🙌

1) 동기화 람다함수

image

  • 이전 동기화 람다 함수 컴포넌트 다이어그램을 보면 app-main, app-{고객사} 모듈이 app-core 모듈에 대한 의존성을 가지고 있다.
  • 즉 저수준(app-main 은 입력 부분이며 app-{고객사 모듈} 모두 입력 부분이라 볼 수 있기에) 컴포넌트가 고수준(고수준의 정책이 담긴, 예를 들어 코드/조직/구성원 데이터를 비교 및 CMS 제품 정책을 유지해줄 수 있는) 컴포넌트 app-core 를 의존하고 있는 형태라 볼 수 있다.
  • 하지만 출력 부분(cms 로 update api 를 호출하는) 또한 app-core 에 위치하고 있기에 이 장의 내용에 따르면 별도 컴포넌트로 분리하는게 좋아보인다.

2) 레이어드 아키텍처

image

  • 일반적인 백엔드 레이어드 아키텍처에서 web 모듈과 infra 모듈은 domain 모듈을 참조하고 있다.
  • 생각해보면 web 모듈은 입력부분, infra 입출력부분으로 볼 수 있기에 web과 infra 모듈은 저수준이며, domain 모듈은 멀리 떨어져있기에 고수준으로 볼 수 있다.

3) 프론트 아키텍처

image

  • 일반 페이지 모듈(사용자 사이트, CMS)은 디자인 시스템 컴포넌트들을 포함하는 위젯 모듈에 대한 의존성을 가지고 있다.
  • 여기서 일반 페이지 모듈(사용자 사이트, CMS)은 입출력에 해당하는 저수준 모듈로 볼 수 있다.

Reference

20장 - 업무 규칙

  • 업무 규칙은 컴퓨터상으로 구현했는지와 무관하게 사업적으로 수익을 얻거나 비용을 줄일 수 있는 규칙 또는 절차다.
    • ex. 대출에 N% 이자를 부과한다는 사실은 은행이 돈을 버는 업무 규칙이다.
    • 이러한 사실은 컴퓨터 프로그램으로 이자를 계산하든, 또는 직원이 주판을 튕겨 계산하든 하등의 관계가 없다.
  • 이러한 업무 규칙을 핵심 업무 규칙이라 부른다.
    • 사업 자체에 핵심적이며, 규칙을 자동화하는 시스템이 없더라도 업무 규칙은 그대로 존재하기 때문이다.
  • 핵심 업무 규칙은 보통 데이터를 요구하는데 이를 핵심 업무 데이터라 부른다.
    • 예를 들어, 대출에는 대출 잔액, 이자율, 지급 일정이 필요하다.
  • 핵심 규칙과 핵심 데이터는 본질적으로 결합되어 있기 떄문에 객체로 만들 좋은 후보다. 이를 엔티티라 칭한다.

엔티티

  • 핵심 업무 데이터를 기반으로 동작하는 일련의 조그만 핵심 업무 규칙을 구체화한 객체다.

image

  • 대출 엔티티를 UML클래스로 표현한 엔티티로 독립적으로 존재한다.
  • 이 클래스는 핵심업무개념을 표현하며 DB, UI 등에 무관하게 존재한다.
  • 엔티티는 꼭 객체 지향 언어로 구현할 필욘 없다.
  • 핵심 업무 데이터와 핵심 업무 규칙을 하나로 묶어 별도 소프트웨어 모듈로 만들면 된다.

유스케이스

  • 사용자가 제공해야 하는 입력, 사용자에게 보여줄 출력, 해당 출력을 생성하기 위한 처리 단계를 기술한다.
  • 엔티티 내의 핵심 업무 규칙과는 반대로 유스케이스는 애플리케이션에 특화된 업무 규칙을 설명한다.

image

  • 유스케이스는 엔티티 내부의 핵심 업무 규칙을 어떻게, 그리고 언제 호출할지를 명시하는 규칙을 담는다.
  • 유스케이스는 UI를 기술하지 않는다는점이 중요하다.
    • 유스케이스만 봐선 웹을 통해 전달되는지, 콘솔 기반인지 구분할 수 없어야 한다.
    • 즉, 애플리케이션에 특화된 규칙을 설명하며, 이를 통해 사용자와 엔티티 사이의 상호작용을 규정한다.
  • 엔티티는 자신을 제어하는 유스케이스에 대해 아무것도 알지 못한다.
    • 이는 DIP 를 준수하는 의존성 방향에 대한 또 다른 예다.
    • 엔티티와 같은 고수준 개념은 유스케이스와 같은 저수준 개념에 대해 아무것도 알지 못한다.
    • 반대로 저수준인 유스케이스는 고수준인 엔티티에 대해 알고 있다.
  • 왜 엔티티는 고수준이며, 유스케이스는 저수준일까?
    • 유스케이스는 단일 애플리케이션에 특화되어 있어 해당 시스템의 입출력과 가깝기 때문이다.
    • 반대로 엔티티는 다양한 애플리케이션에 적용 가능하도록 일반화된 것이기에 더 멀다.

요청 및 응답 모델

  • 제대로 구성된 유스케이스 객체라면 데이터를 사용자나 또 다른 컴포넌트와 주고 받는 방식에 대해 전혀 눈치챌 수 없어야 한다..
    • 유스케이스 클래스의 코드가 HTML, SQL 에 대해 알게 되선 안된다.
  • 유스케이스는 단순한 요청 데이터 구조를 독립적으로 받고, 단순한 응답 데이터 구조를 출력으로 반환한다.
    • 웹에 대해서 알지 못하며 UI 에 종속되서도 안된다..
  • 이처럼 의존성을 제거하는 것은 매우 중요하다.
    • 요청 및 응답 모델이 독립적이지 않다면, 그 모델에 의존하는 유스케이스도 결국 해당 모델이 수반하는 의존성에 간접적으로 결합되어 버린다.
    • 즉 엔티티와 RQ/RS 모델을 독립적으로 분리하라는게 핵심이다.
  • 왜일까? 두 객체의 목적이 완전히 다르기에 시간이 지나면 두 객체는 완전히 다른 이유로 변경될 것이다.
    • 따라서 두 객체를 어떤 식으로든 함께 묶는 행위는 공통 폐쇄 언칙과 단일 책임 원칙을 위반하게 된다.
    • 결국 코드에는 수많은 떠돌이 데이터가 만들어지고, 수 많은 조건문이 추가될 것이다…

결론

  • 업무 규칙은 UI나 DB와 같은 저수준 관심사로 인해 오염되선 안되며, 원래 그대로의 모습으로 남아있어야 한다.
  • 이상적으로는 업무 귝칙을 표현하는 코드는 반드시 시스템의 심장부에 위치해야 하며, 덜 중요한 코드는 이 심장부에 플러그인되어야 한다.
  • 업무 규칙은 시스템에서 가장 독립적이며 가장 많이 재사용할 수 있는 코드여야 한다.

Reference

21장 - 소리치는 아키텍처

아키텍처의 테마

  • 주택이나 도서관의 계획서가 해당 건축물의 유스케이스에 대해 소리치는 것처럼, 소프트웨어 애플리케이션의 아키텍처도 애플리케이션의 유스케이스에 대해 소리쳐야 한다.
  • 아키텍처를 프레임워크로부터 제공받아선 절대 안된다.
  • 프레임워크는 사용하는 도구일 뿐, 아키텍처가 준수해야할 대상이 아니다.
  • 아키텍처를 프레임워크 중심으로 만들어버리면 유스케이스 중심이 되는 아키텍처가 절대 나올 수 없다.

아키텍처의 목적

  • 좋은 아키텍처는 유스케이스를 그 중심에 두므로, 프레임워크나 도구, 환경에 전혀 구애받지 않고 유스케이스를 지원하는 구조를 아무 문제 없이 기술할 수 있다.
  • 좋은 소프트웨어 아키텍처는 프레임워크, DB, 웹서버, 그리고 여타 개발 환경 문제나 도구에 대해선 결정을 미룰 수 있도록 만든다.
    • 프레임워크, 웹 서버 등은 열어 둬야할 선택사항이다.
  • 좋은 아키텍처는 프로젝트의 훨씬 후반까지 레일스, 스프링, 하이버네이트, 톰캣, MySQL 에 대한 결정을 하지 않아도 되도록 해준다.
  • 뿐만 아니라 이러한 결정을 쉽게 번복할 수 있도록한다.
  • 좋은 아키텍처는 유스케이스에 중점을 두며, 지엽적인 관심사에 대한 결합은 분리시킨다.

하지만 웹은?

  • 웹은 전달 메커니즘(입출력 장치)이며, 애플리케이션 아키텍처에서도 그와 같이 다뤄야 한다.
  • 애플리케이션이 웹을 통해 전달된다는 사실은 세부사항이며, 시스템 구조를 지배해선 절대 안된다.
  • 그리고 이는 미뤄야할 결정사항 중 하나이다.

시스템 아키텍처는 시스템이 어떻게 전달될지에 대해 가능하다면 아무것도 몰라야 한다. 과도한 문제를 일으키거나 근본적인 아키텍처를 뜯어고치지 않더라도 시스템을 콘솔 앱, 웹, 앱 등으로 전달할 수 있어야 한다.

프레임워크는 도구일 뿐, 삶의 방식이 아니다.

  • 어떻게 하면 아키텍처를 유스케이스에 중점을 둔채 그대로 보존할 수 있을지를 생각하라.
  • 프레임워크가 아키텍처 중심을 차지하는 일을 막을 수 있는 전략을 개발하라.

테스트하기 쉬운 아키텍처

  • 아키텍처가 유스케이스를 최우선으로 하고 프레임워크와는 적당한 거리를 둔다면, 프레임워크를 전혀 준비하지 않더라도 필요한 유스케이스 전부에 대해 단위 테스트를 할 수 있어야 한다.
  • 테스트를 돌리기 위해 웹서버, DB 가 반드시 필요한 상황이 되선 안된다.
  • 엔티티 객체는 반드시 오래된 방식의 간단한 객체(plain old object)여야 하며, 프레임워크나 DB, 또는 여타 복잡한 것들에 의존해선 안된다.
  • 유스케이스 객체가 엔티티 객체를 조작해야 한다.
  • 최종적으로, 프레임워크로 인한 어려움을 겪지 않고도 반드시 이 모두를 있는 그대로 테스트 가능해야 한다.

결론

  • 아키텍처는 시스템을 이야기해야 하며, 시스템에 적용한 프레임워크에 대해 이야기해선 안된다.
  • 당신이 헬스 케어 시스템을 구축한다면 새로 들어온 팀원이 소스 저장소를 봤을때 첫 인상은 “오 헬스 케어 시스템이군” 이어야만 한다.
  • 새로 합류한 팀원은 시스템이 어떻게 전달될지를 알지 못한 상태에서 시스템의 모든 유스케이스를 이해할 수 있어야 한다.
  • 언제가 이들은 이렇게 물을 것이다. “모델처럼 보이는 것들을 확인했습니다. 그런데 뷰와 컨트롤러는 어디에 있죠?”
  • 그러면 다음과 같이 답해야만 한다. “아, 그것은 세부사항이므로 당장은 고려할 필요가 없습니다. 나중에 결정할 겁니다.”

22장 - 클린 아키텍처

  • 지난 수십년간 시스템 아키텍처와 관련된 여러 가지 아이디어를 봐왔다.
    • 육각형 아키텍처(Hexagonal Architecture): 포트와 어댑터라고도 알려졌으며, 앨리스터 코오번이 개발했다.
    • DCI(Data, Context and Interaction)
    • BCE(Boundary-Control-Entity)
  • 모두 세부적인 면에선 다소 차이가 있더라도 그 내용은 상당히 비슷하다.
  • 이들의 목표는 모두 같은데, 바로 관심사의 분리(separation of concerns)다.
    • 이들은 모두 소프트웨어를 계층으로 분리함으로써 관심사의 분리라는 목표를 달성할 수 있다.
  • 각 아키텍처는 최소한의 업무 규칙을 위한 계층 하나와, 사용자와 시스템 인터페이스를 위한 또 다른 계층 하나를 반드시 포함한다.
  • 이들 아키텍처는 모두 시스템이 다음과 같은 특징을 지니도록 만든다.
프레임워크 독립성
  • 아키텍처는 프레임워크의 존재 여부에 의존하지 않는다.
  • 이를 통해 이러한 프레임워크를 도구로 사용할 수 있으며, 프레임워크가 지닌 제약사항안으로 시스템을 욱여 넣도록 강제하지 않는다.

테스트 용이성

  • 업무 규칙은 UI, DB, 웹서버 또는 여타 외부 요소 없이도 테스트 가능해야 한다.

UI 독립성

  • 시스템 나머지 부분을 변경하지 않고도 UI를 쉽게 변경할 수 있어야 한다.
  • 예를 들어, 업무 규칙을 변경하지 않은채 웹 UI를 콘솔 UI로 대체할 수 있어야 한다.

데이터베이스 독립성

  • 오라클이나 MSSQL 서버를 몽고DB, 빅테이블, 카우치DB 등으로 교체할 수 있어야 한다.
  • 업무 규칙은 DB 에 결합되지 않는다.

모든 외부 에이전시에 대한 독립성

  • 실제로 업무 규칙은 외부 세계와의 인터페이스에 대해 전혀 알지 못한다.

  • 아래 이미지(그림 22.1)의 다이어그램은 이들 아키텍처를 저부 실행 가능한 하나의 아이디어로 통합하려는 시도다.

image

의존성 규칙

  • 그림22.1에서 각각의 동심원은 소프트웨어에서 서로 다른 영역을 표현한다.
  • 보통 안으로 들어갈수록 고수준의 소프트웨어가 된다.
  • 바깥쪽 원은 메커니즘이고, 안쪽 원은 정책이다.
  • 이러한 아키텍처가 동작하도록 하는 가장 중요한 규칙은 의존성 규칙(Dependency Rule) 이다.

소스 코드 의존성은 반드시 안쪽으로, 고수준의 정책을 향해야 한다.

  • 내부 원에 속한 요소는 외부 원에 속한 어떤 것도 알지 못한다.
  • 특히 내부 원에 속한 코드는 외부 원에 선언된 어떤 것에 대해서도 그 이름을 언급해선 절대 안된다.
    • 여기엔 함수, 클래스, 변수, 그리고 소프트웨어 엔티티로 명명되는 모든 것이 포함된다.
  • 같은 이유로, 외부 원에 선언된 데이터 형식도 내부 원에서 절대로 사용해선 안된다.
    • 특히, 그 데이터 형식이 외부 원에 있는 프레임워크가 생성한 것이라면 더더욱 안된다.

엔티티

  • 엔티티는 전사적인 핵심 업무 규칙을 캡슐화한다.
  • 엔티티는 메서드를 가지는 객체이거나 일련의 데이터 구조와 함수의 집합일 수도 있다.
  • 기업의 다양한 애플리케이션에서 엔티티를 재사용할 수만 있다면, 그 형태는 그다지 중요치 않다.
  • 전사적이지 않은 단순한 단일 애플리케이션을 작성하고 있다면 엔티티는 해당 애플리케이션의 업무 객체가 된다.
    • 이 경우 엔티티는 가장 일반적이며 고수준인 규칙을 캡슐화한다.
  • 외부 무언가가 변경되더라도 엔티티가 변경될 가능성은 지극히 낮다.
    • 예를 들어 페이지 네비게이션이나 보안 관련 변경사항이 발생하더라도 업무 객체가 영향을 받지 않을 것이다.
  • 운영 관점에서 특정 애플리케이션에 무언가 변경이 필요하더라도 엔티티 계층에는 절대 영향주어선 안된다.

유스케이스

  • 유스케이스 계층은 애플리케이션에 특화된 업무 규칙을 포함한다. 그리고 시스템의 모든 유스케이스를 캡슐화하고 구현한다.
  • 유스케이스는 엔티티로 들어오고 나가는 데이터 흐름을 조정하며, 엔티티가 자신의 핵심 업무 규칙을 사용해서 유스케이스의 목적을 달성하도록 이끈다.
  • 이 계층에서 발생한 변경이 엔티티에 영향을 줘선 안된다.
    • 또한 DB, UI 또는 여타 공통 프레임워크와 같은 외부 요소에서 발생한 변경이 이 계층에 영향을 줘서도 안된다.
    • 유스케이스 계층은 이러한 관심사들로부터 격리되어 있다.
  • 하지만 운영 관점에서 애플리케이션이 변경된다면 유스케이스가 영향을 받으며, 따라서 이 계층의 소프트웨어에도 영향을 줄 것이다.
    • 유스케이스의 세부사항이 변하면 이 계층의 코드 일부는 분명 영향을 받을 것이다.

인터페이스 어댑터

  • 일련의 어댑터들로 구성된다.
  • 어댑터는 데이터를 유스케이스와 엔티티에게 가장 편리한 형식에서 DB나 웹과 같은 외부 에이전시에게 가장 편리한 형식으로 변환된다.
  • 예를 들어, 이 계층은 GUI의 MVC 아키텍처를 모두 포괄한다.
    • 프레젠터(Presenter), 뷰(View), 컨트롤러(Controller)는 모두 인터페이스 어댑터 계층에 속한다.
    • 모델은 그저 데이터 구조 정도이며, 컨트롤러에서 유스케이스로 전달되고, 다시 유스케이스에서 프레젠터와 뷰로 되돌아간다.
  • 마찬가지로 이 계층은 데이터를 엔티티와 유스케이스에게 가장 편리한 형식에서 영속성용으로 사용 중인 임의 프레임워크(즉, 데이터베이스)가 이용하기에 가장 편리한 형식으로 변환한다.
  • 이 원 안에 속한 어떤 코드도 데이터베이스에 대해 조금도 알아선 안된다.
  • 예컨대 SQL 기반의 DB 를 사용한다면 모든 SQL 은 이 계층을 벗어나선 안된다.
  • 특히 이 계층에서도 DB를 담당하는 부분으로 제한되어야 한다.

  • 또한 이 계층에선 데이터를 외부 서비스와 같은 외부적인 형식에서 유스케이스나 엔티티에서 사용되는 내부적인 형식으로 변환하는 또 다른 어댑터가 필요하다.

프레임워크와 드라이버

  • 그림22.1에서 가장 바깥쪽 계층은 일반적으로 DB나 웹 프레임워크 같은 프레임워크나 도구들로 구성된다.
  • 일반적으로 이 계층에선 안쪽 원과 통신하기 위한 접합 코드 외에는 특별히 더 작성해야할 코드가 그다지 많지 않다.
  • 프레임워크와 드라이버 계층은 모든 세부사항이 위치하는 곳이다.
  • 웹과 DB는 세부사항이며 모두 외부에 위치시켜서 피해를 최소화해야 한다. 그리고 가장 선택사항으로 열어둬야될 부분이다.

원은 네 개여야만 하나?

  • 그림22.1에서 표시한 원들은 그저 갠며을 설명하기 위한 하나의 예시일 뿐이며, 네 개보다 더 많은 원이 필요할 수도 있다.
  • 원이 네 개여야만하는 규칙은 없지만 어떤 경우에도 의존성 규칙은 적용된다.
    • 소스 코드 의존성은 항상 안쪽을 향한다.
    • 안쪽으로 이동할수록 추상화와 정책의 수준은 높아진다.
    • 가장 바깥쪽 원은 저수준의 구체적인 세부사항으로 구성된다.
    • 그리고 안쪽으로 이동할수록 소프트웨어는 점점 추상화되고 더 높은 수준의 정책들을 캡슐화한다.
    • 따라서 가장 안쪽 원은 가장 범용적이며 높은 수준을 가진다.

경계 횡단하기

  • 그림22.1의 우측 하단 다이어그램에 원의 경계를 횡단하는 방법을 보여주는 예시가 있다.
  • 이 예시에선 컨트롤러와 프레젠터가 다음 계층에 속한 유스케이스와 통신하는 모습을 확인할 수 있다.
  • 우선 제어흐름에 주목해보자. 컨트롤러에서 시작해서, 유스케이스를 지난 후, 프레젠터에서 실행되면서 마무리된다.
  • 소스 코드 의존성은 유스케이스를 향해 안쪽을 가리킨다.
  • 이처럼 제어흐름과 의존성의 방향이 명백히 반여야 하는 경우, 대체로 의존성 역전 원칙(DIP)를 사용하여 해결한다.
  • 예를 들어 자바 같은 언어에선 인터페이스와 상속 관계를 적절히 배치함으로써, 제어흐름이 경계를 가로지르는 바로 그 지점에서 소스 코드 의존성을 제어흐름과는 반대가 되게 만들 수 있다.

  • 예를 들어 유스케이스에서 프레젠터를 호출해야 한다고 가정해보자.
  • 이때 직접 호출하면 의존성 규칙(내부 원에서 외부 원에 있는 어떤 이름도 언급해선 안된다)을 위배하기 때문에 안된다.
  • 따라서 우리는 유스케이스가 내부 원의 인터페이스(그림 22.1의 ‘유스케이스 출력 포트’)를 호출하도록 하고, 외부 원의 프레젠터가 그 인터페이스를 구현하도록 만든다.

  • 아키텍처 경계를 횡단할때 언제라도 동일한 기법을 사용할 수 있다.
  • 우리는 동적 다형성을 이용하여 소스 코드 의존성을 제어흐름과는 반대로 만들 수 있고, 이를 통해 제어흐름이 어느 방향으로 흐르더라도 의존성 규칙을 준수할 수 있다.

경계를 횡단하는 데이터는 어떤 모습인가

  • 경계를 가로지르는 데이터는 흔히 간단한 데이터 구조로 기본적인 구조체나 간단한 데이터 전송 객체(data transfer object)등 원하는 대로 고를 수 있다.
  • 또는 함수를 호출할 때 간단한 인자를 사용해서 데이터로 전달할 수도 있다.
  • 그게 아니라며 데이터를 해시맵으로 묶거나 객체로 구성할 수도 있다.
  • 중요한점은 격리되어 있는 간단한 데이터 구조가 경계를 가로질러 전달된다는 사실이다.
  • 꾀를 부려서 엔티티 객체나 데이터베이스의 행(row)을 전달하는 일은 원치 않는다.
  • 우리는 데이터 구조가 어떤 의존성을 가져 의존성 규칙을 위배하게 되는일은 바라지 않는다.

  • 예를 들어 데이터를 행(row)구조 포맷으로 응답 받아서 내부에 전달 할 경우 내부 원에서는 외부 원 DB의 무언가를 알아야 하기 때문에 의존성 규칙이 위배될 수 있기 때문이다.
  • 따라서 경계를 가로질러 데이터를 전달할 때, 데이터는 항상 내부 원에서 사용하기에 가장 편리한 형태를 가져야만 한다.

전형적인 시나리오

image

  • 위 이미지(그림 22.2)의 다이어그램은 데이터베이스를 사용하는 웹 기반 자바 시스템의 전형적인 시나리오이며 상세 설명은 여기를 참고하자.
  • 주목할 점은 의존성 방향이다. 모든 의존성은 경계선을 안쪽으로 가로지르며, 따라서 의존성 규칙을 준수한다.

결론

  • 이상의 간단한 규칙들을 준수하는 일은 어렵지 않으며, 향후에 겪을 수많은 고통거리를 덜어줄 것이다.
  • 소트프웨어를 계층으로 분리하고 의존성 규칙을 준수한다면 본질적으로 테스트하기 쉬운 시스템을 만들게 될 것이며, 그에 따른 이점을 누릴 수 있다.
  • 데이터베이스나 웹 프레임워크와 같은 시스템의 외부 요소가 구식이 되더라도, 이들 요소를 야단스럽지 않게 교체할 수 있다.

Reference

23장 - 프레젠터와 험블 객체

  • 프레젠터는 험블 객체(Humble Object) 패턴을 따른 형태로, 아키텍처 경계를 식별하고 보호하는데 도움이된다.

험블 객체 패턴

  • 디자인 패턴으로, 테스트하기 어려운 행위와 쉬운 행위를 단위 테스트 작성자가 분리하기 쉽게 하는 방법으로 고안되었다.
  • 아이디어는 매우 단순한데, 행위들을 두 개의 모듈 또는 클래스로 나눈다.
  • 하나는 가장 기본적인 본질을 남기고(테스트 하기 쉬운), 나머지 하나(험블 객체)는 테스트하기 어려운 행위를 모두 옮긴다.
  • 예를 들어, GUI 의 경우 단위 테스트가 어려운데, 화면을 보면서 각 요소가 필요한 위치에 적절히 표시되었는지 검사하는 테스트는 작성하기 매우 어렵기 때문이다.
    • 하지만 GUI 에서 수행하는 행위의 대다수는 쉽게 테스트할 수 있다.
    • 험블 객체 패턴을 사용하면 두 분류의 행위를 분리하여 프레젠터와 뷰라는 서로 다른 클래스로 만들 수 있다.

프레젠터와 뷰

  • 뷰는 험블 객체고 테스트하기 어렵다.
  • 이 객체에 포함된 코드는 가능한 간단하게 유지한다.
  • 뷰는 데이터를 GUI로 이동시키지만, 데이터를 직접 처리하진 않는다.
  • 프레젠터는 테스트하기 쉬운 객체며, 역할은 애플리케이션으로부터 데이터를 받아 화면에 표현할 수 있는 포맷으로 만드는 것이다.
  • 이를 통해 뷰는 데이터를 화면으로 전달하는 간단한 일만 처리하도록 만든다.

프레젠터의 예시

  • 어떤 필드에 날짜를 표시하고자 한다면, 애플리케이션은 프레젠터에 Date 객체를 전달하고, 해당 프레젠터는 데이터를 적절한 포맷의 문자열로 만들어, 뷰 모델에 담는다. 그러면 뷰는 뷰 모델에서 이 데이터를 찾는다.
  • 화면에 금액 표시, 버튼 이름, 메뉴 아이템 이름, 라디오 버튼, 체크 박스, 테스트 필드의 이름 모두 동일한 방식으로 처리된다.

테스트와 아키텍처

  • 테스트 용이성은 좋은 아키텍처가 지녀야 할 속성이다.
  • 험블 객체 패턴이 좋은 예인데, 행위를 테스트하기 쉬운 부분과 어려운 부분으로 분리하면 아키텍처 경계가 정의되기 때문이다.
  • 프레젠터와 뷰 사이의 경계는 이러한 경계 중 하나이며, 이 밖에도 수많은 경계가 존재한다.

데이터베이스와 게이틔웨이

  • 유스케이스 인터랙터와 데이터베이스 사이에는 데이터베이스 게이트웨이(Database Gateway)가 위치한다.
  • 이 게이트웨이는 다형적 인터페이스로, 애플리케이션이 DB에 수행하는 생성, 조회, 갱신, 삭제 작업과 관련된 모든 메서드를 포함한다.
    • 예를 들어, 애플리케이션에서 어제 로그인한 모든 사용자의 성(last name)을 알 수 있어야 한다면, UserGateway 인터페이스는 getLastNamesOfUsersWhoLoggedInAfter 라는 메서드를 제공할 것이고, 이 메서드는 날짜를 인자로 받아서 사용자 성들을 담은 목록을 반환할 것이다.
  • 유스케이스 계층은 SQL 을 허용하지 않는다.
  • 따라서 유스케이스 계층은 필요한 메서드를 제공하는 게이트웨이 인터페이스를 호출한다.
  • 그리고 인터페이스의 구현체는 데이터베이스 계층에 위치한다.
  • 이 구현체는 험블 객체이다.
  • 인터렉터는 애플리케이션에 특화된 업무 규칙을 캡슐화하기 떄문에 험블 객체가 아니다.
  • 따라서 테스트하기 쉬운데, 게이트웨이는 스텁(stub)이나 테스트 더블(test-double)로 적당히 교체할 수 있기 때문이다.

데이터 매퍼

  • 분명히 해야 할 점은 객체 관계 매퍼(ORM) 같은건 사실 존재하지 않는다.
  • 객체랑 데이터 구조랑은 다르기 때문이다.
  • 최소한 객체를 사용하는 사람 관점에서 객체는 데이터 구조가 아니다.
    • 데이터는 private 으로 선언되므로 객체의 사용자는 데이터를 볼 수 없다.
    • 사용자는 객체에서 public 메서드만 볼 수 있기에 객체는 단순한 오퍼레이션의 집합이다.
    • 데이터 구조는 함축된 행위를 가지지 않는 public 데이터 변수의 집합이다.
  • 그러기에 ORM 보단 ‘데이터 매퍼(Data Mapper)’라 부르는 편이 나아 보이는데, RDB 테이블로부터 가져온 데이터를 데이터 구조에 맞게 담아주기 때문이다.
  • 이러한 ORM 시스템은 데이터베이스 계층에 위치하는데 실제로 ORM 은 게이트웨이 인터페이스와 데이터베이스 사이에서 일종의 또 다른 험블 객체를 경계를 형성한다.

서비스 리스너

  • 애플리케이션 다른 서비스와 반드시 통신해야 한다면, 또는 애플리케이션에서 일련의 서비스를 제공해야 한다면, 우리는 여기서 서비스 경계를 생성하는 험블 객체 퍁너을 발견할 수 있다.
  • 애플리케이션은 데이터를 간단한 데이터 구조 형태로 로드한 후, 이 데이터 구조를 경계를 가로질러 특정 모듈로 전달한다.
  • 그러면 해당 모듈은 데이터를 적절한 포맷으로 만들어 외부 서비스로 전송한다.
  • 반대로 외부로부터 데이터를 수신하는 서비스의 경우, 서비스 리스너가 서비스 인터페이스로부터 데이터를 수신하고, 데이터를 애플리케이션에서 사용할 수 있게 간단한 데이터 구조로 포맷을 변경한다.
  • 그런 후 이 데이터 구조는 서비스 경계를 가로질러 내부로 전달된다.

결론

  • 각 아키텍처 경계마다 경계 가까이 숨어 있는 험블 객체 패턴을 발견할 수 있을 것이다.
  • 경계를 넘나드는 통신은 거의 모두 간단한 데이터 구조를 수반할때가 많고, 대개 그 경계는 테스트하기 어려운 무언가와 쉬운 무언가로 분리될 것이다.
  • 그리고 이러한 아키텍처 경계에서 험블 객체 패턴을 사용하면 전체 시스템의 테스트 용이성을 크게 높일 수 있다.

24장 - 부분적 경계

  • 아키텍처 경계를 완벽하게 만드는데는 비용이 많이 든다.
  • 쌍방향(ex. InputBoundary와 OutputBoundary)의 다형적 Boundary 인터페이스, Input 과 Output 을 위한 데이터 구조를 만들어야할 뿐만 아니라, 두 영역을 독립적으로 컴파일하고 배포할 수 있는 컴포넌트로 격리하는데 필요한 모든 의존성을 관리해야 한다.
  • 이렇게 만들려면 엄청난 노력을 기울여야 하고, 유지하는데 또 엄청난 노력이 든다.
  • 뛰어난 아키텍트라면 이러한 경계를 만드는 비용이 너무 크다 판단하면서도, 한편으론 나중에 필요할 수도 있으므로 이러한 경계에 필요한 공간을 확보하기 원할 수도 있다.
  • 애자일 커뮤니티에 속한 많은 사람들은 이러한 종류의 선행적 설계를 탐탁치 않게 여기는데, YAGNI(You Aren’t Going to Need it, ‘필요한 작업만 해라’ 라는 익스트림 프로그래밍의 원칙) 원칙을 위반하기 때문이다.
  • 하지만 아키텍트라면 “어쩌면 필요할지도”라는 생각이 들수도 있기에 부분적 경계를 구현해볼 수 있다.

마지막 단계를 건너뛰기

  • 부분적 경계를 생성하는 방법 하나는 독립적으로 컴파일하고 배포할 수 있는 컴포넌트를 만들기 위한 작업은 모두 수행한 후, 단일 컴포넌트에 그대로 모아만 두는 것이다.
    • 쌍방향 인터페이스, 입출력 데이터 구조를 포함하여 모든 것이 완전히 준비되어 있다.
    • 하지만 이모두를 단일 컴포넌트로 컴파일 후 배포한다.
  • 이처럼 부분적 경계를 만들려면 완벽한 경계를 만들때 만큼의 코드량과 사전 설계가 필요하지만, 다수의 컴포넌트를 관리하는 작업 및 추적을 위한 버전 번호, 배포 관리 부담은 필요없게 된다. 이 차이는 가볍지 않다.

FitNess 예시

  • 부분적 경계 전략을 기반으로 FitNess는 웹 서버 컴포넌트가 위키나 테스트 영역과는 분리되도록 설계했다.
  • 새로운 웹 기반 애플리케이션을 만들 때 해당 웹 컴포넌트를 재사용할 수도 있다고 생각했기 때문이다.
  • 그러나 시간이 흐르며, 별도로 분리한 웹 컴포넌트가 재사용 될 가능성은 전혀 없을 것임이 명백해졌다.
  • 웹 컴포넌트와 위키 컴포넌트 사이의 구분도 약화되기 시작했다..

일차원 경계

  • 완벽한 아키텍처 경계는 양방향으로 격리된 상태를 유지해야하므로 쌍방향 Boundary 인터페이스를 사용한다.
    • 하지만 이는 초기 설정시나 유지시에 비용이 많이 든다.
  • 추후 완벽한 형태의 경계로 확장할 수 있는 공간을 확보하고자 할 때 전략(Strategy) 패턴 활용할 수 있다.

image

  • Client는 Service Boundary를 사용하며 이는 ServiceImpl가 구현한다.
  • Client를 ServiceImpl로부터 격리 시키고자 의존성 역전 원칙을 적용하였다.
  • 그러나 쌍방향 인터페이스가 없고 개발자와 아키텍트가 제대로 훈련되어 있지 않다면, 전략 패턴은 위에 점섬과 같은 비밀 통로가 생길 수 있다…

퍼사드

image

  • 훨씬 더 간단한 경계로써 모든 서비스 클래스를 메서드 형태로 정의하고 있는 Facade 클래스가 있다.
  • 클라이언트는 서비스 클래스를 직접 접근할 수 없다.
  • 그러나 정적 언어일 경우 클라이언트가 모든 서비스 클래스에 대해 추이 종속성 가지게된다.
    • 이는 서비스 클래스 하나가 변경되면 클라이언트도 무조건 재 컴파일해야한다…

결론

  • 아키텍처 경계를 부분적으로 구현하는 간단한 방법 세 가지를 살펴봤는데 이외에도 방법은 많다.
  • 각 접근법은 비용과 장점이 다른데, 상황을 고려하여 신중히 선택해야 한다.
  • 아키텍처 경계가 언제, 어디에 존재해야 할지, 그리고 그 경계를 완벽하게 구현할지 아니면 부분적으로 구현할지를 결정하는 일 또한 아키텍처의 역할이다.

Reference

25장 - 계층과 경계

  • 시스템은 보통 세 가지 컴포넌트(UI, 업무 규칙, 데이텅베이스)로만 구성된다 생각하기 쉽지만, 하지만 대다수 시스템은 이보다 훨씬 많다.

움퍼스 사냥 게임

image

  • 위 이미지처럼 소스 코드 의존성을 관리하면, UI 컴포넌트가 어떤 언어를 사용하더라도 게임 규칙을 재사용할 수 있다.
  • 게임 규칙은 어떤 종류의 인간 언어가 사용되는지 알지도 못하며 신경 쓸 이유도 없다.

image

  • 또한 게임 규칙은 데이터 저장소와 통신할때 마찬가지로 의존성 관리가 되어 게임 규칙을 의존하는 형태가 되어야 한다.

클린 아키텍처?

  • 위 예제의 맥락이라면 클린 아키텍처 접근법을 적용해서 유스케이스, 경계, 엔티티, 그리고 관련된 데이터 구조를 모두 만드는 일도 쉬운 일이다. 그런데 중요한 아키텍처 경계를 정말 ㄷ모두 발견한 걸까?
  • 예를 들어 UI에서 언어가 유일한 변경의 축은 아니기 때문에 변경의 축에 의해 정의 되는 잠재된 아키텍처 경계가 있을 수 있다. 텍스트를 주고받는 메커니즘, 데이터 저장 매커니즘등을 다양하고 만들고 싶을수도 있다..

image

  • 이 모든 경우에 해당 Boundary 인터페이스가 정의하는 API는 의존성 흐름의 상위에 위치한 컴포넌트에 속한다.
    • 예를 들어, GameRules 를 들여다 보면 GameRules 내부 코드에서 사용하고 Language 내부 코드에서 구현하는 다형적 Boundary 인터페이스를 발견할 수 있다.
  • English, SMS, CloudData 와 같은 변형들은 추상 API 컴포넌트가 정의하는 다형적 인터페이스를 통해 제공되고, 실제 서비스하는 구체 컴포넌트가 해당 인터페이스를 구현한다.
    • 예를 들어, Language 가 정의하는 다형적 인터페이스는 English 나 Spanish 가 구현할 것이다.

image

  • 변형들을 모두 제거하고 순전한 API컴포넌트만 집중하면 위 다이어그램과 같이 단순화할 수 있다.
  • 모든 화살표가 위를 향하도록 맞춰졌다는 점에 주목하자. 그 결과 GameRules 는 최상위 수준의 정책을 가지는 컴포넌트이므로 이치에 맞는 배치다.
  • 정보가 흐르는 방향을 생각해 보자.
    • 모든 입력은 사용자로부터 전달받아 좌측 하단의 TextDelivery 컴포넌트로 전달된다.
    • 이 정보는 Language 컴포넌트를 거쳐 위로 올라가며, GameRules 에 적합한 명령어로 번역된다.
    • GamesRules 는 사용자 입력을 처리 후, 우측 하단의 DataStorage 로 적절한 데이터를 내보낸다.
    • 그런후 GameRules 는 Language 로 출력을 되돌려 보내고, Language 는 API를 다시 적절한 언어로 번역한 후 번역된 언어를 TextDelivery 를 통해 사용자에게 전달한다.
  • 이 구성은 데이터 흐름을 두 개로 효과적으로 분리한다.
    • 왼쪽 흐름은 사용자와의 통신에 관여하며, 오른쪽 흐름은 데이터 영속성에 관여한다.
    • 두 흐름은 상단의 GameRules 에서 서로 만나며, GameRules는 두 흐름이 모두 거치게 되는 데이터에 대한 최종 처리기가 된다.

흐름 횡단하기

  • 데이터 흐름은 시스템이 복잡해질수록 컴포넌트 구조가 더 많은 흐름으로 분리될 것이다.
    • 예를 들어, 움퍼스 사냥 게임을 네트워크 상에서 여러사람이 함께 플레이할 수 있게 만든다 할 때 네트워크 컴포넌트를 추가해야 한다.
    • 이때 데이터 흐름은 세 개로 분리되며, 이들 흐름은 GameRules 가 제어한다.

image

흐름 분리하기

  • 이쯤 되면 모든 흐름이 결국엔 상단의 단일 컴포넌트에서 서로 만난다고 생각할 수 있지만, 현실은 훨씬 복잡하다.

image

  • 움퍼스 게임에서 게임 규칙중 일부인 지도 관련 메커니즘을 처리하는 MoveManagement를 추가한 경우 지도규칙에 의해 플레이어의 지도 관련 사건을 처리한다.
  • 게임중 구덩이에 빠지면 MoveManagement은 이를 판단한 후 고수준인 PlayerManagement정책에게 알려 플레이어의 승리 여부 상태를 결정해준다.

  • 여기서 좀 더 흥미롭게 마이크로서비스까지 추가해보자.(p.242 그림 25.7 참고)
  • 대규모 플레이어가 동시에 플레이할 수 있는 버전의 움퍼스 사냥 게임이 있다고 가정해보자.
  • MoveManagement 는 플레이어 컴퓨터에서 직접 처리되지만 PlayerManagement 는 서버에서 처리된다.
  • PlayerManagement 는 접속된 모든 MoveManagement 컴포넌트에 마이크로서비스 API를 제공한다.
  • 이러할 때 MoveManagement 와 PlayerManagement는 사이에는 아키텍처 경계가 생기게 된다.

결론

  • 위 움퍼스 사냥 게임 예제는 아키텍처 경계가 어디에나 존재한다는 사실을 보여준다.
  • 아키텍트로서 아키텍처 경계가 언제 필요한지를 신중하게 파악해내야 한다.
  • 경계를 제대로 구현하려면 비용이 많이든다는 사리과 경계가 무시되었을때 나중에 추가하는 비요이 크다는 사실도 알아야 한다.
  • 아키텍트는 추상화가 필요하리라고 미리 예측해선 안된다.
    • 이것이 바로 YAGNI 가 말하는 철학이다.
    • 오버 엔지니링이 언더 엔지니어링보다 나쁠떄가 훨씬 많기 떄문이다.
    • 다른 한편으론 어떤 아키텍처 경계도 존재하지 않는 상황에서 경계가 정말로 필요하다는 사실을 발견 후, 그제서야 경계를 추가하려면 비용이 많이 들고 큰 위험을 감수해야 한다.
  • 프로젝트 초반에는 구현할 경계가 무엇인지와 무시할 경계가 무엇인지 쉽게 결정할 수 없다.
  • 대신 지켜봐야 하고, 시스템이 발전함에 따라 주의를 기울여야 한다.
  • 경계가 필요할 수도 있는 부분에 주목하고, 경계가 존재하지 않아 마찰의 어렴풋한 첫 조짐을 신중하게 관찰해야 한다.
  • 첫 조짐이 보이는 시점이 되면, 해당 경계를 구현하는 비용과 무시할 때 감수할 비용을 가늠한다. 그리고 결정된 사항을 자주 검토한다.
  • 우리의 목표는 경계의 구현 비용이 그걸 무시해서 생기는 비용보다 적어지는 바로 그 변곡점에서 경계를 구현하는 것이다.(어려울 것 같다..)
  • 목표를 달성하려면 빈틈없이 지켜봐야 한다..

Reference

26장 - 메인 컴포넌트

  • 모든 시스템엔 컴포넌트들을 생성, 조정, 관리하는 최소 하나의 컴포넌트가 존재해야 한다.
  • 이를 메인(Main)이라 부른다.

궁극적인 세부사항

  • 메인 컴포넌트는 궁극적인 세부사항으로 가장 낮은 수준의 정책임.

image

  • 시스템 초기 진입점으로서 운영체제를 제외하면 어떤 것도 메인에 의존하지 않는다.
  • 메인은 모든 팩토리(Factory)와 전략(Strategy), 그리고 시스템 전반을 담당하는 나머지 기반 설비를 생성 후, 시스템에서 더 높은 수준을 담당하는 부분으로 제어권을 넘기는 역할을 맡는다.
  • 의존성 주입 프레임워크를 이용해 의존성을 주입하는 일은 바로 이 메인 컴포넌트에서 이뤼져야 한다.
    • 메인에 의존성이 일단 주입되고 나면, 메인은 의존성 주입 프레임워크를 사용하지 않고도 일반적인 방식으로 의존성을 분배할 수 있어야 한다.
  • 메일을 가장 지저분한 컴포넌트라 생각하자.
  • 아래 예제 코드는 최신 움퍼스 사냥 게임(Hunt the Wumpus)의 메인 컴포넌트인데 문자열을 로드하는 방법으로, 코드의 나머지 핵심 영역에서 구체적인 문자열을 알지 못하게 하였음에 주목하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public class Main implements HtwMessageReceiver {
    private static HuntTheWumpus game;
    private static int hitPoints = 10;
    private static final List<String> caverns = new ArrayList<>();
    private static final String[] environments = new String[] {
        "bright",
        "humid",
        "dry",
        "creepy",
        "ugly",
        "foggy",
        "hot",
        "cold",
        "drafty",
        "dreadful"
    };

    private static final String[] shapes = new String[] {
        "round",
        "square",
        "oval",
        "irregular",
        "long",
        "craggy",
        "rough",
        "tail",
        "narrow"
    };

    private static final String[] cavernTypes = new String[] {
        "cavern",
        "room",
        "chamber",
        "catacomb",
        "crevasse",
        "cell",
        "tunnel",
        "passageway",
        "hall",
        "expanse",
    };

    private static final String[] adornments = new String[] {
        "smelling of sulfur",
        "with engravings on the walls",
        "with a bumpy floor",
        "",
        "littered with garbage",
        "spttered with guano",
        "with piles of Wumpus droppings",
        "with bones scattered around",
        "with a corpse on the floor",
        "that seems to vibrate",
        "that feels stuffy",
        "that fills you with dread",
    };
  • main 함수에서 HtwFactory를 사용하여 게임을 생성하는 방식을 주목하자.
    • 게임을 생성할 때 htw.game.HunTheWumpusFacase라는 클래스 이름을 전달하는데, 이 클래스는 메인보다도 더 지저분하기 때문이다. 재컴파일/재배포가 되지 않게하기 위함이다.
  • 그리고 main 함수에서 주목할 점이 하나 더 있다.
    • 바로 입력 스트림 생성 부분, 게임의 메인 루프 처리, 간단한 입력 명령어 해석 등은 main 함수에서 모두 처리하지만, 명령어를 실제로 처리하는 일은 다른 고수준 컴포넌트로 위임한다는 사실이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public static void main(String[] args) throws IOException {
    game = HtwFactory.makeGame("htw.game.HuntTheWumpusFacade", new Main());
    createMap();
    BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
    game.makeRestCommand().execute();

    while (true) {
        System.out.println(game.getPlayerCavern());
        System.out.println("Health: " + hitPoints + " arrows: " + game.getQuiver());
        HuntTheWumpus.Command c = game.makeRestCommand();
        System.out.println(">");
        String command = br.readLine();
        if(command.equalsIgnoreCase("e"))
            c = game.makeMoveCommand(EAST);
        else if (command.equalsIgnoreCase("w"))
            c = game.makeMoveCommand(WEST);
        else if (command.equalsIgnoreCase("n"))
            c = game.makeMoveCommand(NORTH);
        else if (command.equalsIgnoreCase("s"))
            c = game.makeMoveCommand(SOUTH);
        else if (command.equalsIgnoreCase("r"))
            c = game.makeRestCommand();
        else if (command.equalsIgnoreCase("sw"))
            c = game.makeShootCommand(WEST);
        else if (command.equalsIgnoreCase("se"))
            c = game.makeShootCommand(EAST);
        else if (command.equalsIgnoreCase("sn"))
            c = game.makeShootCommand(NORTH);
        else if (command.equalsIgnoreCase("ss"))
            c = game.makeShootCommand(SOUTH);
        else if (command.equalsIgnoreCase("q"))
            return;

        c.execute();
    }
}
  • 마지막으로 지도 생성 역시 main에서 처리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private static void createMap(){
    int nCaverns = (int) (Math,random() * 30.0 + 10.0);
    while (nCaverns-- > 0)
    caverns.add(makeName());

    for(String cavern : caverns) {
        maybeConnectCavern(cavern, NORTH);
        maybeConnectCavern(cavern, SOUTH);
        maybeConnectCavern(cavern, EAST);
        maybeConnectCavern(cavern, WEST);
    }

    String playerCavern = anyCavern();
    game.setPlayerCavern(playerCavern);
    game.setWumpusCavern(anyOther(playerCavern));
    game.addBatCavern(anyOther(playerCavern));
    game.addBatCavern(anyOther(playerCavern));
    game.addBatCavern(anyOther(playerCavern));

    game.addPitCavern(anyOther(playerCavern));
    game.addPitCavern(anyOther(playerCavern));
    game.addPitCavern(anyOther(playerCavern));

    game.setQuiver(5);
    
    // 이하 코드 생략
}
  • 요지는 메인은 클린 아키텍처에서 가장 바깥 원에 위치하는, 지저분한 저수준 모듈이다.
  • 메인은 고수준의 시스템을 위한 모든 것을 로드한 후, 제어권을 고수준의 시스템에게 넘긴다.

결론

  • 메인을 애플리케이션의 플러그인이라 생각하자. 초기 조건과 설정을 구성하고, 외부 자원을 모두 수집한 후 제어권을 애플리케이션의 고수준 정책으로 넘기는 플러그인으로 말이다..
  • 메인은 플러그인이므로 메인 컴포넌트를 애플리케이션의 설정별로 하나씩 두도록 하여 둘 이상의 메인 컴포넌트를 만들수도 있다.
    • 예를 들어, 개발용/테스트용/사용 메인 플러그인처럼 말이다..
    • 또한 배포 대상 국가별, 관할 영역별, 고객별 메인 플러그인을 만들수도 있다.
  • 메인을 플러그인 컴포넌트로 여기고, 그래서 아키텍처 경계 바깥에 위치한다고 보면 설정 관련 문제를 훨씬 쉽게 해결할 수 있다.

Reference

27장 - ‘크고 작은 모든’ 서비스들

서비스 아키텍처?

  • 먼저 서비스를 사용한다는 것은 본질적으로 아키텍처에 해당하지 않는다.
    • 시스템 아키텍처는 의존성 규칙을 준수하며 고수준의 정책을 저수준의 세부사항으로부터 분리하는 경계에 의해 정의된다.
    • 단순히 애플리케이션의 행위를 분리할 뿐인 서비스라면 값 비싼 함수호출에 불과하며, 아키텍처 관점에서 중요하다고 볼 수 는 없다.
  • 서비스는 프로세스나 플랫폼 경계를 가로지르는 함수 호출에 지나지 않는다.
  • 아키텍처적으로 중요한 서비스도 있지만, 그렇지 않은 서비스도 있다.
    • 아래에선 전자에 주목할 것이다.

서비스의 이점?

  • 서비스 아키텍처에 대한 이의 제기 내용을 살펴보자.

결합 분리의 오류?

  • 서비스 사이의 결합이 확실 분리되진 않는다.
  • 프로세서 내의 또는 네트워크 상의 공유 자원 때문에 결합될 가능성이 여전히 존재한다.
  • 서로 공유하는 데이터에 의해 이들 서비스는 강력하게 결합되어 버린다.
  • 예를 들어, 서비스 사이를 오가는 데이터 레코드에 새로운 필드를 추가한다면, 이 필드를 사용해 동작하는 모든 서비스는 반드시 변경되어야 한다.
    • 또한 이 서비스들은 이 필드에 담긴 데이터를 해석하는 방식을 사전에 완벽하게 조율해야 한다.
    • 따라서 서비스들은 이 데이터 레코드에 강하게 결합되고 서비스들 사이는 서로 간접적으로 결합되버린다.

개발 및 배포 독립성의 오류

  • 서비스 아키텍처는 시스템의 개발, 유지보수, 운영 또는 비슷한 수의 독립적인 팀 단위로 분할할 수 있다고 여긴다.
  • 위는 일리는 있지만 극히 일부일 뿐이다.
  • 첫째로, 대규모 엔터프라이즈 시스템은, 모노티틱 또는 컴포넌트 기반 시스템으로도 구축할 수 있다. (꼭 서비스 아키텍처가 아니어도 된다)
  • 둘째로, ‘결합 분리의 오류’에 따르면 서비스라고 해서 항상 독립적으로 개발, 배포, 운영할 수 있는 것은 아니며 데이터나 행위에서 어느 정도 결합되어 있다면 결합된 정도에 맞게 개발, 배포, 운영을 조정해야만 한다.

야옹이 문제

image

  • 이전 택시 통합 시스템을 보면 기능적으로 분해되어 있다.
  • 그래서 야옹이 배달 기능이 추가된다 할경우 전부 다 변경이 필요하다.
    • 독립적으로 개발하고 배포하거나 유지될 수 없다.
    • 횡단 관심사가 가진 문제이며 서비스 지향이든 아니든 모든 소프트웨어가 이 문제에 직면하게 될 것이다.

객체가 구출하다

  • 컴포넌트 기반 아키텍처에선 SOLID 설계원칙을 기반으로 다형적으로 확장할 수 있는 클래스 집합을 생성해 새로운 기능을 처리하도록 함을 알 수 있다.

image

  • 이전 그림 27.1 의 서비스들과 거의 일치한다.
  • 하지만 경계와 의존성 규칙을 준수한다는점에 주목하자.
    • 배차에 특화되는 로직 부분은 Rides 컴포넌트로 추출되고 야옹이에 대한 신규 기능은 Kittens 컴포넌트에 들어갔다.
  • 따라서 야옹이 기능은 결합이 분리되며, 독립적으로 개발, 배포할 수 있다.

컴포넌트 기반 서비스

  • 서비스 또한 SOLID 설계 원칙대로 설계할 수 있으며 컴포넌트 구조를 갖출 수 있어 기존 컴포넌트를 변경하지 않고도 새로운 컴포넌트를 추가할 수 있다.

image

  • 위 이미지는 서비스들의 존재는 이전과 달라진게 없지만, 각 서비스 내부는 자신만의 컴포넌트 설계로 되어 있어 파생클래스를 만드는 방식으로 신규 기능을 추가 가능하다. 파생 클래스들은 각자의 컴포넌트 내부에 놓인다.

횡단 관심사

  • 횡단 관심사를 처리하려면 다이어그램처럼 서비스 내부의 의존성 규칙을 준수하는 컴포넌트 아키텍처로 설계되어야한다.
    • 하나의 서비스 내부에서도 web 모듈, domain 모듈, 인프라스트럭쳐 모듈..
  • 아키텍처 경계는 서비스 사이에 존재하지 않으며, 아키텍처 경계를 정의하는것은 서비스 내에 위치한 컴포넌트이다.

결론

  • 서비스는 시스템의 확장성과 개발 가능성 측면에선 유용하다.
  • 하지만 그자체로 아키텍처적으로 그리 중요한 요소는 아니다.
  • 시스템 아키텍처내는 시스템 내부에 그어진 경계와 경계를 넘나드는 의존성에 의해 정의된다.
    • 시스템의 구성 요소가 통신하고 실행되는 물리적인 메커니즘에 의해 아키텍처가 정의되는 것이 아니다.
  • 서비스는 단 하나의 아키텍처 경계로 둘러싸인 단일 컴포넌트로 만들 수 있다.
    • 혹은 여러 아키텍처 경계로 분리된 다수의 컴포넌트로 구성할 수도 있다.
    • 드물게는 클라이언트와 서비스가 강하게 결합되어 아키텍처적으로 아무 의미 없을때도 있다.

Reference

28장 - 테스트 경계

  • 테스트는 시스템의 일부이며, 아키텍처에도 관여한다.

시스템 컴포넌트인 테스트

  • TDD로 생성한 아주 작은 테스트든, 대규모의 FitNesse, Cucumber 테스트든 이들 테스트는 아키텍처 관점에서 모두 동등하다.
  • 테스트는 태생적으로 의존성 규칙을 따른다.
    • 세부적이며 구체적인 것으로, 의존성은 항상 테스트 대상이 되는 코드를 향한다.
    • 실제로 테스트는 아키텍처에서 가장 바깥쪽 원으로 생각할 수 있다.
    • 시스템 내부 어떤 것도 테스트에는 의존하지 않으며, 테스트는 시스템의 컴포넌트를 향해, 항상 원의 안쪽으로 의존한다.
  • 또한 테스트는 독립적으로 배포 가능하다.
    • 사실 대다수의 경우 테스트는 테스트 시스템에만 배포하며, 상용 시스템에는 배포하지 않는다.
    • 따라서 심지어 배포 독립성이 필요치 않은 시스템에서도 테스트는 독립적으로 배포될 것이다.
  • 테스트는 시스템 컴포넌트중 가장 고립되어 있다.
    • 테스트가 시스템 운영에 꼭 필요치 않다.
    • 테스트의 역할은 운영이 아니라 개발을 지원하는데 있다.
    • 사실 많은 면에서 테스트는 다른 모든 시스템 컴포넌트가 반드시 지켜야 하는 모델을 표현해준다.

테스트를 고려한 설계

  • 개발자는 종종 테스트가 시스템의 설계 범위 밖에 있다는 치명적인 생각을 한다.
    • 테스트가 시스템 설계와 잘 통합되지 않으면 테스트는 깨지기 쉬워지고, 뻣뻣해져 변경하기 어려워진다.
  • 물론 문제는 결합이다.
    • 시스템에 강하게 결합된 테스트라면 시스템이 변경될때 함께 변경되어야만 한다.
    • 시스템 컴포넌트에서 생긴 아주 사소한 변경도, 이와 결합된 수많은 테스트를 망가뜨릴 수 있다.
  • 상황은 더 심각해질 수 있다.
    • 시스템의 공통 컴포넌트가 변경되면 수백, 심지어 수천 개의 테스트가 망가진다.
    • 문제는 깨지기 쉬운 테스트 문제(Fragile Tests Problem)로 알려져 있다.
  • 예를 들어, GUI 를 사용하여 업무 규칙을 검증하는 테스트 스위트가 있다 해보자.
    • 이러한 테스트들은 로그인 화면에서 시작해서 페이지 구조를 탐색해 나가면서 특정 업무 규칙을 검사한다.
    • 따라서 로그인 페이지나 탐색 구조 어딘가가 달라지면 엄청난 수의 테스트가 망가진다.
  • 깨지기 쉬운 테스트는 시스템을 뻣뻣하게 만든다는 부작용을 낳을 때가 많다.
    • 시스템에 가한 간단한 변경이 대량의 테스트 실패로 이어진다는 사실을 알게 되면, 개발자는 그러한 변경을 하지 않으려 들 것이다.
    • 예를 들어 마케팅팀에서 페이지 탐색 구조를 살짝 손봐달라고 요청했는데, 이로 인해 망가지는 테스트가 1000개라면 두 팀 사이의 대화가 어떻게 될지 상상해보자..
  • 이 문제를 해결하려면 테스트를 고려해서 설계해야 한다.
    • 소프트웨어 설계의 첫번쨰 규칙은 언제나 같다. “변동성 있는 것에 의존하지 말라.”
    • GUI 는 변동성이 크다. GUI 로 시스템을 조작하는 테스트 스위트는 분명 깨지기 쉽다.
    • 따라서 시스템과 테스트를 설계할때, GUI 를 사용하지 않고 업무 규칙을 테스트할 수 있게 해야 한다.

테스트 API

  • 위 목표를 달성하려면 값 비싼 자원(DB)은 건너뛰고 보안 제약사항은 무시하며 모든 업무 규칙을 검증할 수 있는 API를 만들면 된다.
  • 그리고 이 테스트 API 는 시스템을 테스트 가능한 특정 상태로 강제하는 강력한 힘을 지녀야 한다.
  • 테스트 API는 테스트를 애플리케이션으로부터 분리할 목적으로 사용됨.
    • 단순히 UI 로부터 분리하는 것만이 아닌..

구조적 결합

  • 테스트 결합 중에 가장 강하다.
  • 테스트 스위트는 애플리케이션 구조에 강하게 결합되어 잇다.
  • 상용 클래스나 메서드중 하나라도 변경되면 딸려 있는 다수의 테스트도 깨지게 되고, 상용 코드를 뻣뻣하게 만든다.
  • 테스트 API의 역할은 애플리케이션의 구조를 테스트로부터 숨겨 상용코드를 리팩터링 하더라도 테스트에는 전혀 영향을 주지 않는것에 있다.

보안

  • 운영 시스템에 배포하면 위험에 처할 수 잇기에 테스트 API 자체와 테스트 API 중 위험한 구현부는 독립적으로 배포할 수 있는 컴포넌트로 분리한다.

Reference

29장 - 클린 임베디드 아키텍처

소프트웨어는 닳지 않지만, 펌웨어와 하드웨어에 대한 의존성을 관리하지 않으면 안으로부터 파괴될 수 있다.

  • 소프트웨어는 시간이 지나도 유용하게 쓸 수 잇지만, 펌웨어는 하드웨어가 발전할 수록 낡아갈 것이다.
  • 안드리오드 앱 개발자 역시 업무 로직을 안드로이드 API 로부터 분리하지 않는다면 펌웨어(하드웨어)를 작성하는 셈이다.
  • 어떻게 하면 임베디드 소프트웨어 아키텍처를 깔끔하게 유지할 수 있는지, 그래서 소프트웨어가 오랫동안 유용하게 살아남을 가능성을 높일 수 있는지를 살펴보자.

앱-티튜드 테스트

  • 임베디드가 아닌 대다수의 앱들도 코드를 올바르게 작성해서 유효 수명을 길게 늘리는데는 거의 관심 없이, 그저 동작하도록 만들어진다.
  • 이렇게 앱이 동작하도록 만드는 것을 개발자용 앱-티튜드 테스트(App-titude test)라 부른다.

타깃-하드웨어 병목현상

  • 임베디드가 지닌 특수한 문제중 하나다.
  • 대개의 경우 테스트할 수 있는 환경이 해당 특정 타깃으로 국한될 것이고, 타깃-하드웨어 병목현상이 발생하여 진척이 느려질 것이다.

클린 임베디드 아키텍처는 테스트하기 쉬운 임베디든 아키텍처다.

계층

  • 소프트웨어, 펌웨어, 하드웨어의 경계를 분리해서 테스트하기 쉽게 만들자.

하드웨어는 세부사항이다.

  • 소프트웨어와 펌웨어 사이의 경계는 하드웨어 추상화 계층(Hardware Abstraction Layer, HAL)이라고 부른다.
  • HAL은 자신보다 위에 있는 소프트웨어를 위해 존재하므로, HAL의 API는 소프트웨어의 필요에 맞게 만들어져야 한다.
  • 특정 데이터를 소프트웨어는 어떤 장치에(플래시 메모리 또는 하드디스크) 영속화되는지는 알 수 없게끔 말이다.

image

HAL 사용자에게 하드웨어 세부사항을 드러내지 말라

  • 클린 임베디드 아키텍처로 설계된 소프트ㅜ에어는 타깃 하드웨어 관계없이 테스트 가능하다.
  • HAL 은 타깃에 상관없이 테스트할 수 있는 경계층 또는 일련의 대체 지점을 제공한다.

프로세스는 세부사항이다.

  • 클린 임베디드 아키텍처라면 이들 장치 접근 레지스터를 직접 사용하는 코드는 소수의, 순전한 펌웨어로만 한정시켜야 한다.
  • 이들 레지스터를 알고 있는 것은 모두 펌웨어가 되어야 하며, 따라서 실리콘 칩에 종속된다.
  • 코드를 프로세스와 직접적으로 묶어버리면 안정적인 하드웨어가 출시되기 이전에 코드를 실행시키고자 할 때 어려움을 겪을 수 있다.
  • 또한 임베디드 애플리케이션을 새로운 프로세서로 이식할 때도 곤란해질 것이다.
  • 펌웨어가 저수준 함수들을 프로세서 추상화 계층(Processor Abstraction Layer, PAL)의 형태로 격리시켜줄 수 있다.
  • PAL 상위에 위치하는 펌웨어는 타깃-하드웨어에 관계없이 테스트할 수 있게 되어, 펌웨어 자체도 덜 딱딱해질 수 있다.

운영체제는 세부사항이다.

  • 작성한 코드의 수명을 늘리려면, 무조건 운영체제를 세부사항으로 취급하고 운영체제에 의존하는일을 막아야한다.
  • OS 는 소프트웨어르르 펌웨어로부터 분리하는 계층이고 OS 를 직접 사용하면 문제가 된다.

image

  • 클린 임베디드 아키텍처는 운영체제 추상화 계층(Operating System Abstraction Layer, OSAL)을 통해 소프트웨어를 운영체제로부터 격리시킨다.
  • 함수 이름을 바꾸는 정도로 단순할 수도 있고, 여러 함수를 하나로 묶어야할 수도 있다.
  • 만약 다른 OS로 이식한다면 작업의 대부분은 기존 OSAL 과 호환되도록 새로운 OSAL 을 작성하는데 소요될 것이다.
    • 기존 복잡한 코드 덩어리를 수정하는 것보단 훨씬 낫다…
  • OSAL은 테스트 지점을 만드는데 도움이 된다. 그 덕분에 소프트웨어 계층의 귀중한 애플리케이션 코드를 타깃이나 OS에 관계없이 테스트할 수 있게 된다.

인터페이스를 통하고 대체 가능성을 높이는 방향으로 프로그래밍하라

  • HAL 을 추가하거나 때로는 OSAL을 추가해야 할 뿐만 아니라, 모든 주요 계층(소프트웨어, OS 펌웨어, 하드웨어) 내부에는 이 책에서 설명한 원칙들을 적용할 수 있다.(아니, 해야 한다.)
  • 이들 원칙은 관심사를 분리시키고, 인터페이스를 활용하며, 대체 가능성을 높이는 방향으로 프로그래밍하도록 유도한다.
  • 계층형 아키텍처(Layered Architecture)는 인터페이스를 통해 프로그래밍하자는 방상을 기반으로 한다.
    • 모듈들이 서로 인터페이스를 통해 상호작용한다면 특정 서비스 제공자를 다른 제공자로 쉽게 대체할 수 있다.
    • 그렇게 되면 테스트 하기도 더욱 쉬워질 것이다.
  • 구현 세부사항의 가시성을 제한하라. 구현 세부사항은 변경될거라고 가정하라. 세부사항을 알고 있는 부분이 적을수록 추적하고 변경해야할 코드도 적어진다.
  • 클린 임베디드 아키텍처에선 모듈들이 인터페이스를 통해 상호작용하기 떄문에 각각의 계층 내부에서 테스트가 가능하다.
  • 각 인터페이스는 타깃과는 별개로 테스트할 수 있도록 해주는 경계층 또는 대체 지점을 제공한다.

DRY 원칙: 조건부 컴파일 지시자를 반복하지 말라

  • 임베디드 시스템의 경우 타깃-하드웨어의 유형을 식별하는 조건 컴파일을 반복해서 사용할때가 많은데 그러면 다른 OS 로 이식하기 어려워지고 전체가 펌웨어가 되버린다..
  • 하드웨어 추상화 계층(HAL)을 통해 세부사항을 가리자. 만약 이 HAL이 조건부 컴파일 대신 사용할 수 있는 일련의 인터페이스를 제공한다면, 우리는 링커 또는 어떤 형태의 실시간 바인딩을 사용해서 소프트웨어를 하드웨어와 연결할 수 있다.

결론

  • 모든 코드가 펌웨어가 되도록 내버려두면 제품이 오래 살아남을 수 없게 된다.
  • 오직 타깃 하드웨어에서만 테스트할 수 있는 제품도 마찬가지다.
  • 클린 임베디드 아키텍처는 제품이 장기간 생명력을 유지하는데 도움을 준다.

Reference

30장 - 데이터베이스는 세부사항이다.

  • 애플리케이션 내부 데이터 구조는 시스템 아키텍처에서 대단히 중요하다. 하지만 데이터베이스는 데이터 모델이 아니다.
  • 데이터가 테이블 구조를 가진다는 사실은 오직 아키텍처의 외부 원에 위치한 최하위 수준의 유틸리티 함수만 알아야 한다. 그저 저수준의 세부사항이기 때문이다..
  • 많은 데이터 접근 프레임워크가 테이블과 행이 객체 형태로 시스템 여기저기에서 돌아다니게 허용하는데, 아키텍처적으로 잘못된 설계다. 이렇게하면 유스케이스, 업무 규칙, 심지어는 UI조차도 관계형 데이터 구조에 결합되어 버린다. (DAO를 통한 DTO 모델이 여기저기 돌아다니는구조..)
  • 데이터베이스는 그저 데이텉를 회전식 자기 디스크 표면에서 이리저리 옮길 뿐인 기술이며 그저 세부사항일 뿐이다.

31장 - 웹은 세부사항이다.

  • 웹은 GUI고 GUI는 세부사항이다. 고로 웹은 세부사항이다.
  • 아키텍트라면 이러한 세부사항을 핵심 업무 로직에서 분리된 경계 바깥에 두어야 한다.
  • 웹은 입출력 장치고 우리는 애플리케이션을 장치 독립적으로 만들어야 한다.
  • UI와 애플리케이션 사이엔 추상화가 가능한 또 다른 경계가 존재한다. 업무 로직은 다수의 유스케이스로 구성되며, 각 유스케이스는 사용자를 대신해서 일부 함수를 수행하는것으로 볼 수 있다. 각 유스케이스는 입력 데이터, 수행할 처리 과정, 출력 데이터를 기반으로 기술할 수 있다.
  • 완전한 입력 데이터와 그에 따른 출력 데이터는 데이터 구조로 만들어서 유스케이스를 실행하는 처리 과정의 입력값과 출력값으로 사용할 수 있다. 이방식을 따르면 각 유스케이스가 장치 독립적인 방식으로 UI라는 입출력 장치를 동작시킨다고 간주할 수 있다.

32장 - 프레임워크는 세부사항이다.

위험 요인

  • 프레임워크는 의존성 규칙을 위반하는 경향을 비롯하여 그다지 깔끔하지 않은 경우가 많다. 프레임워크가 한번 안으로 들어가버리면(핵심 업무 로직) 다시는 원 밖으로 나오지 않을 것이다.
  • 제품이 성숙해지면 프레임워크가 제공하는 기능 틀을 벗어나게 될 것이고 이 과정에서 프레임워크와 계속 싸우고있을것이다.
  • 프레임워크는 도움됮지 않는 방향으로 진화할수도 있다.
  • 새롭고 더 나은 프레임워크가 등장하여 갈아타고 싶을 수도 있다.

해결책

프레임워크와 결혼하지 말라!

  • 프레임워크와는 사용할 수 있지만 결합되선 안되고 적당한 거리를 둬야한다.
  • 프레임워크는 아키텍처의 바깥쪽 원에 속하는 세부사항으로 취급하라. (아키텍처 안쪽 원으로 들어오지 못하게 하라.)
  • 프레임워크가 핵심 코드 안으로 들어오지 못하게 하라. 핵심 코드에 플러그인 할 수 있는 컴포넌트에 프레임워크를 통하라고, 의존성 규칙을 준수하라.
  • 스프링의 @Autowired 어노테이션이 업무 객체 도처에 산재해선 안된다. 업무 객체는 절대로 스프링에 대해 알아선 안된다.
  • 업무 객체보단 메인(Main) 컴포넌트에서 스프링을 사용해서 의존성을 주입해주는 편이 낫다. 메인은 아키텍처 내에서 가장 지저분한, 최저 수준의 컴포넌트기 때문에 스프링을 알아도 상관없다.

결론

  • 프레임워크와 처음부터 너무 강합게 결합하기보단 가급적 오랫동안 아키텍처 경계 너머에 두자. (핵심 업무 로직으로 부터 최대한 바깥쪽)

33장 - 사례 연구: 비디오 판매

유스케이스 분석

image 출처: https://skidrow6122.tistory.com/25

  • 단일 책임 원칙에 따르면 이들 네 액터(제작자, 관리자, 구매자, 시청자)가 시스템이 변경되어야할 네 가지 주요 근원이 된다.
  • 신규 기능을 추가하거나 변경해야한다면 반드시 이들 엑터중 하나에게 해당 기능을 제공하기 위해서다.
  • 따라서 시스템을 분할하여, 특정 액터를 위한 변경이 나머지 액터에게는 전혀 영향을 미치지 않게 만들어야함
  • 중앙 점선으로된 유스케이스는 추상 유스케이스(‘카탈로그 조회’)고 이는 다른 유스케이스(‘구매자 입장에서 카탈로그 조회’, ‘시청자 입장에서 카탈로그 조회’)에 의해 구체화된다.

컴포넌트 아키텍처

image 출처: https://skidrow6122.tistory.com/25

  • view, presenter, interactor, controller 로 분할하였고 또한 대응하는 액터에 따라 카테고리로 분리하였다.
  • 각 컴포넌트는 .jar 혹은 .dll 파일에 해당한다. 이는 변경되는 시스템 상황에 맞게 더 쪼개지거나 합쳐져서 배포 방식을 자유롭게 만든다.

의존성 관리

  • 모든 의존성은 경계선을 한 방향으로만 가르지르는데, 항상 더 높은 수준의 정책(interactors)을 포함하는 컴포넌트를 향한다.
  • 사용 관계(열린 화살표)는 제어 흐름과 같은 방향을 가리키며, 상속 관계(닫힌 화살표)는 제어흐름과는 반대 방향을 가리킨다. 이는 개방 폐쇄 원칙을 적용했음을 보여준다.
  • 저수준의 세부사항에서 발생한 변경이 상위로 파급되어서 상위 수준의 정책에 영향을 미치지 않음을 보장할 수 있다.

결론

  • 그림 33.2의 아키텍처 다이어그램은 두 가지 서로 다른 차원의 분리 개념을 포함하고 있다.
    • 하나는 단일 책임 원칙에 기반한 액터의 분리, 두 번째는 의존성 규칙
  • 이 두 차원은 모두 서로 다른 이유로, 서로 다른 속도로 변경되는 컴포넌트를 분리하는데 그 목적이 있다.
    • 서로 다른 이유라는 것은 액터와 관련 있으며, 서로 다른 속도라는 것은 정책 수준과 관련있다.
  • 이런 방식으로 코드를 한번 구조화하고 나면 시스템을 실제로 배포하는 방식은 다양하게 선택할 수 있게 된다. 상황에 맞게 컴포넌트들을 배포 가능한 단위로 묶을수도 있고, 상황이 변하면 변한 상황에 맞춰 묶는 단위를 바꾸기도 쉬워진다.

Reference

34장 - 빠져 있는 장

계층 기반 패키지

  • 기술적 관점에서 해당 코드가 하는 일에 기반하여 그 코드를 분할
  • 계층 사이의 의존성은 모두 아래를 향하도록 (단방향)

image 출처: https://skidrow6122.tistory.com/26

  • 마틴 파울러가 지적했듯이 커져가고 복잡해져가는 소프트웨어를 감당하기에는 부족하다.
  • 업무 도메인에 대해 아무것도 말해주지 않는다는 문제점

기능 기반 패키지

  • 서로 연관된 기능, 도메인 개념, 또는 (도메인 주도 설계 용어를 사용한다면) Aggregate Root 에 기반하여 수직의 얇은 조각으로 코드를 나누는 방식
  • 모든 타입이 하나의 자바 패키지에 속하며, 패키지 이름은 그 안에 담긴 개념을 반영해 짓는다
    • 이를 통해 코드의 상위 수준 구조가 업무 도메인에 대해 무언가를 알려주게 된다.

image 출처: https://skidrow6122.tistory.com/26

  • 또 다른 이점으로 유스케이스 변경에 대한 영향도를 파악하기 쉽다.
    • ‘주문 조회하기’ 유스케이스가 변경될 경우 영향가는 코드를 모두 찾는 작업이 더 쉬워질 수 있다.
    • 변경해야 하는 코드가 여러 군대 퍼져있는게 아니라 한 패키지에 몰아져있기 떄문에..
  • ‘계층 기반 패키지’와 ‘기능 기반 패키지’ 두 접근법은 모두 차선책이다.

포트와 어댑터

  • 엉클밥에 따르면, ‘포트와 어댑터’ 혹은 ‘육각형 아키텍처’, ‘경계, 컨트롤러, 엔티티’ 등의 방식으로 접근하는 이유는 업무/도메인에 초점을 둔 코드가 프레임워크나 DB와 같은 기술적인 세부 구현과 독립적이며 분리된 아키텍처를 만들기 위해서다.
  • 요약하자면 아래 이미지에서 제시하는것처럼, 그런 코드 베이스는 ‘내부(도메인)’와 ‘외부(인프라)’로 구성됨을 흔히 볼 수 있다.

image 출처: https://skidrow6122.tistory.com/26

  • ‘내부’ 영역은 도메인 개념을 모두 포함하는 반면, ‘외부’ 영역은 외부 세계(ex. UI, DB, 서드파티 통합)와의 상호작용을 포함한다.
  • 여기서 주요 규칙은 바로 ‘외부’가 ‘내부’에 의존하며, 절대 그 반대로는 안된다는 점이다.
  • 아래 이미지는 ‘주문 조회하기’ 유스케이스를 이 방식으로 구현한 모습이다.

image 출처: https://skidrow6122.tistory.com/26

  • 여기서 domain 패키지가 ‘내부’ 이며, 나머지 패키지는 모두 ‘외부’다. 의존성이 ‘내부’ 를 향해 흐르는 모습에 주목하라.

이전 다이어그램의 OrdersRepository가 Orders 라는 간단한 이름으로 바뀌었다. 이는 도메인 주도 설계라는 세계관에서 비롯된 명명법으로, 도메인 주도 설계에서는 ‘내부’에 존재하는 모든 것의 이름은 반드시 ‘유비쿼터스 도메인 언어’ 관점에서 기술하라고 조언한다. 바꿔 말하면, 도메인에 대해 논의할 떄 우리는 ‘주문’에 대해 말하는 것이지, ‘주문 레포지토리’에 대해 말하는것이 아니다.

컴포넌트 기반 패키지

  • 저자는 SOLID, REP, CCP, CRP 등 이 책에서 나온 여러 조언을 전적으로 동의하지만 ‘컴포넌트 기반 패키지’를 지양한다.

image 출처: https://skidrow6122.tistory.com/26

  • 위 이미지처럼 웹컨트롤러가 서비스계층을 건너뛰는 거처럼 인접한 계층을 건너뛰는 일은 지양해야한다.
    • 하지만 CQRS 패턴을 지키려고 시도하는등 경우에 따라 의도된 결과도 있긴하다.
  • 필요한것은 지침(아키텍처 원칙)으로, “웹 컨트롤러가 절대로 리포지토리에 접근해선 안된다”와 같은 원칙이 필요하다. 물론 문제는 강제성이다.
    • 자금이 바닥나거나 납기가 다가오면 이번 한번쯤은 괜찮겠지~ 하고 어기기 쉽기 마련이다.
    • 이럴때 빌드시 정적 분석 도구를 사용해서 아키텍처적인 위반 사항이 없는지를 검사하여 자동으로 강제(빌드 실패)하는 방법도 있다.
    • 하지만 베스트는 컴파일러 단에서 아키텍처를 강제하는 방식이다.
  • ‘컴포넌트 기반 패키지’를 도입해야 하는 이유는 바로 이 때문이다. 이 접근법은 지금까지 우리가 본 모든것들을 혼합한것으로, 큰 단위의 단일 컴포넌트와 관련된 모든 책임을 하나의 자바 패키지로 묶는데 주안점을 둔다.
  • 이 접근법은 서비스 중심적인 시각으로 소프트웨어 시스템을 바라보며, MSA 가 가진 시각과도 동일하다.
  • 컴포넌트 기반 패키지에서도 사용자 인터페이스를 큰 단위의 컴포넌트로부터 분리해서 유지한다. 그림 34.6에서 ‘주문 조회하기’ 유스케이스가 어떤 모습인지 보여준다.

image 출처: https://skidrow6122.tistory.com/26

  • 본질적으로 이 접근법에서 ‘업무 로직’과 영속성 관련 코드를 하나로 묶는데, 이 묶음을 나는 ‘컴포넌트’라 부른다.
    • 엉클밥은 컴포넌트를 배포 가능한 가장 작은 단위로 정의하지만(java의 jar 파일) 저자는 멋지고 깔끔한 인터페이스로 감싸진 연관된 기능들의 묶음으로 정의한다.
  • 컴포넌트 기반 패키지 접근법의 주된 이점은 주문과 관련된 무언가를 코딩해야 할 때 오직 한 곳, 즉 OrdersComponent 만 둘러보면 된다는 점이다.
    • 이 컴포넌트 내부에서 관심사의 분리는 여전히 유효하며, 따라서 업무 로직은 데이터 영속성과 잘 분리되어 있다.
    • 하지만 이는 컴포넌트 구현과 관련된 세부사항으로, 사용자는 알 필요가 없다.
    • 이는 마이크로 서비스 지향 아키텍처를 적용했을때 얻는 이점과도 유사하다.
    • 즉, 주문 처리와 관련된 모든 것들을 캡슐화하는 별도의 OrdersService 가 존재한다.
    • 큰 차이는 결합 분리 모드에 있다.
    • 모노리틱 애플리케이션에서 컴포넌트를 잘 정의하면 MSA로 전환하는데 손쉬울것이다.

구현 세부사항엔 항상 문제가 있다

  • 자바의 public 지시자를 무분별하게 사용한다는건 프로그래밍 언어가 제공하는 캡슐화 관련 이점을 활용하지 않겠따는 뜻이다.
  • 이로 인해 누군가가 구체적인 구현 클래스의 인스턴스를 직접 생성한 코드를 작성하는 일을 절대 막을 수 없으니, 결국 지향하는 아키텍처 스타일을 위반하게 될것이다.

조직화 vs 캡슐화

  • 모든 타입을 public 지시자로 지정한다면, 패키지는 단순 폴더와 같이 묶는 방식으로만 전락하게 되어 캡슐화를 위한 메커니즘이 사라지게 된다.
  • public 타입을 코드 베이스 어디에서도 사용할 수 있따면 패키지를 사용하는데 따른 이점이 거의 없다. 따라서 사실상 패키지를 사용하지 않는것과 같아지게되고 최종적으로 어떤 아키텍처 스타일로 만들려고 하는지는 아무런 의미가 없어진다. public 지시자를 과용하면 이 장의 앞에서 제시한 네 가지 아키텍처 접근법은 본질적으로 완전히 같아진다.

image 출처: https://skidrow6122.tistory.com/26

  • 위 이미지에서 채택하려는 아키텍처 접근법과 아무런 관계 없이 화살표들이 모두 동일한 방향을 가리킨다.
    • 개념적으로 이 접근법들은 매우 다르지만, 구문적으로는 완전히 똑같다.
    • 이처럼 모든 타입을 public 으로 선언시 우리가 실제로 갖게되는것은 수평적 계층형 아키텍처를 표현하는 네 가지 방식에 지나지 않는다.
  • 자바의 접근 지시자는 완벽하진 않지만 무시하면 사서 고생하게 된다. 적절하게 사용시 타입을 패키지로 배치하는 방식에 따라서 각 타입에 접근할 수 있는 정도가 실제로 크게 달라질 수 있다.
  • 만약 다이어그램에서 패키지 구조를 다시 살려서 더 제한적인 접근 지시자를 사용할 수 있는 타입을 (흐리게) 표시하면, 다이그램은 상당히 인상적으로 변한다. 진한게 표시된 요소만 외부에서 접근 가능하도록 열어둠으로써 캡슐화와 은닉화의 장점을 더 잘 살릴수 있게 되는것이다.

image

  • 아키텍처 원칙을 강제할때 이와 같은 접근 지시자처럼 컴파일 단에서 의지하도록 할것을 권장한다. 출처: https://skidrow6122.tistory.com/26

다른 결합 분리 모드

  • 자바엔 OSGi 같은 모듈 프레임워크나 자바9에서 제공하는 새로운 모듈 시스템을 이용하면 모든 타입을 public 으로 지정하도록 일부만 외부에서 사용가능하도록 공표할 수 있다.
  • 다른 선택지로는 소스 코드 수준에서 의존성 분리하는 방법도 있다. 정확하게는 서로 다른 소스 코드 트리로 분리하는 방법이다. 포트와 어댑터를 예로 들면 아래와 같다.
    • 업무와 도메인용 소스 코드: OrderSerivce, OrdersServiceImpl, Orders
    • 웹용 소스 코드: OrdersController
    • 데이터 영속성용 소스 코드: JdbcOrderRepository
  • maven, gradle 같은 빌드 도구로 서로 분리되도록 구성해야하는데, 웹용 데이터 영속성용 소스코드는 컴파일 시점에서 도메인용 소스코드에 의존성을 가지며 도메인용 소스코드는 웹, 데이터 영속성 코드에 대해서 알지 못하도록 해야한다.
  • 애플리케이션을 구성하는 모든 컴포넌트 각각을 개별적인 소스 코드 트리로 구성해야 하는데 이는 너무 이상적인 해결책이고 현실에선 나누다보면 성능, 복잡성, 유지보수 문제가 생기게 된다.
  • 포트와 어댑터 ㅈ버근법을 적용할때는 이보다 간단한 방법을 사용하기도 하는데, 단순히 소스 코드 트리를 두 개 만드는것이다.
    • 도메인 코드(‘내부’)
    • 인프라 코드(‘외부’)
  • 인프라 코드를 단일 소스 코드에 모두 모아둔다는 말은 애플리케이션에서 특정 영역(ex. 웹 컨트롤러)에 잇는 인프라 코드가 애플리케이션의 다른 영역(ex. 데이터베이스 리포지토리)에 있는 코드를 도메인을 통하지 않고 직접 호출 할 수 있다는 뜻이다. 특히 해당 코드에 적절한 접근 지시자를 적용하는걸 잊어버린 경우라면 이러한 호출을 막기는 더욱 힘들다.

결론: 빠져 있는 조언

  • 최적의 설계를 꾀했더라도, 구현 전략에 얽힌 복잡함을 고려하지 않으면 설계가 순식간에 망가질 수도 있다는 사실을 강조하는데 그 목적이 있다.
  • 설계를 어떻게 해야만 원하는 코드 구조로 매핑할 수 있을지, 그 코드를 어떻게 조직화할지, 런타임과 컴파일타임에 어떤 결합 분리 모드를 적용할지를 고민하라.
  • 가능하다면 선택사항을 열어두되, 실용주의적으로 행하라.
  • 그리고 팀의 규모, 기술 수준, 해결책의 복잡성을 일정과 예산이라는 제약과 동시에 고려하라.
  • 또한 선택된 아키텍처 스타일을 강제하는데 컴파일러의 도움을 받을 수 있을지를 고민하며, 데이터 모델과 같은 다른 영역에 결합되지 않도록 주의하라. 구현 세부사항에는 항상 문제가 있는 법이다.

Reference

This post is licensed under CC BY 4.0 by the author.

[Java] HashSet은 어떠한 이유로 순서를 보장하지 않을까

AWS 서버리스 아키텍처 기반 2,000여명 고객사 인사데이터 동기화 시스템 구축기