헥사고날 아키텍처란?
주요 목표
- 응용 프로그램의 비즈니스 로직을 외부 세계로부터(Infra) 격리시켜 유연하고 테스트하기 쉬운 구조를 만드는 것 ⇒ 이를 위해 핵심 비즈니스 로직은 중앙의 도메인 영역에 위치하며, 입력과 출력을 처리하는 포트와 어뎁터를 통해 외부와(Infra) 소통
장점
- 유연성 외부 시스템이나 인프라와의 의존성을 낮춰, 구성 요소를 쉽게 교체하거나 업데이트 할 수 있음
- 테스트 용이성 비즈니스 로직을 독립적으로 테스트할 수 있어, 품질 향상과 개발 속도 향상에 도움이 됨
- 유지보수성 책임이 분리되어 있어, 코드의 이해와 수정이 용이하며 변화에 빠르게 대응가능
나오게 된 배경
- 레이어드 아키텍처의 단점을 해결하기 위해, 의존성의 역전법칙을 필두로 나오게 되었음
- 레이어드 아키텍처 구조도
- 의존성이 위에서 아래
- 계층을 내려갈수록 하위 계층에 의존적인 구조를 띈다.
이때 각 계층 사이에 interface를 추가한 후, 컨트롤러는 서비스 인터페이스를, 서비스는 레포지토리 인터페이스를 바라보도록 수정한다.
- 의존성 역접법칙을 활용한 아키텍처 구조도
그리고 모든 비즈니스 로직을 위와 같이 수정하면 아래와 같아지는데 컨트롤러와 레포지토리는 Adapter로, 각 인터페이스는 Port로 명칭이 바뀐다.
해당 형태를 각 계층마다 적용 시, 아래와 같은 헥사고날 아키텍쳐의 형태를 띄게 됨
여기서 중요한 것은 서비스 계층은 유즈케이스라는 이름으로 각각의 포트로 부터 보호된다.
즉 헥사고날 아키텍쳐는 서비스 계층, 비즈니스 로직을 외부 변화로부터 보호하는 아키텍쳐라고 볼 수 있다.
위 그림을 자세히 보면 헥사고날 아키텍처를 포트&어뎁터 패턴이라 말하는 이유가 보일 것임
- 의존성은 내부에서 외부로 나가지 않음. 외부 어뎁터에서 내부 애플리케이션 코어로는 포트를 통해서만 접근가능함
- 애플리케이션 코어와 어뎁터 간의 통신이 가능하려면, 애플리케이션 코어가 각각의 포트를 제공
- 주도하는 어뎁터에게는 해당 포트가 코어에 있는 유즈케이스 클래스에 의해 구현되어 호출되는 인터페이스가 될 것이며,
- 주도되는 어뎁터에게는 그러한 포트가 어뎁터에 의해 구현되고 코어에 의해 호출되는 인터페이스가 될 것임
패키지 구조로 바라보았을 때, 아래와 같이 나누어진다.
- Adapter
ㄴ in
ㄴ out
-Application
ㄴ port
ㄴ service
- Domain
위와 같은 구조를 가짐으로써,
아키텍처의 가장 가운데 있는 도메인 모델은 외부를 향한 의존성을 가지지 않는다. 즉, 특정 기술에 종속적이지 않게 된다.
코드로 이해해보는 헥사고날 아키텍처 예시
- 간단한 입출금을 REST API로 수행하는 헥사고날 아키텍처
1. 도메인 모델
도메인 모델에는 출금(withdraw), 입금(deposit) 이라는 비즈니스 규칙이 담겨있음
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;
}
}
2. 포트
- 외부 어뎁터에서 헥사고날 내부 애플리케이션 코어에는 포트를 통해서만 접근가능함
- 내부에서 외부를 향하는 의존성은 없기에, 모든 의존성은 중앙을 향하게 됨
Input Port (입금, 출금 인터페이스)
public interface DepositUseCase {
void deposit(Long id, BigDecimal amount);
}
public interface WithdrawUseCase {
boolean withdraw(Long id, BigDecimal amount);
}
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, SaveBankAccount를 사용하여 데이터를 영속화함
- 들어오는 포트 인터페이스 DepositUsecase, WithdrawUseCase를 구현하여 웹 어뎁터인 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;
}
}
본 코드에서는 Output Port(loadAccountPort, saveAccountPort)에서 엔티티를 도메인 모델로 변환해서 반환하였습니다.
@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();
}
}
4. 어뎁터
- 애플리케이션 코어에 들어오고, 나가는 포트를 호출하는 역할
- 어뎁터는 포트를 통하지 않으면 애플리케이션 코어에 접근할 방법이 없음, 오로지 포트를 통해서 제공되는 메서드를 이용해야만 내부 코어에 접근할 수 있음
웹 어뎁터
- 애플리케이션 코어에 들어오는 포트를 구현하는 BankAccountController
@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);
}
}
영속 어뎁터
- 영속 어뎁터에서 Spring Data JPA를 이용하여 DB에 접근함
- Mapper를 이용하여 BankAccountEntity를 도메인 모델로 변환함
- 헥사고날 아키텍처 밖으로 나가는 포트인 LoadAccountPort, SaveAccountPort를 구현하여 DB에서 조회, 저장을 구현함
@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);
}
}
최종 패키지 구조
계층 구조
- 계층형 구조에서 발생하는 비즈니스 로직과 DB가 강하게 결합되는 문제를 피함
⇒ 느슨한 결합을 유지할 수 있게되어, 변경에 유연하게 대응하며 테스트를 쉽게 할 수 있는 환경 구성함
위와 같이 모두 마쳤으면 이제 명확한 관심사의 분리가 된 것입니다.
추후 아래와 같은 상황이 발생했을 때마다 정해진 부분만 수정하면 되는 구조가 완성됩니다
- 외부와의 연결에 문제가 생기면? ⇒ 어뎁터
- 인터페이스에 문제가 생기면? ⇒ 포트
- 처리 중간에 이벤트를 보내거나 트레이스 로그를 심고 싶다면? ⇒ 서비스
- 비즈니스 로직이 제대로 동작하지 않으면? ⇒ 도메인 모델
'개인공부' 카테고리의 다른 글
Redis 개념 및 사용법 (0) | 2023.06.03 |
---|---|
자동 build Tool별 특징 및 장단점 (0) | 2023.05.20 |
Dockerfile, Docker-compose 특징 및 차이 (0) | 2023.04.18 |
Multimodule(멀티모듈) 개념 및 사용법 (0) | 2023.04.14 |
jib란? (0) | 2023.04.04 |
댓글