4. 유스케이스 구현하기

앞서 설계한 패키지대로 코드를 작성하면 애플리케이션, 웹, 영속성 계층이 아주 느슨하게 결합돼 있기 때문에 필요한 대로 도메인 코드를 자유롭게 모델링할 수 있다.

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

유스케이스 둘러보기

유스케이스는 일반적으로 아래와 같은 단계를 따른다.

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

유스케이스는 인커밍 어댑터로부터 입력을 받는데 유스케이스 코드는 도메인 로직에만 집중하고 ‘입력 유효성 검증’은 다른 곳에서 처리하는게 좋다.

그러나 유스케이스는 비즈니스 규칙을 검증할 책임이 있고 도메인 엔티티와 이 책임을 공유한다.

입력 유효성 검증

입력 유효성 검증이 유스케이스의 책임이 아니라도 애플리케이션 계층의 책임은 맞다. 유효성 검증을 하지 않으면 애플리케이션 코어의 바깥쪽으로부터 유효하지 않은 입력값을 받게 되고, 모델의 상태를 해칠 수 있다.

입력 모델(input model)에서 이러한 검증을 담당해보자.

유스케이스에서 입력에 사용할 모델 클래스의 생성자 내에서 특정 조건에 위배될 경우 객체 생성 자체를 막고 예외를 던지면 될 것이다.

또한 필드에 final을 붙여서 불변 필드로 만들어 생성에 성공하면 유효한 상태를 유지하고 잘못된 상태로 변경할 수 없다는 사실을 보장할 수 있다.

사실 이런 기능들은 Bean Validation API를 사용하면 편하게 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
@Getter
public class SendMoneyCommand extends SelfValidating<SendMoneyCommand> {
@NotNull
private final Money;

public SendMoneyCommand(Money money){
this.money = money;
requiredGreaterThan(money, 0);
this.validateSelf();
}
}

SelfValidating 추상 클래스는 validateSelf() 메서드를 제공하여 호출하면 필드에 지정된 Bean Validation 어노테이션(@NotNull 같은)을 검증하고 유효성 검증 규칙을 위반한 경우 예외를 던진다.

생성자의 힘

필드가 많아질 경우 빌더패턴을 도입해서 생성자를 private으로 만들고 빌더의 build() 메서드 내부에 생성자 호출을 숨길 수 있다.

다만, 만약 빌더와 생성자에 새로운 필드를 추가하고 빌더를 호출하는 코드에 새로운 필드를 추가하는 걸 깜빡하더라도 컴파일러는 이러한 시도에 대해서 경고해주지 못할 수 있다.

빌더 뒤에 숨기지 말고 생성자를 직접 사용했다면 컴파일 에러에 따라 나머지 코드에 변경사항을 반영할 수 있을 것이다.

  • 난 저자랑 생각이 다름

    저자가 예시에서 파라미터가 20개인 생성자를 호출하는 대신 빌더를 사용하면 이러이러 하다 라고 말했는데 코드의 가독성 측면에서 빌더패턴이 훨씬 보기 좋다고 생각한다.

    빌더 호출 코드에 깜빡할 경우가 얼마나 될것이며 단위테스트 과정에서 어느정도 걸러낼 수 있다고 생각된다.

    오히려 생성자의 파라미터 20개가 모두 String 타입일 경우 지저분한 코드를 보는게 더 스트레스 받을거 같다.

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

각기 다른 유스케이스에 동일한 모델을 사용하고 싶은 경우가 있다.

계좌 등록하기계좌 업데이트 하기라는 두개의 기능을 구현할 때 계좌에 대한 등록시점에선 소유권을 체크하기 위해 계좌의 소유자 ID 필드가 무조건 필요하지만 업데이트는 계좌번호만 체크한다고 했을때 ID 필드는 null을 허용하도록 만들수 있다.

일단 불변 도메인 모델에 null을 허용하는 것부터 일단 코드 스멜이 난다고 볼수 있다.(잠재적으로 side-Effect가 발생할수도 있는 코드를 말한다.)

단일책임원칙을 고려했을 때 각 유스케이스의 전용 입력모델을 사용하는게 결합도도 낮추고 부수효과도 줄일 수 있는 방법이다. 다만, 모든 입력 데이터에 대해서 각 유스케이스 별 모델 매핑을 해줘야하는 비용이 있지만 매핑 전략에 대해선 후술 한다.

비즈니스 규칙 검증하기

검증은 크게 두가지를 생각해볼 수 있다.

  1. 입력 유효성 검증
  2. 비즈니스 규칙 검증

둘을 구분하는 가장 실용적인 방법은 특정 검증이 도메인의 상태에 접근이 필요한지? **를 생각해보면 된다. 가령 단순히 입력 데이터의 유효성 체크는 도메인의 상태를 체크할 필요 없이 선언적으로 검증이 가능하지만 비즈니스 규칙은 좀 더 맥락을 이해하고 구현할 필요가 있다.

“출금계좌는 초과출금될 수 없다.” 라는 규칙을 검증할땐 출금계좌의 존재여부 부터 체크하는 등 도메인의 상태에 접근해야 하지만
”출금금액은 0보다 커야한다.” 라는 규칙은 도메인의 상태에 접근없이 입력 데이터를 단순히 체크하기만 하면 된다

비즈니스 규칙 검증은 보통 도메인 엔티티 내부에 직접 구현하는게 가장 좋다.

1
2
3
4
5
6
7
8
9
public class Account {
//...
public boolean withDraw(Money money, AccountId targetAccountId) {
if(!mayWithDraw(money)){
return false;
}
//...
}
}

이렇게 하면 지켜야하는 비즈니스 로직 옆에 있기 때문에 위치를 정하기도, 추론하기도 쉽다.

만약 엔티티 내부에 위치하기 어렵다면 유스케이스에서 도메인을 사용하기 전에 검증하는 방식도 있을 것이다.

1
2
3
4
5
6
7
8
9
10
11
@RequriedArgsConstruct
@Transactional
public class SendMoneyService implements SendMoneyUseCase {
//...
@Override
public void SendMoney(SendMoneyCommand command){
requriedSourceAccount(command.getSourceAccountId());
requriedTargetAccount(command.getTargetAccountId());
//...
}
}

유효성 검증 코드를 실행 후 오류가 발생하면 유효성 전용 예외처리를 통해 사용자

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

  • 풍부한 도메인 모델 : 애플리케이션의 코어에 있는 도메인 엔티티에 최대한 많은 로직이 담겨있다. 도메인의 상태를 변경하는 메서드를 제공하고 비즈니스 규칙 검증에 유효한 값만 허용한다.
  • 빈약한 도메인 모델 : 도메인 엔티티는 최대한 적은 로직을 가지고 있다. 보통 getter, setter를 제외한 다른로직은 모두 유스케이스에 구현한다.

결국 복잡한 비즈니스 모델이 어디에 있냐의 차이로 스타일의 차이라고 봐도 무방할 거 같다.

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

입력에 대한 처리가 끝나면 출력을 해야하는데 이때도 동일하게 각 유스케이스마다 다른 출력모델을 사용하는게 좋다.

어떤 출력에선 Account 모델 자체를 받고 싶어할 수도 있고, 단순히 성공실패 여부 등 boolean 값만을 받고 싶을 수도 있다.

정답은 없지만 명확한 규칙이 없다면 최대한 작은 데이터를 반환하는게 좋다. 모델 클래스를 통째로 반환하면 강한 결합이 일어나는데 한 유스케이스의 출력 모델에 새로운 필드가 추가될 경우 동일한 모델을 공유하는 다른 유스케이스에서도 해당 필드를 처리해야 하는 것처럼 영향이 있기 때문에 모델은 구체적이고 작게 결합은 약하게 하는게 좋다.

같은 이유로 도메인 엔티티 자체를 출력모델로 사용하는 것도 최대한 자제해야한다.

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

상태를 변경할 필요 없이 단순히 DB를 조회해서 값을 반환하기만 하는 읽기 전용 유스케이스를 구현해야 할때 상태변경 유스케이스와 동일한 형식으로 만들게 되면 간단한 기능에 비해 구현해야하는 것들이 많아질 수 있다. 간단히 쿼리만 조회해야 한다면 쿼리 서비스를 만들 수 있다.

인커밍 전용 포트를 만들어서 쿼리 서비스에서 구현하는 것이다.

1
2
3
4
5
6
7
8
9
10
@RequiredArgsConstructor
public class GetAccountQueryService implements GetAccountBalanceService {
private final LoadAccountPort loadAccountPort;

@Override
public Money getAccountBalance(AccountId accountId) {
return loadAccountPort.loadAccount(accountId, LocalDateTime.now())
.calculateBalance();
}
}

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

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

Author

Jaeyong Yoo

Posted on

2023-03-05

Updated on

2023-06-10

Licensed under

댓글