Kotlin / Spring Boot로 간단한 example 주문서버 구현해보기- 1) Layered Architecture with Hexagonal

Mango
13 min readFeb 4, 2024

--

최근들어 JVM 계열 언어와 스프링 그리고 아키텍처에 대해 다시 공부 하면서 뭐라도 만들어봐야겠다 싶어, 최근 공부한것들을 적용해보거나 써보고 싶었던 기술들을 사용해보기 위해 간단하지만 막상 간단하지 않았던 example 서버를 만들었던 과정들을 공유하려 합니다.

우선 첫번째, Layered / Hexagonal Architecture 개념들을 고려하게된 이유와 어떤 자료들을 참고했고 최종적으로 어떻게 적용했는지 해당 아티클에서 공유해보겠습니다 :)

example 프로젝트 Github 링크

Layered Architecture

글을 읽기 전, Layered Architecture에 대해 모르신다면 해당 글을 추천 드립니다.

Image created by the author

Gradle Multi-Module을 프로젝트를 api / domain / infrastructure / boot 서브 모듈로 나누어 레이어를 구성하였습니다. 덕분에 각 레이어들은 필요한 의존성 라이브러리 관리를 독립적으로 할 수 있게 되었으며 필요에 따라 다른 모듈을 참조하고 분리시킬 수 있는 환경이 물리적으로 구성되었습니다.

Layered Architecture의 레이어 개념들을 해당 프로젝트의 각 레이어(서브모듈)에 아래와 같이 매핑하여 적용하였습니다.

좌측이 해당 프로젝트의 레이어 이름 / 우측이 Layered Architecture의 레이어 개념 입니다.

  • api — Presentation Layer
    - client의 요청을 받고 응답값을 반환하는 통신 역할을 하는 레이어이며, 우리가 일반적으로 생각하는 요청 / 응답, 권한 검증, 벨리데이션 동작을 수행합니다.
  • domain — Application / Domain
    - Application 레이어 역할을 담당하는 service 에서는 Domain — Infrastructure 레이어의 전체 동작 흐름을 오케스트레이션 합니다.
    - Domain 레이어 역할을 담당하는 model 객체는 해당 도메인 모델이 해야하는 비즈니스 로직을 담당합니다.
  • infrastructure — Infrastructure
    - DB Access, 외부 API 통신 등 I/O 처리를 담당합니다. DB Access 의 경우 흔히 우리가 아는 repository 객체가 해당됩니다.
  • boot — Infrastructure(config)
    - DB Config, Spring Configuration 등등 전반적인 프로젝트 설정을 담당하는 레이어이며, 이와 같은 특성 때문에 Spring Boot 프로젝트의 실행 Application 또한 해당 레이어에 포함되어 있습니다.

Hexagonal Architecture

글을 읽기 전, Hexagonal Architecture에 대해 모르신다면 해당 글을 추천 드립니다.

Hexagonal Architecture 관점에서 Adapter(outside)Application(inside) 으로 나뉩니다. Adapter는 User / Data Side API 등 외부 통신을 담당하는 영역, Application은 비즈니스 요구사항 구현을 담당하는 영역이며, 서로 포트(Port)라는 개념을 통해 참조할 수 있습니다.

Image created by the author

Adapter / Application에 해당하는 example 프로젝트의 레이어는 아래와 같습니다.

좌측이 해당 프로젝트의 레이어 이름 / 우측이 Layered Architecture의 레이어 개념 입니다.

  • api / infrastructure — Adapter
  • domain — Application

example 프로젝트를 예로 들면, api 레이어와 infrastructure 레이어는 domain 레이어에서 정의된 인터페이스를 참조하며, 이 인터페이스가 위에서 설명한 포트 역할을 합니다.

Spring Bean에 등록된 각 인터페이스에 해당하는 내부 구현체(클래스)를 의존성 주입(DI) 받습니다.

좀 더 명확하게 아래 example 프로젝트 코드와 함께 살펴보면

api 레이어에 있는 OrderGrpcService는 OrderService 인터페이스 명세만 참조하고 있으며 스프링 부트 실행 시 DomainOrderService를 주입 받지만 OrderGrpcService 에서는 구현체(DomainOrderService) 정보는 알지 못합니다.

api 레이어의 gRPC 서비스

@GrpcService
class OrderGrpcService(private val service: OrderService) : OrderServiceGrpcKt.OrderServiceCoroutineImplBase() {
override suspend fun createOrder(request: OrderItemRequest): CreateOrderResponse {
val orderItem = OrderItem(BigDecimal(request.price))
val uuidStr = service.createOrder(orderItem).toString()

return CreateOrderResponse.newBuilder().apply {
id = uuidStr
}.build()
}

override suspend fun addOrderItem(request: AddOrderItemRequest): Empty {
service.addOrderItem(
UUID.fromString(request.id),
OrderItem(BigDecimal(request.orderItem.price)),
)

return Empty.newBuilder().build()
}

override suspend fun completeOrder(request: CompleteOrderRequest): Empty {
service.completeOrder(UUID.fromString(request.id))

return Empty.newBuilder().build()
}

override suspend fun deleteOrderItem(request: DeleteOrderItemRequest): Empty {
service.deleteOrderItem(UUID.fromString(request.id), UUID.fromString(request.orderItemId))

return Empty.newBuilder().build()
}
}

domain 레이어의 OrderService interface와 구현체 클래스

interface OrderService {
suspend fun createOrder(orderItem: OrderItem): UUID

suspend fun addOrderItem(
id: UUID,
orderItem: OrderItem,
)

suspend fun completeOrder(id: UUID)

suspend fun deleteOrderItem(
id: UUID,
orderItemId: UUID,
)
}
@Service
class DomainOrderService(
private val repository: OrderRepository,
transactionManager: ReactiveTransactionManager,
) : OrderService {
private val transactionalOperator = TransactionalOperator.create(transactionManager)

override suspend fun createOrder(orderItem: OrderItem): UUID {
val order = Order()
order.addOrder(orderItem)
repository.save(order)

return order.id
}

override suspend fun addOrderItem(
id: UUID,
orderItem: OrderItem,
) {
transactionalOperator.executeAndAwait {
val order = getOrder(id)
order.addOrder(orderItem)

repository.save(order)
}
}

override suspend fun completeOrder(id: UUID) {
val order = getOrder(id)
order.complete()

repository.save(order)
}

override suspend fun deleteOrderItem(
id: UUID,
orderItemId: UUID,
) {
transactionalOperator.executeAndAwait {
val order = getOrder(id)
order.removeOrderItem(orderItemId)

repository.save(order)
}
}

private suspend fun getOrder(id: UUID): Order {
return repository.findById(id) ?: throw NotFoundException("Can not found order by id($id)")
}
}

또한, domain 레이어에서 infrastructure 레이어를 참조하지는 않지만 DB Access, 외부 API 연동이 가능한 이유는 domain 레이어 내에 Repository 인터페이스를 정의하고 infrastructure 레이어 내부 구현체가 이를 상속받습니다.

이러한 구조로 개발이 진행되기 때문에 domain 레이어(Application)의 로직이 외부 api, infrastructure(Adapter)에 섞이지 않으며 독립적으로 존재하게 되고 Application 에서는 Adapter와 독립적으로 비즈니스 로직을 구현할 수 있습니다.

이게 가능한 이유는 상단 프로젝트 구조를 보면, infrastructure 레이어에서는 domain을 참조하고 있고 모든 레이어를 참조하고 있는 boot 모듈에서 서버를 실행하고 의존성을 주입하기 때문입니다.

domain 레이어의 OrderRepository 인터페이스

interface OrderRepository {
suspend fun findById(id: UUID): Order?

suspend fun save(order: Order)
}

infrastructure 레이어의 OrderRepository 구현 클래스

@Service
class MySQLOrderRepository(
private val repository: R2DBOrderRepository,
private val itemRepository: R2DBOrderItemRepository,
) : OrderRepository {
override suspend fun findById(id: UUID): Order? {
val entity = repository.findByUUID(id.toString()) ?: throw NotFoundException("It is not existed order by uuid($id)")
val orderItems = itemRepository.findByOrderId(entity.id!!)
val order = Order(UUID.fromString(entity.uuid))
order.price = entity.price
order.orderItems =
orderItems.map {
OrderItem(
it.price,
UUID.fromString(it.uuid),
it.createdAt!!,
it.updatedAt!!,
)
}.toMutableList()
return order
}

override suspend fun save(order: Order) {
repository.upsert(order.id.toString(), order.price, order.status)

val entity = repository.findByUUID(order.id.toString()) ?: throw NotFoundException("It is not existed order by uuid($order.id)")

itemRepository.deleteByNotInUUIDs(order.orderItems.map { it.id.toString() })

order.orderItems.forEach {
itemRepository.upsert(
it.id.toString(),
entity.id!!,
it.price,
)
}
}
}

Conclusions

Layered Architecture와 Hexagonal Architecture의 개념을 적용하여 프로젝트 구조를 설계하고 구현하며 이러한 아키텍처가 추구하는 목적들을 좀 더 구체적으로 이해할 수 있었고 개인적으로는 아키텍처가 추구하는 목적들을 구현하기 위해 필요한 것들이 개발을 번거롭게 한다 느끼는 부분들도 있었고 과하다 생각하는 부분들도 분명히 있었습니다.

그래도 코드의 목적이 서로 섞이지 않고 각자의 영역안에 존재하게 함으로써 코드의 퀄리티를 보장하고 클라이언트 / 서버간 통신 방식이나 DB Access 방식의 변화에 유연하다는것은 정말 큰 장점이기 때문에 위와 같은 불편함들은 충분히 감수할만 하다는 판단이 들었습니다.

다음 아티클에서는 Domain 레이어와 Infrastructure 레이어의 로직들을 검증하기 위해 각각 어떤 테스트를 진행했는지 공유 해보겠습니다 :)

References

--

--

Mango
Mango

Written by Mango

Wanna be a good programmer

No responses yet