플레이 스쿼드 헥사고날 아키텍처 적용기

Fitpet Developer
37 min readDec 15, 2022

시작하며

안녕하세요. 핏펫 플레이 스쿼드에서 백엔드 개발을 하고 있는 kai입니다.

핏펫 고객에게 플레이 탭 서비스를 통해 재미와 혜택(펫과 플레이를 통해 포인트 적립) 그리고 유용함를 느낄 수 있는 서비스를 제공하고 있습니다.

플레이 탭 서비스
플레이탭 서비스

기존에는 MVP 버전인 파이썬 코드로 개발된 프로젝트(계층형 아키텍처)를 저희 팀 스쿼드 주력 언어인 코틀린+스프링으로 전환하면서 핵사고날 아키텍처를 적용한 케이스에 대해서 공유하고자 합니다.

먼저 계층형 아키텍처에 단점 및 클린아키텍처/핵사고날(in-out port)아키텍처에 대해서 간단히 살펴 보겠습니다.

계층형 아키텍처 단점

계층형 아키텍처는 도메인 로직이 아닌 데이터 베이스 주도 설계를 유도하게됩니다.

위 그림처럼 웹 계층은 도메인 계층에 의존하고, 도메인 계층은 영속성 계층에 의존하기 때문에 자연스레 데이터베이스에 의존하게 됩니다. 그러면 영속성 계층 토대로 비지니스 도메인 관점이 아닌 데이터베이스를 먼저 생각하고 그 개념으로 서비스를 만들어 가게 됩니다.

우리가 보통 서비스를 만들때 예를 들어 최근 플레이 탭 개편 관련 비즈니스를 만든다고 해봅시다. 고객이 자신의 펫에 대해서 건강관리하는 로직, 펫과의 산책을 해서 포인트를 얻거나 하는 로직에서 필요한 여러 도메인 자체가 고객 행동 중심으로 모델링이 이루어 져야 합니다.

하지만 우리가 먼저 데이터베이스를 토대로 아키텍처를 만들고 있있다면? 백엔드 개발자들이 보통 특정 서비스를 만들때 보통 데이터베이스 구조를 먼저 생각하고(jpa 엔티티부터 설계를 진행) 그 뒤에 도메인 로직을 구현하는 순서로 개발이 진행될 것입니다.

도메인 계층에서 데이터베이스 엔티티(JAP @Entity)를 사용하는 것은 영속성 계층과 강한 결합 유발한다

위 그림 처럼 강합 결합이 생기면 서비스는 영속성 모델을 비지니스 모델 처럼 사용하게 되고 도메인계층에서 영속성과 관련된 트랜잭션 처리 , 영속성 캐쉬 기능, JPA에 기능인 지연로딩(LAZY)과 같이 비지니스 모델과 상관없는 코드들이 난잡하게 혼재 되어 점점 비대해지는 문제가 발생합니다.

지름길을 택하기 쉬워집니다.

계층형 아키텍처에 유일한 규칙은 특정 계층에서 같은 계층에 컴포넌트나 아래에 있는 계층에만 접근 가능하게 하는 규칙이 있습니다. 하지만 개발을 하다보면 상위 계층에 위치한 컴포넌트에 접근해야 할 경우 간단하게 컴포넌트를 계층 아래로 내려서 사용하면 손쉽게 문제가 해결됩니다.

하지만 s/w는 초기 코딩을 하는 것보다 유지보수 비용이 훨씬 많이 드는 일이라고 생각합니다(일반 건축은 초기 비용 많이 발생합니다.)

일정에 바빠서 한번 내리게 되고 그 뒤에 새로운 개발자들이 어? 여기 팀에서는 그냥 이렇게 하네? 하고 또 다른 개발자가 안좋은 방향으로 개발을 하게 되고 이러면서 코드는 점차 망가지면서 유지보수가 힘들어 지게 됩니다.

이런 이론을 심리학에서는 깨진 창문이론이라고 합니다.

깨진 창문이 있으면 사람들은 심리적으로 돌은 던져서 더 깨고 싶은 심리가 있다.
A라는 개발자가 영속성 계층에 도메인로직을 추가하고 그뒤 B라는 개발자가 일관성있게 영속성계층에서 개발하게 되므로 영속성 계층이 비대해지는 결과를 유발한다.

테스트 코드를 작성하기가 어렵습니다.

보통 엔티티 하나만 수정 하는게 굳이 도메인 계층을 구현할 필요가 있을까? 라는 자기 스스로에게 질문을 던지고 개발자들이 아래 그림처럼 구현하게 되면 2가지의 문제점이 발생합니다.

  • 유스케이스(특정 도메인에 대한 요구사항)가 확장하게 되면 앞으로 많은 도메인 로직이 웹 계층에 추가해서 책임의 분산이 발생 하게 됩니다.
  • 웹 계층에 영속성 계층 및 여러 외부 코드가 존재 하게 됨으로써 테스트 시 테스트코드를 작성하는 것보다 코드에 의존성을 이해하고 목(mock)을 만드는데 더 많은 시간이 걸리게 됩니다. 저는 개발하는 동안 레거시 코드에 테스트 코드가 존재 하지 않아 추가 하기 위해 많은 노력을 했었습니다. 하지만 작성하는게 어려움이 생겨 많이 포기 하는 경우가 태반이었습니다. 테스트 코드를 쉽게 작성하기 위해서는 최초 설계가 잘되어 있어야 합니다.

유스케이스를 감추게 됩니다.

핏펫에 입사한 kai는 신규 일감이 생성되어 새로운 기능을 구현해야 합니다.

기존 레거시 프로젝트를 분석하여 이 코드를 어디에 넣어야 할지 고민하게 되고 기존 로직을 보면서 코드를 일관성있게 추가하려고 시도하게 됩니다.

이렇듯 개발자는 신규 기능구현을 위해 서비스 코드를 어디에 구현할 것인지에 대한 고민을 많이 하게 됩니다. 하지만 계층형 아키텍처에서는 도메인 로직이 여러 계층에 걸쳐 흩어지기 쉽고 유스케이스가 간단하게 도메인 계층을 생략하는 케이스도 있을 것입니다. 또한 도메인 계층과 영속성 계층 모두에서 접근할 수 있도록 특정 컴포넌트를 아래로 내렸다면 영속성 계층에 존재할 수도 있습니다. 이러한 정해지지 않은 코드 패턴을 보면서 신규 입사자는 어떻게 코드를 구현해야 할지 어디에 이 코드를 위치해야 할지 어려운 상태가 됩니다.

여긴 어딘가 나는누구인가

그리고 계층형 아키텍처에 또 하나의 심각한 문제점은 한 서비스에 너비를 강제 하지 않기 때문에 시간이 지나면서 갓서비스(god-신 처럼 모든 기능이 가능해지는 비대한 서비스) 가 만들어 집니다.

과거에 신규 프로젝트에 투입이 되었는데 코드 중에 한 메소드 또는 클래스가 20가지의 역할 책임을 가지는 코드를 본 적 있었습니다. 그 코드는 도저히 리팩토링이 불가능하고 신규 기능 추가 시 사이드 이펙트가 발생하여 유지보수가 불가능 했습니다.

비대해진 서비스는 코드 상에서 특정 유스케이스를 찾는 것을 어렵게 만든다

동시 작업이 어렵습니다.

앞에서 말한 단점들을 봐도 여러 개발자들이 동시에 작업하는게 어렵습니다.

“지연되는 소프트웨어 프로젝트에 인력을 더하는 것은 개발을 늦출 뿐이다”

— 프레더릭 P.브룩스

프로젝트 시 개발자의 수와 생산성은 비례 되지 않습니다. 하지만 적절한 규모의 프로젝트에서는 추가 인원 투입 시 더 나은 생산성을 기대할 수 도 있습니다. 그러나 계층형 아키텍처는 이런 측면에서는 그다지 도움이 되지 않고 위처럼 계층 상관없이 코드가 난잡하거나 갓 서비스로 인해 너무 한 파일에 대한 의존성이 커집니다. 데이터 베이스 주도 설계는 영속성 로직이 도메인 로직과 너무 뒤섞여서 각 측면을 각각 여러 개발자들이 개별적으로 작업하기가 매우 힘들어 지게 됩니다.

한 서비스 파일(클래스파일)을 여러 3~4명 개발자들이 수정 하다 보면 병합 충돌(merge conflict)이 발생되고 이를 해결 하기 위해 잠재적으로 이전 코드로 돌리고 서로 리뷰하는 등 더 많은 리소스가 필요하게 됩니다. 계층형 아키텍처에서는 코드를 수정하게 되면 동일 파일을 더 수정하는 비효율적인 상황들이 자주 발생 됩니다.

클린 아키텍처

개발자들 대부분 클린 코드/클린 아키텍처라는 용어를 알 것입니다. 클린 코드 저자인 로버트 C.마틴은 클린 아키텍처에서는 설계가 비지니스 규칙의 테스트를 용이하게 하고, 비즈니스 규칙은 프레임워크, 데이터베이스, UI기술, 그 밖의 외부 애플리케이션이나 인터페이스로부터 독립적일 수 있다고 말하고 있습니다.

이는 도메인 코드가 바깥으로 향하는 어떤 의존성도 없어야 함을 의미하고 대신 의존성 역전 원칙의 도움으로 모든 의존성이 도메인 코드를 향하게 만드는것이 핵심입니다.

https://engineering-skcc.github.io/microservice inner achitecture/inner-architecture-2/ 참조 클린 아키텍처에서 모든 의존성은 도메인 로직을 향해 안쪽 방향으로만 향한다. 출저 : <<클린 아키텍처>>(인사이트,2019)

클린 아키텍처 구조는 비지니스 규칙에 집중 할 수 있는 구조로 되어 있기 때문에 우리가 흔히 DDD(도메인주도설계) 를 가장 순수한 형태로 적용해 볼 수 있습니다. 클린 아키텍처 구조는 그림만 봤을때 일반 계층형 아키텍처 같이 보일 수 있고 저는 저 그림과 책에 있는 내용으로 어떻게 구현해야 할지 막막 하다고 느꼈습니다.

스프링 진영에서 보통 ORM JPA를 많이 사용하는데 JPA의 영속성 계층의 책임에 대한 로직(예를 들어 엔티티 JPA 맵핑 작업이나 연관관계 작업, 데이터베이스 칼럼의 맵퍼을 서술한 메타 데이터 등)에는 엔티티 클래스가 보통 필요합니다. 그런데 도메인 계층은 이러한 JPA속성 관련 된 영속성 계층을 모르기 때문에 도메인 계층에서 사용한 엔티티 클래스를 영속성 계층에서 함께 사용할 수 없고 두 계층에서 각각 엔티티를 만들어야 합니다. 매번 맵핑(modelmapper 이용하거나 객체를 해당 엔티티에 맞게 맵핑 하는작업)하는 번거로움이 있으나 이것 또한 하나의 트레이드 오프라고 생각합니다.

예를 들어 우리가 Java Persistence API(자바 세계의 표전 ORM-API)에서는 ORM이 관리하는 엔티티에 인자가 없는 기본 생성자를 추가하도록 강제합니다. 이것이 바로 도메인 모델에는 포함이 되지 않아야할 프레임워크에 특화된 결합의 예입니다.

우리는 클린 아키텍처 책을 읽으면서 위 그림이 너무 추상적으로 느껴질 것 입니다. 이러한 사항을 구체적으로 프로젝트에 적용하기 쉽게 만든 하나의 아키텍처가 육각형 아키텍처(= 핵사고날, 인아웃포트) 입니다. 구현도 클린 아키텍처보다 명확 하면서 쉽게 구현 가능합니다. 그리고 우리가 제일 많이 사용한 계층형 아키텍처와 매우 유사합니다.

핵사고날(port-adapter) 아키텍처

port-adapter 아키텍처는 계층형 아키텍처의 단점을 해결하고자 나온 아키텍처로 복잡한 비지니스 로직을 구현하는데 적합합니다.

아래 그림을 보면 충분히 이해 할 것입니다.

약속되어진 port로만 구현하여 의존성은 역전시켜 외부로 부터 보호

아래 그림이 전통적인 port-adapter 아키텍처의 계층입니다.

이렇게 구성할 경우 도메인 계층은 프레젠테이션 계층과 데이터 접근 계층에 끼어 있는 대신 중심적인 역할(코어) 입니다. 계층형과 달리 더 이상 인프라스트럭처 계층 구성요소에 의존하지 않게 됩니다.

클린 아키텍처와 같이 모든 의존성이 도메인 계층(코어)를 향하는걸 보면됩니다. 육각형 밖에는 DB가 될수도 있고 redis될수도있고 큐서비스인 카프카나 rabbitMQ가 될수도 있습니다. 이러한 여러 외부 서비스를 adapter를 이용하여 포트(in,out)통신을 하여 도메인계층으로 접근해서 비지니스 로직을 침범하지 않으면서 사용할수 있다라는 개념이 제일 중요하다고 생각합니다.

이러한 개념을 바탕으로 실제로 플레이 탭 개편 프로젝트를 살펴 보겠습니다.

우리의 비지니스 로직은 외부로 부터 지켜야한다!

핏펫 플레이 탭 아키텍처 구성

프로젝트 스펙 — Kotlin / Gradle / JPA / Kotest / Querydsl

개념적인 글로 설명하는 것보다 코드로 보면 쉽게 이해하기 쉬우니 현재 핏펫 플레이 스쿼드에서 만들고 있는 플레이 서비스를 예로 설명하겠습니다.

기본 패키지 구성

Adapter 패키지

먼저 adapter에 대한 설명입니다.

adapter 구성

애플리케이션 계층의 in 포트를 호출하는 인커밍 adapter와 애플리케이션 계층의 out 포트에 대한 구현을 제공하는 아웃고잉 adapter를 포함합니다.

adapter.inbound : Web adapter 기능(모바일, pc환경에 api 호출을 받거나 카프카 구독을 하는부분)으로 이 부분에서는 REST API(HTTPS), 입력유효성검증, 라우팅 책임만 담당하게 됩니다. 비지니스 로직이 해당 레이어에 있어서는 안됩니다.

adapter-inbound.web.dto: web adapter를 통해서 들어오고 내보내는reqeust/response dto를 관리합니다.

사용자들이 행하게 될 Item을 관리하는 ItemResource 역할을 예시로 들어보겠습니다.

  • @WebAdapter 어노테이션을 생성하여 명시합니다.
  • 계층형 아키텍처에서 RestController 기능을 합니다.
  • https 요청을 자바 객체로 맵퍼 기능 및 권한 검사 합니다.
package kr.co.fitpet.play.adpter.inbound.web

@WebAdapter
class ItemResource(
private val itemUseCase: ItemUseCase
) {

@GetMapping("/items")
fun getItemList(
@RequestParam page: Int,
@RequestParam page_size: Int
): ResponseEntity<ItemListResponse> {
val pageable: Pageable = PageRequest.of(page, page_size)
return ResponseEntity.ok(itemUseCase.getItemList(pageable))
}
@GetMapping("/items/{itemNo}")
fun getItem(
@PathVariable itemNo: Long
): ResponseEntity<ItemResponse> {
return ResponseEntity.ok(itemUseCase.getItem(itemNo))
}
@PostMapping("/items")
fun saveItem(
@RequestBody @Valid itemSaveRequest: ItemSaveRequest
): ResponseEntity<String> {
itemUseCase.saveItem(itemSaveRequest)
return ResponseEntity(HttpStatus.CREATED)
}

@PatchMapping("/items/{itemNo}")
fun modifyItem(
@PathVariable itemNo: Long,
@RequestBody @Valid itemUpdateRequest: ItemUpdateRequest
): ResponseEntity<String> {
itemUseCase.modifyItem(itemNo, itemUpdateRequest)
return ResponseEntity(HttpStatus.CREATED)
}

@DeleteMapping("/items/{itemNo}")
fun deleteItem(
@PathVariable itemNo: Long
): ResponseEntity<String> {
itemUseCase.deleteItem(itemNo)
return ResponseEntity(HttpStatus.NO_CONTENT)
}


}
  • dto에서 입력 유효성을 검증합니다.(spring validation 이용하여 요청 dto에 대한 검증)
package kr.co.fitpet.play.adapter.inbound.web.dto.request.item

data class ItemSaveRequest(

@field:NotEmpty(message = "아이템 이름을 필수값입니다.")
val name: String,

@field:ValidEnum(enumClass = TargetTypeEnum::class)
val key: TargetTypeEnum,

@field:ValidEnum(enumClass = CycleType::class)
val cycleType: CycleType,

val iconUrl: String? =null,

val title: String,

val title2: String,

@field:NotNull(message = "포인트는 필수값입니다.")
val point: Int,

@field:NotNull(message = "최대 포인트는 필수값입니다.")
val maxPoint: Int,

val isOpened: Boolean? = false,

val position: Int = 1,

val memo: String? = null
)
  • 입력을 유스케이스의 입력 모델로 맵퍼합니다.
  • 유스케이스를 호출합니다.
  • 유스케이스의 출력을 HTTP로 맵퍼합니다.
  • HTTP 응답을 반환합니다.
  • 여기에는 유스케이스 인터페이스를 통해서만 라우팅해주는 역할만 구현해야 하고 비지니스 로직이 있어서는 안됩니다.

adapter.outbound.persistence — 영속성 계층입니다. 영속성 adapter기능(JPA 이용하여 DB에 CRUD를 보통 진행하거나 Querydsl 이용하여 구현한 클래스를 호출하는 부분)이 adapter부분에서 실제 JPA Respository 사용하여 DB에 CRUD가 일어나 트랜잭션 관리를 영속성 계층에서 관리하는게 아니라 호출하는 서비스 레이어에 위임하는 방식으로 처리합니다.

ItemPersistenceAdapter를 예로 들어보겠습니다

package kr.co.fitpet.play.adpter.outbound.persistence.item

@PersistenceAdapter
class ItemPersistenceAdapter(
val itemRepository: ItemRepository,
val itemRepositorySupport: ItemRepositorySupport,
val modelMapper: ModelMapper
) : SaveItemPort, LoadItemPort {

@Transactional(readOnly = true)
override fun getOpenItemList(pageable: Pageable): Page<Item> {
return itemRepositorySupport.getOpenItemList(pageable).map {
modelMapper.map(it, Item::class.java)
}
}

@Transactional(readOnly = true)
override fun findById(itemNo: Long): Item {
return modelMapper.map(
itemRepository.findById(itemNo.toInt())
.orElseThrow { FitpetNonException(ApiCode.NOT_FOUND) },
Item::class.java
)
}

@Transactional
override fun save(item: Item) {
itemRepository.save(modelMapper.map(item, ItemJpaEntity::class.java))
}

@Transactional(readOnly = true)
override fun getItemByActionType(itemActionType: TargetTypeEnum): Item {
val itemJpaEntity = itemRepository.findByKey(itemActionType).orElseThrow {
FitpetNonException(ApiCode.NOT_FOUND)
}
return modelMapper.map(itemJpaEntity, Item::class.java)
}

@Transactional(readOnly = true)
override fun getByItemActionTypeList(itemActionTypeList: List<TargetTypeEnum>): List<Item> {
return itemRepositorySupport.getOpenItemListByType(itemActionTypeList)?.map { modelMapper.map(it, Item::class.java) } ?: listOf()
}
}

ItemJpaEntity — JPA엔티티 역할이며 DB관련 컬럼 메타데이터만 기술하고 다른로직은 존재해서는 안됩니다

package kr.co.fitpet.play.adpter.outbound.persistence.item

@Entity
@Table(name = "item")
class ItemJpaEntity(

@Column(name = "name")
val name: String,

@Column(name = "`key`")
@Enumerated(EnumType.STRING)
val key: TargetTypeEnum?,

@Column(name = "cycle_type")
@Enumerated(EnumType.STRING)
val cycleType: CycleType?,

@Column(name = "icon_url")
val iconUrl: String?,

@Column(name = "title")
val title: String?,

@Column(name = "point")
val point: Int,

@Column(name = "max_point")
val maxPoint: Int?,

@Column(name = "is_opened")
val isOpened: Boolean,

@Column(name = "position")
val position: Int,

@Column(name = "memo")
val memo: String? = null,

) : BaseEntity()

ItemRespository — JPA CRUD가 구현되어지진 인터페이스로 이 코드도 영속성계층에 역할로 영속성 계층에 위치합니다.

package kr.co.fitpet.play.adapter.outbound.persistence.item

interface ItemRepository: JpaRepository<ItemJpaEntity, Int> {
fun findByKey(itemActionType: TargetTypeEnum): Optional<ItemJpaEntity>
}

ItemRespositorySupport — 기존 JPA 순수 메소드 쿼리로 하기 힘든 쿼리인 경우 Querydsl이 구현되어진 로직 이것또한 영속성 계층으로 판단합니다.

package kr.co.fitpet.play.adapter.outbound.persistence.item

@Repository
class ItemRepositorySupport(
val queryFactory: JPAQueryFactory
): QuerydslRepositorySupport(ItemJpaEntity::class.java) {

fun getOpenItemList(page: Pageable): PageImpl<ItemJpaEntity> {
val query = querydsl!!.applyPagination(
page,
queryFactory
.selectFrom(itemJpaEntity)
.where(itemJpaEntity.isOpened.isTrue)
.orderBy(itemJpaEntity.position.desc())
)

return PageImpl(query.fetch(), page, query.fetchCount())
}

fun getOpenItemListByType(itemActionTypeList: List<TargetTypeEnum>): MutableList<ItemJpaEntity>? {
return queryFactory
.selectFrom(itemJpaEntity)
.where(
itemJpaEntity.key.`in`(itemActionTypeList)
.and(itemJpaEntity.isOpened.isTrue)
)
.fetch()
}
}

영속성 adapter의 역할 단계

  • 입력을 받습니다.
  • 입력을 데이터베이스 포맷으로 맵퍼합니다. (도메인 계층에 도메인 클래스 → JPA엔티티 객체로 맵퍼 작업)
  • 입력을 데이터 베이스로 보냅니다.
  • 데이터베이스 출력을 어플리케이션 레이어에 맞게 맵퍼 합니다. (이러한 맵퍼 작업도 하나의 트레이드 오프입니다.)

영속성 adapter는 포트 인터페이스를 통해 입력을 받고 입력 모델은 인터페이스가 지정한 도메인 엔티티나 특정 데이터 베이스 연산 작용 객체데이터 베이스를 쿼리하거나 변경하는데 사용 할 수 있는 포맷입니다. 입력 모델을 맵퍼하고 맥락에 따라 입력 모델을 JPA 엔티티로 맵퍼 하는 것이 조금 불편할 수도 있는데 여러 맵퍼 전략을 통해 트레이드 오프 해야합니다.

adapter.outbound.infratructure — 외부 서비스 API adapter가 존재하는 패키지입니다.

Feign 클라이언트를 이용하여 내부 서비스 호출하는 책임을 지니고 있습니다. 예를들어 아이템 중에 펫관련 정보가 매번 필요한 비지니스 규칙이 있습니다. 그러면 내부 서비스인 user서비스 rest call을 통하여 user정보를 가져와야 합니다. 유저서비스는 지금 구축하는 서비스의 비지니스로직에 의존성이 없어야 하고 도메인(코어)에 영향이 없어야 합니다.

dto는 유저 서비스 호출시 요청/응답에 필요한 dto를 정의했습니다.

redis 패키지도 외부 서비스를 호출하여 key/value를 저장하는 책임이 있습니다. redis도 의존성 역전을 통해서 비지니스 로직에 영향이 없어야 하고 의존성도 없어야 합니다.

비지니스 로직중 사진업로드 로직이 있는 경우에 aws S3에 업로드을 해야합니다. s3에 사진업로드 기술은 비지니스 로직과 의존성이 없어야 합니다.

aws api 관련 adapter를 만들어 아래코드와 같이 구현합니다.

package kr.co.fitpet.play.adpter.outbound.infrastructure.aws


@InfrastructureAdapter
class AWSClientAdapter : AWSS3Port {

@Value("\${****}")
lateinit var bucketUrl: String

private val log = KotlinLogging.logger {}
private val BUCKET_PATH = "***"

@OptIn(DelicateCoroutinesApi::class)
override fun putS3Object(file: MultipartFile): String {
val uuid: String = UUID.randomUUID().toString().replace("-".toRegex(), "")
val ext: String? = file.originalFilename?.substringAfterLast(".")
val handler = CoroutineExceptionHandler { _, exception ->
log.error("CoroutineExceptionHandler got $exception")
}
val pattern = dateFormat(LocalDate.now())
val s3fileName = "$BUCKET_PATH$pattern/$uuid.$ext"

GlobalScope.launch(handler) {
val objectRequest = PutObjectRequest {
bucket = bucketUrl
key = s3fileName
metadata = mapOf()
body = ByteStream.fromBytes(file.bytes)
}
S3Client { region = "****" }.use { s3 ->
val response = s3.putObject(objectRequest)
log.debug("response = $response")
}

}

return s3fileName
}

private fun dateFormat(localDate: LocalDate): String {
val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy/MM/dd")
return localDate.format(formatter)
}

}

s3 업로드 하는 외부 API AWS adapter는 비지니스 로직과 의존성을 완전히 분리 시켜야 합니다.(DIP — 의존성 역전)

Application 패키지

비지니스 로직 액션을 담당하는 서비스 계층입니다.

inbound 에서는 외부에서 입력을 받는 인커밍 역할 유스케이스를 담당하는 역할로 인커밍 adapter로 부터 입력받고 이부분에서는 절대 입력 유효성 검증을 하지 않고 그 책임은 프리젠테이션(=웹) 계층에서 진행해야 해야 합니다. 이 부분에서는 오직 비지니스 로직만 구현하도록 합니다.

port.inbound — 웹 계층 item리소스에서 요청들어오는 port역할로 예로 ItemUseCase는 아이템 관련 도메인을 정의 하는 인터페이스입니다.

package kr.co.fitpet.play.application.port.inbound

interface ItemUseCase {
fun getItemList(pageable: Pageable): ItemListResponse
fun getItem(itemNo: Long): ItemResponse
fun saveItem(itemSaveRequest: ItemSaveRequest)
fun modifyItem(itemNo: Long, itemUpdateRequest: ItemUpdateRequest)
fun deleteItem(itemNo: Long)

}

ItemUserCase 역할 단계

  • 입력을 받습니다.
  • 비지니스 규칙을 검증합니다.
  • 모델 상태를 조작합니다.
  • 출력을 반환합니다.

service 패키지에서는 위 useCase를 구현하는 서비스로 비지니스 흐름(액션)을 담고있습니다.

@UseCase
class ItemService(
private val saveItemPort: SaveItemPort,
private val loadItemPort: LoadItemPort,
private val modelMapper: ModelMapper
) : ItemUseCase {
companion object: KLogging()

@Transactional(readOnly = true)
override fun getItemList(pageable: Pageable): ItemListResponse {
val findItem = loadItemPort.getOpenItemList(pageable)
return ItemListResponse(
count = findItem.totalPages,
next = "",
previous = "",
result = findItem.content.map {
modelMapper.map(it, ItemResponse::class.java)
}
)
}

@Transactional(readOnly = true)
override fun getItem(itemNo: Long): ItemResponse {
val item = loadItemPort.findById(itemNo)
return modelMapper.map(item, ItemResponse::class.java)
}

@Transactional
override fun saveItem(itemSaveRequest: ItemSaveRequest) {
val saveItem = modelMapper.map(itemSaveRequest, Item::class.java)
saveItemPort.save(saveItem)
}

@Transactional
override fun modifyItem(itemNo: Long, itemUpdateRequest: ItemUpdateRequest) {
val item = loadItemPort.findById(itemNo)
item.updateItem(
name = itemUpdateRequest.name,
key = itemUpdateRequest.key,
cycleType = itemUpdateRequest.cycleType,
iconUrl = itemUpdateRequest.iconUrl,
title = itemUpdateRequest.title,
title2 = itemUpdateRequest.title2,
point = itemUpdateRequest.point,
maxPoint = itemUpdateRequest.maxPoint,
isOpened = itemUpdateRequest.isOpened,
position = itemUpdateRequest.position,
memo = itemUpdateRequest.memo
)
saveItemPort.save(modelMapper.map(item, Item::class.java))
}

@Transactional
override fun deleteItem(itemNo: Long) {
val item = loadItemPort.findById(itemNo)
item.deleteItem()
saveItemPort.save(modelMapper.map(item, Item::class.java))
}
}

port.outbound 는 adapter의 outbound로 가는 포트 역할로 itemPort 인터페이스를 통해 영속성 계층과 의존성 역전을 통해서 item을 저장, 조회, 목록조회등 역할을 가지고 있습니다. 영속성 adapter가 아래 그림코드를 받아 오버라이딩 하여 구현하게 됩니다.

package kr.co.fitpet.play.application.port.outbound.persistence

interface LoadItemPort {
fun getOpenItemList(pageable: Pageable) : Page<Item>
fun findById(itemNo: Long) : Item
fun getItemByActionType(itemActionType: TargetTypeEnum): Item
fun getByItemActionTypeList(itemActionTypeList: List<TargetTypeEnum>): List<Item>
}
package kr.co.fitpet.play.application.port.outbound.persistence

interface SaveItemPort {
fun save(item:Item)
}

Domain 패키지

domain 은 POJO 클래스로 비지니스 로직을 구현하는 도메인계층입니다.

ItemJpaEntity와 동일하게 컬럼들이 선언되어져있고 외부와 의존성이 절대 있어서는 안됩니다. 도메인계층에서 ItemJpaEntity를 사용하면 위에서 설명한것처럼 JPA 에 의존성이 발생하기 때문에 순수 POJO 형식을 선택했습니다.

package kr.co.fitpet.play.domain.item

@Domain
class Item(
var id: Long? = null,
var name: String? = null,
var key: TargetTypeEnum? = null,
var cycleType: CycleType = CycleType.DAILY,
var iconUrl: String? = null,
var title: String? = null,
var title2: String? = null,
var point: Int = 0,
var maxPoint: Int = 0,
var isOpened: Boolean? = false,
var position: Int = 0,
var memo: String? = null,
val createdAt: LocalDateTime? = null,
val updatedAt: LocalDateTime? = null,
) {
fun updateItem(
name: String,
key: TargetTypeEnum,
cycleType: CycleType,
iconUrl: String?,
title: String,
title2: String,
point: Int,
maxPoint: Int,
isOpened: Boolean?,
position: Int,
memo: String?
) {
this.name = name
this.key = key
this.cycleType = cycleType
this.iconUrl = iconUrl
this.title = title
this.title2 = title2
this.point = point
this.maxPoint = maxPoint
this.isOpened = isOpened
this.position = position
this.memo = memo
}

fun deleteItem() {
this.isOpened = false
}
}

config 패키지는 스프링 설정 및 외부 라이브러리 예를들어 스웨거,Kafka등을 설정하는 패키지입니다.

package kr.co.fitpet.play.config

@EnableAsync
@Configuration
class AsyncConfig: AsyncConfigurerSupport() {

@Override
override fun getAsyncExecutor(): Executor {
val executor = ThreadPoolTaskExecutor()
executor.corePoolSize = 232323
executor.maxPoolSize = 102323
executor.queueCapacity = 5002323
executor.setThreadNamePrefix("***")
executor.initialize()
return executor
}
}

exception 패키지는 프로젝트내 공통예외처리를 하는 예외관련 기능에 패키지입니다.

package kr.co.fitpet.play.exception

@RestControllerAdvice
class GlobalControllerAdvice {

companion object : KLogging()


@ExceptionHandler(Exception::class)
@ResponseStatus(code = HttpStatus.INTERNAL_SERVER_ERROR)
fun unknownException(e: Exception): ResponseEntity<ExceptionResponse> {
logger.error("알 수 없는 오류 발생 message: ${e.message}", e)
return ResponseEntity(
ExceptionResponse(ApiCode.INTERNAL_SERVER_ERROR),
HttpStatus.INTERNAL_SERVER_ERROR,
)
}


@ExceptionHandler(HttpRequestMethodNotSupportedException::class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
fun httpRequestMethodNotSupportedExceptionHandler(e: Exception) =
ResponseEntity(
ExceptionResponse(ApiCode.METHOD_NOT_ALLOWED),
HttpStatus.METHOD_NOT_ALLOWED
)

.....

}

extension같은 경우 애매하긴 한데 코틀린을 사용하다보면 확장함수를 사용하는 케이스가 드물게 나타나는데 그 확장함수를 구현하는 부분으로 정의를 해놨습니다. (https://velog.io/@ho-taek/Kotlin-확장-함수Extension-Function)

요약

위 예시로 든 Item를 삭제하는 흐름은 아래와 같습니다.

파란색 — adapter기능 / 빨간색 — 비지니스 로직 액션 / 노란색 — 도메인 로직 / 주황색 — DIP(의존성 역전처리를 통한 인터페이스)

플레이 스쿼드에서는 핵사고날 아키텍처를 도입함으로서 백엔드 개발자들이 도메인에 집중하며 신규 기능들이 추가 될때마다 빠르게 개발가능하도록 하는것 목표로 합니다.

아래는 마틴파울러에 디자인 스테미너 가설입니다.

핏펫에서는 애자일 문화를 만들어 가고 있는데 애자일의 강점은, 독립적으로, 빠르게 행동 할 수 있다는 것입니다.

각 스쿼드가 해당 도메인을 민첩하고 빠르게 기능을 개발하기 위해서는 먼저 올바른 아키텍처로 프로젝트가 구성되어야 한다고 생각합니다.

내부 품질이 낮은(엉망인 아키텍처)로 개발하게 되면 빠르게 눈으로 볼 수는 있지만 신규 기능이 계속 추가될때마다 개발 속도도 느려지고(고객에 요구사항을 빠르게 반영하지못하여 핏펫의 유저들이 떠남) 버그 발생도 빈번하게 발생되며 개발자들이 코드를 수정할 때마다 많은 스트레스를 받게 될 수 밖에 없습니다. (참조에 마틴파울러 영상은 꼭 보시길!) 이후 신규 개발자들이 들어 와도 내부품질이 좋지 않은 프로젝트는 유지보수 비용이 초기 만든 비용보다 2~3배 더 발생할 수 밖에 없습니다.

핵사고날 아키텍처로 개발자들이 어디에 집중 해야 할 지, 무엇을 수정 해야할 지, 일관성 있는 구조로 빠르게 대응할 수 있는 개발 환경을 만들어가 서로 성장할 수 있는 개발 문화를 만들어가는 플레이 스쿼드가 되었으면 좋겠습니다🙂

마치며

무조건 port-adapter 아키텍처 구조가 정답은 아닙니다. 해당 비지니스가 간단하다고 하면 일반 계층형 아키텍처를 사용하는게 효율적일 수 있습니다. 모든 개발은 트레이드 오프가 존재합니다.

현재 플레이 서비스가 port-adapter 아키텍처 구조로 되어 있지만 궁극적인 목표는 DDD(도메인주도설계)를 벡엔드 개발자들끼리 학습하여 적용하는 것입니다. port-adapter 아키텍처 구조에 대한 장점을 활용하여 앞으로 발전하는 것이 저희 플레이 스쿼드의 목표입니다. 감사합니다 🙂

written by Kai (Junwoo Ahn)

email: junwoo.ahn@fitpet.co.kr

개인 블로그 : https://blog.naver.com/cutesboy3

참조

https://devlos.tistory.com/55

--

--