멀티 모듈 기반 헥사고날 아키텍처
헥사고날 아키텍처에 멀티 모듈 도입하기
2024-07-23
이번에 개발을 시작하기 전에 데이터베이스로 SQL(Structured Query Language)과 NoSQL 중 어느 것을 사용할지 고민을 했었는데요.
사실 무엇을 선택하든 중간에 기술을 바꿀 가능성을 배제할 수 없다고 생각했습니다.
그래서 저는 어떤 기술을 사용할지 보다는 중간에 다른 기술로 바꾼 경우를 좀 더 고려해보기로 했습니다.

아키텍처

기술 변경에 대한 수용성은 소프트웨어 설계도인 아키텍처에 영향받는데요.
백엔드 분야에서 주로 알려진 아키텍처로는 계층형 아키텍처와 클린 아키텍처(Clean Architecture)가 있습니다.

계층형 아키텍처

계층형 아키텍처는 시스템을 프레젠테이션(Presentation), 도메인(Domain), 데이터 액세스(Data Access) 계층으로 나누는 아키텍처입니다.
각각의 계층은 관심사에 따라 분류되며, 직접적인 하위 계층만을 의존하게 되는데요.
이러한 계층 분리는 응집성을 높이고 의존성을 낮춤으로써 유지보수를 용이하게 해줍니다.


그러나 계층형 아키텍처에서는 도메인 계층이 하위 계층인 영속성 계층을 의존하고 있다는 문제를 가지는데요.
이는 데이터 중심 설계이기에 비즈니스 로직이 외부 기술인 데이터베이스에 크게 영향을 받는다는 단점을 가집니다.

클린 아키텍처

클린 아키텍처(Clean Architecture)는 시스템에서 도메인 계층이 외부에 가지는 의존성이 전혀 없도록 설계하는 이론입니다.
계층형 아키텍처와 달리 클린 아키텍처에서는 비즈니스 규칙을 담는 도메인 엔티티는 어떤 외부 기술에도 종속적이지 않는데요.
이런 부분에서 클린 아키텍처가 기술 변경에 좀 더 용이하다고 판단해 해당 아키텍처를 도입해 보기로 했습니다.

헥사고날 아키텍처

헥사고날 아키텍처(Hexagonal Architecture)는 클린 아키텍처를 구현한 모델 중 하나로, 육각형 형태로 설계되어 헥사고날 아키텍처라 불립니다.
도메인 엔티티를 가지는 도메인 계층, 여러 포트와 비즈니스 로직을 가진 애플리케이션 계층, 외부 영역인 인프라 계층으로 육각형을 이루고 있습니다.


해당 육각형에서 외부와 내부를 연결해주는 구성 요소로 포트(Port)와 어댑터(Adapter)가 있는데요.
포트는 외부 영역에서 비즈니스 로직을 호출하기 위해 사용하는 인바운드 포트와 비즈니스 로직에서 외부 영역을 호출하기 위해 사용하는 아웃바운드 포트로 나뉩니다.
또한 어댑터는 외부 영역에서 비즈니스 로직을 호출하기 위해 사용하는 인바운드 어댑터, 비즈니스 로직에서 외부 영역을 호출하기 위해 사용하는 아웃바운드 어댑터로 나뉩니다.

멀티 모듈 기반 헥사고날 아키텍처

저는 멀티 모듈을 활용해 헥사고날 아키텍처를 구축할 생각인데요.
멀티 모듈을 사용하면 각 계층마다 라이브러리 등의 의존성들을 완전히 분리시킬 수 있다는 장점을 가지고 있습니다.
이는 도메인 계층에서 특정 기술에 종속된 코드를 작성하는 등의 실수를 예방할 수 있도록 해줍니다.
저는 도메인 계층인 domain, 애플리케이션 계층인 application, 인프라 계층인 infrastructure로 모듈을 구성하였습니다.

Spring WebFlux

앞서, 저는 Spring WebFlux를 사용할 생각인데요.
Spring WebFlux에서는 기본적으로 리액티브 스트림즈 구현체인 Reactor를 기반으로 코드를 작성합니다.
PostService.kt
@Transactional
fun deletePostById(id: String, authentication: DefaultJwtAuthentication): Mono<Void> =
    postRepository.findByIdAndDeletedDateIsNull(id)
        .switchIfEmpty(Mono.error(PostNotFoundException()))
        .filter { it.writer.id == authentication.id }
        .switchIfEmpty(Mono.error(PermissionDeniedException()))
        .flatMap {
            val now = LocalDateTime.now()
 
            postRepository.save(it.copy(deletedDate = now))
                .zipWith(
                    commentRepository.findAllByPostIdAndDeletedDateIsNull(id)
                        .map { it.copy(deletedDate = now) }
                        .flatMap { commentRepository.save(it) }
                        .then(Mono.just(true))
                )
        }
        .then()
문제는 Reactor의 여러 리액티브 연산자들로 인해 비즈니스 로직의 가독성이 안좋아지는 것을 경험해 왔는데요.
이렇게 되면 헥사고날 아키텍처에서 비즈니스 로직이 외부 기술인 Reactor에 의존성을 갖게 됩니다.
그러나 Spring WebFlux를 사용한다면 비즈니스 로직에 Reactor 등의 비동기 프로그래밍이 필수적으로 존재할 수 밖에 없습니다.
그래서 저는 이 한계점을 Kotlin의 코루틴(Coroutine)을 통해 극복하고자 했습니다.
PostService.kt
@Transactional
suspend fun deletePostById(id: String, authentication: DefaultJwtAuthentication): Unit = coroutineScope {
    val now = LocalDateTime.now()
    val post = postRepository.findByIdAndDeletedDateIsNull(id)
        .awaitSingleOrNull() ?: throw PostNotFoundException()
 
    if (post.writer.id != authentication.id) throw PermissionDeniedException()
 
    val postJob = launch {
        postRepository.save(post.copy(deletedDate = now))
            .awaitSingle()
    }
    val commentDeferred = async {
        commentRepository.findAllByPostIdAndDeletedDateIsNull(id)
            .asFlow()
            .map { it.copy(deletedDate = now) }
            .map {
                commentRepository.save(it)
                    .awaitSingle()
            }
            .collect()
    }
 
    listOf(postJob, commentDeferred).joinAll()
}
코루틴은 Reactor와 달리 명령형 프로그래밍 방식으로 비동기 처리가 가능하므로 Spring WebFlux에서 Spring Web MVC로 기술이 변경되더라도 코드에 큰 변화를 주지 않습니다.

도메인 계층

이제 어떠한 의존성도 가지지 않는 도메인 계층부터 구성해보겠습니다.
User.kt
@Entity
data class User(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,
    val email: String
)
헥사고날 아키텍처에서는 도메인 계층이 어떤 기술에도 종속적이지 않아야 합니다.
문제는 JPA(Java Persistence API) 등의 기술을 사용하는 경우, 엔티티가 @Entity 등의 해당 기술에 종속적인 내용을 포함하게 되는데요.
User.kt
data class User(
    val id: Long? = null,
    val email: String
)
UserEntity.kt
@Table(name = "user")
data class UserEntity(
    @Id
    val id: Long? = null,
    val email: String
)
그래서 저는 인프라 계층에서 사용할 엔티티와 도메인 계층의 엔티티를 따로 분리시켰습니다.
이로써 도메인 계층에서는 어떠한 라이브러리 없이 POJO(Plain Old Java Object)로 도메인 규칙을 작성하게 됩니다.

애플리케이션 계층

그 다음 도메인 계층을 의존하는 애플리케이션 계층을 구성해보겠습니다.
build.gradle.kts
dependencies {
    implementation(project(":domain"))
    compileOnly("org.springframework:spring-context")
}
애플리케이션 계층도 도메인 계층처럼 어떤 기술에도 종속적이면 안됩니다.
그러나 비즈니스 로직을 가진 클래스를 Bean으로 등록하기 위해 예외적으로 Spring Boot 관련 의존성을 추가했는데요.
커스텀 어노테이션을 만들고 인프라 계층에서 해당 어노테이션을 Bean으로 등록하는 방법이 제일 좋겠지만, 해당 방법을 구현하는 비용보다는 Spring Boot 관련 의존성을 추가하는 것이 비용이 좀 더 싸다고 생각했습니다.
또한 프레임워크에 있어서는 Spring framework가 아닌 다른 프레임워크를 사용할 가능성이 굉장히 희박하다고 생각했기 때문에 해당 의존성은 예외적으로 허용했습니다.
애플리케이션 계층은 인바운드 및 아웃바운드 포트와 비즈니스 로직을 가지게 됩니다.
SignUpUseCase.kt
interface SignUpUseCase {
    suspend fun signUp(command: SignUpCommand)
}
CreateUserPort.kt
interface CreateUserPort {
    suspend fun createUser(user: User): User
}
이제 특정 비즈니스 로직에 대한 인바운드 포트와 해당 포트에서 사용할 아웃바운드 포트를 구현합니다.
참고로 저는 비즈니스 로직에 대해 입력과 출력을 명확히 정의하는 인바운드 포트를 비즈니스 요구 사항에 따른 기능 단위인 유즈케이스라고 명명했습니다.
SignUpService.kt
@Service
class SignUpService(
    private val createUserPort: CreateUserPort
) : SignUpUseCase {
    override suspend fun signUp(command: SignUpCommand) {
        createUserPort.createUser(
            User(
                email = request.email
            )
        )
    }
}
그 다음, 해당 유즈케이스에 대한 비즈니스 로직을 구현해줍니다.
비즈니스 로직에서 인프라 계층에 종속된 기능들은 전부 아웃바운드 포트를 통해 추상화되는데요.
이로써 비즈니스 로직은 오직 도메인 계층에만 의존하며 POJO 기반으로 비즈니스 로직을 구현하게 됩니다.

연산자 오버로딩

비즈니스 로직을 작성하는 도중, 저는 CreateUserPort에서 createUser()를 호출하는 부분이 의미상 중복이라고 생각헀습니다.
포트는 단일 책임 원칙(Single Responsibility Principle, SRP)에 의해 하나의 책임만을 가지며, 해당 책임에 대한 정보는 이미 포트 이름에 명시되어 있는데요.
그럼 굳이 메서드를 호출해서 중복되는 의미를 만들 필요가 없다고 생각했습니다.
그래서 이 부분을 연산자 오버로딩을 통해 개선하고자 했습니다.
CreateUserPort.kt
fun interface CreateUserPort {
    suspend operator fun invoke(user: User): User
}
기존의 포트 내에 정의된 메서드를 호출 연산자 오버로딩으로 바꿨습니다.
또한 포트가 하나의 책임만을 가지도록 강제하기 위해 포트를 함수형 인터페이스로 만들었습니다.
SignUpService.kt
@Service
class SignUpService(
    private val createUser: CreateUserPort
) : SignUpUseCase {
    override suspend fun signUp(request: SignUpRequest) {
        createUser(
            User(
                email = request.email
            )
        )
    }
}
이제 비즈니스 로직에서는 단순히 메서드를 호출하듯이 CreateUserPort를 사용하면 됩니다.
확실히 이전 코드와 달리 불필요하게 중복되는 의미도 없어지고 코드도 간결해진 것을 확인할 수 있습니다.

인프라 계층

인프라 계층에서는 인바운드 어댑터와 아웃바운드 어댑터를 구현하게 되는데요.
우선 아웃바운드 어댑터에서는 일반적으로 데이터베이스 통신 등을 처리하게 됩니다.
build.gradle.kts
dependencies {
    implementation(project(":application"))
    implementation(project(":domain"))
    implementation("org.springframework.boot:spring-boot-starter-data-r2dbc")
    runtimeOnly("com.github.jasync-sql:jasync-mysql:2.2.4")
}
어댑터는 포트를 사용하기 때문에 application 모듈과 application 모듈이 의존하는 domain 모듈을 의존성으로 가지게 됩니다.
아웃바운드 어댑터의 경우, R2DBC(Reactive Relational Database Connectivity) 등의 데이터베이스 관련 의존성도 추가했습니다.
UserEntity.kt
@Table(name = "user")
data class UserEntity(
    @Id
    val id: Long? = null,
    val email: String
) {
    companion object {
        operator fun invoke(user: User): UserEntity =
            with(user) {
                UserEntity(
                    id = id,
                    email = email
                )
            }
    }
 
    fun toDomain(): User =
        User(
            id = id,
            email = email
        )
}
앞서 도메인 계층에서 설계했듯이, User와 별개로 데이터베이스에 저장될 UserEntity를 구현합니다.
User에서는 외부 구현을 알면 안되기 때문에 UserEntity에서만 User와의 양방향 매핑을 위한 메서드들을 모두 가지도록 했습니다.
UserRepository.kt
@Repository
interface UserRepository : CoroutineCrudRepository<UserEntity, Long>
CreateUserAdapter.kt
@Component
class CreateUserAdapter(
    private val userRepository: UserRepository
) : CreateUserPort {
    override suspend fun createUser(user: User): User =
        userRepository.save(UserEntity(user))
            .toDomain()
}
그 다음, 아웃바운드 어댑터에서 사용할 UserRepository와 아웃바운드 어댑터인 CreateUserAdapter를 구현했습니다.
포트를 하나의 어댑터에 모두 구현하는 방법도 있지만, 저는 단일 책임 원칙을 위해 각각의 포트 별로 어댑터를 구현할 생각입니다.


그 다음, 인바운드 어댑터를 구현해 보겠습니다.
인바운드 어댑터는 일반적으로 HTTP(Hypertext Transfer Protocol) 요청 등을 처리하게 됩니다.
build.gradle.kts
dependencies {
    implementation(project(":application"))
    implementation(project(":domain"))
    implementation("org.springframework.boot:spring-boot-starter-webflux")
    implementation("org.springframework.boot:spring-boot-starter-security")
}
인바운드 어댑터는 기존의 아웃바운드 어댑터와 동일하게 applicationdomain 모듈을 의존하며, 추가로 HTTP 요청을 처리해야 하므로 Spring WebFlux 등의 의존성을 추가했습니다.
SignUpController.kt
@RequestMapping("/user")
@RestController
class SignUpController(
    private val signUp: SignUpUseCase
) {
    @PostMapping("/sign-up")
    suspend operator fun invoke(
        @RequestBody
        request: SignUpRequest
    ) {
        signUp(request.toCommand())
    }
}
그 다음, 인바운드 어댑터인 SignUpController를 구현했습니다.

의존성 확인

마지막으로 지금까지의 설계를 점검하기 위해 의존성 다이어그램을 보겠습니다.
위 다이어그램을 보면 비즈니스 로직을 가진 SignUpServiceCreateUserPortSignUpUseCase 등의 인터페이스를 통해서만 CreateUserAdapterSignUpController 등의 외부 영역과 연결되는 것을 확인할 수 있습니다.
이렇게 되면 데이터베이스 등의 외부 기술이 변경되어도 비즈니스 로직을 건드리지 않고 CreateUserPort의 구현체를 새로 추가하는 것만으로 기술을 도입할 수 있습니다.
또한 도메인 주도 설계를 통해 실제 비즈니스 요구 사항을 정확히 반영할 수 있다는 이점도 가져갈 수 있습니다.