본문 바로가기
잡다구리

Hexagonal Architecture with Java and Spring

by Growing! 2022. 8. 13.

원문: https://reflectoring.io/spring-hexagonal/ (by Tom Hombergs)
(원문을 임의로 번역한 글입니다)

"육각형 아키텍처"라는 용어는 오래전부터 사용되어 왔습니다. https://alistair.cockburn.us/hexagonal-architecture/

그러나 이 아키텍처 스타일에서 애플리케이션을 실제로 구현하는 방법에 대한 리소스가 거의 없다는 것을 알았습니다. 이 기사의 목표는 Java 및 Spring을 사용하여 육각형 스타일로 웹 애플리케이션을 구현하는 나의 방법을 제공하는 것입니다.

이 주제에 대해 더 깊이 알고 싶다면 제 을 보세요.

Code Example

This article is accompanied by a working code example on GitHub.

What is “Hexagonal Architecture”?

일반적인 계층 아키텍처 스타일과 반대되는 "육각형 아키텍처"의 주요 특징은 구성 요소 간의 종속성이 "안쪽(inner)"인 도메인 개체를 향합니다.

육각형은 도메인 개체, 해당 개체를 조작하는 유즈 케이스, 외부 세계에 대한 인터페이스를 제공하는 입력 및 출력 포트로 구성된 애플리케이션 코어를 설명하는 멋진 방법일 뿐입니다.

이 아키텍처 스타일에서 각각의 개념을 살펴 보겠습니다.

Domain Objects

비즈니스 규칙이 풍부한 도메인에서 도메인 개체는 애플리케이션의 생명선입니다. 도메인 개체는 상태(state)와 동작(behavior)을 모두 포함할 수 있습니다. 동작과 상태가 가까이 있을수록 코드를 더 쉽게 이해하고 유추하고 유지 관리할 수 있습니다.

도메인 개체에는 외부로 향하는 종속성이 없습니다. 그것들은 순수한 Java이며 유즈 케이스가 도메인 객체를 조작(operate)할 수 있도록 API를 제공합니다.

도메인 개체는 응용 프로그램의 다른 계층에 의존하지 않으므로 다른 계층의 변경 사항이 도메인 객체에 영향을 미치지 않습니다. 종속성 없이 발전할 수 있습니다. 이것은 Single Responsibility Principle("SOLID"의 "S")의 대표적인 예입니다. 구성 요소는 변경되어야할 이유가 하나만 있어야 한다고 명시되어 있습니다. 도메인 개체가 변경되는 이유는 비즈니스 요구 사항의 변경입니다.

단일 책임을 가짐으로써 외부 종속성을 고려할 필요 없이 도메인 개체를 발전시킬 수 있습니다. 이러한 발전 가능성은 도메인 주도 설계를 적용할 때 육각형 아키텍처 스타일을 완벽하게 만들어줍니다. 개발하는 동안 우리는 종속성의 자연스러운 흐름을 따릅니다: 우리는 도메인 개체에서 코딩을 시작하고 거기에서 바깥쪽으로 나아갑니다. 도메인 주도가 아닌 경우에는 무엇일지 모르겠습니다.

Use Cases

우리는 유즈 케이스를 사용자가 우리 소프트웨어로 수행하는 작업에 대한 추상적인 설명으로 알고 있습니다. 육각형 아키텍처 스타일에서는 유즈 케이스를 코드베이스의 일급 시민으로 승격시키는 것이 맞습니다.

이러한 의미에서 유즈 케이스는 특정 유즈 케이스와 관련된 모든 것을 처리하는 클래스입니다. 예를 들어 은행 애플리케이션에서 "한 계정에서 다른 계정으로 송금" 유즈 케이스를 살펴보겠습니다. 사용자가 돈을 이체할 수 있도록 하는 고유한 API를 가지는 SendMoneyUseCase 클래스를 만듭니다. 이 코드에는 유즈 케이스와 관련된 모든 비즈니스 규칙 검사와 로직이 포함되어 있으므로 도메인 개체 내에서 구현할 수는 없습니다. 그 외의 다른 모든 것은 도메인 개체에 위임됩니다(예를 들어 도메인 개체인 Account가 있을 수 있습니다).

도메인 개체와 유사하게 유스 케이스 클래스는 외부 구성 요소에 대한 종속성이 없습니다. 육각형 외부에서 무언가가 필요할 때는 출력 포트를 만듭니다.

Input and Output Ports

도메인 개체 및 유즈 케이스는 육각형 내에 있습니다. 즉, 애플리케이션의 핵심 내에 있습니다. 외부와의 모든 통신은 전용 "포트"를 통해 이루어집니다.

입력 포트는 외부 구성 요소에서 호출할 수 있고 유즈 케이스에 의해 구현되는 간단한 인터페이스입니다. 이러한 입력 포트를 호출하는 구성 요소를 입력 어댑터 또는 "구동하는" 어댑터라고 합니다.

출력 포트는 외부에서 무언가가 필요한 경우(예: 데이터베이스 액세스), 유즈 케이스에서 호출할 수 있는 간단한 인터페이스입니다. 이 인터페이스는 유즈 케이스의 요구 사항에 맞게 설계되었지만 출력 또는 "구동되는" 어댑터라고 하는 외부 구성 요소에 의해 구현됩니다. SOLID 원칙에 익숙하다면 인터페이스를 사용하여 유즈 케이스에서 출력 어댑터로 종속성의 방향을 반전시키기 때문에 이것은 Dependency Inversion Principle(SOLID의 "D")의 적용입니다.

입력 및 출력 포트가 있으면, 데이터가 시스템에 들어오고 나가는 위치가 매우 뚜렷하므로 아키텍처에 대해 쉽게 추론할 수 있습니다.

Adapters

어댑터는 육각형 아키텍처의 외부 레이어를 형성합니다. 그것들은 코어의 일부가 아니지만 그것과 상호작용합니다.

입력 어댑터 또는 "구동하는" 어댑터는 입력 포트를 호출하여 작업을 수행합니다. 예를 들어 입력 어댑터는 웹 인터페이스가 될 수 있습니다. 사용자가 브라우저에서 버튼을 클릭하면 웹 어댑터가 특정 입력 포트를 호출하여 해당 유즈 케이스를 호출합니다.

출력 어댑터 또는 "구동되는" 어댑터는 유즈 케이스에 따라 호출되며 예를 들어 데이터베이스의 데이터를 제공할 수 있습니다. 출력 어댑터는 출력 포트 인터페이스의 집합을 구현합니다. 인터페이스는 유즈 케이스에 따라 결정되며 그 반대는 아닙니다.

어댑터를 사용하면 응용 프로그램의 특정 계층을 쉽게 교환할 수 있습니다. 응용 프로그램을 웹에 추가로 팻 클라이언트에서 사용할 수 있어야 하는 경우 팻 클라이언트 입력 어댑터를 추가합니다. 응용 프로그램에 다른 데이터베이스가 필요한 경우 이전 것과 동일한 출력 포트 인터페이스를 구현하는 새 영속성 어댑터를 추가합니다.

Show Me Some Code!

위에서 육각형 아키텍처 스타일에 대한 간략한 소개를 했으므로 이제 코드를 좀 살펴보겠습니다. 아키텍처 스타일의 개념을 코드로 변환하는 것은 항상 해석과 기호의 영향을 받으므로 다음 코드 예제를 주어진 대로 받아들이지 말고 대신 자신의 스타일을 만드는 데 영감을 주기 바랍니다.

코드 예제는 모두 GitHub의 "BuckPal" 예제 응용 프로그램에서 가져온 것이며 한 계정에서 다른 계정으로 돈을 이체하는 유즈 케이스를 중심으로 합니다. 일부 코드 스니펫은 이 블로그 게시물의 목적을 위해 약간 수정되었으므로 원본 코드에 대한 리포지토리를 살펴보세요.

Building a Domain Object

유즈 케이스를 제공하는 도메인 개체를 구축하는 것으로 시작합니다. 계정에 대한 출금 및 입금을 관리하는 Account 클래스를 만듭니다.

@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Account {

  @Getter private final AccountId id;

  @Getter private final Money baselineBalance;

  @Getter private final ActivityWindow activityWindow;

  public static Account account(
          AccountId accountId,
          Money baselineBalance,
          ActivityWindow activityWindow) {
    return new Account(accountId, baselineBalance, activityWindow);
  }

  public Optional<AccountId> getId(){
    return Optional.ofNullable(this.id);
  }

  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())
        .isPositiveOrZero();
  }

  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;
  }

  @Value
  public static class AccountId {
    private Long value;
  }

}

Account에는 해당 계정에 대한 출금 또는 입금을 나타내는 여러 관련 Activity들이 있을 수 있습니다. 주어진 계정에 대한 모든 활동을 항상 로드하고 싶지 않기 때문에 특정 ActivityWindow로 제한합니다. 계정의 총 잔액을 계속 계산할 수 있도록 Account 클래스에는 활동 창 시작 시간의 계정 잔액이 포함된 baselineBalance 속성이 있습니다.

위의 코드에서 볼 수 있듯이 아키텍처의 다른 계층에 대한 종속성이 완전히 없는 도메인 개체를 빌드합니다. 우리는 코드를 적합하다고 생각하는 방식으로 자유롭게 모델링할 수 있습니다. 이 경우 모델의 상태(state)에 매우 가까운 곳에 "풍부한" 동작(behavior) 코드가 존재하여 더 쉽게 이해할 수 있습니다.

원하는 경우 도메인 모델에서 외부 라이브러리를 사용할 수 있지만 이러한 종속성은 코드에 대한 강제 변경을 방지하기 위해 비교적 안정적이어야 합니다. 위의 경우 예를 들어 Lombok 애너테이션을 포함했습니다.

이제 Account 클래스를 사용하여 단일 계정으로 돈을 인출하고 입금할 수 있지만 두 계정 간에 돈을 이체하려고 합니다. 따라서 이를 조정하는 유즈 케이스 클래스를 만듭니다.

Building an Input Port

그러나 실제로 유즈 케이스를 구현하기 전에 해당 유즈 케이스에 대한 외부 API를 생성합니다. 이 API는 육각형 아키텍처의 입력 포트가 됩니다.

public interface SendMoneyUseCase {

  boolean sendMoney(SendMoneyCommand command);

  @Value
  @EqualsAndHashCode(callSuper = false)
  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();
    }
  }

}

이제 애플리케이션 코어 외부의 어댑터가 sendMoney()를 호출하여 이 유즈 케이스를 호출할 수 있습니다.

SendMoneyCommand 값 개체에 필요한 모든 매개 변수를 담았습니다. 이를 통해 값 개체의 생성자에서 입력 유효성 검사를 수행할 수 있습니다. 위의 예에서는 Bean Validation 애너테이션인 @NotNull을 사용했으며 이는 validateSelf() 메소드에서 검증됩니다. 이렇게 하면 실제 유즈 케이스 코드가 장황한 유효성 검사 코드로 오염되지 않습니다.

이제 이 인터페이스를 구현해야 합니다.

Building a Use Case and Output Ports

유즈 케이스 구현에서 우리는 도메인 모델을 사용하여 소스 계정에서 인출하고 대상 계정에 입금합니다.

@RequiredArgsConstructor
@Component
@Transactional
public class SendMoneyService implements SendMoneyUseCase {

  private final LoadAccountPort loadAccountPort;
  private final AccountLock accountLock;
  private final UpdateAccountStatePort updateAccountStatePort;

  @Override
  public boolean sendMoney(SendMoneyCommand command) {

    LocalDateTime baselineDate = LocalDateTime.now().minusDays(10);

    Account sourceAccount = loadAccountPort.loadAccount(
        command.getSourceAccountId(),
        baselineDate);

    Account targetAccount = loadAccountPort.loadAccount(
        command.getTargetAccountId(),
        baselineDate);

    accountLock.lockAccount(sourceAccountId);
    if (!sourceAccount.withdraw(command.getMoney(), targetAccountId)) {
      accountLock.releaseAccount(sourceAccountId);
      return false;
    }

    accountLock.lockAccount(targetAccountId);
    if (!targetAccount.deposit(command.getMoney(), sourceAccountId)) {
      accountLock.releaseAccount(sourceAccountId);
      accountLock.releaseAccount(targetAccountId);
      return false;
    }

    updateAccountStatePort.updateActivities(sourceAccount);
    updateAccountStatePort.updateActivities(targetAccount);

    accountLock.releaseAccount(sourceAccountId);
    accountLock.releaseAccount(targetAccountId);
    return true;
  }

}

기본적으로 유즈 케이스 구현은 데이터베이스에서 소스 및 대상 계정을 로드하고, 다른 트랜잭션이 동시에 발생하지 않도록 계정을 잠그고, 출금 및 입금을 수행합니다. 마지막으로 계정의 새 상태를 데이터베이스에 다시 기록합니다.

또 이 서비스를 실제 구현체에 대한 참조가 없어도 SendMoneyUseCase 입력 포트에 액세스해야 하는 모든 구성 요소에 주입할 수 있도록 @Component를 사용하여 Spring 빈으로 만듭니다.

데이터베이스에서 계정을 로드하고 저장하기 위해 구현체는 LoadAccountPortUpdateAccountStatePort 출력 포트 인터페이스에 의존합니다. 나중에 영속성 어댑터 내에서 구현합니다.

출력 포트 인터페이스의 모양은 유즈 케이스에 따라 결정됩니다. 유즈 케이스를 구현하면서 데이터베이스에서 특정 데이터를 로드해야 하는 경우가 있으므로 이에 대한 출력 포트 인터페이스를 만듭니다. 물론 이러한 포트는 다른 유즈 케이스에서 재사용될 수 있습니다. 우리의 경우 출력 포트는 다음과 같습니다.

public interface LoadAccountPort {

  Account loadAccount(AccountId accountId, LocalDateTime baselineDate);

}
public interface UpdateAccountStatePort {

  void updateActivities(Account account);

}

Building a Web Adapter

도메인 모델, 유즈 케이스, 입력 및 출력 포트를 통해 이제 애플리케이션의 코어(즉, 육각형 내의 모든 것)을 완료했습니다. 그러나 이 핵심은 외부 세계와 연결하지 않으면 쓸모가 없습니다. 따라서 REST API를 통해 애플리케이션 코어를 노출하는 어댑터를 빌드합니다.

@RestController
@RequiredArgsConstructor
public class SendMoneyController {

  private final SendMoneyUseCase sendMoneyUseCase;

  @PostMapping(path = "/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}")
  void sendMoney(
      @PathVariable("sourceAccountId") Long sourceAccountId,
      @PathVariable("targetAccountId") Long targetAccountId,
      @PathVariable("amount") Long amount) {

    SendMoneyCommand command = new SendMoneyCommand(
        new AccountId(sourceAccountId),
        new AccountId(targetAccountId),
        Money.of(amount));

    sendMoneyUseCase.sendMoney(command);
  }

}

Spring MVC에 익숙하다면 이것이 꽤 평범한 웹 컨트롤러라는 것을 알게 될 것입니다. request path에서 필요한 파라미터를 읽어 SendMoneyCommand에 넣고 유즈 케이스를 호출하기만 하면 됩니다. 더 복잡한 시나리오에서 웹 컨트롤러는 인증 및 권한 부여를 확인하고 JSON 입력에 대해 보다 정교한 매핑을 수행할 수도 있습니다.

위의 컨트롤러는 HTTP 요청을 유즈 케이스의 입력 포트에 매핑하여 우리의 유즈 케이스를 세상에 노출합니다. 이제 출력 포트를 연결하여 애플리케이션을 데이터베이스에 연결하는 방법을 살펴보겠습니다.

Building a Persistence Adapter

입력 포트는 유즈 케이스 서비스에 의해 구현되지만 출력 포트는 영속성 어댑터에 의해 구현됩니다. 코드베이스에서 영속성을 관리하기 위한 도구로 Spring Data JPA를 사용한다고 가정해 보겠습니다. 출력 포트 LoadAccountPortUpdateAccountStatePort를 구현하는 영속성 어댑터는 다음과 같을 수 있습니다.

@RequiredArgsConstructor
@Component
class AccountPersistenceAdapter implements
    LoadAccountPort,
    UpdateAccountStatePort {

  private final AccountRepository accountRepository;
  private final ActivityRepository activityRepository;
  private final AccountMapper accountMapper;

  @Override
  public Account loadAccount(
          AccountId accountId,
          LocalDateTime baselineDate) {

    AccountJpaEntity account =
        accountRepository.findById(accountId.getValue())
            .orElseThrow(EntityNotFoundException::new);

    List<ActivityJpaEntity> activities =
        activityRepository.findByOwnerSince(
            accountId.getValue(),
            baselineDate);

    Long withdrawalBalance = orZero(activityRepository
        .getWithdrawalBalanceUntil(
            accountId.getValue(),
            baselineDate));

    Long depositBalance = orZero(activityRepository
        .getDepositBalanceUntil(
            accountId.getValue(),
            baselineDate));

    return accountMapper.mapToDomainEntity(
        account,
        activities,
        withdrawalBalance,
        depositBalance);

  }

  private Long orZero(Long value){
    return value == null ? 0L : value;
  }

  @Override
  public void updateActivities(Account account) {
    for (Activity activity : account.getActivityWindow().getActivities()) {
      if (activity.getId() == null) {
        activityRepository.save(accountMapper.mapToJpaEntity(activity));
      }
    }
  }

}

어댑터는 출력 포트의 구현에 필요한 loadAccount()updateActivities() 메서드를 구현합니다. Spring Data Repository를 사용하여 데이터베이스에서 데이터를 로드하고 데이터베이스에 데이터를 저장하고, AccountMapper를 사용하여 Account 도메인 개체를 데이터베이스 내의 계정을 나타내는 AccountJpaEntity 개체에 매핑합니다.

다시 @Component를 사용하여 위의 유즈 케이스 서비스에 주입할 수 있도록 Spring 빈으로 만듭니다.

Is it Worth the Effort?

사람들은 종종 이런 아키텍처가 노력할 가치가 있는지 자문합니다(저는 여기에 포함됩니다). 결국, 우리는 포트 인터페이스를 생성해야 하고 도메인 모델의 여러 표현 사이를 매핑할 "x"가 있습니다. 웹 어댑터 내에 도메인 모델 표현이 있고 영속성 어댑터 내에 또 다른 표현이 있을 수 있습니다.

그래서 노력할 가치가 있을까요?

전문 컨설턴트로서 제 대답은 물론 "상황에 따라 다릅니다"입니다.

단순히 데이터를 저장하고 읽는 CRUD 애플리케이션을 구축하는 경우 이와 같은 아키텍처는 아마도 오버헤드일 것입니다. 상태와 동작을 결합하는 풍부한 도메인 모델로 표현할 수 있는 풍부한 비즈니스 규칙으로 애플리케이션을 구축하는 경우, 이 아키텍처는 도메인 모델을 요소들의 중심에 배치하기 때문에 정말 빛납니다.

Dive Deeper

위의 내용은 실제 코드에서 육각형 아키텍처가 어떻게 생겼는지에 대한 아이디어만 제공합니다. 이를 수행하는 다른 방법들이 있으므로 자유롭게 실험하고 당신의 필요에 가장 적합한 방법을 찾으십시오. 또한 웹 및 영속성 어댑터는 외부 어댑터의 예일 뿐입니다. 다른 서드 파티 시스템 또는 기타 사용자 대면 프론트엔드에 대한 어댑터가 있을 수 있습니다.

이 주제에 대해 더 자세히 알고 싶다면 훨씬 더 자세히 설명하고 테스트, 매핑 전략 및 지름길과 같은 항목에 대해 설명하는 내 을 살펴보십시오.

댓글