본문 바로가기
Engineering/SW Architecture

클린 아키텍쳐 (8) - 코드의 조직화 / 빠져있는 장

by 쿨쥰 2023. 4. 9.

이전 글 : 사례연구 - 비디오 판매

2023.04.04 - [Engineering/SW Architecture] - 클린 아키텍쳐 (7) - 사례 연구 : 비디오 판매

 

클린 아키텍쳐 (7) - 사례 연구 : 비디오 판매

이전 글 : 세부사항 2023.03.31 - [Engineering/SW Architecture] - 클린 아키텍쳐 (6) - 세부사항 클린 아키텍쳐 (6) - 세부사항 이전 글 : 아키텍처 [부분적 경계, 계층과 경계, 메인 컴포넌트, 크고작은 모든 서

skidrow6122.tistory.com

 

 


 

 

지금까지 살펴본 아키텍처에 대한 조언은 더 나은 소프트웨어를 설계하는데 확실히 도움이 될 것이다.

이러한 소프트웨어는 올바르게 정의된 경계, 명확한 책임, 그리고 통제된 의존성을 가진 클래스와 컴포넌트로 구성될 것이다.

하지만 항상 악마는 디테일(구현 세부사항)에 있는 법이며, 이를 항상 주시해야 한다.

이 정리에서는 클린 아키텍처를 잠시 제쳐 두고, 설계나 코드 조직화와 관련된 몇가지 actual 한 접근 법을 살펴 보고 클린 아키텍처 정의를 마무리 한다.

 

계층 기반 패키지

가장 단순한 첫 번째 설계 방식이다.

기술적인 관점에서 해당 코드가 하는 ‘일’에 기반하여 코드를 분할하며, 그 ‘일’이 계층화 되어있다고 해서 계층 기반 패키지라고 부른다.

계층 기반 패키지

이 전형적인 아키텍처에는 웹, 업무규칙, 영속성 코드를 위해 계층이 각각 하나씩 존재한다.

다시 말해 코드는 계층이라는 얇은 수평 조각으로 나뉘며, 각 계층은 유사한 종류의 것들을 묶는 도구로 사용된다.

‘엄격한 계층형 아키텍처’의 경우 계층은 반드시 바로 아래 계층에만 의존해야 하며, Java의 경우 계층은 주로 패키지로 구현된다.

다이어그램에서 보듯이 계층(패키지)사이의 의존성은 모두 아래를 향하고 있으며, 이 예제에서는 다음의 Java 타입들이 존재한다.

  • OrdersController - 웹 컨트롤러. 웹 기반 요청을 처리. Spring MVC 컨트롤러에 해당.
  • OrdersService - 주문 관련 ‘업무 규칙’을 정의하는 인터페이스
  • OrdersServiceImpl - OrdersService의 구현체
  • OrdersRepository - 영구 저장된 주문 정보에 접근하는 방법을 정의하는 인터페이스
  • JdbcOrdersRepository:OrdersRepository - 인터페이스의 구현체

마틴 파울러는 ‘프레젠테이션 도메인 데이터 계층화’ 에서 처음 시작하기에는 계층형 아키텍처가 적합하다고 이야기 했다.

이 아키텍처는 엄청난 복잡함을 겪지 않고도 무언가를 작동시켜 주는 아주 빠른 방법이다.

하지만 문제는 마틴이 지적하였듯이, SW가 커지고 복잡해지기 시작하면 머지않아 큰 그릇 세개만으로는 모든 코드를 담기에 부족하다는 사실을 깨닫고, 더 잘게 모듈화해야 할 지를 고민하게 될 것이다.

또한, 계층형 아키텍처는 업무 도메인에 대해 아무것도 말해주지 않는다는 단점도 있다.

전혀 다른 업무 도메인이라도 코드를 계층형 아키텍처로 만들어 나란히 놓고 보면 웹, 서비스, 리포지터리로 구성된 모습이 기분 나쁠 정도로 비슷하게 보일 것이다.

 

 

기능 기반 패키지

코드를 조직화 하는 또 다른 선택지이다.

이는 서로 연관된 기능, 도메인 개념, 또는 Aggregate Root (도메인 주도 설계)에 기반하여 수직의 얇은 조각으로 코드를 나누는 방식이다.

모든 타입이 하나의 Java 패키지에 속하며, 패키지 이름은 그 안에 담긴 개념을 반영해 짓는다.

기능 기반 패키지

등장하는 인터페이스와 클래스는 이전과 같지만, 패키지는 3개가 아니라 단 하나의 패키지에 모두 속하게 된다.

이는 계층 기반 패키지를 간단히 리팩터링 한 형태이지만, 이제 코드의 상위 수준 구조가 업무 도메인에 대해서 무언가를 알려주게 된다.

우리는 이 코드 베이스가 웹, 서비스, 리포지터리가 아니라 주문과 관련한 무언가를 한다는 것을 알 수 있다.

또 다른 이점으로 ‘주문 조회하기’ 유스케이스가 변경 될 경우 변경해야할 코드를 모두 찾는 작업이 더 쉬워질 수 있다. 변경해야 할 코드가 여러 군데 퍼져있지 않고 모두 한 패키지에 담겨 있기 때문이다.

사실 저자는 수평적 계층화 (계층 기반 패키지) 와 수직적 계층화 (기능 기반 패키지)는 둘다 차선책이라고 생각하고 있다.

이보다 더 잘 할 수 있는 방법은 없을까?

 

 

포트와 어댑터

엉클 밥에 따르면, ‘포트와 어댑터’ 혹은 ‘육각형 아키텍처’, ‘경계, 컨트롤러, 엔티티 - BCE’ 등의 방식으로 접근하는 이유는 업무/도메인에 초점을 둔 코드가 프레임워크나 데이터베이스 같은 기술적인 세부 구현과 독립적이며 분리 된 아키텍처를 만들기 위해서라고 한다.

다시 말해, 그러한 코드 베이스는 ‘내부 (도메인)’ 과 ‘외부(인프라)’ 로 구성됨을 흔히 볼 수 있다.

내부와 외부로 나뉘는 코드 베이스

‘내부’ 영역은 도메인 개념을 모두 포함하는 반면, ‘외부’ 영역은 외부 세계 (UI, DB, 서드파티 통합 등) 와의 상호작용을 포함한다.

여기서 주요 규칙은 바로 ‘외부’가 ‘내부’에 의존해야 하며 절대로 그 반대로는 안된다는 점이다.

앞서 본 ‘주문 조회하기’ 유스케이스를 이 방식으로 구현하면 아래와 같은 그림이 나온다.

주문 조회하기 유스케이스

  • 내부 - com.mycompany.myapp.domain
  • 외부 - 나머지 패키지 모두

의존성이 모두 ‘내부’를 향해 흐르고 있다.

또한 이전 다이어그램의 OrderRepository가 Orders 라는 간단한 이름으로 바뀌기도 했다.

이는 도메인 주도 설계라는 세계관에서 비롯된 명명법으로, DDD 에서는 ‘내부’에 존재하는 모든 것의 이름은 반드시 ‘유비쿼터스 도메인 언어’ 관점에서 기술하라고 말한다.

쉽게 말하면, 도메인에 대해 우리가 논의할때 우리는 ‘주문’에 대해 말하는 것이지 ‘주문 리포지터리’ 에 대해 말하는 것이 아니라는 점이다.

이 그림은 UML 클래스 다이어그램을 간소화 할때 어떻게 표현할 수 있는 지를 보여준다는 점도 짚고 갈 만하다.

이 다이어그램에는 인터랙터가 빠졌고, 의존성 경계를 가로질러 데이터를 마샬링 하는 객체 등이 누락 되었다.

 

 

컴포넌트 기반 패키지

SOLID, REP, CCP, CRP 를 포함한 대다수의 조언은 코드를 조직화 하는 방법에 대해서는 조금 다르게 해석되기도 한다.

이 방법이 바로 컴포넌트 기반 패키지이다.

앞서 계층형 아키텍처를 좋지 않은 아키텍처라는 점을 부각했지만, 좋지 않다는 시그널은 몇가지 더있다.

계층형 아키텍처의 목적은 기능이 같은 코드끼리 서로 분리하는 것이다. 웹 관련 코드는 업무 로직으로부터 분리하고, 업무 로직은 다시 데이터 접근으로 부터 분리한다.

UML 다이어그램에서 봤듯이, 구현 관점에서 보면 각 계층은 일반적으로 자바 패키지에 해당한다.

계층형 아키텍처에서는 각 계층은 반드시 아래 계층에만 의존해야 하지만 이를 파괴할 수 있는 큰 문제가 있다.

속임수를 써서 몇몇 의존성을 의도치 않은 방식으로 추가하더라도 여전히 좋은 비순환 의존성 그래프가 생성된다는 사실이다.

신입 사원이 들어와서 업무 로직 계층을 우회한 채로 OrderRepository 구현체를 Contorller 에 바로 주입 하는 케이스도 생길 수 있다.

이는 ‘완화 된 계층형 아키텍처’ 라고 하는데, 대부분의 경우 바람직 하지 못하다.

특히 개별 레코드에 대해 인증된 접근만을 허용하는 일이 업무 로직이 책임 지는 경우라면 더욱 그렇다.

완화된 계층형 아키텍처

이렇게 우회해서 구현 된 새로운 유스케이스가 동작은 하겠지만 기대하는 형태대로 구현되지는 않았다.

저자가 컨설턴트로서 방문했던 수많은 팀에서 이런 현상을 목격했고, 나의 직전 과제에서도 이런식으로 유야무야 개발되어 넘어간 기능도 많았었다.

여기에서 우리에게 필요한 것은 바로 지침(아키텍처 원칙) 이다.

“웹 컨트롤러는 절대로 리포지터리에 직접 접근해서는 안된다” 와 같은 원칙이 필요하다.

강제성에 문제가 있을 수 있는데, 사실 좋은 개발문화 + 개발자에 대한 신뢰성 을 주장하는 조직 치고 그렇게 잘지켜 지는 곳은 실제론 찾기 힘들다.

신뢰는 듣기에는 좋지만, 자금과 납기 라는 주요한 허들앞에서는 어떤 모드로 돌변할지도 모르는 것이 바로 두루뭉술한 원칙이다.

빌드시 정적 분석 도구를 사용해서 아키텍처적으로 위반 사항이 없는지를 검사하여 자동으로 강제할 수도 있다.

예를들어 “**/web 패키지에 있는 타입은 절대 **/data 에 있는 타입에 접근해서는 안된다 와 같은 형태”이며, 이들 규칙은 컴파일 단계가 끝난 후 실행된다.

이 방식은 조잡하지만 효과가 있는데, 팀 차원에서 정의한 아키텍처 원칙을 위반하는 항목을 알려주고, 위반 시 빌드를 깨버리기 때문이다.

‘컴포넌트 기반 패키지’ 를 도입해야 하는 이유는 바로 이 때문이다.

이 접근법은 지금까지 우리가 살펴본 모든것을 혼합한 것으로, 큰 단위의 단일 컴포넌트와 관련된 모든 책임을 하나의 자바 패키지로 묶는데 주안점을 둔다.

이 접근법은 서비스 중심적인 시각으로 SW시스템을 바라보며, 마이크로서비스 아키텍처가 가진 시각과도 동일하다.

포트와 어댑터에서 웹을 그저 또 다른 전달 메커니즘으로 취급하는 것과 마찬가지로, 컴포넌트 기반 패키지에서도 사용자 인터페이스를 큰 단위의 컴포넌트로부터 분리해서 유지한다.

주문 조회하기 유스케이스

본질적으로 이 접근법에서는 ‘업무 로직’과 영속성 관련 코드를 하나로 묶는데, 이 묶음을 컴포넌트라고 부른다.

엉클 밥의 정의를 따르자면,,

“컴포넌트는 배포단위이다. 컴포넌트는 시스템의 구성 요소로, 배포할 수 있는 가장 작은 단위다. Java의경우 Jar파일이 컴포넌트다”

단 컴포넌트에 대한 정의는 조금씩 다를 수 있으며,

“컴포넌트는 멋지고 깔끔한 인터페이스로 감싸진 연관된 기능들의 묶음으로, 어플리케이션과 같은 실행환경 내부에 존재한다.” 로 정의 될 수 도있다. by 시몬 브라운

이 정의는 C4 소프트웨어 아키텍처 모델에 따른 것으로 SW시스템의 정적 구조를 컨테이너/컴포넌트/클래스 측면에서 계층적으로 생각하는 간단한 방법이다.

이 방법론에서 SW시스템은 하나 이상의 컨테이너 (예를 들어 웹 어플리케이션, 모바일 앱, 독립형 어플리케이션, DB, 파일시스템 등) 으로 구성되며, 각 컨테이너는 하나 이상의 컴포넌트를 포함한다.

또한 각 컴포넌트는 하나 이상의 코드로 구현 된다.

이 때 각 컴포넌트가 개별 jar 파일로 분리될 지 여부는 직교적인 관심사(한 요소에서 발생한 변경이 다른 변경에 영향을 미치지 않는다는,, 즉 독립적인 과 같은 뜻) 이다.

컴포넌트 기반 패키지 접근법의 주된 이점은 주문과 관련된 무언가를 코딩해야 할 때 오직 한곳, OrderComponent 만 들여다 보면 된다는 점이다.

이 컴포넌트 내부에서 관심사의 분리는 여전히 유효하며, 따라서 업무 로직은 데이터 영속성과 분리되어 있다.

하지만 이는 컴포넌트 구현과 관련된 세부사항으로, 사용자는 알 필요가 없다. 즉, 주문처리와 관련된 모든 것들을 캡슐화하는 별도의 OrderService가 존재한다는 뜻이다.

큰 차이는 결합 분리 모드에 있다.

모노리틱 어플리케이션에서 컴포넌트를 잘 정의하면 마이크로 아키텍처로 가기위한 발판으로 삼을 수 도 있는 것이다.

 

 

 

조직화 VS 캡슐화

표면상으로는 앞서 정리한 네가지 접근법이 코드를 조직화 하는 완전히 다른 방식처럼 보이며, 서로 다른 아키텍처 스타일로 보일 수 있다.

하지만 세부사항을 잘못 구현하면 이러한 견해도 아주 빠르게 흐트러지기 시작한다.

예를 들어 Java와 같은 언어에서 public 접근 지시자를 지나칠 정도로 자주 사용한다면 (아무런 고민없이 마치 본능적으로..), 프로그래밍 언어가 제공하는 캡슐화 관련 이점을 활용하지 않는다는 뜻이다. 아쉽게도 이러한 현상은 상다히 만연해 있으며, 오픈 소스 프레임워크의 샘플 코드만 봐도 이러한 경향은 뚜렷해 보인다.

만약 Java 어플리케이션에서 모든 타입을 Public 으로 지정한다면, 패키지는 단순히 조직화를 위한 메커니즘 (폴더 그 이상도 그 이하도아닌..)으로 전략하며 캡슐화를 위한 메커니즘이 될 수 없다,. public 타입을 코드 베이스 어디에서도 사용할 수 있다면 패키지를 사용하는데 따른 이점이 거의 없으므로 사실상 패키지를 사용하지 않는것과 같은 의미이다.

이 패키지는 무시되어져 버리면 캡슐화나 은닉을 하는데 아무런 도움도 되지 않으므로 최종적으로 어떤 아키텍처 스타일로 만들려고 하는지는 아무런 의미가 없어진다.

따라서 public 지시자를 과용하면 맨 앞에서 제시한 네가지 아키텍처 접근법은 본질적으로 완전 같아져 버리는 것이다.

네개의 아키텍처 접근법이 동일함

각 타입 사이의 화살표를 보자.

채택하려는 아키텍처 접근법과 관계없이 화살표들이 모두 동일한 방향을 가르킨다.

개념적으로 이 접근법들은 매우 다르지만, 구문적으로는 완전히 똑같다는 것을 의미한다.

이처럼 모든 타입을 public 으로 선언다면, 실제로는 수평적 계층형 아키텍처를 표현하는 네가지 방식에 지나지 않는다.

Java의 접근지시자가 완벽하지는 않지만, 그렇다고 무시하면 안된다. Java에서 접근 지시자를 적절히 사용하면, 타입을 패키지로 배치하는 방식에 따라서 각 타입에 접근할 수 있는 정도가 실제로 크게 달라질 수 있다.

만약 다이어그램에서 패키지 구조를 다시 살려서 더 제한적인 접근 지시자를 사용할 수 있는 타입을 흐리게 표시하면 아래와 같이 다이어그램은 변한다.

회색 처리 된 타입은 더 제한적인 접근 지시자를 사용할 수 있다

  • 계층 기반 패키지
    • OrdersService와 OrdersRepository 인터페이스는 외부 패키지의 클래스로 부터 자신이 속한 패키지 내부로 들어오는 의존성이 존재하므로 public으로 선언 되어야 한다.
    • 반면에 구현체 클래스 (OrderServiceImple 과 JdbcOrdersRepository)는 protected와 같이 더 제한적으로 선언할 수 있다.
  • 기능 기반 패키지
    • OrdersController가 패키지로 들어올 수 있는 유일한 통로를 제공하므로 나머지는 모두 패키지 protected로 지정할 수 있다.
    • 주의할 점은 이 패키지 밖의 코드에서는 컨트롤러를 통하지 않으면 주문 관련 정보에 접근 할 수 없다는 사실이다. (이는 좋을수도 나쁠수도 있다)
  • 포트와 어댑터 방식
    • OrderService와 Orders 인터페이스는 외부에서 들어오는 의존성을 가지므로 Public 을 지정해야한다.
    • 이 경우에도 구현 클래스는 패키지 protected로 지정하며 런타임에 의존성을 주입할 수 있다.
  • 컴포넌트 기반 패키지
    • 컨트롤러에서 OrdersComponent 인터페이스로 향하는 의존성을 가지며, 그외의 모든 것은 패키지 protected 지정해야한다.
    • 패키지 외부의 코드에서는 OrdersRepository 인터페이스나 구현체를 직접 사용할 수 있는 방법이 전혀 없으므로 우리는 컴파일러의 도움을 받아서 ;컴포넌트 기반 패키지’아키텍처 접근법을 강제 할 수 있다.
  •  

 

 

다른 결합 분리 모드

프로그래밍 언어가 제공하는 방법 외에도 소스 코드 의존성을 분리하는 방법은 존재 할 수 있다.

예를 들어 자바에는 OSGi 같은 모듈 프레임워크나 자바9 에서 제공하는 새로운 모듈 시스템이 있다.

모듈 시스템을 제대로 사용하면 public 타입과 외부에 공표할 타입을 분리할 수 있다.

예를 들어 Orders 모듈을 생성할 때 모든 타입을 public 으로 지정하더라도 그 중 일부 타입만 외부에서 사용할 수 있도록 공표할 수 있다.

소스코드 수준에서 의존성을 분리하는 방법도 있다.

정확하게는 서로 다른 소스 코드 트리로 분리하는 방법이다. 포트와 어댑터를 예로 들자면, 다음과 같은 소스 코드 트리를 만들 수 있다.

  • 소스코드 트리1 ) 업무와 도메인용 소스코드 : OrderService, OrderServiceImpl, Orders
  • 소스코드 트리2) 웹용 소스코드 : OrderController
  • 소스코드 트리3) 데이터 영속성용 소스코드 : JdbcOrdersRepository

소스코드 트리2,3은 업무와 도메인 코드에 대해 컴파일 시점에 의존성을 가지며,

업무와 도메인 코드 자체는 웹이나 데이터 영속성 코드에 대해서는 아무것도 알지 못한다.

구현 관점에서 이렇게 분리하려면 빌드도구 (maver, gradle 따위) 를 사용해서 모듈이나 프로젝트가 서로 분리되도록 구성해야한다.

이상적으로는 이런 형태를 반복적으로 적용하여 어플리케이션을 구성하는 모든 컴포넌트 각각을 개별적인 소스코드 트리로 구셩해야 한다.

하지만 이는 현실에서의 소스코드의 성능/복잡성/유지보수 문제를 고려하면 너무 이상적인 해결책 이기도 하다.

단순히 소스코드 트리를 두개만 만드는 방법이 포트와 어댑터 접근 법을 적용할 때는 간단한 방법으로 사용되기도 한다.

  • 소스코드 트리1) 도메인 코드 : 내부
  • 소스코드 트리2) 인프라 코드 : 외부

도메인과 인프라 코드

많은 사람들이 포트와 어댑터 아키텍처를 간략히 설명할때 사용하는 그림이다.

인프라는 도메인에 대해 컴파일 시점의 의존성을 가짐을 보여준다.

이 접근법은 소스코드를 조직화 할때 효과가 있겠지만, 잠재적으로 절충해야할 부분이 사실 있다.

인프라 코드를 단일 소스코드에 모두 모아둔다는 말은,

어플리케이션에서 특정 영역 (예를 들어 웹 컨트롤러)에 있는 인프라 코드가, 다른 영역 (예를 들어 DB 리포지터리)에 있는 코드를 직접 호출할 수 있다는 뜻이다.

심지어 도메인을 통하지 않고 말이다.

특히 해당 코드에 적절한 접근 지시자는 무조건 활용해야하며, 이것을 잊어버린 경우라면 이러한 직접 호출을 막기는 더 힘들어 진다.

 

 

결론

결과적으로 최적의 설계를 꾀했더라도, 구현 전략에 얽힌 복잡함을 고려하지 않으면 설계가 순식간에 망가질 수도 있다.

설계를 어떻게 해야만 원하는 코드 구조로 매핑할 수 있을지? 그 코드를 어떻게 조직화 할지? 런타임과 컴파일타임에 어떤 결합 분리 모드를 적용할 지를 고민하라.

가능하면 선택사항을 열어두되, 실용주의적으로 행하라.

그리곤 팀의 규모, 기술 수준, 해결책의 복잡성을 일정과 예산이라는 제약과 동시에 고려하라.

또한 선택된 아키텍처 스타일에서 데이터 모델과 같은 다른 영역에 결합되지 않도록 주의하라.

구현 세부사항에는 항상 문제가 많은 법이니까.

댓글