아키텍처

[클린 아키텍처] 유스케이스

icecupregular 2025. 3. 27. 23:51

육각형 아키텍처는 도메인 중심의 아키텍처에 적합하기 때문에 도메인 엔티티를 만드는 것으로 시작한 후 해당 도메인 엔티티를 중심으로 유스케이스를 구현한다.

도메인 모델 구현하기

  • 한 계좌에서 다른 계좌로 송금하는 유스케이스를 구현
import java.time.LocalDate;
import java.time.LocalDateTime;

public class Account {

    private AccountId id;
    private Money baselineBalance;
    private 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 withdraw = 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())
                .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;
    }

}

입금과 출금을 할 수 있는 Account 엔티티가 있으므로 이를 중심으로 유스케이스를 구현하기 위해 바깥 방향으로 나아갈 수 있다.

유스케이스 둘러보기

유스케이스가 하는 일

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

유스케이스가 도메인 로직에만 신경써야 하고 입력 유효성 검증으로 오염되면 안된다, 하지만 유스케이스는 비즈니스 규칙을 검증할 책임이 있다. 그리고 도메인 엔티티와 이 책임을 공유한다.

비즈니스 규칙을 충족하면 유스케이스는 입력을 기반으로 어떤 방법으로든 모델의 상태를 변경한다. 일반적으로 도메인 객체의 상태를 바꾸고 영속성 어댑터를 통해 구현된 포트로 이 상태를 전달해서 저장될 수 있게 한다. 유스케이스는 또 다른 아웃고잉 어댑터를 호출할 수도 있다

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

송금하기 유스케이스

@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 클래스다. 더 정확히 말하자면 생성자 내에서 입력 유효성을 검증할 것이다.
@Getter
public class 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);
    }

}

자바 세계에서는 Bean Validation API가 이러한 작업을 위한 사실상의 표준 라이브러리다. 이 API를 이용하면 필요한 유효성 규칙들을 필드의 애너테이션으로 표현할 수 있다.

import org.jetbrains.annotations.NotNull;

@Getter
public class SendMoneyCommand extends SelfValidatingSendMoneyCommand {

    @NotNull
    private final Account.AccountId sourceAccountId;

    @NotNull
    private final Account.AccountId targetAccountId;

    @NotNull
    private final Money;

    public SendMoneyCommand(
            Account.AccountId sourceAccountId,
            Account.AccountId targetAccountId,
            Money money
    ) {
        this.sourceAccountId = sourceAccountId;
        this.targetAccountId = targetAccountId;
        this.money = money;

        requireGreaterThan(money, 0);
        this.validateSelf();
    }
}

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

생성자의 힘

  • 생성자에 파라미터가 3개 보다 더 많아진다면 빌더를 사용해보자
  • 빌더는 런타임에 유효성 검증이 동작한다
  • 훌륭한 IDE들은 파라미터명 힌트를 주기 때문에 생성자를 직접 사용해보자

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

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

비즈니스 규칙 검증하기

입력 유효성을 검증하는 것은 구문상의(syntatical) 유효성을 검증하는 것이라고도 할 수 있다.

반면, 비즈니스 규칙은 유스케이스의 맥락 속에서 의미적인(semantical) 유효성을 검증하는 일이라고 할 수 있다.

“출금 계좌는 초과 출금되어서는 안된다”는 출금 계좌와 입금 계좌가 존재하는지 확인하기 위해 모델의 현재 상태에 접근해야 하기 때문에 비즈니스 규칙이다.

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

비즈니스 규칙 검증은 어떻게 구현할까? 비즈니스 규칙을 도메인 엔티티 안에 넣는 것이다.

    public class Account {

        // ...

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

    }

만약 도메인 엔티티에서 비즈니스 규칙을 검증하기가 여의치 않다면 유스케이스 코드에서 도메인 엔티티를 사용하기 전에 해도 된다.

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

클린 아키텍처는 도메인 모델을 구현하는 방법에 대해서는 열려 있다. 이는 우리의 문맥에 적합한 방식을 선택할 수 있기 때문에 축복이기도 하지만 우리를 도와줄 어떤 지침도 없기 때문에 저주이기도 하다.

풍부한 도메인 모델에서는 엔티티에서 가능한 한 많은 도메인 로직이 구현된다. 엔티티들은 상태를 변경하는 메소드들을 제공하고, 비즈니스 규칙에 맞는 유효한 변경만을 허용한다.

이 시나리오에서 유스케이스는 도메인 모델의 진입점으로 동작한다.

반면, 빈약한 도메인 모델에서 ‘풍부함’이 엔티티 대신 유스케이스에 존재한다.

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

유스케이스를 가능한 한 구체적으로 유지하기 위해서는 계속 질문해야 한다. 만약 의심스럽다면 가능한 한 적게 반환하자

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

프로젝트 맥락에서 유스케이스로 간주되지 않는다면 실제 유스케이스와 구분하기 위해 쿼리로 구현할 수 있다. 한 가지 방법은 쿼리를 위한 인커밍 전용 포트를 만들고 이를 ‘쿼리 서비스’에 구현하는 것이다.

유지보수 가능한 소프트웨어를 만드는 데 어떻게 도움이 될까?

유스케이스 별로 모델을 만들면 유스케이스를 명확하게 이해할 수 있고, 장기적으로 유지보수하기도 더 쉽다. 또한 여러 명의 개발자가 다른 사람이 작업 중인 유스케이스를 건드리지 않은 채로 여러 개의 유스케이스를 동시에 작업할 수 있다.

꼼꼼한 입력 유효성 검증, 유스케이스별 입출력 모델은 지속 가능한 코드를 만드는데 큰 도움이 된다.