드디어 앞에서 논의한 아키텍처를 어떻게 실제 코드로 구현할지 살펴보자.
한 계좌에서 다른 계좌로 송금하는 유스케이스를 구현해보자. 이를 객체지향적인 방식으로 모델링하는 한 가지 방법은 입금과 출금을 할 수 있는 Account 엔티티를 만들고 출금 계좌에서 돈을 출금해서 입금 계좌로 돈을 입금하는 것이다.
@AllArgsConstructor(access = AccessLevel.PRIVATE)
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 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;
}
@Value
public static class AccountId {
private Long value;
}
}
Account 엔티티는 실제 계좌의 현재 스냅숏을 제공한다. 계좌에 대한 모든 입금과 출금은 Activity 엔티티에 포착된다. 한 계좌에 대한 모든 활동(activity)들을 항상 메모리에 한꺼번에 올리는 것은 현명한 방법이 아니기 때문에 Account 엔티티는 ActivityWindow 값 객체(value object)에서 포착한 지난 며칠 혹은 몇 주간의 범위에 해당하는 활동만 보유한다.
계좌의 현재 잔고를 계산하기 위해서 Account 엔티티는 활동창(activity window)의 첫 번째 활동 바로 전의 잔고를 표현하는 baselineBalance 속성을 가지고 있다. 현재 총 잔고는 기준 잔고(baselineBalance)에 활동창의 모든 활동들의 잔고를 합한 값이 된다.
이제 입금과 출금을 할 수 있는 Account 엔티티가 있으므로 이를 중심으로 유스케이스를 구현하기 위해 바깥 방향으로 나아갈 수 있다.
먼저, 유스케이스가 실제로 무슨 일을 하는지 살펴보자. 일반적으로 유스케이스는 다음과 같은 단계를 따른다.
- 입력을 받는다
- 비즈니스 규칙을 검증한다
- 모델 상태를 조작한다
- 출력을 반환한다
유스케이스는 인커밍 어댑터로부터 입력을 받는다. 이 단계를 왜 '입력 유효성 검증’으로 부르지 않는지 의아할 수도 있다. 나는 유스케이스 코드가 도메인 로직에만 신경 써야 하고 입력 유효성 검증으로 오염되면 안 된다고 생각한다. 그래서 입력 유효성 검증은 곧 살펴볼 다른 곳에서 처리한다.
그러나 유스케이스는 비즈니스 규칙(business rule)을 검증할 책임이 있다. 그리고 도메인 엔티티와 이 책임을 공유한다. 이번 장의 후반부에서 입력 유효성 검증과 비즈니스 규칙 검증의 차이점에 대해 살펴보겠다.
비즈니스 규칙을 충족하면 유스케이스는 입력을 기반으로 어떤 방법으로든 모델의 상태를 변경한다. 일반적으로 도메인 객체의 상태를 바꾸고 영속성 어댑터를 통해 구현된 포트로 이 상태를 전달해서 저장될 수 있게 한다. 유스케이스는 또 다른 아웃고잉 어댑터를 호출할 수도 있다.
마지막 단계는 아웃고잉 어댑터에서 온 출략밧을, 유스케이스를 호출한 어댑터로 반환할 출력 객체로 변환하는 것이다.
이 단계들을 염두에 두고 '송금하기' 유스케이스를 구현하는 방법을 살펴보자.
@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: 출력 값 반환
}
}
서비스는 인커밍 인터페이스인 SendMoneyUserCase를 구현하고, 계좌를 불러오기 위해 아웃고잉 포트 인터페이스인 LoadAccountPort를 호출한다. 그리고 데이터베이스의 계좌 상태를 업데이트하기 위해 UpdateAccountStatePort를 호출한다. 그림 4.1은 이와 관련된 컴포넌트들을 나타낸 것이다.
앞에서 입력 유효성 검증은 유스케이스의 책임이 아니라고 이야기하긴 했지만, 여전히 이 작업은 애플리케이션 계층의 책임에 해당하기 때문에 지금 논의하는게 적절할 것 같다.
호출하는 어댑터가 유스케이스에 입력을 전달하기 전에 입력 유효성을 검증하면 어떨까?
애플리케이션 계층에서 입력 유혀성을 검증해야 하는 이유는, 그렇게 하지 않을 경우 애플리케이션 코어의 바깥쪽으로부터 유효하지 않은 입력값을 받게 되고, 모델의 상태를 해칠 수 있기 때문이다.
유스케이스 클래스가 아니라면 도대체 어디에서 입력 유효성을 검증해야 할까?
입력 모델(input model)이 이 문제를 다루도록 해보자. '송금하기' 유스케이스에서 입력 모델은 예제 코드에서 본 SendMoneyCommand 클래스다. 더 정확히 말하자면 생성자 내에서 입력 유효성을 검증할 것이다.
@Getter
public class SendMoneyCommand {
private final AccountId sourceAccountId;
private final AccountId targetAccountId;
private final Money money;
public SendMoneyCommnad(
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의 필드에 final을 지정해 불변 필드로 만들었다. 따라서 일단 생성에 성공하고 나면 상태는 유효하고 이후에 잘못된 상태로 변경할 수 없다는 사실을 보장할 수 있다.
SendMoneyCommand는 유스케이스 API의 일부이기 때문에 인커밍 포트 패키지에 위치한다. 그러므로 유효성 검증이 애플리케이션 코어(육각형 아키텍처의 육각형 내부)에 남아있지만 신성한 유스케이스 코드를 오염시키지는 않는다.
그런데 이런 귀찮은 작업들을 대신해 줄 수 있는 도구가 이미 있는데 굳이 모든 유효성 검증을 직접 구현해야 할까? 자바 세계에는 Bean Validation API가 이러한 작업을 위한 사실상의 표준 라이브러리다. 이 API를 이용하면 필요한 유효성 규칙들을 필드의 애너테이션으로 표현할 수 있다.
@Getter
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;
requireGreaterThan(money, 0);
this.validateSelf();
}
}
SelfValidating 추상 클래스는 validateSelf() 메서드를 제공하며, 생성자 코드의 마지막 문장에서 이 메서드를 호출하고 있다. 이 메서드가 필드에 지정된 Bean Validation 애너테이션(@NonNull 같은)을 검증하고, 유효성 검증 규칙을 위반한 경우 예외를 던진다. Bean Validation이 특정 유효성 검증을 표현하기 충분하지 않다면 송금액이 0보다 큰지 검사했던 것처럼 직접 구현할 수도 있다.
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);
}
}
}
입력 모델에 있는 유효성 검증 코드를 통해 유스케이스 구현체 주위에 사실상 오류 방지 계층(anti corruption layer)을 만들었다. 여기서 말하는 계층은 하위 계층을 호출하는 아키텍처에서의 계층이 아니라 잘못된 입력을 호출자에게 돌려주는 유스케이스 보호막을 의미한다.
앞에서 살펴본 입력 모델인 SendMoneyCommand는 생성자에 많은 책임을 지우고 있다. 클래스가 불변이기 때문에 생성자의 인자 리스트에는 클래스의 각 속성에 해당하는 파라미터들이 포함돼 있다. 그뿐만 아니라 생성자가 파라미터의 유효성 검증까지 하고 있기 때문에 유효하지 않은 상태의 객체를 만드는 것은 불가능하다.
예제 코드의 생성자에는 3개의 파라미터만 있다. 파라미터가 더 많다면 어떻게 해야 할까? 빌더(Builder) 패턴을 활용하면 더 편하게 사용할 수 있지 않을까? 긴 파라미터 리스트를 받아야 하는 생성자를 private으로 만들고 빌더의 build() 메서드 내부에 생성자 호출을 숨길 수 있다. 그러면 파라미터가 20개인 생성자를 호출하는 대신 다음과 같이 객체를 만들 수 있을 것이다.
new SendMoneyCommandBuilder()
.sourceAccountId(new AccountId(41L))
.targetACcountId(new AccountId(42L))
// ... 다른 여러 필드를 초기화
.build();
유효성 검증 로직은 생성자에 그대로 둬서 빌더가 유효하지 않은 상태의 객체를 생성하지 못하도록 막을 수 있다.
괜찮은 것 같은가? 그러면 SendMoneyCommandBuilder에 필드를 새로 추가해야 하는 상황을 생각해보자(이는 소프트웨어 프로젝트의 생명주기 동안 꽤나 자주 있는 일이다). 먼저 생성자와 빌더에 새로운 필드를 추가한다. 그런데 갑자기 동료가 집중을 방해해 빌더를 호출하는 코드에 새로운 필드를 추가하는 것을 잊고 만다면 어떨까.
컴파일러는 이처럼 유효하지 않은 상태의 불변 객체를 만들려는 시도에 대해서는 경고해주지 못한다. 물론 런타임에(단위 테스트에서라면 더할나위 없고) 유효성 검증 로직이 동작해서 누락된 파라미터에 대해 에러를 던지긴 하겠지만 말이다.
하지만 빌더 뒤에 숨기는 대신 생성자를 직접 사용했다면 새로운 필드를 추가하거나 필드를 삭제할 때마다 컴파일 에러를 따라 나머지 코드에 변경사항을 반영할 수 있었을 것이다.
각기 다른 유스케이스에 동일한 입력 모델을 사용하고 싶은 생각이 들 때가 있다. '계좌 등록하기’와 '계좌 정보 업데이트하기’라는 유스케이스들은 계좌 상세 정보가 필요하다.
차이점은 '업데이트하기’는 업데이트할 계좌를 특정하기 위해 계좌 ID 정보가 필요하고, '등록하기’는 계좌를 귀속시킬 소유자의 ID 정보를 필요로 한다는 것이다. 그래서 두 유스케이스에서 같은 입력 모델을 공유할 경우 '계좌 정보 업데이트하기’에서는 소유자 ID에, '계좌 등록하기’에서는 계좌 ID에 null 값을 허용해야 한다.
불변 커맨드 객체의 필드에 대해서 null을 유요한 상태로 받아드리는 것은 그 자체로 코드 냄새(code smell)다. 하지만 더 문제가 되는 부분은 이제 입력 유효성을 어떻게 검증하느냐다. 등록 유스케이스와 업데이트 유스케이스는 서로 다른 유효성 검증 로직이 필요하다. 아마도 유스케이스에 커스텀 유효성 검증 로직을 넣어야 할 테고, 이는 비즈니스 코드를 입력 유효성 검증과 관련된 관심사로 오염시킨다.
각 유스케이스 전용 입력 모델은 유스케이스를 훨씬 명확하게 만들고 다른 유스케이스와의 결합도도 제거해서 불필요한 부수효과가 발생하지 않는다. 물론 비용이 발생하지만 들어오는 데이터를 각 유스케이스에 해당하는 입력 모델에 매핑해야 하기 때문이다. 이 매핑 전략은 8장에서 다룬다.
입력 유효성 검증은 유스케이스 로직의 일부가 아닌 반면, 비즈니스 규칙 검증은 분명히 유스케이스 로직의 일부다. 비즈니스 규칙은 애플리케이션의 핵심이고, 언제 입력 유효성을 검증하고 비즈니스 규칙을 검증해야 할까?
가장 쉬운 구분점은 비즈니스 규칙 검증은 도메인 모델의 현재 상태에 접근해야 하는 반면, 입력 유효성 검증은 그럴 필요가 없다는 것이다. 입력 유효성을 검증하는 일은 @NotNull 어노테이션을 붙인 것처럼 선언적으로 구현할 수 있지만 비즈니스 규칙을 검증하는 일은 조금 더 맥락이 필요하다.
입력 유효성은 검증하는 것은 구문상의(syntactical) 유효성을 검증하는 것이라고도 할 수 있다.
반면 비즈니스 규칙은 유스케이스의 맥락 속에 의미적인(semantical) 유효성을 검증하는 일이라고 할 수 있다.
"출금 계좌는 초과 출금되어서는 안된다": 이 규칙은 출금, 입금 계좌가 존재하는지 확인하기 위해 모델의 현재 상태에 접근해야 하기 때문에 비즈니스 규칙이다.
"송금되는 금액은 0보다 커야한다": 모델에 접근하지 않고도 검증될 수 있다. 그러므로 입력 유효성 검증으로 구현할 수 있다.
비즈니스 규칙 검증을 하는 가장 좋은 방법은 비즈니스 규칙을 도메인 엔티티 안에 넣는 것이다.
public class Account {
// ...
public boolean withdraw(Money money, AccountId targetAccountId) {
if (!mayWithdraw(money)) {
return false;
}
// ...
}
}
이렇게 하면 이 규칙을 지켜야 하는 비즈니스 로직 바로 옆에 규칙이 위치하기 때문에 위치를 정하는 것도 쉽고 추론하기도 쉽다.
만걍 도메인 엔티티에서 비즈니스 규칙을 검증하기가 여의치 않다면 유스케이스 코드에서 도메인 엔티티를 사용하기 전에 해도 된다.
@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) {
requireAccountExists(command.getSourceAccountId());
requireAccountExists(command.getTargetAccountId());
...
}
}
유효성을 검증하는 코드를 호출하고, 유효성 검증이 실패할 경우 예외를 던진다. 사용자와 통신하는 어댑터는 이 예외를 에러 메시지로 사용자에게 보여주거나 적절한 다른 방법으로 처리한다.
앞의 예제에서 살펴본 유효성 검증은 단순히 출금 계좌와 입금 계좌가 데이터베이스에 있는지 확인하는 것이었다. 더 복잡한 비즈니스 규칙의 경우에는 먼저 데이터베이스에서 도메인 모델을 로드해서 상태를 검증해야 할 수도 있다. 어쨌든 도메인 모델을 로드해야 한다면 앞에서 "출금 계좌는 초과 인출되어서는 안 된다" 규칙을 다뤘을 때처럼 도메인 엔티티 내에 비즈니스 규칙을 구현해야 한다.
이 책의 아키텍처 스타일은 도메인 모델을 구현하는 방법에 대해서 열려 있다. 이는 우리의 문맥에 적합한 방식을 선택할 수 있기 때문에 축복이기도 하지만 우리를 도와줄 어떤 지침도 없기 때문에 저주이기도 하다.
자주 논의되는 사항은 DDD 철학을 따르는 풍부한 도메인 모델(rich domain model)을 구현할 것이지, '빈약한' 모델(anemic domain model)을 구현할 것인가다. 나는 둘 중 하나를 선호하지는 않을 예정이지만, 이 책의 아키텍처에 각각이 어떻게 어울리는지 살펴보자.
풍부한 도메인 모델에서는 애플리케이션의 코어에 있는 엔티티에서 가능한 한 많은 도메인 로직이 구현된다. 엔티티들은 상태를 변경하는 메서드를 제공하고, 비즈니스 규칙에 맞는 유효한 변경만을 허용한다. 이는 예제의 Account 엔티티에서 따랐던 방식과 같다.
이 시나리오에서 유스케이스는 어디에 구현돼 있을까?
유스케이스는 도메인 모델의 진입점으로 동작한다 -> 유스케이스는 사용자의 이도만을 표현하면서 이 의도를 실제 작업을 수행하는 체계화된 도메인 엔티티 메서드 호출로 변환한다. -> 많은 비즈니스 규칙이 유스케이스 구현체 대신 엔티티에 위치하게 된다.
'빈약한' 도메인 모델에서는 엔티티 자체가 굉장히 얇다. 일반적으로 엔티티는 상태를 표현하는 필드와 이 값을 읽고 바꾸기 위한 getter, setter 메서드만 포함하고 어떤 도메인 로직도 가지고 있지 않다.
이 말은, 도메인 로직이 유스케이스 클래스에 구현돼 있다는 것이다. 비즈니스 규칙을 검증하고, 엔티티의 상태를 바꾸고, 데이터베이스 저장을 담당하는 아웃고잉 포트에 엔티티를 전달할 책임 역시 유스케이스 클래스에 있다. '풍부함’이 엔티티 대신 유스케이스에 존재하는 것이다.
앞의 두 가지 스타일을 비롯한 그 밖의 여러 가지 다른 스타일들도 이 책에서 논의하는 이케틱처 접근법을 이용해서 구현할 수 있다. 각자의 필요에 맞게 스타일을 자유롭게 택해서 사용하면 된다.
유스케이스가 할 일을 다 하고 나면 호출자에게 무엇을 반환해야 할까?
입력과 비슷하게 출력도 각 유스케이스에 맞게 구체적일수록 좋다. 출력은 호출자에게 꼭 필요한 데이터만 들고 있어야 한다.
'송금하기' 유스케이스 코드에서는 boolean 값 하나를 반환했다. 이는 이 맥락에서 반환할 수 있는 가장 구체적인 최소한의 값이다.
업데이트된 Account를 통째로 반환하고 싶거나 호출자가 계좌의 새로운 잔액에 관심이 있을 수도 있다.
그러나 '송금하기' 유스케이스에서 정말로 이 데이터를 반환해야 할까? 호출자가 정말로 이 값을 필요로 할까? 만약 그렇다면 다른 호출자도 사용할 수 있도록 해당 데이터에 접근할 전용 유스케이스를 만들어야 하지 않을까?
이러한 질문엔 정답은 없다. 그러나 유스케이스를 가능한 한 구체적으로 유지하기 위해서는 계속 질문해야 한다. 만약 의심스럽다면 가능한 한 적게 반환하자.
유스케이스들 간에 같은 출력 모델을 공유하게 되면 유스케이스들도 강하게 결합된다. 한 유스케이스에서 출력 모델에 새로운 필요해지면 이 값과 관련이 없는 다른 유스케이스에서도 이 필드를 처리해야 한다. 공유 모델은 장기적으로 봤을 때 갖가지 이유로 점점 커지게 돼 있다. 단일 책임 원칙을 적용하고 모델을 분리해서 유지하는 것은 유스케이스의 결합을 제거하는 데 도움이 된다.
앞에서 모델의 상태를 변경하는 유스케이스를 어떻게 구현할지 논의했다. 그렇다면 읽기 전용 유스케이스는 어떻게 구현할까?
UI에 계좌의 잔액을 표시해야 한다고 가정해보자. 이를 위한 새로운 유스케이스를 구현해야 할까?
이 책의 아키텍처 스타일에서 이를 구현하는 한 가지 방법은 쿼리를 위한 인커밍 전용 포트를 만들고 이를 '쿼리 서비스(query service)'에 구현하는 것이다.
@RequiredArgsConstructor
public 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)나 CORS(Command-Query Responsibility segregation) 같은 개념과 아주 잘 맞는다.
앞의 예제 코드에서 서비스는 아웃고잉 포트로 쿼리를 전달하는 것 외에 다른 일을 하지 않는다. 여러 계층에 걸쳐 같은 모델을 사용한다면 지름길을 써서 클라이언트가 아웃고잉 포트를 직접 호출하게 할 수도 있다. 이 지름길에 대해서는 11장에서 다뤄보자.
이 책의 아키텍처에서는 도메인 로직을 우리가 원하는 대로 구현할 수 있도록 허용하지만, 입출력 모델을 독립적으로 모델링한다면 원치 않는 부수효과를 피할 수 있다.
물론 유스케이스 간에 모델을 공유하는 것보다는 더 많은 작업이 필요하다. 각 유스케이스마다 별도의 모델을 만들어야 하고, 이 모델과 엔티티를 매핑해야 한다.
그러나 유스케이스별로 모델을 만들면 유스케이스를 명확하게 이해할 수 있고, 장기적으로 유지보수하기도 더 쉽다. 또한 여러 명의 개발자가 다른 사람이 작업 중인 유스케이스를 건드리지 않은 채로 여러 개의 유스케이스를 동시에 작업할 수 있다.
꼼꼼한 입력 유효성 검증, 유스케이스별 입출력 모델은 지속 가능한 코드를 만드는 데 큰 도움이 된다.