2.2 도메인 모델 설계

2.2 도메인 모델 설계

도메인 모델은 비즈니스 도메인의 핵심 개념과 규칙을 코드로 표현한 것입니다. 효과적인 도메인 모델을 설계하기 위해서는 단순한 데이터 구조를 넘어서, 도메인의 본질적인 개념과 규칙을 명확하게 표현해야 합니다.

2.2.1 빈약한 도메인 모델의 특징과 한계

빈약한 도메인 모델(Anemic Domain Model)은 겉으로는 객체지향적으로 보이지만, 실제로는 데이터 구조체에 불과한 모델을 의미합니다. 이러한 모델은 도메인 로직이 외부에 존재하게 되어 여러 가지 문제를 초래합니다.

다음은 빈약한 도메인 모델의 전형적인 예시입니다:

// 빈약한 도메인 모델의 예시
public class Book {
    private String isbn;
    private String title;
    private String author;
    private BookStatus status;
    
    // 단순 getter/setter만 존재
    public String getIsbn() { return isbn; }
    public void setIsbn(String isbn) { this.isbn = isbn; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public BookStatus getStatus() { return status; }
    public void setStatus(BookStatus status) { this.status = status; }
}

// 도메인 로직이 서비스 계층에 존재
public class BookService {
    public void checkoutBook(Book book, Member member) {
        // 도메인 로직이 서비스 계층에 구현됨
        if (book.getStatus() != BookStatus.AVAILABLE) {
            throw new IllegalStateException("대출 불가능한 도서입니다.");
        }
        if (member.getCurrentCheckouts() >= member.getCheckoutLimit()) {
            throw new IllegalStateException("대출 한도를 초과했습니다.");
        }
        book.setStatus(BookStatus.CHECKED_OUT);
        // 추가 처리...
    }
}

이러한 빈약한 도메인 모델의 주요 문제점은 다음과 같습니다:

  1. 캡슐화 위반: setter를 통해 객체의 상태를 직접 변경할 수 있어, 잘못된 상태 변경을 방지하기 어렵습니다.

  2. 비즈니스 규칙의 분산: 도메인 로직이 여러 서비스 계층에 분산되어 있어, 유지보수가 어렵고 중복 코드가 발생하기 쉽습니다.

  3. 도메인 지식의 암묵화: 코드만으로는 도메인의 규칙과 제약사항을 이해하기 어렵습니다.

2.2.2 풍부한 도메인 모델의 구현

풍부한 도메인 모델(Rich Domain Model)은 도메인의 개념과 규칙을 명확하게 표현하며, 관련된 행위를 캡슐화합니다. 다음은 도서 대출 도메인의 풍부한 모델 예시입니다:

public class Book {
    private final BookId id;
    private final ISBN isbn;
    private BookStatus status;
    private Member currentBorrower;
    private LocalDate dueDate;
    private final List<CheckoutRecord> checkoutHistory;

    // 생성자를 통한 유효성 검증
    public Book(BookId id, ISBN isbn) {
        this.id = Objects.requireNonNull(id, "도서 ID는 필수입니다");
        this.isbn = Objects.requireNonNull(isbn, "ISBN은 필수입니다");
        this.status = BookStatus.AVAILABLE;
        this.checkoutHistory = new ArrayList<>();
    }

    // 도메인 행위를 메서드로 표현
    public CheckoutResult checkoutTo(Member member, CheckoutPolicy policy) {
        // 도메인 규칙 검증
        if (status != BookStatus.AVAILABLE) {
            return CheckoutResult.failure("대출 가능한 상태가 아닙니다");
        }

        if (!policy.canCheckout(member)) {
            return CheckoutResult.failure("대출 자격이 없습니다");
        }

        // 상태 변경
        this.status = BookStatus.CHECKED_OUT;
        this.currentBorrower = member;
        this.dueDate = LocalDate.now().plusDays(policy.getLoanDuration());
        
        // 이력 기록
        CheckoutRecord record = new CheckoutRecord(member, LocalDateTime.now(), this.dueDate);
        this.checkoutHistory.add(record);

        // 도메인 이벤트 생성
        return CheckoutResult.success(new BookCheckedOutEvent(this.id, member.getId(), this.dueDate));
    }

    public ReturnResult returnBook() {
        if (status != BookStatus.CHECKED_OUT) {
            return ReturnResult.failure("대출 중인 도서가 아닙니다");
        }

        LocalDate returnDate = LocalDate.now();
        boolean isOverdue = returnDate.isAfter(dueDate);

        this.status = BookStatus.AVAILABLE;
        this.currentBorrower = null;
        this.dueDate = null;

        return ReturnResult.success(new BookReturnedEvent(this.id, returnDate, isOverdue));
    }

    // 불변식(invariant) 보장
    private void validateState() {
        if (status == BookStatus.CHECKED_OUT) {
            if (currentBorrower == null || dueDate == null) {
                throw new IllegalStateException("대출 중인 도서는 대출자와 반납일이 필수입니다");
            }
        }
    }
}

// 대출 정책을 별도의 클래스로 분리
public class CheckoutPolicy {
    public boolean canCheckout(Member member) {
        return !member.hasOverdueBooks() &&
                member.getCurrentCheckouts() < member.getCheckoutLimit();
    }

    public int getLoanDuration() {
        return 14; // 기본 대출 기간 14일
    }
}

풍부한 도메인 모델의 장점은 다음과 같습니다:

  1. 캡슐화와 일관성: 도메인 객체가 자신의 상태를 책임지고 관리하며, 유효하지 않은 상태 변경을 방지합니다.

  2. 도메인 지식의 명확한 표현: 메서드 이름과 구현을 통해 도메인의 규칙과 개념을 명확하게 이해할 수 있습니다.

  3. 유지보수성 향상: 관련된 로직이 한 곳에 모여있어 변경이 용이하고, 비즈니스 규칙의 변경을 반영하기 쉽습니다.

2.2.3 도메인 객체의 생명주기 관리

도메인 객체의 생명주기는 생성, 상태 변경, 소멸의 과정을 포함합니다. 이를 효과적으로 관리하기 위해서는 팩토리와 리포지토리 패턴을 활용할 수 있습니다:

// 팩토리를 통한 객체 생성
public class BookFactory {
    public Book createNewBook(ISBN isbn, String title, Author author) {
        // 식별자 생성
        BookId id = BookId.generate();
        
        // 도메인 객체 생성
        Book book = new Book(id, isbn);
        
        // 초기 메타데이터 설정
        book.updateMetadata(new BookMetadata(title, author));
        
        return book;
    }
}

// 리포지토리를 통한 영속성 관리
public interface BookRepository {
    Optional<Book> findById(BookId id);
    List<Book> findByStatus(BookStatus status);
    Book save(Book book);
    void delete(Book book);
}

2.2.4 도메인 서비스의 활용

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

public class OverdueManagementService {
    private final BookRepository bookRepository;
    private final NotificationService notificationService;

    public void handleOverdueBooks() {
        // 연체 도서 조회
        List<Book> overdueBooks = bookRepository.findOverdueBooks(LocalDate.now());
        
        for (Book book : overdueBooks) {
            // 연체 처리
            Member borrower = book.getCurrentBorrower();
            if (needsSuspension(borrower)) {
                borrower.suspend("장기 연체로 인한 자격 정지");
                notificationService.sendSuspensionNotice(borrower.getId());
            } else {
                notificationService.sendOverdueReminder(borrower.getId());
            }
        }
    }

    private boolean needsSuspension(Member borrower) {
        return borrower.getOverdueCount() >= 3 ||
               borrower.hasLongTermOverdue();
    }
}

이러한 도메인 모델 설계 방식을 통해, 복잡한 비즈니스 규칙을 명확하고 유지보수하기 쉬운 코드로 구현할 수 있습니다.

Last updated on