안녕세계
[헥사고날 아키텍처] 포트와 어댑터(Ports and Adapters) 본문
[헥사고날 아키텍처] 포트와 어댑터(Ports and Adapters)
Junhong Kim 2024. 11. 23. 10:38헥사고날 아키턱처란?
헥사고날 아키텍처는 애플리케이션 코어(비즈니스 로직)와 외부 요소(예: 웹, 데이터베이스, 외부 시스템)를 분리하기 위해 설계된 아키텍처입니다. 헥사고날 아키텍처는 시스템을 애플리케이션 코어와 외부 요소로 나누고, 포트(Ports)와 어댑터(Adapters)를 통해 애플리케이션 코어와 외부 요소를 연결합니다. 따라서, 포트와 어댑터 아키텍처(ports-and-adapters architecture)라고도 불립니다.
계층형 아키텍처와 헥사고날 아키텍처
계층형 아키텍처(Layered Architecture)
계층형 아키텍처는 웹, 도메인, 영속성 계층으로 구성된 애플리케이션 구조를 의미합니다. 계층형 아키텍처는 의존성이 상위 계층에서 하위 계층으로만 흐릅니다. 즉, 웹 계층은 도메인 계층에 의존하고, 도메인 계층은 영속성 계층에 의존하기 때문에 자연스럽게 데이터베이스에 의존하게 됩니다. 따라서, 계층형 아키텍처는 데이터베이스 주도 설계를 유도하게 됩니다.
헥사고날 아키텍처(Hexagonal Architecture)
헥사고날 아키텍처는 애플리케이션 코어 로직이 외부 요소(예: 웹, 데이터베이스, 외부 시스템)에 의존하지 않습니다. 모든 외부 요소는 포트를 통해 코어와 연결됩니다. 즉, 의존성이 역전되어 코어는 외부의 구현체를 알 필요가 없습니다. 헥사고날 아키텍처는 도메인 중심 설계를 지원하며, 의존성의 방향을 제어하여 외부 요소 변경에 유연하게 대응할 수 있습니다.
포트와 어댑터(Ports and Adapters)
포트(Ports)
포트는 애플리케이션 코어와 외부 요소를 연결하는 인터페이스(Interface)이며, 포트는 입력 포트(Input Port)와 출력 포트(Output Port)로 구분됩니다. 입력 포트는 애플리케이션이 외부로부터 요청을 받을 때 사용하는 인터페이스이고, 출력 포트는 애플리케이션이 외부에 요청을 보낼 때 사용하는 인터페이스 입니다.
// 입력 포트
interface CreateOrderUseCase {
fun execute(request: CreateOrderRequest): CreateOrderResponse
}
// 출력 포트
interface OrderRepository {
fun save(order: Order): Order
fun findById(id: Long): Order?
}
어댑터(Adapter)
어댑터는 포트와 외부 요소를 연결하는 구체적인 구현체입니다. 포트는 비즈니스 로직을 정의하고 외부와 상호 작용하는 인터페이스를 정의하지만, 실제로 외부 요소와 데이터를 주고받는 역할은 어댑터가 담당합니다.
입력 어댑터(Input Adapter)
입력 어댑터는 외부에서 들어오는 요청을 포트로 전달하는 역할을 합니다. 즉, 사용자 요청을 적절한 UseCase를 호출하여 비즈니스 로직을 호출합니다. 입력 포트를 UseCase라고 부르는 이유는, UseCase는 비즈니스 요구사항과 애플리케이션의 행동을 명확히 나타내기 때문입니다. 즉, 단순히 기술적인 용어인 포트라는 표현보다 비즈니스 맥락을 강조하며 개발자에게 역할과 의도를 더 잘 전달하기 위해 UseCase라는 용어를 사용합니다.
@RestController
class OrderController( // 입력 어댑터
private val createOrderUseCase: CreateOrderUseCase // 입력 포트(UseCase) 주입
) {
@PostMapping("/orders")
fun createOrder(@RequestBody request: CreateOrderRequest): ResponseEntity<CreateOrderResponse> {
// 어댑터는 HTTP 요청을 받아 UseCase를 호출
val response = createOrderUseCase.execute(request)
return ResponseEntity.ok(response)
}
}
출력 어댑터(Output Adapter)
출력 어댑터는 UseCase에서 정의된 출력 포트를 사용하여, 비즈니스 로직의 요청을 외부 시스템으로 전달합니다. 즉, 비즈니스 로직이 외부 요소와 상호작용이 필요할 때 출력 어댑터를 통해 처리합니다. 출력 포트는 데이터를 저장하거나, 외부 API를 호출하거나, 메시지를 발행하는 등 외부 요소와 상호작용하므로 UseCase보다는 OrderRepository, PaymentGateway, NotificationService와 같은 조금 더 구체적인 이름을 사용합니다.
// 출력 포트
interface OrderRepository {
fun save(order: Order): Order
}
// 출력 어댑터
@Repository
class OrderRepositoryImpl(
private val jdbcTemplate: JdbcTemplate
) : OrderRepository {
override fun save(order: Order): Order {
// SQL을 사용하여 데이터베이스에 저장
jdbcTemplate.update("INSERT INTO orders ...", order)
return order
}
}
// UseCase에서 출력 포트 사용
class CreateOrderService(
private val orderRepository: OrderRepository // 출력 포트
) : CreateOrderUseCase {
override fun execute(request: CreateOrderRequest): CreateOrderResponse {
val order = Order(request.userId, request.productId, request.quantity)
orderRepository.save(order) // 출력 어댑터 호출
return CreateOrderResponse(order.id, "SUCCESS")
}
}
CreateOrderUseCase는 비즈니스 로직의 흐름을 정의하고, 필요한 경우 OrderRepository라는 출력 포트를 호출합니다. 출력 어댑터(OrderRepositoryImpl)는 포트(OrderRepository)를 구현하여 구체적인 데이터베이스 저장 로직을 수행합니다. UseCase는 저장 로직의 구체적인 구현(어댑터)을 알 필요가 없으므로 데이터 저장소가 변경되어도 비즈니스 로직은 영향을 받지 않습니다.
UseCase(포트)와 어댑터
어댑터는 UseCase와 외부 요소를 연결하는 구현체이며, 입력 어댑터(OrderController)는 외부의 요청(User)을 UseCase로 전달하고, 출력 어댑터(OrderRepositoryImpl)는 UseCase의 요청을 외부(DB)로 전달합니다. UseCase는 비즈니스 로직을 중심으로 설계되므로, 어댑터는 이 로직을 기술적 세부사항과 분리하여 헥사고날 아키텍처의 유연성과 확장성을 보장합니다. 즉, 입력 어댑터와 출력 어댑터는 함께 작동하여 UseCase를 중심으로 외부 요소와 내부 비즈니스 로직을 연결합니다.
- 입력 어댑터: 외부의 요청을 받아 UseCase 실행
- 출력 어댑터: UseCase의 요청을 받아 외부 시스템과 상호작용
- UseCase: 비즈니스 로직을 정의하고 입력/출력 포트를 통해 어댑터와 상호작용
헥사고날 아키텍처에 대해 공부하다보면 입력 어댑터와 출력 어댑터와 관련된 또 다른 용어로 드라이빙 어댑터(Driving Adapter)와 드라이븐 어댑터(Driven Adapter)가 있습니다. 이 두 개념은 서로 같은 맥락에서 사용되지만, 바라보는 관점이 약간 다릅니다.
드라이빙 어댑터 (Driving Adapter)
- 드라이빙 어댑터는 외부에서 발생한 입력(사용자 요청, API 호출 등)을 받아 애플리케이션의 입력 포트(Input Port)를 호출합니다.
- 드라이빙 어댑터는 애플리케이션의 비즈니스 로직을 호출하여 애플리케이션의 동작을 주도(Drive)한다고 해서 드라이빙 어댑터라고 불립니다.
- 입력 어댑터 (Input Adapter)는 드라이빙 어댑터와 같은 개념으로 볼 수 있습니다. 외부에서 들어오는 요청을 받아 애플리케이션의 입력 포트를 호출하여 비즈니스 로직을 실행합니다.
- 입력 어댑터는 드라이빙 어댑터의 구체적인 구현체입니다. 예를 들어, API 요청을 처리하는 컨트롤러(OrderController)는 입력 어댑터이자 드라이빙 어댑터입니다.
@RestController
class OrderController( // 입력 어댑터 (드라이빙 어댑터의 구체적인 구현체)
private val createOrderUseCase: CreateOrderUseCase // 입력 포트(UseCase) 주입
) {
@PostMapping("/orders")
fun createOrder(@RequestBody request: CreateOrderRequest): ResponseEntity<CreateOrderResponse> {
// 어댑터는 HTTP 요청을 받아 UseCase를 호출
val response = createOrderUseCase.execute(request)
return ResponseEntity.ok(response)
}
}
드라이븐 어댑터 (Driven Adapter)
- 드라이븐 어댑터는 애플리케이션에서 출력 포트(Output Port)를 호출하여 외부 시스템과의 통신을 처리합니다.
- 드라이븐 어댑터는 애플리케이션의 요청에 의해 외부 시스템(데이터베이스 저장소, 외부 API 클라이언트 등)과의 통신을 처리하기 위해 구동(Driven)된다고 해서 드라이븐 어댑터라고 불립니다.
- 출력 어댑터 (Output Adapter)는 드라이븐 어댑터와 같은 개념으로 볼 수 있습니다. 애플리케이션의 출력 포트에서 요청을 받아 외부 시스템과 통신을 처리합니다.
- 출력 어댑터는 드라이븐 어댑터의 구체적인 구현체입니다. 예를 들어, 데이터베이스에 데이터를 저장하는 OrderRepositoryImpl은 출력 어댑터이자 드라이븐 어댑터입니다.
@Repository
class OrderRepositoryImpl( // 출력 어댑터 (드라이븐 어댑터의 구체적인 구현체)
private val jdbcTemplate: JdbcTemplate
) : OrderRepository {
override fun save(order: Order): Order {
// SQL을 사용하여 데이터베이스에 저장
jdbcTemplate.update("INSERT INTO orders ...", order)
return order
}
}
요약
1. 사용자 요청 → 입력 어댑터(드라이빙 어댑터)
- 입력 어댑터는 사용자의 요청을 받아 입력 포트를 호출
2. 입력 어댑터 → 입력 포트(UseCase) → 비즈니스 로직 실행
- 입력 어댑터에 의해 입력 포트가 호출되면서 UseCase(비즈니스 로직) 실행
3. 출력 포트 → 출력 어댑터(드라이븐 어댑터)
- 비즈니스 로직을 처리하는 과정에서 외부 시스템과 통신 필요시, 출력 포트를 호출하여 출력 어댑터로 외부 시스템과 통신