‘만들면서 배우는 클린 아키텍처’ 기술 서적을 읽고 학습 내용을 정리하기 위한 목적의 TIL 포스팅입니다🙆♂️ 예제코드는 깃허브 레포지토리를 참고해주세요.
4장 - 유스케이스 구현하기
- 위에서 설명한 내용에 따르면 애플리케이션, 웹, 영속성 계층이 현재 아키텍처에서 아주 느슨하게 결합돼 있기 때문에 필요한 대로 도메인 코드를 자유롭게 모델링할 수 있다.
- 육각형 아키텍처는 도메인 중심의 아키텍처에 적합하기에 도메인 엔티티를 만드는것으로 시작후 해당 도메인 엔티티를 중심으로 유스케이스를 구현한다.
도메인 모델 구현하기
- 한 계좌에서 다른 계좌로 송금하는 유스케이스를 구현한다.
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package buckpal.domain;
@AllArgsConstructor
@Getter
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()
).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(계좌) 엔티티는 실제 계좌의 현재 스냅숏을 제공한다.
- 계좌에 대한 모든 입출금은 Activity 엔티티에 포착한다.
- 한 계좌에 대한 모든 활동(activity)들은 항상 메모리에 한꺼번에 올리는건 현명한 방법이 아니기에 Account 엔티티는 ActivityWindow 값 객체(value object)에서 포착한 지난 며칠 혹은 몇 주간의 범위에 해당하는 활동만 보유한다.
유스케이스 둘러보기
- 일반적으로 유스케이스는 아래와 같은 단계를 따른다.
- 1)입력을 받는다
- 2)비즈니스 규칙을 검증한다
- 3)모델 상태를 조작한다
- 4)출력을 반환한다
- 비즈니스 규칙을 충족하면 유스케이스는 입력을 기반으로 어떤 방법으로든 모델 상태를 변경한다. 일반적으로 도메인 객체의 상태를 바꾸고 영속성 어댑터를 통해 구현된 포트로 이 상태를 전달해서 저장될 수 있게 한다. 유스케이스는 또 다른 아웃고잉 어댑터를 호출할 수도 있다.
- 마지막 단계는 아웃고잉 어댑터에서 온 출력값을, 유스케이스를 호출한 어댑터를 반환할 출력 객체로 변환하는 것이다.
- 1장에서 이야기한 넓은 서비스 문제를 피하기 위해 모든 유스케이스를 각 분리된 서비스로 만든다.
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
package buckpal.application.service;
import buckpal.application.port.in.SendMoneyCommand;
import buckpal.application.port.in.SendMoneyUseCase;
import buckpal.application.port.out.AccountLock;
import buckpal.application.port.out.LoadAccountPort;
import buckpal.application.port.out.UpdateAccountStatePort;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SendMoneyService implements SendMoneyUseCase {
private final LoadAccountPort loadAccountPort;
private final AccountLock accountLock;
private final UpdateAccountStatePort updateAccountStatePort;
@Transactional
@Override
public boolean sendMoney(SendMoneyCommand command) {
//TODO: 비즈니스 규칙 검증
//TODO: 모델 상태 조작
//TODO: 출력 값 반환
return false;
}
}
- 서비스는 인커밍 포트 인터페이스인 SendMoneyUseCase를 구현하고, 계좌를 불러오기 위해 아웃고잉 포트 인터페이스인 LoadAccountPort 를 호출한다. 그리고 DB의 계좌상태 업데이트를 위해 UpdateAccountStatePort 를 호출한다.
출처: https://jandari91.tistory.com/54
입력 유효성 검증
- 저자는 유스케이스 코드가 도메인 로직에만 신경써야 하고 입력 유효성 검증으로 오염되면 안된다 생각한다
- 그러나 유스케이스는 비즈니스 규칙(business rule)을 검증할 책임이 있다.
- 과연 유스케이스에서 필요로 하는것을 호출자가 모두 검증했다고 믿을수있을까? 또 해당 유스케이스를 호출하는 모든 각 어댑터에서 유효성검증을 해야할텐데 실수할수도 있고 잊을수도 있다.
- 애플리케이션 계층에서 입력 유효성을 검증하는 이유는, 그렇게 하지 않을 경우 애플리케이션 코어의 바깥쪽으로부터 유효하지 않은 입력값을 받게 되고, 모델 상태를 해칠수 있기 때문이다.
- 그러면 어디서 입력유효성 검증을 해야할까? 입력 모델(input model)이 이 문제를 다루도록 해보자. ‘송금하기’ 유스케이스에선 SendMoneyCommand 클래스다. 더 정확히는 생성자내에서다.
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
32
33
34
35
package buckpal.application.port.in;
import buckpal.common.SelfValidating;
import buckpal.domain.Account.AccountId;
import buckpal.domain.Money;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Value;
import javax.validation.constraints.NotNull;
@Value
@Getter
@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();
}
}
- 객체 생성시 예외를 던져서 객체 생성을 막으면 된다.
- SendMoneyCommand 필드에 final 을 지정해 불변 필드로 만들면 안정적으로 유효한 불변 객체를 유지할 수 있다.
- SendMoneyCommand는 유스케이스 API의 일부이기에 인커밍 포트 패키지에 위치한다. 그러므로 유효성 검증이 애플리케이션 코어(육각형 아키텍처 내부)에 남아있지만 신성한 유스케이스 코드를 오염시키지 않게된다.
- 자바에선 Bean Validation API(spring-boot-starter-validation)가 필요한 유효성 규칙들을 필드 애너테이션으로 표현 가능하다.
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
32
33
34
35
36
37
38
39
40
41
42
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();
}
}
public abstract class SelfValidating<T> {
private Validator validator;
public SelfValidating() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}
/**
* Evaluates all Bean Validations on the attributes of this
* instance.
*/
protected void validateSelf() {
Set<ConstraintViolation<T>> violations = validator.validate((T) this);
if (!violations.isEmpty()) {
throw new ConstraintViolationException(violations);
}
}
}
- 커맨트 모델은 SelfValidating.validateSelf() 함수를 호출함으로써, 유효성 검증을 수행하고 예외를 던지게 된다.
- 유스케이스 로직 내부에 구현하지 않고 커맨드 모델 생성자를 통해 입력 유효성 검증을 수행함으로써 오류 방지 계층을 만든것이다.
생성자의 힘
- 빌더 패턴 활용하여 커맨드 모델을 생성하는 경우 입력 유효성 검증에 대한 누락이 발생할수 있게 된다.
- 요즘 IDE 는 생성자 파라미터 힌트를 제공해주기도 한다.
유스케이스마다 다른 입력 모델
- 두 유스케이스에 동일한 입력 모델을 사용하고 싶은 생각이 들때가 있다. ‘계좌 등록하기’와 ‘계좌 정보 업데이트하기’ 유스케이스처럼 말이다.
- ‘계좌 등록하기’는 소유자 ID가 필요하고, ‘계좌 정보 업데이트’ 유스케이스는 업데이트칠 계좌 ID가 필요하다.
- 그러다보면 ‘계좌 등록하기’에선 계좌 ID가 null을 허용해야 하고, ‘계좌 정보 업데이트’ 에선 소유자 ID에 null을 허용해야 한다.
- 불변 커맨드 객체 필드에 null을 유효한 상태로 받아들이는 것은 그 자체로 코드 냄새다.
- 하지만 더 문제되는 부분은 이제 입력 유효성을 어떻게 검증하느냐다. 등록 유스케이스와 업데이트 유스케이스는 서로 다른 유효성 검증 로직이 필요할텐데 아마 유효성 검증 로직을 분기처리하여 관리되고 유지보수에 좋지 못할것이다.
- 각 유스케이스 전용 입력 모델은 유스케이스를 훨씬 명확하게 만들고 다른 유스케이스와의 결합도 제거해서 불필요한 부수효과가 발생하지 않게 한다. 들어오는 데이터를 각 유스케이스에 해당하는 입력모델로 매핑해야 하기 때문에 물론 비용이 안 드는 것은 아니다.
비즈니스 규칙 검증하기
- 입력 유효성 검증은 구문상의 유효성을 검증하는 것이고, 비즈니스 규칙은 유스케이스 맥락에서 의미적인(semantical) 유효성 검증이라 할 수 있다.
- 좀 더 쉽게 설명하면 입력 유효성 검증은 논리적인 수준의 검증 없이 단순한 필드에 대한 검증이고 비즈니스 규칙 검증은 모델의 현재 상태를 기반으로 하는 논리적인 수준의 검증이라 할 수 있다.
- “송금되는 금액은 0보다 커야 한다”라는 규칙은 모델에 접근하지 않고도 검증될 수 있기에 입력 유효성 검증으로 구현할 수 있다. 하지만 논란의 여지는 있다. 송금액은 매우 중요하므로 비즈니스 규칙으로도 다룰수 있다는 것이다.
- 하지만 맨 처음 정의대로 구현하게 되면 장점이 있다. 코드 상의 어느 위치에 둘지 결정하고 나중에 더 쉽게 찾을 수 있다. 현재 모델 상태에 접근해야 하는지 여부만 확인하면 되기 떄문이다. 그러면 유지보수하기 쉬워진다.
- 비즈니스 규칙 검증은 도메인 모델 안에 유효성 검증 로직을 넣는것이 베스트하다. 그러면 위치를 정하기도 쉽고 추론하기도 쉽다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Account {
// ...
public boolean withDraw(Money money, AccountId targetAccountId) {
if (!mayWithDraw(money)) {
return false;
}
...
}
...
}
- 만약 도메인 엔티티에서 비즈니스 규칙을 검증하기 여의치 않다면 유스케이스 코드에서 도메인 엔티티를 사용하기 전에 해도 된다. (ex. 단순 ID 값에 해당하는 데이터가 DB에 있는지 확인이 필요할 경우)
풍부한 도메인 모델 vs 빈약한 도메인 모델
- 풍부한 도메인 모델은 애플리케이션 코어 있는 엔티티에서 가능한 많은 도메인 로직이 구현된다. 엔티티들은 상태를 변경하는 메서드를 제공하고, 비즈니스 규칙에 맞는 유효한 변경만을 허용하면 된다.
- 빈약한 도메인 모델은 상태를 표현하는 필드와 getter, setter 메서드만 포함하며 어떤 도메인 로직도 가지고 있지 않다. 즉, 도메인 로직이 유스케이스 클래스에 구현되있다는것이다.
- 위 두 가지중 각자 스타일에 맞게 선택해서 사용하면 된다.
유스케이스마다 다른 출력 모델
- 입력과 마찬가지로 출력도 가능하면 각 유스케이스에 맞게 구체적일수록 좋고, 출력은 호출자에게 꼭 필요한 데이터만 들고 이썽야 한다.
- 유스케이스들 간에 출력 모델을 공유하면 강한 결합이 생기게되고 유지보수하기 어려워진다. (한 유스케이스에만 필요한 필드들이 계속 늘어나게되는 등..)
읽기 전용 유스케이스
- 읽기 전용 작업을 유스케이스라 언급하는것은 조금 이상하다.
- 예를 들어 UI에 계좌 잔액을 표시한다고 할때 애플리케이션 코어 관점에선 간단한 데이터 쿼리다.
- 이를 구현하는 한 가지 방법은 쿼리를 위한 인커밍 전용 포트를 만들어 이를 ‘쿼리 서비스’로 구현하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
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();
}
}
- 여러 계층에 걸쳐 같은 모델을 사용한다면 지름길을 써서 클라이언트가 아웃고잉 포트를 직접 호출하게 할 수도 있다.
유지보수 가능한 소프트웨어를 만드는데 어떻게 도움이 될까?
- 입출력 모델을 독립적으로 모델링한다면 원치 않는 부수효과를 피할수 있다.
- 유스케이스별 모델을 명확히 이해할수 있고, 여러 개발자가 협엽시 다른 사람이 작업중인 유스케이스를 건들지 않는채로 동시 작업을 할 수 있게 된다.