4장 : 유스케이스 구현하기

헥사고날 아키텍처에서는 애플리케이션, 웹, 영속성 계층이 느슨하게 결합돼 있기 때문에 필요한 대로 도메인 코드를 자유롭게 모델링할 수 있다.

도메인 모델 구현하기

한 계좌에서 다른 계좌로 송금하는 유스케이스 구현해본다.

  • 계좌에 대한 모든 입금과 출금은 Activity 엔티티에 포착된다.

  • Account 엔티티는 ActivityWindow 값 객체(value obejct)에서 포착한 지난 며칠 혹은 몇 주간의 범위에 해당하는 활동만 보유한다.

  • 계좌의 현재 잔고를 계산하기 위해서 Account 엔티티는 활동창(activity window)의 첫번째 활동 바로 전의 잔고를 표현하는 baselineBalance 속성을 가지고 있다.

  • 계좌에서 일어나는 입금과 출금은 각각 withdraw()와 deposit() 메서드에서처럼 새로운 활동을 활동창에 추가하는 것이다.

  • 출금하기 전에는 잔고를 초과하는 금액은 출금할 수 없도록 하는 비즈니스 규칙을 검사한다.

Account Entity snippet

package com.judy.book.buckpal.domain;

import java.time.LocalDateTime;
import lombok.Value;

public class Account {

    private AccountId id;
    private Money baselineBalance;
    private ActivityWindow activityWindow;

    // 생성자와 getter 생략
    
    public Money calculateBalance() {
        return Money.add(
            this.baselineBalance,
            this.activityWindow.calculateBalance(this.id)
        );
    }

    public boolean withdraw(Money money, AccountId targetAccountId) {
        if (!mayWithdraw(money)) {
            return false;
        }

        Activity withdrawal = new Activity(
            this.id,
            this.id,
            targetAccountId,
            LocalDateTime.now(),
            money
        );

        this.activityWindow.addActivity(withdrawal);
        return true;
    }

    private boolean mayWithdraw(Money money) {
        return Money.add(
            this.calculateBalance(),
            money.negative())
            .isPositive();
    }

    public boolean deposit(Money money, AccountId sourceAccountId) {
        Activity deposit = new Activity(
            this.id,
            sourceAccountId,
            this.id,
            LocalDateTime.now(),
            money
        );
        this.activityWindow.addActivity(deposit);
        return true;
    }

 }
 

유스케이스 둘러보기

유스케이스 책임

  1. 입력을 받는다.

    • 인커밍 어댑터로부터 입력을 받는다.

    (유스케이스 코드가 도메인 로직에만 신경 써야되므로 입력 유효성 검증은 여기서 처리하지 않는다.)

  2. 비즈니스 규칙을 검증한다.

    • 유스케이스는 비즈니스 규칙을 검증할 책임이 있다.

  3. 모델 상태를 조작한다.

    • 비즈니스 규칙을 충족하면 유스케이스는 도메인 객체의 상태 바꾸고, 영속성 어댑터를 통해 구현된 포트로 상태를 전달해서 저장될 수 있게 한다.

    • 유스케이스는 또 다른 아웃고잉 어댑터를 호출할 수 있다.

  4. 출력을 반환한다.

    • 아웃고잉 어댑터에서 온 출력값을, 유스케이스를 호출한 어댑터로 반환할 출력 객체로 변환한다.

SendMoneyService snippet

package com.judy.book.buckpal.application;

import com.judy.book.buckpal.application.port.in.SendMoneyUseCase;
import javax.transaction.Transactional;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Transactional
public class SendMoneyService implements SendMoneyUseCase {

    private final LoadAccountPort loadAccountPort;
    private final AccountLock accountLock;
    private final UpdateAccountStatePort updateAccountStatePort;

    @Override
    public boolean sendMoney(SendMoneyCommand command) {
        // TODO : 비즈니스 규칙 검증
        // TODO : 모델 상태 조작
        // TODO : 출력 값 반환
    }

}

  • 서비스는 인커밍 포트 인터페이스인 SendMoneyUseCase를 구현하고, 계좌를 불러오기 위해 아웃고잉 포트 인터페이스인 LoadAccountPort를 호출한다.

  • 데이터베이스의 계좌 상태를 업데이트하기 위해 UpdateAccountStatePort를 호출한다.

입력 유효성 검증

  • 입력 유효성 검증은 유스케이스 클래스의 책임은 아니지만, 애플리케이션 계층의 책임에 해당한다.

    • 유스케이스에서 필요로 하는 것을 호출자가 모두 검증했다고 믿을 수 없다.

    • 유스케이스는 하나 이상의 어댑터에서 호출될 텐데, 그러면 유효성 검증을 각 어댑터에서 전부 구현해야 한다.

    • 애플리케이션 계층에서 입력 유효성 검증을 하지 않으면 애플리케이션 코어의 바깥쪽으로부터 유효하지 않은 입력값을 받아 모델의 상태를 해칠 수 있다.

  • 유효성 검증 위치

    • 입력 모델(input model)이 이 문제를 다루도록 할 것이다 . '송금하기' 유스케이스에서 입력 모델은 예제 코드에서 본 SendMoneyCommand 생성자 클래스이다.

  • 검증 내용

    • 송금을 위해서는 출금 계좌 ID, 입금 계좌 ID, 송금할 금액이 필요

    • 모든 파라미터는 null이면 안됨

    • 송금액은 0보다 커야함

  • 조건을 위배되면 객체를 생성할 때 예외를 던져서 객체 생성을 막는다.

  • SendMoneyCommand의 필드는 final을 지정해 불변 필드로 만들어, 생성에 성공하고 나면 상태는 유효하고 이후에 잘못된 상태로 변경할 수 없다는 사실을 보장한다.

SendMoneyCommand snippet

package com.judy.book.buckpal.application.port.in;

import static java.util.Objects.requireNonNull;

import com.judy.book.buckpal.domain.Account.AccountId;
import lombok.Getter;

@Getter
public class SendMoneyCommand {

    private final AccountId sourceAccountId;
    private final AccountId targetAccountId;
    private final Money;

    public SendMoneyCommand (
        AccountId sourceAccountId,
        AccountId targetAccountId,
        Money money
    ) {
        this.sourceAccountId = sourceAccountId;
        this.targetAccountId = targetAccountId;
        this.money = money;
        requireNonNull(sourceAccountId);
        requireNonNull(targetAccountId);
        requireNonNull(money);
        requireGreaterThan(money, 0);
    }
}

Bean ValidationAPI

  • 유효성 검증을 도와주는 라이브러리이다.

  • 필요한 유효성 규칙들을 필드의 애너테이션으로 표현할 수 있다.

  • SelfValidating 추상 클래스는 validateSelf() 메서드를 제공하며, 생성자 코드의 마지막 문장에서 이 메서드를 호출하고 있다. 이 메서드가 필드에 지정된 Bean Validation 애너테이션(@NotNull 같은)을 검증하고, 유효성 검증 규칙을 위반한 경우 예외를 던진다.

  • Bean Validation이 특정 유효성 검증 규칙을 표현하기에 충분하지 않다면 직접 구현할 수도 있다.

package com.judy.book.buckpal.application.port.in;

@Getter
public class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {
    @NotNull
    private final Account.AccountId sourceAccountId;
    @NotNull
    private final Account.AccountId targetAccountId;
    @NotNull
    private final Money;
    
    public SendMoneyCommand(
        Account.AccountId sourceAccountId,
        Accout.AccountId targetAccountId,
        Money money) {
        this.sourceAccountId = sourceAccountId;
        this.targetAccountId = targetAccountId;
        this.money = money;
        requireGreaterThan(money, 0);
        this.validateSelf();
        }
}

SelfValidating 클래스의 구현

package shared;

public abstract class SelfValidating<T> {

    private Validator;
    
    public SelfValidating() {
        ValidatorFactory factory = Validation.buildDefultValidatorFactory();
        validator = factory.getValidator();
    }
    
    protected void validateSelf() {
        Set<ConstraintViolation<T>> violations = validator.validate((T) this);
        if (!violations.isEnpty()) {
            throw new ConstraintViolationException(violations);
        }
    }
}

입력 모델에 있는 유효성 검증 코드를 통해 유스케이스 구현체 주위에 사실상 오류 방지 계층(anti corruption)을 만들었다. 여기서 말하는 계층은 잘못된 입력을 호출자에게 돌려주는 유스케이스 보호막을 의미한다.

생성자의 힘

  • 현재 입력 모델인 SendMoneyCommand는 생성자에 많은 책임을 지우고 있다.

    • 클래스가 불변이기 때문에 생성자의 인자 리스트에는 클래스의 각 속성에 해당하는 파라미터들이 포함돼 있다.

    • 생성자가 파라미터의 유효성 검증까지 하고 있기 때문에 유효하지 않은 상태의 객체를 만드는 것은 불가능하다.

빌더(Builder) 패턴

  • 긴파라미터 리스트를 받아야 하는 생성자를 private으로 만들고 빌더의 build() 메서드 내부에 생성자 호출을 숨길 수 있다.

  • 유효성 검증 로직은 생성자에 그대로 둬서 빌더가 유효하지 않은 상태의 객체를 생성하지 못하도록 막을 수 있다.

  • 단점

    • 빌더를 호출하는 코드에 새로운 필드를 추가하는 것을 잊기 쉽다.

    • 컴파일러는 유효하지 않은 상태의 불변 객체를 만들려는 시도에 대해서 경고하지 못한다.

    • 런타임에(혹은 단위 테스트) 유효성 검증 로직이 동작해서 누락된 파라미터에 대해 에러를 던지긴 한다.

new SendMoneyCommandBuilder()
	.sourceAccountId(new AccoutId(41L))
	.targetAccountIs(new AccountId(42L))

	// ... 
	.build();
	

유스케이스마다 다른 입력 모델

  • 각 유스케이스 전용 입력 모델은 유스케이스를 훨씬 명확하게 만들고, 다른 유스케이스와의 결합도 제거해서 불필요한 부수효과가 발생을 방지한다.

비즈니스 규칙 검증하기

입력 유효성 검증은 유스케이스 로직의 일부가 아닌 반면, 비즈니스 규칙 검증은 분명히 유스케이스 로직의 일부다

✔️ 입력 유효성 vs 비즈니스 규칙 검증

  1. 도메인 모델의 현재 상태 접근 필요 유무

    • 비즈니스 규칙을 검증도메인 모델의 현재 상태에 접근 필요

    • 입력 유효성 검증현재 상태 접근 필요 없음

  2. 유스케이스 맥락 연관성

    • 입력 유효성 검증선언적으로 구현 가능

      ex. @NotNull

    • 비즈니스 규칙은 유스케이스의 맥락 안에서 의미적인 유효성을 검증

example

"출금 계좌는 초과 출금되어거는 안 된다“라는 규칙의 경우, 정의에 따르면 이 규칙은 출금 계좌와 입금 계좌가 존재하는지 확인하기 위해 모델의 현재 상태에 접근해야 하기 때문에 비즈니스 규칙이다.

반대로 “송금되는 금액은 0보다 커야 한다"라는 규칙은 모델에 접근하지 않고도 검증될 수 있으므로 입력 유효성 검증으로 구현할 수 있다.

비즈니스 규칙 검증 구현

1 . (best) 비즈니스 규칙을 도메인 엔티티 안에 넣는 것이다.

이 규칙을 지켜야 하는 비즈니스 로직 바로 옆에 규칙이 위치하기 때문에 위치를 정하는 것도 쉽고 추론하기도 쉽다.

public class Account {

    // ...
    
    public boolean withdraw(Money, AccountId targetAccountId) {
        if(!mayWithdraw(money)) {
            return false;
        }
        // ...
    }
}

2 . 유스케이스 코드에서 도메인 엔티티를 사용하기 전에 해도 된다.

유효성을 검증하는 코드를 호출하고, 유효성 검증이 실패할 경우 유효성 검증 전용 예외를 던진다.

사용자와 통신하는 어댑터는 이 예외를 에러 메지로 사용자에게 보여주거나 적절한 다른 방법으로 처리한다.

@RequiredArgsConstructor
@Transactional
public class SendMoneyService implements SendMoneyUseCase {

    // ...
    
    @Override
    public boolean sendMoney(SendMoneyCommand command) {
        requireAccountExists(command.getSourceAccountId());
        requireAccountExists(command.getTargetAccountId());
        // ...
    }
}
more

앞의 예제에서 살펴본 유효성 검증은 단순히 출금 계좌와 입금 계좌가 데이터베이스에 있는지 확인하는 것이었지만, 더 복잡한 비드니스 규칙의 경우에는 먼저 데이터베이스에서 도메인 모델을 로드해서 상태를 검증해야 할 수도 있다.

도메인 모델을 로드해야 한다면 도메인 엔티티 내에 비즈니스 규칙을 구현해야 한다.

풍부한 도메인 모델 vs 빈약한 도메인 모델

풍부한 도메인 모델

  • 애플리케이션의 코어에 있는 엔티티에서 가능한 한 많은 도메인 로직이 구현된다.

  • 엔티티들은 상태를 변경하는 메서드를 제공하고, 비즈니스 규칙에 맞는 유효한 변경만을 허용한다.

  • 이 시나리오에서 유스케이스는 도메인 모델의 진입점으로 동작한다. 이어 유스케이스는 사용자의 의도만을 표현하면서 이 의도를 실제 작업을 수행하는 체계화된 도메인 엔티티 메서드 호출로 변환한다.

✔️ 많은 비즈니스 규칙이 유스케이스 구현체 대신 엔티티에 위치하게 된다.

예를 들면

‘송금하기’ 유스케이스 서비스는 출금 계좌와 입금 계좌 엔티티를 로드하고, withdraw(), deposit() 메서드를 호출한 후, 결과를 다시 데이터베이스로 보낸다.

빈약한 도메인 모델

  • 엔티티 자체가 굉장히 얇다.

  • 일반적으로 엔티티는 상태를 표현하는 필드와 이 값을 읽고 바꾸기 위한 getter, setter 메서드만 포함하고 어떤 도메인 로직도 가지고 있지 않다.

  • 도메인 로직이 유스케이스 클래스에 구현돼 있다. 비즈니스 규칙을 검증하고, 엔티티 상태를 바꾸고, 데이터베이스 저장을 담당하는 아웃고잉 포트에 엔티티를 전달할 책임 역시 유스케이스 클래스에 있다.

✔️ '풍부함'이 엔티티 대신 유스케이스에 존재하는 것이다.

유스케이스마다 다른 출력 모델

  • 각 유스케이스 전용 출력 모델은 유스케이스를 훨씬 명확하게 만들고 다른 유스케이스와의 결합도 제거해서 불필요한 부수효과가 발생을 방지할 수 있다.

읽기 전용 유스케이스는 어떨까 ?

애플리케이션 코어의 관점에서 읽기 전용 유스케이스는 간단한 데이터 쿼리다. 그렇기 때문에 프로젝트 맥락에서 유스케이스로 간주되지 않는다면 실제 유스케이스와 구분하기 위해 쿼리로 구현할 수 있다

쿼리를 위한 인커밍 전용 포트를 만들고 이를 '쿼리 서비스(query service)'에 구현하는 것이다.

  • 쿼리 서비스는 유스케이스 서비스와 동일한 방식으로 동작한다. GetAccountBalanceQuery라는 인커밍 포트를 구현하고, 데이터베이스로부터 실제로 데이터를 로드하기 위해 LoadAccountPort라는 아웃고잉 포트를 호출한다.

    • cf. CQS(Command-Query Separation), CQRS(Command-Query Responsibility Segregation)

 @RequiredArgsConstructor
class GetAccountBalanceService implements GetAccountBalanceQuery {
    private final LoadAccountPort loadAccountPort;
    
    @Override
    public Money getAccountBalance(AccountId accountId) {
        return loadAccountPort.loadAccount(accountId, LocalDateTime.now())
            .calculateBalance();
    }
}

DISCUSSION

Last updated