2.1 도메인 주도 설계의 이해

2.1 도메인 주도 설계의 이해

도메인 주도 설계(Domain-Driven Design, DDD)는 소프트웨어 개발에서 복잡한 비즈니스 도메인을 효과적으로 다루기 위한 접근 방식입니다. 이는 단순한 기술적 해결책이 아닌, 도메인 전문가와 개발자 간의 협력을 통해 비즈니스의 본질적인 복잡성을 다루는 전략적인 설계 방법론입니다.

2.1.1 도메인 주도 설계가 해결하고자 하는 문제

현대의 소프트웨어 시스템은 점점 더 복잡해지고 있습니다. 이러한 복잡성은 크게 두 가지로 구분할 수 있습니다. 첫째는 기술적 복잡성이고, 둘째는 도메인 자체의 복잡성입니다. 기술적 복잡성은 프레임워크나 도구의 발전으로 어느 정도 해결할 수 있지만, 도메인의 복잡성은 그렇지 않습니다.

예를 들어, 도서관 시스템에서 ‘대출’이라는 개념을 생각해보겠습니다. 단순히 데이터베이스의 상태를 변경하는 것처럼 보이지만, 실제로는 다음과 같은 복잡한 규칙들이 존재합니다:

public class BookCheckout {
    public CheckoutResult checkout(Member member, BookCopy bookCopy) {
        // 도메인 규칙 1: 회원의 대출 자격 검사
        if (member.hasOverdueBooks()) {
            return CheckoutResult.rejected("연체 중인 도서가 있어 대출이 불가능합니다.");
        }

        // 도메인 규칙 2: 도서의 대출 가능 상태 검사
        if (!bookCopy.isAvailable()) {
            return CheckoutResult.rejected("현재 대출 불가능한 도서입니다.");
        }

        // 도메인 규칙 3: 대출 한도 검사
        if (member.getCurrentCheckouts() >= member.getCheckoutLimit()) {
            return CheckoutResult.rejected("대출 한도에 도달했습니다.");
        }

        // 모든 규칙을 통과한 후 대출 처리
        return processCheckout(member, bookCopy);
    }
}

이러한 코드에서 볼 수 있듯이, 실제 비즈니스 규칙은 단순한 데이터 처리 이상의 복잡성을 가지고 있습니다. 도메인 주도 설계는 이러한 복잡한 비즈니스 규칙들을 효과적으로 모델링하고 관리하기 위한 방법을 제공합니다.

2.1.2 전략적 설계

전략적 설계는 복잡한 소프트웨어 시스템을 효과적으로 구조화하기 위한 높은 수준의 설계 방법을 제공합니다. 주요 요소들을 살펴보겠습니다.

바운디드 컨텍스트(Bounded Context)

바운디드 컨텍스트는 특정 도메인 모델이 적용되는 명확한 경계를 의미합니다. 이는 동일한 용어라도 문맥에 따라 다른 의미를 가질 수 있다는 점을 인식하고, 이를 명시적으로 구분하는 방법을 제공합니다.

예를 들어, 도서관 시스템에서 ‘도서(Book)‘라는 개념의 의미는 컨텍스트에 따라 다릅니다. 도서관 직원이 “이 책의 저자가 누구지?“라고 물을 때의 ‘책’과 “이 책은 현재 대출 중이에요.“라고 말할 때의 ‘책’은 같은 물리적 대상을 가리키지만, 소프트웨어 관점에서는 매우 다른 개념입니다.

도서 관리 컨텍스트에서 ‘Book’은 지적 저작물로서의 책을 의미합니다. ISBN, 제목, 저자, 출판정보와 같은 서지 정보가 중요하며, 이러한 정보는 한 번 등록되면 거의 변경되지 않습니다:

// 도서 관리 컨텍스트의 Book
public class Book {
    private final BookId id;
    private String title;         // 제목은 도서의 본질적 속성
    private Author author;        // 저자 정보
    private ISBN isbn;           // 국제표준도서번호
    private List<Category> categories;  // 도서 분류
    private PublicationInfo publicationInfo;  // 출판 정보

    // 도서 정보 관리에 초점을 맞춘 메서드
    public void updateMetadata(BookMetadata metadata) {
        this.title = metadata.title();
        this.author = metadata.author();
        this.categories = metadata.categories();
    }

    // 새로운 판(edition)이 나왔을 때의 처리
    public void addNewEdition(EditionInfo editionInfo) {
        this.editions.add(new Edition(editionInfo));
        this.notifyNewEditionAvailable();
    }
}

반면, 대출 관리 컨텍스트에서 ‘Book’은 대출 가능한 물리적 자산으로서의 책을 의미합니다. 여기서는 현재 대출 상태, 대출 이력, 반납 예정일 등이 중요하며, 이러한 정보는 자주 변경됩니다:

// 대출 관리 컨텍스트의 Book
// 대출 관리 컨텍스트의 Book
public class Book {
   private final BookId id;
   private BookStatus status;        // 현재 대출 상태
   private Member currentBorrower;   // 현재 대출자
   private LocalDate dueDate;        // 반납 예정일
   private List<BorrowHistory> borrowHistory;  // 대출 이력

   // 대출 상태 관리에 초점을 맞춘 메서드
   public BorrowResult borrowTo(Member member) {
      if (status != BookStatus.AVAILABLE) {
         return BorrowResult.rejected("현재 대출 불가능한 상태입니다.");
      }
      this.status = BookStatus.BORROWED;
      this.currentBorrower = member;
      this.dueDate = LocalDate.now().plusDays(14);
      this.borrowHistory.add(new BorrowHistory(member, LocalDateTime.now()));
      return BorrowResult.success();
   }

   // 연체 상태 확인
   public boolean isOverdue() {
      return status == BookStatus.BORROWED &&
              LocalDate.now().isAfter(dueDate);
   }
}

이처럼 바운디드 컨텍스트는 같은 ‘도서’라는 개념이라도 각 컨텍스트의 관심사에 따라 다르게 모델링됩니다. 도서 관리 컨텍스트의 Book은 “무엇에 관한 책인가?“에 초점을 맞추고, 대출 관리 컨텍스트의 Book은 “이 책을 누가 가지고 있는가?“에 초점을 맞춥니다. 이러한 구분은 단순히 코드 구조의 문제가 아닙니다. 이는 실제 도서관 업무에서 사용되는 언어와 개념을 정확히 반영하는 것이며, 이를 통해 각 컨텍스트에서 필요한 기능을 더 명확하고 유지보수하기 쉽게 구현할 수 있습니다.

컨텍스트 맵

컨텍스트 맵은 시스템 내의 바운디드 컨텍스트들이 어떻게 상호작용하는지를 보여주는 전략적 설계 도구입니다. 이는 단순한 다이어그램이 아니라, 조직의 의사결정과 팀 간 협업 방식을 반영하는 중요한 문서입니다. 도서관 시스템을 예로 들어보겠습니다. 도서 카탈로그 관리, 대출 관리, 회원 관리 등 여러 바운디드 컨텍스트가 존재하며, 이들은 각각 다른 팀에 의해 개발되고 관리될 수 있습니다. 컨텍스트 맵은 이러한 컨텍스트들 간의 관계를 다음과 같은 패턴으로 정의합니다:

상류-하류(Upstream-Downstream) 관계

한 컨텍스트가 다른 컨텍스트에 영향을 주는 일방향적 관계를 의미합니다. 상류 컨텍스트는 하류 컨텍스트의 요구사항을 고려하지 않고 독립적으로 결정을 내릴 수 있습니다.

// 도서 카탈로그(상류)와 대출 관리(하류) 사이의 관계
public class CatalogService {
   private final BookCatalogContext catalogContext;  // 상류 컨텍스트
   private final LendingContext lendingContext;      // 하류 컨텍스트
   private final ContextMapper mapper;               // 컨텍스트 간 변환기

   public void addNewBook(BookInfo bookInfo) {
      // 상류 컨텍스트는 자신의 모델에 따라 도서를 생성
      CatalogBook catalogBook = catalogContext.createBook(bookInfo);

      // 하류 컨텍스트는 상류의 변경에 맞춰 자신의 모델을 조정
      LendingBook lendingBook = mapper.toLendingBook(catalogBook);
      lendingContext.registerBook(lendingBook);
   }

   // 상류 컨텍스트의 변경이 발생하면 이벤트를 발생시킴
   public void updateBookMetadata(BookId id, BookMetadata metadata) {
      CatalogBook updatedBook = catalogContext.updateBook(id, metadata);
      // 하류 컨텍스트는 이 변경에 대응해야 함
      domainEventPublisher.publish(new BookMetadataUpdatedEvent(updatedBook));
   }
}

// 하류 컨텍스트는 상류 컨텍스트의 변경에 대응하는 리스너를 구현
public class LendingContextEventHandler {
   @EventListener
   public void handleBookMetadataUpdate(BookMetadataUpdatedEvent event) {
      // 대출 관리 컨텍스트의 도서 정보를 업데이트
      LendingBook lendingBook = lendingContext.findBook(event.getBookId());
      lendingBook.updateMetadata(event.getMetadata());
      lendingContext.save(lendingBook);
   }
}

이 예시에서 도서 카탈로그는 상류 컨텍스트로서, 도서의 메타데이터나 분류 체계를 자유롭게 변경할 수 있습니다. 대출 관리는 하류 컨텍스트로서, 이러한 변경에 맞춰 자신의 모델을 조정해야 합니다.

협력(Partnership) 관계

두 컨텍스트가 특정 기능을 위해 함께 성공하거나 실패해야 하는 관계입니다.

// 대출 처리와 결제 처리가 하나의 트랜잭션으로 처리되어야 하는 경우
public class BookCheckoutCoordinator {
    private final LendingContext lendingContext;
    private final PaymentContext paymentContext;
    
    @Transactional
    public CheckoutResult processCheckout(CheckoutRequest request) {
        // 두 컨텍스트의 작업이 모두 성공하거나 모두 실패해야 함
        try {
            // 대출 처리
            LendingResult lendingResult = 
                lendingContext.processLending(request.getBookId(), request.getMemberId());
            
            // 수수료 결제 처리
            PaymentResult paymentResult = 
                paymentContext.processFee(request.getMemberId(), request.getFeeAmount());
            
            if (lendingResult.isSuccess() && paymentResult.isSuccess()) {
                return CheckoutResult.success();
            }
            
            // 하나라도 실패하면 모두 롤백
            throw new CheckoutException("대출 처리 중 오류가 발생했습니다.");
            
        } catch (Exception e) {
            // 실패 시 두 컨텍스트 모두 롤백
            throw new CheckoutException("대출 처리 중 오류가 발생했습니다.", e);
        }
    }
}

공유 커널(Shared Kernel) 관계

여러 컨텍스트에서 도메인 모델의 일부를 공유하는 관계입니다. 이는 긴밀한 팀 협업이 가능한 경우에만 사용해야 합니다.

// 공유 커널(Shared Kernel) 패턴의 예시
public class SharedTypes {
    // 여러 컨텍스트에서 공유하는 기본 타입들
    public record BookId(String value) {
        public BookId {
            Objects.requireNonNull(value, "Book ID must not be null");
        }
    }

    public record ISBN(String value) {
        public ISBN {
            if (!value.matches("\\d{13}")) {
                throw new IllegalArgumentException("Invalid ISBN format");
            }
        }
    }
}

고객-공급자(Customer-Supplier) 관계

한 컨텍스트가 다른 컨텍스트에 서비스를 제공하는 관계입니다.


// 고객-공급자(Customer-Supplier) 패턴의 예시
public interface CatalogService {  // 공급자 인터페이스
    BookInfo getBookInfo(BookId id);
    List<BookInfo> findByCategory(Category category);
    void notifyNewEdition(BookId id, EditionInfo editionInfo);
}

public class LendingService {  // 고객
    private final CatalogService catalogService;
    
    public void processNewEdition(BookId id) {
        BookInfo bookInfo = catalogService.getBookInfo(id);
        // 대출 시스템에 맞게 정보 처리
        updateLendingBookInfo(bookInfo);
    }
}

도메인 하위 영역(Subdomain)

소프트웨어 시스템이 커지고 복잡해지면서 전체 도메인을 한 번에 다루는 것은 매우 어려워집니다. 이는 마치 거대한 회사를 부서 없이 운영하려는 것과 같은 도전과제입니다. 이러한 복잡성을 관리하기 위해 도메인 주도 설계(DDD)에서는 전체 도메인을 더 작은 하위 영역으로 나누어 접근합니다.

하위 영역의 종류

도메인 하위 영역은 비즈니스적 가치와 전문성을 기준으로 세 가지로 구분됩니다:

  • 핵심 도메인(Core Domain)은 비즈니스의 핵심 경쟁력을 담당하는 영역입니다. 이 영역은 가장 많은 투자와 전문가의 관심이 필요합니다. 도서관 시스템을 예로 들면, 도서 대출/반납 관리와 카탈로그 관리가 핵심 도메인에 속합니다. 이러한 기능들은 도서관의 고유한 가치를 창출하고 다른 도서관과의 차별화를 만드는 요소입니다.
  • 지원 도메인(Supporting Domain)은 핵심 도메인을 보조하는 영역입니다. 비즈니스에 특화되어 있지만 핵심은 아닌 기능들을 담당합니다. 도서관에서는 도서 구매 및 폐기 관리, 열람실 관리 등이 여기에 해당합니다. 이러한 기능들은 도서관 운영에 필수적이지만, 주된 경쟁력의 원천은 아닙니다.
  • 일반 도메인(Generic Domain)은 많은 시스템에서 공통적으로 사용되는 기능들을 포함합니다. 회원 관리, 알림 발송, 공지사항 관리와 같은 기능들이 이에 해당합니다. 이러한 기능들은 보통 이미 잘 만들어진 솔루션을 활용하는 것이 효율적입니다.

하위 영역 구분의 기준

하위 영역을 구분할 때는 다음과 같은 요소들을 고려해야 합니다:

  • 비즈니스 가치: 해당 기능이 비즈니스의 성공에 얼마나 중요한 역할을 하는지 평가합니다. 높은 비즈니스 가치를 가진 영역은 일반적으로 핵심 도메인으로 분류됩니다.
  • 전문성 요구: 해당 영역이 특별한 도메인 지식이나 전문성을 필요로 하는지 판단합니다. 도서관의 카탈로그 관리처럼 전문적인 지식이 필요한 영역은 핵심 도메인이나 지원 도메인으로 분류될 가능성이 높습니다.
  • 복잡성과 변경 빈도: 기능의 복잡도와 얼마나 자주 변경이 필요한지를 고려합니다. 복잡하고 자주 변경되는 영역은 더 세심한 관리가 필요하므로, 이는 영역 분류에 중요한 기준이 됩니다.

하위 영역 구분의 이점

도메인을 하위 영역으로 나누면 다음과 같은 실질적인 이점을 얻을 수 있습니다:

  • 복잡성 관리: 전체 시스템의 복잡성을 더 작고 다루기 쉬운 단위로 나눌 수 있습니다. 이는 각 영역에 대한 이해와 관리를 용이하게 합니다.
  • 리소스 최적화: 각 영역의 중요도에 따라 적절한 수준의 리소스를 할당할 수 있습니다. 핵심 도메인에는 많은 투자를, 일반 도메인에는 기존 솔루션을 활용하는 등의 전략적 선택이 가능합니다.
  • 전문성 활용: 각 영역의 특성에 맞는 전문성을 효과적으로 활용할 수 있습니다. 도메인 전문가들은 핵심 영역에 집중할 수 있으며, 일반적인 기술 영역은 해당 분야의 전문가들이 담당할 수 있습니다.
  • 유지보수성 향상: 영역 간의 경계가 명확하므로, 한 영역의 변경이 다른 영역에 미치는 영향을 최소화할 수 있습니다. 이는 시스템의 장기적인 유지보수성을 크게 향상시킵니다.

이러한 하위 영역 구분은 단순한 코드 구조의 문제가 아닌, 비즈니스의 본질적 가치와 조직의 효율성을 고려한 전략적 설계 결정입니다. 적절한 하위 영역 구분은 소프트웨어 시스템의 성공적인 구현과 진화에 핵심적인 역할을 합니다.

코드로 보는 하위 영역 구현 예제

도서관 시스템을 예로 들어 각 하위 영역의 구현을 살펴보겠습니다.

핵심 도메인 (Core Domain)

대출 관리 시스템은 도서관의 핵심 비즈니스 로직을 포함합니다:

// 대출 도메인의 핵심 엔티티
public class Checkout {
   private Member member;
   private Book book;
   private LocalDateTime checkoutDate;
   private LocalDateTime dueDate;
   private CheckoutStatus status;

   // 대출 규칙을 엔티티 내부에 캡슐화
   // isOverdue() 메서드는 현재 대출이 연체 상태인지 확인합니다
   public boolean isOverdue() {
      return LocalDateTime.now().isAfter(dueDate);
   }

   // calculateFine() 메서드는 연체료를 계산합니다
   public BigDecimal calculateFine() {
      if (!isOverdue()) return BigDecimal.ZERO;

      long overdueDays = ChronoUnit.DAYS.between(dueDate, LocalDateTime.now());
      return new BigDecimal(overdueDays).multiply(FinePolicy.DAILY_RATE);
   }
}

// CheckoutService는 도서 대출과 관련된 복잡한 도메인 규칙을 구현합니다
public class CheckoutService {
   private final CheckoutRepository checkoutRepository;
   private final BookRepository bookRepository;
   private final CheckoutPolicyService checkoutPolicyService;

   public Checkout checkoutBook(Member member, Book book) {
      // 도메인 규칙 검증
      // 회원이 대출 가능한 상태인지 확인합니다
      if (!checkoutPolicyService.canCheckout(member)) {
         throw new CheckoutDeniedException("대출 한도 초과 또는 연체 상태");
      }

      // 도서가 대출 가능한 상태인지 확인합니다
      if (!book.isAvailable()) {
         throw new BookNotAvailableException("대출 불가능한 도서");
      }

      // 비즈니스 로직 수행
      Checkout checkout = new Checkout(member, book);
      // 회원 유형과 도서 종류에 따라 반납 기한을 계산합니다
      checkout.setDueDate(checkoutPolicyService.calculateDueDate(member, book));
      book.markAsCheckedOut();

      return checkoutRepository.save(checkout);
   }
}
지원 도메인 (Supporting Domain)

도서 보존 및 수리 관리는 지원 도메인의 예시입니다:

// 도서 상태 관리를 위한 지원 도메인 서비스
public class BookMaintenanceService {
    private final BookRepository bookRepository;
    private final MaintenanceLogRepository maintenanceLogRepository;

    public MaintenanceLog registerRepairRequest(Book book, String damage) {
        // 도서 수리 요청 처리
        MaintenanceLog log = new MaintenanceLog();
        log.setBook(book);
        log.setDamageDescription(damage);
        log.setStatus(MaintenanceStatus.REQUESTED);
        
        book.setStatus(BookStatus.UNDER_MAINTENANCE);
        bookRepository.save(book);
        
        return maintenanceLogRepository.save(log);
    }
}
일반 도메인 (Generic Domain)

알림 시스템은 일반적인 기능을 제공하는 좋은 예시입니다:

// 일반적인 알림 기능을 제공하는 서비스
public class NotificationService {
    private final EmailSender emailSender;
    private final SMSSender smsSender;

    public void sendNotification(NotificationRequest request) {
        switch (request.getType()) {
            case EMAIL:
                emailSender.send(
                    request.getRecipient(),
                    request.getSubject(),
                    request.getContent()
                );
                break;
            case SMS:
                smsSender.send(
                    request.getRecipient(),
                    request.getContent()
                );
                break;
        }
    }
}

// 핵심 도메인과 일반 도메인의 통합
public class CheckoutNotificationService {
   private final NotificationService notificationService;

   public void sendDueDateReminder(Checkout checkout) {
      // 핵심 도메인의 비즈니스 로직
      if (shouldSendReminder(checkout)) {
         // 일반 도메인 서비스 활용
         notificationService.sendNotification(
                 NotificationRequest.builder()
                         .type(NotificationType.EMAIL)
                         .recipient(checkout.getMember().getEmail())
                         .subject("도서 반납 예정 알림")
                         .content(createReminderContent(checkout))
                         .build()
         );
      }
   }

   private boolean shouldSendReminder(Checkout checkout) {
      // 도서관의 알림 정책에 따른 판단 로직
      return checkout.getDueDate().minusDays(3).isEqual(LocalDate.now());
   }
}
하위 영역 간의 통합

각 하위 영역은 독립적으로 개발되지만, 필요에 따라 서로 협력합니다:

// 도메인 간 협력을 보여주는 파사드 서비스
public class LibraryService {
   private final CheckoutService checkoutService;        // 핵심 도메인
   private final BookMaintenanceService maintenance;     // 지원 도메인
   private final NotificationService notification;       // 일반 도메인

   public void processBookReturn(Checkout checkout) {
      // 1. 핵심 도메인: 반납 처리
      checkoutService.returnBook(checkout);

      // 2. 지원 도메인: 도서 상태 점검
      if (checkout.getBook().needsMaintenance()) {
         maintenance.registerRepairRequest(
                 checkout.getBook(),
                 "정기 상태 점검 필요"
         );
      }

      // 3. 일반 도메인: 반납 완료 알림
      notification.sendNotification(
              NotificationRequest.builder()
                      .type(NotificationType.EMAIL)
                      .recipient(checkout.getMember().getEmail())
                      .subject("도서 반납 완료")
                      .content("도서가 성공적으로 반납되었습니다.")
                      .build()
      );
   }
}

이 코드 예제들은 각 하위 영역의 특성과 역할을 잘 보여줍니다:

  • 핵심 도메인은 복잡한 비즈니스 규칙과 정책을 구현합니다.
  • 지원 도메인은 비즈니스에 특화되었지만 덜 복잡한 기능을 제공합니다.
  • 일반 도메인은 재사용 가능한 일반적인 기능을 제공합니다.

각 영역은 명확한 책임과 경계를 가지면서도, 전체 시스템의 목표를 위해 유기적으로 협력합니다. 이러한 구조는 시스템의 복잡성을 관리하면서도 비즈니스 요구사항을 효과적으로 구현할 수 있게 해줍니다.

하위 도메인과 애플리케이션 구조

하위 도메인을 나누는 것과 애플리케이션을 분리하는 것은 서로 다른 차원의 결정입니다. 하위 도메인 분리는 비즈니스 개념의 논리적 구분이고, 애플리케이션 분리는 기술적인 배포 단위의 물리적 구분입니다. 이 두 가지 결정은 서로 영향을 주지만, 반드시 일치할 필요는 없습니다.

논리적 분리와 물리적 분리의 이해

하위 도메인의 논리적 분리는 코드 수준에서 패키지나 모듈로 구현될 수 있습니다. 이는 개념적 경계를 명확히 하고 코드의 응집도를 높이는 데 도움이 됩니다. 반면, 물리적 분리는 실제 별도의 애플리케이션으로 나누는 것을 의미합니다.

예를 들어, 도서관 시스템에서 논리적 분리는 다음과 같이 구현될 수 있습니다:

// 패키지 구조를 통한 논리적 분리
library
  ├── checkout     // 핵심 도메인
     ├── Checkout.java
     ├── CheckoutService.java
     └── CheckoutRepository.java
  
  ├── maintenance  // 지원 도메인
     ├── Maintenance.java
     └── MaintenanceService.java
  
  └── notification // 일반 도메인
      ├── NotificationService.java
      └── EmailSender.java
애플리케이션 분리 결정 요소

애플리케이션을 분리할지 결정할 때는 다음과 같은 요소들을 고려해야 합니다:

  1. 조직의 구조와 규모

    • 개발 팀의 크기
    • 팀 간의 의사소통 패턴
    • 업무 분담 방식
  2. 기술적 요구사항

    • 확장성 요구사항
    • 배포 주기의 차이
    • 기술 스택의 다양성
  3. 비즈니스 요구사항

    • 가용성 요구사항
    • 성능 요구사항
    • 보안 요구사항
애플리케이션 분리 패턴
단일 애플리케이션 패턴

모든 하위 도메인이 하나의 애플리케이션에 포함되는 패턴입니다:

@SpringBootApplication
public class LibraryApplication {
    public static void main(String[] args) {
        SpringApplication.run(LibraryApplication.class, args);
    }
}

// 하위 도메인들이 같은 애플리케이션 컨텍스트를 공유
@Service
public class LibraryService {
    private final CheckoutService checkoutService;
    private final MaintenanceService maintenanceService;
    private final NotificationService notificationService;

    // 트랜잭션 관리가 용이
    @Transactional
    public void processBookReturn(Checkout checkout) {
        checkoutService.returnBook(checkout);
        maintenanceService.checkBookCondition(checkout.getBook());
        notificationService.sendReturnConfirmation(checkout);
    }
}

이 패턴의 장점:

  • 단순한 배포 프로세스
  • 쉬운 트랜잭션 관리
  • 간단한 개발 환경

단점:

  • 독립적인 확장의 어려움
  • 전체 시스템의 복잡도 증가
  • 배포 위험도 증가
분산 애플리케이션 패턴

하위 도메인별로 별도의 애플리케이션으로 분리하는 패턴입니다:

// Checkout 애플리케이션
@SpringBootApplication
public class CheckoutApplication {
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

@RestController
public class CheckoutController {
    @PostMapping("/checkouts")
    public ResponseEntity<Checkout> createCheckout(@RequestBody CheckoutRequest request) {
        // API 구현
    }
}

// Maintenance 애플리케이션
@SpringBootApplication
public class MaintenanceApplication {
    @Bean
    public EventListener<BookReturnedEvent> bookReturnedEventListener() {
        return event -> {
            // 이벤트 기반 처리 로직
        };
    }
}

이 패턴의 장점:

  • 독립적인 확장 가능
  • 명확한 경계와 책임
  • 격리된 배포 가능

단점:

  • 분산 시스템의 복잡성
  • 트랜잭션 관리의 어려움
  • 운영 부담 증가
균형 잡힌 접근

실제로는 두 가지 패턴을 혼합하여 사용하는 것이 일반적입니다:

// 핵심 도메인은 독립 애플리케이션으로 분리
@SpringBootApplication
public class CheckoutApplication {
    // 대출 관련 핵심 기능
}

// 지원 도메인과 일반 도메인은 통합 애플리케이션으로 구성
@SpringBootApplication
public class SupportApplication {
    // 유지보수, 알림 등 지원 기능
}

이러한 혼합 접근은 다음과 같은 상황에서 효과적입니다:

  • 핵심 도메인의 독립성이 중요한 경우
  • 일부 하위 도메인 간의 강한 결합이 필요한 경우
  • 운영 복잡도와 개발 생산성의 균형이 필요한 경우
결론

하위 도메인을 별도의 애플리케이션으로 분리하는 것은 신중하게 고려해야 할 아키텍처 결정입니다. 이는 조직의 상황, 기술적 요구사항, 그리고 비즈니스 목표에 따라 달라질 수 있습니다. 중요한 것은 하위 도메인의 논리적 경계를 명확히 하고, 이를 바탕으로 적절한 물리적 분리 수준을 결정하는 것입니다. 시스템이 발전함에 따라 이러한 구조도 점진적으로 발전할 수 있어야 합니다.

2.1.3 전술적 설계

전술적 설계에서는 도메인 모델을 효과적으로 구현하기 위한 여러 패턴들을 제공합니다. 각 패턴들은 서로 다른 역할과 책임을 가지며, 이들이 조화롭게 동작할 때 견고한 도메인 모델이 완성됩니다.

엔티티(Entity)

엔티티는 고유한 식별자를 가지고 있고, 시간에 따라 상태가 변경되더라도 동일성이 유지되는 객체입니다. 예를 들어, 도서관의 ‘도서’는 제목이나 상태가 변경되어도 동일한 도서로 취급됩니다:

public class Book {
    private final BookId id;  // 식별자
    private String title;     // 가변 속성
    private Author author;    // 가변 속성
    
    public Book(BookId id) {
        this.id = Objects.requireNonNull(id);
    }
    
    // 식별자를 기준으로 동등성 비교
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Book)) return false;
        Book book = (Book) o;
        return id.equals(book.id);
    }
}

값 객체(Value Object)

값 객체는 고유한 식별자 없이 속성 값만으로 정의되는 불변 객체입니다. 동일한 속성을 가진 값 객체는 서로 대체 가능합니다:

public record ISBN(String value) {
    public ISBN {
        validate(value);
    }
    
    private void validate(String value) {
        if (!value.matches("\\d{13}")) {
            throw new IllegalArgumentException("ISBN은 13자리 숫자여야 합니다.");
        }
    }
}

애그리게잇(Aggregate)

애그리게잇은 관련된 객체들의 집합을 하나의 단위로 다루기 위한 패턴입니다. 애그리게잇 루트를 통해서만 내부 객체에 접근하고 수정할 수 있습니다:

public class BookLending {  // 애그리게잇 루트
    private final LendingId id;
    private final BookCopy bookCopy;    // 애그리게잇 멤버
    private final Member borrower;      // 외부 참조
    private LendingStatus status;       // 애그리게잇 멤버
    private List<Extension> extensions; // 애그리게잇 멤버
    
    public ExtensionResult extend(int days) {
        // 연장 규칙 검사
        if (extensions.size() >= 2) {
            return ExtensionResult.rejected("최대 연장 횟수를 초과했습니다.");
        }
        
        // 애그리게잇 내부 상태 변경
        Extension extension = new Extension(days);
        extensions.add(extension);
        
        return ExtensionResult.success(extension);
    }
}

도메인 서비스(Domain Service)

도메인 서비스는 특정 엔티티나 값 객체에 속하지 않는 도메인 로직을 구현합니다:

public class OverduePenaltyService {
    private final HolidayCalendar holidayCalendar;
    
    public Money calculatePenalty(Lending lending, LocalDate currentDate) {
        // 복잡한 도메인 로직을 처리합니다
        int overdueDays = calculateBusinessDays(lending.getDueDate(), currentDate);
        
        // 연체 일수에 따른 벌금을 계산합니다
        Money dailyPenalty = new Money(BigDecimal.valueOf(1000), Currency.getInstance("KRW"));
        return dailyPenalty.multiply(overdueDays);
    }
    
    private int calculateBusinessDays(LocalDate from, LocalDate to) {
        return (int) from.datesUntil(to)
                .filter(date -> !holidayCalendar.isHoliday(date))
                .count();
    }
}

리포지토리(Repository)

도메인 객체의 저장소를 추상화합니다. 이를 통해 도메인 모델은 실제 저장소 구현에 대해 알 필요가 없습니다:

public interface BookRepository {
    Optional<Book> findById(BookId id);
    List<Book> findByStatus(BookStatus status);
    Book save(Book book);
    void delete(Book book);
}

도메인 이벤트(Domain Event)

도메인에서 발생하는 중요한 사건을 표현합니다. 이는 다른 바운디드 컨텍스트와의 통신에도 활용됩니다:

public record BookBorrowedEvent(
    BookId bookId,
    MemberId borrowerId,
    LocalDateTime borrowedAt,
    LocalDateTime dueDate
) implements DomainEvent {
    public BookBorrowedEvent {
        Objects.requireNonNull(bookId, "bookId must not be null");
        Objects.requireNonNull(borrowerId, "borrowerId must not be null");
        Objects.requireNonNull(borrowedAt, "borrowedAt must not be null");
        Objects.requireNonNull(dueDate, "dueDate must not be null");
    }
}

이러한 전술적 설계 요소들은 각각 고유한 역할을 가지고 있으며, 이들이 조화롭게 동작할 때 견고한 도메인 모델이 완성됩니다. 특히 중요한 점은 이러한 패턴들이 단순한 구현 기법이 아니라, 도메인의 본질적인 개념과 규칙을 명확하게 표현하는 도구라는 것입니다.

2.1.4 도메인 모델과 유비쿼터스 언어

도메인 모델은 비즈니스 규칙과 지식을 코드로 표현한 것입니다. 이는 단순한 데이터 구조가 아니라, 도메인의 개념과 규칙을 명확하게 표현하는 살아있는 문서가 되어야 합니다.

유비쿼터스 언어(Ubiquitous Language)는 도메인 전문가와 개발자가 공유하는 공통의 언어입니다. 이는 코드, 문서, 대화에서 일관되게 사용되어야 합니다. 예를 들어:

// 유비쿼터스 언어를 반영한 코드
public class BookCopy {
    public void markAsLost() {
        if (status != BookStatus.BORROWED) {
            throw new IllegalStateException("대출 중인 도서만 분실 처리할 수 있습니다.");
        }
        this.status = BookStatus.LOST;
        DomainEvents.raise(new BookLostEvent(this.id));
    }
}

이 코드에서 ‘markAsLost’(분실 처리)라는 메서드 이름과 예외 메시지는 도서관 업무 담당자들이 실제로 사용하는 용어를 그대로 반영하고 있습니다. 이를 통해 코드가 비즈니스 규칙을 더 명확하게 표현할 수 있습니다.

2.1.5 도메인 전문가와의 협업과 지식 통합

도메인 주도 설계의 핵심은 도메인 전문가와의 지속적인 협업입니다. 이는 단순한 요구사항 수집을 넘어, 도메인 지식을 소프트웨어 설계에 직접 반영하는 과정입니다.

이를 위해서는 다음과 같은 방법들을 활용할 수 있습니다:

  1. 지식 탐구 세션: 도메인 전문가와 함께 특정 도메인 영역을 깊이 있게 탐구합니다.
  2. 이벤트 스토밍(Event Storming): 도메인 이벤트를 중심으로 비즈니스 프로세스를 모델링합니다.
  3. 예제 중심 의사소통: 구체적인 사례를 통해 도메인 규칙을 명확히 합니다.

이러한 협업의 결과는 코드에 직접 반영되어야 합니다. 예를 들어:

public class CheckoutRule {
    // Rule은 명확한 true/false 판단을 제공합니다
    public boolean isSatisfiedBy(Member member, BookCopy book) {
        // 각각의 규칙들을 명시적으로 검사합니다
        return !member.hasOverdueBooks() &&                    // 연체 규칙
                member.getCurrentCheckouts() < member.getCheckoutLimit() && // 대출 한도 규칙
                book.isAvailable() &&                           // 도서 상태 규칙
                !member.isBlacklisted();                        // 회원 자격 규칙
    }

    // 규칙 위반 사유를 구체적으로 설명할 수 있습니다
    public List<RuleViolation> checkViolations(Member member, BookCopy book) {
        List<RuleViolation> violations = new ArrayList<>();

        if (member.hasOverdueBooks()) {
            violations.add(new RuleViolation("OVERDUE_BOOKS", "연체 중인 도서가 있습니다."));
        }

        if (member.getCurrentCheckouts() >= member.getCheckoutLimit()) {
            violations.add(new RuleViolation("CHECKOUT_LIMIT_EXCEEDED", "대출 한도를 초과했습니다."));
        }

        // ... 다른 규칙들도 마찬가지로 구체적인 위반 사유를 제공합니다

        return violations;
    }
}

이러한 접근을 통해, 소프트웨어는 단순한 데이터 처리 시스템을 넘어 도메인의 지식과 규칙을 명확하게 표현하는 모델이 될 수 있습니다.

Last updated on