서론
이전 게시글에서는 도메인 주도 개발 (DDD)을 위해 Event Storming을 진행하였다. 그러나, 도출된 이벤트를 바탕으로 코드를 구현하는 과정을 처음 겪으면서 도메인 주도 개발을 진행하다보니 많은 시간을 소모하게 되었다.
레이어드 아키텍처를 이용하여 프로젝트를 진행하였지만, 외부 의존성을 어떻게 관리할 것인지, 유연한 설계를 위해 어떻게 코드를 구성하는 것이 가장 효과적인지에 대한 고민은 계속하게 되었다.
그리고, 현재 상황에서 가장 큰 고민은 모든 비즈니스 로직이 Service 계층에 존재하고, 다양한 유틸 라이브러리에 대한 의존성이 모든 계층을 통틀어서 더욱 커지게 되는 것이었다.
이러한 문제를 해결하기 위해 다양한 아키텍처 패턴을 고려하고 있었는데, 그중에서 Clean Architecture와 Hexagonal Architecture이 가장 관심을 끌게 되었다. 이 패턴들은 레이어드 아키텍처에서 발생하는 문제를 해결하는데 있어 더욱 좋은 효율을 보이는 것으로 확인하게 되었다.
이번 게시글에서는 헥사고날 아키텍처가 무엇인지, 그리고 어떤 요소들을 가지고 있는지 알게된 내용을 정리하는것을 목적으로 진행하도록 하겠다.
Clean Architecture
클린 아키텍처(Clean Architecture)는 외부 세계와의 결합을 최소화하고, 시스템의 유연성과 확장성을 높이는 것에 중점을 둔 아키텍처 패턴이다. 특히, 의존성 역전 원칙(DIP)을 통해 의존성이 [웹 → 도메인 ← 데이터베이스] 방향으로 흘러갈 수 있도록 설계하게된다.
클린 아키텍처는 도메인 계층이 자신을 사용하는 외부 의존성에 대해 알지 못하게 하여, 변경에 유연하게 대응할 수 있게 하는 아키텍처 패턴이다. 우리가 평소에 익숙하게 사용하는 Controller, Service, Repository 패턴을 통한 프로젝트 구현은 시간이 지남에 따라 비즈니스 로직이 서비스 계층에 집중되게 만들고, 데이터베이스 구조를 변경하기 어렵게 만든다.
예를 들어, TypeORM을 이용해 MySQL, PostgreSQL을 사용하는 프로젝트를 가정해보자.
개발이 완료된 후 1달이상 실제 서비스를 운영하고 있는 상황에서 데이터베이스를 MongoDB로 변경해야 된다면 어떻게 해야하는가? 클린 아키텍처를 도입하지 않은 상태에서는 Nest.js의 모든 비즈니스 로직과 데이터베이스를 의존하고 있는 모든 코드를 변경해야만 MongoDB로 전환할 수 있다. 이러한 변환 작업은 상당한 시간과 리소스를 투입하게 만든다.
반면에 클린아키텍처를 도입한 경우, 각 레이어를 Infrastructure, Application, Domain으로 나누고 각 레이어의 의존성이 내부로만 향하도록 설계하면, 데이터베이스 변경 작업은 단지 Infrastructure 레이어의 구현 부분만 수정하면 된다. 이렇게 된다면, 코드 변경에 소요되는 시간을 크게 단축시킬 수 있게 된다.
구체적인 방법은 다양하겠지만, 클린 아키텍처 패턴을 따라 구현하게된다면, 우리는 더 명확하게 의존성 분리의 방법을 이해하고 그에 따른 이점을 경험할 수 있게 될 것이다.
Hexagonal Architecture
헥사고날 아키텍처(Hexagonal Architecture)는 포트와 어댑터 아키텍처(Ports and Adapters Architecture)라고도 불리며, 고수준의 내부 영역이 외부영역에 구현된 어댑터를 통해 외부 요소와 유연하게 연결되면서도, 핵심 비즈니스 로직은 외부 요소에 대한 직접적인 의존성 없이 유지되는 것을 추구하는 아키텍처 패턴이다.
헥사고날 아키텍처는 고수준의 비즈니스 로직을 표현하는 내부 영역과 인터페이스 처리를 담당하는 저수준의 외부 영역으로 나뉘게 된다. 내부 영역은 순수한 비즈니스 로직을 표현하는 역할을 담당하며, 외부 영역과 연계되는 포트(Port)를 가지게 된다. 외부 영역은 외부에서 들어오는 요청을 처리하는 인바운드 어댑터(Inbound Adapter)와 비즈니스 로직에 의해 호출되어 외부와 연계되는 아웃바운드 어댑터(Outbound Adapter)로 구성된다.
헥사고날 아키텍처와 클린 아키텍처
클린 아키텍처와 헥사고날 아키텍처는 모두 시스템의 유연성을 높이고 의존성을 관리하는 방법에 대해 설명하지만, 각각의 특징이 존재한다. 클린 아키텍처는 의존성 역전 원칙(DIP)을 적극적으로 활용하여, 도메인과 외부 의존성을 역전시키는 것을 중점적으로 다루게된다. 반면 헥사고날 아키텍처는 포트(Port)와 어댑터(Adapter)를 통해 외부 요소와의 연결을 관리하며, 이를 통해 외부 시스템과의 유연한 연동을 가능하게 한다. 따라서, 클린 아키텍처는 의존성 관리에 중점을 둔다면, 헥사고날 아키텍처는 외부 요소와의 유연한 연동에 중점을 두는 아키텍처 패턴이다.
헥사고날 아키텍처의 목적
그렇다면, 다양한 아키텍처 패턴 중 헥사고날 아키텍처는 왜 생겨나게 된 것일까? 대표적으로 아래와 같은 4가지가 존재한다.
1️⃣ 비즈니스 로직과 프레임워크를 분리한다.
헥사고날 아키텍처는 비즈니스 로직을 프레임워크와 분리하여, 프레임워크에 대한 의존성을 줄이고 코드의 가독성을 향상시키게 된다. 이를 통해 동일한 비즈니스 로직이 필요한 상황에서 코드의 중복 생성을 줄이며, 유연성을 향상시키게 된다.
만약 백엔드의 프레임워크를 Nest.js를 사용하고, 프론트엔드의 스택을 React.js를 사용하는 회사가 있다고 가정해보자. 서비스를 개발하던 중 백엔드와 프론트엔드 모두 동일한 비즈니스 로직이 필요한 상황이 발생한 상황에서, 비즈니스 로직이 프레임워크에 종속된 상태라면, 무의미하게 코드만 양산되는 결과가 발생하게 될 것이다.
2️⃣ 테스트 용이한 구조를 가지게 만든다.
어플리케이션의 핵심 로직은 외부 요소와 독립적으로 구성되므로, 단위 테스트를 수행할 때 외부 요소에 대한 요구사항을 고려하지 않아도 된다. 이로 인해 테스트의 복잡성이 줄어들고, 테스트 코드 작성에 필요한 러닝 커브를 최소화하게 된다.
기존 어플리케이션이 Nest.js에 의존적인 비즈니스 로직을 작성한 상태에서 단위 테스트 코드를 작성한다면, Nest.js가 어떻게 동작하고, 테스트 코드를 작성하기 위한 Application Module이 무엇인지 알아야하고, 테스트 코드를 작성하기 위한 수많은 러닝 커브가 발생하게 될 것이다.
3️⃣ 외부 요소에 대한 영향을 최소화한다.
헥사고날 아키텍처는 어플리케이션 로직이 외부 요소에 의존하지 않고, 독립적으로 구성된다. 그렇기 때문에, 외부 인프라나 3rd party 라이브러리가 변경되더라도, 이에 따른 어플리케이션의 변경을 최소화할 수 있게 될 것이다.
만약 우리가 TypeORM에서 Mongoose로 변경한다 하더라도, 어플리케이션 로직이 수정될 필요 없이 단순히 어댑터에 구현된 코드를 Mongoose로 작성하고, 의존성을 주입(DI, Dependency Injection)하게 된다면, 추가적인 리소스없이 코드에 반영될 것이다.
4️⃣ 확장성을 강화한다.
헥사고날 아키텍처는 어플리케이션의 각 요소가 독립적으로 동작하게 만들어, 시스템의 일부를 쉽게 변경하거나 확장할 수 있도록 지원한다. 이로 인해 어플리케이션의 확장성을 크게 향상시키며 ,시스템의 변경에 대응 하는 능력을 강화하게된다.
헥사고날 아키텍처의 구성 요소
도메인 모델 (Domain Model)
도메인 모델(Domain Model)은 실제로 구현될 객체를 나타낸다. 일반적으로 DDD 방법론의 일환으로 진행한 Event Storming에서 도출된 어그리게이트(Aggregate)가 하나의 도메인 모델로 볼 수 있다.
도메인 모델은 내부 의존성 외에는 그 어떠한 외부 의존성을 가져선 안된다. 이를 위해 TypeORM, Mongoose 등의 외부 라이브러리에 의존하지 않는 무결한 상태를 유지하는 것이 필요하다.
포트 (Port)
포트(Port)는 외부 의존성과 내부 로직을 연결하기 위해 사용되는 인터페이스의 집합이다. 인바운드 포트(Inbound Port)와 아웃바운드 포트(Outbound Port)의 두 가지 형태가 존재하며, 비슷해 보일 수 있지만 각각 다른 역할을 수행한다.
인바운드 포트 (Inbound Port)
인바운드 포트(Inbound Port)는 외부 의존성을 가진 구현체들이 내부 비즈니스 로직을 실행할 수 있도록 해주는 인터페이스의 집합이다. 주로, 유즈 케이스(Use Case)와 커맨드(Command)를 관리하는데 사용한다.
유즈 케이스 (Use Case)
유즈 케이스(Use Case)는 특정 비즈니스 요구사항을 수행하는 전체 프로세스를 설명하는 역할을 담당한다. 주로, 서비스가 어떻게 실행되어야 하는지에 대한 시나리오나 흐름을 제공한다.
예를 들어, 우리가 일반적으로 레이어드 아키텍처 패턴을 사용하게 된다면, Controller 계층에서는 Service의 특정 Method를 호출하여 서비스를 실행하게 될 것이다. 하지만, 헥사고날 아키텍처에서는 실제 구현체인 Service를 실행하지 않고, 유즈 케이스를 따라 실제 비즈니스 로직이 구현되도록 구성하여 내부와 외부의 의존성을 독립적으로 가져갈 수 있도록 한다.
아웃바운드 포트 (Outbound Port)
아웃바운드 포트(Outbound Port)는 내부 비즈니스 로직에서 필요한 외부 의존성을 연결하기 위한 인터페이스이다. 주로, 외부 API 요청이나 DB 접근 등의 비즈니스 로직을 실행하기 위한 외부 서비스들을 연결하는데 필요하다.
우리가 TypeORM 라이브러리를 이용하여, Users 모델을 사용하는 Repository를 구현한다고 가정해보자. 사용자를 찾기 위한 메서드(getUser
)와 사용자를 생성하기 위한 메서드(createUser
)가 필요하게 될 것이다. 이렇게 필요한 메서드들과 의존하고 있는 다양한 내용들을 인터페이스화 하여 관리하는 요소이다.
서비스 (Service)
서비스(Service)는 정의된 유즈 케이스를 바탕으로 실제 비즈니스 로직을 구현하는 구성 요소이다. 예를 들어, 회원가입 기능에서는 동일한 사용자가 이미 존재하는지 검증하는 비즈니스 로직이나 레이어드 아키텍처의 서비스 계층에서 구현한 비즈니스 로직을 실제 작성하는 부분이라 볼 수 있다.
어댑터 (Adapter)
어댑터(Adapter)는 외부 의존성을 가진 구성요소를 특정 인터페이스에 맞추어 내부와 연결하는 역할을 하는 구현체이다. 포트와 마찬가지로 인바운드 어댑터(Inbound Adapter)와 아웃바운드 어댑터(Outbound Adapter)로 구분된다.
인바운드 어댑터 (Inbound Adapter)
인바운드 어댑터(Inbound Adapter)는 어플리케이션으로 들어오는 요청을 처리하는 역할을 담당하는 구현체이다. 주로, 사용자 인터페이스 또는 외부 시스템으로부터 요청을 전달받는 역할을 담당한다.
예를 들어보자. 우리가 일반적으로 Controller 계층을 이용하여 API를 실행하게 되는데, 이때 사용하는 Controller를 웹 어댑터(Web Adapter)라고 볼 수 있을것이고, RabbitMQ, Kafka를 이용하여 메시지를 Subscribe 를 하는 경우, 메시지 어댑터(Messaging Adapter)라고 부를 수 있을 것이다.
아웃바운드 어댑터 (Outbound Adapter)
아웃바운드 어댑터(Outbound Adapter)는 어플리케이션으로부터 나가는 요청을 처리하는 역할을 담당하는 구현체이다.주로 데이터 저장소 또는 외부 API에 대한 요청을 보내는 역할을 담당한다.
레이어드 아키텍처 패턴에서는 우리가 일반적으로 외부와 통신하기 위해, Repository를 이용하여, MySQL과 같은 데이터베이스와 통신을 하였을 것 이다. 이런 Database를 사용하기 위한 ORM의 구성요소는 영속성 어댑터(Persistence Adapter)라고 볼 수 있을 것이고, RabbitMQ, Kafka를 이용하여 메시지를 Publish 하는경우, 메시지 어댑터(Messaging Adapter)라고 부를 수 있을 것이다.
어떤 아키텍처 패턴을 선택해야하는가?
아키텍처 패턴을 선택하는 것은 단지 개발의 편의성에만 영향을 미치는 것이 아니라, 전체 시스템의 경제성에도 크게 영향을 미치게된다.
예를 들어, 동일한 기능을 제공하는 두 서비스 중에서 하나는 코드가 난잡하게 구성되어 가격이 저렴하고, 다른 하나는 코드가 깔끔하게 구성되어 가격이 비싸다고 가정해보자. 대다수의 소비자는 최대한 저렴한 선택지를 고르게 될 것이다. 왜냐하면, 소비자들은 보통 시스템을 바로 사용할 것이고, 그 내부 구조에 대해 신경쓰지 않기 때문이다.
하지만, 개발자의 관점에서는 이야기가 달라지게된다. 시간이 지남에 따라 유지보수가 필요할 경우, 아키텍처 패턴이 유지보수의 용이성에 큰 영향을 미치게 되기 때문이다.
처음 서비스를 제공할 때에는 내부 구조가 어떤지는 중요하지 않을 수 있다. 하지만, 시간이 흐르면서 시스템의 구조를 변경하거나 확장해야할 필요가 생기는데, 이때 유지보수를 용이하게 하는 아키텍처 패턴의 선택이 중요해지게될 것이다.
따라서, 경제적인 관점으로 생각한다면 프로젝트의 목표와 필요성에 따라 아키텍처 패턴을 선택해야 될 것이다. 빠르게 기능을 구현하는 것이 목표인 프로젝트인지, 아니면 지속적으로 유지보수를 진행해야 하는 프로젝트 인지를 고려하여 아키텍처 패턴을 선택하는 것이 올바른 선택이 될 것이다.
참고 Document
- 도메인 주도 설계로 시작하는 마이크로서비스 개발 - 위키 북스
- Hexagonal Architecture
- SK(주) C&C’s TECH BLOG
- Software Architecture Patterns
- Making Architecture Matter - Martin Fowler Keynote
'분석과 탐구' 카테고리의 다른 글
글또 8기 회고와 9기의 목표 (0) | 2023.12.10 |
---|---|
제로부터 시작하는 Prisma와 Nest.js (0) | 2023.07.16 |
제로부터 시작하는 DDD를 위한 이벤트스토밍 (0) | 2023.06.18 |
Amazon API Gateway의 WebSocket API란 무엇인가? (0) | 2023.05.21 |
1일 1커밋에서 벗어나기 (0) | 2023.05.07 |