spring boot + kotlin 으로 Hexagonal architecture 맛보기!

김진욱·2024년 3월 30일

Hexagonal architecture

  • 비즈니스 로직을 담당하는 내부 영역과 인터페이스를 담당하는 외부 영역으로 나눈 구조
    • 사용자 인터페이스, 데이터베이스 를 비즈니스 로직으로 부터 분리해야 하는 외부 요소로 취급
  • 관심사를 분리하고 외부와 종속된 관계를 분리

계층형 아키텍쳐(3Tier)의 문제점

  • 도메인 계층이 영속성에 의존
  • 다른 계층의 컴포넌트에 바로 접근이 가능
  • 아키텍쳐 경계를 강제 할 수 없음
    - Helper class, Util class 사용으로 직접 접근하며 의존성이 생김

Hexagonal architecture 구조

내부 영역

비즈니스 로직 영역이며 외부 영역과 직접 통신하지 않고 port 를 통해 통신

  • inbound port : 내부 영역 사용을 위해 노출된 api (usecase)
  • outbound port : 내부 영역이 외부 영역을 사용하기 위한 api (adaptor)

외부 영역

외부에서 들어오는 요청 처리 영역

  • inbound adapter : 외부에서 들어오는 요청을 처리(controller)
  • outbound adapter : 비즈니스 로직에 의해 호출되어 외부와 연계(repository)

전반적인 흐름

request 요청 -> inbound adapter -> inbound port -> 비즈니스 로직(service) -> outbound port -> outbound adapter -> response 반환

샘플 코드 작성

패키지 구조

패키지 구조는 위와 같이 크게 4가지로 나누었습니다.

  • adapter
  • port
  • application
    • 내부 비즈니스 로직을 구현 할 service 단
  • domain
    • 데이터베이스와 분리 되어 있으며 비즈니스 로직을 포함한 작업을 처리하는 계층

실습

간단하게 회원가입 api를 작성해봅시다.

Entity
package com.demo.user.adapter.out.persistence.entity
@Entity
@Table(name = "USER")
class UserJpaEntity(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long? = null,

    @Column(name = "EMAIL")
    var email: String,

    @Column(name = "NICK_NAME")
    var nickName: String,

    @Column(name = "PASSWORD")
    var password: String,

    ) {
}
Domain
package com.demo.user.domain
data class User(
    val id: Long? = null,
    val email: String,
    val nickName: String,
    val password: String
)
요청 -> inbound adapter
package com.demo.user.adapter.in.rest
@RestController
@RequestMapping(value = ["/user"])
class UserController(
	// 내부 영역 사용을 위한 usecase 주입
    private val userUseCase: UserUseCase
) {

    @PostMapping
    fun create(@RequestBody dto: UserDto.CreateUser): ResponseEntity<GenericResponse<UserDto.Response>> {
        val command = UserCommandMapper.instance.toCreateUserCommand(dto)
        val user = userUseCase.create(command)
        return GenericResponse.ok(UserResponseDtoMapper.instance.toDto(user))
    }
}

package com.demo.user.adapter.in.rest.dto
sealed class UserDto {
    data class CreateUser(
        val nickName: String,
        val password: String,
        val email: String
    ) : UserDto()
    data class Response(
        val id: Long,
        val nickName: String,
        val email: String
    ) : UserDto()
}

package com.demo.user.port.in.command
sealed class UserCommend {
    data class CreateUser(
        val nickName: String,
        val password: String,
        val email: String
    ) : UserCommend()
}

package com.demo.user.port.in.mapper
@Mapper
abstract class UserCommandMapper {
    companion object {
        val instance: UserCommandMapper = Mappers.getMapper(UserCommandMapper::class.java)
    }

    abstract fun toCreateUserCommand(dto: UserDto.CreateUser): UserCommend.CreateUser
}

package com.demo.user.adapter.in.rest.mapper
@Mapper
abstract class UserResponseDtoMapper {
    companion object {
        val instance: UserResponseDtoMapper = Mappers.getMapper(UserResponseDtoMapper::class.java)
    }
    abstract fun toDto(user: User): UserDto.Response
}

dto를 그대로 넘기지 않고 command를 사용하는 이유는 외부 시스템 변경 시 usecase도 같이 변경 되기 때문에 usecase 요청 값을 따로 command class 로 분리

inbound port 구현
package com.demo.user.port.in.usecase
interface UserUseCase {
    fun create(commend: UserCommend.CreateUser): User
    fun findById(id: Long): User?
    fun findByEmail(email: String): User?
}

package com.demo.user.application.service
// usecase 를 구현하는 구현체, 비즈니스 로직을 담당
@Service
class UserService(
    private val userJpaPort: UserJpaPort
) : UserUseCase {

    @Transactional
    override fun create(commend: UserCommend.CreateUser): User {
        if (findByEmail(commend.email) != null) {
            throw CommonException(CommonExceptionCode.USER_ALREADY_EXISTS)
        }
        return userJpaPort.saveUser(commend)
    }

    override fun findByEmail(email: String): User? {
        return userJpaPort.findByEmail(email)
    }
}
outbound port & adapter

package com.demo.user.adapter.out.persistence.repository
interface UserRepository : JpaRepository<UserJpaEntity, Long> {
    fun findByEmail(email: String): Optional<UserJpaEntity>
}

package com.demo.user.port.out
// 내부 영역이 외부 영역을 사용하기 위한 통로
interface UserJpaPort {
    fun saveUser(commend: UserCommend.CreateUser): User
    fun findByEmail(email: String): User?
}

package com.demo.user.adapter.out.persistence.repository
// outbound port 구현체
@Component
class UserPersistAdapter(
    private val userRepository: UserRepository
) : UserJpaPort {

    override fun saveUser(commend: UserCommend.CreateUser): User {
        val userJpaEntity = userRepository.save(UserJpaEntityMapper.instance.toJpaEntity(commend))
        return UserJpaEntityMapper.instance.toUser(userJpaEntity)
    }

    override fun findByEmail(email: String): User? {
        val userOptional = userRepository.findByEmail(email)
        return if (userOptional.isPresent) UserJpaEntityMapper.instance.toUser(userOptional.get()) else null
    }

}

package com.demo.user.adapter.out.persistence.mapper
@Mapper
abstract class UserJpaEntityMapper {
    companion object {
        val instance: UserJpaEntityMapper = Mappers.getMapper(UserJpaEntityMapper::class.java)
    }

    abstract fun toJpaEntity(comment: UserCommend.CreateUser): UserJpaEntity

    @Mapping(target = "roles", ignore = true)
    abstract fun toUser(userJpaEntity: UserJpaEntity): User
}

결론

기존 3티어로 된 계층형 아키텍쳐 에서는 비즈니스 로직과 외부 요소가 강하게 결합 되어 있어서 유연성이 없어지고 변경에 취약합니다. 위와 같이 헥사고날 아키텍쳐로 구현했을 때 내부, 외부 영역을 나누고 adapter, port 를 통해서만 통신하도록 구현했을 때 내부 영역은 외부 영역에 전혀 의존 하지 않게 됩니다. usecase 별로 나누어 종속성을 없애고 단일 책임을 갖도록 설계할 수 있습니다.

*공부하면서 주관적인 견해로 작성된 글이라 틀린 내용이 있을 수 있습니다... 피드백은 언제나 환영입니다!

profile
2021.12~ 공부의 기록

0개의 댓글