Intro

이번에 글로벌 프로젝트를 하면서 안정적이고 튼튼하면서도 유연한 애플리케이션을 만들고 싶어 설계에 대해 공부를 많이 하게되었습니다. 그 중에 제가 알게된 ** 육각형 설계(Hexagonal Architecture) **에 대해 설명하는 글을 작성하도록 하겠습니다.

이 글의 가장 중요한 내용은 “육각형 아키텍처가 Silver-bullet 패턴이다"라는 것을 말하기 위함이 아닙니다.

[출처: 헥사고날(Hexagonal) 아키텍처 in 메쉬코리아]

그림을 통해 간단히 살펴보면 고수준의 비지니스 로직을 표현하는 내부 영역과 인터페이스 처리를 담당하는 저수준의 외부 영역으로 나눠집니다.

내부 영역 은 순수한 비지니스로직을 표현하는 기술 독립적인 영역입니다.그리고 외부영역과 연계되는 포트를 가지고 있습니다.

외부 영역 은 외부에서 들어오는 요청을 처리하는 인 바운드 어댑터(Inbound Adapter)와 비지니스 로직에 의해 호출되어 외부와 연계되는 아웃바운드(Outbound Adapter) 어댑터로 구성됩니다.

육각형 설계로 알려져있는 포트와 어뎁터 아키텍쳐는 인터페이스나 기반 요소(infrastructure)의 변경에 영향을 받지 않는 핵심 코드를 만들고 애플리케이션의 규모와 상관없이 이를 견고하게 관리하는 것이 목표입니다.

다음으로 셀메이트 글로벌을 육각형 아키텍쳐로 리팩토링 한다면 어떻게 될지 예시 코드를 통해 설명하도록 하겠습니다.


Domain Objects

비즈니스 규칙이 풍부한 도메인에서 도메인 개체는 애플리케이션의 생명입니다. 도메인 개체는 상태(state)와 동작(behavior)을 모두 포함할 수 있습니다. 동작과 상태가 가까이 있을수록 코드를 더 쉽게 이해하고 유추하고 유지 관리할 수 있습니다.

도메인 개체에는 외부로 향하는 종속성이 없습니다. 그것들은 순수한 코드 덩어리이며 유즈 케이스가 도메인 객체를 조작(operate)할 수 있도록 API를 제공합니다.

도메인 개체는 응용 프로그램의 다른 계층에 의존하지 않으므로 다른 계층의 변경 사항이 도메인 객체에 영향을 미치지 않습니다. 종속성 없이 발전할 수 있습니다. Single Responsibility Principle(“SOLID"의 “S”)의 대표적인 예입니다. 도메인 개체가 변경되는 이유는 비즈니스 요구 사항의 변경입니다.

단일 책임을 가짐으로써 외부 종속성을 고려할 필요 없이 도메인 개체를 발전시킬 수 있습니다. 이러한 발전 가능성은 도메인 주도 설계를 적용할 때 육각형 아키텍처 스타일을 완벽하게 만들어줍니다. 개발하는 동안 우리는 종속성의 자연스러운 흐름을 따릅니다: 우리는 도메인 개체에서 코딩을 시작하고 거기에서 바깥쪽으로 나아갑니다.


Use Cases

우리는 유즈 케이스를 사용자가 우리 소프트웨어로 수행하는 작업에 대한 추상적인 설명으로 알고 있습니다. 육각형 아키텍처 스타일에서는 유즈 케이스를 코드베이스의 일급 시민으로 승격시키는 것이 맞습니다.

일급 객체(일급 시민)란 다른 객체들에 일반적으로 적용 가능한 연산을 모두 지원하는 객체를 가리킨다. 보통 함수에 인자로 넘기기, 수정하기, 변수에 대입하기와 같은 연산을 지원할 때 일급 객체라고 한다.

이러한 의미에서 유즈 케이스는 특정 유즈 케이스와 관련된 모든 것을 처리하는 클래스입니다. 예를 들어 셀메이트 글로벌에서 “사용자가 열설정을 등록” 유즈 케이스를 살펴보겠습니다. 사용자가 열설정을 등록하 고유한 API를 가지는 PresetUseCase 클래스를 만듭니다. 이 코드에는 유즈 케이스와 관련된 모든 비즈니스 규칙 검사와 로직이 포함되어 있으므로 도메인 개체 내에서 구현해서는 안됩니다. 그 외의 다른 모든 것은 도메인 개체에 위임됩니다(예를 들어 도메인 개체인 Preset이 있을 수 있습니다).

도메인 개체와 유사하게 유스 케이스 클래스는 외부 구성 요소에 대한 종속성이 없습니다. 육각형 외부에서 무언가가 필요할 때는 Outbound 포트를 만듭니다.


Inbound and Outbound Ports

도메인 개체 및 유즈 케이스는 육각형 내에 있습니다. 즉, 애플리케이션의 핵심 내에 있습니다. 외부와의 모든 통신은 전용 “포트"를 통해 이루어집니다.

Inbound 포트는 외부 구성 요소에서 호출할 수 있고 유즈 케이스에 의해 구현되는 간단한 인터페이스입니다. 이러한 Inbound 포트를 호출하는 구성 요소를 Inbound 어댑터라고 합니다.

Outbound 포트는 외부에서 무언가가 필요한 경우(예: 데이터베이스 액세스), 유즈 케이스에서 호출할 수 있는 간단한 인터페이스입니다. 이 인터페이스는 유즈 케이스의 요구 사항에 맞게 설계되었지만 Outbound 어댑터라고 하는 외부 구성 요소에 의해 구현됩니다. SOLID 원칙에 익숙하다면 인터페이스를 사용하여 유즈 케이스에서 Outbound 어댑터로 종속성의 방향을 반전시키기 때문에 이것은 Dependency Inversion Principle(SOLID의 “D”)의 적용입니다.

Inbound 및 Outbound 포트가 있으면, 데이터가 시스템에 들어오고 나가는 위치가 매우 뚜렷하므로 아키텍처에 대해 쉽게 추론할 수 있습니다.


Adapters

어댑터는 육각형 아키텍처의 외부 레이어를 형성합니다. 어댑터들은 코어의 일부가 아니지만 코어와 상호작용합니다.

Inbound 어댑터는 Inbound 포트를 호출하여 작업을 수행합니다. 예를 들어 Inbound 어댑터는 웹 인터페이스가 될 수 있습니다. 사용자가 브라우저에서 버튼을 클릭하면 웹 어댑터가 특정 Inbound 포트를 호출하여 해당 유즈 케이스를 호출합니다.

Outbound 어댑터는 유즈 케이스에 따라 호출되며 예를 들어 데이터베이스의 데이터를 제공할 수 있습니다. Outbound 어댑터는 Outbound 포트 인터페이스의 집합을 구현합니다. 인터페이스는 유즈 케이스에 따라 결정되며 그 반대로 구현해서는 안됩니다.

어댑터를 사용하면 응용 프로그램의 특정 계층을 쉽게 교체할 수 있습니다. 응용 프로그램을 웹에 추가로 팻 클라이언트에서 사용할 수 있어야 하는 경우 팻 클라이언트 Inbound 어댑터를 추가합니다. 응용 프로그램에 다른 데이터베이스가 필요한 경우 이전 것과 동일한 Outbound 포트 인터페이스를 구현하는 새 영속성 어댑터를 추가합니다.


Show me the code!

위에서 육각형 아키텍처 스타일에 대한 간략한 소개를 했으므로 이제 코드를 좀 살펴보겠습니다. 아키텍처 스타일의 개념을 코드로 변환하는 것은 항상 해석과 기호의 영향을 받으므로 다음 코드 예제를 주어진 대로 받아들이지 말고 대신 자신의 스타일을 만드는 데 영감을 주기 바랍니다.

밑에 모든 코드들은 셀메이트 글로벌의 열설정과 관련된 코드를 육각형 아키텍쳐에 맞게 간단하게 리팩토링한 예시 코드입니다.

Building a Domain Object

유즈 케이스를 제공하는 도메인 개체를 구축하는 것으로 시작합니다. 양식에 대한 값 수정을 관리하는 Preset 도메인 모델을 만듭니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

from pydantic.dataclasses import dataclass


### Entity ###
@dataclass(frozen=True)
class Preset:
    __id: PresetId  # value object (!= VO)
    __is_manual: PresetIsManual  # value object
    __value: PresetJson  # value object
    __activity_window: ActivityWindow  # 프리셋의 Activity를 관리하기 위한 컬렉션 래핑 객체


    def change_value(self, new_value: PresetJson):

        if not self.__value.change(new_value): return False

        change_value: Activity = Activity(
            self.__id,
            self.__value
            LocalDateTime.now(),
        )
        self.__activity_window.addActivity(change_value)

        return True


    def change_is_manual(self, is_manual: PresetIsManual):
        if not self.__value.change(is_manual): return False

        change_is_manual: Activity = Activity(
            self.__id,
            self.__value
            LocalDateTime.now(),
        )
        self.__activity_window.addActivity(change_is_manual)

        return True

위의 코드에서 볼 수 있듯이 아키텍처의 다른 계층에 대한 종속성이 완전히 없는 도메인 개체를 만듭니다. 우리는 코드를 적합하다고 생각하는 방식으로 자유롭게 모델링할 수 있습니다. 이 경우 모델의 상태(state)에 매우 가까운 곳에 풍부한 동작 코드가 존재하여 더 쉽게 이해할 수 있습니다.

도메인 객체라는 개념이 왜 생겼는지에 대해서는 해당글을 읽어보면 이해에 도움이 됩니다.

Building an Input Port

그러나 실제로 유즈 케이스를 구현하기 전에 해당 유즈 케이스에 대한 외부 API를 생성합니다. 이 API는 육각형 아키텍처의 Inbound 포트가 됩니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from abc import ABCMeta

from pydantic.dataclasses import dataclass


class PresetUseCase(metaclass=ABCMeta):

    @dataclass(frozen=True)
    class ModifyPrestCommand:
        __target_preset_id: PresetId
        __target_preset_is_manual: PresetIsManual
        __target_preset_value: PresetJson

    def modify_preset(self, modify_preset_command: ModifyPrestCommand):
        pass

이제 애플리케이션 코어 외부의 어댑터가 modify_preset()를 호출하여 이 유즈 케이스를 호출할 수 있습니다.

이제 이 인터페이스를 구현해봅시다.


Building a Use Case and Output Ports

유즈 케이스 구현에서 우리는 도메인 모델을 사용하여 프리셋을 수정합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21

class PresetService(PresetUseCase):

    def __init__(self, modify_preset_port: ModifyPresetPort, 
                 load_preset_port: LoadPresetPort, 
                 update_activites_port: UpdateActivitesPort
                 ):
        self.update_activites_port: UpdateActivitesPort = update_activites_port
        self.modify_preset_port: ModifyPresetPort = modify_preset_port
        self.load_preset_port: LoadPresetPort = load_preset_port
        ### 실제로는 Python Dependency Injector 라이브러리를 통해 구현해야함 ###

    def modify_preset(self, command: ModifyPrestCommand):
        preset: Preset = self.load_preset_port.load_preset(command._target_preset_id)

        if not preset.change_value(command._target_preset_value): return False
        if not preset.change_is_manual(command._target_preset_is_manual): return False

        self.update_activites_port.update_activities(preset.__activity_window)

        return PresetResponse.from_orm(modify_preset_port.modify_preset(preset))

기본적으로 유즈 케이스 구현은 데이터베이스에서 소스 및 대상 프리셋을 로드하고, 새 상태를 데이터베이스에 다시 기록합니다.

데이터베이스에서 프리셋을 로드하고 저장하기 위해 구현체는 LoadPresetPort 및 ModifyPresetPort Outbound 포트 인터페이스에 의존합니다. 나중에 영속성 어댑터 내에서 구현합니다.

Outbound 포트 인터페이스의 모양은 유즈 케이스에 따라 결정됩니다. 유즈 케이스를 구현하면서 데이터베이스에서 특정 데이터를 로드해야 하는 경우가 있으므로 이에 대한 Outbound 포트 인터페이스를 만듭니다. 물론 이러한 포트는 다른 유즈 케이스에서 재사용될 수 있습니다. 우리의 경우 Outbound 포트는 다음과 같습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class ModifyPresetPort(metaclass=ABCMeta):
    def modify_preset(preset: Preset) -> None:
        pass

class LoadPresetPort(metaclass=ABCMeta):
    def load_preset(preset_id: PresetId) -> Preset:
        pass

class UpdateActivitesPort(metaclass=ABCMeta):
    def update_activities(activity_window: ActivityWindow) -> Preset:
        pass

Building a Web Adapter

도메인 모델, 유즈 케이스, Inbound 및 Outbound 포트를 통해 이제 애플리케이션의 코어(즉, 육각형 내의 모든 것)을 작성했습니다. 그러나 이 코어가 외부와 연결되지 않으면 쓸모 없습니다. 따라서 REST API를 통해 애플리케이션 코어를 노출하는 어댑터를 빌드합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@router.put("", response_model=ResponseEntity, status_code=200)  # or 201?
async def modify_preset(request: Request,
                        presetRequest: PresetRequest,
                        preset_usecase: PresetUseCase = Depends(Provide[Container.preset_service]),
                        ):
    if not preset_service.store_exists(tenant_db, store_id):
        raise PresetException(status_code=404, detail=f"store_id: {store_id}. couldn't find store.")

    result = preset_usecase.modify_presets(ModifyPrestCommand(presetRequest.to_modify_preset_command()))
    
    logger.info("<<<<<<<<Success modify Preset>>>>>>>>")
    
    return ResponseEntity(
        httpMethod=request.method,
        message="The modification of preset was successful.",
        path=request.url.path,
        timestamp=datetime.datetime.now(tz=KST).strftime('%Y-%m-%d %H:%M:%S'),
        body=result
    )

MVC 패턴에 익숙하다면 흔한 웹 컨트롤러라는것이 알 수 있습니다. request body에서 PresetRequest에 넣고 컨트롤러와 유즈케이스 간에 종속성을 없애기 위해 to_modify_preset_command()을 통해 바꿔주고 유즈 케이스를 호출하기만 하면 됩니다. 더 복잡한 로직에서 웹 컨트롤러는 인증 및 권한 부여를 확인하고 JSON 입력에 대해 보다 정교한 매핑을 수행해야 합니다.

위의 컨트롤러는 HTTP 요청을 유즈 케이스의 Inbound 포트에 매핑하여 우리의 유즈 케이스를 외부에 노출합니다. 이제 Outbound 포트를 연결하여 애플리케이션을 데이터베이스에 연결하는 방법을 살펴보겠습니다.


Building a Persistence Adapter

Inbound 포트는 유즈 케이스 서비스에 의해 구현되지만 Outbound 포트는 영속성 어댑터(JPA, SQLalchemy)에 의해 구현됩니다. 코드베이스에서 영속성을 관리하기 위한 도구로 SqlAlchemy를 사용한다고 가정해 보겠습니다. Outbound 포트 LoadPresetPort 및 ModifyPresetPort 구현하는 영속성 어댑터는 다음과 같이 구현할 수 있습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
class PresetOutboundAdapter(LoadPresetPort, ModifyPresetPort, UpdateActivitesPort):

    def __init__(self, store_preset_crud, activities_crud, preset_mapper):
        self.store_preset_crud: StorePresetCrud = store_preset_crud
        self.activities_crud: ActtivitedCrud = activities_crud
        self.preset_mapper: PresetMapper = preset_mapper

    ### 실제로는 Python Dependency Injector 라이브러리를 통해 구현해야함 ###

    def modify_preset(self, preset: Preset) -> None:
        self.store_preset_crud.update(self.preset_mapper.map_to_orm(preset))

    def load_preset(self, preset_id: PresetId) -> Preset:
        preset_model: PresetModel = self.store_preset_crud.find_by_id(preset_id.get_value())
        activities_model: list[Activity] = self.activities_crud.find_by_owner(preset_id.get_value())

        return self.preset_mapper.map_to_domain_entity(preset_model, activities_model)

    def update_activities(self, preset: Preset) -> None:
        for activity in preset:
            if activity.get_id() is None:
                self.activities_crud.create(self.preset_mapper.map_to_orm(activity))

Pros & Cons

위에 구현을 정리한 육각형 아키텍쳐는 다음과 같습니다.

Pros

  1. 유지보수성

관심사를 분리하고 비즈니스 로직 디커플링을 제공하여 수정하고자 하는 코드를 쉽게 찾을 수 있기 때문에 유지보수 가능성을 높입니다.

  1. 유연함

서로 다른 기술 간의 교환은 쉽습니다. 지정된 포트에 대해 각각 특정 기술을 사용하는 여러 개의 어댑터를 가질 수 있습니다. 둘 중 하나를 선택하려면 해당 포트에 사용할 어댑터를 구성하기만 하면 됩니다. 이 구성은 외부 구성 속성 파일을 수정하는 것만큼 쉽습니다. 소스 코드 수정, 재컴파일, 재작성도 없습니다.

마찬가지로 기존 소스 코드를 건드리지 않고도 포트에 특정 기술 어댑터를 새로 추가할 수 있습니다. 어댑터는 따로 개발 및 컴파일됩니다. 런타임에 의존성이 주입되어 포트에 연결됩니다.

  1. 기술 발전에 영향을 받지 않는 애플리케이션

기술은 비즈니스 로직보다 더 자주 발전합니다. 비즈니스 로직이 기술에 묶여 있는 애플리케이션에서는 비즈니스 로직에 손을 대지 않고는 기술 변화를 수행할 수 없습니다. 비즈니스 로직이 바뀌면 순간 애플리케이션의 모든 코드들은 불안정한 상태로 변하게 됩니다

육각형 아키텍처에서 업그레이드하려는 기술은 애플리케이션 외부의 어댑터에 있기 때문에 어댑터만 교체하면 됩니다. 도메인은 어댑터에 의존하지 않기 때문에 애플리케이션 자체는 변하지 않습니다.

  1. 기술적 의사 결정 연기

개발과 코딩을 시작할 때 어떤 프레임워크와 기술을 사용할지 결정을 미루고 비즈니스 로직에만 집중할 수 있습니다. 나중에 기술을 선택하고 어댑터를 코딩할 수 있습니다.

  1. 테스트 기능 향상

이 아키텍처가 제공하는 주요 이점은 의존적인 외부 장치로부터 애플리케이션을 격리하여 테스트할 수 있다는 것입니다. 이 작업은 다음 두 가지 작업을 통해 수행할 수 있습니다.

  • 각 포트에 대해 포트에 대한 테스트 사례를 실행할 테스트 어댑터를 개발합니다.
  • 각 포트에 대해 mock 어댑터를 개발합니다.

Cons

  1. 복잡성

육각형 아키텍처를 구현하는 소프트웨어 프로젝트는 복잡한 구조를 가지고 있으며, 이들 사이에 많은 모듈과 명시적인 의존성이 정의되어 있습니다.

  1. 애플리케이션 생산성

방금 살펴본 복잡성으로 인해, 프로젝트가 너무 크고 어댑터가 많다면, 컴파일하고 테스트를 실행하고, 모든 모듈을 함께 만들고, 전체 프로젝트를 시작하는 과정에는 많은 시간이 걸릴 것입니다.

  1. 맵핑 객체들

포트 및 어댑터를 통해 기술과 애플리케이션을 분리하면 간접적으로, 즉 어댑터가 포트와 특정 기술 인터페이스 간에 변환될 때 메소드에 대한 추가 호출이 추가됩니다. 그 외에도 응용 프로그램과 외부 객체 간의 매핑이 필요할 수 있습니다.


So, is it Worth the Effort?

많은 분들은 종종 이런 아키텍처가 노력할 가치가 있는지 물어보곤 합니다. 이 아키텍쳐를 구현하기 위해서는 포트 인터페이스를 생성해야 하고 도메인 모델의 여러 표현 사이를 매핑할 객체를 만들어야 합니다. 웹 어댑터 내에 도메인 모델 표현이 있고 영속성 어댑터 내에 또 다른 표현이 있을 수 있습니다.

그래서 노력할 가치가 있을까요?

물론 정답은 “상황에 따라 다릅니다"입니다.

단순히 데이터를 저장하고 읽는 CRUD 애플리케이션을 구축하는 경우 이와 같은 아키텍처는 아마도 오버헤드일 것입니다. 하지만 상태와 동작을 결합하는 풍부한 도메인 모델로 표현할 수 있는 풍부한 비즈니스 규칙으로 애플리케이션을 구축하는 경우, 이 아키텍처는 도메인 모델을 요소들의 중심에 배치하기 때문에 정말 많은 효과를 볼 수 있을 겁니다.


Outro

이 아키텍쳐를 공부하면서 마치 레고 조립하듯이 애플리케이션을 만들 수 있어서 재밌있었습니다. SOLID 원칙도 준수하고 역할,책임,협력이라는 관점에서 객체들을 만들 수 있기 때문에 더 객체지향적인 애플리케이션을 시도 할 수 있을것 같았습니다. 하지만 공부하면서 도메인 객체와 유즈 케이스가 조금 모호해서 이해하는데 힘들었는데, 더 많은 예제 코드를 보면서 공부해 봐야겠습니다.

저도 공부하면서 정리한 글이라 틀린 점이나 잘못된 점이 있다면 지적 부탁드립니다!

참고