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

[DDD Intro #15] Application Service: 유스케이스를 조율하는 계층

페이지 수정

DDD를 적용할 때 가장 혼란스러운 이름 중 하나가 Service다. Application Service, Domain Service, Infrastructure Service가 모두 Service라는 이름을 갖는다. 그래서 시간이 지나면 모든 로직이 SomethingService에 모인다.

특히 Application Service는 비즈니스 로직이 쌓이기 쉬운 장소다. 컨트롤러에서 요청을 받고, Repository를 호출하고, 조건문으로 상태를 확인하고, 외부 API를 호출하고, 저장까지 처리한다. 처음에는 자연스럽다. 하지만 도메인 규칙이 늘어나면 Application Service는 거대한 절차적 스크립트가 된다.

Application Service의 역할은 도메인 로직을 직접 판단하는 것이 아니다. Application Service는 유스케이스의 흐름을 조율하는 계층이다.

유스케이스의 흐름을 담당한다

구독 요금제를 변경하는 기능을 생각해보자. 이 유스케이스에는 여러 단계가 있다.

1. 요청한 사용자의 구독을 조회한다.
2. 대상 요금제를 조회한다.
3. 구독에게 요금제 변경을 요청한다.
4. 추가 결제가 필요하면 결제 요청을 만든다.
5. 변경된 구독을 저장한다.
6. 필요한 이벤트를 발행한다.

이 전체 흐름은 Application Service가 담당하기 좋다.

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

    subscription.changePlan(targetPlan, planChangePolicy, clock.now());

    subscriptionRepository.save(subscription);
    eventPublisher.publishAll(subscription.pullEvents());
}

여기서 Application Service는 중요한 일을 한다. 객체를 조회하고, 유스케이스에 필요한 협력 객체를 준비하고, 도메인 객체에게 일을 시키고, 저장하고, 이벤트를 발행한다.

하지만 “요금제를 변경할 수 있는지”, “남은 기간은 어떻게 계산하는지”, “어떤 상태 변화가 일어나는지”를 직접 판단하지는 않는다. 그 판단은 도메인 객체나 도메인 정책 객체가 담당한다.

Application Service에 들어가도 되는 것

Application Service에 아무 로직도 없어야 한다고 생각하면 오해다. Application Service에는 애플리케이션 흐름과 관련된 로직이 들어간다.

예를 들면 다음과 같다.

트랜잭션 시작과 종료
권한 확인
요청 DTO를 Command로 변환
Repository를 통한 Aggregate 조회
도메인 객체 메서드 호출
외부 시스템 호출 순서 조율
Domain Event 발행
응답 모델 구성

이런 로직은 특정 도메인 객체 하나가 책임지기 어렵다. 유스케이스 전체의 진행을 알고 있어야 하기 때문이다.

반면 다음과 같은 규칙은 Application Service에 오래 머물면 위험하다.

만료된 구독은 요금제를 변경할 수 없다.
무료 체험은 한 계정당 한 번만 가능하다.
결제 실패 후 3일 동안은 유예 기간을 둔다.
기업 요금제는 최소 좌석 수를 만족해야 한다.

이 규칙들은 도메인 규칙이다. Application Service가 직접 판단하기 시작하면 같은 규칙이 여러 유스케이스에 흩어질 수 있다.

나쁜 Application Service의 냄새

Application Service가 비대해지고 있다는 신호는 꽤 분명하다.

첫 번째 신호는 조건문이 도메인 규칙을 직접 표현하는 경우다.

if (subscription.getStatus() == EXPIRED) {
    throw new IllegalStateException("만료된 구독은 변경할 수 없습니다.");
}

if (targetPlan.isEnterprise() && command.seatCount() < 10) {
    throw new IllegalStateException("기업 요금제는 최소 10석 이상이어야 합니다.");
}

이런 조건이 Application Service에 많아지면 도메인 객체는 빈약해진다.

두 번째 신호는 setter 호출이 많아지는 경우다.

subscription.setPlanId(targetPlan.getId());
subscription.setStatus(ACTIVE);
subscription.setNextBillingDate(nextBillingDate);

이 코드는 유스케이스를 조율하는 것이 아니라 도메인 객체의 내부 상태를 조작하고 있다.

세 번째 신호는 같은 규칙이 여러 Application Service에 반복되는 경우다. 사용자 해지, 관리자 해지, 결제 실패 종료에서 비슷한 상태 검증이 반복된다면, 규칙이 있어야 할 곳을 다시 봐야 한다.

좋은 Application Service는 얇지만 의미가 있다

Application Service는 무조건 얇아야 한다는 말이 있다. 방향은 맞지만, “얇다”는 표현만으로는 부족하다. 좋은 Application Service는 단순히 코드가 짧은 것이 아니라 도메인 판단을 도메인 객체에게 맡긴다.

@Transactional
public void cancelSubscription(CancelSubscriptionCommand command) {
    Subscription subscription = subscriptionRepository.findById(command.subscriptionId());

    subscription.cancelByMember(command.reason(), clock.now());

    subscriptionRepository.save(subscription);
    eventPublisher.publishAll(subscription.pullEvents());
}

이 코드는 짧다. 하지만 단순히 짧아서 좋은 것이 아니다. 해지 가능 여부, 해지 시 상태 변화, 발생할 도메인 이벤트는 Subscription이 판단한다. Application Service는 유스케이스의 흐름을 명확하게 보여준다.

이런 코드는 읽는 사람이 “이 기능이 어떤 순서로 진행되는지” 빠르게 이해하게 해준다. 동시에 세부 규칙은 도메인 객체를 찾아보면 된다.

트랜잭션 경계와 Application Service

Application Service는 트랜잭션 경계를 잡기 좋은 위치다. 하나의 유스케이스가 어떤 Aggregate를 조회하고 변경하는지 알고 있기 때문이다.

@Transactional
public void renewSubscription(RenewSubscriptionCommand command) {
    Subscription subscription = subscriptionRepository.findById(command.subscriptionId());
    PaymentResult paymentResult = paymentGateway.pay(command.paymentRequest());

    subscription.renew(paymentResult, clock.now());

    subscriptionRepository.save(subscription);
}

다만 여기서도 주의할 점이 있다. 외부 시스템 호출을 트랜잭션 안에 넣어도 되는지, 결제 성공 후 저장 실패가 발생하면 어떻게 처리할지, 이벤트 발행은 트랜잭션 커밋 전인지 후인지 고민해야 한다.

Application Service는 이런 애플리케이션 수준의 일관성을 조율한다. 하지만 “갱신이 가능한 구독인가” 같은 도메인 판단까지 직접 떠안으면 책임이 섞인다.

트랜잭션 경계는 기술적 문제이면서 도메인 문제와 연결된다. Aggregate 경계와 Application Service의 트랜잭션 경계는 함께 고민해야 한다.

Domain Service와의 차이

Application Service와 Domain Service는 이름이 비슷하지만 역할이 다르다.

Application Service는 유스케이스 흐름을 조율한다. 사용자의 요청을 받아 어떤 객체들을 불러오고, 어떤 순서로 호출하고, 결과를 어떻게 저장할지 결정한다.

Domain Service는 도메인 개념이지만 특정 Entity나 Value Object에 자연스럽게 속하지 않는 로직을 표현한다. 예를 들어 요금제 변경 수수료 계산, 여러 구독 이력을 바탕으로 한 무료 체험 가능 여부 판단 같은 것이 될 수 있다.

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

planChangePolicy는 도메인 규칙을 담는다. Application Service는 이 정책 객체를 준비해서 도메인 행위에 전달한다.

역할을 구분하지 않으면 모든 것이 Application Service에 모인다. 그러면 DDD는 계층만 나뉜 절차적 코드가 된다.

마치며

Application Service는 중요한 계층이다. 하지만 그 중요성은 모든 비즈니스 로직을 담는 데 있지 않다. Application Service의 가치는 유스케이스 흐름을 명확하게 조율하는 데 있다.

좋은 Application Service는 도메인 객체가 일하게 만든다. Repository에서 Aggregate를 꺼내고, 필요한 정책 객체를 준비하고, 도메인 행위를 호출하고, 저장하고, 이벤트를 발행한다. 직접 모든 것을 판단하지 않는다.

Application Service에 조건문과 setter가 늘어나기 시작하면 경계 신호로 받아들여야 한다. 그 조건은 도메인 객체가 알아야 하는가? Domain Service나 Policy로 분리해야 하는가? 아니면 정말 애플리케이션 흐름에 속하는가?

DDD에서 Application Service는 주인공이 아니다. 무대 위에서 도메인 객체들이 자신의 역할을 하도록 순서를 조율하는 연출자에 가깝다.


페이지 수정
공유하기:

이전 글
[DDD Intro #16] Domain Service: 객체에 억지로 넣기 어려운 도메인 로직
다음 글
[DDD Intro #14] Repository: DAO가 아니라 도메인 컬렉션처럼 다루기