본문 바로가기
개인공부

헥사고날 아키텍처 개념 및 사용법

by 리승우 2023. 5. 5.

헥사고날 아키텍처란?

주요 목표

  • 응용 프로그램의 비즈니스 로직을 외부 세계로부터(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

댓글