들어가기 앞서
DDD 기반의 아키텍처를 적용하면 도메인 레이어와 애플리케이션 레이어로 레이어를 나뉘는 것을 볼 수 있다. 그런데 때로는 어떤 로직을 애플리케이션 레이어에 두기도 애매하고, 그렇다고 도메인 레이어(특정 도메인 객체, 혹은 루트 애그리거트 객체)에 두기도 애매한 경우가 종종 생기곤 한다. 이때, 도메인 서비스라는 새로운 책임을 가진 객체를 생성하는 것으로 결론에 다다른다.
•
애플리케이션 서비스 → 도메인 서비스 → 도메인 객체
이렇게 구성하다보면 어떤 로직을 애플리케이션 서비스/도메인 서비스에 둬야할지 헷갈리고 고민이 된다. 나의 경우 Unit Test 저자로 유명한 Vladimir Khorikov가 “도메인 서비스와 애플리케이션이 어떻게 다르고, 어떠한 경우에 도메인 서비스를 사용하는 것이 좋은지”에 대해 작성한 포스트가 있어서 개념을 이해하는데 많은 도움이 되었다.
이 내용을 이번에 번역하여 공유하고자 한다.
1. 도메인 서비스 vs 애플리케이션 서비스
도메인 서비스는 엔티티와 값객체가 담당하는 책임에 적합하지 않은(역주: 특정 엔티티가 담당하기에 애매한 도메인 로직) 도메인 로직을 담당하고 있다고 합니다. 그러나 이 외에도, 도메인 서비스를 도입하고자 하는 또 다른 이유가 있습니다. 그것은 바로 도메인 모델 격리 와 관련이 있습니다.
도메인 서비스와 애플리케이션 서비스의 차이점은 무엇일까요? 두 개념 모두 도메인 엔티티와 값 객체들과 함께 동작하며, 특정한 상태를 가지고 있지 않은 클래스(stateless class)입니다. 두 개념의 주요한 차이점은 도메인 서비스는 도메인 로직을 보유하는 반면, 애플리케이션 서비스는 그렇지 않다는 것입니다.
도메인 로직은 말 그대로 비즈니스 의사결정과 관련된 모든 것을 의미합니다. 따라서 도메인 서비스는 엔티티와 값 객체와 동일한 방식으로 동작합니다. 그리고 애플리케이션 서비스는 엔티티와 값 객체가 내리는 결정을 조율(Orchestration)합니다.
다음 예시를 살펴보겠습니다.
1.1. Revision 1
// 애플리케이션 서비스
public withdrawMoney(amount: number): void {
// 도메인 로직 호출 -> 비즈니스 의사결정 내림
this.atm.dispenseMoney(amount);
const amountWithCommission = this.atm.calculateAmountWithCommission(amount);
// 결정 -> 결과로 변환
this.paymentGateway.chargePayment(amountWithCommission);
this.repository.save(this.atm);
}
TypeScript
복사
withdrawMoney 메서드는 애플리케이션 서비스의 일부이며, 고객이 사용할 수 있는 API를 포함합니다. 이 메서드는 먼저 ATM 엔티티에 일정 금액의 돈을 인출하도록 지시한 다음, 커미션을 포함한 금액을 계산하도록 요청하고, 그 계산 결과를 사용해 결제 게이트웨이를 통해 결제를 처리한 후 엔티티를 데이터베이스에 저장합니다.
이 애플리케이션 서비스가 어떻게 보이나요? 적절해 보이나요? 아니면 일부 코드를 도메인 서비스로 분리해야할까요? 어떻게 하는게 좋을지 지금부터 살펴보겠습니다.
처음 두 줄에서는 메서드가 ATM 도메인 엔티티를 사용하여 일부 비즈니스 의사결정을 내립니다. 마지막 두 줄에서는 이러한 결정을 가시적인 결과로 변환합니다. 즉, 결제 게이트웨이를 호출하고 데이터베이스 상태를 수정합니다.
처음 두 줄은 도메인 모델에 의사 결정을 위임하는 것을 의미합니다. 그런데, 이러한 두 줄의 코드를 사용하는 것 자체를 도메인 로직으로 볼 수도 있지 않을까요? 그렇다면 아래의 예시처럼 도메인 서비스로 추출해야 하지 않을까요?
1.2. Revision 2
// 애플리케이션 서비스
public withdrawMoney(amount: number): void {
const amountWithCommission = this.atmService.dispenseAndCalculateCommission(
this.atm, amount
);
this.paymentGateway.chargePayment(amountWithCommission);
this.repository.save(this.atm);
}
// 도메인 서비스
public class AtmService {
public dispenseAndCalculateCommission(atm: Atm, amount: number): number {
atm.dispenseMoney(amount);
return atm.calculateAmountWithCommission(amount);
}
}
TypeScript
복사
사실, 그렇지 않습니다. 도메인 모델과 관련된 여러 줄의 코드를 사용한다고 해서, 이 자체가 도메인 로직이 되는 것은 아닙니다. 중요한 것은 이 코드가 비즈니스 의사결정을 내리는 책임이 있는지 여부입니다.
위 예시에서 dispenseAndCalculateCommission 메서드의 복잡도는 1입니다. 어떤 결정을 내리는 분기(if문 처럼)가 없으며, 단순히 도메인 엔티티에게 두 가지 작업을 요청합니다.
이 두 메서드가 호출되는 순서도 중요하지 않습니다. 커미션 계산 쿼리를 돈 인출 명령 위에 두더라도 ATM의 불변성에 아무런 변화가 없습니다. 이러한 점은 구현 세부 사항이 노출되지 않았다는 강력한 신호입니다.
이제 코드를 조금 변경하고 검증을 추가했다고 가정해 보겠습니다.
1.3. Revision 3
// 애플리케이션 서비스
public withdrawMoney(amount: number): void {
if (!this.atm.canDispenseMoney(amount)) return;
this.atm.dispenseMoney(amount);
const amountWithCommission = this.atm.calculateAmountWithCommission(amount);
this.paymentGateway.chargePayment(amountWithCommission);
this.repository.save(this.atm);
}
TypeScript
복사
이 예시에서는 if 문이 추가되어 복잡도가 증가했습니다. 그렇다면 애플리케이션 서비스에 도메인 규칙이 포함된 것일까요? 그렇지 않습니다. 실제 의사 결정 과정은 여전히 ATM에 있습니다. 엔티티만이 돈을 인출할 수 있는지 여부를 결정합니다. 애플리케이션 서비스는 그 결정을 조율하여 실행을 계속할지 여부를 판단할 뿐입니다.
dispenseMoney 메서드가 CanDispenseMoney가 true여야 한다는 전제 조건을 가지고 있는 한, 모든 불변성이 보호됩니다. 전제 조건 자체는 다음과 같이 간단하게 구현할 수 있습니다:
// 애플리케이션 서비스
public withdrawMoney(amount: number): void {
this.atm.dispenseMoney(amount);
const amountWithCommission = this.atm.calculateAmountWithCommission(amount);
this.paymentGateway.chargePayment(amountWithCommission);
this.repository.save(this.atm);
}
// ATM 도메인 객체
public dispenseMoney(amount: number): void {
if (!this.canDispenseMoney(amount))
throw new Error("Invalid Operation");
/* ... */
}
TypeScript
복사
따라서 애플리케이션 서비스가 CanDispenseMoney의 결정을 무시하더라도 ATM 엔티티는 일관성 없는 상태에 들어가지 않습니다. 빠른 실패 원칙에 따라 예외가 발생할 것입니다.
2. 도메인 서비스를 추출해야 할 때는?
위 예시에서 애플리케이션 서비스는 비즈니스 의사결정을 내리지 않고, 그 결정을 도메인 모델에 위임합니다. 도메인 모델은 격리되어 있습니다. ATM 엔티티는 스스로를 데이터베이스에 저장하지 않으며 결제 게이트웨이를 통해직접 결제를 처리합니다. 우리는 비즈니스 로직을 도메인 모델에 할당하고 외부 세계와의 상호작용을 애플리케이션 서비스에 할당하는 명확한 책임 분리를 가지고 있습니다.
이러한 가이드라인을 준수하는 코드들의 패턴을 분석하여 플로우를 살펴보면 다음과 같습니다.
1.
비즈니스 작업에 필요한 모든 정보를 준비합니다.
•
DB로 부터 관련된 엔티티를 질의하고 외부 소스로부터 필요한 데이터를 가져옵니다.
2.
작업(비즈니스 로직)을 실행합니다.
•
작업은 도메인 모델에서 내려진 하나 이상의 비즈니스 의사결정으로 구성됩니다.
•
이러한 결정은 모델의 상태를 변경합니다.
•
혹은 일부 결과물(위 예시에서 amountWithCommission 값)을 생성합니다.
3.
작업 결과를 외부 세계에 적용합니다.
1단계와 3단계 작업만 외부 의존성을 가지고 있습니다 . 2단계는 1단계에서 가져온 데이터로만 닫혀 있습니다 (도메인 모델 순수성). 이 단계에서 Input과 Output은 엔티티, 값 객체, 그리고 원시 타입으로만 구성됩니다.
단순한 CRUD 애플리케이션에서는 2단계가 없습니다. 결정할 사항이 없기 때문입니다. 이 경우 모든 작업은 애플리케이션 서비스에서만 수행할 수 있으며, 도메인 모델에 위임할 필요가 없습니다. 사실, 이 경우 풍부한 도메인 모델이 필요 없으며, 빈약한 도메인 모델(Anemic Domain Model)도 충분히 작동합니다.
2.1. Revision 4
이제 코드 예시를 조금 더 현실적인 시나리오로 변경해보겠습니다.
•
추가 조건
◦
결제에 실패할 수 있음
◦
결제가 실패할 경우 현금을 인출해서는 안 안됨
이 시점에서 앞서 설명한 명확한 책임 분리가 무너집니다. 의사 결정 과정이 그 과정이 시작되기 전에는 사용할 수 없는 정보에 의존하기 때문입니다. 다음은 수정된 코드입니다.
// 애플리케이션 로직
public withdrawMoney(amount: number): void {
if (!this.atm.canDispenseMoney(amount)) return;
const amountWithCommission = this.atm.calculateAmountWithCommission(amount);
const result = this.paymentGateway.chargePayment(amountWithCommission);
// 신규 추가된 로직: 현금 인출 여부를 결정 (도메인 로직)
if (result.isFailure) return;
this.atm.dispenseMoney(amount);
this.repository.save(this.atm);
}
TypeScript
복사
이 버전에서는 두 번째 if문은 도메인 로직을 나타냅니다. 이는 사용자가 현금을 인출할지 여부를 결정합니다. 그러나 첫 번째 if문과 다르게, 이 결정은 도메인 객체(ATM 엔티티)가 내리는 것이 아니라 애플리케이션 서비스 자체가 내립니다.
이제 결제가 실패하더라도 ATM에서 현금을 인출할 수 있습니다. 도메인 엔티티는 이 불변성을 보장하지 않습니다. 이 전제 조건을 확인하려면 3자 서비스(외부 서비스)를 호출해야 하므로 도메인 모델(엔티티) 격리를 위반하지 않고는 이 불변성을 도입할 수 없습니다.
이런 상황에서 어떻게 해야 할까요? 도메인 서비스가 도움이 될 수 있습니다. 외부 세계에서 추가 정보를 필요로 하며, 엔티티와 값 객체가 그 정보를 얻지 못해 결정을 내릴 수 없는 비즈니스 의사결정을 도메인 서비스에 할당할 수 있습니다.
2.2. Revision 5
// 애플리케이션 서비스
public withdrawMoney(amount: number): void {
const atm = this.repository.get();
this.atmService.withdrawMoney(atm, amount);
this.repository.save(atm);
}
// 도메인 서비스
public class AtmService {
public withdrawMoney(atm: Atm, amount: number): void {
// ATM 도메인 객체
if (!atm.canDispenseMoney(amount)) return;
const amountWithCommission = atm.calculateAmountWithCommission(amount);
const result = this.paymentGateway.chargePayment(amountWithCommission);
if (result.isFailure) return;
atm.dispenseMoney(amount);
}
}
TypeScript
복사
여기서 도메인 서비스는 불순성(domain impurity)과 도메인 복잡성(domain complexity) 사이에서 절충점을 찾는 역할을 합니다. 한편으로, 이 서비스는 작업을 수행하기 위해 결제 게이트웨이와 상호작용해야 하기 때문에 완전히 격리될 수 없습니다. 다른 한편으로, 너무 많은 도메인 로직을 서비스에 할당하지 않고, 단지 현금을 신용으로 교환하는 방법에 대한 도메인 로직만을 보유합니다.
여전히 가능한 많은 로직을 엔티티에 할당합니다. 예를 들어, 현금을 인출하는 행위는 여전히 ATM의 책임입니다. 그리고 도메인 서비스를 가능한 한 격리하려고 노력합니다. 예를 들어, 이 서비스를 리포지토리와 연동하지 않는데, 이는 비즈니스 의사결정을 내리는 데 필요하지 않기 때문입니다. 서비스에 도입된 불순성과 도메인 로직은 여기서 제대로 작동하기 위한 최소한으로 구성되어 있습니다.
이러한 개선 방식이 다소 이해가 되지 않을 수 있습니다. 실질적인 이점이 없는 단순한 책임 분리라고 생각 할 수 있습니다. 그러나 몇 가지 이점이 있습니다.
1.
도메인 서비스의 코드는 애플리케이션 서비스보다 더 테스트하기 쉽습니다. 외부 의존성이 적기 때문에 단위 테스트에서 더 적은 테스트 더블을 사용할 수 있습니다. 물론 이 서비스는 엔티티만큼 쉽게 단위 테스트를 할 수 있는건 아니지만 여전히 쉽습니다.
2.
도메인 규칙이 외부 레이어로 누출되지 않도록 하고, 도메인 로직이 도메인 레이어 경계 내에 유지되어 가독성을 높일 수 있습니다.
이 특정 예제에서 이러한 두 가지 이점이 큰 역할을 한다고 말하기는 어렵습니다. 약간의 로직이 엔티티에 맞지 않을 때마다 별도의 도메인 서비스를 도입하지 않고 애플리케이션 서비스 내에 남겨두는 것이 대부분 괜찮습니다. 그러나 이 로직이 중복되지 않고 너무 복잡하지 않도록 해야 합니다. 만약 DRY(Don't Repeat Yourself) 원칙이 위반되거나 애플리케이션 서비스가 너무 복잡해지면, 반드시 도메인 서비스를 도입해야 합니다.
3. 도메인 서비스를 엔티티에 주입할 수 있는가?
가끔 듣는 질문 중 하나는 도메인 서비스를 엔티티에 주입할 수 있느냐는 것입니다.
개인적으로 저는 두 가지 유형의 도메인 서비스를 구분합니다: 순수한(격리된) 서비스와 비순수한(격리되지 않은) 서비스입니다. 전자는 엔티티와 값 객체로 구성되어 닫혀있으며, 외부 의존성이 없습니다. AtmService는 비순수한 도메인 서비스의 예입니다.
비순수한 도메인 서비스를 엔티티에 주입하는 것은 도메인 모델 격리를 깨뜨리므로 추천하지 않습니다. 반면, 순수한 도메인 서비스는 해가 되지 않으므로 엔티티와 값 객체에서 참조하는 것은 전혀 문제가 없습니다.
4. 요약
•
도메인 서비스는 도메인 로직을 담고 있으며, 애플리케이션 서비스는 그렇지 않습니다 (이상적으로).
•
도메인 서비스는 특정 엔티티와 값 객체가 가지기에 모호한 도메인 로직을 보유합니다.
•
엔티티/값 객체에 할당할 수 없는 도메인 규칙이 있거나 그것이 격리(도메인 레이어 내에 존재하는것)를 깨뜨리게 된다면 도메인 서비스를 도입해야 합니다.
5. 원본 포스트 주요 댓글
•
어플리케이션 / 도메인 서비스를 명확히 분리하는 것이 항상 효과적이진 않은 것 같다. YAGNI를 따른다. 특정 도메인 엔티티에 넣기 애매한 도메인 로직이 하나의 유즈케이스에만 사용된다면 애플리케이션 서비스에 그냥 둔다. 그러나 만약 두개 이상의 유즈케이스에서 사용된다면 도메인 서비스로 분리한다.