본문 바로가기
📙/Study

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

by kiwi_wiki 2024. 10. 7.

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

도메인 모델 구현하기

package buckpal.domain;

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.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 엔티티를 중심으로 유스케이스를 구현하기 위해 바깥 방향으로 나아갈 수 있다.

유스케이스 둘러보기

유스케이스가 하는 일

- 입력을 받는다.

인커밍 어댑터로부터 입력을 받는다. 유스케이스 코드는 도메인 로직에만 신경 써야 하므로 입력 유효성 검증은 다른 곳에서 이루어져야 한다.

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

- 모델 상태를 조작한다.

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

- 출력을 반환한다.

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

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

넓은 서비스 문제를 피하기 위해 각 유스케이스별로 분리된 각각의 서비스로 만든 예시

서비스는 인커밍 포트 인터페이스(SendMoneyUseCase)를 구현하고, 계좌를 불러오기 위해 아웃고잉 포트 인터페이스(LoadAccountPort)를 호출한다. 데이터베이스의 계좌 상태를 업데이트하기 위해 또 다른 아웃고잉 포트 인터페이스(UpdateAccountStatePort)를 호출한다.

입력 유효성 검증

호출하는 어댑터가 유스케이스에 입력을 전달하기 전에 입력 유효성을 검증하면 유스케이스에서 필요로 하는 것을 호출자가 모두 검증했다고 믿을 수 없다. 유스케이스는 하나 이상의 어댑터에서 호출되는 경우도 있기 때문에 각 어댑터에서 입력 유효성 검증을 전부 구현해야 한다.

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

 

입력 유효성 검증은 입력 모델의 생성자 내에서 검증할 것이다.

package buckpal.application.port.in;

@Getter
public class SendMoneyCommand {
	// final로 지정해 생성에 성공하고 나면 상태는 유효하고 이후에 잘못된 상태로 변경할 수 없다는 사실을 보장한다.
	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); // 출금 계좌 ID 필수
    	requireNonNull(targetAccountId); // 입금 계좌 ID 필수
    	requireNonNull(money); // 송금 금액 필수
    	requireGreaterThan(money, 0); // 송금 금액은 0보다 커야한다
    }
}

유효성 검증 조건 중 하나라도 위배되면 객체를 생성할 때 예외를 던져서 객체 생성을 막게 된다.

SendMoneyCommend는 유스케이스 API의 일부이기 때문에 인커밍 포트 패키지에 위치한다. 그러므로 유효성 검증이 애플리케이션 코어에 남아있으며 유스케이스 모드를 오염시키지 않는다.

 

Java에서는 Bean Validation API가 이러한 작업을 애너테이션으로 표현할 수 있게 해 준다.

package 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, 
            Account.AccountId targetAccountId, 
            Money money) {
        this.sourceAccountId = sourceAccountId;
        this. targetAccountId = targetAccountId; 
        this.money = money;
        // Bean Validation이 특정 유효성 검증 규칙을 표현하기에 충분하지 않다면 직접 구현할 수 있다.
        requireGreaterThan(money, 0); 
        this.validateSelf(); // SelfValidating 추상 클래스가 제공하는 메서드
    }
}

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

생성자의 힘

SendMoneyCommand는 생성자에 많은 책임을 지고 있다.

생성자에 파라미터가 더 많다면 빌더 패턴을 활용하면 더 편하게 객체를 만들 수 있을 것이다.

new SendMoneyCommandBuilder()
	.sourceAccountId(new AccountId(41L)) 
	.targetAccountId(new AccountId(42L)) 
	// ...다른 여러 필드 초기화
	.build();

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

 

하지만 builder에 새로운 필드를 추가해야 하는 상황이 생겼을 때 생성자와 빌더에 새로운 필드를 추가한 뒤 빌더를 호출하는 코드에는 새로운 필드를 추가하는 것을 잊을 수 있다.

컴파일러는 이런 유효하지 않은 상태의 불변 객체를 만들려는 시도에 대해서는 경고해주지 못하며 런타임에서 에러가 발생하게 된다.

생성자를 직접 사용했다면 이러한 상황이 생겼을 때 컴파일 에러가 발생하기 때문에 호출하는 나머지 코드에 변경사항을 빠뜨리지 않고 반영해 줄 수 있다.

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

각기 다른 유스케이스에 동일한 입력 모델을 사용하고 싶은 생각이 들 때가 있다.

같은 입력 모델을 공유할 경우 몇몇 필드에는 null 값을 허용해야 하는 상황이 발생한다. 불변 커맨드 객체의 필드에 대해서 null을 유효한 상태로 받아들이는 것은 위험하다. 더 문제가 되는 부분은 입력 유효성을 어떻게 검증하냐는 가 이다. 서로 다른 유스케이스는 서로 다른 유효성 검증 로직이 필요하다. 이렇게 되면 유스케이스에 커스텀 유효성 검증 로직을 넣게 된다.

 

각 유스케이스 전용 입력 모델은 각 유스케이스에 해당하는 입력 모델에 매핑하는 비용은 들겠지만 유스케이스를 훨씬 명확하게 만들고 부수효과가 발생하지 않게 한다.

비즈니스 규칙 검증하기

비즈니스 규칙 검증은 유스케이스 로직의 일부이다. 애플리케이션의 핵심이기에 적절하게 잘 다뤄야 한다.

언제 입력 유효성을 검증하고 언제 비즈니스 규칙을 검증해야 할까?

 

비즈니스 규칙을 검증하는 것은 도메인 모델의 현재 상태에 접근해야 하지만 입력 유효성 검증은 그럴 필요가 없다.

입력 유효성 검증은 구문상의 유효성을 검증하는 일이며, 비즈니스 규칙은 유스케이스의 맥락 속에서 의미적인 유효성을 검증하는 일이다.

출금 계좌는 초과 출금되어서는 안 된다 -> 비즈니스 규칙 검증

송금되는 금액은 0보다 커야 한다 -> 입력 유효성 검증

 

비즈니스 규칙 검증은 도메인 엔티티 안에 넣는 것이 좋다.

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

package buckpal.domain; 

public class Account {

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

 

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

package buckpal.application.service;

@RequiredArgsConstructor 
@Transactional
public class SendMoneyService implements SendMoneyUseCase{ 
	// ...
	@Override
	public boolean sendMoney(SendMoneyCommand command) { // 유효성을 검증하는 코드
		requireAccountExists(command.getSourceAccountId); 
        requireAccountExists(command.getTargetAccountId));
        ...
    }
}

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

도메인 모델을 구현하는 방법에 대해서는 열려 있다.

 

풍부한 도메인 모델

애플리케이션 코어에 있는 엔티티에서 가능한 많은 도메인 로직이 구현된다. 상태를 변경하는 메서드를 제공하고, 비즈니스 규칙에 맞는 유효한 변경만 허용한다. (예제의 Account 엔티티)

빈약한 도메인 모델

상태를 표현하는 필드와 getter, setter 메서드만 포함한다. 도메인 로직이 유스케이스 클래스에 구현되어 있다.

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

입력과 비슷하게 출력도 가능하면 각 유스케이스에 맞게 구체적일수록 좋다. 출력은 호출자에게 꼭 필요한 데이터만 들고 있어야 한다.

유스케이스들 간에 같은 출력 모델을 공유하게 되면 유스케이스들도 강하게 결합된다. 한 유스케이스에서 출력 모델에 새로운 필드가 필요해지면 이 값과 관련 없는 다른 유스케이스에서도 이 필드를 처리해야 한다.

도메인 엔티티를 출력 모델로 사용하고 싶은 유혹도 견뎌야 한다.

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

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

쿼리를 위한 인커밍 전용 포트를 만들고 이를 쿼리 서비스에 구현한다.

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);
	}
}

쿼리 서비스는 유스케이스 서비스와 동일한 방식으로 동작한다. 인커밍 포트(GetAccountBalanceQuery)를 구현하고, 아웃고잉 포트(LoadAccountPort)를 호출한다.

읽기 전용 쿼리는 쓰기가 가능한 유스케이스와 코드 상에서 명확하게 구분된다. 이런 방식은 CQS(Command-Query Separation)나 CQRS(Command-Query Responsibility Segregation)과 같은 개념과 잘 맞는다.

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

도메인 로직을 우리가 원하는 대로 구현할 수 있도록 허용하지만, 입출력 모델을 독립적으로 모델링한다면 원치 않는 부수효과를 피할 수 있다.

728x90
반응형