기계 번역으로 제공되는 번역입니다. 제공된 번역과 원본 영어의 내용이 상충하는 경우에는 영어 버전이 우선합니다.
육각형 아키텍처 패턴
의도
포트 및 어댑터 패턴이라고도 하는 육각형 아키텍처 패턴은 2005년 Alistair Cockburn 박사가 제안했습니다. 데이터 저장소 또는 사용자 인터페이스(UI)에 대한 종속성 없이 애플리케이션 구성 요소를 독립적으로 테스트할 수 있는 느슨하게 결합된 아키텍처 생성을 목표로 합니다. 이 패턴은 데이터 저장소 및 UI의 기술 종속을 방지하는 데 도움이 됩니다. 그러면 비즈니스 로직에 영향을 주지 않거나 이에 제한되지 않고 시간이 지남에 따라 기술 스택을 더 쉽게 변경할 수 있습니다. 느슨하게 결합된 이 아키텍처에서 애플리케이션은 포트라는 인터페이스를 통해 외부 구성 요소와 통신하고 어댑터를 사용하여 이러한 구성 요소와의 기술 교환을 변환합니다.
목적
육각형 아키텍처 패턴은 데이터베이스 또는 외부 API에 액세스하는 코드와 같은 관련 인프라 코드에서 비즈니스 로직(도메인 로직)을 격리하는 데 사용됩니다. 이 패턴은 외부 서비스와의 통합이 필요한 AWS Lambda 함수에 대해 느슨하게 결합된 비즈니스 로직 및 인프라 코드를 생성하는 데 유용합니다. 기존 아키텍처에서 일반적인 사례는 사용자 인터페이스 및 데이터베이스 계층의 비즈니스 로직을 저장 프로시저로 임베딩하는 것입니다. 비즈니스 로직 내에서 UI 특정 구문을 사용하는 것과 함께 이러한 사례는 데이터베이스 마이그레이션 및 사용자 경험(UX) 현대화 작업에서 병목 현상을 일으키는 긴밀하게 결합된 아키텍처로 이어집니다. 육각형 아키텍처 패턴을 사용하면 기술이 아닌 용도에 따라 시스템과 애플리케이션을 설계할 수 있습니다. 이 전략을 통해 데이터베이스, UX 및 서비스 구성 요소와 같은 애플리케이션 구성 요소를 쉽게 교환할 수 있습니다.
적용 가능성
다음과 같은 경우 육각형 아키텍처 패턴을 사용합니다.
-
애플리케이션 아키텍처를 분리하여 완전히 테스트할 수 있는 구성 요소를 생성하려고 합니다.
-
여러 유형의 클라이언트가 동일한 도메인 로직을 사용할 수 있습니다.
-
UI 및 데이터베이스 구성 요소에는 애플리케이션 로직에 영향을 주지 않는 주기적 기술 새로 고침이 필요합니다.
-
애플리케이션에는 여러 입력 공급자와 출력 소비자가 필요하며, 애플리케이션 로직을 사용자 지정하면 코드가 복잡해지고 확장성이 부족해집니다.
문제 및 고려 사항
-
도메인 기반 설계: 육각형 아키텍처는 도메인 기반 설계(DDD)에서 특히 효과적입니다. 각 애플리케이션 구성 요소는 DDD의 하위 도메인을 나타내며, 육각형 아키텍처를 사용하여 애플리케이션 구성 요소 간에 느슨한 결합을 달성할 수 있습니다.
-
테스트 가능성: 육각형 아키텍처는 설계상 입력 및 출력에 추상화를 사용합니다. 따라서 내재적 느슨한 결합으로 인해 유닛 테스트 및 테스트를 격리한 상태로 작성하는 것이 더 쉬워집니다.
-
복잡성: 비즈니스 로직을 인프라 코드와 분리하는 복잡성을 신중하게 처리할 경우 민첩성, 테스트 범위 및 기술 적응성과 같은 큰 이점을 얻을 수 있습니다. 그렇지 않으면 문제를 해결하기가 복잡해질 수 있습니다.
-
유지 관리 오버헤드: 아키텍처를 플러그 가능하게 만드는 추가 어댑터 코드는 애플리케이션 구성 요소에서 여러 입력 소스 및 출력 대상을 써야 하거나 입력 및 출력 데이터 저장소가 시간이 지남에 따라 변경되어야 하는 경우에만 정당화됩니다. 그렇지 않으면 어댑터가 유지 관리해야 할 또 다른 추가 계층이 되어 유지 관리 오버헤드가 발생합니다.
-
지연 시간 문제: 포트 및 어댑터를 사용하면 다른 계층이 추가되어 지연 시간이 발생할 수 있습니다.
구현
육각형 아키텍처는 애플리케이션을 UI, 외부 API, 데이터베이스, 메시지 브로커와 통합하는 코드 및 인프라 코드에서 애플리케이션 및 비즈니스 로직의 격리를 지원합니다. 포트 및 어댑터를 통해 애플리케이션 아키텍처의 다른 구성 요소(예: 데이터베이스)에 비즈니스 로직 구성 요소를 쉽게 연결할 수 있습니다.
포트는 애플리케이션 구성 요소에 구애받지 않는 진입점입니다. 이러한 사용자 지정 인터페이스는 인터페이스를 구현하는 사람이나 대상에 관계없이 외부 액터가 애플리케이션 구성 요소와 통신할 수 있도록 허용하는 인터페이스를 결정합니다. USB 어댑터를 사용하는 한 USB 포트를 통해 여러 유형의 디바이스가 컴퓨터와 통신할 수 있도록 허용하는 방식과 유사합니다.
어댑터는 특정 기술을 사용하여 포트를 통해 애플리케이션과 상호 작용합니다. 어댑터는 이러한 포트에 연결되고, 포트에서 데이터를 수신하거나 포트로 데이터를 제공하며, 추가 처리를 위해 데이터를 변환합니다. 예를 들어 REST 어댑터를 사용하면 액터가 REST API를 통해 애플리케이션 구성 요소와 통신할 수 있습니다. 포트는 포트 또는 애플리케이션 구성 요소에 위험을 부과하지 않고도 여러 어댑터를 보유할 수 있습니다. 이전 예제를 확장하기 위해 GraphQL 어댑터를 동일한 포트에 추가하면 REST API, 포트 또는 애플리케이션에 영향을 주지 않고 액터가 GraphQL API를 통해 애플리케이션과 상호 작용할 수 있는 추가 수단을 제공합니다.
포트는 애플리케이션에 연결되며 어댑터는 외부 환경에 대한 연결 역할을 지원합니다. 포트를 사용하여 느슨하게 결합된 애플리케이션 구성 요소를 생성하고 어댑터를 변경하여 종속된 구성 요소를 교환할 수 있습니다. 그러면 애플리케이션 구성 요소는 컨텍스트를 인식하지 않고도 외부 입력 및 출력과 상호 작용할 수 있습니다. 구성 요소는 모든 수준에서 교환할 수 있으므로 자동화된 테스트를 용이하게 합니다. 테스트를 수행하기 위해 전체 환경을 프로비저닝하는 대신 인프라 코드에 대한 종속성 없이 구성 요소를 독립적으로 테스트할 수 있습니다. 애플리케이션 로직은 외부 요인에 종속되지 않으므로 테스트가 단순화되고 종속성을 모의하기가 더 쉬워집니다.
예를 들어 느슨하게 결합된 아키텍처에서 애플리케이션 구성 요소는 데이터 저장소의 세부 정보를 모르고도 데이터를 읽고 쓸 수 있어야 합니다. 애플리케이션 구성 요소가 맡은 책임은 인터페이스(포트)에 데이터를 제공하는 것입니다. 어댑터는 애플리케이션의 요구 사항에 따라 데이터베이스, 파일 시스템 또는 Amazon S3와 같은 객체 스토리지 시스템일 수 있는 데이터 저장소에 쓰는 로직을 정의합니다.
상위 수준 아키텍처
애플리케이션 또는 애플리케이션 구성 요소에는 핵심 비즈니스 로직이 포함되어 있습니다. 다음 다이어그램과 같이 포트에서 명령이나 쿼리를 수신하고 포트를 통해 외부 액터로 요청을 전송합니다. 이때 이 액터는 어댑터를 통해 구현됩니다.
AWS 서비스를 사용하여 구현
AWS Lambda 함수에는 목표를 달성하기 위해 긴밀하게 결합된 비즈니스 로직과 데이터베이스 통합 코드가 모두 포함되어 있기도 합니다. 육각형 아키텍처 패턴을 사용하여 비즈니스 로직을 인프라 코드에서 분리할 수 있습니다. 이렇게 분리하면 데이터베이스 코드에 종속되지 않고 비즈니스 로직의 유닛 테스트를 수행할 수 있으며 개발 프로세스의 민첩성이 향상됩니다.
다음 아키텍처에서 Lambda 함수는 육각형 아키텍처 패턴을 구현합니다. Lambda 함수는 Amazon API Gateway REST API에 의해 시작됩니다. 함수는 비즈니스 로직을 구현하고 DynamoDB 테이블에 데이터를 씁니다.
샘플 코드
이 섹션의 샘플 코드에서는 Lambda를 사용하여 도메인 모델을 구현하고, 인프라 코드(예: DynamoDB에 액세스하는 코드)와 분리하며, 함수에 대한 유닛 테스트를 구현하는 방법을 보여줍니다.
도메인 모델
도메인 모델 클래스에는 외부 구성 요소 또는 종속성에 대한 지식이 없으며 이는 비즈니스 로직만 구현합니다. 다음 예제에서 Recipient 클래스는 예약 날짜에서 중복이 있는지 확인하는 도메인 모델 클래스입니다.
class Recipient: def __init__(self, recipient_id:str, email:str, first_name:str, last_name:str, age:int): self.__recipient_id = recipient_id self.__email = email self.__first_name = first_name self.__last_name = last_name self.__age = age self.__slots = [] @property def recipient_id(self): return self.__recipient_id #..... def are_slots_same_date(self, slot:Slot) -> bool: for selfslot in self.__slots: if selfslot.reservation_date == slot.reservation_date: return True return False def is_slot_counts_equal_or_over_two(self) -> bool: #.....
입력 포트
RecipientInputPort 클래스는 수신자 클래스에 연결되고 도메인 로직을 실행합니다.
class RecipientInputPort(IRecipientInputPort): def __init__(self, recipient_output_port: IRecipientOutputPort, slot_output_port: ISlotOutputPort): self.__recipient_output_port = recipient_output_port self.__slot_output_port = slot_output_port ''' make reservation: adapting domain model business logic ''' def make_reservation(self, recipient_id:str, slot_id:str) -> Status: status = None # --------------------------------------------------- # get an instance from output port # --------------------------------------------------- recipient = self.__recipient_output_port.get_recipient_by_id(recipient_id) slot = self.__slot_output_port.get_slot_by_id(slot_id) if recipient == None or slot == None: return Status(400, "Request instance is not found. Something wrong!") print(f"recipient: {recipient.first_name}, slot date: {slot.reservation_date}") # --------------------------------------------------- # execute domain logic # --------------------------------------------------- ret = recipient.add_reserve_slot(slot) # --------------------------------------------------- # persistent an instance throgh output port # --------------------------------------------------- if ret == True: ret = self.__recipient_output_port.add_reservation(recipient) if ret == True: status = Status(200, "The recipient's reservation is added.") else: status = Status(200, "The recipient's reservation is NOT added!") return status
DynamoDB 어댑터 클래스
DDBRecipientAdapter 클래스는 DynamoDB 테이블에 대한 액세스를 구현합니다.
class DDBRecipientAdapter(IRecipientAdapter): def __init__(self): ddb = boto3.resource('dynamodb') self.__table = ddb.Table(table_name) def load(self, recipient_id:str) -> Recipient: try: response = self.__table.get_item( Key={'pk': pk_prefix + recipient_id}) ... def save(self, recipient:Recipient) -> bool: try: item = { "pk": pk_prefix + recipient.recipient_id, "email": recipient.email, "first_name": recipient.first_name, "last_name": recipient.last_name, "age": recipient.age, "slots": [] } # ...
Lambda 함수 get_recipient_input_port는 RecipientInputPort 클래스의 인스턴스를 위한 팩토리입니다. 관련 어댑터 인스턴스를 사용하여 출력 포트 클래스의 인스턴스를 구성합니다.
def get_recipient_input_port(): return RecipientInputPort( RecipientOutputPort(DDBRecipientAdapter()), SlotOutputPort(DDBSlotAdapter())) def lambda_handler(event, context): body = json.loads(event['body']) recipient_id = body['recipient_id'] slot_id = body['slot_id'] # get an input port instance recipient_input_port = get_recipient_input_port() status = recipient_input_port.make_reservation(recipient_id, slot_id) return { "statusCode": status.status_code, "body": json.dumps({ "message": status.message }), }
유닛 테스트
모의 클래스를 주입하여 도메인 모델 클래스의 비즈니스 로직을 테스트할 수 있습니다. 다음 예제에서는 도메인 모델 Recipent 클래스에 대한 유닛 테스트를 제공합니다.
def test_add_slot_one(fixture_recipient, fixture_slot): slot = fixture_slot target = fixture_recipient target.add_reserve_slot(slot) assert slot != None assert target != None assert 1 == len(target.slots) assert slot.slot_id == target.slots[0].slot_id assert slot.reservation_date == target.slots[0].reservation_date assert slot.location == target.slots[0].location assert False == target.slots[0].is_vacant def test_add_slot_two(fixture_recipient, fixture_slot, fixture_slot_2): #..... def test_cannot_append_slot_more_than_two(fixture_recipient, fixture_slot, fixture_slot_2, fixture_slot_3): #..... def test_cannot_append_same_date_slot(fixture_recipient, fixture_slot): #.....
GitHub 리포지토리
이 패턴의 샘플 아키텍처를 완전히 구현하려면 https://github.com/aws-samples/aws-lambda-domain-model-sample
관련 내용
-
Hexagonal architecture
, 문서 작성자: Alistair Cockburn -
Developing evolutionary architectures with AWS Lambda
(AWS 블로그 게시물(일본어))
동영상
다음 비디오(일본어)에서는 Lambda 함수를 사용하여 도메인 모델을 구현할 때 육각형 아키텍처를 사용하는 방법을 설명합니다.