반응형
헥사고날 아키텍처란?
- 클린 아키텍처를 일반화한 구조 중 하나로 포트와 어댑터 아키텍처라고도 부른다.
- 비즈니스 로직이 외부 요소에 의존하지 않고 도메인 계층에 의존하도록 만드는 아키텍처이다.
- 모든 의존성은 코어(육각형 내부)를 향해 개발한다.
- 도메인 ← 애플리케이션 ← 어댑터
- 도메인은 애플리케이션, 어댑터가 의존할 수 있고, 애플리케이션은 어댑터가 의존할 수 있다. 그 반대 방향의 의존은 불가능하다.
- 이렇게 구성함으로써 외부 모듈이나 기술의 변경에 유연하고 테스트가 용이하여 유지보수하기 좋아진다.
- 반면 헥사고날 아키텍처로 구성하기 위해 코드의 양이 많아지고 복잡해질 수 있다.
- 포트를 외부에 노출하고(public), 구현체는 노출하지 않는다(private).
헥사고날 구성 요소
- 도메인
- 애플리케이션(Port, UseCase, Service)
- Inbound Port
- 내부 영역 사용을 위해 노출된 인터페이스.
- 해당 포트를 UseCase라고 부르고, Service가 구현한다.
- 조회용 UseCase를 Query로 표현하기도 한다.
- Outbound Port
- 외부 영역을 사용하기 위한 인터페이스.
- 해당 포트를 Adapter가 구현한다.
- UseCase에서 해당 포트를 주입 받아 외부 영역을 사용한다.
- Inbound Port
- 어댑터
- Inbound Adapter
- Inbound Port(UseCase)를 사용하는 주체이다.
- 대표적으로 웹 어댑터가 있다.
- Outbound Adapter
- Outbound Port의 구현체.
- 실제 라이브러리나 기술에 의존적인 코드로 데이터를 가져오거나 전달하는 역할을 주로 담당한다.
- Inbound Adapter
멀티모듈로 구성하는 스프링 헥사고날 프로젝트 예제
프로젝트 루트
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(Deprecated) (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 |