Architecture

헥사고날 아키텍처: 코드의 유연성과 확장성을 높이는 비결

Written by 개발자서동우 · 49 sec read >
헥사고날 아키텍처

안녕하세요! Devloo 입니다. 여러분은 혹시 헥사고날 아키텍처에 대해 들어보신 적이 있으신가요? 아마 처음이시라면, 이 글에서는 먼저 헥사고날 아키텍처에 대해 자세히 설명하고, 이를 기반으로 스프링 부트 애플리케이션을 만드는 방법을 다룰 예정입니다. 😃

계층형 아키텍처의 목표와 구조

전통적인 계층형 아키텍처의 목표는 애플리케이션을 여러 계층으로 분리하는 것입니다. 각 계층은 공통되거나 유사한 책임을 가진 모듈과 클래스로 구성되며, 특정 작업을 수행하기 위해 함께 작동합니다.

계층형 아키텍처에는 다양한 형태가 있으며, 몇 개의 계층이 있어야 한다는 규칙은 없습니다. 가장 일반적인 패턴은 3계층 (3 Tiers) 아키텍처로, 애플리케이션을 프레젠테이션 계층, 로직 계층, 데이터 계층으로 나누는 것입니다.

계층형 아키텍처(Layered Architecture)의 구조
계층형 아키텍처(Layered Architecture)의 구조 / 이미지 출처 : herbertograca.com

Eric Evans는 그의 책 “도메인 주도 설계: 소프트웨어 복잡성의 핵심 문제 해결“에서 도메인 계층(비즈니스 로직을 포함하는 계층)과 나머지 3개의 지원 계층(사용자 인터페이스, 애플리케이션, 인프라스트럭쳐)을 분리하는 4계층 아키텍처를 제안합니다.

도메일 주도 설계(DDD)가 적용된 계층형 아키텍처(Layered Architecture)
도메인 주도 설계(DDD)가 적용된 계층형 아키텍처(Layered Architecture)

계층형 아키텍처를 따르는 것은 여러 가지 면에서 유익합니다. 그중 가장 중요한 것은 관심사의 분리입니다. 하지만, 계층형 아키텍처는 종종 부작용이 발생합니다. 계층 간 로직이 혼합되는 것을 방지하는 자연스러운 매커니즘이 없기 때문에, 사용자 인터페이스에 비지니스 로직이 섞이거나 인프라 문제가 비지니스 로직에 섞이게될 가능성이 있습니다.

2005년, Alistair Cockburn은 사용자 인터페이스와 데이터베이스가 애플리케이션과 상호 작용하는 방식에 큰 차이가 없음을 깨달았습니다. 이들은 모두 유사한 구성 요소로 교체할 수 있는 ‘외부’ 액터이기 때문입니다. 이렇게 생각함으로써, 애플리케이션을 이러한 ‘외부’ 액터들과 독립적으로 유지하면서, 이들이 포트와 어댑터를 통해 상호 작용하도록 할 수 있습니다. 이를 통해 비즈니스 로직과 외부 구성 요소 간의 얽힘과 비지니스 로직의 누출을 방지할 수 있습니다.

이 글에서는 헥사고날 아키텍처의 주요 개념, 이점 및 단점을 안내하고, 이 패턴을 프로젝트에서 어떻게 활용할 수 있는지 간단하게 설명하겠습니다.

헥사고날 아키텍처란?

헥사고날 아키텍처(Ports and Adapters 라고도 불립니다)는 사용자가나 외부 시스템의 입력을 어댑터를 통해 포트로 받아들이고, 애플리케이션의 출력을 어댑터를 통해 포트를 거쳐 외부로 내보내는 아키텍처 패턴입니다. 이렇게 하면 애플리케이션의 핵심을 보호하고 외부의 관련 없는 도구와 기술로부터 격리할 수 있는 추상화 계층이 만들어집니다.

헥사고날 아키텍처의 개념
헥사고날 아키텍처의 개념
포트

포트는 기술에 구애받지 않는 진입점으로 볼 수 있습니다. 포트는 ‘외부’ 액터들이 애플리케이션과 통신할 수 있도록 인터페이스를 결정하며, 그 인터페이스를 누가 또는 무엇이 구현할지는 중요하지 않습니다. USB 포트가 USB 어댑터만 있으면 여러 종류의 장치가 컴퓨터와 통신할 수 있는 것과 마찬가지입니다. 포트는 또한 애플리케이션이 데이터베이스, 메시지 브로커, 다른 애플리케이션 등 외부 시스템이나 서비스와 통신할 수 있게 합니다.

(참고: 포트에는 항상 두 개의 항목이 연결되어 있어야 하며, 그 중 하나는 항상 테스트여야 합니다.)

어댑터

어댑터는 특정 기술을 사용하여 포트를 통해 애플리케이션과의 상호 작용을 시작합니다. 예를 들어, REST 컨트롤러는 클라이언트가 애플리케이션과 통신할 수 있게 하는 어댑터의 한 예입니다. 하나의 포트에는 필요한 만큼 많은 어댑터를 추가할 수 있으며, 이는 포트나 애플리케이션에 아무런 문제가 되지 않습니다.

애플리케이션

애플리케이션은 시스템의 핵심으로, 애플리케이션 서비스가 포함되어 있어 기능이나 유스 케이스(Use Case)를 조정합니다. 또한 애플리케이션에는 애그리거트, 엔티티, 그리고 값 객체에 내재된 비즈니스 로직을 포함하는 도메인 모델이 포함되어 있습니다. 애플리케이션은 명령(Command)이나 쿼리(Query)를 포트로부터 받고, 포트를 통해 데이터베이스와 같은 외부 요소에 요청을 보내는 육각형 형태로 표현됩니다.

헥사고날 아키텍처의 구조
헥사고날 아키텍처의 구조

도메인 주도 설계(Domain-Driven Design)와 결합되었을 때, 애플리케이션 또는 육각형은 애플리케이션과 도메인 계층을 모두 포함하며, 사용자 인터페이스와 인프라 계층은 외부에 위치하게 됩니다.

왜 Hexagonal(육각형)인가?

알리스터가 육각형을 사용하자는 아이디어를 낸 이유는 애플리케이션이 가질 수 있는 다양한 포트/어댑터 조합을 시각적으로 표현하기 위해서입니다. 또한 애플리케이션의 왼쪽, 즉 ‘구동 측면'(Driving Side)이 오른쪽, ‘구동되는 측면'(Driven Side)과 어떻게 다른 상호작용과 구현 방식을 가지는지를 보여주기 위함입니다. 해당 부분에 대해서 조금 더 자세히 설명드리도록 하겠습니다.

드라이빙 사이드 vs 드리븐 사이드

Driving (또는 ‘구동’) 액터는 상호작용을 시작하는 주체로, 항상 왼쪽에 위치합니다. 예를 들어, 드라이빙 어댑터는 컨트롤러일 수 있으며, 이는 사용자 입력을 받아 포트를 통해 애플리케이션에 전달합니다.

Driven (또는 구동되는) 액터는 애플리케이션에 의해 행동을 시작하는 주체입니다. 예를 들어, 데이터베이스 어댑터는 애플리케이션에 의해 호출되어 특정 데이터를 가져옵니다.

구현에 있어 중요한 몇 가지 세부 사항이 있습니다:

  1. 포트는 (선택한 언어에 따라 대부분) 코드에서 인터페이스로 표현됩니다.
  2. 드라이빙 어댑터는 포트를 사용하고, 애플리케이션 서비스는 포트가 정의한 인터페이스를 구현합니다. 이 경우 포트의 인터페이스와 구현은 모두 육각형 내부에 있습니다.
  3. 드리븐 어댑터는 포트를 구현하고, 애플리케이션 서비스는 이를 사용합니다. 이 경우 포트는 육각형 내부에 있지만, 구현은 어댑터에 있어 육각형 외부에 위치합니다.
헥사고날 아키텍처의 자세한 구조
헥사고날 아키텍처의 자세한 구조

헥사고날 아키텍처의 의존 역전 원칙

의존 역전 원칙은 Bob Martin이 그의 논문 “OO Design Quality Metrics“와 이후 책 “Agile Software Development Principles, Patterns and Practices“에서 제시한 5가지 원칙 중 하나입니다. 그는 이를 다음과 같이 정의합니다:

  • 고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.
  • 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.

앞서 언급했듯이, 육각형의 왼쪽과 오른쪽에는 각각 Driving 및 Driven 두 가지 유형의 액터가 있으며, 포트와 어댑터가 존재합니다.

Driving 측면에서 어댑터는 포트에 의존하며, 이는 애플리케이션 서비스에 의해 구현됩니다. 따라서 어댑터는 자신이 호출하는 대상이 누구인지 알 필요는 없고, 단지 사용할 수 있는 메서드만 알면 됩니다. 이는 추상화에 의존하는 것입니다.

Driven 측면에서는 애플리케이션 서비스가 포트에 의존하고, 어댑터가 포트의 인터페이스를 구현합니다. 이는 의존성을 역전시키는 효과가 있으며, ‘저수준’ 어댑터(예: 데이터베이스 저장소)가 애플리케이션의 핵심에서 정의된 ‘고수준’ 추상화를 구현하도록 강제합니다.

포트와 어댑터를 사용해야 하는 이유

포트와 어댑터 아키텍처를 사용하는 데는 많은 장점이 있습니다. 그 중 하나는 애플리케이션 로직과 도메인 로직을 완전히 격리하여 완전하게 테스트할 수 있다는 점입니다. 외부 요인에 의존하지 않기 때문에 테스트가 자연스럽고, 의존성을 Mocking 하기도 쉽습니다.

또한, 기술이 아닌 목적에 따라 시스템의 모든 인터페이스를 설계할 수 있게 해주어, 특정 기술에 종속되는 것을 방지하고, 애플리케이션의 기술 스택을 시간에 따라 쉽게 진화시킬 수 있습니다. 예를 들어, 데이터 저장소 계층을 변경해야 한다면, 그냥 바꾸면 됩니다. 사람 대신 슬랙 봇이 애플리케이션을 호출할 수 있게 해야 한다면, 새로운 어댑터를 구현하기만 하면 됩니다.

포트와 어댑터 아키텍처는 도메인 주도 설계와도 매우 잘 어울립니다. 이 아키텍처의 주요 장점은 도메인 로직이 애플리케이션의 코어에서 누출되지 않도록 보호한다는 점입니다. 단, 애플리케이션 계층과 도메인 계층 간의 로직 누출에 주의해야 합니다.

애플리케이션 구조화 및 코드 예제

아래 예제에서는 가상의 이커머스 애플리케이션에서 주문을 생성하는 요청을 처리하는 서비스를 매우 간단하게 구현해보겠습니다.

예제에서는 기본 개념에 집중하기 위해 에러 처리와 적절한 네이밍 등 잘 작성된 코드의 중요한 부분을 의도적으로 생략했음을 유의해 주세요.

포트와 어댑터를 기반으로 애플리케이션을 구조화할 때, 도메인 주도 설계의 계층 아키텍처의 모든 계층이 여전히 적합하다는 점을 강조하는 것이 중요합니다. 이는 모든 구성 요소를 이상적으로 분리해줍니다.

아래와 같이, 애플리케이션에서 컨트롤러는 Driving 어댑터 역할을 하며, 이는 Driving 포트를 사용합니다.

/**
 * Driving Adapter
 * Location: src/user-interface/adapter/OrderAdapter.kt
 */
class OrderAdapter(private val orderService: DrivingPort) : HttpRequestHandler() {

    suspend fun createOrder(req: Request): Response {
        val createOrderCommand = CreateOrderCommand(req)
        val orderResult = orderService.handle(createOrderCommand)

        return createResponse(orderResult)
    }

    private fun createResponse(orderResult: OrderResult): Response {
        // Implement response creation logic here
        return Response(orderResult)
    }
}

Driving 포트는 애플리케이션 계층, 즉 육각형 내에 있는 인터페이스입니다.

/**
 * Driving Port
 * Location: src/domain/port/DrivingPort.kt
 */
interface DrivingPort {

    suspend fun handle(command: OrderCommand): Boolean
}

애플리케이션 서비스가 Driving 포트를 구현합니다.

/**
 * Application service implementing Driving Port
 * Location: src/application/service/OrderService.kt
 */
class OrderService(private val orderRepository: DatabasePort) : DrivingPort {

    private lateinit var order: Order

    override suspend fun handle(command: CreateOrderCommand): Boolean {
        order = Order.create(command)

        return try {
            orderRepository.save(order)
        } catch (error: Exception) {
            false
        }
    }
}

보시다시피, 애플리케이션 서비스는 조정 역할을 하는 구성 요소로서 Driven 포트를 사용하게 됩니다.

/**
 * Driven Port
 * Location: src/domain/port/DatabasePort.kt
 */
interface DatabasePort {

    suspend fun save(aggregate: Aggregate): Boolean
}

마지막으로, Driven 포트는 Driven 어댑터에 의해 구현됩니다.

/**
 * Order repository implementing Driven Port
 * Location: src/infrastructure/repository/OrderRepositoryAdapter.kt
 */
class OrderRepositoryAdapter : Repository(), DatabasePort {

    override suspend fun save(order: Aggregate): Boolean {
        return insert(order)
    }

    private suspend fun insert(order: Aggregate): Boolean {
        // 실제 데이터베이스 삽입 로직을 여기에 구현하세요
        return true // 예제의 단순한 반환값입니다.
    }
}

마무리

헥사고날 아키텍처, 또는 포트와 어댑터 아키텍처는 모든 애플리케이션에 대한 만능 해결책이 아닙니다. 이 아키텍처는 일정 수준의 복잡성을 포함하고 있으며, 신중하게 다루면 시스템에 큰 이점을 제공할 수 있습니다. 하지만 관리가 소홀해지면 많은 골칫거리를 초래할 수 있습니다.

포트와 어댑터 아키텍처를 도메인 주도 설계와 같은 다른 방법론과 함께 올바르게 구현하면 애플리케이션의 장기적인 안정성과 확장성을 보장할 수 있으며, 시스템과 기업에 큰 가치를 가져다줄 수 있습니다.

이번 시간에는 헥사고날 아키텍처를 알아보는 시간을 갖았습니다.

글이 꽤 길었는데, 끝까지 읽어주셔서 감사합니다. 궁금하신 사항은 편하게 댓글 남겨주세요 !! 🙂

Written by 개발자서동우
안녕하세요! 저는 기술 분야에서 활동 중인 개발자 서동우입니다. 명품 플랫폼 (주)트렌비의 창업 멤버이자 CTO로 활동했으며, AI 기술회사 (주)헤드리스의 공동 창업자이자 CTO로서 역할을 수행했습니다. 다양한 스타트업에서 일하며 회사의 성장과 더불어 비즈니스 상황에 맞는 기술 선택, 개발팀 구성 및 문화 정착에 깊은 경험을 쌓았습니다. 개발 관련 고민은 언제든지 편하게 연락주세요 :) https://linktr.ee/dannyseo Profile

Leave a Reply

Your email address will not be published. Required fields are marked *