서비스에 모든 비즈니스 로직을 작성할 수도 있습니다. 그러나 다음과 같은 단점이 있습니다.
1. 서비스 로직의 복잡도가 높아지게 된다.
2. 로직(메서드)이 중복되면, Service Layer 가 너무 비대해진다.
3. 이로 인해 유지보수와 테스트가 힘든 상황이 발생할 수 있고, 유연하지 못한 소프트웨어가 될 수 있다.
위와 같은 이유로 서비스의 로직을 도메인으로 옮기는 중에 AbstractAggregateRoot를 알게 됐습니다.
AbstractAggregateRoot는 DDD(Domain Driven Design)를 구현하기 편리하게 해주는 클래스입니다.
좀 더 정확히 말하면 도메인 이벤트를 등록, 삭제 등 관리하는 것을 도와주는 클래스입니다.
예시
'계좌'라는 도메인이 있습니다.
1. A 유저가 B 유저에게 계좌 송금을 합니다. (잔고 변경)2. 계좌 송금에 대한 이력을 저장합니다. (송금 이력 등록)
목표
1. DDD 관점에 맞게 Service 가 아닌 Domain에 비즈니스 로직을 작성한다.
2. 잔고 변경 시 자동으로 송금 이력 이벤트를 등록한다.
@EventLister를 사용하여 핸들러 생성
package com.my.bank.interfaces.event;
@Component
@RequiredArgsConstructor
public class AccountTransferEventHandler {
private final CreateAccountTransferHistoryService createAccountTransferHistoryService;
@EventListener
public void setAccountTransferEvent(AccountTransferEvent accountTransferEvent) {
CreateAccountTransferHistoryCommand createAccountTransferHistoryCommand = CreateAccountTransferHistoryCommand.builder()
.money(accountTransferEvent.getMoney())
.senderId(accountTransferEvent.getSender())
.receiverId(accountTransferEvent.getReceiver()).build();
createAccountTransferHistoryService.createAccountTransferHistory(createAccountTransferHistoryCommand);
// createAccountTransferHistory 는 단순히 이력을 저장(save)하는 메서드, 생략
}
}
계좌 송금 이력을 등록하는 이벤트를 작성했습니다.
@EventList를 사용하면 이벤트를 수신하는 메서드라는 것을 표시할 수 있습니다.
서비스 로직 추가
package com.my.bank.application.commandServices;
public class TransferCommandService {
private final AccountRepository accountRepository;
@Transactional
public void transferToAccount(TransferAccountDTO transferAccountDTO) {
Account senderAccount = accountRepository.findById(transferAccountDTO.getSendUserId())
.orElseThrow(() -> new RuntimeException("송금 계좌가 존재하지 않습니다."));
Account receiverAccount = accountRepository.findById(transferAccountDTO.getReceiveUserId())
.orElseThrow(() -> new RuntimeException("입금 계좌가 존재하지 않습니다."));
senderAccount.sendMoney(transferAccountDTO.getMoney(), receiverAccount);
accountRepository.save(senderAccount);
accountRepository.save(receiverAccount);
}
}
계좌 송금 서비스입니다.
Repository의 JPA 메서드를 호출하고, Domain의 메서드를 호출할 뿐 추가 비즈니스 로직은 없습니다.
도메인에 비즈니스 로직 추가
package com.my.bank.domain.model.aggregates;
public class Account extends AbstractAggregateRoot<Account> {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column
private Long id;
@Column
private String name;
@Column
private int balance;
public void sendMoney(int money, Account receiver) {
checkBalance(money);
this.subtract(money);
receiver.add(money);
registerEvent(new AccountTransferEvent(this.id, receiver.id, money));
}
private void checkBalance(int money) {
if (this.getBalance() < money) {
throw new RuntimeException("잔액이 부족합니다.");
}
}
public void add(int money) {
this.balance += money;
}
public void subtract(int money) {
this.balance -= money;
}
}
비즈니스 로직(검증, 엔티티 수정, 이벤트 등록)을 도메인에 작성했습니다.
상속받은 AbstractAggregateRoot의 registerEvent 메서드로 이벤트를 등록합니다.
이후 계좌 송금 API를 호출하면, 이력이 자동으로 등록되는 것을 확인할 수 있습니다.
AbstractAggregateRoot 동작 원리
public class AbstractAggregateRoot<A extends AbstractAggregateRoot<A>> {
private transient final @Transient List<Object> domainEvents = new ArrayList<>();
/**
* Registers the given event object for publication on a call to a Spring Data repository's save or delete methods.
*
* @param event must not be {@literal null}.
* @return the event that has been added.
* @see #andEvent(Object)
*/
protected <T> T registerEvent(T event) {
Assert.notNull(event, "Domain event must not be null");
this.domainEvents.add(event);
return event;
}
/**
* Clears all domain events currently held. Usually invoked by the infrastructure in place in Spring Data
* repositories.
*/
@AfterDomainEventPublication
protected void clearDomainEvents() {
this.domainEvents.clear();
}
AbstractAggregateRoot 에는 domainEvents라는 이벤트 리스트를 저장하는 필드가 있습니다.
registerEvent를 호출하면 해당 리스트에 이벤트를 추가하는 것입니다.
위의 클래스에는 이벤트를 발생시키는 publish 메서드가 존재하지 않습니다.
@AfterDomainEventPublication라는 어노테이션을 사용한 곳을 따라가다 보니 이벤트를 발생시키는 곳이 존재했습니다.
public Object invoke(MethodInvocation invocation) throws Throwable {
Object result = invocation.proceed();
if (!isEventPublishingMethod(invocation.getMethod())) {
return result;
}
Object[] arguments = invocation.getArguments();
eventMethod.publishEventsFrom(arguments[0], publisher);
return result;
}
}
private static boolean isEventPublishingMethod(Method method) {
return method.getParameterCount() == 1 //
&& (isSaveMethod(method.getName()) || isDeleteMethod(method.getName()));
}
private static boolean isSaveMethod(String methodName) {
return methodName.startsWith("save");
}
private static boolean isDeleteMethod(String methodName) {
return methodName.equals("delete") || methodName.equals("deleteAll") || methodName.equals("deleteInBatch")
|| methodName.equals("deleteAllInBatch");
}
위의 코드는 EventPublishingRepositoryProxyPostProcessor 클래스의 이벤트 발행 인터셉터 (내부) 클래스입니다.
isEventPublishingMethod를 호출하여 결괏값이 true 일 때만 publishEventsFrom 메서드를 호출하도록 작성됐습니다.
JPA로 호출하는 메서드가 save 나 delete 메서드이고, 대상 엔티티가 1개 일 때만 이벤트가 실행됩니다.
따라서 save 나 delete 가 호출된 후에 자동으로 리스너에 등록한 이벤트가 실행된다는 것을 알 수 있었습니다.
참고자료
'Spring' 카테고리의 다른 글
MapStruct (0) | 2023.11.28 |
---|---|
Save, SaveAndFlush 차이 (0) | 2023.11.28 |
Save 메서드 동작 원리 (1) | 2023.11.28 |
Valid 어노테이션 커스텀해서 사용하기 (0) | 2023.10.22 |
Valid, Validated 어노테이션 (0) | 2023.09.24 |