책을 읽기 전 어떠한 마음을 가지고 책을 바라볼 것인지 생각해보면 좋다고 생각합니다. 나에게 익숙하고 개인적으로 선호하는 패러다임에 갇혀 생각하기보다, 책의 저자가 무슨 말을 하고 싶은지 생각하면서 읽다보면 내가 놓치고 있던 부분을 깨닫기도 하고 말하고자 하는 것이 무엇인지 핵심을 좀 더 잘 파악할 수 있습니다. 최근에 함수형 프로그래밍이 인기가 많고 나에게 익숙하다고 해서 구조적 프로그래밍을 몰라도 되는것이 아닙니다. 저자는 왜 구조적 프로그래밍을 소개하고 있고, 내가 관심없더라도 왜 알아야 하는지 고민하면서 읽어보면 좋을것 같아요.

Clean Architecture

3장. 패러다임 개요

구조적 프로그래밍

무분별한 점프(goto 문장)는 프로그램 구조에 해롭다는 사실을 제시했고, 이러한 점프들을 if/then/else와 do/while/until과 같이 더 익숙한 구조로 대체했다.

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

객체 지향 프로그래밍

언어의 함수 호출 스택 프레임 stack frame 을 힙 heap 으로 옮기면, 함수 호출이 반환된 이후에도 함수에서 선언된 지역 변수가 오랫동안 유지될 수 있음을 발견.

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

함수형 프로그래밍

어떤 수학적 문제를 해결하는 과정에서 람다 lambda 계산법을 발명. 람다 계산법의 기초가 되는 개념은 불변성 immutability 으로, 심볼 symbol 의 값이 변경되지 않는다는 개념.

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


4장. 구조적 프로그래밍

데이크스트라는 goto문의 '좋은' 사용 방식은 if/then/else와 do/while과 같은 분기와 반복이라는 단순한 제어 구조에 해당한다는 사실을 발견했다. 그리고 모든 프로그램을 순차 sequence, 분기 selection, 반복 iteration 이라는 세 가지 구조만으로 표현할 수 있다는 사실을 증명했다. 즉, 모듈을 증명 가능하게 하는 바로 그 제어 구조가 모든 프로그램을 만들 수 있는 제어 구조의 최소 집합과 동일하다는 구조적 프로그래밍이 탄생했다.

현재의 우리 모두는 구조적 프로그래머이며, 여기에는 선택의 여지가 없다. 제어흐름을 제약 없이 직접 전환할 수 있는 선택권 자체를 언어에서 제공하지 않기 때문이다. 구조적 프로그래밍을 통해 모듈을 증명 가능한 더 작은 단위로 재귀적으로 분해할 수 있게 되었고, 이는 결국 모듈을 기능적으로 분해할 수 있음을 뜻한다.

구조적 프로그래밍이 오늘날까지 가치 있는 이유는 프로그래밍에서 반증 가능한 단위를 만들어 낼 수 있는 바로 이 능력 때문이다. 소프트웨어 아키텍트는 모듈, 컴포넌트, 서비스가 쉽게 반증 가능하도록(테스트하기 쉽도록) 만들기 위해 분주히 노력해야 한다. 이를 위해 구조적 프로그래밍과 유사한 제한적인 규칙들을 받아들여 활용해야 한다.


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

캡슐화?

OO Object-Oriented 를 정의하는 요소 중 하나로 캡슐화를 언급하는 이유는 데이터와 함수를 쉽고 효과적으로 캡슐화하는 방법을 OO 언어가 제공하기 때문이다. 그리고 이를 통해 데이터와 함수가 응집력 있게 구성된 집단을 서로 구분 짓는 선을 그을 수 있다. 구분선 바깥에서 데이터는 은닉되고, 일부 함수만이 외부에 노출된다.

// 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 구조체의 데이터 구조와 함수가 어떻게 구현되었는지에 대해서는 조금도 알지 못한다.

이것이 바로 완벽한 캡슐화이며, OO가 아닌 언어에서도 충분히 캡슐화가 가능하다. 이 때문에 OO가 강력한 캡슐화에 의존한다는 정의는 받아들이기 힘들고, 실제로 많은 OO 언어가 캡슐화를 거의 강제하지 않는다.

상속?

OO 언어가 더 나은 캡슐화를 제공하지는 못했지만, 상속만큼은 OO 언어가 확실히 제공했다. 상속이란 단순히 어떤 변수와 함수를 하나의 유효 범위로 묶어서 재정의하는 일에 불과하다. OO 언어가 고안되기 훨씬 이전에도 상속과 비슷한 기법이 사용되었지만, 사실상 상속만큼 편리한 방식은 절대 아니고 다중 상속을 구현하기란 훨씬 더 어려운 일이었다.

다형성?

OO 언어는 다형성을 제공하지는 못했지만, 다형성을 좀 더 안전하고 더욱 편리하게 사용할 수 있게 해준다. 함수에 대한 포인터를 직접 사용하여 다형적 행위를 만드는 관례를 없애주어 실수할 위험이 없어졌다.

다형성이 가진 힘

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

아무런 변경도 필요치 않다! 심지어 복사 프로그램을 다시 컴파일할 필요조차 없다. 복사 프로그램의 소스 코드는 입출력 드라이버의 소스코드에 의존하지 않기 때문이다. 입출력 드라이버가 FILE에 정의된 다섯 가지 표준 함수를 구현한다면, 복사 프로그램에서는 이 입출력 드라이버를 얼마든지 사용할 수 있다. 플러그인 아키텍처 plugin architecture 는 이처럼 입출력 장치 독립성을 지원하기 위해 만들어졌고, 등장 이후 거의 모든 운영체제에서 구현되었다.

의존성 역전

전형적인 호출 트리거의 경우 main 함수가 고수준 함수를 호출하고, 고수준 함수는 다시 중간 수준 함수를 호출하며, 중간 수준 함수는 다시 저수준 함수를 호출한다. 이러한 호출 트리에서 소스 코드 의존성의 방향은 반드시 제어흐름 flow of control 을 따르게 된다. 제어흐름은 시스템의 행위에 따라 결정되며, 소스 코드 의존성은 제어흐름에 따라 결정된다.

소스 코드 의존성 vs. 제어흐름

그림 5.2에서 HL1 모듈은 ML1 모듈의 F() 함수를 호출한다. 소스 코드에서는 HL1 모듈은 인터페이스를 통해 F() 함수를 호출한다. 이 인터페이스는 런타임에는 존재하지 않는다. HL1은 단순히 ML1 모듈의 함수 F()를 호출할 뿐이다. ML1과 I 인터페이스 사이의 소스 코드 의존성(상속 관계)이 제어흐름과는 반대인데, 이를 의존성 역전 dependency inversion 이라고 부른다.

의존성 역전

OO 언어가 다형성을 안전하고 편리하게 제공한다는 사실은 소스 코드 의존성을 어디에서든 역전시킬 수 있다 는 뜻이기도 하다. 그림 5.1의 소스 코드 의존성은 소스 코드 사이에 인터페이스를 추가함으로써 방향을 역전시킬 수 있다. OO 언어로 개발된 시스템을 다루는 소프트웨어 아키텍트는 시스템의 소스 코드 의존성 전부에 대해 방향을 결정할 수 있는 절대적인 권한 을 갖는다. 즉, 소스 코드 의존성이 제어흐름의 방향과 일치되도록 제한되지 않는다.

데이터베이스와 사용자 인터페이스가 업무 규칙에 의존한다.

업무 규칙이 DB와 UI에 의존하는 대신에, 시스템의 소스 코드 의존성을 반대로 배치하여 DB와 UI가 업무 규칙에 의존하게 만들 수 있다. 즉, UI와 DB가 업무 규칙의 플러그인이 된다는 뜻이다. 다시 말해 업무 규칙의 소스 코드에서는 UI나 DB를 호출하지 않는다. 특정 컴포넌트의 소스 코드가 변경되면, 해당 코드가 포함된 컴포넌트만 다시 배포하면 된다. 이것이 바로 배포 독립성 independent deployability 이다. 시스템의 모듈을 독립적으로 배포할 수 있으면 서로 다른 팀에서 각 모듈을 독립적으로 개발할 수 있다. 그리고 이것이 개발 독립성 independent developability 이다.

결론

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


6장. 함수형 프로그래밍

시스템에서 경합 race 조건, 교착상태 deadlock 조건, 동시 업데이트 concurrent update 문제가 모두 가변 변수로 인해 발생한다. 만약 어떠한 변수도 갱신되지 않는다면 경합 조건이나 동시 업데이트 문제가 일어나지 않는다. 락 lock 이 가변적이지 않다면 교착상대도 일어나지 않는다. 그렇다면 불변성이 정말로 실현 가능한지를 스스로에게 물어봐야 한다.

가변성의 분리

불변 컴포넌트에서는 순수하게 함수형 방식으로만 작업이 처리되며, 어떤 가변 변수도 사용되지 않는다. 불변 컴포넌트는 변수의 상태를 변경할 수 있는, 즉 순수 함수형 컴포넌트가 아닌 하나 이상의 다른 컴포넌트와 서로 통신한다.

상태 변경과 트랜잭션 메모리(transactional memory)

상태 변경은 컴포넌트를 갖가지 동시성 문제에 노출하는 꼴이므로, 흔히 트랜잭션 메모리 transactional memory 와 같은 실천법을 사용하여 동시 업데이트와 경합 조건 문제로부터 가변 변수를 보호한다.

이벤트 소싱

이벤트 소싱은 상태가 아닌 트랜잭션을 저장하자는 전략이다. 상태가 필요해지면 단순히 상태의 시작점부터 모든 트랜잭션을 처리한다. 중요한 점은 데이터 저장소에서 삭제되거나 변경되는 것이 하나도 없다는 사실이다. 결과적으로 애플리케이션은 CRUD가 아니라 그저 CR만 수행한다. 또한 데이터 저장소에서 변경과 삭제가 전혀 발생하지 않으므로 동시 업데이트 문제 또한 일어나지 않는다.

저장 공간과 처리 능력이 충분하면 애플리케이션이 완전한 불변성을 갖도록 만들 수 있고, 따라서 완전한 함수형 으로 만들 수 있다.

결론

  • 구조적 프로그래밍은 제어흐름의 직접적인 전환에 부과되는 규율이다.
  • 객체 지향 프로그래밍은 제어흐름의 간접적인 전환에 부과되는 규율이다.
  • 함수형 프로그래밍은 변수 할당에 부과되는 규율이다.

어떤 패러다임도 우리의 권한이나 능력에 무언가를 보태지는 않는다. 우리가 배운 것은 해서는 안 되는 것 에 대해서다.

도구는 달라졌고 하드웨어도 변했지만, 소프트웨어의 핵심은 여전히 그대로다. 소프트웨어, 즉 컴퓨터 프로그램은 순차 sequence, 분기 selection, 반복 iteration, 참조 indirection 로 구성된다. 그 이상도 이하도 아니다.


7장. SRP: 단일 책임 원칙

하나의 모듈(소스 파일)은 하나의, 오직 하나의 액터(변경을 요청하는 집단)에 대해서만 책임져야 한다.

징후 1: 우발적 중복

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

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

Employee 클래스

예를 들어 calculatePay() 메서드와 reportHours() 메서드가 초과 근무를 제외한 업무 시간을 계산하는 알고리즘을 공유ㅠ한다고 해보자. 그리고 개발자는 코드 중복을 피하기 위해 이 알고리즘을 regularHours()라는 메서드에 넣었다고 해보자.

공유된 알고리즘

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

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

징후 2: 병합

DBA가 속한 CTO 팀에서 데이터베이스의 Employee 테이블 스키마를 약간 수정하기로 했고, 동시에 인사 담당자가 속한 COO 팀에서 reportHours() 메서드의 보고서 포맷을 변경하기로 결정했다고 해보자. 두 명의 서로 다른 개발자가 Employee 클래스를 체크아웃 받은 후 변경사항을 적용하지만, 이들 변경사항은 서로 충돌한다. 결과적으로 병합이 발생한다.

이 문제를 벗어나는 방법은 서로 다른 액터를 뒷받침하는 코드를 서로 분리하는 것이다.

해결책

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

세 클래스는 서로의 존재를 알지 못한다.

반면 이 해결책은 개발자가 세 가지 클래스를 인스턴스화하고 추적해야 한다는게 단점이다. 이는 퍼사드 Facade 패턴으로 해결하면 된다.

퍼사드(Facade) 패턴

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

결론

단일 책임 원칙은 메서드와 클래스 수준의 원칙이다. 하지만 이보다 상위의 두 수준에서도 다른 형태로 다시 등장한다. 컴포넌트 수준에서는 공통 폐쇄 원칙 Common Closure Principle 이 된다.


8장. OCP: 개방-폐쇄 원칙

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

재무제표를 웹 페이지로 보여주는 시스템이 있다고 해보자. 이해관계자가 동일한 정보를 보고서 형태로 변환해서 흑백 프린터로 출력해 달라고 요청했다고 해보자. 당연히 새로운 코드를 작성해야 하지만, 원래 코드는 얼마나 수정해야 할까?

서로 다른 목적으로 변경되는 요소를 적절하게 분리하고(단일 책임 원칙 SRP), 이들 요소 사이의 의존성을 체계화함으로써(의존성 역전 원칙 DIP) 변경량을 최소화할 수 있다.

단일 책임 원칙 SRP 을 적용하면 데이터 흐름을 아래와 같은 형태로 만들 수 있다. 재무 데이터를 검사한 후 보고서용 데이터를 생성한 다음, 필요에 따라 두 가지 보고서 생성 절차 중 하나를 거쳐 적절히 포매팅한다.

SRP 적용하기

여기서 중요한 것은 보고서 생성이 두 개의 책임으로 분리된다는 사실이다.

  1. 보고서용 데이터를 계산하는 책임
  2. 웹으로 보여주거나 종이로 프린트하기에 적합한 형태로 표현하는 책임

두 책임 중 하나에서 변경이 발생하더라도 다른 하나는 변경되지 않도록 소스코드 의존성도 확실히 조직화해야 한다. 또한, 새로 조직화한 구조에서는 행위가 확장될 때 변경이 발생하지 않음을 보장해야 한다. 이러한 목적을 달성하려면 처리 과정을 클래스 단위로 분할하고, 이들 클래스를 아래와 같이 이중선으로 표시한 컴포넌트 단위로 구분해야 한다.

처리 과정을 클래스 단위로 분할하고, 클래스는 컴포넌트 단위로 분리한다.

화살표가 열려 있다면 사용 using 관계이며, 닫혀 있다면 구현 implement 관계 또는 상속 inheritance 관계다. 여기서 주목할 점 두 가지가 있다.

  1. 모든 의존성이 소스 코드 의존성을 나타낸다.
  2. 이중선은 화살표와 오직 한 방향으로만 교차한다.

이는 그림 8.3에서 보듯, 모든 컴포넌트 관계는 단 방향으로 이루어진다는 뜻이다. 이들 화살표는 변경으로부터 보호하려는 컴포넌트를 향하도록 그려진다.

컴포넌트 관계는 단방향으로만 이루어진다.

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

Database, Controller, Presenter, View에서 발생한 어떤 변경도 Interactor에 영향을 주지 않는다. Interactor는 업무 규칙을 포함하기 때문에 특별한 위치를 차지해야만 한다. Interactor는 애플리케이션에서 가장 높은 수준의 정책을 포함한다.

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

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

방향성 제어

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

정보 은닉

FinancialReportRequester 인터페이스는 FinancialReportController가 Interactor 내부에 대해 너무 많이 알지 못하도록 막기 위해서 존재한다. 만약 이 인터페이스가 없었다면, Controller는 FinancialEntities에 대해 추이 종속성 transitive dependency 을 가지게 된다.

추이 종속성을 가지게 되면, 소프트웨어 엔티티는 '자신이 직접 사용하지 않는 요소에는 절대로 의존해서는 안 된다'는 소프트웨어 원칙을 위반하게 된다.

Controller에서 발생한 변경으로부터 Interactor를 보호하는 일의 우선순위가 가장 높지만, 반대로 Interactor에서 발생한 변경으로부터 Controller도 보호되기를 바란다. 이를 위해 Interactor 내부를 은닉한다.

결론

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


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

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

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

License라는 클래스는 calcFee()라는 메서드를 가지며, Billing 애플리케이션에서 이 메서드를 호출한다. License에는 PersonalLicense와 BusinessLicense라는 두 가지 '하위 타입'이 존재한다.

License와 파생 클래스는 LSP를 준수한다.

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

LSP와 아키텍처

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

여기서 말하는 인터페이스는 다양한 형태로 나타난다. 자바스러운 언어라면 인터페이스 하나와 이를 구현하는 여러 개의 클래스로 구성된다. 루비라면 동일한 메서드 시그니처를 공유하는 여러 개의 클래스로 구성된다. 또는 동일한 REST 인터페이스에 응답하는 서비스 집단일 수도 있다.

아키텍처 관점에서 LSP를 이해하는 최선의 방법은 이 원칙을 어겼을 때 시스템 아키텍처에서 무슨 일이 일어나는지 관찰하는 것이다.

결론

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


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

인터페이스 분리 원칙

다수의 사용자가 OPS 클래스의 오퍼레이션을 사용한다. User1은 오직 op1, User2는 op2만을, User3는 op3만을 사용한다고 가정해 보자. 이 경우 User1은 op2와 op3을 전혀 사용하지 않음에도 User1의 소스 코드는 이 두 메서드에 의존하게 된다. 이러한 의존성으로 인해 OPS 클래스에서 op2의 소스 코드가 변경되면 User1도 다시 컴파일한 후 새로 배포해야 한다.

이러한 문제는 그림 10.2에서 보는 것처럼 오퍼레이션을 인터페이스 단위로 분리하여 해결할 수 있다.

분리된 오퍼레이션

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

IPS와 언어

정적 타입 언어는 사용자가 import, use 또는 include와 같은 타입 선언문을 사용하도록 강제한다. 이처럼 소스코드에 포함된 include 선언문으로 인해 소스 코드 의존서이 발생하고, 이로 인해 재컴파일 또는 재배포가 강제되는 상황이 무조건 초래된다.

루비나 파이썬 같은 동적 타입 언어에서는 소스 코드에 이러한 선언문이 존재하지 않는다. 대신 런타임에 추론이 발생한다. 따라서 소스 코드 의존성이 아예 없으며, 결국 재컴파일과 재배포가 필요없다. 동적 타입 언어를 사용하면 정적 타입 언어를 사용할 때보다 유연하며 결합도가 낮은 시스템을 만들 수 있는 이유는 바로 이 때문이다.

ISP와 아키텍처

필요 이상으로 많은 걸 포함하는 모듈에 의존하는 것은 해로운 일이다. 소스 코드 의존성의 경우 이는 분명한 사실인데, 불필요한 재컴파일과 재배포를 강제하기 때문이다. 하지만 더 고수준인 아키텍처 수준에서도 마찬가지 상황이 발생한다.

S 시스텐 구축에 F라는 프레임워크를 시스템에 도입하기를 원한다. 그리고 F 프레임워크 개발자는 특정한 D 데이터베이스를 사용하도록 만들었다고 가정 해보자. 따라서 S는 F에 의존하며, F는 다시 D에 의존하게 된다.

문제가 있는 아키텍처


11장. DIP: 의존성 역전 원칙

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

자바와 같은 정적 타입 언어에서 이 말은 use, import, include 구문은 오직 인터페이스나 추상 클래스 같은 추상적인 선언만을 참조해야 한다는 뜻이다. 구체적인 대상에는 절대로 의존해서는 안 된다.

우리가 의존하지 않도록 피하고자 하는 것은 바로 변동성이 큰 volotile 구체적인 요소다. 그리고 이 구체적인 요소는 우리가 열심히 개발하는 중이라 자주 변경될 수밖에 없는 모듈들이다.

안정된 추상화

인터페이스를 변경하지 않고도 구현체에 기능을 추가할 수 있는 방법을 찾기 위해 노력해야 한다. 이는 소프트웨어 설계의 기본이다.

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

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

팩토리

변동성이 큰 구체적인 객체는 특별히 주의해서 생성해야 한다. 자바 등 대다수의 객체 지향 언어에서 이처럼 바람직하지 못한 의존성을 처리할 때 추상 팩토리를 사용하곤 한다.

의존성을 관리하기 위해 추상 팩토리 패턴을 사용한다.

그림 11.1의 곡선은 아키텍처 곡선을 뜻한다. 이 곡선은 구체적인 것들로부터 추상적인 것들을 분리한다. 소스 코드 의존성은 해당 곡선과 교차할 때 모두 한 방향, 즉 추상적인 쪽으로 향한다. 곡선은 시스템을 추상 컴포넌트와 구체 컴포넌트로 분리한다. 추상 컴포넌트는 애플리케이션의 모든 고수준 업무 규칙을 포함하고, 구체 컴포넌트는 업무 규칙을다루기 위해 필요한 모든 세부사항을 포함한다.

제어흐름은 소스 코드 의존성과는 정반대 방향으로 곡선을 가로지른다는 점에 주목하자. 다시 말해 소스 코드 의존성은 제어흐름과는 반대 방향으로 역전된다. 이러한 이유로 이 원칙을 의존성 역전 Dependency Injection 이라고 부른다.

구체 컴포넌트

그림 11.1에서 ServiceFactoryImpl 구체 클래스는 ConcreteImpl 구체 클래스에 의존한다. 구체 클래스에 의존하지 말라는 DIP 위배가 존재하지만, DIP 위배를 모두 없앨 수는 없다. 하지만 DIP를 위배하는 클래스들은 적은 수의 구체 컴포넌트 내부로 모을 수 있고, 이를 통해 시스템의 나버지 부분과는 분리할 수 있다.


12장. 컴포넌트

컴포넌트는 배포 단위다. 컴포넌트가 마지막에 어떤 형태로 배포되든, 잘 설계된 컴포넌트라면 반드시 독립적으로 배포 가능한, 따라서 독립적으로 개발 가능한 능력을 갖춰야 한다.

컴포넌트의 간략한 역사

소프트웨어 개발 초창기에는 프로그래머가 함수 라이브러리에 더 많은 함수를 추가하면 이 역시 할당된 메모리 주소를 넘어서게 되고, 결국 추가 공간을 할당해야 한다. 프로그램과 라이브러리가 사용하는 메모리가 늘어날수록 이와 같은 단편화는 계속될 수 밖에 없었다.

애플리케이션을 두 개의 주소 세그먼트로 분리

재배치성

해결책은 재배치가능한 바이너리 relocatable binary 였다. 지능적인 로더를 사용해서 메모리에 재배치할 수 있는 형태의 바이너리를 생성하도록 컴파일러를 수정하자는 것이었다. 또한 컴파일러는 재배치 가능한 바이너리 안의 함수 이름을 메타데이터 형태로 생성하도록 수정되었다. 만약 프로그램이 라이브러리 함수를 호출한다면 컴파일러는 라이브러리 함수 이름을 외부 참조 external reference 로 생성한다. 반면 라이브러리 함수를 정의하는 프로그램이라면 컴파일러는 해덩 이름을 외부 정의 external definition 로 생성했다. 이렇게 링킹 로더 linking loader 가 탄생했다.

링커

링킹 로더는 프로그램을 개별적으로 컴파일하고 로드할 수 있는 단위로 분할할 수 있게 해주었지만, 너무 느렸다. 마침내 로드와 링크가 두 단계로 분리되었고, 링커는 링크가 완료된 재배치 코드를 만들어 주어 로더의 로딩 과정을 아주 빠르게 만들었다.


13장. 컴포넌트 응집도

REP: 재사용/릴리스 등가 원칙(Reuse/Release Equivalence Principle)

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

새로운 릴리스가 나온다는 소식을 접하면, 개발자는 새 릴리스의 변경 사항을 살펴보고 기존 버전을 계속 쓸지 여부를 결정하곤 한다.

소프트웨어 설계와 아키텍처 관점에서 보면 단일 컴포넌트는 응집성 높은 클래스와 모듈들로 구성되어야 함을 뜻한다. 하나의 컴포넌트로 묶인 클래스와 모듈은 반드시 함께 릴리스 할 수 있어야 한다. 하나의 컴포넌트로 묶인 클래스와 모듈은 버전 번호가 같아야 하며, 동일한 릴리스로 추적 관리되고, 동일한 릴리스 문서에 포함되어야 한다.

CCP: 공통 폐쇄 원칙(Common Closure Principle)

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

SRP에서 단일 클래스는 변경의 이유가 여러 개 있어서는 안 된다고 말하듯이, 공통 폐쇄 원칙 CCP 에서도 마찬가지로 단일 컴포넌트 component 는 변경의 이유가 여러 개 있어서는 안 된다고 말한다.

애플리케이션에서 유지보수성 maintainability 은 재사용성보다 훨씬 중요하다. 만약 변경을 단일 컴포넌트로 제한할 수 있다면, 해당 컴포넌트만 재배포하면 된다. 변경된 컴포넌트에 의존하지 않는 다른 컴포넌트는 다시 검증하거나 배포할 필요가 없다.

CCP는 같은 이유로 변경될 가능성이 있는 클래스는 모두 한 곳으로 묶을 것을 권한다. CCP에서는 동일한 유형의 변경에 대해 닫혀 있는 클래스들을 하나의 컴포넌트로 묶음으로써 OCP에서 얻은 교훈을 확대 적용한다.

SRP와의 유사성
앞서 언급했듯이, CCP는 컴포넌트 수준의 SRP다. SRP에서는 서로 다른 이유로 변경되는 메서드를 서로 다른 클래스로 분리하라고 말한다. CCP에서는 서로 다른 이유로 변경되는 클래스를 서로 다른 컴포넌트로 분리하라고 말한다.

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

CRP: 공통 재사용 원칙(Common Reuse Principle)

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

CRP에서는 같이 재사용되는 경향이 있는 클래스와 모듈들은 같은 컴포넌트에 포함해야 한다고 말한다. 개별 클래스가 단독으로 재사용되는 경우는 거의 없다. 대체로 재사용 가능한 클래스는 재사용 모듈을의 일부로써 해당 모듈의 다른 클래스와 상호작용하는 경우가 많다. CRP에서는 이런 클래스들이 동일한 컴포넌트에 포함되어야 한다고 말한다.

어떤 컴포넌트가 다른 컴포넌트를 사용하면, 두 컴포넌트 사이에는 의존성이 생겨난다. 어쩌면 사용하는 using 컴포넌트가 사용되는 used 컴포넌트에서 단 하나의 클래스만 사용할 수도 있다. 그렇다고 해서 의존성은 조금도 약해지지 않는다. 사용하는 컴포넌트는 사용되는 컴포넌트에 여전히 의존하다. 이 같은 의존성으로 인해 사용되는 컴포넌트가 변경될 때마다 사용하는 컴포넌트도 변경해야 할 가능성이 높다. 따라서 의존하는 컴포넌트가 있다면 해당 컴포넌트의 모든 클래스에 대해 의존함을 확실히 인지해야 한다.

ISP와의 관계
ISP는 사용하지 않은 메서드가 있는 클래스에 의존하지 말라고 조언한다. CRP는 사용하지 않는 클래스를 가진 컴포넌트에 의존하지 말라고 조언한다.

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

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

REP와 CCP는 컴포넌트를 더욱 크게 만드는 포함 inclusive 원칙이다. CRP는 컴포넌트를 작게 만드는 배제 exclusive 원칙이다. 다이어그램의 각 변 edge 은 반대쪽 꼭지점에 있는 원칙을 포기했을 때 감수해야 할 비용을 나타낸다.

결합도 원칙들의 균형 다이어그램

REP와 CRP에만 중점을 두면, 사소한 변경이 생겼을 때 너무 많은 컴포넌트에 영향을 미친다. 반대로 CCP와 REP에만 과도하게 집중하면 불필요한 릴리스가 너무 빈번해진다. 프로젝트 초기에는 CCP가 REP보다 훨씬 더 중요한데, 개발 가능성 developability 이 재사용성보다 더욱 중요하기 때문이다.


14장. 컴포넌트 결합

ADP: 의존성 비순환 원칙

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

많은 개발자가 동일한 소스 파일을 수정하는 환경에서 빌드는 커녕 개발팀 모두가 누군가가 마지막으로 수정한 코드 때문에 망가진 부분이 동작하도록 만들기 위해 코드를 수정하고 또 수정하는 작업이 계속된다.

주단위 빌드(Weekly Build)
중간 규모의 프로젝트에서 흔하게 사용한다. 일주일의 첫 4일 동안은 서로 신경 쓰지 않고 개발하다가, 금요일이 되면 변경된 코드를 모두 통합하여 시스템을 빌드한다. 하지만 프로젝트가 커지면 금요일 하루 만에 끝마치는 게 불가능하다. 효율성을 유지하기 위해 빌드 일정을 계속 늘려야 하고, 빌드 주기가 늦어질수록 프로젝트가 감수할 위험은 커진다. 통합과 테스트를 수행하기가 점점 더 어려워지고, 팀은 빠른 피드백이 주는 장점을 잃는다.

순환 의존성 제거하기
개발 환경을 릴리스 가능한 컴포넌트 단위로 분리하면, 컴포넌트는 개별 개발자 또는 단일 개발팀이 책임질 수 있는 작업 단위가 된다. 각 팀은 특정 컴포넌트가 새롭게 릴리스되면 자신의 컴포넌트를 해당 컴포넌트에 맞게 수정할 시기를 스스로 결정할 수 있다. 이 같은 작업 절차는 단순하면서 합리적이어서 널리 사용되지만, 성공적으로 동작하려면 컴포넌트 사이의 의존성 구조를 반드시 관리해야 한다.

전형적인 컴포넌트 다이어그램

그림 14.1을 보면 어느 컴포넌트에서 시작하더라도, 의존성 관계를 따라가면서 최초의 컴포넌트로 돌아갈 수 없다. 이 구조는 순환이 없는 비순환 방향 그래프 Directed Acyclic Graph, DAG 이다.

  • Main은 새로 릴리스되더라도 시스템에서 이로 인해 영향받는 컴포넌트가 전혀 없다.
  • Presenters 컴포넌트 개발자가 이 컴포넌트를 테스트하고자 한다면, 단순히 현재 사용 중인 버전의 Interactors와 Entities를 이용해서 자체 버전을 빌드하면 그만이다.
  • 전체 시스템 릴리스는 상향식으로 진행된다. Entities -> Database, Interactors -> Presenters, View, Controllers, Authorizer -> Main 순으로 진행한다.

순환이 컴포넌트 의존성 그래프에 미치는 영향
예를 들어 Entities의 User 클래스가 Authorizer의 Permissions 클래스를 사용한다고 해보자. 이렇게 되면 그림 14.2처럼 순환 의존성 dependency cycle 이 발생한다. 이제 Database 컴포넌트를 새로 실리스하려면 Entities 뿐만 아니라 Authorizer와도 호환되어야 한다. 그런데 Authorizer는 Interactors에 의존한다. 이로 인해 Entities, Authorizer, Interactors는 사실상 하나의 거대한 컴포넌트가 되어 버린다.

순환 의존성

의존성 그래프에 순환이 생기면 하나의 간단한 단위 테스트를 실행에도 많고 다양한 라이브러리와 다른 사람들의 많은 작업물을 포함된다. 그리고 순환이 생기면 컴포넌트를 분리하기가 상당히 어려워진다.

순환 끊기

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

Entities와 Authorizer 사이의 의존성을 역전시킨다.

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

Entities와 Authorizer 모두 의존하는 새로운 컴포넌트

흐트러짐(Jitters)
두번째 해결책에서 시사하는 바는 요구사항이 변경되면 컴포넌트 구조도 변경될 수 있다는 사실이다. 따라서 의존성 구조에 순환이 발생하는지를 항상 관찰해야 한다.

하향식(top-down) 설계

  • 컴포넌트 구조는 하향식으로 설계될 수 없다. 컴포넌트는 시스템에서 가장 먼저 설계할 수 있는 대상이 아니며, 오히려 시스템이 성장하고 변경될 때 함께 진화한다.
  • 컴포넌트 의존성 다이어그램은 애플리케이션의 빌드 가능성 buildability유지보수성 maintainability 을 보여주는 지도와 같다.
  • 의존성 구조와 관련된 최우선 관심사는 변동성을 격리하는 일이다. 결국 컴포넌트 의존성 그래프는 자주 변경되는 컴포넌트로부터 안정적이며 가치가 높은 컴포넌트를 보호하는 방향으로 가다듬게 된다.

SDP: 안정된 의존성 원칙

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

변경이 쉽지 않은 컴포넌트가 변동이 예상되는 컴포넌트에 의존하게 만들어서는 절대로 안 된다. 한 번 의존하게 되면 변동성이 큰 컴포넌트도 결국 변경이 어려워진다.

안정성
안정성은 변경을 만들기 위해 필요한 작업량과 관련된다. 소프트웨어 컴포넌트를 변경하기 어렵게 만드는 확실한 방법 하나는 수많은 다른 컴포넌트가 해당 컴포넌트에 의존하게 만드는 것이다. 컴포넌트 안쪽으로 들어오는 의존성이 많아지면 상당히 안정적이라고 볼 수 있는데, 사소한 변경이라도 의존하는 모든 컴포넌트를 만족시키면서 변경하려면 상당한 노력이 들기 때문이다.

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

X는 안정된 컴포넌트다.

반면 14.6의 Y 컴포넌트는 상당히 불안정한 컴포넌트다. 어떤 컴포넌트도 Y에 의존하지 않으므로 Y는 책임성이 없다고 말할 수 있다. 또한 Y는 세 개의 컴포넌트에 의존하므로 변경이 발생할 수 있는 외부 요인이 세 가지다.

X는 상당히 불안정한 컴포넌트다.

SAP: 안정된 추상화 원칙

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

고수준 정책을 어디에 위치시켜야 하는가?
시스템에서 고수준 정책을 캡슐화하는 소프트웨어는 반드시 안정된 컴포넌트(I=0)에 위치해야 한다. 고수준 정책을 안정된 컴포넌트에 위치시키면, 그 정책을 포함하는 소스 코드는 수정하기 어려워진다. 이는 개방 폐쇄 원칙 OCP 로 해결할 수 있다. 추상 클래스 abstract 클래스를 사용해서 클래스를 수정하지 않고도 확장이 충분히 가능할 정도로 클래스를 유연하게 만들 수 있다.

안정된 추상화 원칙
안정된 추상화 원칙 Stable Abstract Principle, SAP 은 안정성 stability 과 추상화 정도 abstractness 사이의 관계를 말한다. 안정된 컴포넌트는 추상 컴포넌트여야 하며, 이를 통해 안정성이 컴포넌트를 확장하는 일을 방해해서는 안 된다고 말한다. 다른 한편으로는 불안정한 컴포넌트는 반드시 구체 컴포넌트여야 한다고 말하는데, 컴포넌트가 불안정하므로 컴포넌트 내부의 구체적인 코드를 쉽게 변경할 수 있어야 하기 때문이다.

따라서 안정적인 컴포넌트라면 반드시 인터페이스와 추상 클래스로 구성되어 쉽게 확장할 수 있어야 한다. SDP에서는 의존성이 반드시 안정성의 방향으로 향해야 한다고 말하며, SAP에서는 안정성이 결국 추상화를 의미한다고 말한다. 따라서 의존성은 추상화의 방향으로 향하게 된다.


Reference