헥사고날 아키텍처

2024-04-07 20:27:51
#architecture#hexagonal#ports-and-adapters#clean-architecture

헥사고날 아키텍처란?

헥사고날 아키텍처는 2005년 Alistair Cockburn이 제안한 아키텍처 패턴입니다. "포트와 어댑터(Ports and Adapters)" 아키텍처라고도 불러요. 핵심 아이디어는 비즈니스 로직을 중심에 두고, 외부 세계(UI, DB, 외부 API 등)와의 통신을 포트와 어댑터로 분리하는 겁니다.

육각형 모양으로 그리는 건 특별한 이유가 있는 게 아니라, "여러 방향에서 접근 가능하다"는 걸 표현하기 위해서예요.

왜 사용해야 할까요?

  • 포트와 어댑터라는 컴포넌트를 두어 비즈니스 코드를 기술 코드로부터 분리 하여 즉 외부 기술이 변경되더라도 비즈니스 코드에 영향을 주지 않고도 기술 코드를 변경할 수 있습니다 (Change-Tolerant)
  • 도메인 규칙이 변할 경우에는 유일하게 도메인 헥사곤만 변경합니다. 만약 새로운 프로토콜을 사용하는 기능이 추가되면 프레임워크 헥사곤에서 사용하는 새로운 어댑터만 추가하면 됩니다. (Maintainability)
  • UI/데이터베이스에 의존하지 않는 도메인 헥사곤이 테스트하기 용이합니다. (Testability)

헥사고날 아키텍처의 구성 요소

도메인 헥사곤 (Domain Hexagon)

  • 소프트웨어가 해결하기를 원하는 핵심 문제를 설명하는 요소를 Entity ObjectValue Object(값 객체)를 사용하여 결합합니다.
  • 비즈니스 규칙을 엔티티와 값 객체로 캡슐화합니다.
  • 소프트웨어가 풀고자 하는 실 세계 문제를 이해하고 모델링하는 영역입니다.
  • Entity는 기술적인 요구사항으로부터 보호되어야 합니다. 즉 특정 기술에 연관되지 않아야 합니다.
  • 도메인 헥사곤에 위치한 도메인 서비스는 외부의 애플리케이션, 프레임워크 헥사곤에는 의존해서는 안됩니다. 반대로 애플리케이션 헥사곤이나, 프레임워크 헥사곤은 도메인 헥사곤에 의존하는 관계입니다.

애플리케이션 헥사곤 (Application Hexagon)

도메인의 비즈니스 규칙을 조합해서 실제 기능을 만드는 곳이에요. 비즈니스와 기술 사이에서 중재자 역할을 합니다. 유스케이스, 입력 포트, 출력 포트로 구성됩니다.

유스케이스

  • 유스케이스는 도메인 제약 사항을 지원하기 위해 시스템의 동작을 소프트웨어 영역내 존재하는 애플리케이션 특화 오퍼레이션을 통해 나타냅니다.
  • 도메인 헥사곤의 엔티티와 다른 유스케이스와 직접 상호작용할 수 있습니다.
  • 다음과 같이 인터페이스로 소프트웨어가 할 수 있는 동작을 추상화로 나타냅니다.
public interface RouterViewUseCase {

    List<Router> getRouters(Predicate<Router> filter);
}

입력 포트

유스케이스 인터페이스의 구현체가 입력 포트입니다. "그냥 Service/ServiceImpl이랑 뭐가 다르냐?"고 물을 수 있는데, 차이점은 입력 포트가 출력 포트에만 의존한다는 거예요. Repository 같은 구체적인 기술에 직접 의존하지 않습니다.

public class RouterViewInputPort implements RouterViewUseCase {

    private RouterViewOutputPort routerViewOutputPort;

    public RouterViewInputPort(RouterViewOutputPort routerViewOutputPort) {
        this.routerViewOutputPort = routerViewOutputPort;
    }

    @Override
    public List<Router> getRouters(Predicate<Router> filter) {
        var routers = routerViewOutputPort.fetchRouters();
        return Router.retrieveRouter(routers, filter);
    }
}

출력 포트

  • 유스케이스는 목표를 달성하기 위해 외부 리소스에서 데이터를 가져와야 하는 상황이 있습니다. 이때 출력 포트가 외부에서 데이터를 제공하는 인터페이스 역할을 수행합니다.
  • 이때 출력 포트는 특정 기술에 종속되지 않습니다. 즉 데이터가 RDB로부터 오든 API로부터 오든 세분화된 책임은 출력 어댑터에 할당됩니다.
public interface RouterViewOutputPort {

    List<Router> fetchRouters();
    
}

프레임워크 헥사곤 (Framework Hexagon)

외부와 실제로 통신하는 어댑터들이 사는 곳입니다. 통신 방식은 크게 두 가지로 나뉘어요:

  • Driving (Primary): 외부 → 우리 시스템 (예: REST Controller, CLI)
  • Driven (Secondary): 우리 시스템 → 외부 (예: DB Repository, 외부 API Client)

Driving Operation

  • 프론트 서버에서 우리 소프트웨어에 동작을 요청하는 방식이다.즉 입력 어댑터(Input Adapter)를 사용한다.
  • 드라이빙이라는 용어를 사용하는 이유가 외부에서 우리 소프트웨어의 동작을 유도(driving)하기 때문에 driving operation이라고 부른다고 합니다.
public class RouterViewCLIAdapter {

    RouterViewUseCase routerViewUseCase;

    public RouterViewCLIAdapter(RouterViewUseCase routerViewUseCase) {
        this.routerViewUseCase = routerViewUseCase;
    }

    public List<Router> obtainRelatedRouters(String type){
        return routerViewUseCase.getRouters(router-> router.filterRouterByType(type));
    }

    private void setAdapters(){
        // 유스케이스 인터페이스를 통해 입력 포트를 사용한다. 
        this.routerViewUseCase = new RouterViewInputPort(RouterViewFileAdapter.getInstance());
    }
}
  • 위와 같이 입력 어댑터를 입력 포트에 연결하여 사용할 수 있다.

Driven Operation

  • Driving Operation과 반대로 우리 소프트웨어에서 외부에 요구사항을 만족시키기 위한 데이터를 가져옵니다.
  • 출력 어댑터(Output Adapter)를 통해서 Driven Operation을 정의합니다.
  • 입력 어댑터가 입력 포트와 매핑되야 하듯이, 출력 어댑터도 출력 포트와 매핑되야 합니다.
  • 구체적인 예시로 Oracle부터 데이터를 가져오는 출력 어댑터, MongoDb로부터 데이터를 가져오는 출력 어댑터 등이 있을 수 있습니다.
public class RouterViewFileAdapter implements RouterViewOutputPort {

    @Override
    public List<Router> fetchRouters() {
        return readFileAsString();
    }

    private List<Router> readFileAsString() {
        // 외부파일을 읽어 List<Router>를 생성해서 반환하는 로직
    }

}

의존성 방향

헥사고날 아키텍처에서 의존성은 항상 바깥에서 안쪽으로 향합니다.

프레임워크 헥사곤 → 애플리케이션 헥사곤 → 도메인 헥사곤

도메인 헥사곤은 아무것도 의존하지 않고, 애플리케이션 헥사곤은 도메인만 알고, 프레임워크 헥사곤이 나머지를 다 알아요. 이게 의존성 역전 원칙(DIP)을 자연스럽게 따르는 구조입니다.

레이어드 아키텍처와 비교

구분 레이어드 헥사고날
의존성 방향 위 → 아래 (Presentation → Business → Data) 바깥 → 안쪽
DB 의존성 Business 레이어가 Data 레이어에 의존 도메인이 DB를 모름 (출력 포트로 추상화)
테스트 용이성 DB Mocking 필요 포트만 Mocking하면 됨
복잡도 단순 상대적으로 복잡

레이어드 아키텍처가 나쁜 건 아닙니다. 단순한 CRUD 앱이라면 레이어드가 더 적합할 수 있어요. 헥사고날은 비즈니스 로직이 복잡하고, 외부 의존성이 자주 바뀌는 경우에 빛을 발합니다.

적용 예시

  • 아래 예시는 참고일 뿐 이렇게 구성해야만 한다는 아닙니다!

Spring Boot 패키지 구조 예시

com.example.order/
├── adapter/
│   ├── in/
│   │   └── web/
│   │       └── OrderController.java
│   └── out/
│       └── persistence/
│           ├── OrderJpaEntity.java
│           ├── OrderJpaRepository.java
│           └── OrderPersistenceAdapter.java
├── application/
│   ├── port/
│   │   ├── in/
│   │   │   └── CreateOrderUseCase.java
│   │   └── out/
│   │       └── SaveOrderPort.java
│   └── service/
│       └── CreateOrderService.java
└── domain/
    └── Order.java

JPA Entity 분리는 필수인가요?

엄격하게 적용하면 도메인 Order와 JPA OrderJpaEntity를 분리해야 해요. 도메인 객체에 @Entity 같은 JPA 어노테이션이 붙으면 기술에 종속되니까요.

하지만 실무에서는 trade-off가 있습니다:

  • 분리하면: 순수한 도메인, 하지만 매핑 코드 증가
  • 합치면: 코드 간결, 하지만 도메인이 JPA에 종속

팀 상황에 맞게 선택하면 됩니다. 개인적으로는 복잡한 도메인이면 분리하고, 단순하면 합치는 편이에요.

언제 쓰면 좋을까?

  • 비즈니스 로직이 복잡한 도메인
  • DB나 외부 API가 자주 바뀔 가능성이 있을 때
  • 테스트 커버리지를 높이고 싶을 때
  • MSA에서 각 서비스의 독립성을 높이고 싶을 때

주의할 점

  • 단순 CRUD에는 오버엔지니어링이 될 수 있음
  • 팀원들이 패턴을 이해해야 일관성 유지 가능
  • 초기 설계 시간이 더 필요함

마무리

헥사고날 아키텍처의 핵심은 "비즈니스 로직을 보호하자"입니다.

  • 포트: 애플리케이션이 외부와 통신하는 인터페이스
  • 어댑터: 포트를 실제 기술로 구현한 것
  • 의존성 방향: 항상 바깥에서 안쪽으로

처음엔 복잡해 보이지만, 익숙해지면 "DB를 MySQL에서 PostgreSQL로 바꿔야 해"라는 요청이 와도 출력 어댑터만 수정하면 됩니다. 비즈니스 로직은 건드릴 필요가 없어요.

참고문헌

프로필 이미지
@chani
바둑, 스타크래프트 등 고전 게임을 좋아하는 내향인 개발자입니다

댓글