본문으로 건너뛰기
Leo Jung
뒤로 가기

[DDD Intro #16] Domain Service: 객체에 억지로 넣기 어려운 도메인 로직

페이지 수정

DDD를 공부하면 “도메인 로직은 도메인 객체 안에 있어야 한다”는 말을 자주 듣는다. 이 말은 중요하다. 도메인 규칙이 Application Service나 Controller에 흩어지면 모델은 빈약해진다.

하지만 모든 도메인 로직을 Entity나 Value Object에 넣을 수 있는 것은 아니다. 어떤 규칙은 특정 객체 하나의 책임으로 보기 어렵다. 여러 객체를 함께 봐야 하거나, 계산 자체가 독립적인 도메인 개념인 경우가 있다.

이때 사용할 수 있는 것이 Domain Service다.

Domain Service는 도메인 로직의 쓰레기통이 아니다. 특정 Entity나 Value Object에 자연스럽게 속하지 않는 도메인 개념을 표현하는 방법이다.

왜 Domain Service가 필요한가

구독 서비스에서 요금제 변경을 생각해보자. 사용자가 월간 Basic 요금제에서 연간 Pro 요금제로 바꾸려고 한다. 이때 추가 결제 금액을 계산해야 한다.

계산에는 여러 정보가 필요할 수 있다.

현재 구독의 남은 기간
현재 요금제의 가격
대상 요금제의 가격
월간/연간 결제 주기
프로모션 할인
사용자 등급
기업 요금제 최소 좌석 수

이 로직을 Subscription에 모두 넣을 수도 있다. 하지만 구독 객체가 가격 정책, 할인 정책, 요금제 비교 규칙까지 모두 알게 되면 너무 많은 책임을 갖는다.

반대로 Plan에 넣기도 애매하다. 계산은 현재 구독과 대상 요금제, 현재 시점, 정책을 함께 봐야 하기 때문이다.

이런 경우 다음과 같은 도메인 서비스나 정책 객체가 자연스럽다.

PlanChangeResult result = planChangePolicy.calculate(subscription, targetPlan, now);
subscription.applyPlanChange(result);

여기서 PlanChangePolicy는 단순 기술 서비스가 아니다. 도메인에서 말하는 “요금제 변경 정책”을 코드로 표현한 것이다.

Domain Service는 이름이 도메인 언어여야 한다

Domain Service가 실패하는 흔한 이유는 이름이 너무 일반적이기 때문이다.

SubscriptionService
OrderService
PaymentService

이런 이름은 너무 넓다. 무엇을 하는 서비스인지 알기 어렵고, 시간이 지나면 모든 로직이 들어가는 장소가 된다.

Domain Service는 가능한 한 구체적인 도메인 개념을 이름으로 가져야 한다.

PlanChangePolicy
RenewalEligibilityChecker
RefundPolicy
SubscriptionPricingService
TrialAvailabilityPolicy

이 이름들은 도메인 질문을 드러낸다.

요금제 변경은 어떤 정책으로 계산되는가?
갱신 가능한 구독인가?
환불 가능 여부와 금액은 어떻게 정하는가?
무료 체험을 다시 사용할 수 있는가?

좋은 Domain Service 이름은 “서비스가 있다”는 사실보다 “어떤 도메인 개념이 존재한다”는 사실을 보여준다.

Application Service와 Domain Service의 차이

두 Service를 구분하는 가장 쉬운 방법은 질문을 다르게 하는 것이다.

Application Service는 유스케이스의 흐름을 묻는다.

이 요청을 처리하기 위해 무엇을 어떤 순서로 호출해야 하는가?
어떤 Aggregate를 조회해야 하는가?
어떤 트랜잭션 안에서 저장해야 하는가?
어떤 이벤트를 발행해야 하는가?

Domain Service는 도메인 판단을 묻는다.

이 구독은 갱신 가능한가?
이 요금제 변경에는 얼마를 청구해야 하는가?
이 사용자는 무료 체험 대상인가?
이 환불 요청은 어떤 정책에 따라 처리해야 하는가?

예를 들어 다음 코드를 보자.

public void changePlan(ChangePlanCommand command) {
    Subscription subscription = subscriptionRepository.findById(command.subscriptionId());
    Plan targetPlan = planRepository.findById(command.targetPlanId());

    PlanChangeResult result = planChangePolicy.calculate(subscription, targetPlan, clock.now());
    subscription.changePlan(result);

    subscriptionRepository.save(subscription);
}

이 메서드는 Application Service다. 흐름을 조율한다. 반면 planChangePolicy.calculate()는 도메인 서비스 또는 정책 객체다. 도메인 규칙에 따라 변경 결과를 계산한다.

Infrastructure Service와도 다르다

Domain Service는 Infrastructure Service와도 다르다. Infrastructure Service는 기술적 기능을 제공한다.

이메일 발송
결제 API 호출
파일 업로드
메시지 브로커 발행
암호화
외부 주소 검증 API 호출

이들은 도메인에 필요할 수 있지만, 그 자체가 도메인 규칙은 아니다.

예를 들어 PaymentGateway는 외부 결제 시스템을 호출하는 인프라 서비스일 수 있다. 반면 RefundPolicy는 환불 가능 여부와 금액을 판단하는 도메인 서비스다.

RefundDecision decision = refundPolicy.decide(subscription, payment, requestDate);
paymentGateway.refund(decision.amount());

첫 번째 줄은 도메인 판단이다. 두 번째 줄은 외부 시스템 호출이다. 둘을 구분해야 테스트도 쉬워지고 모델도 선명해진다.

Domain Service가 남용되는 순간

Domain Service는 유용하지만 남용되기 쉽다. 특히 Entity에 넣어야 할 로직을 모두 Service로 빼내면 빈약한 도메인 모델이 된다.

예를 들어 다음과 같은 서비스가 있다고 하자.

public class SubscriptionDomainService {
    public void cancel(Subscription subscription, CancelReason reason) {
        if (subscription.getStatus() == EXPIRED) {
            throw new IllegalStateException();
        }
        subscription.setStatus(CANCELED);
    }
}

이 로직은 굳이 Domain Service에 있을 필요가 없다. 구독 자신의 상태에 따라 해지 가능 여부를 판단하는 것은 Subscription의 책임이 더 자연스럽다.

subscription.cancelByMember(reason, now);

Domain Service를 만들기 전에 먼저 물어야 한다.

이 로직은 특정 Entity가 스스로 판단할 수 있는가?
이 로직은 Value Object의 행위로 표현할 수 있는가?
여러 객체를 함께 봐야 하는가?
도메인에서 독립적인 정책이나 계산으로 부를 수 있는가?

앞의 두 질문에 “그렇다”고 답한다면 Domain Service로 빼기 전에 객체 안으로 넣는 것이 나을 수 있다.

상태를 바꾸는 서비스보다 판단을 표현하는 서비스

좋은 Domain Service는 종종 상태를 직접 바꾸기보다 판단이나 계산을 표현한다.

RenewalDecision decision = renewalPolicy.decide(subscription, paymentHistory, now);
subscription.applyRenewalDecision(decision);

이 구조에서는 정책 객체가 결정을 만들고, Entity가 그 결정을 자신의 상태 변화로 반영한다. 이렇게 하면 역할이 분명해진다.

정책은 계산과 판단을 담당한다. Entity는 자신의 상태를 변경하고 불변식을 지킨다.

반대로 Domain Service가 Entity의 필드를 직접 조작하기 시작하면 책임이 섞인다. Domain Service가 너무 많은 내부 상태를 알고 있다면, 그 로직이 Entity 안에 있어야 하는 것은 아닌지 다시 봐야 한다.

도메인 서비스도 테스트의 좋은 대상이다

Domain Service는 도메인 규칙을 명시적으로 담기 때문에 테스트하기 좋은 대상이다.

예를 들어 무료 체험 가능 여부를 판단하는 정책을 테스트할 수 있다.

무료_체험을_이미_사용한_회원은_다시_무료_체험을_시작할_수_없다
결제_이력이_있는_회원은_신규_무료_체험_대상이_아니다
관리자가_초대한_기업_회원은_무료_체험_대상이_아닐_수_있다

이런 테스트는 단순한 코드 검증을 넘어 도메인 정책의 문서가 된다. Domain Service가 구체적인 도메인 이름을 가질수록 테스트 이름도 도메인 언어에 가까워진다.

마치며

Domain Service는 도메인 로직을 아무 데나 넣기 어려울 때 사용하는 편의 공간이 아니다. 특정 Entity나 Value Object에 자연스럽게 속하지 않는 도메인 개념을 표현하는 도구다.

좋은 Domain Service는 이름부터 도메인 언어를 사용한다. SubscriptionService처럼 넓고 모호한 이름보다 PlanChangePolicy, RefundPolicy, TrialAvailabilityPolicy처럼 구체적인 이름이 좋다.

Domain Service를 사용할 때는 항상 책임의 위치를 물어야 한다. 이 로직은 Entity가 스스로 지켜야 할 규칙인가? Value Object의 행위인가? 여러 객체를 함께 봐야 하는 독립적인 정책인가?

이 질문을 통해 Domain Service를 신중하게 사용하면, 도메인 모델은 빈약해지지 않으면서도 과도하게 비대해지지 않는다. 중요한 것은 모든 로직을 한곳에 넣는 것이 아니라, 도메인 의미가 가장 자연스럽게 드러나는 곳에 두는 것이다.


페이지 수정
공유하기:

이전 글
[DDD Intro #17] Factory: 생성에도 도메인 지식이 있다
다음 글
[DDD Intro #15] Application Service: 유스케이스를 조율하는 계층