헥사고날 아키텍처의 이해
"여러분의 애플리케이션을 UI나 데이터베이스 없이 동작하도록 만드십시오. 그러면 애플리케이션에 대해 자동화된 회귀 테스트를 실행할 수 있고, 데이터베이스를 사용할 수 없을 때도 동작합니다. 그리고 어떤 사용자의 개입 없이도 애플리케이션을 함께 연결할 수 있습니다."
- 알리스테어 코크번 ( Alistair Cokburn )
헥사고날 아키텍처의 조된 아이디어 중 하나는 비즈니스 코드를 기술 코드로 부터 분리하는 것이다. 그뿐만 아니라 기술 측면이 비즈니스 측면에 의존하는지도 확인해 비즈니스 측면이 비즈니스 목표를 달성하는데 사용되는 기술에 대한 우려 없이도 발전할 수 있게 해야 한다.
또한 비즈니스 코드에 피해를 주지 않고 기술 코드를 변경할 수 있어야 한다. 이러한 목표를 달성하려면 비즈니스 코드가 어디에 존재해야 하는지, 기술 문제로부터 격리되고 보호돼야 하는 위치를 결정해야 한다. 이것은 우리의 첫 번째 헥사곤, 즉 도메인 헥사곤 (Domain hexagon) 을 생성하게 될 것이다.
헥서고날 아키첵터에서는 총 3개의 헥사곤을 구성한다.
- 도메인 헥사곤
- 도메인 헥사곤은 소프트웨어가 해결하기를 원하는 핵심 문제를 설명하는 요소들을 결합한다. (비즈니스 코드 개발 )
- 도메인 헥사곤의 주된 요소는 엔티티(Entity), 값 객체 (Value Object) 이다.
- 엔티티는 식별자를 할당하는 것을 의미하며, 값 객체는 엔티티들을 합성하기 위해 사용하는 불변 컴포넌트이다.
- 애플리케이션 헥사곤
- 도메인 헥사곤에서 나오는 비즈니스 로직의 결과를 사용, 처리하고 조정하는 역할을 한다.
- 애플리케이션 헥사곤은 비즈니스 측면과 기술 측면 사이에 있으며, 양쪽과 상호작용하는 중개자 역할을 한다.
- 애플리케이션 헥사곤은 포트와 유스케이스를 활용해 이런한 기능을 수행한다.
- 프레임워크 헥사곤
- 애플리케이션 기능의 노출 방법을 결정할 수 있는 곳이다.
- 프레임워크 헥사곤에서 REST나 gRPC 엔드 포인트를 정의한다.
- 외부 소스에서 무언가를 소비하기 위해 데이터베이스, 메시지 브로커 또는 다른 시스템에서 데이터를 가져오는 메커니즘을정의히기 위해 프레임워크 헥사곤을 사용한다.
헥사고날 아키텍처에서는 어댑터를 통해 기술 결정을 구체화한다. 다음 다이어그램은 해당 아키텍처에 대한 상위 수준의 뷰를 제공한다.
도메인 헥사곤
도메인 헥사곤은 실 세계 문제를 이해하고 모델링하는 활동을 나타낸다. 도메인 헥사곤 안에는 중요한 비즈니스 데이터와 규칙에 관련된 엔티티들이 있다. 모든 스프트웨어가 간단하게 시작해서, 기반 코드가 커짐에 따라 기술 부채가 누적되고 유지보수가 더 어려워지는 이유 중 하나로 여겨진다. 이러한 약한 가장들은 초기에는 비즈니스 문제를 해결할 수 있지만, 응집력 있게 변경을 수용할 준비가 되어 있지 않은 취약하고 표현력이 부족한 코드로 이어질 수 있다. 도메인 헥사곤은 문제 영역을 표현하는 데 유용한 모든 종류의 객체들로 구성된다. 도메인 헥사곤의 구성 영역에는 엔티티 영역, 값 객체이 있다.
엔티티
엔티티는 좀 더 표현력 있는 코드를 작성하는 데 도움을 준다. 예를 들어, 라우터가 다른 라우터나 네트워크 장비와 갖는 관계를 설명하는 일부 속성을 할당할 수 있다. 시간이 지나면서 이러한 모든 속성은 변할 수 있다. 따라서 문제 영역에서 라우터가 고정된 것이 아니고, 라우터의 특성이 변경될 수 있다는 사살을 알 수 있다. 이 때문에 라우터는 수명주기를 가진다고 말할 수 있다. 이 외에도 모든 라우터는 인벤토리에서 고유해야 하므로 실별자를 가져야 한다. 이러한 연속성과 정체성은 엔티티를 결정하는 요소이다.
다음 코드는 RouterType 과 RouterId로 구성된 Router 엔티티 클래스를 보여준다.
public class Router {
private final RouterType routerType;
private final RouterId routerId;
public Router(RouterType routerType, RouterId routerId) {
this.routerType = routerType;
this.routerId = routerId;
}
public static Predicate<Router> filterRouterByType(RouterType routerType){
return routerType.equals(RouterType.CORE)
? isCore() :
isEdge();
}
private static Predicate<Router> isCore(){
return p -> p.getRouterType() == RouterType.CORE;
}
private static Predicate<Router> isEdge(){
return p -> p.getRouterType() == RouterType.EDGE;
}
public static List<Router> retrieveRouter(List<Router> routers, Predicate<Router> predicate){
return routers.stream()
.filter(predicate)
.collect(Collectors.<Router>toList());
}
public RouterType getRouterType(){
return routerType;
}
@Override
public String toString(){
return "Router{" +
"routerType=" + routerType +
", routerId=" + routerId +
'}';
}
}
값 객체
값 객체는 무언가 고유하게 식별할 필요가 없는 경우는 물론이고, 객체의 정체성보다 속성에 관심을 갖는 경우에도 코드의 표현력을 보완하는데 도움이 된다. 값 객체를 사용해 엔티티 객체를 구성할 수 있다. 따라서 도메인 전체에서 예상치 못한 불일치를 방지하기 위해 값 객체를 변경할 수 없게 해야 한다. 이전 라이터 예제에서 그랬듯이, 라이터 Type을 Router 엔티티에서 값 객체 속성을 나타낼 수 있다.
public enum RouterType {
EDGE,
CORE;
}
애플리케이션 헥사곤
애플리케이션 헥사곤은 애플리케이션 특화 작업을 추상적으로 처리하는 곳이다. 이 헥사곤은 도메인 비즈니스 규칙에 기반한 소프트웨어 사용자의 의도와 기능을 표현한다. 애플리케이션 헥사곤의 구성 영역에는 유스케이스(Use Case), 입력 포트(Input Port), 출력 포트(Output Port)를 기반으로 하는 고수준의 애플리케이션 헥사곤 구조를 보여준다.
유스케이스
유스케이사는 도메인 제약사항을 지원하기 위해 시스템의 동작을 소프트웨어 영역 내에 존재하는 애플리케이션 특화 오퍼레이션을 통해 나타낸다. 유스케이스는 엔티티 및 다른 유스케이스와 직접 상호작용하고 그것들을 유연한 컴포넌트로 만들 수 있다. 자바에서 유스케이스는 소프트웨어가 할 수 있는 것을 표현하는 인터페이스로 추상화 된다.
public interface RouterViewUseCase {
List<Router> getRouters(Predicate<Router> filter);
}
public class RouterViewInputPort implements RouterViewUseCase {
private RouterViewOutputPort routerListOutputPort;
public RouterViewInputPort(RouterViewOutputPort routerViewOutputPort) {
this.routerListOutputPort = routerViewOutputPort;
}
@Override
public List<Router> getRouters(Predicate<Router> filter) {
var routers = routerListOutputPort.fetchRouters();
return Router.retrieveRouter(routers, filter);
}
}
@Test
void retrieveRouter() {
// given (준비)
List<Router> routers = List.of(
new Router(RouterType.EDGE, RouterId.of("1")),
new Router(RouterType.CORE, RouterId.of("2")),
new Router(RouterType.EDGE, RouterId.of("3"))
);
Predicate<Router> predicate = Router.filterRouterByType(RouterType.EDGE);
// when (실행)
List<Router> result = Router.retrieveRouter(routers, predicate);
// then (단언 assert)
assertEquals(2, result.size());
}
위 예제는 도메인 제약사항을 사용해 조회하고자 하는 라우터를 필터링 하는 방법을 보여준다. 입력 포트의 구현에서는 애플리케이션 외부에서 라우터의 리스트를 가져올 수도 있다. 우리는 출력 포트를 사용해 이런한 동작을 수행할 수 있다.
출력 포트
유스케이스의 목표를 달상하기 위해 외부 리소스에서 데이터를 가져와야 하는 상황이 있다. 이것이 출력 포트의 역할이다. 출력 포트는 유스케이스나 입력 포트가 오퍼레이션을 수행하기 위해 어떤 종류의 데이터를 외부에서 가져와야 하는지 기술에 구애 받지 않고 설명하는 인터페이스로 표현된다.
public interface RouterViewOutputPort {
List<Router> fetchRouters();
}
프레임워크 헥사곤
도메인 헥사곤으로 제한되는 중요한 비즈니스 규칙은 모든 것이 잘 구성된 것처럼 보인다. 다음으로 유스케이스, 입력 포트, 출력 포트를 통해 일부 애플리케이션 특화 오퍼레이션을 다루는 애필리케이션 헥사곤이 뒤 따른다. 이제 소프트웨어와 통신할 수 있는 기술을 결정해야 하는 순간이 왔다. 통신은 두 가지 형태로 발생할 수 있다. 하나는 드라이빙(driving) 방식이고 다른 하나는 드리븐(driven) 방식이다. 다음 다이어 그램에서 볼 수 있듯이, 드라이버 관점의 경우는 입력 어댑터(Input Adapter)를 사용한다. 그리고 드리븐 관점의 경우 출력 어댑터(Output Adapter)를 사용한다.
드라이빙 오퍼레이션과 입력 어댑터
드라이빙 오퍼레이션은 소프트웨어에 동작을 요청하는 것이다. 예를 들어, 드라이빙 오퍼레이션은 명령행 클라이언트를 갖는 사용자나 사용자를 대신하는 프론트엔트 애플리케이션이 될 수 있다. 소프트웨어에 의해 노출된 항목들의 정확성을 검사하는 일부 테스트가 있을 수 있다.
이러한 통신은 입력 어댑터 상단에 구축된 애플리케이션 프로그래밍 인터페이스(API)를 통해 일어난다.
이 외부 엔티티가 시스템과 상호작용하고, 외부 엔티티의 요청을 여러분의 도메인 애플리케이션으로 변환하는 방법을 정의한다. 드라이빙이라는 용어를 사용하는 이유는 이러한 외부 엔티티들이 시스템 동작을 유도(driving)하기 때문이다. 입력 어댑터는 다음과 같이 애플리케이션에서 지원하는 프로토콜을 정의할 수 있다.
다음은 유스케이스 참조를 사용해 입력 포트 오퍼레이션 중 하나를 호출하는 입력 어댑터다.
public class RouterViewCLIAdapter {
RouterViewUseCase routerViewUseCase;
public RouterViewCLIAdapter(){
setAdapters();
}
public List<Router> obtainRelatedRouters(String type) {
return routerViewUseCase.getRouters(
Router.filterRouterByType(RouterType.valueOf(type)));
}
private void setAdapters(){
this.routerViewUseCase = new RouterViewInputPort(RouterViewFileAdapter.getInstance());
}
}
이 예제는 STDIN에서 데이터를 가져오는 입력 어댑터의 생성 방법을 보여준다. 유스케이스 인터페이스를 통해 입력 포트를 사용하는 것을 눈여겨보자. 여기서는 도메인 헥사곤의 제약사항을 다루기 위해 애플리케이션 헥사곤에서 사용하는 입력 데이터를 갭슐화하는 명령어를 전달했다. 시스템에서 REST 같은 다른 통신 형식을 활성화하려면 REST 통신 엔드포인트 노출을 위한 의존성을 포함하는 새로운 REST 어댑터를 생성하면 된다.
드라븐 오퍼레이션과 출력 어댑터
드라이븐 오퍼레이션의 반대편에는 드리븐 오퍼레이션이 있다. 이 오퍼레이션은 애플리케이션에서 트리거되고, 외부에서 소프트웨어 요구사항을 충족시키는 데 필요한 데이터를 가져온다.
출력 포토는 일부 애플리케이션 특화 작업을 수행하는 데 필요한 데이터의 종류를 알려준다는 점을 기억하자. 데이터를 어떻게 가져올지 설명하는 것은 출력 어댑터에 달려 있다. 다음은 출력 어댑터와 드리븐 오퍼레이션에 대한 다이어그램이다.
애플리케이션이 오라클 관계형 데이터베이스로 시작 했고, 얼마 후 기술을 변경해 데이터 소스로 MongoDB를 수용하는 NoSQL 방식으로 바꾸기로 결정했다고 가정해 보자. 처음에는 오라클데이터 베이스와 지속성을 허용하는 출력 어댑터만 가지고 있었다. MongoDB와 통신하기 위해 애플리케이션 헥사곤과 가장 중요한 도메인 헥사곤을 건들지 말고 프레임워크 헥사곤에 출력 어댑터를 생성해야 한다. 입력 어댑터와 출력 어댑터 모두 헥사곤 내부를 가리키고 있기 때문에 이것들을 애플리케이션 및 도메인 헥사곤에 종속되게 만들어 의존성을 역전시킨다.
드리븐(driven) 이라는 용어를 사용하는 이유는 헥사고날 애플리케이션 자체에 의해 이러한 오퍼레이션은 유도되고 통제되며, 다른 외부 시스템에서 동작을 트리거하기 때문이다. 다음 예저에서 애플리케이션이 외부 데이터를 얻는 방법을 지정하기 위해 출력 어댑터가 출력 포트 인터페이스를 구현하는 방법을 눈여겨 보자
public class RouterViewFileAdapter implements RouterViewOutputPort {
private static RouterViewFileAdapter instance;
@Override
public List<Router> fetchRouters() {
return readFileAsString();
}
private static List<Router> readFileAsString() {
List<Router> routers = new ArrayList<>();
try (Stream<String> stream = new BufferedReader(
new InputStreamReader(
RouterViewFileAdapter.class.getClassLoader().
getResourceAsStream("routers.txt"))).lines()) {
stream.forEach(line ->{
String[] routerEntry = line.split(";");
var id = routerEntry[0];
var type = routerEntry[1];
Router router = new Router(RouterType.valueOf(type),RouterId.of(id));
routers.add(router);
});
} catch (Exception e){
e.printStackTrace();
}
return routers;
}
private RouterViewFileAdapter() {
}
public static RouterViewFileAdapter getInstance() {
if (instance == null) {
instance = new RouterViewFileAdapter();
}
return instance;
}
}
출력 포트는 애플리케이션이 외부로부터 필요로 하는 데이터를 나타낸다.
헥사고날 접근 방식의 장점
회사나 개인 프로젝트에서 소프트웨어를 개발하는 방법을 표준화하는 데 도움이 되는 패턴을 찾고 있다면 헥사고날 아키텍처는 클래스, 패키지, 코드 구조가 전체적으로 구성되는 방법에 영향을 주어 이러한 표준을 만드는 기초로 사용할 수 있다.
변경과 테스트가 쉬운, 유지보수하기 좋은 애플리케이션은 언제나 환영받는다. 이제 헥사고날 아키텍처가 이러한 장점을 얻는 데 어떻게 도움이 되는지 살펴보자
변경 허용 ( Change-tolerant)
기술의 변화는 빠른 속도로 일어나고 있다. 새로운 프로그래밍 언어와 수많은 정교한 도구가 매일 등장하고 있다. 경쟁에서 이기려면 잘 확립되고 오랜 시간 테스트된 기술을 고집하는 것만으로 충분하지 않다. 첨단 기술의 활용은 더 이상 선택이 아닌 필수가 되었으며, 소프트웨어가 이러한 변경을 수용할 준비가 되어 있지 않다면 소프트웨어 아키텍처가 변화에 대한 내성이 없기 때문에 회사는 대규모 리팩토링에 돈과 시간을 날려버릴 수도 있다.
따라서 헥서고날 아키텍처의 포트와 어댑터라는 특성은 마찰이 적고 기술 변화를 흡수할 준비가 되어 있는 애플리케이션을 위한 아키텍처 원칙을 강력한 이점으로 제공한다.
유지보수성 ( Maintainability )
비즈니스 규칙을 변경해야 하는 경우 유일하게 변경해야 하는 것은 도메인 헥사곤이다. 반면, 아직 애플리케이션에서 지원하지 않는 특정 기술이나 프로토콜을 사용하는 기존 기능을 고객이 트리거할 수 있게 허용해야 하는 경우 프레임워크 헥사곤에서 실행할 수 있는 새로운 어댑터를 생성하기만 하면 된다. 이러한 관심사의 분리는 단순해 보이지만 아키텍처 원칙으로 시행되는 경우 소프트웨어의 복잡성을깊이 있게 살펴보기 전에 기본적으로 소프트웨어 구조를 파악하는 데 따르는 정신적인 부하를 줄이기에 충분한 예측 가능성을 보장한다.
테스트 용이성 ( Testability )
헥사고날 아키텍처의 궁극적인 목표 중 하나는 알리스테어 코크번이 말한 것처럼 UI와 데이터베이스 같은 외부 의존성이 없더라도 개발자가 애플리케이션을 테스트할 수 있게 하는 것이다. 그러나 이것은 헥사고날 아키텍처가 통합 테스트를 무시한다는 의미는 아니다. 이와는 별개로, 헥사고날 아키텍처는 코드의 가장 중요한 부분을 테스트하는 데 필요한 유연성을 제공함으로써 더욱더 계속해서 통합하는 방식을 허용한다.
아키텍처 구조
├── src
│ ├── main
│ │ ├── java
│ │ │ └── dev
│ │ │ └── davivieira
│ │ │ ├── App.java
│ │ │ ├── application
│ │ │ │ ├── ports
│ │ │ │ │ ├── input
│ │ │ │ │ │ └── RouterViewInputPort.java
│ │ │ │ │ └── output
│ │ │ │ │ └── RouterViewOutputPort.java
│ │ │ │ └── usecases
│ │ │ │ └── RouterViewUseCase.java
│ │ │ ├── domain
│ │ │ │ ├── Router.java
│ │ │ │ ├── RouterId.java
│ │ │ │ └── RouterType.java
│ │ │ └── framework
│ │ │ └── adapters
│ │ │ ├── input
│ │ │ │ └── stdin
│ │ │ │ └── RouterViewCLIAdapter.java
│ │ │ └── output
│ │ │ └── file
│ │ │ └── RouterViewFileAdapter.java
│ │ └── resources
│ │ └── routers.txt
참조 : 만들면서 배우는 헥사고날 아키텍처 설계와 구현 ( 다비 비에이라 )