Hexagonal Architecture

소프트웨어 아키텍처의 개요와 육각형(Hexagonal) 아키텍처 패턴에 대해 설명한다.

소프트웨어 아키텍처 개요

소프트웨어 아키텍처는 소프트웨어를 쉽게 개발, 배포, 운영, 유지보수 하기 위한 방법이다. 소프트웨어 아키텍처의 목표는 시스템을 제대로 동작하도록 만드는 것이 아니다. 형편 없는 아키텍처를 갖춘 시스템도 수없이 많지만 그런대로 잘 동작한다. 이러한 시스템들은 대체로 운영에서는 문제를 겪지 않는다. 운영 보다는 배포, 유지보수, 계속되는 개발 과정에서 어려움을 겪는다. 소프트웨어 아키텍처는 처음부터 모든 것을 완벽하게 설계하는 것이 아니고, 세부적인 결정을 뒤로 미루고 쉽게 조정할 수 있도록 하는 것이다.

소프트웨어는 행위(behavior)와 구조(structure)에서 두 가지 가치를 제공한다.

  • 행위적 가치

    고객을 위해 기계가 수익을 창출하거나 비용을 절약하도록 한다. 고객의 요구사항을 구체화하고, 기계가 이러한 요구사항을 수행하도록 코드를 작성한다.

  • 구조적 가치(아키텍처)

    소프트웨어는 부드러운(soft) 제품(ware)의 합성어이다. 소프트웨어를 만든 이유는 기계(하드웨어)의 행위를 쉽게 변경할 수 있도록 하기 위해서다. 고객이 기능에 대한 생각, 즉 요구사항을 바꾸면 쉽게 소프트웨어를 변경할 수 있어야 한다.

둘 중 구조적 가치가 더 중요하다. 완벽하게 동작하지만 수정이 아예 불가능한 프로그램과 동작은 하지 않지만 변경이 쉬운 프로그램이 주어진다고 하면, 첫 번째 프로그램은 요구사항이 변경될 때 동작하지 않게 되고, 결국 프로그램이 돌아가도록 만들 수 없게 된다. 즉 이러한 프로그램은 쓸모가 없다. 두 번째 프로그램을 수정하면 돌아가게 만들 수 있고, 변경사항이 발생하더라도 여전히 동작하도록 유지보수 할 수 있다. 따라서 이러한 프로그램은 앞으로도 계속 유용한 채로 남을 것이다.

선택사항 열어두기

소프트웨어를 부드럽게 유지하는 방법은 선택사항을 가능한 한 많이, 그리고 가능한 한 오랫동안 열어 두는 것이다.

소프트웨어는 정책(policy)과 세부사항(detail) 두 가지 구성요소로 분해할 수 있다.

  • 정책

    모든 비즈니스 로직을 구체화 하는 것이다. 정책은 시스템이 제공하는 핵심 가치가 들어 있는 곳이다.

  • 세부사항

    사람, 외부 시스템, 프로그래머가 정책과 소통할 때 필요한 요소지만, 정책이 가진 행위에는 조금도 영향을 미치지 않는다. 이러한 세부사항에는 입출력 장치, 데이터베이스, 웹 시스템, 서버, 프레임워크, 통신 프로토콜 등이 있다.

예를 들면,

  • 개발 초기에는 데이터베이스 시스템을 선택할 필요가 없다. 상위 레벨의 정책은 어떤 종류의 데이터베이스를 사용하는지, 텍스트 파일을 사용할지 신경 써서는 안된다.
  • 개발 초기에는 웹 서버를 선택할 필요가 없다. 상위 레벨의 정책은 자신이 웹을 통해 전달된다는 사실을 알아서는 안된다. 심지어는 시스템이 웹을 통해 전송할 것인지조차도 결정할 필요가 없다.
  • 개발 초기에는 REST를 적용할 필요가 없다. 상위 레벨의 정책은 외부 세계로의 인터페이스에 대해 독립적이어야 하기 때문이다.
  • 개발 초기에는 프레임워크를 결정할 필요가 없다. 개발 성숙도가 높아지면 프레임워크로부터 독립할 수도 있다.

세부사항을 신경 쓰지 않고 상위 레벨의 정책을 만들 수 있다면, 이러한 세부사항에 대한 결정을 연기할 수 있다. 이를 통해 다양한 실험을 시도해 볼 수 있는 선택지도 열어 둘 수 있다. 현재 동작하고 있는 일부 상위 레벨 정책이 있고, 이들 정책이 데이터베이스에 독립적이라면 다양한 데이터베이스를 후보에 두고 그 적용 가능성과 성능을 검토해 볼 수 있다. 통신 프로토콜도 마찬가지다.

소프트웨어 아키텍처는 처음부터 모든 것을 완벽하게 설계하는 것이 아니고, 세부적인 결정을 뒤로 미루고 쉽게 조정할 수 있도록 하는 것이다.

육각형(Hexagonal) 아키텍처

Alistair Cockburn이 2005년 소개한 소프트웨어 아키텍처. 다음과 같은 목표를 정의하였다.

  • 애플리케이션은 유저, 다른 애플리케이션 및 자동화 된 테스트 환경에서 동동하게 제어할 수 있어야 한다. 비즈니스 로직은 UI, REST API 또는 테스트 프레임워크에서 호출되는지 여부에 차이가 없다.
  • 비즈니스 로직은 데이터베이스, 다른 인프라 및 서드파티 시스템과 격리하여 개발하고 테스트 할 수 있어야 한다. 비즈니스 로직 관점에서 데이터가 관계형 데이터베이스, NoSQL 시스템, XML 파일 혹은 자체 바이너리 포맷으로 저장되는지 여부는 차이가 없다.
  • 인프라 업그레이드(데이터베이스 서버 업그레이드, 외부 인터페이스 변경에 대응, 라이브러리 업그레이드 등)는 비즈니스 로직을 조정하지 않고도 가능해야 한다.

포트와 어댑터

육각형 아키텍처는 포트와 어댑터 아키텍처라고도 한다.

비즈니스 로직(육각형 아키텍처에서 애플리케이션이라고 함)을 외부 세계로부터 분리하는 것은 그림과 같이 포트어댑터를 통해 이루어진다.

포트는 기계적 및 전기적 프로토콜을 준수하는 어떤 장치가 연결될 수 있는 전기적 연결점을 의미한다. 어댑터는 서로 다른 프로토콜을 사용하는 장치를 연결하기 위해 전기적 연결을 변환해 주는 것을 의미한다.

애플리케이션은 아키텍처의 코어에 있고, 외부 세계와 통신하기 위한 인터페이스(포트)를 정의한다. 포트는 API, 사용자 인터페이스 혹은 다른 애플리케이션에 의해 제어되고, 데이터베이스, 외부 인터페이스 및 기타 인프라를 제어한다.

애플리케이션은 포트만 알고 있고, 포트 인터페이스만 참조하여 구현된다. 기술적 세부 사항은 포트 뒤에 있으며, 애플리케이션과는 상관이 없다.

외부 컴포넌트에 대한 연결은 어댑터에 의해 제공된다. 예시를 보자.

  1. 유저가 UI를 통해 제어
  2. 유저가 REST API를 통해 제어
  3. 외부 앱이 2와 동일한 REST API를 통해 제어
  4. 애플리케이션에서 데이터베이스를 제어
  5. 애플리케이션에서 서드파티 API를 통해 외부 애플리케이션을 제어

예를 들어, UI는 회원 가입 양식을 제공할 수 있다. 사용자가 모든 데이터를 입력하고 ‘Submit’을 클릭하면 UI 어댑터가 ‘Register User’ 명령을 생성하여 애플리케이션으로 보낸다. 혹은 HTTP POST 요청에 의해 REST 어댑터에서 동일한 명령을 생성할 수 있다.

애플리케이션의 반대편에서는 데이터베이스 어댑터가 ‘Save User’ 명령을 ‘INSERT INTO User VALUES (…)’과 같은 SQL 쿼리로 변환할 수 있다.

어댑터가 어떤 데이터베이스를 사용하는지, 어떤 버전에서 이 작업을 수행하는지는 애플리케이션 코어의 관점에서는 상관이 없다.

하나의 포트에 여러 어댑터가 연결될 수 있다. 위의 예와 같이 UI 어댑터와 REST 어댑터를 모두 포트에 연결하여 애플리케이션을 제어할 수 있다. 그리고 알림을 보내기 위한 포트에는 이메일 어댑터, SMS 어댑터, 메신저 어댑터가 연결되어 있을 수 있다.

위의 예에서 두 가지 유형의 포트와 어댑터, 즉 애플리케이션을 제어하는 포트와 어댑터와 애플리케이션에 의해 제어되는 포트를 보았다. 첫 번째 유형의 포트를 입력(inbound), 주(primary) 혹은 드라이빙(driving) 포트 및 어댑터라고 하고 일반적으로 육각형의 왼쪽에 표시한다. 두 번째 유형의 포트를 출력(outbound), 부(secondary) 혹은 드리븐(driven) 포트 및 어댑터라고 하며 일반적으로 육각형의 오른쪽에 표시한다.

종속성 규칙

기술적인 세부 사항 및 라이브러리가 애플리케이션에 영향을 주지 않도록 하기 위한 종속성 규칙이 있다.

종속성 규칙은 모든 소스 코드 종속성이 외부에서 내부로, 즉 육각형에서 애플리케이션 방향으로만 가리킬 수 있음을 의미한다.

왼쪽에 있는 입력 포트의 경우 클래스 및 관계성을 매핑 하는 것이 매우 간단하다.

사용자 등록 예제의 경우 다음 클래스와 같이 아키텍처를 구현할 수 있다.

RegistrationController는 어댑터이고, RegistrationUseCase 인터페이스는 입력 포트를 정의하며, RegistrationService는 포트에서 명세한 기능을 구현한다.

소스 코드 종속성은 RegistrationController에서 RegistrationUseCase로 이동하며, 코어를 향한다.

interface RegistrationUseCase {
  Register();
}

class RegistrationService : RegistrationUseCase {
  @Override
  Register() {
    // do registration
  }
}

class RegistrationController {
  RegistrationUseCase registration;
  
  ProcessRegister() {
    registration.Register();
  }
}

그러나 오른쪽에 있는 출력 포트와 어댑터, 즉 소스 코드 종속성이 호출 방향과 반대 방향이어야 하는 경우는 어떻게 구현해야 할까. 예를 들어 데이터베이스가 코어 외부에 있고 소스 코드 종속성이 코어로 전달되는 경우 애플리케이션 코어가 데이터베이스에 어떻게 엑세스 할 수 있을까

여기서 종속성 반전(dependency inversion) 원리가 적용된다.

종속성 반전

출력 포트에서도 포트는 인터페이스로 정의된다. 그러나 클래스 간의 관계성은 역전 된다.

PersistanceAdapterPersistancePort를 사용(use)하는 것이 아니고 구현(implement)한다. 그리고 RegistrationServicePersistencePort를 구현(implement)하는 것이 아니고 사용(use) 한다.

interface PersistancePort {
  SaveRegistration();
}

class PersistanceAdapter : PersistancePort {
  @Override
  SaveRegistration() {
    // save to DB
  }
}

class RegistrationService : RegistrationUseCase {
  PersistencePort persistence;

  Register() {
    persistence.SaveRegistration();
  }
}

종속성 반전 원리를 사용하여 호출 방향과 반대 되는 출력 포트 및 어댑터에 대한 소스 코드 종속성의 방향을 선택할 수 있다.

테스트

좋은 아키텍처의 요구 사항 중 하나는 ‘격리되어 테스트 할 수 있는 컴포넌트’를 제공하는 것이다. 육각형 아키텍처를 사용하면 애플리케이션을 쉽게 테스트 할 수 있다.

  • 테스트는 입력 포트를 통해 애플리케이션을 호출할 수 있다.
  • 출력 포트는 애플리케이션 쿼리에 응답하는 스텁 혹은 애플리케이션에서 보낸 이벤트를 기록하는 등 테스트 환경에 연결될 수 있다.

다음 그림은 데이터베이스에 대한 테스트 환경을 만들어 출력 데이터베이스 포트에 연결하고(Arrange), 입력 포트에서 애플리케이션을 호출하고(Act), 포트의 응답 및 데스트 환경과의 상호작용(Assert)을 확인하는 유닛 테스트를 보여 준다.

애플리케이션을 어댑터와 격리하여 테스트할 수 있을 뿐만 아니라 어댑터를 애플리케이션과 격리하여 테스트할 수도 있다.

다음 그림은 REST 어댑터에 대한 유닛 테스트를 보여준다. 입력 포트에 대한 테스트 환경을 생성하고, REST를 통해 HTTP POST 요청을 REST 어댑터로 보내고, 마지막으로 HTTP 응답 및 상호작용을 확인한다(Assert).

다음은 데이터베이스 어댑터에 대한 유닛 테스트를 보여준다. 테스트 컨테이너를 사용하여 데이터베이스를 시작하고(Arrange), 데이터베이스 어댑터에 있는 메소드를 호출하고(Act), 마지막으로 리턴값과 데이터베이스의 변경 내용이 예상과 같은지 여부를 확인한다(Assert).

육각형 아키텍처의 장점

확장

  • 어댑터나 인프라를 변경하지 않고도 애플리케이션 코어를 수정할 수 있다.
  • 애플리케이션에서 단 한 줄의 코드도 변경하지 않고 인프라를 교체할 수 있다. 해당 어댑터만 조정하면 된다.
  • 애플리케이션 코어 개발부터 시작하여 인프라에 대한 결정을 미뤄둘 수 있다. 애플리케이션 개발 과정에서 얻을 경험을 통해 사용할 인프라(프레임워크, 데이터베이스 등)에 대한 더 나은 결정을 내릴 수 있다.

격리

  • 애플리케이션은 순수하게 비즈니스 로직만 다룬다.
  • 모든 기술적 문제들은 입력 및 출력 포트에서 구현된다.
  • 애플리케이션 코어와 어댑터는 포트에 의해 격리되며, 애플리케이션 코어는 그 뒤에 숨겨진 기술적 세부사항을 알 필요 없이 포트와 상호작용한다.
  • 격리를 통해 코드의 책임을 지역화 할 수 있으므로, 아키텍처 경계가 흐려질 위험이 크게 줄어든다.

개발

  • 애플리케이션 포트가 정의되면 각 컴포넌트(코어, UI, 데이터베이스, 통신 등)에 대한 작업을 여러 개발자 간에 쉽게 나눌 수 있다.

테스트

  • 테스트 환경을 사용하여 모든 컴포넌트를 완전히 격리 된 상태로 테스트 할 수 있다.

육각형 아키텍처의 단점

포트와 어댑터를 정의하고 유지하기 위한 추가 작업이 필요하다.

References