Notice
Recent Posts
Recent Comments
Link
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
Archives
Today
Total
관리 메뉴

csct3434

[만들면서 배우는 클린 아키텍처] 04. 유스케이스 구현하기 본문

개발 서적/만들면서 배우는 클린 아키텍처

[만들면서 배우는 클린 아키텍처] 04. 유스케이스 구현하기

csct3434 2024. 3. 3. 21:06

구현 순서

  • 도메인 → 유스케이스 → 포트 → 어댑터

도메인 모델 구현하기

Account

package buckpal.domain;

@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Account {

  @Getter
  private final AccountId id;
  @Getter
  private final Money baselineBalance;
  @Getter
  private final ActivityWindow activityWindow;

  // 생성자 생략

  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.negate())
        .isPositiveOrZero();
  }

  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;
  }
}
  • 실제 계좌의 현재 스냅샷을 제공
  • 계좌에 대한 모든 입금과 출금은 Activity 엔티티에 포착된다
  • 한 계좌에 대한 모든 활동을 항상 메모리에 한꺼번에 올리는 것은 현명한 방법이 아니기에, Account 엔티티는 ActivityWindow 값 객체에서 포착한 지난 며칠 혹은 몇 주간의 범위에 해당하는 활동만 보유한다
  • ActivityWindow의 baselineBalance 속성은 현재 ActivityWindow가 보유한 첫 번째 활동 전의 잔고를 표현한다.
    • 계좌의 현재 잔고 : (ActivityWindow의 baselineBalance) + (ActivityWindow의 모든 활동들의 합)

유스케이스

  • 유스케이스 코드는 도메인 로직만 다루는 것이 좋기에, 입력 유효성 검증은 다른 곳에서 처리한다
  • 유스케이스는 도메인 엔티티와 함께 비즈니스 규칙을 검증할 책임을 공유한다
  • 넓은 서비스 문제를 피하기 위해, 각 유스케이스별로 서비스를 분리하여 구현한다

유스케이스의 동작 과정

1. 입력을 받는다
2. 비즈니스 규칙을 검증한다
3. 모델 상태를 조작한다
4. 출력을 반환한다

송금하기 유스케이스 구현 서비스

package buckpal.application.service;

@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) {
    // 비즈니스 규칙 검증
    // 모델 상태 변경
    // 출력 값 반환
  }
}

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

입력 모델을 통한 유효성 검증

  • 인커밍 어댑터에서 입력 유효성을 검증하는 경우
    • 유스케이스를 호출하는 모든 어댑터에서 유효성 검증 로직을 모두 구현해야 한다 (코드 중복)
    • 각 어댑터가 유스케이스를 호출하기 전에 유효성 검증을 정상적으로 수행했음을 장담할 수 없다 (신뢰성)
    • 따라서, 입력 유효성 검증은 애플리케이션 계층에서 수행해야 한다
  • 각 유스케이스는 입력 모델을 통해 값을 전달받고, 입력 모델의 생성자에서 유효성 검증을 수행함으로써 입력값의 유효성을 보장할 수 있다.
  • 입력 모델에 있는 유효성 검증 코드는 사실상 유스케이스 주변에 오류를 방지할 수 있는 계층을 형성한다

송금하기 유스케이스의 입력 모델

package buckpal.application.port.in;

@Getter
public class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {

  private final AccountId sourceAccountId;
  private final AccountId targetAccountId;
  private final Money 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);
  }
}
  • SendMoneyCommand는 SendMoneyUseCase의 입력 모델이다
  • 송금을 위해서는 출금 계좌와 입금 계좌의 ID, 송금 금액이 필요하다
  • 유효성 검증 조건 : 모든 파라미터가 null이 아니어야 하고 송금액은 0보다 커야 한다.
  • 각 필드에 final을 지정함으로써, 생성된 이후 잘못된 상태로 변경되지 않도록 보장할 수 있다
  • SendMoneyCommand는 유스케이스 API의 일부이기 때문에 인커밍 포트 패키지에 위치한다.
  • 유효성 검증 로직을 유스케이스와 분리된 별도의 입력 모델에서 구현함으로써, 유스케이스 코드는 오염되지 않고 비즈니스 로직에 집중할 수 있다
package io.reflectoring.buckpal.account.application.port.in;

import io.reflectoring.buckpal.account.domain.Account.AccountId;
import io.reflectoring.buckpal.account.domain.Money;
import io.reflectoring.buckpal.common.SelfValidating;
import lombok.EqualsAndHashCode;
import lombok.Value;

import javax.validation.constraints.NotNull;

@Value
@EqualsAndHashCode(callSuper = false)
public class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {

  @NotNull
  private final AccountId sourceAccountId;

  @NotNull
  private final AccountId targetAccountId;

  @NotNull
  private final Money money;

  public SendMoneyCommand(AccountId sourceAccountId, AccountId targetAccountId, Money money) {
    this.sourceAccountId = sourceAccountId;
    this.targetAccountId = targetAccountId;
    this.money = money;
    this.validateSelf();
  }
}
package shared;

public abstract class SelfValidating<T> {

  private Validator validator;

  public SelfValidating() {
    ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    validator = factory.getValidator();
  }

  protected void validateSelf() {
    Set<ConstraintViolation<T>> violations = validator.validate((T) this);
    if (!violations.isEmpty()) {
      throw new ConstraintViolationException(violations);
    }
  }
}
  • 자바의 Bean Validation API를 이용하여 필요한 유효성 규칙들을 필드의 애너테이션으로 표현할 수 있다.
  • SelfValidating 추상 클래스가 제공하는 validateSelf() 메서드는 필드에 지정된 Bean Validation 애너테이션을 검증하고, 유효성 검증 규칙을 위반한 경우 예외를 던진다.

생성자의 힘

  • 긴 파라미터 리스트를 받아야 하는 경우 생성자 대신 빌더 패턴을 활용할 수 있다
  • 빌더를 호출하는 코드에 새로운 필드를 추가하지 않는 실수가 있어도 컴파일러는 이를 경고해주지 못한다
  • 하지만 빌더 대신 생성자를 직접 사용했다면 컴파일 에러를 따라 나머지 코드에 변경사항을 반영할 수 있었을 것이다.
  • 또한, 긴 파라미터 리스트라도 충분히 깔끔하게 포매팅할 수 있고, 몇몇 IDE가 파라미터명 힌트를 제공해준다
  • 이와 같은 이유로, 책에서는 빌더 대신 생성자를 직접 사용하는 것을 권장한다

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

  • 서로 다른 유스케이스에서 동일한 입력 모델을 사용하는 경우 다음과 같은 문제가 발생할 수 있다
    • 특정 유스케이스에서 입력받지 못하는 필드에 대해서는 null을 허용해야 한다
    • 유스케이스별로 유효성 검증 로직이 다르기 때문에, 이를 입력 모델에서 구현하지 못하고 유스케이스에서 구현함으로써 비즈니스 코드가 유효성 검증과 관련된 관심사로 오염된다
  • 각 유스케이스별로 전용의 입력 모델을 사용하면, 유스케이스를 훨씬 명확하게 만들고 다른 유스케이스와의 결합도 제거함으로써 불필요한 부수효과를 방지할 수 있다.

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

  • 입력과 비슷하게 출력도 가능하면 각 유스케이스에 맞게 구체적일수록 좋다
  • 출력은 호출자에게 꼭 필요한 데이터만 들고 있어야 한다
  • 출력 모델을 공유하는 것은 지양해야 한다
    • 유스케이스들 간에 출력 모델을 공유하게 되면 유스케이스들도 강하게 결합된다
    • 한 유스케이스에서 출력 모델에 새로운 필드가 필요해지면 이 값과 관련이 없는 다른 유스케이스에서도 해당 필드를 처리해야 한다
    • 공유 모델은 장기적으로 봤을 때 갖가지 이유로 점점 커지게 돼 있다
    • 단일 책임 원칙을 적용하고 모델을 분리해서 유지하는 것은 유스케이스의 결합을 제거하는데 도움이 된다
  • 도메인 엔티티를 출력 모델로 사용하는 것도 지양해야 한다
    • 도메인 엔티티를 변경할 이유가 필요 이상으로 늘어난다

비즈니스 규칙 검증

vs 입력 유효성 검증

  • 입력 유효성 검증은 유스케이스 로직의 일부가 아닌 반면, 비즈니스 규칙 검증은 유스케이스 로직의 일부이다
  • 입력 유효성 검증은 @NotNull 애너테이션을 붙이는 것처럼 선언적으로 구현할 수 있지만, 비즈니스 규칙을 검증하는 일은 맥락이 필요하다.
    • 입력 유효성 검증 : 구문상(syntatical)의 유효성을 검증하는 일
    • 비즈니스 규칙 검증 : 의미적인(semantical) 유효성을 검증하는 일
  • 구분법 : 비즈니스 규칙을 검증하는 것은 도메인 모델의 현재 상태에 접근해야 하는 반면, 입력 유효성 검증은 그렇지 않다.
    • 이러한 구분은 논쟁의 여지가 있지만, 현재 모델 상태에 접근하는 여부만으로 코드 상의 위치를 결정할 수 있기 때문에 처음 규칙을 구현할 때 뿐만 아니라 향후 유지보수에도 도움이 된다.

비즈니스 규칙 검증 구현

  • 비즈니스 규칙 검증을 구현하는 가장 좋은 방법은 비즈니스 규칙을 도메인 엔티티 안에 넣는 것이다.
package buckpal.domain;

public class Account {
  // 생략
  public boolean withdraw(Money money, AccountId targetAccountId) {
    // 비즈니스 규칙 검증 : 출금 계좌는 초과인출되어서는 안된다
    if (!mayWithdraw(money)) {
      return false;
    }
    // 생략
  }
}
  • 이처럼 비즈니스 로직 바로 옆에 규칙을 위치시킨다면 위치를 정하는 것도 쉽고 추론하기도 쉽다
package buckpal.application.service;

public class SendMoneyService implements SendMoneyUseCase {
  // 생략
  @Override
  public boolean sendMoney(SendMoneyCommand command) {
    requireAccountExists(command.getSourceAccountId());
    requireAccountExists(command.getTargetAccountId());
    // 생략
  }
  // 생략
}
  • 유스케이스 코드에서 도메인 엔티티를 사용하기 전에 비즈니스 규칙을 검증해도 된다

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

  • 풍부한 도메인 모델
    • DDD 철학을 따르는 모델
    • 애플리케이션 코어에 있는 엔티티에서 가능한 한 많은 도메인 로직이 구현됨
    • 유스케이스는 도메인 모델의 진입점으로 사용자의 의도만을 표현하고, 이 의도를 실제 작업을 수행하는 체계화된 도메인 엔티티 메서드 호출로 변환한다.
    • 많은 비즈니스 규칙이 유스케이스 구현체 대신 엔티티에 위치하게 된다
  • 빈약한 도메인 모델
    • 빈약한 도메인 모델에서는 엔티티 자체가 굉장히 얇다
      • 일반적으로 엔티티는 상태를 표현하는 필드와 이 값을 읽고 변경하기 위한 getter, setter 메서드만 포함하고 어떠한 도메인 로직도 가지고 있지 않다
    • 도메인 로직은 엔티티가 아닌 유스케이스 클래스에 구현되어 있다
      • 비즈니스 규칙을 검증하고, 엔티티의 상태를 바꾸고, 아웃고잉 포트에 엔티티를 전달할 책임 역시 유스케이스 클래스에 있다
  • 이러한 스타일은 각자의 필요에 맞춰 자유롭게 택해서 사용하면 된다

쿼리 vs 유스케이스

  • UI에 계좌의 잔액을 표시해야 하는 읽기 전용 작업을 유스케이스로 구현하는 것은 조금 이상하다
  • 애플리케이션 코어의 관점에서 해당 작업은 간단한 데이터 쿼리다. 따라서, 프로젝트 맥락에서 유스케이스로 간주되지 않는다면 실제 유스케이스와 구분하기 위해 쿼리로 구현할 수 있다
  • 이 책의 아키텍처 스타일에서 이를 구현하는 방법은 쿼리를 위한 인커밍 전용 포트를 만들고 이를 쿼리 서비스에 구현하는 것이다
package buckpal.application.service;

@RequiredArgsConstructor
class GetAccountBalanceService implements GetAccountBalanceQuery {

    private final LoadAccountPort loadAccountPort;

    @Override
    public Money getAccountBalance(AccountId accountId) {
        return loadAccountPort.loadAccount(accountId, LocalDateTime.now())
                .calculateBalance();
    }
}
  • 이처럼 읽기 전용 쿼리는 쓰기가 가능한 유스케이스(또는 커맨드)와 코드 상에서 명확하게 구분된다
  • 이러한 방식은 CQS(Command-Query Separation)나 CQRS(Command Query Responsibility Segregation) 같은 개념과 잘 맞는다

정리

  • 입출력 모델을 독립적으로 모델링한다면 원치 않는 부수효과를 피할 수 있다.
  • 유스케이스별로 모델을 만들면 유스케이스를 명확하게 이해할 수 있고, 장기적으로 유지보수하기도 더 쉽다.
  • 또한, 여러 명의 개발자가 다른 사람이 작업 중인 유스케이스를 건드리지 않은 채로 여러 개의 유스케이스를 동시에 작업할 수 있다.
  • 꼼꼼한 입력 유효성 검증, 유스케이스별 입출력 모델은 지속 가능한 코드를 만드는 데 큰 도움이 된다.