소프트웨어 아키텍처 패턴

헥사고날 아키텍처와 도메인 계층 – 주문 시스템 예제로 배우기

silbaram 2025. 6. 22. 21:34
728x90

 

 

헥사고날 아키텍처 개념 요약

헥사고날 아키텍처(Hexagonal Architecture)는 포트와 어댑터(Ports & Adapters) 아키텍처라고도 불리며, 애플리케이션의 핵심 비즈니스 로직(도메인)을 인프라스트럭처로부터 분리하는 것을 목표로 하는 아키텍처 패턴입니다. 전통적인 레이어드 아키텍처처럼 계층으로 구성되지만, 의존성 방향이 안쪽(도메인)으로만 흐르도록 강제합니다. 다시 말해, 도메인 계층은 UI나 데이터베이스 같은 외부 기술을 전혀 참조하지 않고(의존하지 않고), 오직 자신의 비즈니스 규칙만을 표현합니다. 이렇게 하면 도메인 로직을 외부 변화에 독립적으로 유지할 수 있어 시스템의 유지보수성, 유연성, 테스트 용이성이 향상됩니다.

헥사고날 아키텍처 다이어그램. 육각형 중심에 Entity(도메인 모델)이 위치하고, 그 주위에 Use Case(응용 서비스)가 배치되어 도메인 로직을 처리합니다. 외부 세계와의 모든 통신은 좌측의 입력 포트(Input Port)와 우측의 출력 포트(Output Port)를 통해 이루어지며, 웹이나 외부 시스템 등의 어댑터들이 이 포트를 경유하여 도메인과 상호작용하는 구조입니다.

헥사고날 아키텍처의 핵심은 비즈니스 로직을 중심에 둔 도메인 중심 설계입니다. 엔티티 등 도메인 모델 객체가 애플리케이션의 중심에 있으며, 이들에 접근하려면 반드시 포트 계층을 통해야 합니다. 이러한 구조에서는 의존성 역전(Dependency Inversion) 원리가 적용되어, 외부 계층이 내부 도메인 계층의 추상(인터페이스)에 의존하게 됩니다. 결과적으로 도메인 계층은 외부를 전혀 모르고(내부 레이어는 바깥 레이어를 알아서는 안된다), 반대로 인프라 계층이나 UI 계층은 도메인에 의존하게 되어 의존성 방향이 한쪽(밖→안)으로만 흐릅니다.

요약하면, 헥사고날 아키텍처는 도메인 모델(비즈니스 로직)을 외부(World)로부터 격리하여 보호하는 아키텍처입니다. 이를 통해 도메인 로직만 따로 쉽게 테스트할 수 있고, 외부 구현(detail)을 유연하게 교체하거나 확장할 수 있습니다. 예를 들어 데이터베이스나 UI가 변경돼도 도메인 로직은 영향을 적게 받고, 새로운 기능 확장 시에도 도메인을 건드리지 않거나 최소한으로 변경하게 되어 유지보수가 용이해집니다.

도메인 계층의 정의와 역할

도메인 계층(Domain Layer)은 시스템이 제공해야 하는 핵심 비즈니스 규칙과 도메인 로직을 구현하는 부분입니다. 일반적인 4계층 아키텍처에서 표현/응용/도메인/인프라로 나눌 때, 도메인 계층은 “무엇을 해야 하는가”에 해당하는 영역으로, 업무 규칙(비즈니스 규칙)을 실제 코드로 표현합니다. 예를 들어 온라인 쇼핑몰의 도메인 계층은 주문, 결제, 배송, 상품 등의 도메인 개념과 그들의 규칙(예: “결제 완료 후에만 배송 시작 가능”) 등을 코드로 구현합니다. 도메인 계층은 사용자의 유스케이스를 충족하기 위한 핵심 로직을 담고 있으며, 데이터베이스나 UI와 같은 기술 세부사항은 포함하지 않습니다.

도메인 계층은 객체 지향 기법으로 도메인 모델을 구현하는데, 이를 흔히 도메인 모델 패턴이라고 부릅니다. 도메인 모델은 해당 도메인의 주요 개념(Entity, Value Object 등)과 행위(Operations)를 객체로 표현한 것입니다. 중요한 점은 도메인 모델 객체들은 스스로 비즈니스 규칙을 가지고 있어야 한다는 것입니다. 도메인 객체 내부에 검증 로직이나 상태 변화 로직을 구현함으로써, 비즈니스 규칙이 도메인 계층에 응집되게 합니다. 이렇게 하면 규칙이 변경될 때 해당 도메인 객체만 수정하면 되므로, 다른 계층에 영향을 주지 않고 유연하게 변화에 대응할 수 있습니다.

예를 들어, “배송지 변경은 배송 출발 전까지만 가능하다”는 규칙이 있다면, 이 로직을 Order 엔티티의 메서드로 구현합니다. 도메인 계층 외부(예: UI나 DB 코드)에서는 Order 객체의 changeShippingInfo() 메서드를 호출하기만 하면 되고, 그 내부에서 스스로 현재 상태를 검사하여 규칙을 적용합니다. 이처럼 도메인 계층은 해당 도메인의 용어(Ubiquitous Language)와 규칙을 가장 직접적으로 표현하는 계층이며, 시스템의 심장부라고 할 수 있습니다.

정리하면, 도메인 계층의 역할은 도메인 개념과 비즈니스 로직의 구현입니다. 헥사고날 아키텍처에서는 이 도메인 계층이 가장 안쪽(core)에 위치하며, 외부 인프라로부터 완전히 독립적이고 순수한 상태로 존재하게 됩니다. 따라서 도메인 계층의 코드는 데이터베이스, 프레임워크(Spring 등), UI 등에 대한 의존이 없고, 오직 비즈니스 의미에만 집중합니다.

주문 시스템에서의 도메인 모델 예시

이제 실제 예제로 “주문 시스템(Order System)”의 도메인 모델을 살펴보겠습니다. 온라인 쇼핑몰을 모델로 가정하면, 주요 도메인 객체로 주문(Order), 상품(Product), 고객(Customer) 등을 생각해볼 수 있습니다. 이들은 해당 영역의 엔티티(Entity)로서 고유한 식별자와 라이프사이클을 가지며, 서로 관계를 맺고 비즈니스 규칙을 구현합니다.

  • Order (주문): 고객이 상품을 구매한 주문 정보를 나타내는 엔티티입니다. Order에는 고유 식별자인 주문 ID와 함께, 주문한 고객 정보, 주문 항목 목록(OrderItem), 주문 상태(OrderState), 배송지 정보 등이 포함됩니다. Order 엔티티는 “주문 생성”, “배송지 변경”, “결제 완료”, “주문 취소” 등 자신과 관련된 행동을 메서드로 제공합니다. 예를 들어 Order는 changeShippingInfo(newInfo) 메서드로 배송지 정보를 변경하며, 이때 현재 주문 상태가 배송 전에만 가능하도록 자체적으로 검증합니다 (배송 출발 이후 상태라면 예외 발생). 또한 Order는 calculateTotalAmounts() 메서드로 주문 전체 금액을 계산하는 로직도 갖습니다 – 각 상품의 가격과 수량에 따라 합계를 구하고, 최소 한 개 이상의 상품이 있어야 함을 검증합니다.
  • Product (상품): 판매되는 상품을 나타내는 엔티티입니다. Product에는 상품 ID, 이름, 가격, 재고 등 정보가 있으며, 상품 자체에 대한 도메인 로직(예: 할인 적용, 재고 감소 등)이 있을 수 있습니다. 주문 도메인 관점에서 Product는 Order의 구성 요소로 사용되어, Order 내의 OrderItem이 참조하는 대상입니다. 일반적으로 Product는 상품 관리(Subdomain) 컨텍스트의 핵심 엔티티이기도 하지만, 여기서는 주문과 연관된 개념으로 간략히 다룹니다.
  • Customer (고객): 주문을 하는 사용자(고객)를 나타내는 엔티티입니다. Customer에는 고객 ID, 이름, 등급(예: 일반, VIP) 등 정보가 있으며, 고객 등급이나 상태에 따라 주문 프로세스에 영향을 줄 수 있습니다(예: VIP 고객은 할인율 적용 등). Customer 엔티티도 “회원 등급 변경” 등의 자신만의 도메인 로직을 가질 수 있지만, 주문 도메인 예시에서는 주로 주문의 주체로서 쓰입니다.

위의 엔티티들은 함께 도메인 모델을 구성하며, 하나의 주문 시나리오를 실현합니다. 예를 들어, 고객이 상품을 선택하여 주문을 생성하면, Order 엔티티가 생성되어 Customer와 Product 정보를 포함하고, 초기 상태는 “결제 대기(PAYMENT_WAITING)”로 설정될 것입니다. 결제가 완료되면 Order 객체의 completePayment() 메서드를 호출하여 상태를 “준비중(PREPARING)”으로 바꾸고, 이후 배송이 시작되면 ship() 메서드로 “배송중(SHIPPED)” 상태로 변경하는 식입니다. 이러한 상태 전이와 검증 로직이 도메인 엔티티 내부에 구현되어 있으면, 비즈니스 규칙(예: “배송 중 상태에서는 배송지를 변경할 수 없음”)이 자연스럽게 강제됩니다. 아래는 Kotlin으로 표현한 간단한 Order 엔티티 예시 코드입니다:

enum class OrderState {
    PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETE;
    fun isShippingChangeable(): Boolean =
        this == PAYMENT_WAITING || this == PREPARING
}

data class Order(
    val id: OrderId,
    val orderer: Customer,
    var shippingInfo: ShippingInfo,
    val items: List<OrderItem>,
    var state: OrderState = OrderState.PAYMENT_WAITING
) {
    init {
        require(items.isNotEmpty()) { "최소 한 개의 상품이 포함되어야 합니다." }  // :contentReference[oaicite:22]{index=22}
        calculateTotalAmounts()  // 주문 총액 계산
    }

    fun changeShippingInfo(newInfo: ShippingInfo) {
        if (!state.isShippingChangeable()) {
            throw IllegalStateException("배송 출발 이후에는 배송지를 변경할 수 없습니다.")
        }
        this.shippingInfo = newInfo
    }

    fun calculateTotalAmounts(): Money {
        val total = items.sumOf { it.totalPrice().amount }
        return Money(total)  // Money 값 객체로 총액 표현
    }

    fun completePayment() {
        // 결제 완료 처리 로직 (예: 결제 완료 시 상태 변경)
        this.state = OrderState.PREPARING
    }

    // 기타 도메인 기능 예: 주문 취소, 배송 시작, 등
}

위 코드에서 Order 엔티티는 스스로 불변식(Invariant)과 규칙을 지키도록 구현되어 있습니다. init 블록에서 주문 항목이 최소 하나 이상 존재하는지 확인하고 (require(items.isNotEmpty())), 총 금액을 계산합니다. changeShippingInfo 메서드는 현재 상태가 배송 가능 상태인지(isShippingChangeable) 체크하여, 규칙을 어길 경우 IllegalStateException을 발생시킵니다. OrderState 열거형은 각 상태에서 배송지 변경 가능 여부를 판단하는 도메인 규칙을 캡슐화하고 있습니다 (PAYMENT_WAITING이나 PREPARING 상태에서만 true 반환). 이러한 방식으로 주요 비즈니스 로직이 도메인 모델 안에 녹아들도록 구현하면, 규칙 변경 시 이 부분만 수정하면 되므로 유지보수가 쉬워지고 코드의 의미가 분명해집니다.

또한 calculateTotalAmounts 메서드는 모든 OrderItem의 가격 합계를 구하여 Money 값 객체로 반환합니다. Order 엔티티는 내부에 totalAmounts 프로퍼티를 가질 수도 있지만, 여기서는 계산 메서드로 표현했습니다. 이처럼 도메인 모델은 데이터와 그 데이터와 관련된 행동을 한곳에 묶어 객체로 표현하며, 객체 간 협력을 통해 요구사항을 수행합니다. 결과적으로, 도메인 모델을 보면 해당 도메인의 업무 흐름과 규칙을 이해할 수 있게 됩니다.

도메인 서비스, 엔티티, 값 객체의 구분과 예제 코드

도메인 모델을 구성할 때는 엔티티(Entity), 값 객체(Value Object), 도메인 서비스(Domain Service)의 역할을 구분하여 설계합니다. 이는 도메인 주도 설계(DDD)의 기본 원칙 중 하나로, 각각의 차이를 명확히 이해하는 것이 중요합니다. 아래에서는 이 세 가지 개념을 정의하고, Kotlin 예제 코드와 함께 살펴보겠습니다.

  • 엔티티(Entity): 엔티티는 고유 식별자(ID)를 가져서 동일성(identity)을 판단할 수 있는 객체입니다. 엔티티는 생성 시부터 삭제될 때까지 식별자가 유지되며, 식별자가 같으면 동일한 엔티티로 간주합니다. 예를 들어 Order, Product, Customer는 각각 orderId, productId, customerId 등의 ID로 구분되는 엔티티입니다. 엔티티는 보통 변경 가능한 상태를 가지며, 도메인 내에서 상태 변화 로직을 포함합니다. 두 엔티티 객체를 비교할 때는 ID로 비교하고, Kotlin의 data class를 사용할 경우 모든 프로퍼티 대신 ID만으로 동등성을 판단하도록 equals/hashCode를 재정의하기도 합니다.
    data class Product(
        val id: ProductId,
        val name: String,
        var price: Money
    ) {
        fun changePrice(newPrice: Money) {
            this.price = newPrice
        }
        // 필요 시 할인 적용 등의 도메인 로직 추가
    }
    
    위 Product 엔티티는 간단히 값만 담고 있지만, 상황에 따라 changePrice처럼 자신의 상태를 변경하는 도메인 로직을 가질 수 있습니다. 핵심은 id 필드를 통해 각각의 Product 인스턴스를 구별하며, 영속화 시에도 이 ID를 활용한다는 점입니다.
  • 엔티티 예시: Product 엔티티를 Kotlin으로 표현하면 다음과 같습니다. 상품명과 가격 등 속성을 가지며, 고유 ID로 구분됩니다.
  • 값 객체(Value Object): 값 객체는 고유 식별자가 없고 값으로만 비교되는 객체입니다. 값 객체는 개념적으로 완전한 하나의 값을 표현하는데 사용되며, 불변(immutable)으로 설계하는 것이 일반적입니다. 값 객체는 변경 불가능하기 때문에 한 번 생성되면 내부 속성이 바뀌지 않고, 동일한 값이면 언제나 동등(equal)합니다. 대표적인 예로 금액(Money), 주소(Address), 날짜(Date) 등이 값 객체로 설계될 수 있습니다. 값 객체의 이점은 개념을 명시적으로 표현하고, 관련 행위를 추가할 수 있다는 것입니다. 예를 들어 Money 값 객체에 금액 합산이나 통화 변환 로직을 메서드로 추가할 수 있습니다.
    @JvmInline
    value class Money(val amount: Long) {
        operator fun plus(other: Money) = Money(this.amount + other.amount)
        fun multiply(factor: Double) = Money((this.amount * factor).toLong())
    }
    
    위 Money 값 객체는 amount라는 Long 값을 감싸며, + 연산자 오버로드와 multiply 메서드를 통해 금액 계산 기능을 제공합니다. 불변 객체로서 내부 상태를 변경하는 setter가 없고, 더하거나 곱한 결과를 새로운 Money 인스턴스로 반환합니다. 두 Money 객체의 동등성은 금액 값(amount)이 같은지로 판단하면 되고, Kotlin의 값 클래스(value class)를 사용하여 효율적으로 표현했습니다. 다른 값 객체 예로 Address를 들면, city, street 등의 필드를 가지고 동일한 주소 값인지 비교할 때 모든 필드 값이 동일한지 확인합니다. 값 객체를 사용하면 도메인 코드를 더 의미 있게 만들 수 있습니다. 예를 들어 Order.totalAmounts를 Money 타입으로 표현하면 단순한 숫자 타입보다 의도가 분명해지고, Money 타입 내에서 통화나 단위 일관성을 유지하는 로직을 구현할 수도 있습니다.
  • 값 객체 예시: Money 클래스를 Kotlin으로 만들어보겠습니다. 금액을 나타내는 값으로, 덧셈 등의 연산을 도메인 메서드로 제공합니다.
  • 도메인 서비스(Domain Service): 도메인 서비스는 특정 엔티티에 속하지 않거나 둘 이상의 엔티티를 필요로 하는 비즈니스 도메인 로직을 제공하는 객체입니다. 엔티티 자체에 넣기 애매한 복잡한 규칙이나, 여러 엔티티/값 객체를 조합해야 나오는 도메인 연산의 경우 도메인 서비스를 활용합니다. 예를 들어 “할인 계산” 로직을 생각해보겠습니다. 할인 금액은 상품, 쿠폰, 회원 등급, 구매 금액 등 여러 조건에 따라 결정될 수 있습니다. 이처럼 한 엔티티에 속하기 어려운 도메인 규칙은 별도의 도메인 서비스로 구현하는 것이 깔끔합니다. 도메인 서비스는 순수한 도메인 로직만 포함하고, 보통 상태 없이 함수 형식으로 표현됩니다. (일반 클래스로 만들거나, 경우에 따라 싱글톤/컴패니언 오브젝트로 두기도 합니다.)
    class DiscountPolicy {
        fun calculateDiscount(order: Order, customer: Customer): Money {
            val total = order.calculateTotalAmounts()
            // 예: GOLD 등급 회원은 10% 할인
            val membershipDiscount = if (customer.membershipLevel == MembershipLevel.GOLD) {
                total.multiply(0.10)
            } else Money(0)
            // TODO: 쿠폰 할인 등 다른 조건들 반영
            return membershipDiscount
        }
    }
    
    위 DiscountPolicy에서는 Order 엔티티와 Customer 엔티티를 모두 사용하여 비즈니스 규칙을 구현했습니다. GOLD 등급 고객에게 10% 할인을 적용하는 로직을 예시로 넣었으며, 쿠폰 할인 같은 추가 조건도 고려할 수 있을 것입니다. 이처럼 도메인 서비스는 여러 도메인 객체를 활용한 계산이나 검증 로직을 담는 그릇입니다. 도메인 서비스는 순수한 비즈니스 연산만을 수행하고, 자체적으로 상태를 가지지 않으며, 다른 서비스나 리포지토리에 의존하지 않는 것이 이상적입니다. (만약 도메인 서비스에서 DB 조회가 필요하다면, 그것은 응용 서비스도메인 로직을 위한 리포지터리 호출로 분리하는 편이 좋습니다.)
  • 도메인 서비스 예시: 할인 정책을 계산하는 DiscountPolicy 도메인 서비스를 만들어 보겠습니다. 이 서비스는 Order와 Customer 정보를 이용해서 할인 금액을 계산한다고 가정합니다:

정리하면, 엔티티는 식별자와 생명주기를 갖는 도메인 객체, 값 객체는 의미 있는 값을 표현하고 불변인 객체, 도메인 서비스는 엔티티/값 객체에 걸친 도메인 로직을 처리하는 객체입니다. 이러한 구분을 명확히 하면 도메인 모델을 풍부하게 만들고 유지보수를 쉽게 합니다. 코드를 작성할 때 “이 로직이 한 엔티티의 책임인가? 아니면 여러 객체에 걸친 규칙인가?”를 고민하여, 전자면 엔티티 메서드로, 후자면 도메인 서비스로 구현하면 됩니다.

포트와 어댑터: 개념 설명 및 도메인과의 연결

헥사고날 아키텍처에서 포트(Port)와 어댑터(Adapter)는 핵심 개념입니다. 앞서 도메인 계층이 외부와 단절되어 있다고 했는데, 현실적으로 애플리케이션은 입력(Input)을 받아서 도메인 로직을 실행하고, 결과를 출력(Output)하거나 외부 자원(DB, API 등)에 접근해야 합니다. 이 때 필요한 것이 포트와 어댑터입니다.

  • 포트(Port): 포트는 도메인과 외부를 잇는 인터페이스입니다. 쉽게 말해, 도메인 계층에서 정의한 추상적인 기능으로, 구현은 모릅니다. 포트에는 두 가지 방향이 있는데, 입력 포트(Input Port)는 외부에서 도메인(또는 응용 서비스)을 호출하기 위한 인터페이스이고, 출력 포트(Output Port)는 도메인에서 외부 자원(DB, 메시지 시스템 등)을 호출하기 위한 인터페이스입니다. 입력 포트의 예로는 “주문 생성” 유스케이스 인터페이스가 될 수 있고, 출력 포트의 예로는 “주문 저장소(Repository)” 인터페이스가 될 수 있습니다. 포트는 DIP(Dependency Inversion Principle)를 적용하기 위한 추상화 계층으로 이해하면 됩니다.
  • 어댑터(Adapter): 어댑터는 포트 인터페이스를 실제로 구현하여 외부 세계와의 연결을 담당하는 클래스입니다. 예를 들어 웹 어댑터는 입력 포트를 구현하여 HTTP 요청을 받아 도메인 로직을 호출하는 역할을 하고, 데이터베이스 어댑터는 출력 포트를 구현하여 DB와의 연동을 수행합니다. 어댑터에는 프라이머리(Primary) 어댑터세컨더리(Secondary) 어댑터 개념이 있는데, 프라이머리 어댑터는 사용자/외부 시스템으로부터 들어오는 신호를 처리하는 쪽(UI, Controller 등이 해당)이고, 세컨더리 어댑터는 도메인 로직 수행 중에 나가는 요청을 처리하는 쪽(DB, 외부 API 호출 등이 해당)입니다. 결국 어댑터들은 기술적인 세부사항을 처리하는 곳이고, 포트를 매개로 도메인과 통신합니다. 이때 어댑터는 구체 기술(JPA, REST, gRPC 등)에 의존하지만, 도메인 계층은 어댑터가 아닌 포트만 알기 때문에 여전히 기술에 독립적입니다.

포트와 어댑터가 도메인과 연결되는 방식을 간단한 주문 예시로 설명해보겠습니다. 가령, 주문 생성 흐름은 다음과 같은 구성을 가질 수 있습니다:

  • 입력 포트: PlaceOrderUseCase라는 인터페이스를 정의합니다. 이 인터페이스는 애플리케이션이 제공하는 “주문하기” 기능의 추상화로, 메서드로 placeOrder(command: PlaceOrderCommand): Order 등을 가질 것입니다. 도메인 계층이나 응용 계층에 위치하며, 유스케이스의 명세를 나타냅니다.
  • 응용 서비스 (유스케이스 구현): PlaceOrderService라는 클래스를 만들고 PlaceOrderUseCase를 구현합니다. 이 서비스는 도메인 계층 바로 바깥(Applicaton Layer)에서 동작하며, 여기서 도메인 엔티티를 생성하고 리포지터리 등을 호출합니다. 즉, 도메인 객체들을 활용하여 시나리오를 오케스트레이션하는 역할입니다. 예를 들어 PlaceOrderService는 orderRepository (출력 포트)를 주입받아(OrderRepository는 Order를 저장하는 인터페이스) Order 객체를 생성한 뒤 저장하고, 결과를 반환합니다.
  • 출력 포트: OrderRepository 인터페이스를 도메인 계층에 정의합니다. 이 포트에는 fun save(order: Order), fun findById(id: OrderId): Order? 등의 메서드가 있습니다. 구현체는 모르지만, 도메인 로직에서는 이 메서드를 호출하여 주문을 저장하거나 조회할 수 있습니다.
  • 입력 어댑터: 웹에서 들어오는 요청을 처리하는 예로 OrderController (Spring MVC의 @RestController 등)을 생각해볼 수 있습니다. 이 컨트롤러는 HTTP 요청을 받아 JSON 등을 PlaceOrderCommand로 변환하고, 내부에서 placeOrderUseCase.placeOrder(command)를 호출합니다. 이렇게 하면 UI 레이어(웹)는 도메인에 직접 접근하지 않고, 입력 포트를 통해 간접적으로 도메인 로직을 실행합니다.
  • 출력 어댑터: 데이터베이스에 Order를 저장하는 OrderRepositoryImpl 클래스를 구현합니다. 이 클래스는 Spring Data JPA 같은 걸 사용할 수도 있고, 직접 JDBC를 사용할 수도 있습니다. 중요한 것은 OrderRepository 인터페이스를 구현하고 있다는 점입니다. 예를 들어 JPA를 쓴다면 OrderRepositoryImpl 내부에서 JPA 엔티티로 변환 후 springDataOrderJpa.save(entity)를 호출하거나, 간단히 JPA 레포지토리를 주입받아 사용하는 식이 될 것입니다. 아래는 간단한 출력 어댑터 구현 예시입니다:
    class OrderRepositoryImpl(
        private val jpaRepo: SpringDataOrderJpaRepository
    ) : OrderRepository {
        override fun save(order: Order) {
            val entity = OrderEntity.fromDomain(order)  // 도메인 -> JPA 엔티티 변환
            jpaRepo.save(entity)
        }
        override fun findById(id: OrderId): Order? {
            val entity = jpaRepo.findById(id.value)
            return entity?.toDomain()  // JPA 엔티티 -> 도메인 객체 변환
        }
    }
     위 코드에서 OrderRepositoryImpl은 도메인 계층의 OrderRepository 포트를 구현하여 DB 연동을 수행하는 어댑터입니다. JPA를 사용하는 예를 들었지만, 구현 방식은 어떤 기술을 쓰든 무방합니다(예: MyBatis, MongoDB 등). 중요한 점은, 도메인 로직은 OrderRepository 인터페이스만 의존하고 구현은 전혀 모르기 때문에, 나중에 DB를 교체하거나 구현을 바꾸어도 도메인 쪽 코드는 영향이 없다는 것입니다. 또한 이러한 구조 덕분에 도메인 로직을 테스트할 때 실제 DB 없이 가짜 구현체(fake)를 주입하여 테스트할 수도 있습니다.

 

이러한 포트와 어댑터의 연결 구조를 그림으로 나타내면, 입력 어댑터(예: Controller)는 입력 포트(UseCase 인터페이스)를 호출하고, 응용 서비스(UseCase 구현)는 도메인 엔티티를 사용하며 출력 포트(Repository 인터페이스)를 호출하고, 그 출력 어댑터(Repository 구현)가 DB 등과 통신하는 형태입니다. 결국 모든 외부와의 상호작용은 도메인 바깥에서 이루어지고, 도메인 계층은 오직 포트(인터페이스)로 소통하기 때문에 깨끗한 상태를 유지합니다.

요약하면: 포트는 도메인의 입출력 경계(interface)이고, 어댑터는 그 경계를 구현한 구체 클래스입니다. 덕분에 도메인과 기술적 요소가 느슨하게 결합되며, 헥사고날 아키텍처에서는 새로운 어댑터를 추가하여 기능을 확장하거나 기존 어댑터를 수정해도 도메인을 손대지 않을 수 있습니다. 예를 들어 주문 정보를 CSV 파일로 저장하는 새로운 요구사항이 생기면, OrderRepository를 구현한 OrderCsvFileRepository 어댑터만 추가로 만들어 끼워넣으면 됩니다(도메인 또는 UseCase 코드는 수정 불필요).

간단한 프로젝트 구조 예시

위에서 살펴본 개념들을 실제 프로젝트에 적용하려면 폴더/패키지 구조 또는 멀티 모듈 구조를 설계해야 합니다. 헥사고날 아키텍처에서는 일반적으로 계층(레이어) 또는 컴포넌트 단위로 모듈을 나누는 방식을 사용합니다. 예를 들어, 다음과 같이 기능별 패키지 안에 레이어별 하위 패키지를 두는 구조를 고려할 수 있습니다:

com.example.ordersystem
├── domain                    // 도메인 계층 - 핵심 비즈니스 로직
│   ├── model                 // 엔티티, 값 객체 등이 위치 (Order, Product, Customer 등)
│   ├── service               // 도메인 서비스 (예: DiscountPolicy 등)
│   └── repository            // 출력 포트 인터페이스 (예: OrderRepository 등)
├── application               // 응용 계층 (유스케이스 구현 등)
│   ├── port                  // 입력 포트 인터페이스 (예: PlaceOrderUseCase 등)
│   └── service               // 유스케이스 구현체, 응용 서비스 (예: PlaceOrderService 등)
├── adapters                  // 어댑터 계층 - 인프라 및 인터페이스 구현
│   ├── persistence           // 영속성 어댑터 (예: OrderRepositoryImpl - DB 연동)
│   └── web                   // 웹 어댑터 (예: OrderController - REST API 구현)
└── common/util               // (선택) 공통 유틸리티나 설정

위 구조는 하나의 예시이며, 프로젝트 성격에 따라 변형될 수 있습니다. 중요한 것은 도메인 코드(domain)가 한 곳에 모여 있고, 이 도메인 패키지를 다른 계층이 참조하지만 그 반대는 없는 것입니다. 예를 들어 application 패키지는 domain을 사용하지만, domain은 application이나 adapters를 모르도록 합니다. 이를 의존성 규칙으로 강제하려면, Maven/Gradle 멀티모듈로 domain, application, adapters를 분리하고 module dependency를 설정하는 방법도 있습니다.

실제로 멀티모듈로 구성한다면 다음처럼 나눌 수 있습니다:

  • domain 모듈 – 도메인 엔티티, 값 객체, 도메인 서비스, 포트 인터페이스 등을 포함. (순수 Kotlin/Java, 어떤 프레임워크에도 의존하지 않음)
  • application 모듈 – 유스케이스 인터페이스 및 구현체, 응용 서비스, (필요에 따라 포트 정의가 여기에 위치하기도 함). domain 모듈에 의존.
  • adapters 모듈들 – 구현 기술별 어댑터. 예를 들어 adapter-persistence (DB 접근 구현, domain + application 모듈에 의존), adapter-web (REST API 구현, application 모듈에 의존), 기타 외부 시스템 연동 어댑터 등.
  • interface/api 모듈 (선택) – 프론트엔드에 노출할 API 정의나 DTO 등이 별도 모듈로 있는 경우.

프로젝트에 따라 위 모듈들을 통합하거나 더 세분화할 수 있습니다. 예를 들어 모놀리식 애플리케이션이라면 굳이 물리적 모듈 분리는 하지 않고 패키지로만 구분할 수도 있습니다. 중요한 것은 계층별로 코드가 분리되어 있고 의존성 방향이 domain쪽으로만 향하도록 관리하는 것입니다. 실제 현업 프로젝트에서도 도메인이 복잡해지면 com.myapp.order.domain.order 패키지에 엔티티들을, com.myapp.order.domain.service 패키지에 도메인 서비스를 넣는 등 계층+기능 별로 세분화하여 관리합니다. 이렇게 하면 도메인 모델의 경계가 명확해지고, 각 부분을 모듈화하여 개발 팀 간에 병렬작업하거나 특정 부분을 교체하기도 수월해집니다.

정리하면, 헥사고날 아키텍처 기반의 프로젝트 구조는 “도메인 / 응용 / 어댑터” 세 부분을 분리하는 방향으로 구성됩니다. 각 부분은 서로 명확한 경계(Boundary)를 가지며, 특히 도메인 계층은 다른 부분과 느슨하게 연결됩니다. 이러한 구조를 통해 얻는 이점은 모듈별로 관심사의 분리(Separation of Concerns)가 이루어지고, 도메인 로직에만 집중한 코드베이스를 구축할 수 있다는 것입니다.

테스트 전략 – 도메인 테스트 중심 접근

헥사고날 아키텍처의 장점을 최대화하려면 테스트 전략 역시 도메인 중심으로 가져가는 것이 효과적입니다. 일반적으로 권장되는 접근은 다음과 같습니다:

  • 도메인 엔티티 및 도메인 로직 – 단위 테스트(Unit Test)로 검증한다.
  • 유스케이스 구현(응용 서비스)단위 테스트로 검증한다 (포트 인터페이스를 구현한 가짜 or 목 객체를 활용).
  • 어댑터 구현 – 통합 테스트(Integration Test)로 검증한다 (실제 DB나 외부 연동을 포함한 환경에서 테스트).
  • 애플리케이션 전체 경로 – 선택적으로 인수/시스템 테스트(System/E2E Test)로 주요 시나리오를 검증한다.

위와 같이 도메인과 응용 계층의 로직은 빠르고 가벼운 단위 테스트로 폭넓게 커버하고, 외부 세계와 만나는 어댑터 계층은 필요한 최소한의 통합 테스트로 신뢰성을 확보하는 전략입니다. 이렇게 하면 테스트가 전반적으로 빠르고 안정적으로 돌아갈 수 있습니다. 특히 도메인 로직은 외부 의존이 없으므로 순수한 함수처럼 테스트할 수 있어, 수백 개의 케이스도 짧은 시간에 검증 가능합니다. 반면 DB나 네트워크를 포함한 테스트는 설정과 실행에 비용이 크므로, 핵심 흐름만 통합 테스트로 확인하고 나머지 비즈니스 규칙은 단위 테스트로 담보하는 것입니다.

예를 들어 앞서 정의한 Order 도메인 엔티티에 대해 단위 테스트를 작성해보겠습니다. 배송지 변경 규칙을 검증하는 테스트는 다음과 같이 만들 수 있습니다:

import kotlin.test.Test
import kotlin.test.assertFailsWith

class OrderTest {
    @Test
    fun `배송지 변경은 배송 완료 후 불가`() {
        // 준비: 배송 완료 상태의 주문 생성
        val order = Order(
            id = OrderId(1L),
            orderer = Customer(CustomerId(1L), "홍길동", MembershipLevel.SILVER),
            shippingInfo = ShippingInfo("서울", "강남구 ..."),
            items = listOf(OrderItem(Product(ProductId(1L), "테스트상품", Money(10000)), 1)),
            state = OrderState.DELIVERY_COMPLETE   // 배송 완료 상태
        )
        // 실행 및 검증: 배송지 변경 시도 -> 예외 발생 기대
        assertFailsWith<IllegalStateException> {
            order.changeShippingInfo(ShippingInfo("부산", "해운대구 ..."))
        }
    }
}

위 테스트는 Order 도메인 객체의 changeShippingInfo 메서드가 비즈니스 규칙에 따라 예외를 던지는지 확인합니다. 테스트는 순수 Kotlin 코드로 매우 빠르게 수행되며, 외부 환경에 영향을 받지 않기 때문에 안정적입니다. 이처럼 단위 테스트가 도메인 엔터티에 녹아있는 비즈니스 규칙을 검증하기에 가장 적절한 방법입니다. 도메인 계층의 로직을 철저히 테스트하면, 인프라나 UI가 달라져도 핵심 규칙이 유지되는지를 보장할 수 있습니다.

응용 서비스(유스케이스)에 대한 테스트도 마찬가지로 단위 테스트로 작성합니다. 예컨대 PlaceOrderService를 테스트할 때는 실제 DB에 접근하지 않도록 OrderRepository를 구현한 메모리 저장소(fake)를 주입하거나, Mockito 같은 라이브러리로 목(Mock)을 만들어 사용합니다. 그러면 주문 생성 로직이 올바르게 동작하는지 빠르게 검증할 수 있습니다. 아래는 PlaceOrderService의 단위 테스트 예시 흐름입니다 (코드 생략):

  1. 가짜 OrderRepository와 ProductRepository 구현체를 준비 (또는 목 객체 생성).
  2. PlaceOrderService(orderRepo, productRepo) 인스턴스 생성.
  3. placeOrder(주문커맨드) 호출하여 결과 Order 확인.
  4. orderRepo에 주문이 저장되었는지, Order의 상태와 값들이 예상대로인지 검증.

이때도 외부 시스템 없이 메모리 상에서 테스트가 이루어지므로 아주 빠르게 실행됩니다.

한편, 출력 어댑터(예: OrderRepositoryImpl)에 대한 테스트는 DB 통합 테스트로 수행합니다. 예를 들어 H2 같은 인메모리 데이터베이스나 테스트 컨테이너 등을 이용해 실제 JPA로 Order를 저장하고 조회하는 과정을 검증할 수 있습니다. 이런 통합 테스트는 준비가 복잡하지만, 어댑터 구현의 신뢰성을 확보하는 데 필요합니다. 다만 통합 테스트는 변경 시 디버깅이 어렵고 느릴 수 있으므로, 도메인 로직을 검증하는 용도로는 사용하지 않는 것이 좋습니다. 도메인 로직은 이미 단위 테스트로 충분히 검증되었기 때문에, 통합 테스트에서는 주로 설정과 기술 연동에 초점을 맞춥니다 (예: 잘못된 SQL 매핑이나 외부 API 호출 URL 오류 잡기 등).

종합하면, 헥사고날 아키텍처에서는 “안쪽에 있는 핵심 로직일수록 간단하고 빠른 테스트로 빈틈없이 검증하고, 바깥쪽 구현일수록 적절한 통합 테스트로 확인”하는 전략을 취합니다. 이렇게 테스트를 구성하면 변경으로 인한 문제를 조기에 발견할 수 있고, 리팩터링 시에도 도메인 규칙이 견고하게 보호되므로 안심하고 코드를 개선할 수 있습니다. 실제로 이 방식을 따르면 도메인 수정 -> 단위 테스트 통과 -> 어댑터 수정 -> 통합 테스트 통과 -> 전체 시나리오 테스트 통과의 단계를 거쳐 안정적인 배포가 가능해집니다.

 

 

 

728x90