회사 프로젝트의 기존 레이어드 아키텍처에서 DIP 원칙을 지켜 설계했지만,
애매한 부분들이 있었고 이런 문제로 헥사고날 기반의 아키텍처를 적용해 봤습니다.
헥사고날 아키텍처(포트와 어댑터)란?
구글, 유튜브에 검색하면 질 좋은 글이나 영상이 많이 있어 이 글에서는 생략하도록 하겠습니다.
구성 레이어
헥사고날 아키텍처에서 레이어는 다음과 같이 구분됩니다.
- Inbound adapter
- Use case
- Domain
- Outbound adapter
각 레이어가 어떤 역할을 하는지 하나씩 알아보겠습니다.
Inbound adapter(ui)
- 사용자의 요청을 처리하는 영역입니다.
- 외부에서 요청해야 동작한다고 하여 Driving Side Adapter 또는 Primary Adapter 로 불립니다.
e.g.
- HTTP API (RestController)
- Kafka 메시지 리스너
- WebSocket
- gRPC
Use case(application)
- 사용자가 요구하는 비즈니스 로직을 처리하는 영역입니다.
- 비즈니스 로직을 처리하기 위해 use case 레이어의 서비스, Domain 레이어에 있는 도메인 모델, Outbound Adapter 레이어에 위치한 실 구현체 간 협력을 통해 비즈니스 로직을 전개합니다.
e.g.
- 사용자 가입 서비스
- 관리자 이메일 전송 서비스
- 계약 저장/수정 서비스
Domain(domain)
- 애플리케이션이 풀고자 하는 문제의 영역으로 볼 수 있습니다.
- 외부 요인으로 부터 영향을 받지 않아야하는 것이 핵심이므로 도메인은 다른 레이어를 의존하는 것이 바람직합니다.
- 비즈니스 핵심인 도메인 모델과 외부 영역과 통신할 수 있는 Port 인터페이스가 위치하고 있습니다.
e.g.
- Contract: 계약을 표현하는 도메인 모델
- ContractMoney: 계약 금액을 표현한 도메인 모델
- ContractRepository(Port): 계약에 대한 CRUD 규격이 정의된 인터페이스
- MailSender(Port): 메일 전송을 위한 규격이 정의된 인터페이스
종종 도메인 레이어는 프레임워크나 라이브러리도 포함하지 않은 순수 언어로만 제한해야 한다는 의견이 있습니다.
이 의견에 동의하나 현실적으로는 스프링에 의존적인 프로젝트로 구성했기 때문에 스프링이 제공하는 애노테이션 정도는 사용해도 괜찮을 것 같다는 생각이 듭니다.
만약 스프링이 망해서 다른 프레임워크(Ktor, …)로 갈아탄다면 도메인 레이어에 큰 변화가 올지 모르지만, 그러한 가능성은 매우 낮다고 판단했습니다.
IT 세계에서 '은총알은 없다' 라는 말이 있듯이 현재 상황에 맞게 구성하는 것이 올바른 아키텍처라고 생각합니다.
Outbound adapter(infrastructure)
- 실제 외부 통신을 처리하는 영역입니다.
- 애플리케이션이 호출하면 동작하는 영역으로 Driven Side Adapter 또는 Secondary Adpater 로 불립니다.
- 도메인 레이어의 Port 인터페이스를 구현한 실 구현체가 위치합니다.
e.g.
- MailSenderImpl
구성도
앞선 설명과 헥사고날 아키텍처를 따르면 다음과 같은 그림이 만들어집니다.

똥손

이렇게 봤을 때 완벽해보이지만 규모가 그리 크지 않은 환경에서는 오버헤드가 있습니다.
바로 use case 인터페이스를 매번 만들어야 한다는 점인데요
비즈니스 로직을 수행하기 위해서 들어오는 영역을 인터페이스로 추상화 시키고 구현체를 매번 만드는 것은 매우 번거로운 일 입니다.
그래서 아래와 같이 변경해봤습니다.

use case 인터페이스를 제거하고 곧바로 서비스 클래스를 사용했습니다.
만약 요구사항이 변경되어 use case가 변경되면 서비스도 같이 변경될 가능성이 높습니다.
그래도 이러한 변경이 도메인 레이어에 영향을 주지 않기 때문에
Inbound Port에는 인터페이스를 굳이 사용하지 않아도 될 것 같다고 판단했습니다.
Outbound Port는 인터페이스를 그대로 사용하기로 했습니다.

도메인은 핵심이므로 외부 요인 변경에 의한 영향을 최소화해야 하기 때문에 잘 변하지 않는 부분에 의존해야만 합니다.
잘 변하지 않는 부분은 인터페이스이며, 도메인은 인터페이스를 의존하고 외부에서 도메인의 인터페이스를 의존하면
의존 역전 관계 원칙을 지킬 수 있습니다.
DIP를 잘 지키고 있다면, 만약 RDB가 망해서 NoSQL 세상이 와도 NoSQL Repository로 교체만 하면 되기 때문에 도메인 변화 없이 변경사항을 만족할 수 있습니다.

이로써 외부 영향을 최소화하면서 확장하기 쉬운 아키텍처가 완성되었습니다.
향후 외부 Kafka, Rabbit MQ 같은 메시지 큐 서비스로 부터 메시지를 받아야 한다면 해당 구현체의 이벤트 리스너를 Adapter로 추가하면 됩니다.
실제 적용된 애플리케이션 패키지

+
스프링 이벤트 리스너의 위치
이벤트를 사용했을 때의 장점인 관심사의 분리 때문에 사내 프로젝트에서 스프링 이벤트를 사용하고 있습니다.
이벤트를 발행하는 주체는 이벤트를 처리하는 대상에는 관심을 갖지 않기 때문에 결합이 느슨해지는 장점이 있습니다.
이런 내부 이벤트를 Spring 프레임워크의 도움을 받으면 발행/처리를 쉽게 할 수 있습니다.
// Member 도메인 예시
...
fun change(role: Role) {
check(!role.isGuest()) { "게스트 권한으로 변경할 수 없습니다." }
if (isFirstGranted(role)) {
Events.publish(RoleGrantedEvent(this.id!!)) // 이벤트 발행
}
this.role = role
}
이렇게 도메인 객체 내에서 이벤트(RoleGrantedEvent)를 발행한다고 했을 때
헥사고날 아키텍처가 적용되기 전 위 이벤트를 어디서 핸들링 해야할 지 고민을 많이 했습니다.
- Outbound Adapter에 위치
- Outbound Adapter에서 비즈니스 로직을 위해 Service를 호출하면 흐름이 부자연스러움…
- 컨트롤러와 같은 레이어에 위치
- 도메인에서 발행한 이벤트의 흐름이 역으로 올라간다…
많은 고민 중 ..
이벤트 핸들러는 이벤트를 받고 use case를 실행하는 역할로 봤기 때문에 inbound adapter 에서 받는 것이 자연스럽다고 판단했습니다.

@Component
class RoleGrantedEventHandler( // 내부 이벤트를 받는 Inbound Adapter
private val roleApprovalService: RoleApprovalService, // use case 레이어 서비스 의존
) {
@TransactionalEventListener(value = [RoleGrantedEvent::class])
fun handle(event: RoleGrantedEvent) {
roleApprovalService.approve(event.memberId)
}
}
최종적으로 다음과 같은 흐름이 발생합니다.
비즈니스 로직 수행 중 이벤트 발행 → Inbound Adapter 에서 이벤트 구독 → 해당 이벤트에 대한 로직을 담당하는 use case 호출 → …
추가로 우리 애플리케이션이 카프카 메시지를 구독한다고 했을 때에 ui.message.external 패키지 하위에 카프카 토픽 리스너를 붙이면 좋을 것 같다는 생각이 듭니다.
잘못된 내용이 있다면 댓글로 남겨주세요
참고