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

[DDD Intro #13] 도메인 객체에 행위를 넣는다는 것

페이지 수정

DDD를 적용한다고 말하면서도 실제 코드를 보면 도메인 객체가 거의 아무 일도 하지 않는 경우가 많다. 클래스 이름은 Subscription, Order, Payment처럼 도메인스럽지만, 내부에는 필드와 getter, setter만 있다. 중요한 규칙은 대부분 SubscriptionService, OrderService, PaymentService 같은 서비스 클래스에 들어 있다.

겉으로 보기에는 계층도 나뉘어 있고, 도메인 패키지도 있고, Entity도 있다. 하지만 도메인 객체는 스스로 아무것도 판단하지 않는다. 상태만 들고 있을 뿐이다.

이런 모델을 흔히 빈약한 도메인 모델이라고 부른다.

DDD에서 도메인 객체에 행위를 넣는다는 것은 단순히 메서드를 많이 만들자는 말이 아니다. 도메인 객체가 자신의 규칙을 알고, 의미 있는 행위를 표현하게 하자는 뜻이다.

상태 변경은 행위가 아니다

구독을 해지하는 기능을 생각해보자. 흔한 구현은 다음과 비슷하다.

public void cancelSubscription(Long subscriptionId) {
    Subscription subscription = repository.findById(subscriptionId);

    if (subscription.getStatus() == SubscriptionStatus.EXPIRED) {
        throw new IllegalStateException("이미 만료된 구독입니다.");
    }

    subscription.setStatus(SubscriptionStatus.CANCELED);
    subscription.setCanceledAt(Instant.now());

    repository.save(subscription);
}

이 코드는 동작한다. 하지만 도메인 규칙이 Application Service에 들어 있다. Subscription은 자신의 상태가 만료되었는지, 해지 가능한지, 해지되면 어떤 값이 바뀌어야 하는지 모른다.

이런 구조에서는 같은 규칙이 여러 곳에 반복되기 쉽다. 사용자가 직접 해지하는 기능, 관리자가 직권 해지하는 기능, 결제 실패로 자동 종료하는 배치가 각각 비슷한 상태 변경 코드를 갖게 된다.

더 나은 방식은 구독 객체에게 행위를 요청하는 것이다.

public void cancelSubscription(SubscriptionId subscriptionId, CancelReason reason) {
    Subscription subscription = repository.findById(subscriptionId);
    subscription.cancelByMember(reason, Instant.now());
    repository.save(subscription);
}

여기서 Application Service는 흐름을 조율한다. 구독을 조회하고, 구독에게 해지를 요청하고, 저장한다. 해지 가능한지 판단하고 어떤 상태로 바뀌어야 하는지는 구독 객체가 책임진다.

메서드 이름은 도메인 언어여야 한다

도메인 객체에 행위를 넣을 때 중요한 것은 메서드 이름이다. 단순히 setter를 감춘다고 좋은 모델이 되는 것은 아니다.

subscription.updateStatus(CANCELED);

이 코드는 setStatus보다 조금 나아 보일 수 있지만, 여전히 도메인 행위가 아니다. 상태를 업데이트한다는 말은 기술적 표현이다. 도메인 전문가는 “상태를 CANCELED로 업데이트한다”고 말하지 않는다. “구독을 해지한다”, “무료 체험을 종료한다”, “결제 실패로 유예 기간에 들어간다”고 말한다.

메서드 이름은 도메인에서 실제로 쓰는 말을 따라야 한다.

subscription.cancelByMember(reason, now);
subscription.startGracePeriod(paymentFailure, now);
subscription.expire(now);
subscription.renew(payment, now);
subscription.changePlan(targetPlan, policy, now);

이 이름들은 상태 변경보다 더 많은 의미를 담는다. “누가”, “왜”, “어떤 조건에서” 일어난 일인지 드러낸다.

좋은 도메인 메서드는 호출하는 쪽의 코드를 도메인 시나리오처럼 읽히게 만든다.

subscription.changePlan(targetPlan, planChangePolicy, now);
subscription.publishEvents(eventPublisher);

물론 모든 메서드가 아름답게 읽혀야 한다는 뜻은 아니다. 하지만 중요한 도메인 행위는 코드에서도 중요한 행위처럼 보여야 한다.

객체가 규칙을 지켜야 하는 이유

도메인 규칙을 서비스 계층에만 두면 우회 경로가 생긴다. 같은 Entity를 수정하는 코드가 여러 곳에 있을수록 위험은 커진다.

예를 들어 만료된 구독은 요금제를 변경할 수 없다는 규칙이 있다고 하자. 이 규칙이 Application Service에만 있으면, 다른 유스케이스에서 누락될 수 있다.

if (subscription.getStatus() != EXPIRED) {
    subscription.setPlanId(targetPlanId);
}

하지만 구독 객체가 스스로 규칙을 지키면, 어떤 경로에서 호출하더라도 같은 규칙이 적용된다.

public void changePlan(Plan targetPlan, PlanChangePolicy policy, Instant now) {
    if (this.isExpired()) {
        throw new CannotChangePlanException("만료된 구독은 요금제를 변경할 수 없습니다.");
    }

    PlanChangeResult result = policy.calculate(this, targetPlan, now);
    this.planId = targetPlan.id();
    this.period = result.nextPeriod();
}

이 코드는 Entity가 모든 것을 혼자 알아야 한다는 뜻이 아니다. 요금제 변경 금액 계산처럼 복잡한 정책은 PlanChangePolicy가 담당할 수 있다. 하지만 “만료된 구독은 변경할 수 없다”처럼 구독 자신의 상태에 대한 핵심 규칙은 구독이 지키는 것이 자연스럽다.

도메인 객체가 스스로를 보호해야 모델이 안정된다.

빈약한 모델은 절차적 코드를 낳는다

도메인 객체에 행위가 없으면 서비스 계층은 점점 커진다. 모든 유스케이스가 다음 패턴을 반복한다.

1. 데이터를 조회한다.
2. 상태를 확인한다.
3. 조건문으로 규칙을 판단한다.
4. setter로 값을 바꾼다.
5. 저장한다.

처음에는 단순하다. 하지만 도메인 규칙이 늘어날수록 서비스 메서드는 절차적 스크립트가 된다. 객체는 데이터를 담는 구조체가 되고, 서비스는 모든 판단을 담당하는 거대한 함수가 된다.

이 구조에서는 모델을 읽어도 도메인을 이해하기 어렵다. Subscription 클래스를 열어봐도 구독이 무엇을 할 수 있는지 알 수 없다. 진짜 규칙은 여러 서비스와 배치, 이벤트 핸들러에 흩어져 있다.

DDD가 원하는 방향은 반대다. 도메인 객체를 보면 그 객체가 어떤 책임을 갖고, 어떤 행위를 할 수 있고, 어떤 규칙을 지키는지 어느 정도 알 수 있어야 한다.

subscription.renew(paymentResult, now);
subscription.cancelByMember(reason, now);
subscription.startGracePeriod(failureReason, now);
subscription.expire(now);

이 메서드 목록은 구독의 생명주기를 설명한다. 이것이 도메인 객체에 행위를 넣는다는 의미다.

모든 로직을 Entity에 넣으라는 뜻은 아니다

도메인 객체에 행위를 넣자는 말은 모든 로직을 Entity 하나에 몰아넣으라는 뜻이 아니다. 그렇게 하면 또 다른 문제가 생긴다. Entity가 너무 커지고, 여러 외부 의존성을 갖게 되며, 테스트하기 어려워진다.

중요한 것은 책임의 위치다.

다음과 같은 규칙은 Entity에 잘 어울린다.

만료된 구독은 요금제를 변경할 수 없다.
해지된 구독은 다시 해지할 수 없다.
무료 체험 구독은 체험 종료일을 가져야 한다.
구독 기간의 종료일은 시작일보다 빠를 수 없다.

반면 다음과 같은 로직은 별도 객체가 더 자연스러울 수 있다.

요금제 변경 시 추가 결제 금액을 계산한다.
사용자 등급과 프로모션에 따라 할인 정책을 적용한다.
여러 구독과 결제 이력을 바탕으로 재가입 가능 여부를 판단한다.
외부 결제 시스템에 결제를 요청한다.

이런 경우에는 Domain Service, Policy, Specification, Application Service, Infrastructure Adapter 등으로 나눌 수 있다. 핵심은 도메인 규칙을 기술적 서비스나 절차적 코드에 무분별하게 흩뜨리지 않는 것이다.

좋은 행위는 불변식을 보호한다

도메인 객체의 행위는 단순한 편의 메서드가 아니다. 그 행위는 객체의 불변식을 보호해야 한다.

예를 들어 SubscriptionPeriod는 종료일이 시작일보다 빠를 수 없다는 규칙을 지킨다. Subscription은 만료된 상태에서 갱신 없이 활성 상태로 바뀔 수 없다는 규칙을 지킨다. Plan은 최소 좌석 수가 1보다 작을 수 없다는 규칙을 지킨다.

행위는 이런 불변식을 깨지 않는 방식으로 상태를 바꾼다.

public void expire(Instant now) {
    if (this.status == SubscriptionStatus.CANCELED) {
        return;
    }

    if (this.period.endsAfter(now)) {
        throw new CannotExpireSubscriptionException("아직 만료일이 지나지 않았습니다.");
    }

    this.status = SubscriptionStatus.EXPIRED;
    this.expiredAt = now;
}

이 메서드는 단순히 상태를 EXPIRED로 바꾸지 않는다. 만료될 수 있는 조건을 확인하고, 유효한 상태 변화만 허용한다.

좋은 도메인 행위는 객체가 이상한 상태에 빠지지 않게 한다.

마치며

도메인 객체에 행위를 넣는다는 것은 객체지향다운 코드를 만들자는 추상적인 주장이 아니다. 도메인 규칙이 코드 안에서 흩어지지 않게 하자는 현실적인 제안이다.

setter 중심의 모델은 빠르게 만들기 쉽다. 하지만 도메인 규칙이 늘어나면 서비스 계층에 조건문이 쌓이고, 도메인 객체는 빈 껍데기가 된다. 결국 코드를 읽어도 도메인을 이해할 수 없게 된다.

반대로 도메인 객체가 의미 있는 행위를 갖게 되면, 코드는 도메인의 언어를 말하기 시작한다. setStatus(CANCELED)가 아니라 cancelByMember(reason, now)라고 말할 수 있게 된다. 이 차이가 작아 보여도, 시간이 지날수록 모델의 이해 가능성과 유지보수성을 크게 바꾼다.

DDD에서 중요한 것은 객체가 많아지는 것이 아니다. 중요한 것은 도메인의 규칙과 행위가 있어야 할 곳에 있는 것이다. 도메인 객체가 자신의 이야기를 할 수 있을 때, 모델은 비로소 살아 있는 모델이 된다.


페이지 수정
공유하기:

이전 글
[DDD Intro #14] Repository: DAO가 아니라 도메인 컬렉션처럼 다루기
다음 글
[DDD Intro #12] Entity: 식별성과 생명주기를 가진 도메인 객체