도메인 서비스

Bonjugi·2025년 1월 17일
1

도메인서비스의 실체에 대해 여러 사람과 논해 봤지만 보면 조금씩 생각하는게 달랐다.
(이하 '서비스' 로 축약하겠다. 응용서비스는 구분을 위해 풀네임으로 부르겠다)

가장 많은 견해의 차이는 "서비스가 리파지토리를 가질수 있는가" 였다.
나는 "있다" 고 생각한다.

버논의 iddd 책이나 예제코드에 그렇게 쓰인 적이 있었고,
에반스의 ddd 에서도 굳이 가져선 안된단 말을 보진 못했다.
오늘은 오랜만에 에반스의 ddd 에서 서비스 부분을 다시 읽어봤는데 여전히 내생각은 가질수 있다고 생각한다.
책 내용들을 옮기고 밑에 내 해석을 붙이는 식으로 정리 해 보겠다.

결론부터 말하자면 직접적으로 가지면 안된다는 말은 못봤고 오히려 가져도 된다는 뉘앙스를 전반에서 느꼈다.
두 사람의 의견이 모든 상황에서 항상 옳을수도 없고 내가 잘못 해석 했을수도 있다.
가질수 있든 없든 트레이드오프가 있으니 팀의 정책에 맞출수 있는 부분이다.

책 내용과 리뷰

서비스는 클라이언트 상호작용이 중요

서비스 라는 이름은 다른 객체와의 관계를 강조한다.
entity, vo 와 달리 클라이언트에 무엇을 제공할수 있느냐 에 있다.
entity가 주로 동사나 명사로 이름을 부여하는것과 달리 service는 활동으로 이름을 짓는다.

서비스는 도메인로직을 캡슐화 하고 응집도를 높이는것이 1차적 목적일 것이다.
명사도, 동사도 아닌 클라이언트가 기대하는것을 해주는 무언가다.
"묻지말고 말하라", "책임주도 개발" 원칙을 적용하는게 바람직한 구성요소라고 생각한다.
서비스를 사용하고 있음에도 원칙이 깨지고 있거나 응용서비스가 많은걸 핸들링 해야 한다면 서비스가 제 역할을 다하지 못하는 상황이다.

서비스는 보편언어로 작성되어야 한다.

서비스의 책임과 해당 책임을 이행하는 인터페이스는 도메인 모델의 일부로 정의될 것이다.
연산의 명칭은 보편언어에서 유래하거나 보편언어에 도입돼야 한다.
또한 서비스의 매개변수와 결과는 도메인 객체여야 한다.

위 내용은 쉽게 실천할수 있다.
인터페이스를 작성하든, 변수를 만들던 어떤 상황에서든 보편언어를 적용하면 된다.
응용서비스와 쉽게 구분할만한 내용으로 인자와 결과값이 도메인객체 라는 특징이 있다.

잘 만들어진 서비스의 3가지 특징

잘 만들어진 서비스는 3가지 특징이 있다.
1. 연산이 원래부터 entity나 vo 의 일부를 구성하는것이 아니라, 도메인 개념과 관련돼 있다.
2. 인터페이스가 도메인 모델의 외적 요소의 측면에서 정의된다.
3. 연산이 상태를 갖지 않는다.

1번은 번역이 좀 이상하긴 한데 entity, vo 의 로직을 서비스가 대리로 구현하지 말라는 의미로 이해할수 있다.
그리고 entity, vo 에 있으면 어색한 개념을 억지로 넣지 말고, 분리해서 서비스로 캡슐화 하는 용도로 쓰라는 의미로 이해하면 된다. (밑에서 설명할 계좌와 계좌간의 전송 예시가 여기에 해당한다.)

2번은 인터페이스가 클라이언트 지향적으로 작성되어야 한다는 의미다.
범용성, 융통성 보다는 인터페이스의 단순함으로 클라이언트에게 묻지말고 말하게끔 만들면 된다.

3번이 중요한데, 상태를 갖지 말라는 말을 의존성을 가지면 안된다 라고 오해 하는것 같다.
상태와 의존성은 다르게 구분할수 있다.

  • 상태 : 예를들어 Member 내 age는 오퍼레이션이 발생할때 바뀐다. 이를 상태가 바꼈다고 말한다.
  • 의존성 : 서비스가 혼자서 책임질수 없는 일을 하기 위해, 다른 역할을 가진 객체와 협업하는것. 상태가 바뀌지 않는다. 의존성을 서비스가 직접 생성하지 않고 외부로부터 주입 받기 때문에 문제되지 않는다.

즉, 도메인서비스가 리파지토리 의존성을 갖는데 문제되지 않는다.
또한 리파지토리가 인프라스트럭쳐로 인식하고 있다면 어색할수 있지만 인터페이스는 도메인 계층으로 분류된다.

JPA 리파지토리는 인프라스트럭쳐여야 한다고 주장 할수도 있다.
나는 그럴거라면 모든 영속화 객체들 (리파지토리, 엔티티, VO) 모두 도메인객체와 따로 만들어서 관리해야 한다고 생각한다.
클래스를 두벌씩 가져야 하고 read/write 할때마다 매핑하는 복잡함이 생긴다. (늘 그렇든 유연한건 읽기 복잡하다)

나는 JPA 자체를 반대하는 주장에도 매우매우 공감하지만, 적정한 수준으로 쓸거면 굳이 분류하지 않는게 효율적 이라고 생각한다.

서비스를 수행하면 전역적으로 접근 가능한 정보를 사용할 것이며, 심지어 그러한 전역 정보를 변경할 수도 있다. 다시 말하면 부수효과가 발생할수도 있다는 의미다. 그러나 서비스는 대부분의 도메인 객체와 달리 자신의 행위에 영향을 줄 수 있는 상태를 갖지 않는다.
도메인의 중대한 프로세스나 변환 과정이 엔티티나 VO의 고유한 책임이 아니라면 연산을 서비스로 선언되는 독립 인터페이스로 모델에 추가하라.

이 대목 부수효과 얘기를 듣고 나서 "상태를 가져선 안된다" 라는 말이 상태에만 해당된다는 것을 좀더 확신할수 있다.
의존성은 위 내용과 상관이 없다.

응용서비스와 모호한 경계

수많은 서비스는 entity와 value 를 조직화해서 뭔가 이뤄지게 하는 시나리오와 같다.
간혹 entity, vo를 너무 세밀하게 구성해서 도메인 계층의 사용성을 떨어트리기도 한다.
도메인계층과 응용계층 사이에 아주 가느다란 경계선에 마주치게 된다.
예를들어, 거래내역을 파일로 내보낼수 있다면, 내보내기 기능은 응용서비스에 해당한다.
은행업무 도메인에서 "파일 형식" 이라는 것이 아무런 의미가 없다.

아주 가느다란 경계선 에 공감이 간다.
확실한 구분선은, 파일 I/O 정도 들어갈 일은 없다는 것이다.

하지만 "파일형식을 xlsx로 다운받거나 csv로 다운받을수 있다" 는 경계가 가느다랗다.
나는 보편언어로 정의할수가 있고 테스트도 가능하다면, 도메인 규칙이라고 볼수 있다고 생각한다.
파일형식도 그 대상이 될수 있다.

계좌와 계좌간 전송역할 (서비스의 필요성)

계좌에서 다른계좌로 이체하는 기능은 도메인서비스 이다.
다만 서비스가 그 자체로 많은 일을 하진 않으며, 두 Account 객체가 대부분의 일을 수행하도록 요청 할 것이다.
그러나 이체 연산을 Account 에 집어넣는것은 다소 부자연스러울수도 있는데, 왜냐하면 이체 연산은 두 계좌와 일부 전역적인 규칙을 수반하기 때문이다.

두 Account간의 전송 예시는 적절한 예시인것 같다.
만약 Account에 집어넣었을때 예시는 이러할 것이다.
사실 나는 이 방법도 표현력이 좋기 때문에 "가벼운 도메인 객체면" 종종 쓰기도 한다.

var account1 = ...
var account2 = ...
account1.submit(account2, 1_000);

반대로 경계 하기도 하는데, 계좌 객체는 핵심 도메인중 하나로 이미 많은 책임을 지고 있을 확률이 높다.
여기에 거래 가 들어가면, 언젠가 거래내역 도 들어가기도 좋다.
책임을 하나 둘 얹다보면 진흙공이 되는 속도가 점점 가속화 될수 있다.
반대로 너무 세분화되어 도메인 지식이 새어 나오거나 사용성이 떨어지지 않게끔 주의 해야한다.

자금 이체와 관련된 규칙과 이력이 더해진 두 기입 내역을 나타내는 Transfer 객체를 생성하고 싶을지도 모른다.

맞다. 나는 Transfer 객체를 만드는걸 좋아하는 편이다.
꼭 영속화 객체가 아니어도 좋다고 생각한다.

var t = Transfer.from(account1).to(account2);
t.send(1_000);

하지만 그런 경우에도 은행 간 네트워크에서 서비스를 요청하지 않을 수는 없다.
또한 대부분의 개발 시스템에서는 도메인 객체와 외부 자원 간의 직접 적인 인터페이스를 만든다는 것이 자연스러워 보이진 않는다.
우리는 그와 같은 외부 서비스를 모델의 측면에서 입력을 받아들이는 퍼사드로 만들 수 있으며, 아마도 퍼사드에서는 Transfer 객체를 결과로 반환할 것이다. 그러나 어떠한 매개체가 있거나, 또는 없더라도 서비스는 자금 이체와 관련된 도메인의 책임을 수행할 것이다.

네트워크 사용이 필요해지면서 Transfer 객체로써 할수 없는 상황이 왔고 TransferService 가 필요하다는 설명이다.
서비스 스스로 자금이체를 수행할수 있어야 하는것까진 이해가 되는데, 퍼사드도 가질수 있다는 건지는 좀 헷갈린다.
가능하다면 이러한 코드가 작성될 것이다.

class TransferDomainService {

	var 퍼사드 = ...;
    var accountRepo = ...;

	void send(fromId, toId, amount) {
    
    	Account from = accountRepo.find(fromId);
    	from.pendingWithdrawal(amount);
        
        // 네트워크 전송을 퍼사드 밑으로 숨기고 마치 도메인 서비스처럼 동작함
        var transfer = 퍼사드.send(toId, amount);
        
        if (transfer.isSuccess()) {
	        from.withdraw();        
        } else {
        	from.rollback();
        }

    }

}

위 서비스는 리파지토리도 의존하고 퍼사드도 의존하지만 외부로 부터 주입받으며 내부 구현을 전혀 모른다.
send 인터페이스와 로직은 보편언어에서 전혀 벗어나지 않는다.
send 오퍼레이션에서 딱 바라는 만큼을 처리하고 있다.
인터페이스가 간결하여 클라이언트와 상호작용이 매끄럽다.

구성 단위

구성 단위가 중간 크기인 무상태 서비스는 대형 시스템에서 재사용하기 더 쉬울수 있는데, 단순한 인터페이스 너머에 중요한 기능을 캡슐화 하고 있기 때문이다.
아울러 구성 단위가 세밀한 객체는 분산 시스템에서 비효율적인 메시지 전송을 초래할 수 있다.

서비스에 대해서는 인터페이스를 단순화하고 캡슐화 하라는 얘기가 반복되고 있다.
너무 세밀한 객체가 도메인을 잘 표현하지 못하다면 서비스를 사용해야 될수도 있다.

구성 단위가 세밀한 객체는 도메인에서 지식이 새어 나오게 해서 도메인 객체의 행위를 조정하는 응용 계층으로 흘러가게 할수 있다. 그렇게 되면 고도로 세분화된 상호작용의 복잡성이 결국 응용 계층에서 처리되고 만다.
이 패턴(서비스)은 클라이언트 제어와 융통성 보다는 인터페이스의 단순함을 선호한다.
이는 대형 시스템이나 분산 시스템에서 컴포넌트를 패키지화 하는데 매우 유용한 중간 구성 단위의 기능성을 제공한다. 그리고 때로는 서비스가 도메인 개념을 표현하는 가장 자연스러운 방법이기도 하다.

또 인터페이스를 단순화 하라는 얘기.
만약 인터페이스가 쓰기 복잡하고 응용계층이 알아야 할게 많다면 잘못 설계된 것이다.

마치며

"서비스는 repository를 가져선 안된다" 라는 의견은 2가지 근거를 다른 해석으로 회피 해 봤다.

1. 상태를 가져선 안된다는 글이 많이 퍼졌음
2. JPA repository가 인프라스트럭쳐 라서 상위 수준인 도메인이 의존하면 안된다는 원칙

필요한 의존성은 갖고있는 편이 더 높은 응집도를 만들고, 인터페이스가 간결해 진다고 생각한다.
반드시 의존성을 가지도록 해야 한다는것은 아니다.
의존성이 적을수록 테스트성이 좋아지기 때문에 트레이드오프가 있는 부분이라 협의해서 결정할수 있는 문제라고 생각한다.

0개의 댓글