Hexagonal Architecture, 어렵지 않게 이해하기

2023. 8. 3. 01:45BACKEND

반응형

본 포스팅은 Hexagonal Architecture의 등장 배경과 개념을 이해하는 것을 목표로 합니다.

 

본 편 이후, Hexagonal Architecture를 Spring을 통해 실제로 구성하는 내용을 다룰 예정입니다.

 

 

 

Architecture

소프트웨어 개발은 복잡합니다.

생각 없이 코드를 작성하다 보면 서로 얽히고설킨 구조가 만들어지기 쉽죠.

하지만, 소프트웨어의 특성 상 비즈니스는 변경되기 쉽고, 코드 또한 변경되기 쉽습니다. 

 

소프트웨어의 아키텍처를 아래의 순서에 따라 살펴 보도록 하겠습니다.

 

Layered Architecture

Clean Architecture

- Hexagonal Architecture

 

Layered Architecture은 Clean Architecture가 나오기까지의 과정을 소개하기 위해 담은 내용입니다. 

바로 Hexagonal Architecture를 찾아보셔도 문제없이 읽으실 수 있습니다.

 

 

 

Layered Architecture

 

전통적인 아키텍처로는 계층형 아키텍처가 있습니다.

현재까지 많이 사용합니다.

계층형 아키텍처는 같은 목적의 코드들을 같은 계층으로 그룹화한 것으로, 역할과 관심사를 계층으로 분리합니다.

 

 

https://overcoded.dev/posts/Arch-14

 

 

✔️ Presentation Layer

: 사용자와의 상호 작용(UI)을 처리하는 계층

 

 

✔️ Domain Layer

= Business or Service Layer

: 서비스 및 시스템의 핵심 로직으로, 비즈니스 로직을 포함한 작업을 처리하는 계층

 

Presentation 계층에서 전달 받은 데이터의 유효성(Validation) 검사를 포함하고,

Persistence의 연결을 결정하여 사용합니다.

 

 

✔️ Persistence Layer

= Data Access Layer

: 영속성을 유지하기 위한 기능을 지원해 주는 소프트웨어 계층

 

데이터베이스에서 영구 데이터를 관리하도록 결정할 뿐만 아니라,

Message Queue 나 외부 API 통신 등을 처리합니다.

 

 

계층형 아키텍쳐는 각 계층의 관심사가 다른 계층으로 분리된 견고한 아키텍처 패턴입니다. 
각 계층 별로 역할이 나뉜 “관심사의 분리 seperation concern”를 요구하기 때문에,
각 계층의 역할을 수행할 수 있는데 집중하여 도메인 로직을 개발할 수 있습니다.

하지만, 개발자들이 계층형 아키텍쳐를 기반한 개발을 진행해 오면서,몇 가지의 문제점들을 발견하게 됩니다.

대표적인 두 가지 문제점을 살펴보겠습니다.

 

 

 

 

 

✔️ 영속성 계층의 변경에 반응하는 도메인 객체

 

계층형 아키텍쳐의 구조적 특징을 살펴보면,

Presentation 계층은 Domain 계층에 의존하고, Domain 계층은 Persistence 계층에 의존합니다.

 

때문에 자연스레 데이터에 의존하게 됩니다.

그렇게 모든 것이 영속성 계층을 토대로 만들어지는 데이터베이스 주도 설계를 따르게 됩니다.

 

이렇게 데이터베이스 주도 설계를 하게 되면,

설계를 시작하는 처음부터 데이터를 결정하도록 강요하기 때문에

너무 이른 시기에 내부 구현에 초점을 맞추게 됩니다. 

 

캡슐화를 위반하지 않기 위해서는 협력을 우선하여 생각해야 하고, 협력에 초점을 맞춰야만 응집도가 높고 결합도가 낮은 객체들을 창조할 수 있으며 훌륭한 책임을 수확할 수 있다. 그러니, 상태가 우선이 아니라 행동이 우선이다.

- 오브젝트

 

 

 



✔️ 영속성 계층의 변경에 반응하는 도메인 객체

 

계층형 아키텍처에서는 도메인 로직이 여러 계층에 걸쳐 흩어지기 쉽습니다.

 

로직이 간단하다는 이유로 도메인 계층을 생략한다면 웹 계층에 존재할 수도 있고,

특정 컴포넌트를 도메인 계층과 영속성 계층 모두에서 접근할 수 있도록 아래 계층으로 내린다면 영속성 계층에 존재할 수도 있습니다.

이는 계층을 분리하는 규칙 외에 다른 규칙을 강제하지 않아서,

대충 하려는 개발자들의 코드가 '깨진 창문 이론'처럼 번져가게 된다는 것을 보여줍니다.

 

또, 한 서비스에 다양한 기능을 하는 로직들을 담는 문제를 야기하며,

이를 넓은 서비스 문제라고 부릅니다.

 

 

 

이 밖에도 테스트하기 어려워진다는 점도 있습니다.

이러한 계층형 아키텍처의 문제점들을 보완하고자,

개발자들은 새로운 아키텍처를 찾는 시도들을 합니다.

 

 

 

 

 

 

 

Clean Architecture

 

Clean Architecture는 Robert C. Martin이 'Clean Architecture' 책에서 처음 소개한 개념입니다.

 

Robert C. Martin은 클린 아키텍처 설계가 비즈니스 규칙의 테스트를 용이하게 하고,

*외부 애플리케이션이나 인터페이스로부터 비즈니스 규칙이 독립적일 수 있다고 주장합니다.

* Framework, Database, UI 등

 

 

 

 

클린 아키텍처에서의 핵심 규칙은 도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 한다는 것입니다.

의존성 역전 원칙의 도움으로 계층 간의 모든 의존성이 코어 안쪽으로 향하게 해야 합니다.

 

이로써 비즈니스 규칙을 담는 도메인 코드, 즉, 엔티티

어떤 영속성 프레임워크나 UI 프레임워크가 사용되는지 알 수 없기 때문에,

특정 프레임워크에 특화된 코드를 가질 수 없고 비즈니스 규칙에 집중할 수 있습니다.

 

생각해 볼 점은, 도메인 모델이 외부 계층과 분리돼야 하기 때문에
애플리케이션의 엔티티에 대한 모델을 각 계층에서 유지보수해야 합니다.

가령, ORM(Object-relational mapping) 프레임워크를 사용한다고 했다고 한다며,
도메인 계층에서는 ORM을 사용한다는 것을 모릅니다. 영속성 계층을 모르기 때문이죠.
때문에, ORM을 위한 'Entity 객체'를 '도메인 객체'와 따로 생성해야 합니다.

하지만, 다시 생각해보면 이는 바람직한 분리입니다.
도메인 코드를 특정 프레임워크에 한정된 문제로부터 파생되는 의존성을 제거된 상태로 만들기 때문입니다.

가령, JPA은 엔티티에게 기본 생성자 생성을 강제하며, 이는 JPA 프레임워크가 만드는 의존성입니다. 

 

 

유스케이스는 전통적인 아키텍처에서 서비스로 불렸던 로직을 담지만, *단일 책임을 갖기 위해 조금 더 세분화됩니다.

서비스 로직이 불필요하게 커지는 문제 해결합니다.

*단일 책임: 변경할 단 한 가지의 이유

 

 

 

 

 

하지만, Clean Architecture는 하나의 추상적인 아키텍처 이론이기 때문에,

바로 현업에 적용하기란 쉽지 않습니다.

 

이를 위해, Clean Architecture의 원칙들을 조금 더 구체적으로 구현하는

'육각형 아키텍처 Hexagonal Architecture'가 등장합니다.

 

 

 

 

 

Hexagonal Architecture

헥사고날 아키텍처 Hexagonal Architecture는 클린 아키텍처를 구현하는 가장 대표적인 모델입니다.

Alistair Cockburn가 Hexagonal Architecture 용어를 만들었으며, 그의 블로그에서 소개되었습니다.

 

 

Netflix Medium Blog - "Ready for changes with Hexagonal Architecture"

 

 

 

육각형 안에는 도메인 엔티티와 이와 상호작용하는 유스케이스가 위치합니다.

육각형 바깥에는 애플리케이션과 상호작용하는 다양한 어댑터들이 위치합니다.

 

가령, 웹이나 데이터베이스 등 외부 시스템과 상호작용하는 어댑터들이 위치할 수 있습니다.

 

또, 애플리케이션 코어가 각각의 포트를 제공해야 하는데,

이는 애플리케이션 코어와 어댑터들 간의 통신을 위함입니다.

 

이러한 구조로 인해 '포트와 어댑터 (ports-and-adapters)' 아키텍처로도 알려져 있습니다.

Netflix에서 작성한 헥사고날 아키텍처의 설명이 이해를 더해줍니다.

 

 

The idea of Hexagonal Architecture is to put inputs and outputs at the edges of our design. Business logic should not depend on whether we expose a REST or a GraphQL API, and it should not depend on where we get data from — a database, a microservice API exposed via gRPC or REST, or just a simple CSV file.

- Netflix Technology Blog

 

 

정리해보면 아래와 같습니다. 

 

헥사고날 아키텍처의 아이디어는 입력과 출력을 설계의 가장자리에 배치하는 것입니다.

비즈니스 로직은 REST 또는 GraphQL API를 노출시키는지 여부에 의존해서는 안됩니다.

마찬가지로, 데이터베이스나 API 또는 단순한 CSV 파일과 같은 데이터를 어디에서 가져오는지에 의존해서는 안 됩니다.

 

 

즉, 비즈니스 로직을 설계의 중심으로 하면서,

노출시키는 영역(outputs)과 데이터를 가져오는 영역(inputs)에 의존하지 않는 디자인을 의미입니다.

 

 

이런 설계가 갖는 이점이 무엇일까요?

 

 

 

 

Benefits

✔️ 유연성 Flexibility

포트와 어댑터를 사용함으로써, 다양한 기술 변화에 대응할 준비가 되어 있습니다.

빠르게 기술이 변화되고 있는 현재, 더 나은 기술의 활용은 더 이상 선택이 아닌 필수로 볼 수 있습니다.

이러한 유연성은 변경에 용이한 소프트웨어의 이점을 잘 나타냅니다.

 

 

✔️ 유지보수성 Maintainability 

책임이 분리되어 있어, 코드의 이해와 수정이 용이하며, 변화에 빠르게 대응할 수 있습니다.

기존 기능을 다른 기술로 변경하고자 할 때, 새로운 어댑터 만으로 추가할 수 있습니다.

 

 

✔️ 테스트 용이성 Testability

각 컴포넌트를 독립적으로 테스트할 수 있을 뿐만 아니라, 외부 의존성 없이 테스트할 수 있습니다.

이를 통해 품질 향상과 개발 속도 향상에 도움이 됩니다.

 

 

 

 

Is it worth the effort?

사실 Hexagonal Architecture를 도입하는 건, 그만큼의 노력이 필요합니다.

 

웹 어댑터에 도메인 구현 모델이 있고 데이터베이스 어댑터에 또 다른 도메인 모델 표현이 있을 수 있습니다.

결국, 포트 인터페이스를 만들고, 여러 도메인 모델 구현체 사이에 매핑할 필요가 생길 수도 있습니다.

 

뿐만 아니라, 팀원들과의 의견을 조율하는 과정 또한 중요한 관건입니다.

 

그렇다면, Hexagonal Architecture을 도입하는 것이 과연 가치가 있을까요?
이에 대한 대답은 "상황에 따라 다르다" 입니다.

단순히 데이터를 저장하는 간단한 CRUD 애플리케이션을 만들고 있다면, 아마 오버헤드일 것입니다.

혹은 상태와 행동으로 가득 찬 도메인 모델로 표현할 수 있는 비즈니스 규칙을 따르는 애플리케이션을 만들고 있다면, 해당 아키텍처는 빛을 발할 수 있습니다.

 

 

 


 

Hexagonal Architecture

Layers

 

클린 아키텍처와 동일하게 Hexagonal Architecture도 계층으로 구성할 수 있습니다.

가장 안쪽 계층에는 도메인 엔티티 위치시키고, - Domain Layer

중간 계층에는 포트유스케이스 구현체를 결합해서 애플리케이션 계층 구성시키고,  - Application Layer: Port & Usecase

두 개 모두 인터페이스를 정의하기 때문에 결합시킵니다.

가장 바깥쪽 계층에는 애플리케이션과 다른 시스템 간의 번역을 담당하는 어댑터를 위치시킵니다.  - Adapter Layer

 

이를 정의하는 Domain, Port, Usecase, Adapter를 각각 자세히 살펴보겠습니다.

예시 코드는 Get Your Hands Dirty on Clean Architecture (Github) 책을 참고했습니다.

 

 

 

Domain

: 실세계에서 사건이 발생하는 집합의 모델링


소프트웨어를 통해 해결하기 원하는 핵심 문제를 설명하는 요소를 나타냅니다.
중요한 비즈니스 데이터와 규칙에 관련된 엔티티들로 채워집니다.
도메인 개체는 상태와 동작을 모두 포함할 수 있습니다. 

도메인 개체는 바깥으로 향하는 어떤 의존성도 없어야 합니다.
순수한 자바이며 사용 사례에서 작동할 수 있는 API를 제공합니다.

도메인 개체는 다른 계층에 종속성이 없기 때문에 다른 계층에서의 변경에 영향을 받지 않습니다. 

이는 "클래스는 단 한 개의 책임을 가져야 한다"의 의미를 가진

SOLID의 단일 책임 원칙 (SRP, Single Responsibility Principle)의 대표적인 예시가 됩니다.

 

객체는 오직 하나의 변경의 이유 만을 가져야 하며, 

도메인 개체의 경우 유일한 변경의 이유는 비즈니스 요구 사항의 변경이 됩니다.

개발하는 동안 종속성의 흐름을 따라, 

도메인 객체에서 코딩을 시작하고 육각형 모델에 맞춰 바깥으로 개발해 나아갑니다.

 

도메인 계층 내에는 Entity와 Value Object가 포함될 수 있습니다.

예를 들어, 은행 애플리케이션의 "다른 계정으로 돈 보내기"을 구현한다고 해봅시다.

아래는 Account를 모델링한 도메인의 예시입니다. 

 

public record Account(
    @Getter AccountId id,
    @Getter ActivityWindow activityWindow,
    Money baselineBalance
) {

    public Money calculateBalance() {
        return Money.add(
                this.baselineBalance,
                this.activityWindow.calculateBalance(this.id));
    }

    public boolean withdraw(Money money, AccountId targetAccountId) {
        if (!mayWithdraw(money)) {
            return false;
        }

        Activity withdrawal = new Activity(
                this.id,
                this.id,
                targetAccountId,
                LocalDateTime.now(),
                money);
        this.activityWindow.addActivity(withdrawal);
        return true;
    }

    private boolean mayWithdraw(Money money) {
        return Money.add(
                        this.calculateBalance(),
                        money.negate())
                .isPositiveOrZero();
    }
    
    public boolean deposit(Money money, AccountId sourceAccountId) {
        Activity deposit = new Activity(
                this.id,
                sourceAccountId,
                this.id,
                LocalDateTime.now(),
                money);
        this.activityWindow.addActivity(deposit);
        return true;
    }
}

 

 

 

 

Usecase

: 소프트웨어 동작을 추상화하여, 소프트웨어가 무엇을 하는지를 설명하는 역할을 합니다. 

예를 들어, 은행 애플리케이션의 "다른 계정으로 돈 보내기"를 하나의 사용 사례라고 가정해 보겠습니다. 

사용자가 돈을 송금할 API로 SendMoneyUseCase 클래스를 만듭니다.

 

이때, 송금 시 필요한 비즈니스 규칙 검증 및 논리를 Usecase의 구현체에 정의할 수 있습니다.

가령, 입력 값을 확인한다거나 계좌 번호를 확인하는 로직을 도메인에 구현할 수 없기 때문입니다.

 

도메인 객체와 유사하게 Usecase는 외부 구성 요소에 의존하지 않습니다.

육각형 외부에서 무언가가 필요할 때에는 출력 포트를 만듭니다.

 

public interface SendMoneyUseCase {
    boolean sendMoney(SendMoneyCommand command);
}
public class SendMoneyService implements SendMoneyUseCase {
	// ...
}

 

 

Port

도메인 객체와 Usecase는 육각형 내에 있으며, 즉 애플리케이션의 핵심 내에 있습니다. 

이때 외부와의 모든 통신은 전용 "Port"를 통해 이루어집니다.

 

Input과 Output Port로 시스템에 들어오고 나가는 위치가 매우 명확하기 때문에,

아키텍처를 쉽게 추론할 수 있어 코드를 이해하는 데 큰 도움이 됩니다.

 

✔️ Input Port

입력 포트는 외부 구성 요소에 의해 호출될 수 있고, Usecase에 의해 구현되는 인터페이스입니다. 
입력 포트를 호출하는 구성 요소를 입력 어댑터 또는 "driving" adapter라고 합니다.

 

 

✔️ Output Port

출력 포트는 데이터베이스와 같이 외부에서 필요한 것이 있을 경우 Usecase에서 호출할 수 있는 인터페이스입니다. 

이 인터페이스는 Usecase의 요구에 맞게 설계되지만, output adapter 또는 "driven" adapter라는 외부 구성 요소로 구현됩니다.

 

인터페이스를 사용하여 Usecase에서 Output Adapter로 종속성을 뒤집기 때문에,

SOLID의 DIP(Dependency Inversion Principle)가 적용된 것을 확인할 수 있습니다.

 

public interface LoadAccountPort {
    Account loadAccount(AccountId accountId, LocalDateTime baselineDate);
}
public record AccountPersistenceAdapter(
        AccountRepository accountRepository,
        ActivityRepository activityRepository,
        AccountMapper accountMapper
) implements LoadAccountPort, UpdateAccountStatePort {
	// ...
}

 

 

 

 

Adapter

외부 인터페이스 제공하며, 애플리케이션 기능의 노출 방법을 결정합니다.

소프트웨어와 통신할 수 있는 기술을 결정합니다.

 

Adapter는 육각형 구조의 외부 층에 위치하여, 코어와 상호 작용합니다.


어댑터를 사용하면 애플리케이션의 특정 계층을 쉽게 교체할 수 있습니다.

예를 들어, 애플리케이션에 다른 데이터베이스가 필요한 경우,

이전 데이터베이스와 동일한 출력 포트 인터페이스를 구현하는 새로운 rsistence 어댑터를 추가합니다.

 

 

✔️ Driving Adapter

= Input adapter.

 

애플리케이션 코어를 호출하며, 애플리케이션을 주도하는 어댑터입니다.

코드 상으로는, 코어에 있는 유스케이스 클래스들에 의해 구현 및 호출되는 인터페이스가 될 수 있습니다.


Driving Adapter는 특정된 목표를 수행하기 위해 입력 포트를 호출합니다.

가령, Driving Adapter는 웹 인터페이스가 될 수 있습니다.

사용자가 브라우저에서 버튼을 클릭하면, 웹 어댑터는 특정 입력 포트를 호출하여 해당 사용 사례를 호출합니다.

 

@WebAdapter
@RestController
class SendMoneyController {
	// ...
}

 

 

✔️ Driven Adapter

= Output adapter.

 

Driven Adapter는 Usecase에 의해 호출됩니다.

출력 어댑터는 Output Port 인터페이스 집합을 구현합니다.

 

가령, 데이터베이스로 부터 애플리케이션으로 데이터를 제공할 수 있습니다.

혹은 File에서 외부 데이터를 읽기 위해 txt 파일에 접근해 출력 포트를 구현할 수도 있습니다.

 

@PersistenceAdapter
class AccountPersistenceAdapter implements LoadAccountPort, UpdateAccountStatePort {
        // ...
}

 

 

 

|  Reference  |

Netflix Medium Blog - Ready for Changes with Hexagonal Architecture

Reflectoring - Spring Hexagonal

Github - buckpal

만들면서 배우는 클린 아키텍처 - 톰 홈버그

 

반응형