[만들면서 배우는 헥사고날 아키텍처 설계와 구현] 1-5 드라이빙 오퍼레이션과 드리븐 오퍼레이션의 본질 탐색
[만들면서 배우는 헥사고날 아키텍처 설계와 구현] 1-5 드라이빙 오퍼레이션과 드리븐 오퍼레이션의 본질 탐색

[만들면서 배우는 헥사고날 아키텍처 설계와 구현] 1-5 드라이빙 오퍼레이션과 드리븐 오퍼레이션의 본질 탐색

Tags
다비비에이라
HexagonalArchitecture
Published
April 10, 2024
Author
lkdcode

드라이빙 오퍼레이션과 드리븐 오퍼레이션의 본질 탐색

헥사고날 시스템을 잘 이해하기 위해서는 헥사고날 시스템을 둘러싼 환경도 알아야 한다. 드라이빙 오퍼레이션과 드리븐 오퍼레이션은 헥사고날 애플리케이션과 상호작용하는 외부 요소를 나타낸다. 드라이빙 관정메서 프런트엔드 애플리케이션이 헥사고날 시스템의 행위를 유도하는 주요 액터로 행동하는 방법과 드리븐 관점에서는 메시지 기반 시스템이 헥사고날 시스템에 의해 구동되게 하려면 무엇이 필요한지 설명한다.
  • 드라이빙 오퍼레이션을 통한 헥사고날 애플리케이션에 대한 요청 호출
  • 헥사고날 시스템과 웹 애플리케이션의 통합
  • 테스트 에이전트 실행 및 다른 애플리케이션의 헥사고날 시스템 호출
  • 드리븐 오퍼레이션을 통한 외부 리소스 처리
 

드라이빙 오퍼레이션을 통한 헥사고날 애플리케이션에 대한 요청 호출

시스템은 자급자족 할 수 없다. 아무도 시스템과 상호작용하지 않고 시스템도 다른 사용자나 시스템과 상호작용하지 않을 수 없다. 모든 컴퓨터 시스템에는 입력 및 출력 오퍼레이션이 있다고 가정한다.(컴퓨터 아키텍처, 폰 노이만) 헥사고날 아키텍처 시점에서는 시스템의 입력 측은 '드라이빙 오퍼레이션'에 의해 제어된다. (헥사고날 애플리케이션의 동작을 시작하게 하고 유도하기 때문) 드라이빙 오퍼레이션은 다양한 관점을 가정할 수 있다. 명령행 콘솔, 브라우저 요청, 특정 테스트 케이스 검증을 원하는 테스트 에이전트, 다른 시스템 등이 있다.
 

웹 애플리케이션을 헥사고날 시스템에 통합

기술의 발전과 변화는 오래된 시스템을 환상적인 프레임워크를 기반으로 교체되었다. 서로 다른 범주의 컴포넌트들 사이의 경계를 명확하게 만들기를 원했고 마침내 프론트엔드 코드와 백엔드 코드를 가까이 두는 것이 소프트웨어 프로젝트에서 엔트로피의 원인이 될 수 있다는 것을 깨달았다. 이러한 관행에 대한 대응으로, 프런트엔드 시스템이 하나 이상의 백엔드 시스템이 있는 네트워크를 통해 상호작용하는 분리된 독립 실행형 애플리케이션이 있는 분리된 아키텍처에 관심은 갖게 되었다.

구현 & 리팩터링 예시

public interface RouterNetworkUseCase { Router addNetworkToRouter(RouterId routerId, Network network); Router getRouter(RouterId routerId); }
  • getRouter 메서드를 추가해 프런트엔드 애플리케이션이 라우터를 표시할 수 있도록 한다.
public class RouterNetworkInputPort implements RouterNetworkUseCase { /** 코드 생략**/ @Override public Router getRouter(RouterId routerId) { return fetchRouter(routerId); } private Router fetchRouter(RouterId routerId) { return routerNetworkOutputPort.fetchRouterById(routerId); } /** 코드 생략**/ }
fetchRouter 는 이미 입력 포트 구현을 갖고 있지만, 라우터 검색을 할 수 있는 노출된 오퍼레이션을 갖고 있지 않다. fetchRouter 메서드는 addNetworkToRouter 뿐만 아니라 getRouter 에서도 사용된다.
/* RouterNetworkAdapter */ public Router getRouter(Map<String, String> params){ var routerId=RouterId.withId(params.get("routerId")); return routerNetworkUseCase.getRouter(routerId); }
입력 포트가 변경된 것을 입력 어댑터에도 전달해야 한다. RouterNetworkAdapterRouterNetworkCLIAdapterRouterNetworkRestAdapter 모두의 기본 입력 어댑터다. 프론트엔드 애플리케이션이 헥사고날 시스템과 통신할 수 있게 하려면 REST 어댑터를 사용해야 한다. 따라서 이러한 통신을 위해 RouterNetworkRestAdapater 를 알맞게 변경해야 한다. 이제 애플리케이션의 프런트엔드 부분에 대한 개발로 초점을 이동할 수 있다.

테스트 에이전트 실행

프런트엔드 애플리케이션 외에 드리븐 오퍼레이션의 또 다른 일반적인 유형은 기능이 잘 동작하는지 확인하기 위해 헥사고날 시스템과 상호작용하는 테스트와 모니터링 에이전트다. 포스트맨 같은 도구를 이용하면 특저 요청에 대한 애플리케이션의 동작 방법을 검증하기 위한 포괄적인 테스트 케이스를 생성할 수 있다. 또한 특정 애플리케이션 엔드포인트가 정상인지 아닌지를 확인하기 위해 엔드포인트에 대한 요청을 주기적으로 발행할 수 있다. 애플리케이션이 정상 상태인지 확인할 수 있도록 특저 엔드포인트를 제공하는 스프링 액추에이터 같은 도구와 함께 대중화되었다. 일부 기법은 애플리케이션이 활성화되어 있는지 확인하기 위해 주기적으로 애플리케이션에 요청을 보내는 프로브 메커니즘(probe mechanism)을 포함하기도 한다.
 
예를 들어, 애플리케이션이 활성화되어 있지 않거나 시간 초과를 발생시키는 경우에는 애플리케이션이 자동으로 다시 시작할 수 있다. 쿠버네티스에 기반한 클라우드 네이티브 아키텍처에서는 프로브 메커니즘을 사용하는 시스템을 아주 흔히 볼 수 있다. 포스트맨과 뉴먼을 사용해 테스트를 실행하는 것은 헥사고날 애플리케이션을 지속적인 통합(CI:Continuous Intergration) 파이프라인에 통합하는 데 좋다. 포스트맨을 사용해 컬렉션과 개별 테스트를 만들고, 이러한 동일 컬렉션들은 테스트를 실행하는 데 뉴먼을 사용할 수 있는(젠킨스 등) CI 도구를 통해 트리거되고 검증된다.

애플리케이션 간의 헥사고날 시스템 호출

모놀리스(monolith) 에서는 객체와 메서드 호출 사이에 직접적으로 데이터가 흐른다. 같은 애플리케이션 내의 모든 소프트웨어 명령어는 그룹화되어 있으며, 통신 오버헤드를 줄이고 시스템에서 생성된 로그가 중앙 집중화된다. 마이크로서비스(microservice)와 분산 시스템에는 전체 시스템이 제공하는 기능을 위해 협력하는 독립 실행형 애플리케이션 사이에서 일부 데이터가 네트워크를 통해 흐른다. 분산 방식에서는 둘 이상의 자급식(self-contained) 헥사고날 시스템이 전체 헥사고날 기반 시스템을 구성할 수 있다. 헥사고날 시스템 A 는 요청을 시작하는 주요 액터로 행동하며 헥사고날 시스템 B 에 대한 드라이빙 오퍼레이션을 트리거 한다.
notion image
시스템 A 는 출력 어댑터 중 하나를 통해 요청을 트리거한다. 이 요청은 시스템 B 의 입력 어댑터 중 하나로 직접 이동한다. 분산 아키텍처의 흥미로운 점은 모든 시스템 컴포넌트를 개발하는 데 같은 프로그래밍 언어를 사용할 필요가 없다는 것이다.

드리븐 오퍼레이션을 통한 외부 리소스 처리

출력 포트 및 출력 어댑터는 외부 리소스를 담당하게 되고 '보조 액터(secondary actor)' 로 알려져 있으며, 헥사고날 애플리케이션에 없는 데이터나 기능을 제공한다. 헥사고날 애플리케이션이 보조 액터에게 요청을 보내는 경우, 즉 일반적으로 헥사곤 애플리케이션의 유스케이스 중 하나에서 드라이빙 오퍼레이션을 처음 트리거하는 주요 액터를 대신해서 요청을 보내는 경우, 이러한 요청을 '드리븐' 오퍼레이션이라고 부른다. (헥사고날 시스템에 의해 통제되고 유도되기 때문) 드라이빙 오퍼레이션은 헥사고날 시스템의 행위를 유도하는 주요 액터의 요청에서 비롯된다. 드리븐 오퍼레이션은 헥사고날 시스템에 의해 데이터베이스나 다른 시스템 같은 보조 액터 쪽으로 시작된 요청이다. 드리븐 측면에서는 몇 가지 오퍼레이션이 존재한다.
  • 지속성(Database 등)
  • 메시징
  • 모의객체 서버
  • 자바 시스템 등

데이터 지속성

데이터 지속성을 기반으로 하는 드리븐 오퍼레이션이 가장 일반적이다. H2 출력 어댑터(인메모리 DB) 가 예다. 이러한 종류의 드리븐 오퍼레이션은 헥사고날 시스템과 데이터베이스 사이에서 객체를 처리하고 변환하기 위해 ORM(Obejct-Relational Mapping) 기법을 사용할 때가 많다. 자바 분야에서는 하이버네이트와 EclipseLink 가 ORM 기능을 제공하기 위한 견고한 자바 지속성 API(JPA) 구현을 제공한다. 트랜잭션 메커니즘도 지속성 기반 드리븐 오퍼레이션의 일부다. 트랜잭션을 활용할 때 헥사고날 시스템이 직접 트랜잭션 경계를 처리하거나 이러한 책임을 애플리케이션 서버로 위임할 수 있다.

메시징과 이벤트

모든 시스템이 동기식 통신에 의존하는 것은 아니다. 상황에 따라 애플리케이션의 런타임 흐름을 방해하지 않고 특정 이벤트를 트리거하고 싶을 수도 있다. 시스템 컴포넌트 사이의 통신이 비동기적으로 발생하는 기법의 영향을 크게 받는 아키텍처 유형이 있다. 시스템의 컴포넌트들이 더 이상 다른 애플리케이션이 제공하는 인터페이스에 연결되어 있지 않기 때문에 이 같은 시스템은 그러한 기법을 사용하여 더욱 느슨하게 결합된다. 여기서는 연결을 블로킹하는 API 에만 의존하지 않고, 메시지와 이벤트가 논블로킹(non-blocking) 방식으로 애플리케이션의 행위를 유도하게 한다. '블로킹(blocking)' 은 애플리케이션 흐름이 진행되기 위해 응답을 기다려야 하는 연결을 의미한다. 논블로킹은 반대다. 메시지 기반 시스템은 헥사고날 애플리케이션에 의해 유도되는 보조 액터다.
$ bin/zookeeper-server-start.sh config/zookeeper.properties $ bin/kafka-server-start.sh config/server.properties
$ bin/kafka-topics.sh --create --topic topology-inventory-events --bootstrap-server localhost:9092 $ bin/kafka-console-producer.sh --topic topology-inventory-events --bootstrap-server localhost:9092 $ bin/kafka-console-consumer.sh --topic topology-inventory-events --bootstrap-server localhost:9092
헥사고날 애플리케이션이 카프카로 이벤트를 내보내고 소비할 수 있도록 적절한 포트의 어댑터를 추가해야 한다.
public interface NotifyEventOutputPort { void sendEvent(String event); String getEvent(); }
  • 출력 어댑터로 출력 포트를 구현한다.
public class NotifyEventKafkaAdapter implements NotifyEventOutputPort { private static String KAFKA_BROKERS = "localhost:9092"; private static String GROUP_ID_CONFIG = "consumerGroup1"; private static String CLIENT_ID = "hexagonalclient"; private static String TOPIC_NAME = "topology-inventory-events"; private static String OFFSET_RESET_EARLIER = "earliest"; private static Integer MAX_NO_MESSAGE_FOUND_COUNT = 100; /** 코드 생략 **/ }
  • 카프카 토픽에 메시지를 보내기 위한 메서드를 작성한다.
private static Producer<Long, String> createProducer() { Properties props = new Properties(); props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_BROKERS); props.put(ProducerConfig.CLIENT_ID_CONFIG, CLIENT_ID); props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, LongSerializer.class.getName()); props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); return new KafkaProducer<>(props); }
  • Producer 메서드가 생성한 메시지를 소비하는 consumer 메서드를 작성한다.
public static Consumer<Long, String> createConsumer() { Properties props = new Properties(); props.put(ConsumerConfig.BOOSTRAP_SERVERS_CONFIG, KAFKA_BROKERS); props.put(ConsumerConfig.GROUP_ID_CONFIG, GROUP_ID_CONFIG); props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, LongDeserializer.class.getName()); props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 1); props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, OFFSET_RESET_EARLIER); Consumer<Long, String> consumer = new KafkaConsumer<>(props); consumer.subscribe(Collections.singletonList(TOPIC_NAME)); return consumer; }
  • 카프카 producer 인스턴스로 메시지를 보내는 메서드를 작성한다.
@Override public void sendEvent(String eventMessage) { var record = new ProducerRecord<Long, String>(TOPIC_NAME, eventMessage); try { var metadata = producer.send(record).get(); getEvent(); } catch (Exception e) { e.printStackTrace(); } }
  • 카프카의 메시지를 소비하고 그것을 WebSocekt 서버로 보내기 위한 메서드를 작성한다.
@Override public String getEvent() { int noMessageToFetch = 0; AtomicReference<String> event = new AtomicReference<>(""); while(true) { /** 코드 생략 **/ consumerRecords.forEach(record -> { event.set(record.value()); }); } var eventMessage = event.toString(); if(sendToWebsocket) { sendMessage(eventMessage); } return eventMessage; }
  • 메시지를 검색한 후 WebSocket 서버로 메시지를 전달하는 sendMessage 를 호출한다.
public void sendMessage(String message) { try { var client = new WebSocketClientAdapter(new URI("ws://localhost:8887")); client.connetBlocking(); client.send(message); client_closeBlocking(); } catch (URISyntaxException | InterruptedException e) { e.printStackTrace(); } }
카프카 토픽에서 메시지가 소비되면 헥사고날 애플리케이션은 WebSocketClientAdapter를 사용해 WebSocket 서버로 메시지를 전달한다. 헥사고날 애플리케이션에서 마지막으로 해야 할 일은 지금 생성한 포트와 어댑터를 사용해 이벤트를 보내는 메서드를 작성하는 것이다.
public class RouterNetworkInputPort implements RouterNetworkUseCase { /** 코드 생략 **/ @Override public Router addNetworkToRouter(RouterId routerId, Network network) { var router = fetchRouter(routerId); notifyEventOutputPort.sendEvent("adding " + network.getName() + "network to router " + router.getId().getUUID()); return createNetwork(router, network); } @Override public Router getRouter(RouterId routerId) { notifyEventOutputPort.sendEvent("Retrieving router ID" + routerId.getUUID()); return fetchRouter(routerId); } }
notion image
카프카와 웹소켓을 이용한 이러한 통합은 헥사고날 애플리케이션이 메시지 주도 오퍼레이션을 처리하는 방법을 보여준다. 이러한 기술을 추가하기 위해 비즈니스 로직을 건드릴 필요가 없다. 시스템의 기능을 보강하려면 포트와 어댑터만 더 만들면 된다.

모의 서버

일반적인 소프트웨어 개발 방법은 개발, QA, 운영 같이 다양한 환경을 갖는 것이다. 첫 번째로 동작하는 소프트웨어 배포판은 개발 환경으로 간다. CI 검증, 단위 테스트 통합 테스트는 파이프라인 실행 중에 이뤄질 수 있다. 특히 통합 테스트는 다른 애플리케이션이나 시스템, 데이터베이스, 서비스 같이 모두 다른 환경에서 제공되는 컴포넌트에 의존한다. 개발 환경에서 통합 테스트를 실행하는 것은 위험성이 낮지만, 리소스를 동시에 사용하는 경우에는 문제가 발생할 수 있다. 이러한 동시성 문제는 테스트 결과의 불일치를 일으킬 수 있다. 테스트 장애를 극복하기 위해 어떤 도구는 애플리케이션 엔드포인트와 그것들의 응답을 시뮬레이션하기도 한다. 이러한 도구는 모의 솔루션(mock solution) 으로 알려져 있으며, 다양한 모양과 형태로 제공된다. 지저분한 작업을 대신 수행하고 논리에만 집중할 수 있게 해주는 정교한 도구인 모의 서버(mock server) 가 있다. 모의 서버는 애플리케이션에 유용한 리소스를 제공하는 외부 엔티티 역할을 하기 때문에 모의 서버를 실제 시스템을 건드리는 대신 모의 서버의 기능을 활용하고자 하는 헥사고날 시스템에 의해 유도된 보조 엑터로 생각할 수 있다.