[위드마켓 개발기] Hexagonal Architecture로의 마이그레이션 여정

Doccimann·2022년 9월 14일
2

위드마켓 개발기

목록 보기
10/10
post-thumbnail

🤔 왜 Hexagonal Architecture를 고려하게 되었어요?

우선 기존의 설계된 아키텍처를 먼저 보겠습니다.

해당 아키텍처는 얼핏 보면 아주 깔끔하게 정리된 구조처럼 보입니다. 그러나 이 아키텍처도 치명적인 단점을 몇가지 가지고 있습니다.

  1. 외부와의 연결을 담당하는 모듈은 단 하나, Infrastructure Layer만이 담당하고 있다. 따라서 연결요소가 확장이 된다고 한다면 모두 Infrastructure가 책임져야하는 불합리한 구조를 가지고있다.
  2. 외부와의 연결 요소가 변경되어야할 때가 있다. 예를 들어서, 현재에는 Kafka를 사용하여 Event를 전파하고 있으나, 이거를 SQS를 이용해서 전파하도록 요구사항이 변경될 수 있다. 이 때는 Infrastructure의 코드를 건드려서 수정을 해야한다.

따라서 위의 계층화 아키텍처는 확장에 매우 불리한 구조를 가지고 있기 때문에 개선점이 필요합니다. 저는 이를 개선하기 위해서 포트앤어댑터(Port and Adapter) 아키텍처라고 흔히 불리는 Hexagonal Architecture를 고려하게 되었습니다.


🤔 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 적용 사례

저는 아래의 형태로 Hexagonal Architecture를 도입하게 되었습니다.

물론 아직 더 분리해야할 로직은 존재합니다. 예를 들어 gRPC 로직을 Port로 분리한다거나, Kafka Producer 로직을 adapter에 추가적으로 분리하는게 아직은 구현이 덜 되어있습니다.

그러나 지금까지 해온것을 바탕으로 설명을 드리도록 하겠습니다.

우선 첫번째로 설명드려야하는 것은, Hexagonal Architecture에서 지켜야하는 원칙들입니다.

  1. 무조건 의존성은 외부에서 내부로 흘러야한다. 반대방향은 허용하지 않는다.
  2. 두 단계 하향은 금지한다. 하지만, DAO에 대해서는 예외로 한다.
  3. Adapter는 웬만하면 Port에 의존하도록한다.

저도 아직 3번을 덜 지키고 있고, 3번을 지키기 위해서 보수를 하는 중이긴합니다.

사실 이렇게만 보면 기존의 Layered Architecture와 현재의 Hexagonal Architecture가 큰 차이를 보이는 것 같으나, 제가 개선한 것은 사실 크게 몇 개가 안됩니다.

  1. 기존에 Infrastructure 계층을 여러개로 찢었다. 이에 대응하는게 Kafka-listener, DAO 모듈이다.
  2. Domain Module은 어떠한 의존도 가지지않아야한다는 원칙에 의해서 Validator를 모두 port로 이동시켰다.
  3. 기존에 Domain + Business에 있던 validator 실구현체를 adapter로 이동시켰다. validator 구현에 있어 gRPC 로직이 필요할 것으로 예상되기 때문이다.

크게 위의 세가지를 꼽을 수 있겠으며, 아래에서 부터는 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 구현을 위해서 유지보수중에 있으며, 그 과정에서 추가적으로 포스팅할 내용이 있다면 추가적으로 포스팅해드리겠습니다.

긴 글 읽어주셔서 감사합니다 ⭐️


🌲 References

지속 가능한 소프트웨어 설계 패턴: 포트와 어댑터 아키텍처 적용하기 - Line corps

위드마켓 가게노출 시스템 - BrianDYKim(본인임 ㅎ)

profile
Hi There 🤗! I'm college student majoring Mathematics, and double majoring CSE. I'm just enjoying studying about good architectures of back-end system(applications) and how to operate the servers efficiently! 🔥

2개의 댓글

comment-user-thumbnail
2022년 9월 14일

크.. 많이 배우고 갑니다 ㄷㄷ

1개의 답글