Skip to content
ioob.dev
Go back

DDD 2편 — 유비쿼터스 언어와 Bounded Context

· 10분 읽기
DDD 시리즈 (2/7)
  1. DDD 1편 — 도메인 중심 설계가 필요한 이유
  2. DDD 2편 — 유비쿼터스 언어와 Bounded Context
  3. DDD 3편 — Context Mapping
  4. DDD 4편 — Entity와 Value Object
  5. DDD 5편 — Aggregate와 Repository
  6. DDD 6편 — Domain Service와 Application Service
  7. DDD 7편 — Domain Event와 Anti-Corruption Layer
Table of contents

Table of contents

상품이 세 개다

이커머스 시스템을 만든다고 해보자. 기획 회의에서 상품이라는 단어가 끊임없이 등장한다. 그런데 가만히 듣다 보면, 같은 상품이 사람마다 다른 의미로 쓰이고 있다.

카탈로그 팀은 상품을 전시 정보로 본다. 이름, 설명, 이미지, 카테고리. 고객이 검색하고 클릭하는 그 페이지의 데이터다.

주문 팀은 상품을 주문 항목으로 본다. 수량, 단가, 할인 적용 여부. 고객이 장바구니에 담고 결제하는 단위다. 카탈로그의 이미지나 카테고리는 관심 밖이다.

배송 팀은 상품을 물리적 화물로 본다. 무게, 부피, 포장 방식, 창고 위치. 전시 설명은 필요 없고, 할인 여부도 상관없다.

세 팀이 상품을 논하는데, 사실은 서로 다른 세 개의 개념을 이야기하고 있었다. 이 혼란이 코드에 그대로 반영되면 어떻게 되는가?

// 모든 맥락의 "상품" 정보를 하나의 클래스에 욱여넣은 결과
public class Product {
    // 카탈로그 관심사
    private String name;
    private String description;
    private String imageUrl;
    private String category;

    // 주문 관심사
    private BigDecimal price;
    private BigDecimal discountRate;
    private int stockQuantity;

    // 배송 관심사
    private double weight;
    private double volume;
    private String warehouseCode;
    private PackagingType packagingType;

    // getter, setter 수십 개...
}

이 클래스는 신(God) 객체다. 카탈로그 로직을 고치면 주문 테스트가 깨지고, 배송 필드를 추가하면 카탈로그 팀이 머지 충돌을 해결해야 한다. 모든 팀이 하나의 Product에 의존하기 때문에, 누구도 자유롭게 변경할 수 없다.

DDD는 이 문제를 두 가지 도구로 풀어낸다. 유비쿼터스 언어(Ubiquitous Language)바운디드 컨텍스트(Bounded Context)다.

유비쿼터스 언어

유비쿼터스 언어(Ubiquitous Language)란 하나의 프로젝트 안에서 개발자와 도메인 전문가가 공유하는 공통 용어 체계를 말한다. 유비쿼터스어디에나 있는이라는 뜻이다. 이 언어가 대화에서도, 문서에서도, 코드에서도 동일하게 쓰인다는 의미를 담고 있다.

핵심 원칙은 세 가지다.

첫째, 코드에 도메인 언어가 반영되어야 한다. 기획자가 주문 확정이라고 부르면 코드에도 confirmOrder()가 있어야 한다. 기획서에는 주문 확정인데 코드에서는 updateStatus(CONFIRMED)면, 둘 사이에 번역 비용이 발생한다.

// 나쁜 예: 기술적 표현. "상태를 업데이트한다"는 도메인 언어가 아니다
fun updateOrderStatus(orderId: Long, status: String) { ... }

// 좋은 예: 도메인 언어. "주문을 확정한다"
fun confirmOrder(orderId: OrderId) { ... }

이 차이가 사소해 보일 수 있지만, 메서드가 수백 개가 되면 이야기가 달라진다. updateXxxStatus가 20개 있는 서비스 클래스에서 “주문 취소 로직은 어디 있지?”를 찾는 것과, cancelOrder()를 바로 찾는 것은 인지 부하가 다르다.

둘째, 개발자와 도메인 전문가가 같은 용어로 대화해야 한다. 기획자가 주문 확정이라고 하는데 개발자가 “아, 스테이터스 업데이트 말씀하시는 거죠?”라고 번역하면, 그 순간 두 사람은 다른 세계에 있는 것이다. 용어를 통일하면 대화의 품질이 올라간다.

셋째, 용어가 모호하면 모델이 모호하다. 처리한다라는 단어를 생각해보자. 주문을 처리한다는 것이 주문을 확정하는 것인가, 결제를 진행하는 것인가, 배송을 시작하는 것인가? 이런 모호한 동사 하나가 버그의 원인이 되기도 한다. 유비쿼터스 언어는 이런 모호함을 용납하지 않는다.

유비쿼터스 언어를 만드는 과정은 단순히 용어 사전을 작성하는 게 아니다. 도메인 전문가와 개발자가 함께 대화하면서, 모델을 그려보면서, 이 개념은 이렇게 부르자라고 합의하는 지속적인 활동이다. 화이트보드 앞에서 모델링 세션을 하거나, Event Storming 같은 워크숍을 통해 자연스럽게 정제된다.

하나의 언어는 하나의 경계 안에서만 유효하다

유비쿼터스 언어에는 중요한 한계가 있다. 하나의 유비쿼터스 언어는 전체 시스템에서 통용되는 게 아니라, 특정 경계 안에서만 유효하다.

앞서 본 상품의 예를 다시 떠올려보자. 카탈로그에서의 상품과 배송에서의 상품은 이름만 같을 뿐, 속성도 행위도 다르다. 이 두 개념을 하나의 용어로 통일하려고 하면 오히려 혼란이 커진다. 카탈로그 팀에게 상품의 무게를 이야기하면 어리둥절할 것이고, 배송 팀에게 상품의 카테고리를 물으면 그건 우리 관심사가 아닌데라는 답이 돌아올 것이다.

이 경계가 바로 바운디드 컨텍스트(Bounded Context)다.

Bounded Context

바운디드 컨텍스트(Bounded Context)는 하나의 유비쿼터스 언어가 일관되게 적용되는 명시적인 경계다. 이 경계 안에서는 모든 용어가 하나의 의미만 가진다.

이커머스 시스템을 바운디드 컨텍스트로 나누면 이렇게 된다.

flowchart TB
    subgraph catalog["카탈로그 컨텍스트"]
        CP["Product<br/>이름, 설명, 이미지, 카테고리"]
        CC["Category<br/>계층 구조, 전시 순서"]
    end

    subgraph ordering["주문 컨텍스트"]
        OO["Order<br/>주문 항목, 총액, 상태"]
        OI["OrderItem<br/>상품ID, 수량, 단가"]
    end

    subgraph shipping["배송 컨텍스트"]
        SS["Shipment<br/>배송 상태, 추적번호"]
        SP["Parcel<br/>무게, 부피, 포장 방식"]
    end

    subgraph payment["결제 컨텍스트"]
        PP["Payment<br/>결제 수단, 금액, 상태"]
        PR["Receipt<br/>영수증, 승인번호"]
    end

    catalog -.->|"상품ID 참조"| ordering
    ordering -.->|"주문 확정 이벤트"| shipping
    ordering -.->|"결제 요청"| payment

각 컨텍스트 안에서 상품은 서로 다른 모습으로 존재한다. 카탈로그의 Product와 주문의 OrderItem 안에 있는 상품 개념은 같은 물리적 상품을 가리키지만, 모델링은 완전히 다르다. 그리고 그것이 정상이다.

이 관계를 더 명확하게 보자. 같은 상품이라는 단어가 컨텍스트에 따라 어떻게 달라지는지 다이어그램으로 확인할 수 있다.

flowchart LR
    subgraph real["현실 세계"]
        R["상품 = 물리적 사물 하나"]
    end

    subgraph cat["카탈로그 컨텍스트"]
        C["Product<br/>이름, 설명, 이미지<br/>카테고리, 전시 상태"]
    end

    subgraph ord["주문 컨텍스트"]
        O["OrderItem<br/>상품ID, 수량<br/>단가, 할인율"]
    end

    subgraph ship["배송 컨텍스트"]
        S["Parcel<br/>무게, 부피<br/>포장 방식, 창고 위치"]
    end

    R --> C
    R --> O
    R --> S

현실의 하나의 사물이, 소프트웨어에서는 맥락에 따라 서로 다른 모델로 표현되는 것이다. 이것을 억지로 하나의 Product 클래스에 합치려는 시도가 바로 문제의 시작이었다.

컨텍스트 경계를 나누는 기준

“그래서 경계를 어디에 그어야 하는가?”는 DDD에서 가장 어려운 질문 중 하나다. 정답은 없지만, 몇 가지 실용적인 기준이 있다.

언어의 불일치가 발생하는 지점이 경계다. 같은 단어를 쓰면서 서로 다른 속성을 떠올리기 시작하면, 그곳이 컨텍스트 경계일 가능성이 높다. 상품이 이미지를 의미하는 팀과 무게를 의미하는 팀은 다른 컨텍스트에 있다.

비즈니스 프로세스의 자연스러운 단절점을 찾는다. 이커머스에서 주문 확정 → 결제 처리 → 배송 시작은 각각 다른 프로세스다. 주문이 확정되었다고 바로 배송이 시작되지 않고, 결제가 실패해도 주문 자체가 사라지지는 않는다. 이런 프로세스 사이의 단절점이 컨텍스트 경계와 자주 일치한다.

팀 구조를 참고한다. Conway의 법칙(Conway’s Law)에 따르면, 소프트웨어 구조는 조직의 커뮤니케이션 구조를 따라간다. 카탈로그 팀, 주문 팀, 배송 팀이 따로 있다면, 바운디드 컨텍스트도 그에 맞추는 것이 자연스럽다. 물론 팀이 하나라고 해서 컨텍스트를 나누지 못하는 건 아니지만, 팀 경계는 유용한 출발점이 된다.

변경의 이유가 다르면 경계를 의심한다. 카탈로그의 전시 방식을 바꿨을 때 배송 로직이 영향받으면 안 된다. 두 영역의 변경 이유가 독립적이라면, 같은 컨텍스트에 있을 이유가 없다.

실무 예시: 이커머스

좀 더 구체적으로, 이커머스에서 각 바운디드 컨텍스트가 어떤 모델을 가지는지 코드로 살펴보자.

카탈로그 컨텍스트의 상품은 전시 정보에 초점을 맞춘다.

// 카탈로그 컨텍스트: 전시 관심사
package com.shop.catalog

data class Product(
    val id: ProductId,
    val name: String,
    val description: String,
    val imageUrls: List<String>,
    val category: Category,
    val displayStatus: DisplayStatus,
) {
    fun publish() {
        // 전시 상태를 "노출"로 변경
    }

    fun hide() {
        // 전시 상태를 "숨김"으로 변경
    }
}

enum class DisplayStatus { DRAFT, PUBLISHED, HIDDEN }

주문 컨텍스트에서는 같은 상품이 완전히 다른 모습이다.

// 주문 컨텍스트: 주문 관심사
package com.shop.ordering

class Order private constructor(
    val id: OrderId,
    val customerId: CustomerId,
    private val lines: MutableList<OrderLine>,
    private var status: OrderStatus,
) {
    fun addLine(productId: ProductId, productName: String, price: Money, quantity: Int) {
        require(status == OrderStatus.DRAFT) { "초안 상태에서만 항목 추가가 가능하다" }
        lines.add(OrderLine(productId, productName, price, quantity))
    }

    fun totalAmount(): Money =
        lines.fold(Money.ZERO) { acc, line -> acc + line.subtotal() }
}

data class OrderLine(
    val productId: ProductId,     // 카탈로그의 Product를 직접 참조하지 않는다
    val productName: String,      // 주문 시점의 스냅샷
    val unitPrice: Money,
    val quantity: Int,
) {
    fun subtotal(): Money = unitPrice * quantity
}

여기서 주목할 점은 OrderLine이 카탈로그의 Product 객체를 직접 참조하지 않는다는 것이다. productId로만 연결하고, 주문 시점의 상품명과 가격을 스냅샷으로 저장한다. 왜 그럴까?

카탈로그에서 상품명이 바뀌어도 이미 접수된 주문의 상품명은 바뀌면 안 되기 때문이다. 주문 확정 후 상품 가격이 인상되어도 주문 금액은 그대로여야 한다. 두 컨텍스트는 독립적으로 변경된다. 그래서 객체 참조가 아닌 ID 참조와 스냅샷을 사용한다.

배송 컨텍스트는 또 다른 관점이다.

// 배송 컨텍스트: 물류 관심사
package com.shop.shipping

class Shipment(
    val id: ShipmentId,
    val orderId: OrderId,         // 주문 컨텍스트와 ID로만 연결
    val parcels: List<Parcel>,
    private var status: ShipmentStatus,
    private var trackingNumber: String? = null,
) {
    fun dispatch(trackingNumber: String) {
        require(status == ShipmentStatus.READY) { "준비 완료 상태에서만 발송 가능하다" }
        this.trackingNumber = trackingNumber
        this.status = ShipmentStatus.DISPATCHED
    }
}

data class Parcel(
    val weight: Weight,
    val dimensions: Dimensions,
    val packagingType: PackagingType,
)

배송 컨텍스트에는 Product라는 개념 자체가 없다. 대신 Parcel(소포)이 있고, WeightDimensions라는 물류에 특화된 값 객체를 사용한다. 상품의 이미지가 뭔지, 할인율이 얼마인지는 알 필요가 없다.

이것이 바운디드 컨텍스트의 힘이다. 각 컨텍스트는 자기 관심사에 집중하고, 다른 컨텍스트의 변경에 영향받지 않는다.

패키지 구조로 보는 경계

바운디드 컨텍스트를 코드에 반영하는 가장 직접적인 방법은 패키지 구조다.

com.shop
├── catalog/            ← 카탈로그 컨텍스트
│   ├── domain/
│   │   ├── Product.kt
│   │   ├── Category.kt
│   │   └── ProductRepository.kt
│   └── application/
│       └── ProductService.kt

├── ordering/           ← 주문 컨텍스트
│   ├── domain/
│   │   ├── Order.kt
│   │   ├── OrderLine.kt
│   │   └── OrderRepository.kt
│   └── application/
│       └── OrderService.kt

├── shipping/           ← 배송 컨텍스트
│   ├── domain/
│   │   ├── Shipment.kt
│   │   ├── Parcel.kt
│   │   └── ShipmentRepository.kt
│   └── application/
│       └── ShipmentService.kt

└── payment/            ← 결제 컨텍스트
    ├── domain/
    │   ├── Payment.kt
    │   └── PaymentRepository.kt
    └── application/
        └── PaymentService.kt

모놀리식 애플리케이션이라도 패키지로 컨텍스트 경계를 표현할 수 있다. catalog.domain.Productordering.domain.OrderLine은 같은 JVM 위에서 돌아가지만, 서로 직접 참조하지 않는다. ID로만 연결되고, 필요하면 이벤트로 소통한다.

이 구조가 마이크로서비스 전환의 출발점이 되기도 한다. 패키지 경계가 명확하면, 나중에 한 컨텍스트를 별도 서비스로 분리할 때 잘라낼 단위가 이미 정해져 있는 셈이다. 모놀리스에서 시작해서 필요할 때 분리하는 것은 건강한 전략이다.

흔한 실수: 하나의 모델로 모든 것을 표현하려는 시도

DDD를 적용하지 않은 시스템에서 가장 흔히 보이는 구조가 통합 모델이다. Product 하나, User 하나, Order 하나로 시스템 전체를 커버하려는 접근이다.

이 접근이 초반에는 편하다. 클래스가 적으니 구조가 단순해 보인다. 그런데 시스템이 커지면 문제가 드러난다.

바운디드 컨텍스트는 이 문제를 원칙적으로 해결한다. 상품이라는 하나의 현실 개념을 여러 모델로 분리하는 것이 처음에는 중복처럼 느껴질 수 있지만, 실은 각 모델이 자기 맥락에 필요한 것만 담고 있어서 더 응집도가 높다.

핵심 정리

이 편에서 다룬 내용을 정리하면 이렇다.


다음 편에서는 컨텍스트 매핑(Context Mapping)을 다룬다. 바운디드 컨텍스트 사이의 관계를 어떻게 정의하고, 어떤 협력 패턴을 선택할 수 있는지 살펴보자.

3편: Context Mapping — 컨텍스트 간 관계를 정의하는 패턴들


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
DDD 1편 — 도메인 중심 설계가 필요한 이유
Next Post
DDD 3편 — Context Mapping