스프링 코드로 이해하는 핵사고날 아키텍처
시작하며
전형적인 계층화 아키텍처(layered architecture)의 대안인 핵사고날 아키텍처를 스프링 코드로 살펴보겠습니다. 핵사고날 아키텍처는 포트와 어댑터 아키텍처라고도 불리며 비즈니스 로직을 구현한 내부 영역, 비즈니스 로직을 호출해 외부의 요청을 처리하는 인바운드 어댑터(컨트롤러가 이에 포함), 영속 계층 대신 비즈니스 로직에 의해 호출되고 외부 애플리케이션을 호출하는 아웃바운드 애플리케이션이 있습니다. 애플리케이션 코어는 외부 어댑터에 의존하지 않는 것이 특징입니다.
코드로 구현해보자
본 글에서는 간단한 입출금을 REST API로 수행하는 핵사고날 아키텍처를 스프링부트로 구현해보겠습니다.
1. 도메인 모델
public class BankAccount {
private Long id;
private BigDecimal balance;
@Builder
public BankAccount(Long id, BigDecimal balance) {
this.id = id;
this.balance = balance;
}
public boolean withdraw(BigDecimal amount) {
if(balance.compareTo(amount) < 0) {
return false;
}
balance = balance.subtract(amount);
return true;
}
public void deposit(BigDecimal amount) {
balance = balance.add(amount);
}
public BigDecimal getBalance() {
return balance;
}
}
핵사고날 아키텍처의 가장 가운데 있는, 외부를 향한 의존성이 없는 도메인 모델먼저 만들어보았습니다.
도메인 모델의 특징은 특정 기술에 종속적이지 않다는 것입니다. Spring 어노테이션이 없는 순수 java, POJO(Plan Old Java Object)입니다. 이렇게 하면 특정 라이브러리에 종속적이지 않으며 테스트에 쉬워져 엉클 밥이 말씀하신 클린아키텍처에 가까워집니다.
현재 BankAccount 도메인 모델에는 출금, 입금이라는 비즈니스 규칙이 담겨있습니다.
2. 포트
핵사고날 아키텍처의 내부에서 외부로 나가는 의존성은 없습니다. 외부 어댑터에서 핵사고날 내부 애플리케이션 코어에로는 포트를 통해서만 접근할 수 있습니다.
내부에서 외부를 향하는 의존성이 없기에 모든 의존성은 중앙을 향하게 됩니다.
Input Port
public interface DepositUseCase {
void deposit(Long id, BigDecimal amount);
}
public interface WithdrawUseCase {
boolean withdraw(Long id, BigDecimal amount);
}
위 코드는 Input Port에 해당하는 입금, 출금 UseCase 인터페이스입니다.
Output Port
public interface LoadAccountPort {
BankAccount load(Long id);
}
public interface SaveAccountPort {
void save(BankAccount bankAccount);
}
입출금을 위한 애플리케이션 코어에 접근하기 위한 Input Port가 두 개가 있듯이 입출금을 위하여 데이터베이스와 상호작용하기 위한 Output Port 두 개가 있습니다.
핵사고날 아키텍처 그림의 가장 우측에 있는 BankAccountPersistentAdapter에서 Output Port인 인터페이스를 구현하여 애플리케이션 코어에서 필요하는 데이터베이스 영속을 수행할 것입니다.
3. 서비스
서비스에서는 나가는 포트 인터페이스 LoadBankAccount, ServiceBankAccount를 사용하여 데이터 영속화합니다.
들어오는 포트 인터페이스(LoadAccountPort, SaveAccountPort)를 구현하여 웹 어뎁터(BankAccount Controller)에 제공합니다.
@Service
@RequiredArgsConstructor
public class BankAccountService implements DepositUseCase, WithdrawUseCase {
private final LoadAccountPort loadAccountPort;
private final SaveAccountPort saveAccountPort;
@Override
public void deposit(Long id, BigDecimal amount) {
BankAccount account = loadAccountPort.load(id);
account.deposit(amount);
saveAccountPort.save(account);
}
@Override
public boolean withdraw(Long id, BigDecimal amount) {
BankAccount account = loadAccountPort.load(id);
boolean hasWithdrawn = account.withdraw(amount);
if(hasWithdrawn) {
saveAccountPort.save(account);
}
return hasWithdrawn;
}
}
여기서 설계의 선택이 생길 수 있습니다. 저는 영속 계층에서 도메인 모델인 BankAccount를 반환하였습니다. (참고. Github thombergs/buckpal) 도메인 모델과 영속 모델(JPA Entity)을 구분하지 않자는 의견도 있습니다. (참고. Just Stop It! The Domain Model Is Not The Persistence Model)
@Component
public class BankAccountMapper {
public BankAccount toDomain(BankAccountEntity entity) {
return BankAccount.builder()
.id(entity.getId())
.balance(entity.getBalance())
.build();
}
public BankAccountEntity toEntity(BankAccount domain) {
return BankAccountEntity.builder()
.balance(domain.getBalance())
.build();
}
}
본 코드에서는 Output Port(loadAccountPort, saveAccountPort)에서 엔티티를 도메인 모델로 변환해서 반환하였습니다.
4. 어뎁터
애플리케이션 코어에 들어오고, 나가는 포트를 호출하는 어댑터입니다. 어댑터는 애플리케이션 코어에 포트를 통하지 않으면 접근할 방법이 없습니다. 오로지 포트를 통해서 제공되는 메서드를 이용해서만 핵사고날 내부 코어에 접근할 수 있습니다.
4-1. 웹 어뎁터
@RestController
@RequestMapping("/account")
@RequiredArgsConstructor
public class BankAccountController {
private final DepositUseCase depositUseCase;
private final WithdrawUseCase withdrawUseCase;
@PostMapping(value = "/{id}/deposit/{amount}")
void deposit(@PathVariable final Long id,
@PathVariable final BigDecimal amount) {
depositUseCase.deposit(id, amount);
}
@PostMapping(value = "/{id}/withdraw/{amount}")
void withdraw(@PathVariable final Long id,
@PathVariable final BigDecimal amount) {
withdrawUseCase.withdraw(id, amount);
}
}
애플리케이션 코어에 들어오는 포트를 구현하는 BankAccountController 입니다. 핵사고날 아키텍처에서 어뎁터에 해당하기에 BankAccountAdapter 가 맞을 수 있으나 Controller를 관념상 사용하는 표현이기에 BankAccountController로 클래스 명을 정하였습니다.
4-2. 영속 어뎁터
@Entity
@Table(name = "account")
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class BankAccountEntity {
@Id @GeneratedValue
private Long id;
private BigDecimal balance;
}
public interface BankAccountSpringDataRepository
extends JpaRepository<BankAccountEntity, Long> {
}
@Repository
@RequiredArgsConstructor
public class BankAccountPersistenceAdapter
implements LoadAccountPort, SaveAccountPort {
private final BankAccountMapper bankAccountMapper;
private final BankAccountSpringDataRepository repository;
@Override
public BankAccount load(Long id) {
BankAccountEntity entity = repository.findById(id)
.orElseThrow(NoSuchElementException::new);
return bankAccountMapper.toDomain(entity);
}
@Override
public void save(BankAccount bankAccount) {
BankAccountEntity entity = bankAccountMapper.toEntity(bankAccount);
repository.save(entity);
}
}
영속 어댑터에서 Spring Data JPA를 이용하여 데이터베이스에 접근합니다. Mapper를 이용하여 BankAccountEntity를 도메인 모델로 변환합니다. 핵사고날 아키텍처에서 밖으로 나가는 포트인 LoadAccountPort, SaveAccountPort를 구현하여 데이터베이스에서 조회, 저장을 구현하였습니다.
핵사고날 아키텍처의 장점
REST API로 응답하는 송금 시스템에 외부 위험 감시를 위하여 보안 서버로 통신해야 하는 요구사항이 생겨 gRPC 프로토콜을 이용하여 입출금 기록을 보내야 한다는 시나리오를 가정해봅시다. 애플리케이션 코어 수정하거나 거대한 은행 시스템을 파악할 필요 없이 인풋 포트에서 제공하는 인터페이스를 이용하여 입출금 정보를 가져오면 될 것입니다.
계층형 구조에서 발생하는 비즈니스 로직과 데이터베이스가 강하게 결합하는 문제를 피하게 되어 느슨한 결합을 유지할 수 있게 합니다. 이는 변경에 유연하게 대응할 수 있으며 테스트를 쉽게 해줍니다.
마치며
본 글에서 사용한 플레이그라운드 코드는 Github. Tistory-Covenant-Code에서 확인할 수 있습니다.
본문의 코드베이스는 Github jivimberg/hexagonal-architecture에서 참고하였습니다. 해당 코드와 차이점은 엔티티와 도메인 모델을 분리하였으며 영속 어뎁터에서는 MongoDB가 아닌 Spring Data JPA를 사용하였습니다. 패키지 구조는 Get Your Hands Dirty on Clean Architecture 예제를 구현한 Github thombergs/buckpal를 따랐습니다.