Back to posts

도메인 서비스

도메인 서비스의 역할과 repository 의존성에 대한 개인적인 정리.


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

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

버논의 IDDD 책이나 예제 코드에 그렇게 쓰인 적이 있었고, 에반스의 DDD에서도 굳이 금지한다는 말을 보진 못했다. 오랜만에 다시 읽어봐도 내 생각은 여전히 같다.

결론부터 말하면, 직접적으로 금지한다는 말은 못 봤고 오히려 가질 수 있다는 뉘앙스를 전반에서 느꼈다. 다만 이건 트레이드오프가 있으니 팀 정책에 맞추는 게 맞다.

책 내용과 리뷰

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

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

서비스는 도메인 로직을 캡슐화하고 응집도를 높이는 게 1차 목적이다. 명사도 동사도 아닌, 클라이언트가 기대하는 것을 해주는 무언가다. "묻지 말고 말하라", "책임 주도 개발" 원칙을 적용하기 좋은 구성요소다. 서비스를 쓰는데도 응용서비스가 많은 일을 떠안고 있다면 서비스가 제 역할을 못 하는 상황이다.

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

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

인터페이스를 만들든 변수를 만들든, 보편언어를 적용하면 된다. 응용서비스와 쉽게 구분되는 특징이 하나 있는데, 인자와 결과값이 도메인 객체라는 점이다.

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

잘 만들어진 서비스는 3가지 특징이 있다.

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

1번은 번역이 어색하지만, entity/vo 로직을 서비스가 대리하면 안 된다는 뜻으로 이해한다. entity/vo에 넣기 애매한 개념을 서비스로 캡슐화하라는 의미다. (밑에서 설명할 계좌 간 전송 예시가 여기에 해당한다)

2번은 인터페이스가 클라이언트 지향적으로 작성돼야 한다는 의미다. 범용성보다 단순함을 우선해 "묻지 말고 말하게" 만들면 된다.

3번이 중요하다. 흔히 상태를 가지면 안 된다는 말을 의존성을 가지면 안 된다로 오해하는 경우가 많다. 상태와 의존성은 구분해야 한다.

  • 상태: 오퍼레이션에 의해 값이 바뀌는 것 (예: Member의 age)
  • 의존성: 역할을 위해 다른 객체와 협업하는 것 (상태가 바뀌지 않음)

즉, 도메인서비스가 리파지토리를 의존하는 건 문제되지 않는다. 리파지토리가 인프라라고 느껴진다면 어색할 수 있지만, 인터페이스 자체는 도메인 계층으로 분류된다.

JPA 리파지토리를 인프라라고 주장할 수도 있다. 그렇다면 영속화 객체를 따로 두고 매핑해야 한다. 클래스를 두 벌로 관리해야 하고 복잡해진다. (늘 그렇듯 유연한 건 복잡하다)

나는 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 facade = ...;
    var accountRepo = ...;
 
    void send(fromId, toId, amount) {
        Account from = accountRepo.find(fromId);
        from.pendingWithdrawal(amount);
 
        // 네트워크 전송을 퍼사드 밑으로 숨겨 도메인 서비스처럼 동작하게 함
        var transfer = facade.send(toId, amount);
 
        if (transfer.isSuccess()) {
            from.withdraw();
        } else {
            from.rollback();
        }
    }
}

이 서비스는 리파지토리도, 퍼사드도 의존하지만 외부에서 주입받는다. 내부 구현을 모르고도 협업할 수 있다. send 인터페이스와 로직은 보편언어에서 벗어나지 않는다.

구성 단위

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

서비스는 인터페이스를 단순화하고, 중요한 기능을 캡슐화하라는 이야기다. 너무 세밀한 객체가 도메인을 잘 표현하지 못하면 서비스가 필요할 수 있다.

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

인터페이스가 쓰기 복잡하고 응용계층이 알아야 할 게 많다면, 설계가 잘못된 것이다.

마치며

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

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

필요한 의존성을 갖는 편이 응집도를 높이고 인터페이스가 간결해진다고 생각한다. 다만 의존성이 적을수록 테스트성은 좋아지므로 트레이드오프가 있다. 팀과 합의해서 결정하면 된다.