일반적인 계층화 아키텍처의 문제점
저 같은 경우 예전에는 습관적으로 Controller, Service, Repository를 작성하고, 이것을 표현(프레젠테이션) 계층, 비즈니스 로직 계층, 영속화(퍼시스턴스) 계층이라고 생각하고 사용했습니다.
하지만 일반적인 계층화 아키텍처에는 몇 가지 중요한 이슈가 있다고 생각합니다.
표현 계층은 하나뿐인가?
사실 애플리케이션을 호출하는 시스템은 다양할 수 있습니다. 자주 사용되는 HTTP 호출이 있을 수 있고, 또는 웹소켓을 통한 호출 및 기타 다양한 프로토콜이 될 수 있습니다.
영속화 계층이 하나뿐인가?
표현 계층에 대한 문제와 유사합니다.
사용하는 DB가 MySQL, Oracle과 같은 RDBMS일수도 있고, 빠른 검색 및 샤딩을 위한 NoSQL이 될 수 있습니다.
영속화 계층에 의존성이 강한 비즈니스 로직 계층
결국에는 직접적인 영속화 계층에 대한 의존성으로 인해서 테스트가 어려운 또는 변화에 취약한 비즈니스 로직 계층이 탄생하게 됩니다.
헥사고날(Hexagonal Architecture) 아키텍처란?
제가 생각하는 헥사고날 아키텍처의 가장 큰 특징은 내부 도메인 모델에 외부 인프라 및 다른 기반 요소들이 영향을 끼치지 않는다는 것입니다.
또한 그렇기 때문에 외부 변경에 대해서 도메인 모델의 변경이 일어나지 않고, 도메인 모델이 단일 책임 원칙을 지킬 수 있다는 것입니다.
헥사고날 아키텍처는 포트와 어댑터 아키텍처라고도 불리는데 그림과 같이 여러 개의 어댑터와 포트로 구성됩니다.
이러한 포트와 어댑터는 의존성을 항상 안으로(비즈니스 로직 / 도메인 모델) 향하게 만들고, 이를 통하여 외부 레이어에 변경이 일어나도 내부 레이어(비즈니스 로직 / 도메인 모델)는 영향을 받지 않을 수 있습니다.
포트와 어댑터란?
그렇다면 헥사고날 아키텍처에서 포트와 어댑터란 무엇일까요?
간단하게 포트란 각 레이어를 사용하기 위한 인터페이스입니다.
외부에서 내부 영역을 사용하기 위한 포트를 인바운드 (Inbound) 포트, 내부에서 외부 영역을 사용하기 위한 포트를 아웃바운드 (Outbound) 포트라고 부릅니다.
어댑터는 이러한 포트의 규약을 지키면서 각자 구현한 구현체라고 볼 수 있습니다.
케이스에 따라서 하나의 포트를 구현하는 여러 개의 어뎁터를 만들 수 있습니다.
결론적으로 내부 영역은 순수한 비즈니스 로직을 구현하고 인바운트 포트라는 표출된 인터페이스를 제공합니다. 그리고 외부 영역의 인바운드 어댑터가 인바운트 포트를 호출하여 내부 영역을 사용합니다.또한 비즈니스 로직이 아웃바운트 포트를 사용해 외부 아웃바운드 어댑터를 호출하는 방식입니다.
시나리오
첫 번째는 외부에서 전달되는 JSON 형태가 변경된 케이스,
두 번째는 테이블의 키 값이 변경된 케이스입니다.
첫 번째 케이스
첫 번째 케이스는 외부에서 전달된 JSON 형태가 변경된 케이스입니다.
일반적으로 UI의 변경은 서비스로직보다 훨씬 다양하고 빈번합니다.
그렇기 때문에 어떤 값들이 변경돼서 전달된다거나, 전달하는 방법이 변경된다거나 하는 케이스들이 발생할 수 있습니다.
전통적인 계층화 아키텍처
as-is 케이스의 예제 코드는 아래와 같습니다.
@RestController
@RequestMapping("/v1/user")
class UserCommandController(
private val userCommandService: UserCommandService
) {
@PostMapping
fun addUser(@RequestBody saveUserDto: SaveUserDto){
userCommandService.addUser(saveUserDto)
}
}
...
@Service
class UserCommandService {
fun addUser(saveUserDto: SaveUserDto) {
// 서비스 로직
}
}
...
data class SaveUserDto(
val name: String,
val age: Int,
val birth: String, // YYYY-MM-DD
)
코드를 보면 앞에서 설명했던 일반적인 계층화 아키텍처의 문제점을 그대로 가지고 있습니다.
해당 코드를 호출하는 HTTP Request를 보면 아래와 같습니다.
curl --request POST 'http://localhost:8080/v1/user' \
--header 'Content-Type: application/json' \
--data '{
"name": "bob",
"age": 33,
"birth": "1991-01-01"
}'
만약 여기서 HTTP Request 형태가 변경이 된다면 어떤 일이 발생할까요?
data 형태가 아래처럼 변경되면 SaveUserDto 객체의 변경이 일어납니다.
curl --request POST 'http://localhost:8080/v1/user' \
--header 'Content-Type: application/json' \
--data '{
"content": {
"name": "bob",
"age": 33,
"birth": "1991-01-01"
}
}'
data class SaveUserDto(
val content: User
)
data class User(
val name: String,
val age: Int,
val birth: String, // YYYY-MM-DD
)
불행하게도 userCommandService.addUser 서비스 로직 내부에는 SaveUserDto 객체에 의존성을 가지고 있기 때문에 서비스 로직에 변경이 일어나게 됩니다.
비즈니스 로직을 직접 호출하게 된다면 제어 흐름이 컨트롤러에서 서비스 로직으로 흐르기 때문에 강하게 결합할 수밖에 없습니다.
헥사고날 아키텍처 적용
만약 이러한 구조에 헥사고날 아키텍처를 적용한다면 어떻게 변경할 수 있을지 알아봅시다.
as-is 예제와 동일한 시나리오로 HTTP Request에서 전달되는 JOSN 포맷이 변경되었다고 가정해 봅시다.
최초 예제는 아래와 같습니다.
package com.demo.hexagonal.adapter.`in`.web
@RestController
@RequestMapping("/v1/user")
class UserCommandController(
private val userCommandPort: UserCommandPort
) {
@PostMapping
fun addUser(@RequestBody saveUserDto: SaveUserDto){
userCommandPort.addUser(saveUserDto.toDomain())
}
}
...
package com.demo.hexagonal.application.port.`in`
interface UserCommandPort {
fun addUser(user: User)
}
...
package com.demo.hexagonal.application.service
@Service
class UserCommandService: UserCommandPort {
override fun addUser(user: User) {
print(user)
}
}
...
package com.demo.hexagonal.domain
data class SaveUserDto(
val name: String,
val age: Int,
val birth: String, // YYYY-MM-DD
) {
fun toDomain(): User {
return User(
name = name,
age = age,
birthParam = birth
)
}
}
드디어 어댑터와 포트가 등장했습니다!
예제 코드에서 Adapter 역할을 하는 것은 UserCommandContorller, Port 역할을 하는 것은 UserCommandPort 가 됩니다.
코드를 자세히 보면 UserCommandPort를 통해 제어의 흐름을 역전시켜 주었습니다.
어뎁터에 해당하는 UserCommandContorller가 Port에 정의한 인터페이스의 규약을 지키면서 서비스로직과 통신하는 형태로 변경되었습니다.
그렇다면 동일하게 HTTP Request JSON 포맷이 변경되면 어떤 일이 발생할까요? 아마도 port의 규약을 지키기 위해서 아래 예제와 같이 변경될 것입니다.
data class SaveUserDto(
val content: UserInfo
) {
fun toDomain(): User {
return User(
name = content.name,
age = content.age,
birthParam = content.birth
)
}
}
data class UserInfo(
val name: String,
val age: Int,
val birth: String, // YYYY-MM-DD
)
여기서 중요한 점은 우리가 정의한 port와 도메인 객체(User)는 변경이 일어나지 않습니다.
이것이 바로 헥사고날 아키텍처의 장점이라고 볼 수 있습니다.
지금은 간단한 예제이지만 User라는 도메인 객체에 많은 서비스 로직들이 의존성을 가지고 있다고 가정한다면 훨씬 변경에서 자유로운 상황이 됩니다.
두 번째 케이스
두 번째 케이스에서는 조금 논란이 있을 수 있는 주제를 가지고 예제를 만들어 보겠습니다.
우선 다시 한번 시나리오를 기억해 본다면 이번 케이스 시나리오는 테이블의 키 값이 변경된 케이스입니다.
전통적인 계층화 아키텍처
user 테이블을 우선 정의해 봅시다
간단한 테이블이고 id 값은 Auto Increment 형식의 숫자 값입니다.
그리고 보통 JPA를 사용한다고 가정한다면 Entity는 아래와 작성할 수 있습니다.
@Entity
@Table(name = "user_info")
class UserEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
val id: Long? = null,
val name: String,
val age: Int,
val birthDay: String
)
그리고 습관적으로 작성했었던 Service와 Repository를 작성해 본다면 아래와 같이 나타낼 수 있습니다.
@Service
class UserCommandService(
private val userSaveRepository: UserSaveRepository
) {
fun addUser(name: String, age: Int, birth: String) {
userSaveRepository.save(UserEntity(
name = name,
age = age,
birthDay = birth
))
}
}
...
interface UserSaveRepository: JpaRepository<UserEntity, Long>
...
@Entity
@Table(name = "user_info")
class UserEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
val id: Long? = null,
val name: String,
val age: Int,
val birthDay: String
)
만약 이런 상황에서 Table의 ID가 name과 age 조합의 문자열로 변경된다고 가정합시다.
예를 들면 key 값이 "bob-23"와 같은 형태입니다.
그럼 entity의 구현이 변경되어야 하고, 해당 entity와 repository에 강결합 되어 있는 비즈니스 로직에서 변경이 일어나게 됩니다.
@Entity
@Table(name = "user_info")
class UserEntity(
@Id
@Column(name = "id")
val id: String,
val name: String,
val age: Int,
val birthDay: String
)
...
@Service
class UserCommandService(
private val userSaveRepository: UserSaveRepository
) {
fun addUser(name: String, age: Int, birth: String) {
userSaveRepository.save(UserEntity(
id = "$name-$age",
name = name,
age = age,
birthDay = birth
))
}
}
또 다른 문제는 만약 Entity 객체를 도메인객체로 사용할 때 발생합니다.
JPA를 사용한다며 entity 객체를 도메인객체로 볼 것이냐? 아니면 도메인 객체 따로 entity 따로 사용할 것이냐?
이 부분은 앞에서 잠깐 언급했던 것과 같이 논란이 많은 부분입니다.
다만 이 글에서는 잠깐은 그 논란을 덮어두고 생각하겠습니다.
만약 entity 객체를 도메인객체로 사용한다면 테이블의 변경으로 인해 도메인 객체에도 변경이 발생한다는 문제가 발생합니다.
이렇게 된다면 변경의 이유는 한 가지여야 한다는 단일책임원칙에 위배되는 상황이 발생합니다.
도메인 객체는 비즈니스 로직이 변경되었을 때 책임이 변경되는 것이고, 이러한 이유에서만 변경이 일어나야 하는데
해당예제에서 발생한 외부적인 요소(테이블 변경)에 의해서 도메인 객체가 변경이 되고 있는 상황이 발생하게 됩니다.
헥사고날 아키텍처 적용
그렇다면 헥사고날 아키텍처에서 이러한 문제를 어떻게 해결할까?
결과부터 말하자면 인바운드 어뎁터와 인바운드 포트를 두는 방법과 비슷하게 처리를 하면 된다.
데이터를 저장하는 어뎁터를 작성하고 비즈니스 로직이 호출할 수 있도록 port를 작성하여 인터페이스를 제공해 주는 방식으로 변경이 가능하다.
변경 전 코드를 확인해 보겠습니다.
package com.demo.hexagonal.application.service
@Service
class UserCommandService(
private val userSavePort: UserSavePort
) {
fun addUser(name: String, age: Int, birth: String) {
userSavePort.saveUser(
User(
name = name,
age = age,
birthParam = birth
)
)
}
}
...
package com.demo.hexagonal.application.port.out
interface UserSavePort {
fun saveUser(user: User)
}
...
package com.demo.hexagonal.adapter.out.persistence
@Service
class UserSavePersistenceAdapter(
private val userSaveRepository: UserSaveRepository
): UserSavePort {
override fun saveUser(user: User) {
userSaveRepository.save(UserEntity.of(user))
}
}
...
interface UserSaveRepository: JpaRepository<UserEntity, Long>
...
package com.demo.hexagonal.adapter.out.persistence
@Entity
@Table(name = "user_info")
class UserEntity(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
val id: Long? = null,
val name: String,
val age: Int,
val birthDay: String
) {
companion object {
fun of(user: User): UserEntity {
return UserEntity(
name = user.name,
age = user.age,
birthDay = user.birth
)
}
}
}
우선 비즈니스 로직을 담당하는 UserCommandService에서는 UserPort라는 Port를 사용해서 user를 save 합니다.
그리고 UserPort는 UserSavePersistenceAdapter라는 Adapter가 구현하고 있습니다.
Adapter에서는 UserSaveRepository를 호출하여 저장 로직을 수행하고 있습니다.
만약 앞에 예제와 같이 키 값이 변경된다면 어떻게 될까요?
단순히 entity와 도메인을 매핑해 주는 부분만 변경해 준다면 손쉽게 변경에 대처할 수 있습니다.
package com.demo.hexagonal.adapter.out.persistence
@Entity
@Table(name = "user_info")
class UserEntity(
@Id
@Column(name = "id")
val id: String,
val name: String,
val age: Int,
val birthDay: String
) {
companion object {
fun of(user: User): UserEntity {
return UserEntity(
id = "${user.name}-${user.age}",
name = user.name,
age = user.age,
birthDay = user.birth
)
}
}
}
더 나아가서 만약 MySQL을 사용하다 elasticsearch와 같은 NoSQL로 변경한다고 가정해 봅시다.
앞에서 말씀드린 것과 같이 하나의 port에는 다양한 Adapter가 붙을 수 있기 때문에 UserSaveEsPersistenceAdapter와 같은 새로운 Adapter를 작성하고, elasticsearch에 데이터를 삽입하는 로직을 작성한다고 하면 domain 로직에는 전혀 영향을 주지 않고 데이터를 저장할 수 있습니다.
그렇다면 헥사고날의 문제점은?
하지만 헥사고날 아키텍처가 은총알이라고 생각하지는 않습니다.
실제 실무에서 사용하다 보면 느끼는 불편함들이 있습니다.
첫 번째로 작성해야 할 클래스 및 인터페이스가 증가한다는 것입니다.
위에서 보는 것과 같이 전토적인 계층화 아키텍처에서는 Controller - Service - Repository 3개의 클래스만 작성하여 로직을 구성하고 있습니다.
하지만 헥사고날 아키텍처를 적용하면 의존성 역전을 위해 ControllerAdapter - ServicePort - ServiceAdapter- PersistenceAdapter - Repository와 같은 여러 개의 class들과 interface들이 생성되게 됩니다.
또한 계층에서 계층으로 이동할 때 전달할 객체에 대한 맵핑코드가 추가적으로 발생하게 됩니다.
이것이 보일러플레이트 코드는 아니더라도 정말 간단한 조회용 로직을 작성할 때도 작성해 가며 개발을 해야 한다는 점이 큰 불편함으로 다가왔습니다.
두 번째로 깨끗한 상태로 시작할 책임에 대한 것입니다.
많은 개발자들이 함께 작업을 하다 보면 최초 의도한 것처럼 패키지 구조와 네이밍등이 지켜지지 않을 때가 있습니다.
헥사고날 아키텍처에서는 특히나 패키지 구조나 네이밍등이 중요하다고 생각합니다.
하지만 누구나 도메인 객체를 입출력으로 받고 싶기도 하고, 포트를 건너뛰어 로직을 작성하고 싶을 때도 있습니다.
물론 이것이 잘못은 아니지만, 꾸준하게 헥사고날 아키텍처를 유지하는데 조금의 귀찮음과 노력이 동반이 되는 것은 사실이라고 생각합니다.
마치며
실무에서 경험했던 문제점들을 헥사고날 아키텍처가 어느 정도 보완해 주었던 경험을 통해서 작성해 보았습니다.
다시 한번 말씀드리지만 절대적으로 헥사고날 아키텍처가 은총알은 아닙니다.
오히려 유연하지 못한 계층 구조로 생상성이나 개발에 문제점을 발생시킬 수 있습니다.
하지만 헥사고날에 대해 알고 있다면 필요할 때 또 다른 경우의 수를 생각해 낼 수 있다고 생각합니다.
감사합니다~!
참고 링크
https://engineering.linecorp.com/ko/blog/port-and-adapter-architecture/
'IT > 기타' 카테고리의 다른 글
2021년 한 해를 되돌아보며...(5년차 개발자 2021년 회고) (1) | 2022.01.21 |
---|---|
100일 커밋 회고 - (잔디를 심어보자) (0) | 2021.05.17 |
intellij Unexpected error (103) returned by AddToSystemClassLoaderSearch 문제 (1) | 2021.02.15 |
git repo user 변경 (0) | 2021.01.29 |
2020년 한 해를 되돌아보며... (0) | 2020.12.27 |