티스토리 뷰

프로그래밍

도메인에 대한 설명

silbaram 2025. 3. 26. 00:34
728x90

OOP에서 도메인이란 무엇인가?

도메인(domain)은 소프트웨어가 해결하고자 하는 문제 영역을 의미합니다. 예를 들어, 온라인 서점 소프트웨어의 도메인은 상품 조회, 구매, 결제 등 온라인 서점 비즈니스 전반이며, 이를 더 작게는 주문, 결제, 배송 같은 하위 도메인으로 나눌 수 있습니다. 도메인은 소프트웨어의 핵심 비즈니스 로직이 위치하는 부분으로, 사용자 인터페이스나 데이터베이스 같은 기술적 세부사항보다 해결해야 할 문제 자체에 초점을 맞춥니다. 이러한 도메인을 명확히 이해하고 모델링하는 것은 소프트웨어 설계의 중심이며, 유지보수성과 확장성 측면에서 매우 중요합니다.

요약: 도메인은 소프트웨어가 다루는 문제 영역이며, 핵심 비즈니스 로직이 담기는 부분입니다. 도메인을 잘 정의하고 모델링하면 복잡한 문제를 효과적으로 해결하고 향후 변경에도 유연하게 대응할 수 있습니다.

도메인과 애플리케이션 로직, 인프라스트럭처의 차이

소프트웨어 시스템은 흔히 레이어드 아키텍처(계층형 구조)로 구분됩니다. 대표적으로 프레젠테이션(UI) 계층, 애플리케이션 계층, 도메인 계층, 인프라스트럭처 계층 등이 있습니다. 여기서 도메인 계층비즈니스 규칙과 로직이 위치하는 곳으로, 애플리케이션의 심장부 역할을 합니다.

  • 도메인 로직(비즈니스 로직): 비즈니스 의미를 가진 의사결정과 규칙들로 구성되며, 데이터 처리나 UI 같은 세부사항을 다루지 않습니다. 도메인 로직은 문제 해결을 위한 방법에 해당하며, 모든 중요한 비즈니스 결정은 이 계층에서 이루어집니다.
  • 애플리케이션 로직: 도메인 로직이 올바르게 실행되도록 흐름을 조율하는 역할을 합니다. 예를 들어, 사용자의 입력을 받아 도메인 계층에 전달하고, 결과를 다시 사용자에게 반환하는 일을 담당합니다. 애플리케이션 계층은 도메인 로직을 호출하고 각종 인프라 세부사항(데이터베이스, API 호출 등)과 연계하여 작업을 완료합니다. 쉽게 말해, 애플리케이션 로직은 도메인 로직(업무 규칙)인프라스트럭처(기술) 사이의 접착제 역할을 합니다.
  • 인프라스트럭처 로직: 데이터베이스 접근, 파일 시스템, 외부 시스템 연동, UI 처리 등 기술적인 세부사항을 담당합니다. 도메인이나 애플리케이션 로직에 필요한 서비스를 제공하지만, 비즈니스 규칙 자체와는 관련이 없습니다.

도메인과 다른 계층을 구분하는 이유는 관심사의 분리를 통해 각각을 독립적으로 이해하고 변경할 수 있게 하기 위해서입니다. 예를 들어, 도메인 로직을 UI 코드와 분리하면, 화면이 바뀌어도 비즈니스 규칙은 그대로 유지할 수 있으며, 데이터베이스 종류를 바꿔도 도메인 로직에는 영향이 없도록 만들 수 있습니다. 이는 유지보수성 향상테스트 용이성으로 이어집니다.

정리: 도메인 계층은 무엇을 해야 하는지(문제 자체)를 담고, 애플리케이션 계층은 어떻게 도메인 로직을 실행할지(흐름 제어)를 담당하며, 인프라스트럭처 계층은 그 실행에 필요한 _기술적 지원_을 제공합니다. 이러한 계층 분리는 코드의 응집도를 높이고 결합도를 낮춰서 유연한 설계를 가능하게 합니다.

도메인 모델링 (Domain Modeling)

도메인 모델링이란 현실 세계의 도메인을 소프트웨어 객체로 표현하는 과정입니다. 도메인 모델은 도메인의 개념, 규칙, 데이터를 객체 지향적으로 추상화한 것으로, 올바른 모델링을 통해 복잡한 비즈니스도 이해하기 쉽게 구조화할 수 있습니다. 도메인 모델링의 핵심 구성요소로는 엔티티(Entity), 값 객체(Value Object), 애그리거트(Aggregate), 도메인 서비스(Domain Service) 등이 있습니다. 각각을 알아보겠습니다:

  • 엔티티(Entity): 엔티티는 고유한 식별자를 가져서 동일성과 연속성을 지니는 객체입니다. 예를 들어 온라인 서점의 주문(Order), 회원(User), 상품(Product) 등은 각각 식별 ID를 가지며, 시간에 따라 속성이 변경되어도 같은 엔티티로 추적됩니다. 엔티티는 자신의 라이프사이클을 가지며, 내부 상태와 행위를 통해 도메인 내에서 의미 있는 역할을 수행합니다.

    • 엔티티의 중요한 특징은 _식별성_입니다. 두 엔티티를 비교할 때는 보통 ID로 비교하며, 설령 속성 값이 달라져도 ID가 같으면 동일한 엔티티로 간주합니다.

    • 예시 (Java 엔티티)Order 엔티티:

      public class Order {
          private Long id;             // 고유 식별자 (ID)
          private List<LineItem> items;
          private Address shippingAddress;
          private Money totalAmount;
          // ... 주문 관련 비즈니스 로직 (예: calculateTotal)
      }

      Order 클래스처럼, 엔티티는 식별자(id)와 자신만의 속성, 그리고 관련 비즈니스 메소드를 포함할 수 있습니다.

  • 값 객체(Value Object): 값 객체는 고유 식별자가 없고 값으로만 비교되는 객체입니다). 값 객체는 일정한 불변성을 가지며(대부분 생성 후 상태가 변하지 않음), 주로 다른 객체의 일부 속성을 표현하거나 계산 결과 등을 담는 데 사용됩니다. 예를 들어 주소(Address), 금액(Money), 기간(DateRange) 등이 값 객체로 적합합니다.

    • 값 객체는 불변(immutable)으로 설계하는 것이 일반적입니다. 한 번 생성된 값 객체의 속성은 변하지 않으며, 변경이 필요하면 새로운 인스턴스를 만들어 사용합니다. 이렇게 하면 부작용을 줄이고 동등성 비교(Equality)도 단순해집니다.

    • 두 값 객체는 모든 속성 값이 같다면 같은 것으로 간주됩니다 (동등성). 예를 들어 Money(1000, "KRW")라는 값 객체 두 개를 만들면, 둘 다 1000원이라는 동일한 가치를 가지므로 서로 동등한 것으로 판단합니다.

    • 예시 (Java 값 객체)Money 값 객체:

      public final class Money {
          private final BigDecimal amount;
          private final String currency;
      
              public Money(BigDecimal amount, String currency) {
              this.amount = amount;
              this.currency = currency;
          }
          // getter만 제공하여 불변객체로 사용
          // equals와 hashCode는 amount와 currency로 비교하도록 구현
      }
      

      Money 클래스는 통화와 금액을 속성으로 가지며, 두 Money 객체를 비교할 때 amountcurrency 값이 모두 같으면 동등하다고 판단합니다.

  • 애그리거트(Aggregate): 애그리거트는 관련된 여러 엔티티와 값 객체를 그룹화한 군집으로, 한 개의 애그리거트 루트(aggregate root) 엔티티를 중심으로 구성됩니다. 애그리거트는 경계를 가지며, 트랜잭션 일관성불변 조건(invariant)을 그 경계 내에서 유지하는 것이 목표입니다.

    • 애그리거트 루트는 해당 애그리거트의 유일한 진입점으로, 외부에서는 애그리거트 루트를 통해서만 내부 구성요소에 접근합니다. 예를 들어 주문(Order) 애그리거트는 주문(Order) 엔티티를 루트로 하고, 그 하위에 주문 항목(LineItem) 값 객체들, 배송지(Address) 값 객체 등을 포함할 수 있습니다. 이때 Order가 루트이며, 외부에서는 Order를 통해서만 LineItem이나 Address에 접근하거나 수정하게 됩니다.
    • 애그리거트는 경계 내의 불변 조건을 책임집니다. 예를 들어 Order 애그리거트는 "주문 합계 금액 = 각 주문 항목 금액 합"과 같은 규칙을 유지해야 한다면, 이러한 검증 로직을 Order 엔티티(루트)에 구현하여 애그리거트 전체의 일관성을 보장합니다. 트랜잭션 범위도 일반적으로 애그리거트 단위로 설정되어, 하나의 애그리거트 내의 변경은 모두 함께 성공하거나 실패해야 합니다.
    • 예시: Order 애그리거트 – Order 엔티티(루트) + LineItem 값 객체들 + Address 값 객체 등. Order 엔티티는 LineItem을 추가/제거하거나, 배송지 Address를 변경하는 메소드를 제공하고, 이러한 작업 시 유효성 검사를 수행해 전체 일관성을 유지합니다.

애그리거트의 예 –  Order 애그리거트 와  Customer 애그리거트 를 나타낸 도메인 모델 다이어그램.

보라색 점선으로 묶인 부분이 각각 하나의 애그리거트를 의미하며, Order (엔티티)이 주문 애그리거트의 루트, Customer (엔티티)이 고객 애그리거트의 루트로 표시되어 있습니다. 각 애그리거트 내부에 여러 값 객체(Value Object)들이 포함되어 도메인 모델이 구성된 것을 볼 수 있습니다. (예: Order 내부에 LineItem, Address 등이 값 객체로 존재)

  • 도메인 서비스(Domain Service): 도메인 서비스는 특정 엔티티에 속하지 않은 비즈니스 로직을 구현하는 객체입니다. 일반적으로 하나의 엔티티로 해결하기 어렵거나 여러 엔티티에 걸친 작업을 수행할 때 도메인 서비스를 사용합니다.
    • 도메인 서비스는 상태를 가지지 않는 경우가 많으며 (stateless), 오직 행위만을 제공합니다. 예를 들어, _송금 서비스(TransferService)_가 계좌(Account) 엔티티 두 개를 받아 한 계좌에서 출금하고 다른 계좌에 입금하는 로직을 수행한다고 해봅시다. 이 로직은 두 Account 엔티티에 걸쳐 있으므로 Account 엔티티 내부보다는 별도의 AccountDomainService로 만드는 편이 응집도가 높습니다.
    • 도메인 서비스의 네이밍은 해당 비즈니스 작업을 나타내도록 짓습니다. (예: 환율 변환 서비스, 배송비 계산 서비스 등) 도메인 서비스도 도메인 계층의 일부이므로, 다른 도메인 객체들과 함께 유비쿼터스 언어에 맞는 용어로 표현되어야 합니다.

도메인 모델링 핵심: 도메인 모델은 엔티티, 값 객체, 애그리거트, 도메인 서비스 등의 개념을 통해 현실 세계의 비즈니스를 소프트웨어 객체로 나타낸 것입니다. 올바른 도메인 모델링을 하면 시스템이 현실 비즈니스 규칙을 정확히 반영하고, 코드가 비즈니스 언어와 1:1로 대응되어 이해하기 쉬워집니다. 또한 모델 경계를 명확히 (애그리거트 단위 등) 정하면 한 영역의 변경이 다른 영역에 미치지 않도록 일관성 유지와 변경 용이성을 모두 잡을 수 있습니다.

객체지향 설계 원칙과 도메인 모델

도메인 모델을 설계할 때는 객체 지향 설계 원칙을 적용하면 응집도 높은 구조를 얻을 수 있습니다. 특히 SOLID 원칙은 도메인 계층에도 유용하게 적용됩니다. SOLID는 다음 다섯 가지 객체지향 원칙의 약자입니다:

  • 단일 책임 원칙 (SRP) – 한 클래스는 하나의 책임만 가져야 한다.
  • 개방-폐쇄 원칙 (OCP) – 클래스는 확장에 열려 있고 변경에는 닫혀 있어야 한다. (기존 코드를 수정하지 않고 기능을 추가하도록 설계)
  • 리스코프 치환 원칙 (LSP) – 자식 클래스는 부모 클래스 대신 사용할 수 있어야 한다. (계약의 호환성)
  • 인터페이스 분리 원칙 (ISP) – 클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않아야 한다. (인터페이스를 역할에 따라 세분화)
  • 의존성 역전 원칙 (DIP) – 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 모두 추상화에 의존해야 한다.

SOLID 원칙을 도메인 모델에 적용하면 유지보수성이 높고 유연한 설계를 얻을 수 있습니다. 예를 들어, 엔티티 클래스를 설계할 때 단일 책임 원칙을 지켜 응집도를 높이고, 도메인 로직을 여러 클래스로 분리할 때 DIP를 적용해 결합도를 낮추는 식입니다. 아래에 응집도와 결합도를 고려한 설계 팁을 정리합니다:

  • 응집도(Coherence): 클래스나 모듈 내부의 요소들이 서로 얼마나 밀접하게 관련되어 있는지를 나타냅니다. 응집도가 높다는 것은 하나의 모듈이 단일한 목적을 잘 수행한다는 뜻입니다. 예를 들어 Order 엔티티 클래스가 주문과 관계없는 회원 인증 로직을 가지고 있다면 응집도가 낮아집니다. 높은 응집도를 위해서는 관련 있는 데이터와 기능을 한 클래스에 모으고, 관련 없는 것은 분리해야 합니다. 응집도가 높으면 모듈이 하나의 논리적 단위를 이루고 유지보수가 쉬워집니다.
  • 결합도(Coupling): 모듈 간의 상호 의존성 정도를 의미합니다. 결합도가 높으면 한 모듈의 변경이 다른 모듈에 쉽게 영향을 주며, 시스템이 불안정해집니다. 이상적인 설계는 필요한 최소한으로만 모듈이 연결되어 있는 낮은 결합도를 갖는 것입니다. 예를 들어, 도메인 계층의 코드가 특정 데이터베이스 프레임워크(Entity Framework 등)에 직접 의존한다면 인프라 변화에 취약해집니다. 이를 줄이기 위해 저수준 구현(인프라)에 의존하지 않고 인터페이스나 추상화를 사용하여 의존성을 역전(DIP)시키는 것이 좋습니다.

응집도와 결합도는 트레이드오프 관계처럼 보일 수 있지만, 둘 다 높일 수 있는 방향으로 설계하는 것이 가능합니다. 예컨대, 도메인 개념별로 클래스를 잘 나누고(SRP로 응집도 상승), 각 클래스 간 통신을 인터페이스로 제한하면(DIP로 결합도 감소) 깨끗한 도메인 모델을 얻을 수 있습니다. 이러한 원칙들은 결국 도메인 모델이 변경에 유연하고 이해하기 쉬운 구조가 되도록 돕습니다.

도메인 주도 설계 (DDD: Domain-Driven Design)

도메인 주도 설계(DDD)는 소프트웨어의 복잡한 도메인을 다루기 위한 설계 접근법으로, 도메인 모델에 집중하고 팀의 모든 구성원이 공유하는 언어를 구축하는 것을 중시합니다. DDD의 여러 개념 중 특히 중요한 몇 가지를 살펴보겠습니다:

  • 유비쿼터스 언어 (Ubiquitous Language): DDD에서 제시하는 개념으로, 도메인 전문가와 개발자가 공통으로 사용하는 언어를 뜻합니다. 유비쿼터스 언어를 통해 요구 사항을 논의하고 코드를 작성하면, 용어의 혼동을 줄이고 모두가 같은 의미로 소통할 수 있습니다. 이 언어는 도메인 모델을 기반으로 정제되며, 소프트웨어의 코드에도 그대로 반영됩니다. 예를 들어 "주문", "결제", "배송" 같은 용어를 팀 전체가 동일한 의미로 이해하고, 코드 클래스명이나 메서드명에도 그 용어를 사용하도록 합니다. 유비쿼터스 언어는 팀 커뮤니케이션의 중심이므로, 모호하거나 일관성 없는 용어가 발견될 때마다 팀원들이 함께 언어를 다듬어 가야 합니다.
  • 바운디드 컨텍스트 (Bounded Context): 큰 도메인을 명확한 경계(context)로 나눠서 각각 독립적인 모델로 관리하는 개념입니다. 하나의 바운디드 컨텍스트는 특정한 도메인 모델이 유효한 범위를 나타내며, 그 안에서는 유비쿼터스 언어의 용어들이 일관된 의미를 갖습니다. 컨텍스트가 다르면 같은 단어라도 의미가 달라질 수 있습니다. 예를 들어 "고객(Customer)"이라는 용어가 영업 컨텍스트와 배송 컨텍스트에서 각각 다른 속성과 행위를 가질 수 있습니다. DDD에서는 이러한 컨텍스트 경계를 명시적으로 정의하여 모델 간 혼동을 막습니다. 또한 컨텍스트마다 별도 모델링과 데이터베이스를 둘 수도 있어, 팀별로 독립적인 작업과 배포가 가능해집니다.
    바운디드 컨텍스트 간에는 필요한 경우 연동 관계를 맺는데, 이때 컨텍스트 간 통신 방식(예: 이벤트, API 호출 등)과 상호 변환되는 언어(Published Language)를 정의하게 됩니다. 이를 통해 각 컨텍스트는 자신의 영역을 보호하면서도 협력할 수 있습니다.
  • 도메인 이벤트 (Domain Event): 도메인 이벤트는 도메인 내에서 발생한 중요한 사건을 객체로 표현한 것입니다. 예를 들어 "주문이 완료되었다", "배송이 출발했다"와 같은 사건이 도메인 이벤트가 됩니다. 도메인 이벤트는 보통 과거형 이름(예: OrderPlaced, DeliveryStarted)으로 명명하며, 해당 이벤트 발생 시 다른 부분에서 이를 구독(listen)하여 비즈니스 로직을 수행할 수 있게 합니다.
    • 도메인 이벤트를 도입하면 한 엔티티의 상태 변경에 대한 부수 효과(side effect)를 명시적으로 관리할 수 있다는 장점이 있습니다. 예를 들어, 주문이 완료되면 포인트 적립, 이메일 발송 등의 후속 처리가 필요할 때, 이를 Order 엔티티 내부에서 직접 호출하는 대신 "OrderCompleted" 이벤트를 발생시키고, 별도의 핸들러들이 그 이벤트를 처리하게 할 수 있습니다. 이렇게 하면 Order 엔티티는 핵심 로직만 유지하고, 부가 로직은 이벤트 핸들러로 분리되어 결합도를 낮춥니다.
    • 도메인 이벤트는 동일한 바운디드 컨텍스트 내에서 동기적으로 처리하거나, 또는 시스템 경계를 넘어 비동기 메시지로 전달되어 이벤트 드리븐 아키텍처를 구성할 수도 있습니다. 이벤트를 활용하면 모델 간 느슨한 연결(loose coupling)이 가능해지고, 나중에 새로운 요구사항이 생겨 이벤트에 반응하는 로직을 추가해도 기존 코드에 영향을 주지 않아 확장성이 높아집니다.
  • 이벤트 소싱 (Event Sourcing): 이벤트 소싱은 시스템의 상태를 이벤트의 시퀀스(이력)로 저장하고 관리하는 기법입니다. 전통적으로 데이터베이스에 현재 상태만 저장하는 것과 달리, 이벤트 소싱을 적용하면 모든 상태 변화를 이벤트로 남겨둡니다. 예를 들어, 계좌(Account) 도메인에 이벤트 소싱을 적용하면 "잔액 1000원에서 500원 출금", "잔액 500원에서 300원 입금" 같은 이벤트들을 누적 저장하고, 현재 계좌 잔액은 이 이벤트들을 순차적으로 재생(replay)하여 산출합니다.
    • 이벤트 소싱의 이점은 완전한 감사 로그(audit log)를 얻고, 과거 임의 시점의 상태를 재구성할 수 있다는 것입니다. 또한 이벤트 스트림을 분석하여 비즈니스 인사이트를 얻거나, 복구 및 트랜잭션 처리에 활용할 수도 있습니다.
    • 다만 이벤트 소싱을 도입하면 시스템 구현 복잡도가 증가하고, 이벤트 설계에 신중해야 합니다. DDD에서는 필요한 경우에 한해 전략적으로 이벤트 소싱을 적용하며, 특히 도메인 이벤트와 함께 사용하면 과거 시점 복원, CQRS 패턴과의 결합 등의 강력한 효과를 낼 수 있습니다.

정리하면, DDD의 전략적 설계는 유비쿼터스 언어와 바운디드 컨텍스트로 큰 그림을 그리고, 전술적 설계는 엔티티, 값 객체, 애그리거트, 도메인 서비스, 도메인 이벤트 등의 패턴으로 구체적인 구현을 다루는 형태입니다. DDD를 따르면 팀 내 소통이 원활해지고, 복잡한 도메인 로직도 일관된 모델로 유지되며, 요구사항 변경에 따른 모델 수정도 비교적 수월해집니다.

도메인 관련 패턴 및 설계 기법

도메인 모델을 구현하고 활용하는 데 도움을 주는 여러 가지 설계 패턴이 있습니다. 이러한 패턴들은 도메인 로직과 인프라스트럭처를 깔끔하게 분리하고, 복잡한 객체 생성이나 데이터 저장 등의 문제를 해결하도록 도와줍니다.

  • 리포지토리 패턴 (Repository Pattern): 리포지토리는 도메인 객체의 영속성(storage)을 관리하는 저장소 역할 객체입니다. 마틴 파울러는 리포지토리를 "도메인과 데이터 매핑 계층 사이를 중재하며, 컬렉션처럼 도메인 객체를 접근하는 인터페이스"라고 정의했습니다. 쉽게 말해, 리포지토리는 데이터베이스 등의 세부사항을 감춘 채 도메인 객체를 메모리의 컬렉션 다루듯 추가, 조회, 삭제할 수 있게 합니다.

    • 리포지토리를 사용하면 도메인 계층의 코드가 SQL이나 ORM 등에 의존하지 않고, save(order)findById(orderId) 같은 의미적인 메서드로 영속화 작업을 수행합니다. 이는 의존성 역전 원칙(DIP)에도 부합하여, 도메인 계층은 리포지토리 인터페이스에만 의존하고 실제 구현은 인프라 계층에 위임합니다.
    • 예를 들어, OrderRepository 인터페이스를 정의해두고, 구현체로 JpaOrderRepository (JPA 이용)나 MemoryOrderRepository (테스트용 인메모리 구현)를 둘 수 있습니다. 도메인 로직에서는 어느 구현인지 몰라도 상관없이 orderRepository.save(order)처럼 호출하면 됩니다.
    • 장점: 도메인 모델이 영속성 기술과 무관하게 순수하게 유지되고, 영속성 로직이 한 곳(리포지토리 구현)에 모여 응집도가 높아집니다. 또한 테스트 시에는 가짜 구현체(Mock Repository)로 교체하여 도메인 로직을 빠르게 검증할 수 있습니다.
  • 팩토리 패턴 (Factory Pattern): 복잡한 도메인 객체의 생성 로직을 캡슐화하기 위한 패턴입니다. 엔티티나 애그리거트를 생성할 때 필요한 값 설정이나 불변 조건 체크 등이 많아지면, 생성자를 직접 호출하는 것보다 팩토리를 통해 객체를 생성하는 것이 코드의 책임을 분리하는 데 도움이 됩니다.

    • 팩토리는 메서드(정적 팩토리 메서드)일 수도 있고 별도 객체(팩토리 클래스)일 수도 있습니다. 예를 들어, Order.createNewOrder(customer, items)와 같은 정적 팩토리 메서드를 Order 엔티티에 정의하여, 주문 생성 시 필요한 검증(재고 확인 등)을 수행한 후 Order 객체를 반환하도록 만들 수 있습니다. 또는 OrderFactory 클래스를 만들어 복잡한 주문 생성 절차를 그 안에서 처리할 수도 있습니다.
    • 장점: 객체 생성 로직이 분리되어 도메인 객체의 코드가 단순해지고, 생성 과정의 변경이 필요할 때 해당 팩토리만 수정하면 되므로 OCP를 지키기 쉽습니다. 또한 어떤 구현 클래스의 인스턴스를 반환할지 팩토리에서 결정할 수 있으므로 다형성 활용에도 유리합니다.
  • 빌더 패턴 (Builder Pattern): 빌더는 특히 필드가 많은 도메인 객체를 단계적으로 생성할 때 유용한 패턴입니다. 예를 들어, 필수 값만으로 객체를 만들고 나중에 선택 값을 설정한다거나, 유창한 인터페이스(fluent interface)로 가독성 있게 객체를 초기화할 때 사용합니다. 도메인 모델에서는 주로 복합 값 객체를 만들 때 빌더를 쓰거나, 테스트 시에 편리하게 객체를 생성하는 용도로 활용합니다.

    • 예를 들어:

      Order order = OrderBuilder.newBuilder()
                     .withCustomer(customer)
                     .withItem(product1, 2)
                     .withItem(product2, 1)
                     .withShippingAddress(addr)
                     .build();

      위와 같은 빌더를 구현하면, 복잡한 Order 객체를 누락 없이 이해하기 쉽게 생성할 수 있습니다. 빌더 패턴은 가독성 향상생성 과정의 유연성을 제공합니다.

  • 애그리거트 및 도메인 규칙 검증: 앞서 애그리거트가 내부 불변 조건을 지킨다고 했는데, 이를 구현할 때 도메인 모델 내에서 규칙을 검증하는 것이 중요합니다. 예를 들어, Order 애그리거트는 주문 추가 시 재고(stock)를 확인해야 한다면, Order.addItem(product, quantity) 메소드 내에서 product.isStockAvailable(quantity)를 체크하고 없으면 예외를 던지는 식으로 규칙을 강제합니다. 이러한 도메인 검증 로직은 엔티티나 값 객체의 메서드로 구현하여 응집도를 높이고, 규칙 위반 상태가 애초에 생성되지 않도록 해야 합니다.

    • 또한 애그리거트 루트는 자신이 포함하는 엔티티/값 객체들의 일관성을 책임지므로, 외부에서 애그리거트 내부 객체들을 변경하지 못하게 설계합니다. (애그리거트 내부 컬렉션을 private으로 두고, 루트를 통해서만 조작하도록)

이 외에도 DDD 전술 패턴에는 도메인 이벤트(앞서 설명), 사양 패턴(Specification) 등이 있습니다. 사양 패턴은 복잡한 비즈니스 규칙을 객체로 캡슐화하여 재사용하거나 조합할 수 있게 해주는 패턴으로, 예컨대 "골드 등급 고객 AND 최근 1년 이내 구매횟수 > 5" 같은 조건을 Specification 객체로 표현해두고 다른 로직에서 활용하는 식입니다. 이러한 패턴들은 상황에 맞게 적용하면 도메인 로직을 더욱 명확하고 견고하게 만들 수 있습니다.

도메인 로직의 테스트 및 유지보수 전략

도메인 계층은 복잡한 비즈니스 로직의 집약체이므로, 철저한 테스트와 지속적인 리팩토링이 특히 중요합니다. 몇 가지 권장 전략을 소개합니다:

  • 단위 테스트 작성: 도메인 로직에 대한 단위 테스트를 작성하여, 엔티티나 도메인 서비스의 메서드들이 예상대로 동작하는지 검증합니다. 예를 들어 Order 엔티티의 calculateTotal() 메서드나 addItem() 메서드를 테스트하여 합계 계산이나 아이템 추가 로직이 규칙에 맞는지 확인합니다.
    • 도메인 계층은 UI나 DB와 독립적이기 때문에, 순수한 자바 객체(POJO) 형태로 테스트하기 쉽습니다. 입력 값과 기대 결과만으로 테스트할 수 있어 테스트 자동화에 유리합니다.
    • 비즈니스 규칙마다 경계 조건(엣지 케이스)을 고려한 테스트 케이스를 마련하고, 도메인 전문가에게 검증받은 시나리오들을 시뮬레이션하는 것이 좋습니다.
  • Mock과 Stub을 이용한 격리: 도메인 계층을 테스트할 때 다른 계층 의존성이 있다면 (예: 도메인 서비스가 리포지토리를 사용), 모의 객체(Mock)스텁(Stub)을 활용하여 의존성을 격리합니다. 예를 들어, PaymentService 도메인 서비스가 BankApiClient를 사용한다면, 테스트 시에는 실제 은행 API를 호출하지 않도록 BankApiClient의 모조 구현을 주입하여 (의미 있는 응답을 미리 반환하게) 테스트를 수행합니다. 이를 통해 외부 환경과 무관하게 도메인 로직만을 검증할 수 있고, 테스트 실행 속도도 빨라집니다.
  • 통합 테스트의 최소화: 도메인 로직은 가능하면 단위 테스트로 철저히 검증하고, 인프라를 포함한 통합 테스트는 주요 시나리오 위주로 최소화하는 것이 피드백 루프를 빠르게 합니다. 예컨대 주문 생성부터 결제까지 이어지는 전체 흐름은 통합 테스트로 확인하되, 세부 규칙들은 각각 단위 테스트로 커버하는 전략입니다.
  • 지속적인 리팩토링: 테스트가 뒷받침되면 도메인 모델을 점진적으로 개선하기가 수월해집니다. 새로운 요구사항이 나오거나 설계상 개선점이 보일 때, 안전망인 테스트를 믿고 리팩토링을 진행합니다. 예를 들어 계산 로직을 더 효율적으로 바꾸거나, 메서드 분리를 통해 가독성을 높이는 변경을 할 때 단위 테스트가 통과하는지 확인하며 진행합니다. 리팩토링의 궁극적 목표는 도메인 의도가 코드에 더 잘 드러나도록 하는 것이며, 유비쿼터스 언어에 어긋나는 이름이나 중복 로직 등을 꾸준히 정돈해나가야 합니다.
  • 코드 리뷰와 도메인 지식 공유: 도메인 모델은 팀의 핵심 자산이므로, 코드 리뷰를 통해 도메인 지식을 공유하고 설계 결정을 함께 논의하는 것이 좋습니다. 리뷰 과정에서 도메인 용어의 사용이 적절한지, 비즈니스 규칙이 정확히 구현됐는지 서로 점검합니다. 이는 버그를 줄일 뿐만 아니라, 팀원들이 도메인 개념을 깊이 있게 이해하는 데 도움을 줍니다.

테스트와 유지보수 요점: 탄탄한 테스트 suite는 도메인 모델의 품질을 지켜주는 안전망입니다. 테스트 가능한 구조로 설계하는 것 자체가 좋은 도메인 모델링의 지표이며, 이를 통해 언제든 리팩토링하여 모델을 발전시켜나갈 수 있습니다. 비즈니스 변화에 대응하여 코드를 수정할 때도, 테스트를 통과하면 기존 기능을 보호하면서 변경을 안심하고 배포할 수 있습니다.

실제 사례와 경험 공유

잘 설계된 도메인 모델은 장기적인 유지보수성과 확장성에서 큰 이점을 가져옵니다. 몇 가지 사례를 가상으로 살펴보겠습니다:

  • 성공 사례: 한 전자상거래 프로젝트에서 DDD 원칙에 따라 주문, 카탈로그, 배송 등의 바운디드 컨텍스트를 정의하고 각 컨텍스트에 맞는 도메인 모델을 구축했습니다. 팀은 유비쿼터스 언어로 용어를 정립하여 개발자와 도메인 전문가(업무 담당자)가 같은 언어로 대화했습니다. 그 결과 요구사항 오해가 줄었고, 신규 기능 추가 시에도 어떤 컨텍스트에 넣어야 할지가 명확하여 개발 속도가 빨라졌습니다. 예를 들어 "할인 쿠폰 적용" 기능이 생겼을 때, 주문 컨텍스트의 Order 애그리거트에 쿠폰 개념을 추가하고 관련 서비스만 수정하면 되었고, 다른 컨텍스트와는 경계가 분리되어 부작용이 없었습니다. 이렇듯 명확한 도메인 분리는 시스템을 모듈화하고 팀 간 작업을 병렬화하여 전체 생산성을 높였습니다.
  • 실패 및 교훈: 반대로 초기에 도메인 모델링을 제대로 하지 못한 사례도 있습니다. 어느 프로젝트에서는 도메인 지식이 부족한 상태에서 화면 기능별로만 클래스를 나누는 빈약한 모델(anemic model) 형태로 개발이 진행되었습니다. 모든 비즈니스 로직이 서비스 클래스에 절차적으로 몰려 있고, 엔티티는 단순한 데이터 구조로 전락했던 것입니다. 시간이 지날수록 서비스 클래스는 거대해지고 중복 코드가 늘어갔으며, 업무 규칙 변경 시 곳곳에 흩어진 코드를 모두 수정해야 하는 문제가 발생했습니다. 결국 일정이 지연되고 버그가 증가하여, 나중에야 DDD를 도입하면서 도메인 모델을 재구축해야 했습니다. 이 경험은 처음부터 올바른 도메인 개념을 파악하고 모델링하는 것의 중요성을 일깨워주었습니다. 또한 한편으로는 도메인 모델링에 너무 집착한 나머지 과도하게 추상적인 설계를 한 사례도 있는데, 필요 이상으로 복잡한 상속 구조와 일반화된 개념을 도입했다가 정작 구현이 어려워진 경우였습니다. 이 경우 역시 현재 필요한 수준으로 모델을 단순하게 유지하고, 추후 확장에 대비하되 미리 만들지 않는 게 중요하다는 교훈을 남겼습니다.

결론적으로, 도메인 모델링은 균형 잡힌 접근이 필요합니다. 도메인 지식을 최대한 끌어모아 모델에 반영하되, 팀의 생산성을 떨어뜨릴 정도의 과도한 일반화나 추상화는 지양해야 합니다. 실제 현업에서는 핵심적인 도메인에 집중하여 모델링하고, 부차적인 부분은 상황에 따라 타협하는 경우도 있습니다. 중요한 것은 모델이 현실 비즈니스를 잘 표현하면서도 개발 가능한 형태로 유지되는 것이며, 이를 위해 지속적인 학습과 피드백이 필요합니다.

아키텍처 관점에서 본 도메인: 계층 및 헥사고날 아키텍처

도메인 계층은 전체 시스템 아키텍처에서 핵심 역할을 하며, 다양한 아키텍처 패턴에서 중심부(core)로 다뤄집니다. 대표적인 아키텍처 스타일에서 도메인의 위치와 상호작용을 살펴보겠습니다:

  • 레이어드 아키텍처(Layered Architecture): 전통적인 3계층 또는 N계층 아키텍처에서 도메인 계층(비즈니스 로직 계층)은 프레젠테이션(표현) 계층과 데이터 접근(영속성) 계층 사이에 위치합니다. 프레젠테이션 계층은 주로 UI나 API로 사용자/외부와 상호작용하고, 데이터 접근 계층(인프라)은 DB나 외부 시스템 연동을 담당합니다. 도메인 계층은 이 둘 사이에서 비즈니스 규칙을 처리하는 역할을 합니다. 이러한 계층 구조에서는 상위 계층이 하위 계층에 의존하고, 도메인 계층보다 아래의 세부사항에 도메인 로직이 침투하지 않도록 계층 간 의존성을 관리합니다. (예: UI 계층 -> 도메인 계층 -> 인프라 계층 순으로 의존)
  • 헥사고날 아키텍처 (Hexagonal Architecture, 포트와 어댑터 패턴): 헥사고날 아키텍처에서는 도메인 로직을 애플리케이션 코어로 두고, 이를 둘러싸고 있는 여섯 면(실제로는 임의의 다각형 형태)의 포트(Ports)를 통해 외부와 통신합니다. 그리고 각 포트에 대응하는 어댑터(Adapter)들이 UI, DB, 메시징 등 구체 구현을 맡습니다. 쉽게 말해, 도메인 계층이 어느 한쪽 기술에 치우치지 않게 경계 인터페이스(포트)를 정의하고, 실제 동작은 어댑터가 수행하도록 하는 구조입니다.
    • 예를 들어, 주문 애플리케이션을 헥사고날로 설계하면, 코어에는 OrderServiceOrder 애그리거트 등의 도메인 로직이 있습니다. 코어는 "주문 생성", "결제 요청" 같은 기능을 포트 인터페이스로 노출합니다. 한쪽 면에 UI 어댑터가 연결되어 웹 요청을 받아 포트 인터페이스를 호출하고, 다른 쪽 면에 DB 어댑터가 연결되어 코어가 호출하는 저장 포트를 구현합니다. 또 다른 면에는 결제 시스템 연동 어댑터가 있을 수 있습니다. 각 면(포트)을 통해 코어와 외부가 소통하며, 코어 자신은 어떤 어댑터가 연결되었는지 몰라도 포트를 통해 추상적으로만 상호작용하므로 느슨한 결합이 달성됩니다.

헥사고날 아키텍처 – 중앙의 Application(core)이 도메인 로직을 담당하며, 주변의 육각형 모양 포트들을 통해 UI, 테스트, 로깅, SMS, Email, DB, 파일 등 다양한 어댑터들과 연결됩니다. 이 그림에서 짙은 파란색 육각형이 도메인 코어이고, 그 주위의 파란 타원들은 각종 어댑터를 나타냅니다. 이러한 구조 덕분에 코어는 어떤 방식으로 호출되거나 어떤 저장소에 데이터를 쓰는지에 대해 몰라도 되고, 새로운 어댑터(SMS, Email 등)를 추가하거나 교체해도 코어를 수정하지 않아도 되는 유연성을 얻습니다.

  • Onion 아키텍처 (Onion Architecture): 이것도 헥사고날과 유사한 개념으로, 도메인 모델을 가장 안쪽 핵심에 두고 계층을 동심원처럼 싸는 형태입니다. 바깥쪽으로 갈수록 인프라스트럭처 계층이 위치합니다. 의존성은 항상 바깥 쪽이 안쪽을 의존하도록 설계되며, 안쪽의 도메인 모델은 어떤 것도 의존하지 않고 순수한 상태를 유지합니다. 이 역시 결과적으로 도메인 로직의 순수성의존성 역전을 강조하는 아키텍처입니다.
  • 클린 아키텍처 (Clean Architecture): 로버트 C. 마틴의 클린 아키텍처는 앞의 두 가지를 포함하는 개념으로, 엔티티/유즈케이스(도메인)에 해당하는 계층이 가장 안쪽에 있고, 프레젠테이션, 영속성, 외부 디바이스 등이 바깥 계층에 위치합니다. 규칙은 안쪽 계층은 바깥을 전혀 모른다는 것입니다. 결국 도메인 계층이 어떤 UI나 DB에도 영향받지 않고 독립적이어야 하며, 모든 의존성은 반대 방향으로 흐릅니다.

도메인 계층의 상호작용

도메인 계층은 애플리케이션의 중심이지만, 결국 단독으로 존재하지는 않습니다. 다른 계층들과의 상호작용 방식을 이해하는 것도 중요합니다:

  • 도메인 <-> 프레젠테이션(또는 애플리케이션) 계층: 프레젠테이션 계층(예: 웹 컨트롤러)은 사용자의 입력을 받아 애플리케이션(서비스) 계층에 전달하고, 이 서비스 계층이 도메인 객체를 이용해 업무를 처리합니다. 처리 결과는 다시 DTO(Data Transfer Object) 형태로 변환되어 프레젠테이션 계층으로 보내집니다. 이때 유즈케이스 단위로 애플리케이션 서비스가 도메인 로직을 호출하는 패턴이 흔합니다. (예: OrderService.placeOrder(orderRequest) 가 내부에서 Order 엔티티를 생성하고 orderRepository.save()를 호출)
  • 도메인 <-> 인프라 계층: 도메인 객체는 직접 DB나 메시지 시스템을 만지지 않고, 앞서 언급한 리포지토리나 인터페이스를 통해 작업을 위임합니다. 예를 들어 orderRepository.save(order)를 호출하면, 인프라 계층의 구현체가 DB에 저장합니다. 마찬가지로 외부 시스템 호출이 필요하면 도메인 계층은 이를 추상화한 인터페이스(예: PaymentGateway 인터페이스)를 호출하고, 실제 HTTP 통신은 인프라의 구현체가 처리합니다. 이러한 구조 덕분에 테스트 시에는 인프라 구현을 모의 객체로 대체하기도 쉽습니다.
  • 도메인 이벤트 활용: 도메인 계층에서 발생시킨 이벤트는 인프라 계층의 이벤트 버스를 통해 다른 바운디드 컨텍스트나 내부 리스너로 전달될 수 있습니다. 이를 통해 도메인 계층 간의 느슨한 연결과 최종 일관성을 구현합니다. 예를 들어 Order 컨텍스트에서 "OrderPlaced" 이벤트를 발행하면, Customer 컨텍스트에서 이를 구독해 "구매 횟수 증가" 로직을 실행하는 식으로 컨텍스트 간 협력을 합니다. 이 경우 이벤트 전달은 인프라(메시징 시스템 등)가 담당하지만, 이벤트의 의미와 처리는 도메인 수준에서 정의합니다.

요약하면, 현대적인 아키텍처에서 도메인 계층은 언제나 중심에 있으며, 그 주위를 감싸는 다양한 어댑터나 인터페이스를 통해 세상을 접합니다. 이것은 도메인 로직을 보호하고, 시스템을 변경에 강하게 만드는 핵심 설계 철학입니다.


마무리

OOP에서 도메인 개념을 이해하는 것은 초보 개발자에게 매우 중요한 일입니다. 도메인은 단순히 데이터나 기능 묶음이 아니라, 소프트웨어가 풀고자 하는 문제 그 자체입니다. 도메인을 제대로 파악하면 어떤 클래스가 있어야 하고, 어떤 규칙을 코드로 구현해야 하는지가 명확해집니다.

지금까지 살펴본 바와 같이, 도메인 모델링에는 엔티티, 값 객체, 애그리거트, 도메인 서비스 같은 개념들이 쓰이고, 이를 뒷받침하는 여러 설계 원칙과 패턴(SOLID, 리포지토리, 팩토리 등)이 존재합니다. 처음에는 다소 복잡해 보일 수 있지만, 핵심은 "현실 세계의 비즈니스를 코드 세계에 자연스럽게 옮기는 것"입니다. 그 과정에서 팀원 모두가 이해할 수 있는 유비쿼터스 언어를 만들고, 경계(바운디드 컨텍스트)를 설정하며, 변화에 대응하기 쉽도록 아키텍처를 구성하는 것이 좋습니다.

마지막으로, 도메인 지식에 대한 겸손도 필요합니다. 프로젝트를 진행하면서 도메인 전문가와 적극적으로 소통하고, 도메인 지식을 습득하며, 이를 모델에 반영하는 반복적인 과정을 거쳐야 합니다. 잘 다듬어진 도메인 모델은 시간이 지날수록 큰 자산이 되어, 요구사항 변경에도 끄떡없는 튼튼한 소프트웨어를 만들어줍니다. 초보 개발자라도 이번 내용을 바탕으로 작은 시스템부터 도메인 중심으로 설계를 연습해보고, 조금씩 개선해나간다면 어느새 도메인 주도 설계의 이점을 체감하게 될 것입니다.

728x90
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
글 보관함