4.5 도메인 서비스(Domain Service) 구현 가이드

4.5 도메인 서비스(Domain Service) 구현 가이드

도메인 서비스(Domain Service) 구현 가이드

개요

도메인 서비스는 특정 엔티티나 값 객체에 속하지 않는 도메인 로직을 캡슐화하는 객체입니다. 비즈니스 프로세스가 여러 애그리거트에 걸쳐 있거나, 외부 시스템과의 상호작용이 필요한 경우에 도메인 서비스를 사용합니다. 이 가이드에서는 도메인 서비스를 효과적으로 구현하는 방법을 살펴보겠습니다.

도메인 서비스가 필요한 상황

도메인 서비스는 다음과 같은 상황에서 특히 유용합니다:

  1. 여러 애그리거트 간의 조정이 필요한 경우
  2. 외부 시스템과의 상호작용이 필요한 경우
  3. 상태가 없는(stateless) 도메인 로직이 필요한 경우
  4. 특정 엔티티나 값 객체에 배치하기 어려운 비즈니스 로직이 있는 경우

예를 들어, 비밀번호 해싱은 특정 엔티티에 속하기보다는 도메인 서비스로 구현하는 것이 더 적절합니다:

public interface PasswordHasher {
    HashedPassword hash(String password);

    default HashedPassword hash(Password password) {
        return hash(password.value());
    }

    boolean matches(String inputPassword, String hashedPassword);
}

도메인 서비스의 특징

1. 상태가 없음(Statelessness)

도메인 서비스는 상태를 유지하지 않아야 합니다. 모든 필요한 데이터는 매개변수로 전달받아야 합니다:

public interface LoanStrategy {
    // 대출 가능 여부를 판단하는 메서드
    // 필요한 모든 데이터를 매개변수로 받음
    Result<Boolean> canLoan(Member member, 
                          Book book, 
                          int currentLoansCount,
                          LocalDateTime loanTime);
}

2. 명확한 도메인 개념 표현

도메인 서비스의 이름과 메서드는 도메인 전문가가 이해할 수 있는 용어를 사용해야 합니다:

public interface ReservationStrategy {
    // 도메인 용어를 사용한 메서드 이름
    Result<Boolean> isBookReservable(Book book, Member member);
    
    // 도메인 개념을 명확히 표현하는 메서드
    Result<Integer> calculateReservationPriority(Member member, Book book);
}

3. 전략 패턴의 활용

비즈니스 로직이 상황에 따라 다르게 적용되어야 할 때는 전략 패턴을 활용합니다:

public interface LoanStrategy {
    Result<Boolean> canLoan(Member member, Book book);
}

// 일반 회원을 위한 기본 대출 전략
public class BasicLoanStrategy implements LoanStrategy {
    @Override
    public Result<Boolean> canLoan(Member member, Book book) {
        return ContractValidator.start()
                .require(member.getCurrentLoans() < 5, 
                        ErrorCodes.LOAN_LIMIT_EXCEEDED)
                .require(!member.hasOverdueBooks(), 
                        ErrorCodes.HAS_OVERDUE_BOOKS)
                .validate()
                .map(__ -> true);
    }
}

// VIP 회원을 위한 우대 대출 전략
public class VIPLoanStrategy implements LoanStrategy {
    @Override
    public Result<Boolean> canLoan(Member member, Book book) {
        return ContractValidator.start()
                .require(member.getCurrentLoans() < 10, 
                        ErrorCodes.LOAN_LIMIT_EXCEEDED)
                .validate()
                .map(__ -> true);
    }
}

4. 인터페이스 기반 설계

도메인 서비스는 인터페이스로 정의하고 구현체는 분리하는 것이 좋습니다:

public interface FineCalculationStrategy {
    Money calculateOverdueFine(Loan loan, LocalDateTime returnTime);
}

public class DefaultFineCalculationStrategy implements FineCalculationStrategy {
    private static final Money DAILY_FINE = Money.of(1000);

    @Override
    public Money calculateOverdueFine(Loan loan, LocalDateTime returnTime) {
        if (!loan.isOverdue(returnTime)) {
            return Money.ZERO;
        }

        long overdueDays = loan.calculateOverdueDays(returnTime);
        return DAILY_FINE.multiply(overdueDays);
    }
}

도메인 서비스 구현 패턴

1. 명시적 의존성 주입

도메인 서비스가 다른 서비스나 리포지토리를 필요로 할 때는 생성자를 통해 명시적으로 주입받습니다:

@Service
public class BookLoanService {
    private final LoanStrategy loanStrategy;
    private final LoanRepository loanRepository;
    private final FineCalculationStrategy fineCalculator;

    public BookLoanService(LoanStrategy loanStrategy,
                          LoanRepository loanRepository,
                          FineCalculationStrategy fineCalculator) {
        this.loanStrategy = Objects.requireNonNull(loanStrategy);
        this.loanRepository = Objects.requireNonNull(loanRepository);
        this.fineCalculator = Objects.requireNonNull(fineCalculator);
    }

    public Result<Loan> processLoan(Member member, Book book) {
        return loanStrategy.canLoan(member, book)
                .flatMap(__ -> createLoan(member, book))
                .onSuccess(loanRepository::save);
    }
}

2. Result 타입을 활용한 실패 처리

도메인 서비스의 연산이 실패할 수 있는 경우, Result 타입을 사용하여 명시적으로 처리합니다:

public interface MembershipEvaluationStrategy {
    Result<MembershipTier> evaluateMembershipUpgrade(Member member) {
        return ContractValidator.start()
                .require(member.isActive(), ErrorCodes.MEMBER_NOT_ACTIVE)
                .validate()
                .flatMap(__ -> calculateNewTier(member));
    }

    private Result<MembershipTier> calculateNewTier(Member member) {
        // 비즈니스 로직 구현
        return Result.success(MembershipTier.GOLD);
    }
}

도메인 서비스 테스트

1. 단위 테스트

각 도메인 서비스의 로직을 독립적으로 테스트합니다:

@Test
@DisplayName("대출 기간이 지난 경우 연체료가 정확히 계산되어야 함")
void shouldCalculateOverdueFineCorrectly() {
    // Given
    LocalDateTime loanDate = LocalDateTime.now().minusDays(10);
    LocalDateTime dueDate = loanDate.plusDays(7);
    LocalDateTime returnDate = LocalDateTime.now();
    
    Loan loan = TestLoanBuilder.aLoan()
            .withLoanDate(loanDate)
            .withDueDate(dueDate)
            .build();
            
    FineCalculationStrategy calculator = new DefaultFineCalculationStrategy();

    // When
    Money fine = calculator.calculateOverdueFine(loan, returnDate);

    // Then
    assertThat(fine).isEqualTo(Money.of(3000)); // 3일 연체 = 3000원
}

2. 전략 패턴 테스트

다양한 전략의 동작을 검증합니다:

class LoanStrategyTest {
    @Test
    @DisplayName("VIP 회원은 일반 회원보다 더 많은 책을 대출할 수 있어야 함")
    void vipMemberShouldBeAbleToLoanMoreBooks() {
        // Given
        Member vipMember = TestMemberBuilder.aVIPMember()
                .withCurrentLoans(7)
                .build();
        Book book = TestBookBuilder.anAvailableBook().build();
        
        LoanStrategy basicStrategy = new BasicLoanStrategy();
        LoanStrategy vipStrategy = new VIPLoanStrategy();

        // When
        Result<Boolean> basicResult = basicStrategy.canLoan(vipMember, book);
        Result<Boolean> vipResult = vipStrategy.canLoan(vipMember, book);

        // Then
        assertThat(basicResult.isSuccess()).isFalse();
        assertThat(vipResult.isSuccess()).isTrue();
    }
}

모범 사례

1. 전략의 명확한 구분

각 전략은 명확한 책임과 적용 조건을 가져야 합니다:

// 좋은 예: 명확한 책임을 가진 전략들
public interface LoanStrategy {
    Result<Boolean> canLoan(Member member, Book book);
}

// 나쁜 예: 너무 많은 책임을 가진 서비스
public interface LibraryService {
    Result<Loan> loanBook(Member member, Book book);
    Result<Reservation> reserveBook(Member member, Book book);
    Result<Fine> calculateFine(Loan loan);
    Result<Boolean> extendLoan(Loan loan);
}

2. 전략 선택의 캡슐화

전략 선택 로직도 별도의 서비스로 캡슐화할 수 있습니다:

public class LoanStrategySelector {
    public LoanStrategy selectStrategy(Member member) {
        if (member.isVIP()) {
            return new VIPLoanStrategy();
        }
        return new BasicLoanStrategy();
    }
}

3. 일관된 명명 규칙

도메인 서비스의 이름은 그 목적을 명확히 나타내야 합니다:

  • ~Service: 일반적인 도메인 서비스
  • ~Strategy: 상황에 따라 다른 동작이 필요한 경우
  • ~Calculator: 계산을 수행하는 서비스
  • ~Validator: 유효성 검사를 수행하는 서비스

결론

도메인 서비스는 도메인 주도 설계에서 중요한 구성 요소입니다. 특히 전략 패턴을 활용하면 비즈니스 규칙을 유연하게 구현할 수 있으며, 새로운 요구사항에 따라 쉽게 확장할 수 있습니다.

핵심 포인트를 정리하면:

  • 도메인 서비스는 상태를 가지지 않아야 합니다
  • 전략 패턴을 활용하여 유연한 비즈니스 로직을 구현합니다
  • 인터페이스를 통해 추상화하고 구현을 분리합니다
  • 명확한 책임 범위를 가져야 합니다
  • 도메인 용어를 사용하여 의도를 명확히 표현해야 합니다

이러한 원칙들을 따르면서 도메인 서비스를 구현하면, 유지보수하기 쉽고 도메인 규칙을 명확하게 표현하는 코드를 작성할 수 있습니다.