본문 바로가기
Engineering/SW Architecture

클린 아키텍쳐 (2) - 컴포넌트 원칙

by 쿨쥰 2023. 3. 16.

이전 글 : SW개발 패러다임과 기본 설계 원칙

2023.03.12 - [Engineering/SW Architecture] - 클린 아키텍쳐 (1) - 오버뷰, 패러다임, 설계원칙 SOLID

 

클린 아키텍쳐 (1) - 오버뷰, 패러다임, 설계원칙 SOLID

로버트 마틴 형님의 클린 아키텍쳐 정리. 경험이 부족했던 주니어 시절 완독 했지만, 다시 손에 잡아 읽어 보니 더 보이는게 많아 놓치고 싶지 않은 마음에 기록으로 남겨두는 컴퓨터 쟁이들의

skidrow6122.tistory.com

 


<컴포넌트 원칙>

SOLID 설계 원칙이 벽과 방에 벽돌을 배치하는 방법에 관한 것이라면, 컴포넌트 원칙은 빌딩에 방을 배치하는 방법을 설명한다.

여기서는 SW 컴포넌트가 무엇이고 무엇으로 구성되는지, 컴포넌트를 결합하여 시스템을 구성하는 방법에 대해 다룬다.

 

컴포넌트

컴포넌트란? 시스템의 구성요소로 배포할 수 있는 가장 작은 단위이다. (ex, jar / gem / dll files)

여러 컴포넌트를 서로 링크하여 실행가능한 단일 파일로 생성할 수 있고, 여러 컴포넌트를 묶어 war 파일같은 단일 아카이브로 만들 수도 있고, 컴포넌트 각각을 jar, dll 같이 동적 로드가 가능한 플러그인이나 .exe 로 만들어 독립적 배포도 가능하게 만들 수 있다.

어떤 형태든 간에, 잘 설계 된 컴포넌트라면 반드시 독립적으로 배포 가능하고, 개발 가능한 능력을 가져야 한다.

[과거] 

SW 개발 초창기에는 메모리에서의 프로그램 위치와 메모리를 프로그래머가 직접 제어 했었고, 이 시절 단순한 라이브러리 함수를 사용하려면, 함수의 네이티브 소스코드를 개발자의 애플리케이션에 직접 포함시켜 단일 프로그램으로 컴파일 했다. 즉, 라이브러리는 바이너리가 아니라 소스 코드 형태로 유지 되었던 것이다. 하지만 이 시절 장치는 너무 느리고 메모리는 비싸서 컴파일러가 코드 전체를 메모리에 상주 시킬 수가 없었으므로 컴파일러는 소스코드를 여러차례 반복해서 읽어야만 했다. 이는 프로그램이 커져갈 수록 큰 딜레마가 되었다.

[조금 덜 과거]

해결책은 재배치가 가능한 바이너리 였다. 지능적 로더를 사용해서 메모리에 재배치 할 수 있는 형태의 바이너리를 생성하도록 컴파일러가 수정되었다. 실제로 로더는 여러개의 바이너리를 입력받은 후, 단순히 하나씩 차례로 메모리로 로드하면서 재배치 하는 작업을 처리했다. 즉, 개발자는 오직 필요한 함수만을 로드할 수 있게 된 것이다. 하지만 이 역시 프로그램 규모가 커져가면서 로더 동작 자체가 느려지는 결과를 가져왔다. 함수 라이브러리는 바이너리 형태라 하더라도 자기 테이프 같은 저속 저장장치에 저장되었으며, 디스크 조차 매우 느렸기 때문이다.

[현대]

병목구간이었던 로드/컴파일/링크 시간을 해결한 것은 역시 하드웨어의 찬란한 발전이었다. 디스크는 작아졌고 빨라졌으며, 메모리 역시 말도 안되게 저렴해져 디스크에 저장된 많은 데이터를 RAM에 모두 캐싱 할 수 있을 정도로 까지 발전했다. 90년대 후반이 되자, 개발자가 프로그램을 성장시키는 속도보다 컴파일 및 바이너리 링킹 속도가 줄어든 속도가 더 빨라지기 시작했다. 이렇게 다수의 jar 파일 또는 공유 라이브러리를 순식간에 링크한 후 프로그램을 실행하는 로드 & 링크가 거의 동시에 일어나는 수준까지 발전했다. 비로소 컴포넌트 플러그인 아키텍쳐가 탄생한 것이다.

런타임에 플러그인 형태로 결합할 수 있는 동적 링크 파일이 바로 SW 컴포넌트에 해당한다. 여기까지 오는데 50년이 걸렸다.

 

 

 

컴포넌트 응집도

어떤 클래스를 어떤 컴포넌트에 포함시켜야 할까? 라는 중요한 결정을 돕는 세가지 원칙이 있다.

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

“재사용 단위는 릴리즈 단위와 같다” - 재사용성 관점

우리는 maven, gradle 등을 활용한 sw재사용의 시대에 살고있다. REP는 너무도 당연한데, SW 컴포넌트가 릴리즈 절차를 통해 추적관리 되지 않거나 릴리즈 번호가 부여되지 않는다면, 재사용 컴포넌트들이 서로 호환되는지 보증할 방법도 없고 이를 재사용할 수가 없다.

이 원칙을 설계와 아키텍쳐 관점에서 보면 단일 컴포넌트는 응집성 높은 클래스와 모듈로 구성되어 컴포넌트를 구성하는 모든 모듈은 서로 공유하는 중요한 테마나 목적이 있어야 한다.

이는 곧 하나의 컴포넌트로 묶인 클래스와 모듈은 버전 번호가 같아야하며, 반드시 함께 릴리즈 할 수 있어야 한다는 것을 의미한다.

 

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

“동일한 이유로 동일한 시점에 변경되는 클래스를 같은 컴포넌트로 묶어라” - 유지보수성 관점

이는 SOLID 의 단일 책임 원칙(SRP)를 컴포넌트 관점에서 다시 쓴 것과 같다. 즉, 컴포넌트 관점의 CCP에서도 단일 컴포넌트는 변경의 이유가 여러개 있어서는 안된다는 것을 의미한다.

물리적 또는 개념적으로 강하게 결합되어 항상 함게 변경되는 클래스들은 하나의 컴포넌트에 속해야 한다. 이를 통해 SW를 릴리즈, 재검증, 배포하는 일과 관련된 작업량을 최소화 할 수 있다.

 

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

“컴포넌트 사용자들을 필요하지 않는 것에 의존하게 강요하지 말라” - 릴리즈 최소화 관점

함께 재사용되는 경향이 있는 클래스와 모듈은 반드시 같은 컴포넌트에 포함되어야 한다. 대체로 재사용이 가능한 클래스는 재사용 모듈의 일부로써 해당 모듈의 다른 클래스와 상호작용하는 경우가 많고 CRP에서는 이런 클래스들이 컴포넌트 내부에서 수많은 의존성을 가지고 있으므로 동일한 컴포넌트에 포함되어야 한다고 말한다.

이를 다르게 말하면, 한 컴포넌트에 속한 클래스들은 더 작게 그룹화할 수 없다.

CRP는 인터페이스 분리 원칙(ISP)의 포괄적인 버전이다. 즉, CRP는 사용하지 않는 클래스를 가진 컴포넌트에 의존하지 말라고 조언한다.

 

컴포넌트 응집도 관점에서의 원칙 별 해석

REP와 CCP는 포함 원칙으로 두 원칙은 컴포넌트를 더욱 크게 만든다.

CRP는 배제 원칙이며, 컴포넌트를 더욱 작게 만든다.

좋은 SA라면 이 원칙들이 균형을 이루는 방법을 찾아야 한다.

각 edge는 반대쪽 꼭지점에 있는 원칙을 포기 했을 때 감수해야 할 비용을 나타낸다.

일반적으로 프로젝트는 삼각형의 오른쪽에서 시작하는 편이며, 이때는 재사용성을 희생한다. 프로젝트가 성숙하고 확장되어 감에 따라 삼각형은 점차 왼쪽으로 이동해 간다.

즉, 프로젝트의 컴포넌트 구조는 시간과 성숙도에 따라 변해갈 수 밖에 없다.

종합하면, 재사용성과 개발가능성이라는 상충된 힘을 항상 고려해야 하고, 이들 사이에서 어플리케이션의 요구에 맞게 균형을 잡는 것이 중요하다. 결과적으로 시간이 흐름에 따라 프로젝트의 초점이 개발가능성에서 재사용성으로 바뀌고, 그에 따라 컴포넌트를 구성하는 방식 또한 조금씩 흐트러지고 진화한다.

 

 

 

컴포넌트 결합도

컴포넌트간 관계는 어떻게 맺어야 할까? 라는 중요한 결정을 돕는 세가지 원칙이 있다.

 

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

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

대규모 프로젝트의 경우 개발자1이 무언가를 동작하게 만들어놓고 퇴근했는데, 그 사이 다른 누군가가 개발자1이 의존하고 있던 무언가를 수정했을때 전체적인 프로그램이 돌지 않는 현상을 종종 겪게 된다(숙취 증후군).

이것을 해결하기 위한 두가지 방법이 바로 주단위 빌드, ADP 이다.

 

주단위 빌드

프로세스로 이 문제를 해결하는 방식으로 개발자들은 일주일의 첫 4일동안 서로를 신경쓰지 않고 로컬 브랜치에서 개발한다. 금요일이 되면 변경 코드를 모두 취합하여 통합하고 시스템을 빌드하는 프로세스다. 하지만 이 방식은 규모가 점점 커져감에 따라 개발보다 통합에 드는 짐이 점점 커져 갈 수 밖에 없고 팀의 효율성 또한 현저하게 저하된다. 결국 빌드는 모두가 피하고 싶어하는 공포의 순간이 되는 것이다.

 

순환 의존성

이것을 설계관점으로 해결하기 위해서는 개발환경을 릴리즈 가능한 컴포넌트 단위로 분리하는 것이다. 이는 곧 개별 개발자 또는 단일 개발팀이 책임 질 수 있는 작업 단위가 된다. 유닛은 해당 컴포넌트가 동작하도록 만든 후, 해당 컴포넌트를 릴리즈하여 다른 개발자가 사용할 수 있도록 만든다. 담당 개발자는 이 컴포넌트에 릴리즈 번호를 부여하고 다른팀에게 공개된 디렉토리로 이동시킨다. 그러고 담당개발자는 자신만의 로컬 브랜치에서 해당 컴포넌트를 지속적으로 수정하고, 나머지 타팀 개발자는 릴리즈 된 버전을 사용하는 방식이다.

이는 어떤 팀도 다른 팀에 의해 좌우되지 않는다는 장점을 가진다. 특정 컴포넌트가 변경되더라도 다른 팀에 즉각 영향을 주지 않기 때문이다. 각 팀은 특정 컴포넌트가 새롭게 릴리즈 되면 자신의 컴포넌트를 해당 컴포넌트에 맞게 수정할 시기를 스스로 결정 할 수 있게 되고, 통합은 작고 점진적으로 이루어진다.

이는 단순하고 합리적이라 널리 사용되지만, 이 절차가 성공하려면 컴포넌트 사이의 의존성 구조를 반드시 관리해야한다. 즉, 의존성에 순환이 있어서는 안되고, 만약 순환이 있다면 숙취 증후군이 발현될 수 밖에 없다.

컴포넌트 의존성 다이어그램

위 다이어그램은 전형적인 컴포넌트 다이어그램으로 컴포넌트간 의존성을 보여준다. 자세히 vertex 별로 시작점을 찍어 의존성을 파악해보면 그 어떤 컴포넌트에서 시작하더라도 최초의 컴포넌트로 돌아갈 수 없는 좋은 구조이다. 이 구조에서는 Presenter 컴포넌트가 릴리즈 되더라도 이에 의존하는 컴포넌트는 view, main 밖에 없으므로 영향도를 최소화 할 수 있다.

시스템 전체를 릴리즈 해야하는 상황이 온다면, 릴리즈 절차는 가장 하위 컴포넌트인 entities 부터 상향식으로 진행해 나가 결국 Main 을 마지막에 처리한다. 이처럼 구성 요소간 의존성을 잘 파악하고 있으면 시스템을 빌드하는 방법까지 도출 할 수 있게 된다.

만약 entities 와 authorizer 간 entities 가 authorizer 에 의존하는 의존성을 하나 만들어 넣었다고 가정해보자. 이는 interactors / entities / authorizer 컴포넌트간 순환구조를 만들게 되며, 사실 상 세가지 컴포넌트가 하나의 거대한 컴포넌트가 되어 버리게 된다. 이렇게 된다면? 바로 숙취 증후군이 나타날 수 있다. 또한 의존성 그래프에 순환이 생겨버리면 컴포넌트를 어떤 순서로 빌드 해야 올바를지 파악하기가 매우 힘들어 진다.

 

순환 끊기

컴포넌트 사이의 순환을 끊어내려면 두가지 방법을 쓸 수 있다.

  • 의존성 역전 원칙 (DIP) 적용
    • 아래 처럼 User 가 필요로 하는 메서드를 제공하는 인터페이스를 생성한다. 그리고 이 인터페이스는 entities 에 위치시키고, authorizer 에서는 이 인터페이스를 상속받게 한다.
    • 이렇게 하면 entities 와 autorizer 사이의 의존성을 역전 시킬 수 있고 이를 통해 순환을 끊을 수 있다.

인터페이스 클래스를 배치해서 의존성 역전

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

     

두번째 방법이 시사하는 바는 요구사항이 변경되면 곧 컴포넌트 구조도 변경될 수 있다는 사실이다. 실제로 어플리케이션이 성장함에 따라 컴포넌트 의존성 구조는 서서히 흐트러지며 또 성장하는데 이를 “흐트러짐 (Jitter)” 라고 한다. 따라서 의존성 구조에 순환이 발생하는 지를 항상 관찰해야 하며, 순환이 발생하면 어떤식으로든 끊어 낼 수 있어야 한다.

사실 컴포넌트 구조는 하향식으로 설계될 수 없고, 의존성 다이어그램은 애플리케이션의 기능을 기술하는 일과는 거의 관련이 없다.

오히려 이는 애플리케이션의 빌드 가능성과 유지보수성을 보여주는 지도와 같은 역할을 하므로, 컴포넌트 구조는 프로젝트 초기에 설계 해둘 수가 없다. 하지만 자연스레 프로젝트 초기에 모듈들이 점차 쌓이기 시작하면 숙취 증후군을 겪지 않기 위해서 의존성 관리에 대한 요구가 점차 늘어나게 되어있다. (이시기에 보통 SRP와 CCP 에 관심을 갖게 된다.)

의존성 구조와 관련 된 최우선 관심사는 바로 변동성을 격리하는 일이다. 최종 목적은 자주 변경되는 컴포넌트들로 부터 안정적이며 가치가 높은 컴포넌트를 보호하려는 아키텍쳐를 만드는 것이다. 동시에 어플리케이션이 계속 성장함에 따라 재사용 가능성에 관심을 기울이게 되고, 이쯤 컴포넌트를 조합하는 과정에서 CRP가 영향을 미치기 시작한다.

이 과정에서 순환은 발생할 수 있고 이때 ADP 관점을 적용하여 컴포넌트 관계를 가다듬는다.

결국 컴포넌트 의존성 구조는 시스템의 논리적 설계에 발맞춰 성장하며 또 진화해 나가는 것이다.

 

 

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

“더 안정된 컴포넌트에 의존하라”

OCP 를 준수하면서 컴포넌트가 다른 유형의 변경에는 영향받지 않으면서도 특정 유형의 변경에만 민감하게 만들 수 있지만, sw는 괴팍하게도 변동성을 가질 수 밖에 없다.

즉, 내가 모듈을 만들때는 변경에 확장가능하도록 쉽게 만들어 뒀다 하더라도, 이 모듈에 누군가가 의존성을 매달아 버리면 그 모듈도 변경하기 어려워진다. SDP를 적절히 준수하면 변경하기 어려운 모듈이 변경하기 쉽게 만들어진 모듈에 의존하지 않도록 탄탄하게 만들 수 있다.

 

안정성

SW에서의 안정성이란 변화가 발생하는 빈도와는 직접적인 관련이 없고, 변경을 만들기 위해 필요한 작업량과 관련된다. 탁자위에 옆으로 선 동전은 쉽게 쓰러뜨릴 수 있어 안정적이지 않은 반면, 탁자를 뒤집으려면 상당한 수고를 감수해야 하므로 안정적이라 할 수 있다.

X는 안정된 컴포넌트이다. 세 컴포넌트가 X에 의존하며 X 컴포넌트를 변경하지 말아야할 이유가 세가지나 된다. X는 세 컴포넌트를 책임진다 (responsible). 반대로 X는 어디에도 의존하지 않으므로 X는 독립적이다 (independent).

Y는 상당히 불안정한 컴포넌트다. 어떤 컴포넌트도 Y에 의존하지 않으므로 Y는 책임성이 없다. 반대로 Y기준으로 봤을때 세개의 컴포넌트에 의존하므로, 변경이 발생할 수 있는 외부 요인이 3가지다. Y를 의존적이라고한다 (dependent).

 

안정성 지표

  • Fan-in : 안으로 들어오는 의존성 개수. 컴포넌트 내부의 클래스에 의존하는 외부의 클래스 개수를 의미.
  • Fan-out : 바깥으로 나가는 의존성 개수. 컴포넌트 외부의 클래스에 의존하는 컴포넌트 내부의 클래스 개수를 의미.
  • 불안정성 I : fan-out / (fan-in + fan-out). 0 이면 최고로 안정된 컴포넌트, 1이면 굉장히 불안정한 컴포넌트를 의미.

사실 모든 컴포넌트가 최고로 안정적인 시스템이라면 아예 변경이 불가능하며 이것은 바람직한 상황이 아니다. 즉, 적절히 불안정 컴포넌트와 안정된 컴포넌트가 조화롭게 혼재된 상태가 이상적인 구조라 할 수 있다.

이상적인 컴포넌트 구조

관례적으로 변경가능한 (불안정한) 컴포넌트를 위쪽에 배치하는데 이것이 상당히 유용하다. 위로 향하는 의존성이 있어버리면 SDP를 위배하고 결국 ADP도 위반하게 되기 때문이다.

만약 stable 밑에 쉬이 변경가능한 flexible 컴포넌트를 배치하고, stable이 flexible에 의존하게 만들었다고 가정해보자. 이제 flexible을 변경해버리면 이제 stable과 stable에 의존하는 나머지 컴포넌트들도 조치를 취해줘야 하므로 어떻게든 stable의 flexible에 대한 의존성을 끊어내야한다.

이는 DIP관점에서 stable과 flexible 사이에 인터페이스를 구현하여 의존성을 역전시키던지, 추상 컴포넌트를 배치함으로써 해결할 수 있다.

 

 

SAP (Stable Abstractions Principle) - 안정된 의존성 원칙

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

시스템에서 고수준 정책을 캡슐화 하는 sw는 반드시 안정된 컴포넌트 (I=0) 에 위치시켜야 하며, 불안정한 컴포넌트 (I=1) 은 반드시 변동성이 커 쉽고 빠르게 변경할 수 있는 sw만을 포함해야 한다. 하지만, 고수준 정책을 안정된 컴포넌트에 위치시키면 그 정책을 포함하는 소스코드는 수정하기 어려워지며, 이로 인해 시스템 전체 아키텍쳐에 유연성을 잃어 버린다.

컴포넌트가 최고로 안정된 상태이면서도, 동시에 변경에 충분히 대응 할 수 있을 정도로 유연하게 만들 수 없을까? 해답은 OCP 에서 찾을 수 있다.

안정된 컴포넌트는 추상 컴포넌트여야 하며, 안정성이 컴포넌트를 확장하는 일을 방해해서는 안된다고 말한다.

불안정한 컴포넌트는 구체 컴포넌트여야 한다.

SAP와 SDP를 결합하면 컴포넌트에 대한 DIP 나 마찬가지가 된다. 실제로 SDP에서는 의존성이 반드시 안정성의 방향으로 향해야 한다고 하고, 안정성이 결국 추상화를 의미하기 때문에 의존성은 곧 추상화의 방향으로 향하게 되는 것이다.

여기서 핵심은, 안정적인 컴포넌트라면 반드시 인터페이스와 추상클래스로 구성되어 쉽게 확장할 수 있게 만들어야 한다는 점이다.

 

  컴포넌트 섹션에서 의존성 관리지표, 안정성 지표, 추상화 정도 (컴포넌트의 클래스 총 수 대비 인터페이스와 추상클래스의 개수를 단순히 계산한 값)등 설계의 의존성과 추상화 정도가 내가 훌륭한 패턴이라고 생각하는 수준에 얼마나 잘 부합하는지를 측정하는 지표가 소개되었다.

다만, 우리는 경험을 통해 좋은 의존성과 좋지 않지만 반드시 필요한 의존성이 발생한다는 사실을 알고 있고, 지표는 신이 아니므로 불완전 할 수 밖에 없다.

그저 원칙을 준수하며 최적의 구조를 찾을 수 있도록 노력하자.

댓글