Hexagonal Architecture 구현 - 1 (Multi-Module, Kotlin)
📚 헥사고날 아키텍처란?
헥사고날 아키텍처(Hexagonal Architecture)는 포트와 어댑터 아키텍처(Ports and Adapters Architecture) 라고도 불리며,
어플리케이션의 핵심 비즈니스 로직을 외부 시스템(데이터베이스, 웹, 메시징 시스템 등)으로부터 완전히 분리하여 독립성을 높이는 클린 아키텍처 패턴 중 하나입니다.
어플리케이션의 도메인 로직이 외부 의존성과 분리됨으로써, 유연성과 테스트 용이성을 크게 향상시킬 수 있습니다.

아키텍처 구조
이 구조는 도메인이 항상 중심에 위치하고, 외부 시스템과의 상호작용이 포트와 어댑터로 분리되는 형태로 동작합니다.
도메인 중심 설계(Domain-Centric Design):
- 어플리케이션의 핵심 비즈니스 로직을 중심으로 설계되고, 외부 시스템(데이터베이스, API 등)에 대한 의존성을 최소화 합니다.
포트(Ports):
- 도메인에서 외부와의 상호작용을 정의하는 인터페이스. 입력 포트는 외부에서 들어오는 데이터를 처리하고, 출력 포트는 외부 시스템에 데이터를 전달합니다.
어댑터(Adapters):
- 포트에 정의된 인터페이스를 실제로 구현하는 구현체. 예를 들어, 데이터베이스와 상호작용하는 어댑터는
JPA
또는JDBC
로 구현될 수 있습니다.
- 포트에 정의된 인터페이스를 실제로 구현하는 구현체. 예를 들어, 데이터베이스와 상호작용하는 어댑터는
유연한 의존성 관리:
- 어플리케이션의 비즈니스 로직은 외부 기술 스택(JPA, REST, Messaging 등)에 의존하지 않기 때문에, 필요에 따라 쉽게 기술 스택을 교체하거나 변경할 수 있습니다.
📚 헥사고날 아키텍처를 사용하는 이유
1. 비즈니스 로직의 독립성 강화
헥사고날 아키텍처는 비즈니스 로직을 외부 시스템과 분리하여 비즈니스 로직의 순수성을 유지합니다.
이를 통해 기술적 구현체(JPA, 메시징 시스템, API 등)에 영향을 받지 않고 비즈니스 로직을 설계할 수 있습니다.
2. 유연한 변경 및 확장성
어플리케이션의 특정 구현체(JPA, 메시징 시스템 등)를 다른 기술로 쉽게 교체할 수 있습니다.
포트와 어댑터를 통해 외부 시스템과 도메인 로직이 명확히 분리되어 있기 때문에, 새로운 요구사항이나 기술 스택 변경에도 유연하게 대응할 수 있습니다.
3. 테스트 용이성
비즈니스 로직이 외부 시스템에 의존하지 않기 때문에, 테스트할 때 목(Mocks)을 쉽게 사용할 수 있습니다.
이로 인해 단위 테스트와 통합 테스트를 더 쉽게 작성할 수 있고, 외부 시스템과의 상호작용 없이도 비즈니스 로직을 테스트할 수 있습니다.
4. 유지보수성
도메인 로직이 외부 기술과 분리되어 있기 때문에, 유지보수가 더 쉬워집니다.
비즈니스 로직에 집중하면서도 외부 시스템(데이터베이스, API 등)에 대한 변경은 어댑터에서만 처리하면 되기 때문에, 전체 어플리케이션의 변경 범위를 줄일 수 있습니다.
📚 기존 Layered Architecture 와의 차이점
헥사고날 아키텍처와 기존 레이어드 아키텍처(Layered Architecture) 사이에는 몇 가지 중요한 차이점이 있습니다.
1. 의존성의 방향
- 레이어드 아키텍처에서는 일반적으로 하위 레이어가 상위 레이어를 호출하지 못하지만, 상위 레이어는 하위 레이어를 호출하는 단방향 의존성을 갖고 있습니다.
- 주로 컨트롤러 → 서비스 → 레포지토리의 방향으로 호출이 이루어집니다.
- 헥사고날 아키텍처에서는 도메인이 외부 시스템(레포지토리, API 등)과의 의존성을 가지지 않습니다.
- 도메인은 외부와의 상호작용을 포트를 통해 추상화하고, 어댑터가 포트를 구현합니다.
- 즉, 의존성의 방향이 외부로부터 도메인으로 역전됩니다.
2. 비즈니스 로직의 위치
- 레이어드 아키텍처에서는 비즈니스 로직이 서비스 계층에 위치하고, 서비스는 레포지토리나 다른 외부 시스템에 강하게 의존합니다.
- 헥사고날 아키텍처에서는 비즈니스 로직이 도메인 계층에 위치하며, 외부 의존성 없이 비즈니스 규칙만을 다룹니다.
- 비즈니스 로직이 도메인에 집중되고 외부 기술에 종속되지 않기 때문에, 더 유연하고 유지보수하기 더 쉽습니다.
3. 외부 시스템과의 연결
- 레이어드 아키텍처에서는 특정 기술(JPA, REST API 등)이 서비스나 레포지토리 계층에서 직접적으로 사용되기 때문에, 기술 변경 시 서비스 계층의 코드도 변경될 가능성이 높습니다.
- 헥사고날 아키텍처에서는 외부 시스템과의 연결이 어댑터를 통해 구현되며, 도메인 로직은 기술적인 세부사항을 전혀 알 필요가 없습니다.
- 기술 스택을 변경할 때도 어댑터만 수정하면 되기 때문에 더 유연합니다.
📚 Hexagon
구현되어 있는 각 Hexagon(Module)은 아래와 같이 사용됩니다.
Domain Hexagon
도메인 모델을 정의하는 모듈로 어플리케이션의 핵심 로직을 담당합니다.
- 순수한 자바 객체(POJO)로 구현됩니다.
- Common 모듈 내의 라이브러리 외에 의존성을 가지지 않습니다.
Use Case Hexagon
도메인에 대한 Use Case를 정의합니다.
- 외부 시스템과의 통신을 위한 Port 인터페이스 정의
- Domain 외 Spring Boot, Common 모듈 내 라이브러리 의존성을 가집니다.
Infrastructure Hexagon
외부 인프라에 대한 의존성을 정의하는 모듈입니다.
- Domain, Use Case 외 Spring Boot, Common 모듈 내 라이브러리 의존성을 가집니다.
- 외부 인프라가 추가될떄 마다 Module을 분리해 관리합니다.
- infrastructure/persistence
- (필요 시 추가) infrastructure/kafka
- (필요 시 추가) infrastructure/redis
- 각 모듈별로 config class를 정의하며, application-{module-name}.yml 파일을 통해 각 모듈별 설정을 관리합니다.
Bootstrap Hexagon
여러 의존성들을 조합해 하나의 어플리케이션 서버를 구성하는 모듈입니다.
- 외부 요청을 받아 Use Case를 실행하기 위한 Primary Adapter를 정의합니다.
- Rest Controller, Kafka Consumer 등
- Domain, Use Case, Infrastructure 외 Spring Boot, Common 모듈 내 라이브러리 의존성을 가집니다.
- Spring Boot Application을 정의합니다.
- Use Case와 Infrastructure를 의존합니ㅏㄷ.
📚 구현 프로세스
Component Scan에 Lazy Init 적용하기
Use Case 모듈의 UseCaseConfig에 Lazy Init 옵션을 적용합니다.
@Configuration
@ComponentScan(basePackages = ["com.usecase"], lazyInit = true)
class UseCaseConfig
적용 이유는 Bootstrap Module의 API 모듈은 Infrastructure Module의 Persistence Module에 대한 의존성이 존재하지 않습니다.
글서 DB에 저장하는 UseCase 클래스를 Component Scan으로 등록하면 빈 등록 에러가 발생합니다.
마찬가지로 Worker의 경우도 IntraStructure 내부의 모듈에 대한 의존성이 없기 때문에 빈 등록 에러가 발생합니다.
이 문제를 해결하기 위해 Usecase 모듈의 Component Scan에 Lazy Init 옵션을 주어 사용 시점에 Bean을 생성하도록 하였습니다.
이렇게 설정하면 API 에서 Usecase에 의존성을 갖는 클래스가 없게 되고, 마찬가지로 Worker도 동일하게 동작되기 때문에 Usecase 클래스를 Bean으로 등록하지 않게 됩니다.
Component Scan을 각 모듈내에서 수행되도록 하기 위해 각 모듈의 Component Scan Annotation의 패키지 위치를 잘 지정해야 합니다.
더 자세히 얘기하면 @SpringBootApplication
이 @ComponentScan
을 내장하고 있기 때문에, Application 클래스를 최상위로 끌어올리면,
com.hexagonal-architecture.*
패키지 하위의 모든 클래스들을 Component Scan 대상으로 두기 떄문에 의도치 않은 빈 등록 액션이 일어날 수 있습니다.
Module 별 Config Class & Yaml 작성
각 모듈 별로 Config를 작성하고 Bootstrap Hexagon의 API, Worker 모듈의 Config는 각 모듈이 의존하는 Config를 Import 합니다.
@Configuration
@Import(
value = [
GrpcConfig::class,
MongoConfig::class,
UseCaseConfig::class
]
)
class ApiConfig
@Configuration
@Import(
PersistenceConfig::class,
UseCaseConfig::class,
)
class WorkerConfig
API Module의 Yaml
하위 의존성에 대한 yaml을 inclide하도록 구현
server:
port: 9000
shutdown: graceful
spring:
profiles:
active: local
include:
- grpc
- mongo
- usecase
Worker Module의 Yaml
하위 의존성에 대한 yaml을 inclide하도록 구현
server:
port: 9001
shutdown: graceful
spring:
profiles:
active: local
include:
- persistence
- usecase
📚 JPA Entity의 PK 생성 시 DB 채번(Auto-Increment)을 줄이기 위해 Ulid 사용
보통 JPA에서 Primary Key 생성 전략을 @GeneratedValue를 이용해 자동 생성할 수 있습니다.
이런 전략을 사용하면 데이터베이스에서 자동으로 채번을 해주기 때문에 개발자는 신경쓰지 않아도 됩니다.
하지만 이런 전략은 데이터 베이스에 대한 채번을 유발하며, 영속화 되기 전까진 id값을 null로 유지해야한다는, DB에 의존적인 코드를 작성하게 되는 단점이 있습니다.
이런 단점을 해결하기 위해 UUID를 사용하기도 합니다..
UUID는 DB 의존적이지 않고, 영속화 되기 전까지 id값을 null로 유지할 필요가 없습니다.
하지만 UUID는 생성 순서를 보장하지 않기 때문에 목록 조회 시 정렬기준으로 삼기에는 적합하지 않아 성능적인 이점을 가져갈 수 없습니다.
이때 ULID를 활용할 수 있습니다.
ULID는 UUID와 호환성을 가지면서 시간순으로 정렬할 수 있는 특징을 가지고 있습니다.
물론 ULID도단점이 있는데, UUID가 나노초까지 시간순을 보장해주는 반면 ULID는 밀리초까지만 시간순을 보장해줍니다.
이를 보완하기위해 ULID Creator 라이브러리는 Monotonic ULID를 제공합니다.
Monotonic ULID는 동일한 밀리초가 있다면 다음에 생성되는 ULID의 밀리초를 1 증가시켜서 생성하여 앞서 말한 단점을 보완합니다.
DB에 Primary Key를 채번하지 않고 도메인에서 직접 생성해서 사용하는 이러한 방식이 도메인이 외부에 의존하지 않고 직접 식별자를 생성할 수 있어서 클린 아키텍처에서는 더 큰 장점처럼 보였습니다.
build.gradlew.kts
dependencies {
implementation(libs.ulid.creator)
}
UlidUtil.kt
object UlidUtil {
fun createUlid(): UUID {
return UlidCreator.getMonotonicUlid().toUuid()
}
}
Post.kt
data class Post(
val id: UUID = UlidUtil.createUlid(),
val title: String,
val content: String
) {
companion object {
const val LIMIT_COUNT = 100L;
fun create(title: String, content: String): Post {
return Post(title = title, content = content)
}
}
}