반응형

헥사고날 아키텍처란?

  • 클린 아키텍처를 일반화한 구조 중 하나로 포트와 어댑터 아키텍처라고도 부른다.
  • 비즈니스 로직이 외부 요소에 의존하지 않고 도메인 계층에 의존하도록 만드는 아키텍처이다.
  • 모든 의존성은 코어(육각형 내부)를 향해 개발한다.
    • 도메인 ← 애플리케이션 ← 어댑터
    • 도메인은 애플리케이션, 어댑터가 의존할 수 있고, 애플리케이션은 어댑터가 의존할 수 있다. 그 반대 방향의 의존은 불가능하다.
  • 이렇게 구성함으로써 외부 모듈이나 기술의 변경에 유연하고 테스트가 용이하여 유지보수하기 좋아진다.
  • 반면 헥사고날 아키텍처로 구성하기 위해 코드의 양이 많아지고 복잡해질 수 있다.
  • 포트를 외부에 노출하고(public), 구현체는 노출하지 않는다(private).

헥사고날 구성 요소

  • 도메인
  • 애플리케이션(Port, UseCase, Service)
    • Inbound Port
      • 내부 영역 사용을 위해 노출된 인터페이스.
      • 해당 포트를 UseCase라고 부르고, Service가 구현한다.
      • 조회용 UseCase를 Query로 표현하기도 한다.
    • Outbound Port
      • 외부 영역을 사용하기 위한 인터페이스.
      • 해당 포트를 Adapter가 구현한다.
      • UseCase에서 해당 포트를 주입 받아 외부 영역을 사용한다.
  • 어댑터
    • Inbound Adapter
      • Inbound Port(UseCase)를 사용하는 주체이다.
      • 대표적으로 웹 어댑터가 있다.
    • Outbound Adapter
      • Outbound Port의 구현체.
      • 실제 라이브러리나 기술에 의존적인 코드로 데이터를 가져오거나 전달하는 역할을 주로 담당한다.

멀티모듈로 구성하는 스프링 헥사고날 프로젝트 예제

프로젝트 루트

build.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "3.1.5" apply false
    id("io.spring.dependency-management") version "1.1.3" apply false
    kotlin("jvm") version "1.8.22"
    kotlin("plugin.spring") version "1.8.22" apply false
}

group = "com.bank"
version = "0.0.1-SNAPSHOT"

java {
    sourceCompatibility = JavaVersion.VERSION_17
}

allprojects {
    repositories {
        mavenCentral()
    }

    tasks.withType<KotlinCompile> {
        kotlinOptions {
            freeCompilerArgs += "-Xjsr305=strict"
            jvmTarget = "17"
        }
    }

    tasks.withType<Test> {
        useJUnitPlatform()
    }
}

subprojects {
    apply(plugin = "org.springframework.boot")
    apply(plugin = "io.spring.dependency-management")
    apply(plugin = "org.jetbrains.kotlin.plugin.spring")
    apply(plugin = "kotlin")
    apply(plugin = "kotlin-kapt")

    dependencies {
        implementation("org.springframework.boot:spring-boot-starter-web")
        implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
        implementation("org.jetbrains.kotlin:kotlin-reflect")
        testImplementation("org.springframework.boot:spring-boot-starter-test")
    }
}

settings.gradle.kts

rootProject.name = "bank"
include("application")
include("adapter:web")
include("adapter:persistence")
include("configuration")

application 모듈

build.gradle.kts

dependencies {
    implementation("org.springframework:spring-tx:6.1.0")
}

Account

package com.bank.application.domain

data class Account(
    val accountId: Long,
    var amount: Long,
) {
    fun withdrawal(amount: Long): Long {
        if (this.amount - amount < 0) {
            throw IllegalStateException("withdrawal failed.")
        }
        this.amount -= amount
        return this.amount
    }

    fun deposit(amount: Long) {
        this.amount += amount
    }
}

LoadAccountPort

package com.bank.application.port.outbound

import com.bank.application.domain.Account

interface LoadAccountPort {
    fun loadAccount(accountId: Long): Account?
}

UpdateAccountPort

package com.bank.application.port.outbound

import com.bank.application.domain.Account

interface UpdateAccountPort {
    fun updateAccount(account: Account)
}

SendMoneyUseCase

package com.bank.application.port.inbound

interface SendMoneyUseCase {
    fun sendMoney(command: Command): Result

    data class Command(
        val fromAccountId: Long,
        val toAccountId: Long,
        val amount: Long,
    )

    data class Result(
        val isSuccess: Boolean,
    )
}

SendMoneyService

package com.bank.application.service

import com.bank.application.port.inbound.SendMoneyUseCase
import com.bank.application.port.outbound.LoadAccountPort
import com.bank.application.port.outbound.UpdateAccountPort
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
internal class SendMoneyService(
    private val loadAccountPort: LoadAccountPort,
    private val updateAccountPort: UpdateAccountPort,
) : SendMoneyUseCase {
    @Transactional
    override fun sendMoney(command: SendMoneyUseCase.Command): SendMoneyUseCase.Result {
        val fromAccount = loadAccountPort.loadAccount(command.fromAccountId) ?: throw IllegalStateException("not exist account")
        val toAccount = loadAccountPort.loadAccount(command.toAccountId) ?: throw IllegalStateException("not exist account")

        toAccount.deposit(fromAccount.withdrawal(command.amount))

        updateAccountPort.updateAccount(fromAccount)
        updateAccountPort.updateAccount(toAccount)

        return SendMoneyUseCase.Result(true)
    }
}

adapter/web 모듈

build.gradle.kts

dependencies {
    implementation(project(":application")) // adapter는 application(도메인, 포트가 존재하는)을 의존
}

AccountController

package com.bank.adapter.web

import com.bank.application.port.inbound.SendMoneyUseCase
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController

@RestController
class AccountController(
    private val sendMoneyUseCase: SendMoneyUseCase,
) {
    @PostMapping("/api/v1/account/send_money")
    fun sendMoney(@RequestBody request: Request): Response {
        return Response.of(sendMoneyUseCase.sendMoney(request.toCommand()))
    }

    data class Request(
        val fromAccountId: Long,
        val toAccountId: Long,
        val amount: Long,
    ) {
        fun toCommand() = SendMoneyUseCase.Command(
            fromAccountId = fromAccountId,
            toAccountId = toAccountId,
            amount = amount,
        )
    }

    data class Response(
        val isSuccess: Boolean,
    ) {
        companion object {
            fun of(result: SendMoneyUseCase.Result) = Response(
                isSuccess = result.isSuccess,
            )
        }
    }
}

adapter/persistence 모듈

build.gradle.kts

plugins {
    kotlin("plugin.jpa") version "1.8.22"
}

dependencies {
    implementation(project(":application")) // adapter는 application(도메인, 포트가 존재하는)을 의존
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("com.h2database:h2")
    runtimeOnly("com.mysql:mysql-connector-j")
}

resources/db.yml

spring:
  jpa:
    hibernate:
      naming: # 변수명을 그대로 칼럼명으로 지정 가능하도록 설정(변수명이 카멜케이스이면 필드명도 카멜케이스)
        implicit-strategy: org.hibernate.boot.model.naming.ImplicitNamingStrategyLegacyJpaImpl
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
    properties:
      hibernate:
        show_sql: true            # 하이버네이트가 실행하는 모든 SQL문을 콘솔로 출력해 준다.
        format_sql: true          # 콘솔에 출력되는 JPA 실행 쿼리를 가독성있게 표현한다.
        use_sql_comments: false   # 디버깅이 용이하도록 SQL문 이외에 추가적인 정보를 출력해 준다.
        dialect: org.hibernate.dialect.MySQL8Dialect # MySQL 문법에 맞는 쿼리로 실행.

logging:
  level:
    org.hibernate.type.descriptor.sql: trace # SQL문에 바인딩된 파라미터값을 로그로 출력하기 위한 설정.

resources/db-local.yml

spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=MySQL;
    driver-class-name: org.h2.Driver
    platform: h2
    username: sa
    password:

  h2:
    console:
      path: /h2-console
      enabled: true

  jpa:
    hibernate:
      ddl-auto: create

DatabaseConfig

package com.bank.adapter.persistence.config

import org.springframework.beans.factory.config.YamlPropertiesFactoryBean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.PropertySource
import org.springframework.core.env.PropertiesPropertySource
import org.springframework.core.io.support.EncodedResource
import org.springframework.core.io.support.PropertySourceFactory

@PropertySource(
    value = [
        "classpath:db.yml",
        "classpath:db-\${spring.profiles.active}.yml",
    ],
    factory = DatabaseConfig.YamlPropertySourceFactory::class,
)
@Configuration
class DatabaseConfig {
    class YamlPropertySourceFactory : PropertySourceFactory {
        override fun createPropertySource(name: String?, resource: EncodedResource): org.springframework.core.env.PropertySource<*> {
            val factory = YamlPropertiesFactoryBean()
            factory.setResources(resource.resource)

            val filename = resource.resource.filename ?: throw IllegalStateException("filename not exists")
            val properties = factory.getObject() ?: throw IllegalStateException("properties not exists")

            return PropertiesPropertySource(filename, properties)
        }
    }
}

Account

package com.bank.adapter.persistence.entity

import com.bank.application.domain.Account
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import java.time.LocalDateTime

@Entity
data class Account(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val accountId: Long = 0L,
    var amount: Long,
    val registerDate: LocalDateTime = LocalDateTime.now(),
) {
    fun toDomain() = Account(
        accountId = accountId,
        amount = amount,
    )
}

AccountRepository

package com.bank.adapter.persistence.entity

import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface AccountRepository : JpaRepository<Account, Long>

AccountPersistenceAdapter

package com.bank.adapter.persistence

import com.bank.adapter.persistence.entity.AccountRepository
import com.bank.application.domain.Account
import com.bank.application.port.outbound.LoadAccountPort
import com.bank.application.port.outbound.UpdateAccountPort
import jakarta.annotation.PostConstruct
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component

@Component
internal class AccountPersistenceAdapter(
    private val accountRepository: AccountRepository,
) : LoadAccountPort, UpdateAccountPort {
    @PostConstruct
    fun init() {
        accountRepository.save(com.bank.adapter.persistence.entity.Account(amount = 1000))
        accountRepository.save(com.bank.adapter.persistence.entity.Account(amount = 2000))
    }

    override fun loadAccount(accountId: Long): Account? {
        return accountRepository.findByIdOrNull(accountId)?.toDomain()
    }

    override fun updateAccount(account: Account) {
        val accountEntity = accountRepository.findByIdOrNull(account.accountId) ?: throw IllegalStateException("account not found")
        accountEntity.amount = account.amount
    }
}

configuration

build.gradle.kts

dependencies {
    // configuration은 adapter를 의존
    implementation(project(":adapter:web"))
    implementation(project(":adapter:persistence"))
}

ConfigurationApplication

package com.bank

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class ConfigurationApplication

fun main(args: Array<String>) {
    runApplication<ConfigurationApplication>(*args)
}

실행

java -jar -Dspring.profiles.active=local configuration.jar

테스트

### 이체
POST http://localhost:8080/api/v1/account/send_money
Content-Type: application/json

{
  "fromAccountId": 1,
  "toAccountId": 2,
  "amount": 1000
}
반응형

'Development > Spring' 카테고리의 다른 글

[Spring] HTTP Interface  (0) 2024.03.19
[Spring] Elasticsearch  (0) 2024.01.01
[Spring] WireMock  (0) 2023.08.18
[Spring] SpEL  (0) 2023.08.09
[Spring] actuator  (0) 2021.11.27

+ Recent posts