우선 기존의 설계된 아키텍처를 먼저 보겠습니다.
해당 아키텍처는 얼핏 보면 아주 깔끔하게 정리된 구조처럼 보입니다. 그러나 이 아키텍처도 치명적인 단점을 몇가지 가지고 있습니다.
따라서 위의 계층화 아키텍처는 확장에 매우 불리한 구조를 가지고 있기 때문에 개선점이 필요합니다. 저는 이를 개선하기 위해서 포트앤어댑터(Port and Adapter) 아키텍처라고 흔히 불리는 Hexagonal Architecture를 고려하게 되었습니다.
라인 블로그에 매우 좋은 글이 있으니 링크를 걸어드리겠습니다.
지속 가능한 소프트웨어 설계 패턴: 포트와 어댑터 아키텍처 적용하기 - Line corps
해당 글에서는 포트앤어댑터 아키텍처를 도입해야하는 이유로, 코드의 재사용성을 제시하고 있습니다.
저또한 결국에는 코드의 재사용성을 높이기 위해서 Hexagonal Architecture를 도입하게 되었습니다.
Hexagonal Architecture에서는 외부 연결을 담당하는 계층을 Adapter라는 별개의 모듈로 두고, 이에 대한 로직을 정의하는 Port에는 Interface 등의 추상화된 오브젝트들을 놓는 형식으로, 해당 로직을 사용하고자 하는 adapter에서는 간편하게 Port를 주입받게 되면 손쉽게 로직을 갈아끼울 수 있게되는 마법같은 효과를 누릴수 있게됩니다.
해당 효과는 어떻게 얻을 수 있을까요? 라인에서 올린 글에서 예시 그림을 하나 가져오도록 하겠습니다.
해당 그림에서 Adapter 하나에 MySQL이 붙어있는 모습을 볼 수 있습니다. MySQL에 붙어있는 Adapter는 MySQL에 직접 연결되는 책임을 지고있는 Adapter라고 해석할 수 있으며, 해당 Adapter에서는 MySQL와의 로직 (DAO)를 가지고 있었을 것입니다.
만약에 어느 날에 아래의 요구사항이 날아오면 어떻게 될까요?
🤔 영속화 DB를 MySQL이 아니라 Redis로 바꿔주세요!!
이 상황에서는 Hexagonal Architecture에서는 Port, Adapter를 새로 하나 할당해서 DAO를 사용하고 있는 adapter에서 그저 port를 갈아끼우기만 하면됩니다.
그렇게되면 코드 재사용성은 극도로 늘어나며, 또한 사용하지않는 로직은 그저 Port, Adapter로 보관하고 있다가 다시 필요할 때 port를 갈아끼우는 형식으로 사용만하면 됩니다.
저는 아래의 형태로 Hexagonal Architecture를 도입하게 되었습니다.
물론 아직 더 분리해야할 로직은 존재합니다. 예를 들어 gRPC 로직을 Port로 분리한다거나, Kafka Producer 로직을 adapter에 추가적으로 분리하는게 아직은 구현이 덜 되어있습니다.
그러나 지금까지 해온것을 바탕으로 설명을 드리도록 하겠습니다.
우선 첫번째로 설명드려야하는 것은, Hexagonal Architecture에서 지켜야하는 원칙들입니다.
저도 아직 3번을 덜 지키고 있고, 3번을 지키기 위해서 보수를 하는 중이긴합니다.
사실 이렇게만 보면 기존의 Layered Architecture와 현재의 Hexagonal Architecture가 큰 차이를 보이는 것 같으나, 제가 개선한 것은 사실 크게 몇 개가 안됩니다.
크게 위의 세가지를 꼽을 수 있겠으며, 아래에서 부터는 Hexagonal Architecture로의 개선을 위해 어떤 과정을 거쳤는지 설명을 드리도록 하겠습니다.
우선 분리된 결과부터 보여드리도록 하겠습니다.
이전과는 다르게, 모듈 안에 모듈을 넣는 방식으로 commons, domain, port, adapter, independent 모듈을 쪼갰습니다.
이를 위해서 build.gradle.kts와 settings.gradle.kts를 모두 크게 수정을 하였는데요, 수정된 파일 정보들은 아래와 같습니다.
1️⃣ build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
val coroutineVersion = "1.6.3"
val mockkVersion = "1.12.0"
val kotestVersion = "5.3.2"
val springCloudVersion = "2021.0.2"
plugins {
id("org.springframework.boot")
id("io.spring.dependency-management")
id("org.jetbrains.kotlin.plugin.allopen")
id("org.jetbrains.kotlin.plugin.noarg")
kotlin("jvm")
kotlin("plugin.spring")
id("com.google.protobuf")
}
allprojects {
group = "team.bakkas.yumarket"
version = "1.0.0"
apply(plugin = "kotlin")
dependencies {
// common을 포함한 모든 모듈 대상으로 coroutine 의존을 포함해준다
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion")
}
repositories {
mavenCentral()
}
}
// 공통 Dependency 적용을 제외할 모듈 리스트
val nonDependencyProjects = listOf("commons", "independent", "grpc-interface")
configure(subprojects.filter { it.name !in nonDependencyProjects }) {
apply(plugin = "org.springframework.boot")
apply(plugin = "io.spring.dependency-management")
apply(plugin = "kotlin")
apply(plugin = "kotlin-spring")
apply(plugin = "org.jetbrains.kotlin.plugin.allopen")
apply(plugin = "org.jetbrains.kotlin.plugin.noarg")
dependencies {
// Kotlin Standard Library
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
// Jackson
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("com.fasterxml.jackson.module:jackson-module-afterburner")
// Kotlin Coroutines
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:$coroutineVersion")
// Spring Cloud
implementation("org.springframework.cloud:spring-cloud-starter-config")
// Test Implementation
testImplementation("org.springframework.boot:spring-boot-starter-test")
// mockk
testImplementation("io.mockk:mockk:$mockkVersion")
testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") // for kotest framework
testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") // for kotest core jvm assertions
testImplementation("io.kotest:kotest-property:$kotestVersion") // for kotest property test
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutineVersion")
// Annotation Processing Tool
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion")
}
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "11"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
}
2️⃣ settings.gradle.kts
rootProject.name = "yumarket"
pluginManagement {
val kotlinVersion = "1.5.10"
val springBootVersion = "2.6.6"
val dependencyManagementVersion = "1.0.11.RELEASE"
val protobufVersion = "0.8.15" // for gRPC
plugins {
id("org.springframework.boot") version springBootVersion
id("io.spring.dependency-management") version dependencyManagementVersion
id("org.jetbrains.kotlin.plugin.allopen") version kotlinVersion
id("org.jetbrains.kotlin.plugin.noarg") version kotlinVersion
kotlin("jvm") version kotlinVersion
kotlin("plugin.spring") version kotlinVersion
id("com.google.protobuf") version protobufVersion // gRPC
}
}
// include projects
include(
"adapter",
"adapter:dao",
"adapter:kafka-config",
"adapter:kafka-listener",
"adapter:router-command",
"adapter:router-query",
"adapter:router-common"
)
include(
"commons",
"commons:common"
)
include(
"domain",
"domain:dynamo"
)
include(
"port",
"port:repository",
"port:client-command",
"port:client-query",
"port:service-query",
"port:service-command",
"port:event-interface"
)
include(
"independent",
"independent:grpc-interface"
)
해당 설정을 이용하여 multi-module 기반으로 Hexagonal Architecture를 구현할 수 있게됩니다.
그리고 DAO를 구현하기 위해서 사용한 domain, repository, dao 모듈의 build.gradle.kts도 언급하고 가겠습니다.
1️⃣ domain:dynamo
dependencies {
// Connect dependency among the modules
api(project(":commons:common"))
implementation("software.amazon.awssdk:dynamodb-enhanced:2.17.191")
}
// DynamoDbBean에 대해서 allOpen plugin을 이용해 final을 제거한다
allOpen {
annotation("software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean")
}
// DynamoDbBean 어노테이션이 적용된 entity에 대해서 parameter가 없는 생성자를 만들어준다
noArg {
annotation("software.amazon.awssdk.enhanced.dynamodb.mapper.annotations.DynamoDbBean")
}
2️⃣ port:repository
dependencies {
// Connect the dependencies among the modules of this project
api(project(":domain:dynamo"))
}
3️⃣ adapter:dao
import org.jetbrains.kotlin.builtins.StandardNames.FqNames.annotation
dependencies {
// Connect dependencies among the modules of this project
api(project(":domain:dynamo"))
api(project(":port:repository"))
implementation("org.springframework.boot:spring-boot-starter-data-redis")
implementation("software.amazon.awssdk:dynamodb-enhanced:2.17.191")
}
위의 build.gradle.kts 설정을 자세히 보시게되면, 의존성이 절대로 안에서 바깥이 아닌, 항상 바깥에서 안쪽으로 흐르는 것을 확인할 수 있습니다.
이러한 방식으로 다른 모듈에도 유사하게 적용하시면 Hexagonal Architecture를 구성하실수 있게됩니다.
다음에는 아마 위드마켓 가게노출 시스템에 gRPC를 구현하는 과정에 대해서 다루게 될 것 같습니다.
아직도 위드마켓 가게노출 시스템은 최대한 clean한 Hexagonal Architecture 구현을 위해서 유지보수중에 있으며, 그 과정에서 추가적으로 포스팅할 내용이 있다면 추가적으로 포스팅해드리겠습니다.
긴 글 읽어주셔서 감사합니다 ⭐️
크.. 많이 배우고 갑니다 ㄷㄷ