4.5 도메인 서비스(Domain Service) 구현 가이드
도메인 서비스(Domain Service) 구현 가이드
개요
도메인 서비스는 특정 엔티티나 값 객체에 속하지 않는 도메인 로직을 캡슐화하는 객체입니다. 비즈니스 프로세스가 여러 애그리거트에 걸쳐 있거나, 외부 시스템과의 상호작용이 필요한 경우에 도메인 서비스를 사용합니다. 이 가이드에서는 도메인 서비스를 효과적으로 구현하는 방법을 살펴보겠습니다.
도메인 서비스가 필요한 상황
도메인 서비스는 다음과 같은 상황에서 특히 유용합니다:
- 여러 애그리거트 간의 조정이 필요한 경우
- 외부 시스템과의 상호작용이 필요한 경우
- 상태가 없는(stateless) 도메인 로직이 필요한 경우
- 특정 엔티티나 값 객체에 배치하기 어려운 비즈니스 로직이 있는 경우
예를 들어, 비밀번호 해싱은 특정 엔티티에 속하기보다는 도메인 서비스로 구현하는 것이 더 적절합니다:
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: 유효성 검사를 수행하는 서비스
결론
도메인 서비스는 도메인 주도 설계에서 중요한 구성 요소입니다. 특히 전략 패턴을 활용하면 비즈니스 규칙을 유연하게 구현할 수 있으며, 새로운 요구사항에 따라 쉽게 확장할 수 있습니다.
핵심 포인트를 정리하면:
- 도메인 서비스는 상태를 가지지 않아야 합니다
- 전략 패턴을 활용하여 유연한 비즈니스 로직을 구현합니다
- 인터페이스를 통해 추상화하고 구현을 분리합니다
- 명확한 책임 범위를 가져야 합니다
- 도메인 용어를 사용하여 의도를 명확히 표현해야 합니다
이러한 원칙들을 따르면서 도메인 서비스를 구현하면, 유지보수하기 쉽고 도메인 규칙을 명확하게 표현하는 코드를 작성할 수 있습니다.