본문 바로가기
Book

[독서기록] 클린 아키텍처

by Renechoi 2024. 2. 26.

서문

현재의 소프트웨어는 과거와 동일한 것들로 구성된다. 여전히 if문, 할당문, while 루프로 구성된다.

 

지금 우리는 자바, C#, 루비라는 훨씬 진보한 언어를 쓰고, 객체 지향 설계라는 우월한 패러다임을 사용한다고 말이다. 맞는 말이다. 그렇다 하더라도 1960년대나 1950년대와 마찬가지로 코드는 여전히 순차, 분기, 반복의 집합체일 뿐이다.

 

컴퓨터 프로그래밍을 하는 관행을 정말 유심히 관찰해 보면 지난 50년 동안 변한 게 거의 없다는 사실을 깨달을 것이다. 언어는 조금 발전했다. 도구는 환상적으로 좋아졌다. 하지만 컴퓨터 프로그래밍을 이루는 기본 구성요소는 조금도 바뀌지 않았다.

 

이것이 열쇠다. 이처럼 코드가 변하지 않았다는 사실이 시스템의 종류와 관계없이 소프트웨어 아키텍처의 규칙이 일관된 이유다. 소프트웨어 아키텍처의 규칙이란 프로그램의 구성요소를 정렬하고 조립하는 방법에 관한 규칙이다. 그리고 이 구성요소가 보편적이며 변하지 않았으므로, 이들을 정렬하는 규칙 역시도 보편적이며 변한 것이 없다.

소개

프로그램이 동작하도록 만드는 데 엄청난 수준의 지식과 기술이 필요하지는 않다.

 

하지만 프로그램을 제대로 만드는 일은 전혀 다르다. 소프트웨어를 올바르게 만드는 일은 어렵다.

 

어느 정도의 훈련과 헌신이 필요하지만, 대다수의 프로그래머는 훈련과 헌신이 필요하리라는 생각조차 하지 않는다. 소프트웨어를 올바르게 만들려면 무엇보다도 기술을 향한 열정과 전문가가 되려는 열망이 필수다.

설계와 아키텍처

'아키텍처'는 저수준의 세부사항과는 분리된 고수준의 무언가를 가리킬 때 흔히 사용되는 반면, '설계'는 저수준의 구조 또는 결정사항 등을 의미할 때가 많다.

 

저수준의 세부사항과 고수준의 구조는 모두 소프트웨어 전체 설계의 구성요소다. 이 둘은 단절 없이 이어진 직물과 같으며, 이를 통해 대상 시스템의 구조를 정의한다. 개별로는 존재할 수 없고, 실제로 이 둘을 구분 짓는 경계는 뚜렷하지 않다. 고수준에서 저수준으로 향하는 의사결정의 연속성만이 있을 뿐이다.

 

설계 품질을 재는 척도는 고객의 요구를 만족시키는 데 드는 비용을 재는 척도와 다름없다. 이 비용이 낮을 뿐만 아니라 시스템의 수명이 다할 때까지 낮게 유지할 수 있다면 좋은 설계라고 말할 수 있다. 새로운 기능을 출시할 때마다 비용이 증가한다면 나쁜 설계다. 좋은 설계란 이처럼 단순명료하다.

무엇이 잘못되었나?

토끼와 거북이 우화.

 

이 우화 자체는 지나친 과신이 가진 어리석음을 말해준다. 토끼는 타고난 빠르기를 과신한 나머지 경주를 심각하게 받아들이지 않아 낮잠을 자버리고, 그 사이 거북이는 결승선을 통과한다.

 

현대의 개발자도 이와 비슷한 경주를 하며, 토끼와 유사한 과신을 드러낸다. 물론 개발자가 잠을 자는 것은 아니다. 오히려 정반대다. 현대의 대다수 개발자는 뼈 빠지게 일한다. 하지만 그들의 뇌는 잠에 취해 있다. 훌륭하고 깔끔하게 잘 설계된 코드가 중요하다는 사실을 알고 있는 바로 그 뇌가 잠자고 있다.

 

이들 개발자는 "코드는 나중에 정리하면 돼. 당장은 시장에 출시하는 게 먼저야!"라는 흔해 빠진 거짓말에 속는다. 이렇게 속아 넘어간 개발자라면 나중에 코드를 정리하는 경우는 한 번도 없는데, 시장의 압박은 절대로 수그러들지 않기 때문이다. '시장 출시가 먼저'라는 생각을 하는 이유는 바로 뒤에 여러 무리의 경쟁자가 뒤쫓고 있고, 경쟁자보다 앞서 가려면 가능한 한 빠르게 달려야 하기 때문이다.

 

토끼가 자신의 빠르기를 과신한 것과 마찬가지로, 개발자도 생산성을 유지할 수 있다고 자신의 능력을 과신한다. 하지만 엉망진창인 코드가 서서히 쌓이면 개발자 생산성은 차츰 낮아지고, 코드가 엉망이 되는 추세는 절대 멈추거나 수그러들지 않는다. 이대로 진행되면 결국 생산성이 0으로 수렴하는 일은 시간문제다.

TDD 실험

제임스 고먼은 이 실험을 6일에 걸쳐 실행했다. 매일 그는 정수를 로마 숫자로 변환하는 단순한 프로그램을 완성했다. 사전에 정의한 일련의 인수 테스트를 프로그램이 통과하면 개발이 완료된 것으로 봤다. 이 작업은 매일 30분도 채 걸리지 않았다. 제이슨은 코드를 깔끔하게 유지하는 잘 알려진 수련법 중 하나인 테스트 주도 개발TDD을 첫째 날, 셋째 날, 다섯째 날에 적용했다. 나머지 3일에는 TDD를 사용하지 않은 채 코드를 작성했다.

 

TDD를 적용한 날이 적용하지 않은 날보다 대략 10% 빠르게 작업이 완성되었고, 심지어 TDD를 적용한 날 중 가장 느렸던 날이 TDD를 적용하지 않고 가장 빨리 작업한 날보다도 더 빨랐다.

 

빨리 가는 유일한 방법은 제대로 가는 것이다.

결론

어떤 경우라도 개발 조직이 할 수 있는 최고의 선택지는 조직에 스며든 과신을 인지하여 방지하고, 소프트웨어 아키텍처의 품질을 심각하게 고민하기 시작하는 것이다. 소프트웨어 아키텍처를 심각하게 고려할 수 있으려면 좋은 소프트웨어 아키텍처가 무엇인지 이해해야 한다. 비용은 최소화하고 생산성은 최대화할 수 있는 설계와 아키텍처를 가진 시스템을 만들려면, 이러한 결과로 이끌어줄 시스템 아키텍처가 지닌 속성을 알고 있어야 한다.

두 가지 가치에 대한 이야기

행위

소프트웨어의 첫 번째 가치는 바로 행위다. 프로그래머를 고용하는 이유는 이해 관계자를 위해 기계가 수익을 창출하거나 비용을 절약하도록 만들기 위해서다. 이를 위해 프로그래머는 이해관계자가 기능 명세서나 요구사항 문서를 구체화할 수 있도록 돕는다. 그리고 이해관계자의 기계가 이러한 요구사항을 만족하도록 코드를 작성한다.

 

많은 프로그래머가 이러한 활동이 자신이 해야 할 일의 전부라고 생각한다. 이들은 요구사항을 기계에 구현하고 버그를 수정하는 일이 자신의 직업이라고 믿는다. 슬픈 일이지만 그들은 틀렸다.

아키텍처

소프트웨어의 두 번째 가치는 소프트웨어라는 단어와 관련이 있다.

 

소프트웨어는 '부드러움을 지니도록' 만들어졌다.

 

소프트웨어가 가진 본연의 목적을 추구하려면 소프트웨어는 반드시 '부드러워야 한다.' 다시 말해 변경하기 쉬워야 한다. 이해관계자가 기능에 대한 생각을 바꾸면, 이러한 변경사항을 간단하고 쉽게 적용할 수 있어야 한다. 이러한 변경사항을 적용하는 데 드는 어려움은 변경되는 범위에 비례해야 하며, 변경사항의 형태와는 관련이 없어야 한다.

더 높은 가치

기능인가 아니면 아키텍처인가? 둘 중 어느 것의 가치가 높은가? 소프트웨어 시스템이 동작하도록 만드는 것이 더 중요한가? 아니면 소프트웨어 시스템을 더 쉽게 변경할 수 있도록 하는 것이 더 중요한가?

  • 완벽하게 동작하지만 수정이 아예 불가능한 프로그램을 내게 준다면, 이 프로그램은 요구사항이 변경될 때 동작하지 않게 되고, 결국 프로그램이 돌아가도록 만들 수 없게 된다. 따라서 이러한 프로그램은 거의 쓸모가 없다.
  • 동작은 하지 않지만 변경이 쉬운 프로그램을 내게 준다면, 나는 프로그램이 돌아가도록 만들 수 있고, 변경사항이 발생하더라도 여전히 동작하도록 유지보수 할 수 있다. 따라서 이러한 프로그램은 앞으로도 계속 유용한 채로 남는다.

아이젠하워 매트릭스

  1. 긴급하고 중요한
  2. 긴급하지는 않지만 중요한
  3. 긴급하지만 중요하지 않은
  4. 긴급하지도 중요하지도 않은

업무 관리자와 개발자가 흔하게 저지르는 실수는 세 번째 위치한 항목을 첫 번째로 격상시켜 버리는 일이다. 다시 말해 긴급하지만 중요하지 않은 기능과 진짜로 긴급하면서 중요한 기능을 구분하지 못한다. 이러한 실패로 인해 시스템에서 중요도가 높은 아키텍처를 무시한 채 중요도가 떨어지는 기능을 선택하게 된다.

 

업무 관리자는 보통 아키텍처의 중요성을 평가할 만한 능력을 겸비하지 못하기 때문에 개발자는 딜레마에 빠진다. 소프트웨어 개발자를 고용하는 이유는 바로 이 딜레마를 해결하기 위해서다. 따라서 기능의 긴급성이 아닌 아키텍처의 중요성을 설득하는 일은 소프트웨어 개발팀이 마땅히 책임져야 한다.

아키텍처를 위해 투쟁하라

소프트웨어 개발자인 당신도 이해관계자임을 명심하라. 당신은 소프트웨어를 안전하게 보호해야 할 책임이 있으므로 당신 역시도 이해 관계가 있다. 이것이 바로 당신의 역할 중 하나이며, 당신의 책무 중 하나다. 또한 이것이 바로 당신이 고용된 중요한 이유 중 하나이기도 하다.

패러다임 개요

구조적 프로그래밍

구조적 프로그래밍은 제어 흐름의 직접적인 전환에 대해 규칙을 부과한다.

객체지향 프로그래밍

객체지향 프로그래밍은 제어흐름의 간접적인 전환에 대해 규칙을 부과한다.

함수형 프로그래밍

함수형 프로그래밍은 할당문에 대해 규칙을 부과한다.

구조적 프로그래밍

데이크스트라는 이런 goto문의 '좋은' 사용 방식은 if/then/else와 do/while과 같은 분기와 반복이라는 단순한 제어 구조에 해당한다는 사실을 발견했다. 모듈이 이러한 종류의 제어 구조만을 사용한다면 증명 가능한 단위로까지 모듈을 재귀적으로 세분화하는 것이 가능해 보였다.

 

데이크스트라는 단순한 열거법을 이용해 순차 구문이 올바름을 입증할 수 있다는 사실을 보여주었다. 이 기법에는 각 순차 구문의 입력을 순차 구문의 출력까지 수학적으로 추적한다. 이 접근법은 일반적인 수학적 증명 방식과 다를 바 없다.

 

분기의 경우 데이크스트라는 열거법을 재적용하는 방식으로 처리했다. 먼저 분기를 통한 각 경로를 열거했다. 결과적으로 두 경로가 수학적으로 적절한 결과를 만들어낸다면, 증명은 신뢰할 수 있게 된다.

 

반복은 조금 다르다. 반복이 올바름을 증명하기 위해 데이크스트라는 귀납법을 사용했다. 열거법에 따라 1의 경우가 올바름을 증명했다. 그리고 N의 경우가 올바르다고 가정할 때 N+1의 경우도 올바름을 증명하며, 이 경우에도 열거법을 사용했다. 또한 반복의 시작 조건과 종료 조건도 열버버을 통해 증명했다.

테스트

데이크스트라는 "테스트는 버그가 있음을 보여줄 뿐, 버그가 없음을 보여줄 수는 없다"고 말한 적이 있다. 다시 말해 프로그램이 잘못되었음을 테스트를 통해 증명할 수는 있지만, 프로그램이 맞다고 증명할 수는 없다. 테스트에 충분한 노력을 들였다면 테스트가 보장할 수 있는 것은 프로그램이 목표에 부합할 만큼은 충분히 참이라고 여길 수 있게 해주는 것이 전부다.

 

이 같은 사실이 내포하는 의미는 너무도 충격적이다. 소프트웨어 개발이수학적인 구조를 다루는 듯 보이더라도, 소프트웨어 개발은 수학적인 시도가 아니라는 사실이다. 오히려 소프트웨어는 과학과 같다. 최선을 다하더라도 올바르지 않음을 증명하는 데 실패함으로써 올바름을 보여주기 때문이다.

 

이러한 부정확함에 대한 증명은 입증 가능한 프로그램에만 적용할 수 있다. 예를 들어 제약 없는 goto 문을 사용하는 등의 이유로 입증이 불가능한 프로그램은 테스트를 아무리 많이 수행하더라도 절대로 올바르다고 볼 수 없다.

 

구조적 프로그래밍은 프로그램을 증명 가능한 세부 긴으 집합으로 재귀적으로 분해할 것을 강요한다. 그러고 나서 테스트를 통해 증명 가능한 세부 기능들이 거짓인지를 증명하려고 시도한다. 이처럼 거짓임을 증명하려는 테스트가 실패한다면, 이 기능들은 목표에 부합할 만큼은 충분히 참이라고 여기게 된다.

결론

가장 작은 기능에서부터 가장 큰 컴포넌트에 이르기까지 모든 수준에서 소프트웨어는 과학과 같고, 따라서 반증 가능성에 의해 주도된다. 소프트웨어 아키텍트는 모듈, 컴포넌트, 서비스가 쉽게 반증 가능하도록(테스트하기 쉽도록) 만들기 위해 분주히 노력해야 한다. 이를 위해 구조적 프로그래밍과 유사한 제한적인 규칙들을 받아들여 활용해야 한다.

객체 지향 프로그래밍

캡슐화 ?

// 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);
}

int doubleX(struct Point* p1) {
   return 2 * p1->x;
}

이것이 바로 완벽한 캡슐화이며, 보다시피 OO가 아닌 언어에서도 충분히 가능하다. C 프로그래머는 항상 이러한 방식을 활용했다.

자바와 C#은 헤더와 구현체를 분리하는 방식을 모두 버렸고, 이로 인해 캡슐화는 더욱 심하게 훼손되었다.

 

이 때문에 OO가 강력한 캡슐화에 의존한다는 정의는 받아들이기 힘들다. 실제로 많은 OO 언어가 캡슐화를 거의 강제하지 않는다.

 

OO 프로그래밍은 프로그래머가 충분히 올바르게 행동함으로써 캡슐화된 데이터를 우회해서 사용하지 않을 거라는 믿음을 기반으로 한다. 하지만 OO를 제공한다고 주창한 언어들이 실제로는 C 언어에서 누렸던 완벽한 캡슐화를 약화시켜 온 것은 틀림 없다.

상속?

OO 언어가 고안되기 훨씬 이전에도 상속과 비슷한 기법이 사용되었다고 말할 수 있다.

캡슐화에 대해서는 OO에 점수를 줄 수 없고, 상속에 대해서만 0.5점 정도를 부여할 수 있다.

다형성?

요지는 함수를 가리키는 포인터를 응용한 것이 다형성이라는 점이다. 1940년대 후반 폰 노이만 아키텍처가 처음 구현된 이후 프로그래머는 다형적 행위를 수행하기 위해 함수를 가리키는 포인터를 사용해왔다. 따라서 OO가 새롭게 만든 것은 전혀 없다.

 

이 말이 완전히 옳은 말이 아니긴 하다. OO 언어는 다형성을 제공하지는 못했지만, 다형성을 좀 더 안전하고 더욱 편리하게 사용할 수 있게 해준다.

 

함수에 대한 포인터를 직접 사용하여 다형적 행위를 만드는 이 방식에는 문제가 있는데, 함수 포인터는 위험하다는 사실이다. OO 언어를 사용하면 다형성은 대수롭지 않은 일이 된다. OO 언어는 과거 C 프로그래머가 꿈에서야 볼 수 있던 강력한 능력을 제공한다. 이러한 이유로 OO는 제어흐름을 간접적으로 전환하는 규칙을 부과한다고 결론지을 수 있다.

다형성이 가진 힘

복사 프로그램 예제를 다시 살펴보자. 새로운 입출력 장치가 생긴다면 프로그램에는 어떤 변화가 생기는가? 필기체 인식 장치로부터 데이터를 읽어서 음성 합성 장치로 복사할 때도 이 프로그램을 사용해야 한다고 해보자. 이 새로운 장비에서도 복사 프로그램이 동작하도록 만들려면 어떻게 수정해야 할까?

 

아무런 변경도 필요치 않다! 심지어 복사 프로그램을 다시 컴파일할 필요 조차 없다. 왜냐고? 복사 프로그램의 소스 코드는 입출력 드라이버의 소스 코드에 의존하지 않기 때문이다. 입출력 드라이버가 FILE에 정의된 다섯 가지 표준 함수를 구현한다면, 복사 프로그램에서는 이 입출력 드라이버를 얼마든지 사용할 수 있다.

 

다시 말해 입출력 드라이버가 복사 프로그램의 플러그인이 된 것이다.

의존성 역전

다형성을 안전하고 편리하게 적용할 수 있는 메커니즘이 등장하기 전 소프트웨어는 어떤 모습이었을지 생각해 보자. 전형적인 호출 트리의 경우 main 함수가 고수준 함수를 호출하고, 고수준 함수는 다시 중간 수준 함수를 호출하며, 중간 수준 함수는 다시 저수준 함수를 호출한다. 이러한 호출 트리에서 소스 코드 의존성의 방향은 반드시 제어흐름을 따르게 된다.

main 함수가 고수준 함수를 호출하려면 고수준 함수가 포함된 모듈의 이름을 지정해야만 한다. C의 경우 이러한 지정자는 #include다. 자바에서는 import 구문이다.

 

이러한 제약 조건으로 인해 소프트웨어 아키텍트에게 남은 선택지는 별로 없었다. 즉, 제어흐름은 시스템의 행위에 따라 결정되며, 소스 코드 의존성은 제어흐름에 따라 결정된다.

 

하지만 다형성이 끼어들면 무언가 특별한 일이 일어난다.

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

 

하지만 ML1과 I 인터페이스 사시의 소스 코드 의존성(상속 관계)이 제어흐름과는 반대인 점을 주목하자. 이는 의존성 역전이라고 부르며, 소프트웨어 아키텍트 관점에서 이러한 현상은 심오한 의미를 갖는다.

 

OO 언어가 다형성을 안전하고 편리하게 제공한다는 사실은 소스 코드 의존성을 어디에서든 역전시킬 수 있다는 뜻이기도 하다.

 

이러한 접근법을 사용한다면 OO 언어로 개발된 시스템을 다루는 소프트웨어 아키텍트는 시스템의 소스 코드 의존성 전부에 대해 방향을 결정할 수 있는 절대적인 권한을 갖는다. 즉, 소스 코드 의존성이 제어흐름의 방향과 일치되도록 제한되지 않는다. 호출하는 모듈이든 아니면 호출 받는 모듈이든 관계없이 소프트웨어 아키텍트는 소스 코드 의존성을 원하는 방향으로 설정할 수 있다.

 

이것이 힘이다! 이것이 바로 OO가 제공하는 힘이다.

 

그럼 이 힘으로 무엇을 할 수 있는가?

 

예를 들어 업무 규칙이 데이터베이스와 사용자 인터페이스에 의존하는 대신에, 시스템의 소스 코드 의존성을 반대로 배치하여 데이터베이스와 UI가 업무 규칙에 의존하게 만들 수 있다.

즉, UI와 데이터베이스가 업무 규칙의 플러그인이 된다는 뜻이다. 다시 말해 업무 규칙의 소스 코드에서는 UI나 데이터베이스를 호출하지 않는다.

 

따라서 업무 규칙을 UI와 데이터베이스와는 독립적으로 배포할 수 있다. UI나 데이터베이스에서 발생한 변경사항은 업무 규칙에 일절 영향을 미치지 않는다. 즉, 이들 컴포넌트는 개별적이며 독립적으로 배포 가능하다.

결론

OO란 무엇인가?

 

OO를 사용하면 아키텍트는 플러그인 아키텍처를 구성할 수 있고, 이를 통해 고수준의 정책을 포함하는 모듈은 저수준의 세부사항을 포함하는 모듈에 대해 독립성을 보장할 수 있다. 저수준의 세부사항은 중요도가 낮은 플러그인 모듈로 만들 수 있고, 고수준의 정책을 포함하는 모듈과는 독립적으로 개발하고 배포할 수 있다.

함수형 프로그래밍

함수형 언어에서 변수는 변경되지 않는다.

불변성과 아키텍처

아키텍트는 왜 변수의 가변성을 염려하는가? 경합 조건, 교착상태, 동시 업데이트 문제가 모두 가변 변수로 인해 발생하기 때문이다. 만약 어떠한 변수도 갱신되지 않는다면 경합 조건이나 동시 업데이트 문제가 일어나지 않는다. 락이 가변적이지 않다면 교착상태도 일어나지 않는다.

 

우리가 동시성 애플리케이션에서 마주치는 모든 문제, 즉 다수의 스레드와 프로세스를 사용하는 애플리케이션에서 마주치는 모든 문제는 가변 변수가 없다면 절대로 생기지 않는다.

가변성의 분리

불변성과 관련하여 가장 주요한 타협 중 하나는 애플리케이션, 또는 애플리케이션 내부의 서비스를 가변 컴포넌트와 불변 컴포넌트로 분리하는 일이다. 불변 컴포넌트에서는 순수하게 함수형 방식으로만 작ㅇ버이 처리되며, 어떤 가변 변수도 사용되지 않는다.

이벤트 소싱

고객의 계좌 잔고를 관리하는 은행 애플리케이션을 생각해보자. 이 애플리케이션에서는 입금 트랜잭션과 출금 트랜잭션이 실행되면 잔고를 변경해야 한다.

 

이제 계좌 잔고를 변경하는 대신 트랜잭션 자체를 저장한다고 상상해보자. 누군가 잔고 조회를 요청할 때마다 계좌 개설 시점부터 발생한 모든 트랜잭션을 단순히 더한다. 이 전략에서는 가변 변수가 하나도 필요 없다.

 

당연하게도 이러한 접근법은 터무니없다. 시간이 지날수록 트랜잭션 수는 끝없이 증가하고, 잔고 계산에 필요한 컴퓨팅 자원은 걷잡을 수 없이 커진다. 따라서 이 전략이 영원히 실현 가능하려면 무한한 저장 공간과 무한한 처리 능력이 필요하다.

 

하지만 이 전략이 영원히 동작하도록 만들 필요는 없다. 아마도 애플리케이션의 수명주기 동안만 문제없이 동작할 정도의 저장 공간과 처리 능력만 있으면 충분할 것이다.

 

이벤트 소싱에 깔려 있는 기본 발상이 바로 이것이다. 이벤트 소싱은 상태가 아닌 트랜잭션을 저장하자는 전략이다. 상태가 필요해지면 단순히 상태의 시작점부터 모든 트랜잭션을 처리한다.

 

더 중요한 점은 데이터 저장소에서 삭제되거나 변경되는 것이 하나도 없다는 사실이다. 결과적으로 애플리케이션은 CRUD가 아니라 그저 CR만 수행한다. 또한 데이터 저장소에서 변경과 삭제가 전혀 발생하지 않으므로 동시 업데이트 문제 또한 일어나지 않는다.

단일 책임 원칙

역사적으로 SRP는 아래와 같이 기술되어 왔다.

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


소프트웨어 시스템은 사용자와 이해 관계자를 만족시키기 위해 변경된다. SRP가 말하는 '변경의 이유'란 바로 이들 사용자와 이해관계자를 가리킨다. 사실 이 원칙은 아래와 같이 바꿔 말할 수도 있다.

하나의 모듈은 하나의, 오직 하나의 사용자 또는 이해 관계자에 대해서만 책임져야 한다.

SRP의 최정 버전은 아래와 같다.

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

그럼 '모듈'이란 또 무슨 뜻인가? 가장 단순한 정의는 바로 소스 파일이다. 대부분의 경우 이 정의는 잘 들어맞는다. 하지만 일부 언어와 개발 환경에서는 코드를 소스 파일에 저장하지 않는다. 이러한 경우 모듈은 단순히 함수와 데이터 구조로 구성된 응집된 집합이다.

 

'응집된'이라는 단어가 SRP를 암시한다. 단일 액터를 책임지는 코드를 함께 묶어주는 힘이 바로 응집성이다.

징후1: 우발적 중복

급여 애플리케이션의 Employee 클래스.

 

이 클래스는 세 가지 메서드 calculatePay(), reportHours(), save()를 가진다.

 

이 클래스는 SRP를 위반하는데, 이들 세 가지 메서드가 서로 매우 다른 세 명의 액터를 책임지기 때문이다.

 

  • calculatePay() 메서드는 회계팀에서 기능을 정의하며, CFO 보고를 위해 사용한다.
  • reportHours() 메서드는 인사팀에서 기능을 정의하고 사용하며, COO 보고를 위해 사용한다.
  • save() 메서드는 데이터베이스 관리자가 기능을 정의하고, CTO 보고를 위해 사용한다.

개발자가 이 세 메서드를 Employee 라는 단일 클래스에 배치하여 세 액터가 서로 결합되어 버렸다. 이 결합으로 인해 CFO 팀에서 결정한 조치가 COO 팀이 의존하는 무언가에 영향을 줄 수 있다.

 

예를 들어 calculatePay() 메서드와 reportHours() 메서드가 초과 근무를 제외한 업무 시간을 계산하는 알고리즘을 공유한다고 해보자.

 

그리고 개발자는 코드 중복을 피하기 위해 이 알고리즘을 regularHours()라는 메서드에 넣었다고 해보자.

 

이제 CFO 팀에서 초과 근무를 제외한 업무 시간을 계산하는 방식을 약간 수정하기로 결정했다고 하자. 반면 인사를 담당하는 COO 팀에서는 초과 근무를 제외한 업무 시간을 CFO 팀과는 다른 목적으로 사용하기 때문에, 이 같은 변경을 원하지 않는다고 해보자.

 

이 변경을 적용하는 업무를 할당받은 개발자는 calculatePayt() 메서드가 편의 메서드인 regularHours()를 호출한다는 사실을 발견한다. 하지만 안타깝게도 이 함수가 reportHours() 메서드에서도 호출된다는 사실을 눈치채지 못한다.

 

개발자는 요청된 변경사항을 적용하고 신중하게 테스트한다. CFO 팀은 새로운 메서드가 원하는 방식으로 동작하는지 검증하고, 시스템은 배포된다.

 

물론 COO 팀에서는 이러한 일이 벌어지고 있다는 사실을 알지 못한다. COO 팀 직원은 reportHours() 메서드가 생성한 보고서를 여전히 이용한다. 하지만 이제 이 보고서에 포함된 수치들은 엉터리다.

징후2: 병합

두 명의 서로 다른 개발자가, 그리고 아마도 서로 다른 팀에 속했을 두 개발자가 Employee 클래스를 체크아웃 받은 후 변경사항을 적용하기 시작한다. 안타깝게도 이들 변경사항은 서로 충돌한다. 결과적으로 병합이 발생한다.

해결책

아마도 가장 확실한 해결책은 데이터와 메서드를 분리하는 방식일 것이다. 즉, 아무런 메서드가 없는 간단한 데이터 구조인 EmployeeData 클래스를 만들어, 세 개의 클래스가 공유하도록 한다. 각 클래스는 자신의 메서드에 반드시 필요한 소스 코드만을 포함한다. 세 클래스는 서로의 존재를 몰라야 한다. 따라서 '우연한 중복'을 피할 수 있다.

반면 이 해결책은 개발자가 세 가지 클래스를 인스턴스화하고 추적해야 한다는 게 단점이다. 이러한 난관에서 빠져나올 때 흔히 쓰는 기법으로 퍼사드 패턴이 있다.

OCP: 개방 폐쇄 원칙

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

소프트웨어 아키텍처를 공부하는 가장 근본적인 이유가 바로 이 때문이다. 만약 요구사항을 살짝 확장하는 데 소프트웨어를 엄청나게 수정해야 한다면, 그 소프트웨어 시스템을 설계한 아키텍트는 엄청난 실패에 맞닥뜨린 것이다.

사고 실험

재무제표를 웹 페이지로 보여주는 시스템이 있다고 생각해보자. 이해관계자가 동일한 정보를 보고서 형태로 변환해서 흑백 프린터로 출력해 달라고 요청했다고 해보자.

 

재무 데이터를 검사한 후 보고서용 데이터를 생성한 다음, 필요에 따라 두 가지 보고서 생성 절차 중 하나를 거쳐 적절히 포매팅한다.

 

여기서 얻을 수 있는 가장 중요한 영감은 보고서 생성이 두 개의 책임으로 분리된다는 사실이다. 하나는 보고서용 데이터를 계산하는 책임이며, 나머지 하나는 이 데이터를 웹으로 보여주거나 종이로 프린트하기에 적합한 형태로 표현하는 책임이다.

 

이처럼 책임을 분리했다면, 두 책임 중 하나에서 변경이 발생하더라도 다른 하나는 변경되지 않도록 소스 코드 의존성도 확실히 조직화해야 한다. 또한, 새로 조직화한 구조에서는 행위가 확장될 때 변경이 발생하지 않음을 보장해야 한다.

 

이러한 목적을 달성하려면 처리 과정을 클래스 단위로 분할하고, 이들 클래스를 이중 선으로 표시한 컴포넌트 단위로 구분해야 한다.

 

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

A 컴포넌트에서 발생한 변경으로부터 B 컴포넌트를 보호하려면 반드시 A 컴포넌트가 B 컴포넌트에 의존해야 한다.

 

왜 Interactor가 이처럼 특별한 위치를 차지해야만 하는가? 그 이유는 바로 Interactor가 업무 규칙을 포함하기 때문이다. Interactor는 애플리케이션에서 가장 높은 수준의 정책을 포함한다. Interactor 이외의 컴포넌트는 모두 주변적인 문제를 처리한다. 가장 중요한 문제는 Interactor가 처리한다.

 

보호의 계층구조가 '수준'이라는 개념을 바탕으로 어떻게 생성되는지 주목하자. Interactor는 가장 높은 수준의 개념이며, 따라서 최고의 보호를 받는다. View는 가장 낮은 수준의 개념 중 하나이며, 따라서 거의 보호를 받지 못한다. Presenter는 View보다는 높고 Controller나 Interactor보다는 낮은 수준에 위치한다.

 

이것이 바로 아키텍처 수준에서 OCP가 동작하는 방식이다. 아키텍트는 기능이 어떻게, 왜, 언제 발생하는지에 따라서 기능을 분리하고, 분리한 기능을 컴포넌트의 계층구조로 조직화한다. 컴포넌트 계층구조를 이와 같이 조직화하면 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있다.

결론

OCP 목표를 달성하려면 시스템을 컴포넌트 단위로 분리하고, 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있는 형태의 의존성 계층구조가 만들어지도록 해야 한다.

LSP: 리스코프 치환 원칙

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

License라는 클래스가 있다고 해보자. 이 클래스는 CalcFee()라는 메서드를 가지며, Billing 애플리케이션에서 이 메서드를 호출한다. License에는 PersonalLicense와 BusinessLicense라는 두 가지 '하위 타입'이 존재한다. 이들 두 하위 타입은 서로 다른 알고리즘을 이용해서 라이선스 비용을 계산한다.

 

이 설계는 LST를 준수하는데, Billing 애플리케이션의 행위가 License 하위 타입 중 무엇을 사용하는지에 전혀 의존하지 않기 때문이다. 이들 하위 타입은 모두 License 타입을 치환할 수 있다.

정사각형/직사각형 문제

LSP를 위반하는 전형적인 문제로는 유명한 정사각형/직사각형 문제가 있다.

 

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

LSP

LSP는 상속을 하용하도록 가이드하는 방법 정도로 간주되었다. 하지만 시간이 지나면서 LSP는 인터페이스와 구현체에도 적용되는 더 광범위한 소프트웨어 설계 원칙으로 변모해왔다.

LSP 위반 사레

택시 파견 서비스를 통합하는 애플리케이션을 만들고 있가도 해보자.

 

이 서비스는 여러 택시 파견 서비스를 집계하여 고객이 택시 회사에 상관없이 최적의 택시를 찾을 수 있게 해준다. 고객이 기사를 선택하면, 서비스는 선택된 택시를 배차한다.

 

예를 들어, 서비스가 사용하는 URI가 드라이버 데이터베이스 내의 정보의 일부라고 가정해보자. 고객이 Bob의 택시를 선택하면, 서비스는 데이터베이스에 저장된 Bob의 URI를 사용하여 요청을 배차한다.

 

모든 택시 서비스는 집계 서비스와 인터페이스할 수 있도록 이 인터페이스를 준수해야 한다. 만약 TaxiAnyTime.com이 인터페이스를 변경하여 'destination' 대신 'dest'를 사용한다면, 이는 LSP를 위반하는 것으로, 모든 서비스가 하나의 인터페이스를 준수하지 않는 경우이다. 따라서, 이 경우 택시 파견 서비스는 이 택시 서비스의 다른 파라미터를 처리하기 위한 특별한 로직이 필요하게 된다.

-> if 분기문

결론

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

ISP: 인터페이스 분리 원칙

클라이언트가 사용하지 않는 인터페이스에 의존하도록 강제되어서는 안 된다는 것이다. 예를 들어, User1이 op1만을 사용하고, User2가 op2만을 사용하며, User3이 op3만을 사용한다고 가정해보자. 이 클래스를 가진 프로그램을 실행할 때, User1은 op2와 op3를 호출하지 않음에도 불구하고 이들에 의존하게 된다. op2와 op3에 변화가 생기면 User1을 재컴파일해야 한다. 이 문제를 해결하기 위해 아래와 같이 작업을 인터페이스로 분리하는 것이 좋다.

ISP는 아키텍처 문제가 아닌 언어 문제이다. 이는 위의 프로그램이 Python과 같은 동적 타입 언어에서는 실행 시 문제가 되지 않기 때문이다. ISP를 위반하는 또 다른 예로, S가 F에 의존하고, F가 D에 의존하는 구조를 들 수 있다. D에 변경이 생기면 S와 F에 재배포를 강제할 수 있으며, 이는 문제를 일으킬 수 있다.

DIP: 의존성 역전 원칙

의존성 역전 원칙에서 말하는, 유연성이 극대화된 시스템이란 소스코드 의존성이 추상에 의존하며 구체에는 의존하지 않는 시스템이다.

우리가 의존하지 않도록 피하고자 하는 것은 바로 변동성이 큰 구체적인 요소다.

안정된 추상화

  • 변동성이 큰 구체 클래스를 참조하지 말라.
  • 변동성이 큰 구체 클래스로부터 파생하지 말라.
  • 구체 함수를 오버라이드 하지 말라.
  • 구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라.

팩토리

예를 들어, 추상 팩토리 패턴에서 애플리케이션은 서비스 인터페이스를 통해 ConcreteImpl을 사용하지만, ConcreteImpl의 인스턴스를 생성하기 위해 애플리케이션은 ConcreteImpl에 대한 소스 코드 의존성을 생성하지 않고 ServiceFactory 인터페이스의 makeSvc 메소드를 호출한다. 이 메소드는 ServiceFactory를 상속받은 ServiceFactoryImpl 클래스에 의해 구현되며, ConcreteImpl을 인스턴스화하여 Service로 반환한다. 여기서 곡선은 추상과 구체를 분리하는 아키텍처 경계를 나타낸다. 이 경계는 시스템을 추상 컴포넌트와 구체 컴포넌트로 나누며, 추상 컴포넌트는 애플리케이션의 고수준 비즈니스 규칙을, 구체 컴포넌트는 그 비즈니스 규칙이 조작하는 모든 구현 세부사항을 포함한다. 제어의 흐름은 소스 코드 의존성의 방향과 반대로 곡선을 넘나들며, 이러한 이유로 우리는 이 원칙을 의존성 역전이라고 한다.

결론

의존성은 이 곡선을 경계로, 더 추상적인 엔티티가 있는 쪽으로만 향한다. 추후 이 규칙은 의존성 규칙이라 부를 것이다.

컴포넌트

SOLID 원칙이 벽돌을 벽과 방으로 어떻게 배치하는지 알려준다면, 컴포넌트 원칙은 방들을 건물로 어떻게 조합하는지 알려준다. 큰 소프트웨어 시스템은 마치 큰 건물과 같이, 더 작은 컴포넌트로 구축된다. 컴포넌트는 시스템의 일부로 배포될 수 있는 가장 작은 단위이다. Java에서는 jar 파일이 이에 해당한다.

 

컴포넌트는 단일 실행 파일로 연결되거나, .war 파일과 같은 단일 아카이브로 집합될 수 있으며, .jar, .dll, .exe 파일과 같이 별도로 독립적으로 동적으로 로드되는 플러그인으로 배포될 수도 있다.

결론

런타임에 플러그인 형태로 결합할 수 있는 동적 링크 파일이 이 책에서 말하는 소프트웨어 컴포넌트에 해당한다. 여기까지 오는 데 50년이 걸렸다. 과거에는 초인적인 노력을 들여야만 컴포넌트 플러그인 아키텍처를 적용할 수 있었지만 이제는 기본으로 쉽게 사용할 수 있는 지점까지 다다랐다.

컴포넌트 응집도

  • REP: 재사용/릴리스 등가 원칙
  • CCP: 공통 폐쇄 원칙
  • CRP: 공통 재사용 원칙

재사용/릴리즈 등가 원칙

추적 시스템을 통해 릴리즈된 컴포넌트만이 효과적으로 재사용될 수 있다. 개발자들은 새로운 릴리즈에 대해 알림을 받고 해당 릴리즈의 변경 사항을 기반으로 기존 릴리즈의 사용을 계속할지 결정한다. 함께 그룹화된 클래스와 모듈은 함께 릴리즈될 수 있어야 한다.

공통 폐쇄 원칙

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

같은 이유와 같은 시간에 변경되는 클래스를 컴포넌트로 모으고, 다른 시간과 다른 이유로 변경되는 클래스는 다른 컴포넌트로 분리해야 한다는 원칙이다. 이는 컴포넌트에 대해 재정의된 단일 책임 원칙(SRP)이다. SRP가 클래스가 변경되는 여러 이유를 포함하지 않아야 한다고 말하는 것처럼, CCP는 컴포넌트가 여러 이유로 변경되어서는 안 된다고 말한다.

공통 재사용 원칙

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

컴포넌트의 사용자가 필요하지 않은 것에 의존하도록 강제하지 않아야 한다는 원칙이다.

 

한 컴포넌트가 다른 컴포넌트를 사용할 때, 컴포넌트 간에 의존성이 생성된다. 이 의존성으로 인해 사용되는 컴포넌트가 변경될 때마다 사용하는 컴포넌트에도 해당 변경이 필요할 수 있다. 결론적으로, 필요하지 않은 것에 의존하지 않아야 한다.

컴포넌트 응집도에 대한 긴장 다이어그램

REP와 CCP는 컴포넌트를 더 크게 만드는 포괄적 원칙이다. 반면, CRP는 컴포넌트를 더 작게 만드는 배타적 원칙이다. 이러한 원칙들 사이의 긴장을 해결하는 것이 좋은 아키텍트의 목표이다.

 

REP와 CRP에만 중점을 두면, 사소한 변경이 생겼을 때 너무 많은 컴포넌트에 영향을 미친다. 반대로 CCP와 REP에만 과도하게 집중하면 불필요한 릴리스가 너무 빈번해진다.

 

프로젝트 초기에는 CCP가 REP 보다 훨씬 더 중요한데, 개발 가능성이 재사용성보다 더욱 중요하기 때문이다.

컴포넌트 결합

ADP 의존성 비순환 원칙

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

순환 끊기

  1. 의존성 역전 원칙을 적용한다.

그림처럼 User가 필요로 하는 메서드를 제공하는 인터페이스를 생성한다. 그리고 이 인터페이스는 Entities에 위치시키고 Authorize에서는 이 인터페이스를 상속받는다. 이렇게하면 Entities와 Authorizer 사이의 의존성을 역전시킬 수 있고, 이를 통해 순환을 끊을 수 있다.

  1. Entites와 Authorize가 모두 의존하는 새로운 컴포넌트를 만든다. 그리고 두 컴포넌트가 모두 의존하는 클래스들을 새로운 컴포넌트로 이동시킨다.

하향식 설계

컴포넌트 구조는 하향식으로 설계될 수 없다. 컴포넌트는 시스템에서 가장 먼저 설계할 수 있는 대상이 아니며, 오히려 시스템이 성장하고 변경될 때 함께 진화한다.

 

애플리케이션이 계속 성장함에 따라 우리는 재사용 가능한 요소를 만드는 일에 관심을 기울이기 시작한다.

 

아직 아무런 클래스도 설계하지 않은 상태에서 컴포넌트 의존성 구조를 설계하려고 시도한다면 상당히 큰 실패를 맛볼 수 있다. 공통 폐쇄에 대해 그다지 많이 파악하지 못하고 있고, 재사용 가능한 요소도 알지 못하며, 컴포넌트를 생성할 때 거의 확실히 순환 의존성이 발생할 것이다. 따라서 컴포넌트 의존성 구조는 시스템의 논리적 설계에 발맞춰 성장하며 또 진화해야 한다.

 

SDP: 안정된 의존성 원칙

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

세 컴포넌트가 X에 의존할 때, X 컴포넌트는 변경하지 말아야 할 이유가 세 가지나 되기 때문이다. 이 경우 X는 세 컴포넌트를 책임진다라고 말한다. 반대로 X는 어디에도 의존하지 않으므로 X가 변경되도록 만들 수 있는 외적인 영향이 전혀 없다. 이 경우 X는 독립적이다라고 말한다.

안정성 지표
  • fan-in: 안으로 들어오는 의존성.
  • fan-out: 바깥으로 나가는 의존성
  • 불안정성: I = Fan-out / (fan-in + fan-out). 이 지표는 [0,1] 범위의 값을 갖는다. I=0이면 최고로 안정된 컴포넌트라는 뜻이다. I=1이면 최고로 불안정한 컴포넌트라는 뜻이다.

SAP: 안정된 추상화 원칙

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

고수준 정책을 어디에 위치시켜야 하는가?

시스템에는 자주 변경해서는 절대로 안 되는 소프트웨어도 있다. 고수준 아키텍처나 정책 결정과 관련된 소프트웨어가 그 예다. 이처럼 업무 로직이나 아키텍처와 관련된 결정에는 변동성이 없기를 기대한다. 따라서 시스템에서 고수준 정책을 캡슐화하는 소프트웨어는 반드시 안정된 컴포넌트에 위치해야 한다. 불안정한 컴포넌트는 반드시 변동성이 큰 소프트웨어, 즉 쉽고 빠르게 변경할 수 있는 소프트웨어만을 포함해야 한다.

 

하지만 고수준 정책을 안정된 컴포넌트에 위치시키면, 그 정책을 포함하는 소스 코드는 수정하기가 어려워진다. 이로 인해 시스템 전체 아키텍처가 유연성을 잃는다. 컴포넌트가 최고로 안정된 상태이면서도 동시에 변경에 충분히 대응할 수 있을 정도로 유연하게 만들 수 있을까? 해답은 개방 폐쇄 원칙에서 찾을 수 있다. OCP에서는 클래스를 수정하지 않고도 확장이 충분히 가능할 정도로 클래스를 유연하게 만들 수 있을 뿐만 아니라 바람직한 방식이라고 말한다. 어떤 클래스가 이 원칙을 준수하는가? 바로 추상클래스다.

안정된 추상화 원칙

안정된 추상화 원칙은 안정성과 추상화 정도 사이의 관계를 정의한다.

아키텍처란 ?

소프트웨어 아키텍트는 프로그래머이며, 앞으로도 계속 프로그래머로 남는다. 소프트웨어 아키텍트라면 코드에서 탈피하여 고수준의 문제에 집중해야 한다는 거짓말에 절대로 속아 넘어가서는 안 된다. 소프트웨어 아키텍트는 코드와 동떨어져서는 안 된다. 소프트웨어 아키텍트는 최고의 프로그래머이며, 앞으로도 계속 프로그래밍 작업을 맡을 뿐만 아니라 동시에 나머지 팀원들이 생산성을 극대화할 수 있는 설계를 하도록 방향을 이끌어준다. 소프트웨어 아키텍트는 다른 프로그래머만큼 코드를 많이 작성하지 않을 수도 있지만, 프로그래밍 작업에는 지속적으로 참여한다. 프로그래밍 작업을 계속하는 이유는, 발생하는 문제를 경험해보지 않는다면 다른 프로그래머를 지원하는 작업을 제대로 수행할 수 없기 때문이다.

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

시스템 아키텍처는 시스템의 동작 여부와는 거의 관련이 없다. 형편없는 아키텍처를 갖춘 시스템도 수없이 많지만, 그런대로 잘 동작한다. 이러한 시스템들은 대체로 운영에서는 문제를 겪지 않는다. 운영보다는 배포, 유지보수, 계속되는 개발 과정에서 어려움을 겪는다.

개발

팀 구조가 다르다면 아키텍처 관련 결정에서도 차이가 난다. 일례로 팀이 개발자 다섯 명으로 구성될 정도로 작다면, 잘 정의된 컴포넌트나 인터페이스가 없더라도 서로 효율적으로 협력하여 모노리틱 시스템을 개발할 수 있다. 사실 이러한 팀이라면 개발 초기에는 아키텍처 관련 제약들이 오히려 방해가 된다고 여길 가능성이 높다.

 

다른 한편으로 일곱 명씩으로 구성된 총 다섯 팀이 시스템을 개발하고 있다면 시스템을 신뢰할 수 있고 안정된 인터페이스를 갖춘, 잘 설계된 컴포넌트 단위로 분리하지 않으면 개발이 진척되지 않는다.

결론

좋은 아키텍트는 세부사항에 대한 결정을 가능한 한 오랫동안 미룰 수 있는 방향으로 정책을 설계한다.

독립성

좋은 아키텍처는 다음을 지원해야 한다.

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

유스케이스

시스템의 아키텍처는 시스템의 의도를 지원해야 한다는 뜻이다.

운영

시스템이 초당 100,000명의 고객을 처리해야 한다면, 아키텍처는 이 요구와 관련된 각 유스케이스에 걸맞은 처리량과 응답시간을 보장해야 한다.

개발

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

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

배포

좋은 아키텍처는 수십 개의 작은 설정 스크립트나 속성 파일을 약간씩 수정하는 방식을 사용하지 않는다. 좋은 아키텍처라면 시스템이 빌드된 후 즉각 배포할 수 있도록 지원해야 한다.

선택사항 열어놓기

대부분의 경우 우리는 모든 유스케이를 알 수는 없으며, 운영하는 데 따르는 제약사항, 팀 구조, 배포 요구사항도 알지 못하기 때문이다. 요컨대, 우리가 도달하련느 목표는 뚜렷하지 않을 뿐만 아니라 시시각각 변한다.

 

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

 

좋은 아키텍처는 선택사항을 열어 둠으로써, 향후 시스템에 변경이 필요할 때 어떤 방향으로든 쉽게 변경할 수 있도록 한다.

계층 결합 분리

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

 

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

유스케이스 결합 분리

서로 다른 이유로 변경되는 것에는 또 무엇이 있을까? 바로 유스케이스 그 자체가 있다.

 

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

결합 분리 모드

유스케이스에서 서로 다른 관점이 분리되었다면, 높은 처리량을 보장해야 하는 유스케이스와 낮은 처리량으로도 충분한 유스케이스는 이미 분리되어 있을 가능성이 높다. UI와 데이터베이스가 업무 규칙과 분리되어 있다면, UI와 데이터베이스는 업무 규칙과는 다른 서버에서 실행될 수 있다. 높은 대역폭을 요구하는 유스케이는 여러 서버로 복제하여 실행할 수 있다.

 

실제로 서비스에 기반한 아키텍처를 흔히들 서비스 지향 아키텍처라고 부른다.

 

여기서 얘기하고자 하는 핵심은, 우리는 때떄로 컴포넌트를 서비스 수준까지도 분리해야 한다는 것이다.

중복

소프트웨어에서 중복은 일반적으로 나쁜 것이다. 우리는 중복된 코드를 좋아하지 않는다. 코드가 진짜로 중복되었다면, 우리는 전문가로서의 명예를 걸고 중복을 줄이거나 제거해야 한다.

 

하지만 중복에도 여러 종류가 있다. 그중 하나가 진짜 중복이다. 이 경우 한 인스턴스가 변경되면, 동일한 변경을 그 인스턴스의 모든 복사본에 반드시 적용해야 한다. 또 다른 중복은 거짓된 또는 우발적인 중복이다. 중복으로 보이는 두 코드 영역이 각자의 경로로 발전한다면, 즉 서로 다른 속도와 다른 이유로 변경된다면 이 코드는 진짜 중복이 아니다. 몇 년이 지나 다시 보면 두 코드가 매우 다르다는 사실을 알게 될 것이다.

 

예를 들어 두 유스케이스의 화면 구조가 매우 비슷하다고 가정해 보자. 아키텍트는 이 구조에 사용할 코드를 통합하고 싶은 유혹을 강하게 느낄 것이다. 하지만 정말 그래야 할까? 이는 진짜 중복일까? 아니면 우발적 중복일까?

 

우발적 중복일 가능성이 높다. 시간이 지나면서 두 화면은 서로 다른 방향으로 분기하며, 결국에는 매우 다른 모습을 가질 가능성이 높다.

경계: 선 긋기

소프트웨어 아키텍처는 선을 긋는 기술이며, 나는 이러한 선을 경계라고 부른다.

 

경계는 소프트웨어 요소를 서로 분리하고, 경계 한편에 있는 요소가 반대편에 있는 요소를 알지 못하도록 막는다.

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

관련이 있는 것과 없는 것 사이에 선을 긋는다. GUI는 업무 규칙과는 관련 ㅇ벗기 때문에, 이 둘 사이에는 반드시 선이 있어야 한다. 데이터베이스는 GUI와는 관련이 없으므로, 이 둘 사이에도 반드시 선이 있어야 한다. 데이터베이스는 업무 규칙과는 관련이 ㅇ벗으므로, 이 둘 사이에도 선이 있어야 한다.

 

BusinessRules는 Database Interface를 사용하여 데이터를 로드하고 저장한다. DatabaseAccess는 DatabaseInterface를 구현하며, Database를 실제로 조작하는 일을 맡는다.

플러그인 아키텍처

결론

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

경계 해부학

시스템 경계는 다양한 형태로 존재하며, 여기서는 경계를 넘는 것과 두려워하는 단일체에 대해 논의한다.

 

경계를 넘는 것은 단지 한 쪽 경계의 함수가 다른 쪽으로 데이터를 전달하면서 다른 함수를 호출하는 것을 의미한다. 적절한 경계 넘기를 생성하는 것은 소스 코드 의존성을 관리하는 것이다.

서비스

저수준 서비스는 반드시 고수준 서비스에 '플러그인'되어야 한다.

 

고수준 서비스의 소스 코드에는 저수준 서비스를 특정 짓은 어떤 물리적인 정보도 절대 포함해서는 안 된다.

정책과 수준

수준

'수준'을 엄밀하게 정의하자면 '입력과 출력까지의 거리'이다. 시스템의 입력과 출력 모두로부터 멀리 위치할수록 정책의 수준은 높아진다.

소스 코드 의존성은 그 수준에 따라 결합되어야 하며, 데이터 흐름을 기준으로 결합되어서는 안 된다.

 

잘못된 아키텍처 예시

function encrypt() { 
    while(true)
    writeChar(translate(readChar()));
}

이는 잘못된 아키텍처다. 고수준인 encrpt 함수가 저수준인 readChar()와 writeChart 함수에 의존하기 때문이다.

 

아래의 다이어그램은 이 시스템의 아키텍처를 개선해본 모습이다. 주목할 점은 Encrypt 클래스, charWrite와 CharReader 인터페이스를 둘러싸고 있는 점선으로 된 경계다. 이 경계를 횡단하는 의존성은 모두 경계 안쪽으로 향한다. 이 경계로 묶인 영역이 이 시스템에서 최고 수준의 구성요소다.

여기서 ConsoleReader와 ConsoleWriter는 클래스로 표현했다. 이들 클래스는 입력과 출력에 가깝기 때문에 저수준이다.

 

이 구조에서 고수준의 암호화 정책을 저수준의 입력/출력 정책으로부터 분리시킨 방식에 주목하자. 이 방식 덕분에 이 암호화 정책을 더 넓은 맥락에서 사용할 수 있다. 입력과 출력에 변화가 생기더라도 암호화 정책은 거의 영향을 받지 않기 때문이다.

 

이 논의는 저수준 컴포넌트가 고수준 컴포넌트에 플러그인되어야 한다는 관점으로 바라볼 수도 있다. 저수준 컴포넌트가 고수준 컴포넌트에 플러그인 된다면, Encryption 컴포넌트는 IO Devices 컴포넌트를 전혀 알지 못한다. 반면 IO Devices는 Encryption 컴포넌트에 의존적이다.

업무 규칙

엄밀하게 말하면 업무 규칙은 사업적으로 수익을 얻거나 비용을 줄일 수 있는 규칙 또는 절차다. 더 엄밀하게 말하면 컴퓨터상으로 구현했는지와 상관없이, 업무 규칙은 사업적으로 수익을 얻거나 비용을 줄일 수 있어야 한다.

엔티티

엔티티는 컴퓨터 시스템 내부의 객체로서, 핵심 업무 데이털르 기반으로 동작하는 일련의 조그만 핵심 업무 규칙을 구체화한다. 엔티티 객체는 핵심 업무 데이털르 직접 포함하거나 핵심 업무 데이터에 매우 쉽게 접근할 수 있다. 엔티티의 인터페이스는 핵심 업무 데이털르 기반으로 동작하는 핵심 업무 규칙을 구현한 함수들로 구성된다.

유스케이스

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

 

유스케이스는 엔티티 내부의 핵심 업무 규칙을 어떻게, 그리고 언제 호출할지를 명시하는 규칙을 담는다. 엔티티가 어떻게 춤을 출지를 유스케이스가 제어하는 것이다.

 

유스케이스만 봐서는 이 애플리케이션이 웹을 통해 전달되는지, 리치 클라이언트인지, 콘솔 기반인지, 아니면 순수한 서비스인지를 구분하기란 불가능하다.

 

이 점은 매우 중요하다. 유스케이스는 시스템이 사용자에게 어떻게 보이는지를 설명하지 않는다. 이보다는 애플리케이션에 특화된 규칙을 설명하며, 이를 통해 사용자와 엔티티 사이의 상호작용을 규정한다. 시스템에서 데이터가 들어오고 나가느 방식은 유스케이스와는 무관하다.

 

엔티티는 자신을 제어하는 유스케이스에 대해 아무것도 알지 못한다. 이는 의존성 역전 원칙을 준수하는 의존성 방향에 대한 또 다른 예다. 엔티티와 같은 고수준 개념은 유스케이스와 같은 저수준 개념에 대해 아무것도 알지 못한다. 반대로 저수준인 유스케이스는 고수준인 엔티티에 대해 알고 있다.

 

왜 엔티티는 고수준이며, 유스케이는 저수준일까? 왜냐하면 유스케이는 단일 애플리케이션에 특화되어 있으며, 따라서 해당 시스템의 입력과 출력에 보다 가깝게 위치하기 때문이다. 엔티티는 수많은 다양한 애플리케이션에서 사용될 수 있도록 일반화된 것이므로, 각 시스템의 입력이나 출력에서 더 멀리 떨어져 있다.

결론

업뮤 규칙은 핵심적인 기능이다. 업무 규칙은 사용자 인터페이스나 데이터베이스와 같은 저수준의 관심사로 인해 오염되어서는 안 되며, 원래 그대로의 모습으로 남아 있어야 한다. 업무 규칙은 시스템에서 가장 독립적이며 가장 많이 재사용할 수 있는 코드여야 한다.

소리치는 아키텍처

아키텍처는 프레임워크에 대한 것이 아니다.

아키텍처의 목적

좋은 아키텍처는 유스케이스를 그 중심에 두기 때문에, 프레임워크나 도구, 환경에 전혀 구애받지 않고 유스케이스를 지원하는 구조를 아무런 문제 없이 기술할 수 있다.

테스트하기 쉬운 아키텍처

테스트를 돌리는 데 웹서버가 반드시 필요한 상황이 되어서는 안 된다. 데이터베이스가 반드시 연결되어 있어야만 테스트를 돌릴 수 있어서도 안된다. 엔티티 객체는 반드시 오래된 방식의 간단한 객체여야 하며, 프레임워크나 데이터베이스, 또는 여타 복잡한 것들에 의존해서는 안 된다.

클린 아키텍처

여러가지 시스템 아키텍처

  • 육각형 아키텍처
  • DCI
  • BCE

이들의 목표는 모두 같은데, 바로 관심사의 분리다. 이들은 모두 소프트웨어를 계층으로 분리함으로써 관심사의 분리라는 목표를 달성할 수 있었다.

 

아키텍처는 모두 시스템이 다음과 같은 특징을 지니도록 만든다.

  • 프레임워크 독립성
  • 테스트 용이성
  • UI 독립성
  • 데이터베이스 독립성

의존성 규칙

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

엔티티

엔티티는 가장 일반적이며 고수준인 규칙을 캡슐화한다.

유스케이스

애플리케이션에 특화된 업무 규칙을 포함한다.

 

유스케이스 계층의 소프트웨어는 시스템의 모든 유스케이스를 캡슐화하고 구현한다. 유스케이스는 엔티티로 들어오고 나가는 데이터 흐름을 조정하며, 엔티티가 자신의 핵심 업무 규칙을 사용해서 유스케이스의 목적을 달성하도록 이끈다.

 

이 계층에서 발생한 변경이 엔티티에 영향을 줘서는 안 된다. 또한 데이터베이스, UI, 또는 여타 공통 프레임워크와 같은 외부 요소에서 발생한 변경이 이 계층에 영향을 줘서도 안 된다. 유스케이스 계층은 이러한 관심사로부터 격리되어 있다.

인터페이스 어댑터

인터페이스 어댑터 계층은 일련의 어댑터들로 구성된다. 어댑터는 데이터를 유스케이스와 엔티티에게 가장 편리한 형식에서 데이터베이스나 웹 강튼 외부 에이전시에게 가장 편리한 형식으로 변환한다. 이 계층은, 예를 들어 GUI의 MVC 아키텍처를 모두 포괄한다. 프레젠터, 뷰, 컨트롤러는 모두 인터페이스 어댑터 계층에 속한다.

프레임워크와 드라이버

가장 바깥쪽 계층은 일반적으로 데이터베이스나 웹 프레임워크 같은 프레임워크나 도구들로 구성된다. 일반적으로 이 계층에서는 안쪽 원과 통신하기 윟나 접합 코드 외에는 특별히 더 작성해야 할 코드가 그다지 많지 않다.

원은 네 개여야만 하나?

네 개 보다 더 많은 원이 필요할 수도 있다. 항상 네 개만 사용해야 한다는 규칙은 없다. 하지만 어떤 경우에도 의존성 규칙은 적용된다. 소스 코드 의존성은 항상 안쪽을 향한다. 안쪽으로 이동할수록 추상화와 정책의 수준은 높아진다.

경계 횡단하기

우측 하단의 다이어그램에 원의 경계를 횡단하는 방법을 보여주는 예시가 있다. 이 예시에서 컨트롤러와 프레젠터가 다음 계층에 속한 유스케이스와 통신하는 모습을 확인할 수 있다. 우선 제어흐름에 주목해 보자. 제어 흐름은 컨트롤러에서 시작해서, 유스케이스를 지난 후, 프레젠터에서 실핸되면서 마무리된다. 다음으로 소스 코드 의존성도 주목해서 보자. 각 의존성은 유스케이스를 향해 안쪽으로 가리킨다.

 

이처럼 제어흐름과 의존성의 방향이 명백히 반대여야 하는 경우, 대체로 의존성 역전 원칙을 사용하여 해결한다. 예를 들어 자바 같은 언어에서는 인터페이스와 상속 관계를 적절하게 배치함으로써, 제어흐름이 경계를 가로지르는 바로 그 지점에서 소스 코드 의존성을 제어흐름과는 반대가 되게 만들 수 있다.

 

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

 

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

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

기본적인 구조체나 간단한 데이터 전송 객체 등 원하는 대로 고를 수 있다.

전형적인 시나리오

아래 다이어그램은 데이터베이스를 사용하는 웹 기반 자바 시스템의 전형적인 시나리오를 보여준다. 웹 서버는 사용자로부터 입력 데이터를 모아서 좌측 상단의 컨트롤러로 전달한다. Controller는 데이터를 평범한 자바 객체로 묶은 후 InputBoundary 인터페이스를 통해 UseCaseInteractor로 전달한다. UsecaseInterfactor는 이 데이터를 해석해서 Entites가 어떻게 춤출지를 제어하는 데 사용한다.

 

또한 UseCaseInteractor는 DataAccessInterface를 사용하여 Entites가 사용할 데이털르 데이터베이스에서 불러와서 메모리로 로드한다. Entites가 완성되면, UseCaseInteractor는 Entities로부터 데이터를 모아서 또 다른 평범한 자바 객체인 OutputData를 구성한다. 그러고나서 OutputData는 OutputBoundary 인터페이스를 통해 Presenter로 전달된다.

 

Presenter가 맡은 역할은 OutputData를 ViewModel과 같이 화면에 출력할 수 있는 형식으로 재구성하는 일이다. ViewModel 또한 평범한 자바 객체다. ViewModel은 주로 문자열과 플래그로 구성되며, View에서는 이 데이터를 화면에 출력한다. OutputData에서는 date 객체를 포함할 수 있는 반면, Presenter는 ViewModel을 로드할 때 Date 객체를 사용자가 보기에 적절한 형식의 문자열로 변환한다. 이 변환흔 Currency 객체나 다른 업무 관련 데이터 모두에 똑같이 적용된다. Button과 MenuItem의 이름은 ViewModel에 위치하며, 해당 Button과 MenuItem을 비활성화할지를 알려주는 플래그 또한 viewModel에 위치한다.

 

의존성의 방향에 주목하라. 모든 의존성은 경계선을 안쪽으로 가로지르며, 따라서 의존성 규칙을 준수한다.

 

 

반응형