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

시작하며

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

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

계층형 아키텍처 단점

도메인 계층에서 데이터베이스 엔티티(JAP @Entity)를 사용하는 것은 영속성 계층과 강한 결합 유발한다
깨진 창문이 있으면 사람들은 심리적으로 돌은 던져서 더 깨고 싶은 심리가 있다.
A라는 개발자가 영속성 계층에 도메인로직을 추가하고 그뒤 B라는 개발자가 일관성있게 영속성계층에서 개발하게 되므로 영속성 계층이 비대해지는 결과를 유발한다.
  • 웹 계층에 영속성 계층 및 여러 외부 코드가 존재 하게 됨으로써 테스트 시 테스트코드를 작성하는 것보다 코드에 의존성을 이해하고 목(mock)을 만드는데 더 많은 시간이 걸리게 됩니다. 저는 개발하는 동안 레거시 코드에 테스트 코드가 존재 하지 않아 추가 하기 위해 많은 노력을 했었습니다. 하지만 작성하는게 어려움이 생겨 많이 포기 하는 경우가 태반이었습니다. 테스트 코드를 쉽게 작성하기 위해서는 최초 설계가 잘되어 있어야 합니다.
여긴 어딘가 나는누구인가
비대해진 서비스는 코드 상에서 특정 유스케이스를 찾는 것을 어렵게 만든다

클린 아키텍처

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

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

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

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

약속되어진 port로만 구현하여 의존성은 역전시켜 외부로 부터 보호
우리의 비지니스 로직은 외부로 부터 지켜야한다!

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

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

기본 패키지 구성

Adapter 패키지

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

adapter 구성
  • 계층형 아키텍처에서 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)
}


}
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 응답을 반환합니다.
  • 여기에는 유스케이스 인터페이스를 통해서만 라우팅해주는 역할만 구현해야 하고 비지니스 로직이 있어서는 안됩니다.
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()
}
}
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()
package kr.co.fitpet.play.adapter.outbound.persistence.item

interface ItemRepository: JpaRepository<ItemJpaEntity, Int> {
fun findByKey(itemActionType: TargetTypeEnum): Optional<ItemJpaEntity>
}
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()
}
}
  • 입력을 데이터베이스 포맷으로 맵퍼합니다. (도메인 계층에 도메인 클래스 → JPA엔티티 객체로 맵퍼 작업)
  • 입력을 데이터 베이스로 보냅니다.
  • 데이터베이스 출력을 어플리케이션 레이어에 맞게 맵퍼 합니다. (이러한 맵퍼 작업도 하나의 트레이드 오프입니다.)
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)
}

}

Application 패키지

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

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)

}
  • 비지니스 규칙을 검증합니다.
  • 모델 상태를 조작합니다.
  • 출력을 반환합니다.
@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))
}
}
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 클래스로 비지니스 로직을 구현하는 도메인계층입니다.

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
}
}
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
}
}
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
)

.....

}

요약

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

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

마치며

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

참조

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store