철지난 헥사고날 아키텍처 정리

2023년 헥사고날 아키텍처로 프로젝트를 진행하던 일이있었는데 어른들 사정으로 강제종료 되어버렸고, 그때 팀 내부 공유를 위해 정리했던 헥사고널 아키텍처 정리 문서, 그냥 버리기 아까워서 일단 블로그에 백업….


헥사고날 요점 정리

헥사고날 구조로 설계/개발하기 위한 요점정리 입니다 본 문서를 통해 프로젝트를 진행하기전 별도 서적을 통해 헥사고날 기본 지식을 습득하길 권장합니다.

1. 용어 및 기본 개념

1.1. Hexagonal

헥사고날 아키텍처는 소프트웨어 아키텍처 디자인 패턴 중 하나입니다. 이 아키텍처는 소프트웨어 시스템의 모듈성, 유연성 및 테스트 용이성을 향상시키기 위해 설계되었습니다. 헥사고날 아키텍처는 “헥스”라는 육각형의 모양으로 시스템을 구성하며, 세 가지 주요 원칙을 기반으로 합니다.

  • 내부-외부 분리 원칙: 헥사고날 아키텍처에서는 시스템을 “내부”와 “외부”로 구분합니다. 내부는 핵심 비즈니스 로직을 포함하고, 외부는 외부 인터페이스와 상호 작용하는 컴포넌트를 의미합니다. 이 분리는 내부의 변경이 외부에 영향을 미치지 않도록 하고, 내부와 외부를 독립적으로 개발하고 테스트할 수 있도록 합니다.
  • 의존성 역전 원칙: 헥사고날 아키텍처에서는 의존성 역전 원칙을 따릅니다. 즉, 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 모든 모듈은 추상화에 의존해야 합니다. 이는 시스템의 유연성을 높이고, 모듈 간의 결합도를 낮추는데 도움을 줍니다.
  • 인터페이스 분리 원칙: 헥사고날 아키텍처에서는 인터페이스 분리 원칙을 적용합니다. 인터페이스는 클라이언트와 서비스 사이의 계약으로 간주되며, 각 클라이언트는 필요한 인터페이스만 구현하도록 합니다. 이는 인터페이스의 명확성과 응집력을 높이며, 각 컴포넌트의 책임을 명확하게 분리합니다.

헥사고날 아키텍처는 특히 대규모 및 복잡한 소프트웨어 시스템에 유용하며, 비즈니스 로직의 변경이나 기술 스택의 변경에 유연하게 대처할 수 있는 아키텍처를 제공합니다.

from ChatGPT

1.2. domain

  • 헥사고날의 핵심
  • 헥사고날에서 시도하는 모든 노력은 결국 도메인을 encapsulcation하여 외부에 대한 종속성을 완전히 제거하는데 목적이 있다고 생각할수도 있다.
  • 이를통해 외부의 변화에 대한 도메인의 영향을 최소화하며 도메인 응집력이 강한 소프트웨어를 작성할 수 있도록 한다.
  • 따라서 도메인은 도메인간 참조만 가능하며 그외 다른 파트에 대한 참조가 발생해서는 안된다.

1.3. UseCase

  • 시스템의 실제기능과 1:1로 매핑되는 개념의 인터페이스, port.inbound 와 같은 개념이다.
  • 기존 controller-service-repository 아키텍쳐의 서비스를 대체한다는 개념으로 볼 수 있다.
  • 단, 기존 아키텍쳐의 service가 여러 기능의 집합체라면 헥사고날에서는 하나의 서비스가 하나의 UseCase 만 책임(구현)지도록 하는 것을 권장한다.

1.4. Port

  • Domain을 외부 시스템과 연결하는 개념의 인터페이스 집합
  • inbound port
    • 외부에서 도메인을 호출할때 사용되는 포트 interface
    • inbound port대신 usecase로 패키지명을 정하는 곳도 있다.
  • outbound port
    • 도메인 로직(inbound port 구현체)에서 외부 시스템을 호출할때 사용되는 포트 interface

1.5. Adapter

  • Port와 결합하여 Domain을 실제 외부 시스템과 연결 시켜주는 구현체들의 집합
  • inbound adapter
    • inbound port를 호출하는 어탭터
    • spring web controller, kafka consumer 가 이 패키지에 포함된다.
  • outbound adapter
    • domain이 outbound port를 통해 호출하는 어댑터 (port.outbound의 구현체)
    • JPA repository, Kafka Producer 가 이 패키지의 하부로 포함된다.

1.6 Domain, Port, Adapter 간의 참조 관계

  • domain은 다른 레이어를 참조할수 없다.
    • 원칙적으로 port도 참조 할 수 없다.
    • 단, 다른 모듈에 정의된 domain의 최소한 참조는 허용한다. 하지만 권정되지는 않는다.
  • port도 domain 영역에 포함되므로 다른 레이어를 참조할수 없다.
    • port.inbound의 구현체의 경우도 data 조회, 외부 api 호출 모두 외부 모듈을 참조할수 없다.
    • 모든 외부 요청(참조) 기능은 port.outbound interface 를 통해 정의되고 구현체를 주입받아 처리 되어야 한다.
  • adapter는 domain, port에 대한 간접 참조(interface 참조) 만 허용된다.
    • 상황에 따라 DTO 변환이 과하게 많아지는것을 막기 위해 domain 모델에 대한 참조는 일부 허용한다.
  • 레이어별로 호출/응답에서 사용되는 모든 쿼리(파라메터) 객체와 응답 객체들은 DTO를 통해서 각 레이어 독립적으로 정의/변환되어 사용되는 것이 원칙이다.
    • adapter.inbound => (query & response data mapping) => port.inbound, domain => port.outbound => (query & response data mapping) => adapter.outbound 의 형태가 기본 원칙이다.
    • 각 메핑 레이어를 통해서 데이터 모델에 대한 종속성도 제가하는게 원칙이다.
    • 다만 이구조에서는 무수한 DTO가 생성되는데, 이 수고를 줄이기 위해서는 어느정도 domain 모델의 공유를 허용하여 완화할 수 있다.

2. 통합 빌링의 헥사고날 패키지 구성

헥사고날은 각 모듈별로 분리된 패키지 안애서 헥사고날의 패키지 패턴(domain, application.port, adapter)을 반복하여 구성한다.

net
└─ abh0518
   └─ myapp
      ├─ configuration                 : 모든 설정들이 모어져 있는 패키지
      └─ module
         ├─ moduleA                    : 모듈 별 패키지
         │   ├─ domain                 : 각 모듈별 도메인 영역, 헥사고널의 핵심
         │   ├─ adapter
         │   │   ├─ inbound            : UseCase(port.inbound)를 외부 요청과 연결해주는 adapter를 모아놓는 패키지
         │   │   │   └─ web            : web 항목들(controller영역)을 모아놓은 패키지
         │   │   └─ outbound           : port.outbound의 구현체(adapter)를 모아놓는 패키지
         │   │       └─ persistence    : jpa 모듈들을 모아놓은 패키지
         │   ├─ application
         │       ├─ port
         │       │   ├─ inbound        : UseCase interface가 정의되는 패키지, 각 인터페이스 명은 XXXInPort 로 한다.
         │       │   └─ outbound       : Domain이 사용할 포트를 정의하는 패키지. 각 인터페이스 명은 XXXOutPort로 한다.
         │       └─ service            : port.inbound 구현체를 모아놓는 패키지. 각 클래스 명은 XXXService로 한다.
         └─ support                    : 프로젝트 모듈들이 공유하는 패키지
            ├─ domain                  : 도메인간 공유 영역
            ├─ adapter                 : adapter간 공유 영역
            └─ application             : application간 공유 영역

2.1. 패키지별 설명

net.abh0518.myapp

  • 개발하는 서비스의 최상위 패키지

net.abh0518.myapp.configuration

  • 서비스의 모든 설정을 모아놓는곳
  • 헥사고널의 기본 철학에 따라 module들이 목적한 기능 구현에만 충실하도록 하게 하기위해 외부 설정에 관련된 부분은 별개의 configuration을 통해 DI되도록한다.
  • 이런 구성으로 외부 시스템에 대한 직접적 참조를 회피한다.

net.abh0518.myapp.module.XXX

  • 독립된 모듈 단위로 패키지를 나누도록 한다.
  • 각 모듈들은 동일한 헥사고날 패키지 구조로 반복되어 생산된다.(domain, application, adapter)

net.abh0518.myapp.module.XXX.domain

  • 특정 모듈의 도메인 영역을 모아놓은 패키지
  • 이 패키지는 절대 외부를 참조하는 일이 있어서는 안된다.
  • 단, 도메인간 참조는 허용된다.
  • 도메인 내에서 발생하는 excetion 등 도 모두 이곳에 정의되어야한다.
  • 도메인의 모델 설계는 physical 영역과는 완전 무관하게 설계되어야한다.
  • 도메인 모델과 도메인 모델이 매핑될 entity는 대상이 jpa이든 mongo이든 상관없이 완전히 별개여야 한다.
  • physical 영역의 매핑은 모두 outbound.port 를통해 adapter에게 넘기는 구조이다.

net.abh0518.myapp.module.XXX.application.port

  • inbound
    • UseCase interface가 정의되는 패키지
    • 각 인터페이스 명은 XXXInPort 로 한다.
  • outbound
    • Domain이 사용할 포트를 정의하는 패키지.
    • 각 인터페이스 명은 XXXOutPort로 한다.

net.abh0518.myapp.module.XXX.application.service

  • port.inbound의 구현체를 모아 놓는 패키지
  • Class 명은 XXXService 로 한다.
  • UseCase의 단일책임을 보장하기 위해서 UseCase가 UseCase를 호출(참조)하는 일은 허용되지 않는다.
  • UseCase가 다른 UseCase를 참조하는 경우가 발생한다면 이것은 설계가 잘못된것이다.
  • UseCase간에 공유되는 로직이 있다면 이는 Domain에 정의되어 공유되어야 한다.
  • 외부에서 이 서비스가 직접 참조되는 일은 없어야 한다. (존재조차 모르는게 좋다.)
  • 이 서비스에 대한 모든 참조는 port.inbound 를 통해 주입 받아야 한다.

net.abh0518.myapp.module.XXX.adapter.inbound

  • port.inbound interface를 통해 UseCase를 호출하여 외부의 요청을 Domain 으로 연결해주는 adapter 들의 집합
  • adapter는 호출할 port.inbound interface를 참조하고 실제 service 구현체는 스프링을 통해 주입받아야 한다.
  • adapter.inbound가 어떻게 구현/변경 되든 domaindms port.inbound interface를 통해 호출되므로 아무런 영향이 없다.
  • inbound.web
    • 가장 대포적인 inbound adapter 이다.
    • 사용자의 http 요청을 port.inbound 통해 domain으로 전달하는 adapter

net.abh0518.myapp.module.XXX.adapter.outbound

  • port.outbound 를 통해 들어오는 domain 의 요청을 처리해주는 port.outbound의 구현체
  • outbound.persistence
    • 가장 대표적인 inbound adapter 이다.
    • XXXAdapter로 port.inbound 구현체를 정의하고, 내부 구현은 각 목적에 맞게 알아서(!) 구현하면 된다.
    • adapter.outbound 의 내부 구현/변경이 어떻게 되든 domain은 port.outbound interface로만 참조하므로 아무런 영향이 없다.

2.2. 개발 튜토리얼

2.2.1. 가장 먼저 UseCase(port.inbound)를 정의한다.

  • UseCase가 만들어지며 자연스럽게 domain 모델의 일부도 만들어 진다.
  • 시스템이 외부에 제공해야하는 기능과 1:1로 UseCase가 나오는것이 이상적이다.
  • UseCase가 UsaCase를 참조해야하는 상황이 발생하면 뭔가 설계가 잘못된것이다.

2.2.2. Domain 모델과 Service(port.inbound 구현체, 비지니스 로직)를 작성한다.

  • Domain 모델은 jpa 등 외부 시스템의 entity 모델은 무관하게 완전한 POJO로 작성되어야 한다
    • 예를 들어 기존 패러다엠에선 JPA Entity가 데이터의 중심이 되고 도메인 로직이 JPA Entity에 맞춰 작성이 되었다면, 헥사고날은 이 관계를 역전 시킨다.
    • 도메인 모델은 JPA Entity(혹은 다른 저장 시스템)와 상관없이 오직 도메인 관점으로 설계되어야 한다. JPA 변화에 도메인 모델이 영향을 받는 구조가 되어서는 안된다. 그렇게 된다면 더 이상 헥사고날이 아니다.
    • 따라서 jpa entity는 adapter를 통해 자신이 구현될때 알아서 domain model에 맞춰 자신을 매핑하던가 변경을 하던가 알아서 해야한다.
    • 프레임워크에 포함된 annotation 뿐 아니라 데이터 마샬링을 json annotation도 붙이지 않을 것을 권장한다. (이 항목들은 모두 adapter가 알아서 처리해야할 부분이다. => 기존 도메인이 고생했던 부분을 모두 adapter에게 넘기고 domain은 자신의 관심사만 깔꿈하게 구현되도록 하는 목적이다.)
  • 이 과정에서 자연스레 port.outbound가 정리가 된다.
  • 하나의 port.outbound를 여러 UseCase에서 사용하는건 문제가 되지 않는다.

2.2.3 adapter.outbound 을 구현한다.

  • domain에서 정의한 port.outbound의 구현체(Adapter) 클래스들을 만드는 단계
  • adapter에서 사용할 외부 구현체들(jpa, external api call service) 도 함께 개발한다.
  • physical 영역을 domain이 정의한 인터페이스 구조에 맞춰 개발 해야하는 곳이므로 헥사고날에서 가장 힘들고 더러운 일을 모두 맡아 하는 영역이다.
    • JPA repository가 대표적인데, repository adapter의 구현이 상당히 고통스러울수 있다.
    • 앞서 도메인 관점으로만 port.outbound와 도메인 모델이 정의되어 있기 때문에, Physical 영역(JPA Entity)과 domain 영역을 어떻게든 끼워 맞춰야 하는 adpter.outbound 는 상당히 고통스러운 작업을 동반한다.

2.2.4 adapter.inbound 를 구현한다.

  • domain의 port.inbound에 연결할 adapter.inbound의 연결점(Adapter) 클래스를 만드는 단계
  • web의 경우 해당 패키지 내에서 외부와 소통할때 사용할 데이터 모델, 마샬링 등을 직접 정의하거나 configuration쪽에 위임한다. (domain은 이부분에 대해서 알 필요가 없다.)
  • domain으로 전달되는 data 변환만 주로 처리하다보니 adapter.outbound 보다 고통이 좀 덜하다.

2.3. 테스트 코드의 범위

2.3.1. 모듈 테스트

  • Domain과 각 UseCase별로 모듈 테스트를 작성한다.
  • 각 UseCase에서 사용되는 port.outbound는 mocking하여 사용한다.
  • UsaCase 테스트가 충분하다면 Domain별 테스트가 필수는 아니다.
  • 도메인과 UseCase 영역만 테스트 하므로 테스트 코드에 framework 관련 코드가 들어가지 않는다. 테스트 코드에 framework 관련 설정이 들어가게 된다면 뭔가 잘못 설계 된 것이다.

2.3.2. 통합 테스트

  • adapter.inbound, adapter.outbound 별로 통합 테스트를 작성한다.
  • pyhsical layer들을 모두 연결하는 테스트이므로 framework 설정이 테스트코드에 들어간다.
  • adapter 테스트를 통해 관련된 web, kafka, jap repository 테스트가 자연스레 함께 진행된다.

3. 왜 헥사고날을 쓰지?

장점

  • 도메인이 완벽히 인캡슐레이션 되어 외부 변경에 대한 영향이 최소화 된다.
  • 도메인을 순수하게 도메인 과련 코드로만 응집시킬 수 있다.
  • 단위 기능(UseCase)에 대한 책임 소재가 명확해져 소스 분석 및 추적, 테스트 코드 작성이 수월해 진다.
  • 제대로 적용을 했다면 db쪽 스키마를 홀라당 뒤집어도 domain쪽에는 영향이 없다.
    • 다만, adapter 가 죽어나갈뿐이다. domain의 변경이 없는 대신 그만큼 변화된 환경에 맞춰 adapter의 변경량이 많아진다.

단점

  • Layer별(domain, port, )로 DTO, Interface들이 모두 분리해서 작성되어야 되다보니 기존 패러다임 대비해서 코드량이 상당히 늘어는다.
  • 좀 심하면 Domain개발을 깔짝 한 뒤에 DTO와 Adapter 작업하다 진이 다 빠질 정도다.
  • 모든게 domain 중심이다보니 domain을 중심으로 주변 모듈들의 변화량이 꽤 많아지는걸 각오해야한다.
  • 헥사고날은 코드량을 줄여주지 않는다. 핵심 domain을 외부 변화로부터 최대한 보호하는게 목적이고 그 목적 달성을 위해 domain외 관련 코드들의 변화나 테스트 코드의 증가는 감수하겠다는 각오(?)가 필요하다.