컴퓨터 공학/Web

Hexagonal Architecture 소개

혼새미로 2022. 7. 2. 10:23
반응형

"A golden toad in the rain on a marble floor in front of a glass building in a dark night in a realistic style" from DALL-E 2

개요

Hexagonal Architecture (줄여서 HA)는 Alistair Cockburn이 2005년에 제안한 구조입니다. HA는 기존의 Layered Architecture에서 더 나아가 레이어 간에 직접적인 연결을 맺는 대신, 중간에 인터페이스를 통해 상호작용을 함으로써 결합도 (coupling)를 낮추도록 설계된 아키텍처입니다. 결합도를 낮추게 되면 각 레이어를 자유롭게 교체할 수 있으며, 이를 통해 테스트를 손쉽게 수행할 수 있습니다.

 

또 다른 이름으로 HA는 Ports and Adapters라고도 불립니다. 아키텍처의 구조를 인터페이스인 포트와 중개 역할을 하는 어댑터를 통해 설명하고 있기 때문입니다.

작업 영역

이번 포스팅에서 소개하는 HA를 적용한 애플리케이션을 “HA 앱”이라고 하겠습니다. HA에서 작업 영역은 크게 세 가지 영역인 외부 영역 (External Area), 인프라 영역 (Infrastructure Area), 애플리케이션 영역 (Application Area)으로 구분됩니다. 외부 영역은 HA 앱 외부에 존재하여 요청을 보내거나 받는 객체들이 속하는데, HA 앱으로 요청을 보내는 유저 또는 HA 앱에서 요청을 보내는 DB 혹은 서버가 될 수 있습니다. 인프라 영역은 외부 영역과 애플리케이션 영역 사이에서 요청을 중개하는 역할로, 외부 액터 (actor)의 요청을 받아 적절한 애플리케이션 서비스에 전달하거나, 반대로 서비스로부터 요청을 받아 적절한 외부 액터에 전달합니다. 애플리케이션 영역에서는 서비스가 인프라 영역으로부터 받은 요청에 대한 도메인 (Domain)을 처리하기 위해 비지니스 로직 (Business Logic)을 수행합니다. 여기서 중요한 점은 오직 애플리케이션 영역의 서비스에서만 비지니스 로직을 수행한다는 점입니다.

인바운드와 아웃바운드

HA에서는 애플리케이션 영역의 서비스를 기준으로 두 방향 즉, 외부에서 요청이 유입되는 인바운드 측 (Inbound Side)과 외부로 유출되는 아웃바운드 측 (Outbound Side)으로 구분됩니다. 그림 1은 HA에서 두 측으로 구분되는 요청의 흐름을 나타내고 있습니다.

그림 1. 인바운드 측과 아웃바운드 측

인바운드 측 외부 액터는 서버 또는 유저가 될 수 있으며, 아웃바운드 측 외부 액터는 DB (Database), 파일, 캐시 또는 다른 서버가 될 수 있습니다.

 

애플리케이션 영역의 서비스까지 요청이 도달하는 과정에서 인바운드 측에서는 HTTP API, gRPC, WebSocket 등 다양한 통신 프로토콜이 사용될 수 있으며, 서비스에서 필요한 요청을 전송하기 위해 아웃바운드 측에서는 MongoDB, MySQL, Redis 등 다양한 서버에 대한 통신 프로토콜이 사용될 수 있습니다. 그리고 이러한 외부 액터와의 직접적인 상호작용은 인프라 영역에서 수행됩니다.

 

그림 2와 같이 서비스를 기준으로 외부에서 들어오는 요청 스트림을 인바운드 스트림 (Inbound Stream), 외부로 나가는 요청 스트림을 아웃바운드 스트림 (Outbound Stream)이라고 하겠습니다.

그림 2. 인바운드 스트림과 아웃바운드 스트림

인바운드 스트림과 아웃바운드 스트림으로 구분한 이유는 후술하는 포트와 어댑터도 인바운드와 아웃바운드로 구분되는데 이를 효과적으로 설명하기 위해서입니다.

포트와 어댑터

HA는 포트 (Port)와 어댑터 (Adapter)라는 개념이 있습니다. HA 앱에서 외부 액터와 직접적인 상호작용을 하는 객체가 어댑터이며, 어댑터와 서비스가 상호작용 할 때 사용되는 인터페이스 (Interface)가 포트입니다. 인프라 영역과 애플리케이션 영역 사이의 상호작용은 반드시 포트를 통해서만 이루어집니다. 여기서 포트는 인프라 영역과 애플리케이션 영역 간에 결합도를 낮추는 역할을 합니다.

 

인바운드 측 또는 아웃바운드 측에 따라 포트와 어댑터도 두 종류로 구분됩니다. 인바운드 스트림에서 사용되는 포트와 어댑터를 각각 인바운드 포트와 인바운드 어댑터라고 하며, 아웃바운드 스트림에서 사용되는 포트와 어댑터를 각각 아웃바운드 포트와 아웃바운드 어댑터라고 지칭합니다.

포트의 구현

포트는 인터페이스로서 기능만 갖기 때문에 포트를 구현하는 주체가 있어야 합니다. 그림 08a7ca와 같이 인바운드 스트림에서는 서비스가 인바운드 포트를 구현하여 인바운드 어댑터에 제공해야 하며, 아웃바운드 스트림에서는 아웃바운드 어댑터가 아웃바운드 포트를 구현하여 서비스에 제공해야 합니다.

그림 3. 포트 구현

인바운드 스트림에서, 서비스가 구현한 비지니스 로직에 인바운드 어댑터가 의존할 필요가 없으며, 인바운드 어댑터는 인바운드 포트만 받아서 호출하기만 하면 됩니다. 마찬가지로, 아웃바운드 스트림에서 서비스는 아웃바운드 포트를 받아 이를 호출하기만 하면 됩니다. 이 덕분에 추후 어댑터가 교체되더라도 동일한 포트만 사용한다면 서비스의 내부 코드는 변경되지 않습니다. 마찬가지로, 서비스의 비지니스 로직이 변경되더라도 어댑터의 내부 코드는 변경되지 않습니다. 이는 포트를 통한 DIP (Dependency Inversion Principle)를 지켰기 때문에 가능한 것입니다.

 

예를 들어 보겠습니다. 그림 4-1과 같이 WebSocket과 REST API를 통해 외부 영역의 액터로부터 요청을 받아 각각의 인바운드 어댑터가 제어하며, 서비스가 구현한 인바운드 포트를 호출합니다. 서비스는 비지니스 로직을 수행하는 과정에서 아웃바운드 포트를 호출하는데, 이는 아웃바운드 어댑터가 사전에 구현을 해둔 것 입니다. 그림에서는 두 아웃바운드 어댑터가 각각 Kafka와 DynamoDB에게 요청을 보내고 있습니다.

그림 4-1. 어댑터 변경 전

이렇게 잘 사용하고 있다가 어느 날 더 좋은 기술이 도입되면서 사용하는 인프라가 바뀐다면 어떻게 될까요? 그림 4-2와 같이 인바운드 측에서 WebSocket 대신 Socket.io를, REST API 대신 gRPC를 사용하고, 아웃바운드 측에서 Kafka 대신 RabbitMQ를, DynamoDB 대신 MongoDB를 사용하게 되었다고 해보겠습니다.

그림 4-2. 어댑터 변경 후

HA는 포트를 통해 서비스와 어댑터가 상호작용하기 때문에 인프라가 바뀌어도 서비스는 수정되지 않습니다. 그림 4-2에서 WebSocket 대신 Socket.io로 바뀌었다고 하더라도 새 인바운드 어댑터만 작성하고 동일한 인바운드 포트를 호출하기만 하면 서비스는 WebSocket 을 사용했을 때와 마찬가지로 동작하게 될 것 입니다. 마찬가지로, Kafka에서 RabbitMQ로 바뀌었다고 하더라도 아웃바운드 어댑터는 Kafka에서 사용했던 아웃바운드 포트를 구현해주면 됩니다.

 

이쯤되면 아시겠지만 아키텍처 이름이 Hexagon (육각형)인 이유는 Alistair가 육각형 모양이 여러 어댑터와 포트 간 상호작용을 그림으로 설명하기 적합하다고 생각했기 때문입니다.

테스트

HA로 소프트웨어 구조를 구축하면 테스트도 비교적 쉽게 수행할 수 있습니다. HA에서는 앱 초기화 시, 각 모듈이 필요한 의존성을 주입 (Dependency Injection)받기 때문에, 테스트를 수행할 때 스터브 구현체 (Stub Implementation)를 각 모듈의 의존성으로 전달함으로써 모듈의 코드를 수정할 필요가 없습니다.

단위 테스트

HA에서 주요 모듈인 인바운드 어댑터, 서비스 그리고 아웃바운드 어댑터에 대해 단위 테스트를 수행하는 방법에 대해 소개합니다. 그림 5는 세 모듈의 단위 테스트 플로우입니다.

그림 5. HA 주요 모듈의 단위 테스트 플로우

단위 테스트 - 인바운드 어댑터

인바운드 어댑터에 대한 단위 테스트를 수행하기 위해 스터브 인바운드 포트 구현체를 의존성으로 주입합니다. 스터브 인바운드 포트 구현체는 전달받은 요청에 대해 사전에 정의된 적절한 응답 값을 즉시 반환합니다.

단위 테스트 - 서비스

서비스에 대한 단위 테스트를 수행하기 위해 스터브 아웃바운드 포트 구현체를 의존성으로 주입합니다. 스터브 아웃바운드 포트 구현체는 전달받은 요청에 대해 사전에 정의된 적절한 응답 값을 즉시 반환합니다.

단위 테스트 - 아웃바운드 어댑터

아웃바운드 어댑터에 대한 단위 테스트를 수행하기 위해 외부 요청에 대한 작업 부분을 mocking하여 즉시 응답을 받도록 합니다.

통합 테스트

개별 모듈에 대한 단위 테스트가 완료되었다면, 인바운드 어댑터, 서비스, 아웃바운드 어댑터를 모두 아우르는 통합 테스트 (Integration Test)를 수행합니다. 그림 6은 세 모듈을 아우르는 통합 테스트 플로우를 나타냅니다.

그림 6. 통합 테스트 플로우

통합 테스트에서는 그림 7과 같이 스터브 구현체를 사용하지 않고, 실제로 서비스와 아웃바운드 어댑터에서 구현한 포트를 의존성으로 주입받아 앱을 초기화하고, 통합 테스트를 수행합니다. 다만, 빠른 테스트를 위해 아웃바운드 어댑터에서 외부에 요청을 보내는 부분을 mocking하여 즉시 응답을 받도록 합니다.

그림 7. 통합 테스트 구조도

직접 구현해보기

HA 앱을 직접 구현해봅시다. 구현을 위해 타입스크립트 (TypeScript)를 사용합니다.

 

국민은행이 우리가 작성할 HA 앱을 통해 잔액조회 기능을 수행한다고 해보겠습니다. 국민은행은 사용자를 식별하기 위해 사용자 ID와 사용자 도시를 사용하고, DB는 MariaDB를 사용하고 있다고 가정해보겠습니다.

 

프로젝트의 소스코드 디렉토리는 그림 8과 같습니다. 루트 디렉토리에 HA에 핵심 개념인 어댑터, 포트 그리고 서비스 폴더를 생성하였습니다. 특히 어댑터와 포트는 내부에서 인바운드와 아웃바운드 폴더를 생성하여 소스코드를 분류하였습니다.

그림 8. 소스코드 디렉토리 계층

DI는 반대방향으로 진행되기 때문에 아웃바운드 포트부터 정의해보겠습니다. DB로부터 사용자의 잔액을 조회하기 위해서는 사용자 식별자만 있으면되고, 반환 값은 숫자 타입의 잔액이 될 것 입니다. 아웃바운드 포트는 코드 1과 같이 작성합니다.

// 코드 1. 잔액조회 아웃바운드 포트
export interface IBalanceInquiryOutPort {
    selectBalanceInquiry(userId: string): Promise<number>
}

 

이제 서비스는 아웃바운드 포트인 IBalanceInquiryOutPort를 통해 아웃바운드 어댑터에 잔액조회를 요청할 수 있습니다.

 

다음으로 인바운드 포트입니다. 국민은행에 대한 인바운드 어댑터가 포트를 통해 서비스에게 사용자 잔액조회를 요청하기 위해 인수로 사용자 식별자를 전달하고, 그에 대한 반환 값은 숫자 타입의 잔액이 됩니다. 인바운드 포트는 코드 2와 같이 작성합니다.

// 코드 2. 잔액조회 인바운드 포트
export interface IBalanceInquiryInPort {
    getBalanceInquiry(userId: string): Promise<number>
}

아웃바운드 어댑터에서 구현한 아웃바운드 포트는 코드 3과 같습니다.

// 코드 3. 잔액조회 아웃바운드 어댑터
import { IBalanceInquiryOutPort } from '../../../ports/outbound/balance-inquiry-out-port'

async function mockSelectBalanceInquiry(userId: string) {
    return userId.length * 100
}

interface IDependency {}

/**
 * 잔액조회 아웃바운드 어댑터
 *
 * @param dependency 의존성 객체
 * @returns IBalanceInquiryOutPort 구현체
 */
export function balanceInquiryOutAdapterInitiator(dependency: IDependency): IBalanceInquiryOutPort {
    const {} = dependency

    return {
        async selectBalanceInquiry(userId: string): Promise<number> {
            console.log('MariaDB select balance inquiry.', userId)
            return await mockSelectBalanceInquiry(userId)
        },
    }
}

아웃바운드 어댑터에서는 외부 의존성이 없기 때문에 파라미터로 받은 dependency에 아무런 속성이 없습니다. 다만, 함수에서 잔액조회 아웃바운드 포트의 구현체를 반환합니다. 코드에서는 실제 MariaDB에 요청을 보내지는 않고, mock 함수를 통해 임의의 잔액 값을 반환하도록 구현하였습니다.

 

인바운드 어댑터는 코드 4와 같습니다.

// 코드4. 잔액조회 인바운드 어댑터
import { IBalanceInquiryInPort } from '../../../ports/inbound/balance-inquiry-in-port'

interface IDependency {
    balanceInquiryService: IBalanceInquiryInPort
}

interface IRequestKukmin {
    getBalanceInquiry(userId: string, userCity: string): Promise<number>
}

/**
 * 잔액조회 인바운드 어댑터
 *
 * @param dependency 의존성
 * @returns 잔액조회 함수
 */
export function balanceInquiryInAdapterInitiator(dependency: IDependency): IRequestKukmin {
    const {
        balanceInquiryService: { getBalanceInquiry },
    } = dependency

    return {
        async getBalanceInquiry(userId: string, userCity: string): Promise<number> {
            console.log('Kukmin bank get balance inquiry.', userId, userCity)
            return await getBalanceInquiry(`${userId}:${userCity}`)
        },
    }
}

앞서 설명했다시피, 인바운드 어댑터는 서비스에서 구현한 인바운드 포트의 구현체를 의존성으로 받아야 합니다. 이에 따라, 함수 파라미터인 dependency를 통해 IBalanceInquiryInPort의 구현체를 전달받았으며, 이를 반환하는 구현체에서 호출하고 있습니다. 정리하자면, 외부 영역에서 요청이 유입되면 IRequestKukmin 인터페이스를 통해 구현된 getBalanceInquiry 함수가 호출되며, 이 함수 내에서 인바운드 포트의 구현체가 호출됩니다.

 

다음으로, 서비스 코드는 코드 5와 같습니다.

// 코드 5. 잔액조회 도메인
import { IBalanceInquiryInPort } from '../ports/inbound/balance-inquiry-in-port'
import { IBalanceInquiryOutPort } from '../ports/outbound/balance-inquiry-out-port'

interface IDependency {
    balanceInquiryOutAdapter: IBalanceInquiryOutPort
}

/**
 * 잔액조회 서비스
 *
 * @param dependency 의존성 객체
 * @returns 인바운드 포트 구현체
 */
export function balanceInquiryServiceInitiator(dependency: IDependency): IBalanceInquiryInPort {
    const {
        balanceInquiryOutAdapter: { selectBalanceInquiry },
    } = dependency

    return {
        async getBalanceInquiry(userId: string): Promise<number> {
            if (userId.startsWith('Park')) {
                return 0
            }
            console.log('get balance inquiry service.', userId)
            return await selectBalanceInquiry(userId)
        },
    }
}

서비스에서는 잔액조회 작업에 대한 비지니스 로직이 수행되는 영역입니다. 예를 들어, 코드 5와 같이 “잔액조회 대상 사용자의 식별자가 ‘Park’으로 시작하면 잔액을 0으로 반환한다.” 라는 비지니스 로직이 있었다면 해당 코드는 서비스에서 구현되어야 합니다.

 

프로그램의 엔트리포인트에서는 코드 6과 같이 어댑터와 서비스에 대한 초기화 및 DI를 수행합니다. 이때, 아웃바운드 어댑터, 서비스, 인바운드 어댑터 순으로 초기화를 수행해야 하는데, 그 이유는 어댑터 및 서비스의 초기화 반환 객체를 DI로 사용되기 때문입니다.

// 코드 6. app.ts
import { balanceInquiryInAdapterInitiator } from './adapters/inbound/shinhan/balance-inquiry-in-adapter'
import { balanceInquiryOutAdapterInitiator } from './adapters/outbound/mongodb/balance-inquiry-out-adapter'
import { balanceInquiryServiceInitiator } from './service/balance-inquiry-service'

// 아웃바운드 어댑터 초기화
const balanceInquiryOutAdapter = balanceInquiryOutAdapterInitiator({})

// 도메인 초기화
const balanceInquiryService = balanceInquiryServiceInitiator({ balanceInquiryOutAdapter })

// 인바운드 어댑터 초기화
const balanceInquiryInAdapter = balanceInquiryInAdapterInitiator({ balanceInquiryService })

;(async () => {
    const result = await balanceInquiryInAdapter.getBalanceInquiry('honsemiro', 20)
    console.log(result)
})()

코드 6에서와 같이, 잔액조회 아웃바운드 어댑터를 초기화하면 잔액조회 아웃바운드 포트 구현체를 반환하는데, 이를 잔액조회 서비스 초기화 함수에 의존성으로 넘겨주어야 합니다. 마찬가지로, 잔액조회 서비스를 초기화하면서 잔액조회 인바운드 포트 구현체를 반환하는데, 이를 잔액조회 인바운드 어댑터 초기화 함수에 의존성으로 넘겨줍니다.

 

초기화 및 DI이 완료되면 메인 함수에서 잔액조회 인바운드 어댑터의 함수를 호출하면 다음과 같이 출력됩니다.

# 결과 1. 국민은행과 MariaDB를 사용하여 잔액조회한 결과
Kukmin bank get balance inquiry. honsemiro seoul
get balance inquiry service. honsemiro:seoul
MariaDB select balance inquiry. honsemiro:seoul
1500

이 상황에서 잔액조회 은행을 국민은행에서 신한은행으로 교체해야 한다면 어떻게 될까요? 그러면 신한은행에 대한 잔액조회 인바운드 어댑터만 새로 작성하면 됩니다. 그리고, DB를 MariaDB에서 MongoDB로 교체한다면 어떻게 될까요? 마찬가지로 MongoDB에 대한 잔액조회 아웃바운드 어댑터만 새로 작성하면 됩니다.

 

그림 9는 신한은행과 MongoDB가 추가된 디렉토리 구조를 나타냅니다.

그림 9. 수정된 소스코드 디렉토리 계층

코드 7은 신한은행의 인바운드 어댑터 코드입니다.

// 코드 7. 신한은행 잔액조회 인바운드 어댑터
import { IBalanceInquiryInPort } from '../../../ports/inbound/balance-inquiry-in-port'

interface IDependency {
    balanceInquiryService: IBalanceInquiryInPort
}

interface IRequestShinhan {
    getBalanceInquiry(userId: string, userAge: number): Promise<number>
}

/**
 * 잔액조회 인바운드 어댑터
 *
 * @param dependency 의존성
 * @returns 잔액조회 함수
 */
export function balanceInquiryInAdapterInitiator(dependency: IDependency): IRequestShinhan {
    const {
        balanceInquiryService: { getBalanceInquiry },
    } = dependency

    return {
        async getBalanceInquiry(userId: string, userAge: number): Promise<number> {
            console.log('Shinhan bank get balance inquiry.', userId, userAge)
            return await getBalanceInquiry(`${userId}:${userAge}`)
        },
    }
}

다음으로 코드 8는 신규 추가된 MongoDB의 아웃바운드 어댑터입니다.

// 코드 8. MongoDB 잔액조회 아웃바운드 어댑터
import { IBalanceInquiryOutPort } from '../../../ports/outbound/balance-inquiry-out-port'

async function mockSelectBalanceInquiry(userId: string) {
    return userId.length * 200
}

interface IDependency {}

/**
 * 잔액조회 아웃바운드 어댑터
 *
 * @param dependency 의존성 객체
 * @returns IBalanceInquiryOutPort 구현체
 */
export function balanceInquiryOutAdapterInitiator(dependency: IDependency): IBalanceInquiryOutPort {
    const {} = dependency

    return {
        async selectBalanceInquiry(userId: string): Promise<number> {
            console.log('MongoDB select balance inquiry.', userId)
            return await mockSelectBalanceInquiry(userId)
        },
    }
}

마지막으로 코드 9는 app.ts에서 import 파일을 교체합니다.

// 코드 9. app.ts에서 신한은행과 MongoDB를 사용하여 잔액조회
import { balanceInquiryInAdapterInitiator } from './adapters/inbound/shinhan/balance-inquiry-in-adapter'
import { balanceInquiryOutAdapterInitiator } from './adapters/outbound/mongodb/balance-inquiry-out-adapter'
import { balanceInquiryServiceInitiator } from './service/balance-inquiry-service'

// 아웃바운드 어댑터 초기화
const balanceInquiryOutAdapter = balanceInquiryOutAdapterInitiator({})

// 서비스 초기화
const balanceInquiryService = balanceInquiryServiceInitiator({ balanceInquiryOutAdapter })

// 인바운드 어댑터 초기화
const balanceInquiryInAdapter = balanceInquiryInAdapterInitiator({ balanceInquiryService })

;(async () => {
    const result = await balanceInquiryInAdapter.getBalanceInquiry('honsemiro', 23)
    console.log(result)
})()

엔트리포인트에서 신한은행은 잔액조회 시 사용자 ID와 사용자 연령을 받고 있습니다. 이와 같이, 인바운드 어댑터의 입력 파라미터는 상이할 수 있습니다. 위 코드에 대한 실행결과는 결과 2와 같습니다.

# 결과 2. 신한은행과 MongoDB를 이용한 잔액조회
Shinhan bank get balance inquiry. honsemiro 23
get balance inquiry service. honsemiro:23
MongoDB select balance inquiry. honsemiro:23
2400

이처럼 HA 구조를 채택하면 도메인의 내부코드를 수정하지 않고 국민은행을 신한은행으로, MariaDB를 MongoDB로 교체할 수 있습니다.

 

위의 샘플 프로젝트의 전체 소스코드는 Github에서 열람할 수 있습니다.

마무리

비지니스 로직이 존재하는 서비스는 불변성을 갖기 때문에 인프라가 변경되더라도 영향을 받지 않아야 합니다. HA에서는 앱을 인프라 영역과 애플리케이션 영역으로 구분하고, 영역 간의 상호작용을 포트를 통해 수행함으로써 결합도를 낮추었습니다. 이 덕분에 테스트를 수행할 때도 스터브 모듈을 사용할 수 있는데, 이를 통해 수월하게 테스트를 수행할 수 있습니다.

 

그렇다고 소프트웨어를 개발에서 HA가 무조건 정답은 아닙니다. 기존 Layered Architecture 등과 비교하여 복잡도가 높아집니다. 또한, 의존성 주입에 대한 규칙을 제대로 지키지 않으면 기존 아키텍처보다 더 난잡한 코드가 될 수 있습니다.

참고

https://railshurts.com/2018/application-logic/

https://beyondxscratch.com/2017/08/19/hexagonal-architecture-the-practical-guide-for-a-clean-architecture/

https://medium.com/ssense-tech/hexagonal-architecture-there-are-always-two-sides-to-every-story-bc0780ed7d9c

https://netflixtechblog.com/ready-for-changes-with-hexagonal-architecture-b315ec967749

https://alistair.cockburn.us/hexagonal-architecture/

반응형