Home 클린 아키텍처 - 빠져 있는 장
Post
Cancel

클린 아키텍처 - 빠져 있는 장

Table of Contents

  1. 계층 기반 패키지
  2. 기능 기반 패키지
  3. 포트와 어댑터
  4. 컴포넌트 기반 패키지
  5. 구현 세부사항엔 항상 문제가 있다
  6. 조직화 vs 캡슐화
  7. 다른 결합 분리 모드
  8. 결론: 빠져있는 조언

계층 기반 패키지

아마도 가장 단순한 첫 번째 설계 방식은 전통적인 수평 계층형 아키텍처다.
기술적인 관점에서 해당 코드가 하는 일에 기반해 그 코드를 분할한다.
흔히 우리는 이 방식을 ‘계층 기반 패키지’라고 부른다.

이 아키텍처는 웹, ‘업무규칙’, 영속성 코드를 위해 계층이 각각 하나씩 존재한다.
다시 말해 코드는 계층이라는 얇은 수평 조각으로 나뉘며, 각 계층은 유사한 종류의 것들을 묶는 도구로 사용된다.
‘엄격한 계층형 아키텍처’의 경우 계층은 반드시 바로 아래 계층에만 의존해야 한다.

img
마틴 파울러는 ‘프레젠테이션 도메인 데이터 계층화’에서 처음 시작하기에는 계층형 아키텍처가 적합하다고 애기 했다.
이건 다양한 책에서도 이야기한다.
이 아키텍처는 엄청난 복잡함을 겪지 않고도 무언가를 작동시켜 주는 아주 빠른 방법이다.
문제는 마틴이 지적했듯이 소프트웨어가 커지고 복잡해지기 시작하면, 머지 않아 큰 그릇 세 개만으로는 모든 코드를 담기엔 부족하다는 사실을 깨닫고, 더 잘게 모듈화해야 할지를 고민하게 될 것이다.

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

기능 기반 패키지

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

아래 그림에서 보듯이 등장하는 인터페이스와 클래스는 이전과 같지만, 모두가 단 하나의 패키지에 속하게 된다.
이제 코드의 상위 수준 구조가 업무 도메인에 대해 무언가를 알려주게 된다.
드디어 우리는 이 코드 베이스가 주문과 관련한 무언가를 한다는 걸 볼 수 있다.

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

수평적 계층화(계층 기반 패키지)의 문제를 깨닫고, 수직적 계층화(기능 기반 패키지)로 전환하는 걸 자주 목격했다.
하지만 두 접근법은 모두 차선책이다.

포트와 어댑터

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

img

‘내부’ 영역은 도메인 개념을 모두 포함하는 반면, ‘외부’ 영역은 외부 세계(UI, DB, 서드파티 통합)와의 상호작용을 포함한다.
여기서 주요 규칙은 바로 ‘외부’가 ‘내부’에 의존하며, 절대로 그 반대로는 안 된다는 점이다.

img

여기에서 com.mycompany.myapp.domain 패키지가 ‘내부’이며, 나머지 패키지는 모두 ‘외부’다.
의존성이 ‘내부’를 향해 흐르는 모습을 주목하라.
계층 기반 패키지, 기능 기반 패키지에서 OrdersRepository가 Orders라는 간단한 이름으로 바뀌었다.
이는 도메인 주도 설계라는 세계관에서 비롯된 명명법으로, 도메인 주도 설계에서는 ‘내부’에 존재하는 모든 것의 이름은 반드시 ‘유비쿼터스 도메인 언어’ 관점에서 기술하라고 조언한다.
도메인에 대해 논의할 때 우리는 ‘주문’에 대해 말하는 것이지, ‘주문 리포지터리’에 대해 말하는 것이 아니다.

이 그림은 다이어그램을 간소화할 때 어떻게 표현할 수 있는지를 보여준다는 점도 짚고 갈 만하다.
이 다이어그램에는 인터랙터가 빠졌고, 의존성 경계를 가로질러 데이터를 마샬링하는 객체 등이 누락되었다.

컴포넌트 기반 패키지

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

엄격한 계층형 아키텍처에서 의존성 화살표는 항상 아래를 향해야 하며, 각 계층은 반드시 바로 아래 계층에만 의존 해야 한다.
여기에는 큰 문제가 있다. 속임수를 써서 몇몇 의존성을 의도치 않은 방식으로 추가하더라도, 보기에는 여전히 좋은 비순환 의존성 그래프가 생성된다는 사실 이다.

아래의 그림을 보면 화살표는 여전히 아래를 향하지만, OrderController가 OrderService를 우회하고 있다.
이러한 조직화는 계층이 인접한 계층(들)을 건너뛰는 일이 허용되기 때문에 흔히 완화된 계층형 아키텍처 라고 부른다.
업무 로직 계층을 우회하는 일은 바람직하지 못하다.

img

여기에서 우리에게 필요한 것은 지침(아키텍처 원칙) 으로, “웹 컨트롤러는 절대로 리포지터리에 직접 접근해서는 안 된다”와 같은 원칙이 필요하다.
물론 문제는 강제성 이다.
많은 팀들이 훌륭한 규율, 코드 리뷰를 통해서 이 원칙을 강제한다.
또는 빌드 시 정적 분석 도구를 사용해서 아키텍처적인 위반 사항이 없는지를 검사한다.

이 방식은 다소 조잡하지만 효과가 있는데, 위반 시 빌드가 실패하기 때문이다.
하지만 두 접근법 모두 오류가 있을 수 있으며, 그 결과를 알게 되는 주기가 필요 이상으로 길다는 문제 가 있다.
개인적으로는 가능하면 컴파일러를 사용해서 아키텍처를 강제하는 방식을 선호 한다.

‘컴포넌트 기반 패키지’를 도입해야 하는 이유는 바로 이 때문 이다.
큰 단위의 단일 컴포넌트와 관련된 모든 책임을 하나의 자바 패키지로 묶는 데 주안점을 둔다.
이 접근법은 서비스 중심적인 시각으로 소프트웨어 시스템을 바라보며, 마이크로서비스 아키텍처가 가진 시각과도 동일 하다.
컴포넌트 기반 패키지에서도 사용자 인터페이스를 큰 단위의 컴포넌트로부터 분리해서 유지한다.

img

이 접근법에서는 ‘업무 로직’과 영속성 관련 코드를 하나로 묶는데, 이 묶음을 나는 ‘컴포넌트’라고 부른다.
엉클 밥은 이 책 앞부분에서 ‘컴포넌트’에 대한 정의를 아래와 같이 제시했다.

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

컴포넌트에 대한 저자의 정의는 약간 다르다.

“컴포넌트는 멋지고 깔끔한 인터페이스로 감싸진 연관된 기능들의 묶음으로, 애플리케이션과 같은 실행 환경 내부에 존재한다.”

이 정의는 나의 ‘C4 소프트웨어 아키텍처 모델’에 따른 것으로 소프트웨어 시스템의 정적 구조를 컨테이너, 컴포넌트, 클래스(또는 코드)의 측면에서 계층적으로 생각하는 간단한 방법이다.
이 방법론에서 소프트웨어 시스템은 하나 이상의 컨테이너(웹 애플리케이션, 모바일 앱, 돌립형 애플리케이션, DB, FS 등)로 구성되며, 각 컨테이너는 하나 이상의 컴포넌트를 포함 한다.
또한 각 컴포넌트는 하나 이상의 클래스(또는 코드)로 구현된다.
이때 각 컴포넌트가 개별 jar 파일로 분리될지 여부는 직교적인 관심사 다.

컴포넌트 기반 패키지 접근법의 주된 이점은 주문과 관련된 무언가를 코딩해야 할 때 오직 한 곳, 즉 OrdersComponent만 둘러보면 된다는 점이다.
이 컴포넌트 내부에서 관심사의 분리는 여전히 유효하며, 따라서 업무 로직은 데이터 영속성과 분리 되어 있다.
하지만 이는 컴포넌트 구현과 관련된 세부사항으로, 사용자는 알 필요가 없다.
이는 마이크로서비스나 서비스 지향 아키텍처를 적용했을 때 얻는 이점과도 유사하다.
즉, 주문 처리와 관련된 모든 것들을 캡슐화하는 별도의 OrderService가 존재한다.
큰 차이는 결합 분리 모드에 있다.
모노리틱 애플리케이션에서 컴포넌트를 잘 정의하면 마이크로 서비스 아키텍처로 가기 위한 발판으로 삼을 수 있다.

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

표면상으로는 이 네 가지 접근법이 코드를 조직화하는 완전히 다른 방식처럼 보이며, 따라서 서로 다른 아키텍처 스타일로 여길 수도 있다.
하지만, 세부사항을 잘못 구현하면 이러한 견해도 아주 빠르게 흐트러지기 시작한다.

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

조직화 vs 캡슐화

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

img
각 타입 사이의 화살표를 유심히 살펴보라.
모두 동일한 방향을 가리킨다.
개념적으로 이 접근법들은 매우 다르지만, 구문적으로는 완전히 똑같다.
이처럼 모든 타입을 public으로 선언한다면, 우리가 실제로 갖게 되는 것은 수평적 계층형 아키텍처를 표현하는 네 가지 방식에 지나지 않는다.

자바에서 접근 지시자를 적절하게 사용하면, 타입을 패키지로 배치하는 방식에 따라서 각 타입에 접근할 수 있는 정도가 실제로 크게 달라질 수 있다.
만약 다이어그램에서 패키지 구조를 다시 살려서 더 제한적인 접근 지시자를 사용할 수 있는 타입을 표시하면, 다이어그램은 상당히 인상적으로 변한다.

img

  • 계층 기반 패키지
    OrdersService와 OrdersRepository 인터페이스는 외부 패키지의 클래스로부터 자신이 속한 패키지 내부로 들어오는 의존성이 존재하므로 public으로 선언되어야 한다.
    나머지는 더 제한적으로 선언할 수 있다.
    이들 클래스는 누구도 알 필요가 없는 구현 세부사항이다.
  • 기반 기능 패키지
    OrdersController가 패키지로 들어올 수 있는 유일한 통로를 제공한다.
    나머지는 더 제한적으로 선언할 수 있다.
    이들 클래스는 누구도 알 필요가 없는 구현 세부사항이다.
  • 포트와 어댑터
    OrdersService와 Orders 인터페이스는 외부로부터 들어오는 의존성을 가지브로 public으로 지정해야 한다.
    나머지는 더 제한적으로 선언할 수 있다.
    이들 클래스는 누구도 알 필요가 없는 구현 세부사항이다.
  • 컴포넌트 기반 패키지
    컨트롤러에서 OrdersComponent 인터페이스로 향하는 의존성을 가진다
    나머지는 더 제한적으로 선언할 수 있다.
    이들 클래스는 누구도 알 필요가 없는 구현 세부사항이다.
    이제 패키지 외부의 코드에서 OrdersRepository 인터페이스나 구현체를 직접 사용할 수 있는 방법이 전혀 없다.
    따라서 우리는 컴파일러의 도움을 받아서 ‘컴포넌트 기반 패키지’ 아키텍처 접근법을 강제할 수 있다.

여기에서 설명한 내용은 모노리틱 애플리케이션에 대한 것으로, 모든 코드가 단 하나의 소스 코드 트리에 존재하는 경우다.
아키텍처 원칙을 강제할 때 자기 규율이나 컴파일러 후처리 도구를 이용하지말고, 반드시 컴파일러에 의지할 것을 권장한다.

다른 결합 분리 모드

프로그래밍 언어가 제공하는 방법 외에도 소스 코드 의존성을 분리하는 방법은 존재할 수 있다.
자바에서 OSGi 같은 모듈 프레임워크자바9에서 제공하는 새로운 모듈 시스템 이 있다.
모듈 시스템을 제대로 사용하면 public 타입과 외부에 공표할 타입을 분리할 수 있다.
예를 들어 Orders 모듈을 생성할 때 모든 타입을 public으로 지정하더라도, 그중 일부 타입만을 외부에서 사용할 수 있도록 공표할 수 있다.

다른 선택지로는 소스 코드 수준에서 의존성을 분리하는 방법 도 있다.
정확하게는 서로 다른 소스 코드 트리로 분리하는 방법이다.
포트와 어댑터를 예로 들면,

  • 업무와 도메인용 소스 코드 : OrdersService, OrdersServiceImpl, Orders
  • 웹용 소스 코드 : OrdersController
  • 데이터 영속성용 소스 코드 : JdbcOrdersRepository

마지막 두 소스 코드 트리는 업무와 도메인 코드에 대해 컴파일 시점에 의존성을 가지며, 업무와 도메인 코드 자체는 웹이나 데이터 영속성 코드에 대해서는 아무것도 알지 못한다.
이상적으로는 이러한 형태를 반복적으로 적용하여 애플리케이션을 구성하는 모든 컴포넌트 각각을 개별적인 소스 코드 트리로 구성해야 한다.

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

  • 도메인 코드(‘내부’)
  • 인프라 코드(‘외부’)

img

이 접근법은 소스 코드를 조직화할 때 효과가 있겠지만, 잠재적으로 절충해야 할 부분이 있음을 알고 있어야만 한다.
나는 이를 ‘포트와 어댑터에 대한 페리페리크 안티 패턴’이라고 부른다.
인프라 코드를 단일 소스 코드에 모두 모아둔다는 말은 애플리케이션에서 특정 영역에 있는 인프라 코드가 애플리케이션의 다른영역에 있는 코드를 직접 호출할 수 있다는 뜻이다.
도메인을 통하지 않고 말이다.
특히 해당 코드에 적절한 접근 지시자를 적용하는 걸 잊어버린 경우라면 이러한 호출을 막기는 더욱 힘들다.

결론: 빠져있는 조언

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

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

클린 아키텍처 - 프레임워크는 세부사항이다.

자바 ORM 표준 JPA 프로그래밍 - JPA 소개