본 포스트는 Tom Hombergs님께서 2019년 reflectoring.io에 기고하신 "Hexagonal Architecture with Java and Spring"을 번역합니다.
오역, 의역이 있을 수 있으니 흐름만 대강 훑어보시고 원본 글을 천천히 다시 읽어 보시는걸 추천드립니다.
또한 제가 영어가 좀 짧아서(...) 제대로 번역하지 못할 것 같은 부분은 그냥 원문을 남겨 놓겠습니다. 빠이팅!
"Hexagonal Architecture"(이하 헥사고날 아키텍처)라는 용어는 오래전부터 사용되어 왔습니다. 이 주제에 대한 초기 자료가 한동안 잊혀져 있다가 최근에야 아카이브에서 복구될 정도로 오래되었습니다.
하지만 이 아키텍처 스타일로 애플리케이션을 실제로 구현하는 방법에 대한 자료가 거의 없다는 것을 알게 되었습니다. 이 포스트의 목표는 자바/스프링을 사용하여 헥사고날 아키텍처 스타일로 웹 애플리케이션을 구현하는 방법을 소개하는 것입니다.
이 주제에 대해 더 자세히 알아보고 싶으시다면, 제 책을 읽어 보시는걸 추천드립니다.
이 포스트에는 Github에 업로드 된 예제 코드가 포함되어 있습니다.
일반적인 계층형 아키텍처와 달리 헥사고날 아키텍처는 구성 요소간의 종속성이 도메인 객체를 향해, 즉 "안쪽"을 향해 있습니다.
여기서 "헥사고날"(=육각형)은 도메인 객체, 객체를 조작하는 Use case(이하 유즈케이스), 외부에 인터페이스를 제공하는 입출력 포트로 구성된 애플리케이션의 핵심을 설명하기 위해 사용된 단어입니다.
지금부터 이 아키텍처 스타일을 구성하는 각각의 요소들을 하나씩 살펴 보겠습니다.
비즈니스 규칙이 풍부한 도메인에서 도메인 객체는 애플리케이션의 혈액과 같은 존재입니다. 이 도메인 객체는 상태(state)와 동작(behavior)을 모두 포함할 수 있습니다. 또한, 동작이 상태에 가까울수록 코드를 이해하고, 추론하고, 유지 관리하기가 더 쉬워집니다.
도메인 객체는 애플리케이션의 다른 계층에 대한 종속성을 가지고 있지 않으므로, 다른 계층의 변경 사항이 도메인 객체에 영향을 미치지 않습니다. 따라서 종속성과 상관 없이 기능이 추가, 삭제, 변경될 수 있습니다. 이는 구성 요소를 변경해야 할 이유가 단 한가지만 있어야 한다는 단일 책임 원칙("SOLID"의 "S", Single Responsibility Principle)의 대표적인 예시로 볼 수 있습니다. 도메인 객체의 경우 객체를 수정할 이유가 비즈니스 요구 사항의 변경 단 하나 뿐이어야 합니다.
단일 책임 원칙을 지킴으로써 외부 종속성을 고려하지 않고도 도메인 객체를 발전시킬 수 있습니다. 이러한 진화 가능성 덕분에 헥사고날 아키텍처 스타일은 Domain-Driven Design(도메인 중심 설계, 이하 DDD)에 적합합니다. 개발하는 동안 종속성의 자연스러운 흐름을 따라 도메인 객체에서 코딩을 시작하고 거기서부터 외부로 확장해 나가면 됩니다. 이게 Domain-Driven이고, 다입니다.
유즈 케이스는 사용자가 소프트웨어로 무엇을 하고 있는지에 대한 추상적인 명세를 의미합니다. In the hexagonal architecture style, it makes sense to promote use cases to first-class citizens of our codebase.
이러한 의미에서 유즈 케이스는 특정 사용 사례와 관련된 모든 것을 처리하는 클래스입니다. 은행 애플리케이션에서 "한 계좌에서 다른 계좌로 송금"하는 유즈 케이스를 예로 들어 보겠습니다. 이 경우 사용자가 송금할 수 있는 고유한 API가 있는 SendMoneyUseCase
클래스를 만들 수 있습니다. 이 코드에는 해당 사용 사례에 특정한 모든 비즈니스 규칙, 유효성 검사, 로직 등이 포함되어 있으므로 이러한 기능들은 도메인 객체 안에 구현할 수 없습니다. 물론 앞서 언급된 것들 이외의 모든 것은 도메인 객체에 위임됩니다. (예를 들어 계좌를 의미하는 도메인 객체 Account
가 있을 수 있음)
도메인 객체와 마찬가지로, 유즈 케이스 클래스는 외부 컴포넌트에 대한 종속성이 없습니다. 아키텍처의 육각형 외부에서 뭔가 필요한 경우 출력 포트를 만듭니다.
도메인 객체와 유즈 케이스는 육각형 안, 즉 애플리케이션의 코어 안에 있습니다. 외부와의 모든 통신은 전용 "포트"를 통해 이루어집니다.
입력 포트는 외부 컴포넌트에서 호출할 수 있고, 유즈 케이스에 의해 구현되는 간단한 인터페이스입니다. 이러한 입력 포트를 호출하는 컴포넌트를 입력 어댑터, 또는 "driving" 어댑터라고 합니다.
출력 포트는 유즈 케이스가 외부의 무언가를 필요로 할 때(데이터베이스 접근 등) 호출할 수 있는 인터페이스입니다. 이 인터페이스는 유즈 케이스의 요구 사항에 맞게 설계되었지만, 출력 또는 "driven" 어댑터라고 하는 외부 구성 요소에 의해 구현됩니다. 인터페이스를 사용하며 유즈 케이스에서 출력 어댑터 방향으로 종속성을 반전시키기 때문에 이를 SOLID의 D, Dependency Inversion Principle(의존성 역전 원칙)의 적용으로 볼 수도 있습니다.
입출력 포트의 존재로 인해 데이터가 시스템에 들어오고 나가는 지점이 매우 명확하여 아키텍처를 쉽게 추론할 수 있습니다.
어댑터는 헥사고날 아키텍처의 외부 레이어를 형성합니다. 이는 코어의 일부는 아니지만, 코어와 상호 작용합니다.
입력 어댑터(="dirving" 어댑터)는 입력 포트를 호출하여 작업을 수행합니다. 예를 들어 입력 어댑터는 웹 인터페이스가 될 수 있습니다. 사용자가 브라우저에서 버튼을 클릭하면 웹 어댑터가 특정 입력 포트를 호출하여 해당 유즈 케이스를 호출합니다.
출력 어댑터(="driven" 어댑터)는 유즈 케이스에 의해 호출되며, 예를 들어 데이터베이스에서 데이터를 읽어 오는 등의 작업을 수행하는 등의 일을 합니다. 출력 어댑터는 일련의 출력 포트 인터페이스를 구현합니다. 인터페이스는 유즈 케이스에 의해 결저오디는 것이지, 그 반대로 이해하지 않도록 주의해야 합니다.
어댑터를 사용하면 애플리케이션의 특정 계층을 쉽게 바꿀 수 있습니다. 애플리케이션을 웹 외에 fat client(서버 애플리케이션에 매우 의존적인 컴퓨터라곤 하는데,,)에서도 사용할 수 있어야 하는 경우 팻 클라이언트를 위한 입력 어댑터를 추가하면 됩니다. 반대로 애플리케이션에 다른 종류의 데이터베이스가 필요한 경우, 기존 데이터베이스와 동일한 출력 포트 인터페이스를 구현하는 새로운 영속성 어댑터를 추가하면 됩니다.
이제 헥사고날 아키텍처 스타일에 대한 간략한 소개를 마치고, 마지막으로 몇 가지 코드를 살펴보겠습니다. 아키텍처 스타일의 개념을 코드에 녹이는 것은 항상 해석과 취향에 따라 그 결과가 달라질 수 있으므로, 다음 코드 예제를 그대로 받아들이지 마시고 자신만의 스타일을 만드시는데 집중하시기 바랍니다.
아래의 코드 예제는 모두 Github의 "BuckPal" 예제 애플리케이션에서 가져온 것으로, 한 계좌에서 다른 계좌로 돈을 이체하는 사용 사례를 중심으로 구성했습니다. 일부 코드 조각은 이 블로그 포스트의 목적에 따라 약간 수정되었으므로, 원본 코드는 리포지토리를 참조하세요.
먼저 유즈 케이스에서 사용할 도메인 객체를 만들어 보겠습니다. 계좌에 대한 입출금을 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 mauWithdraw(Money money) {
return Momey.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;
}
}
이 계좌에는 각각 해당 계좌에 대한 입출금을 나타내는 등의 많은 Activity
와 연관되어 있습니다. 특정 계좌에 대한 모든 활동(Activity
)을 항상 로드하고 싶지는 않으므로, ActivityWindow
를 통해 일부만 확인합니다. 그래도 계정의 총 잔액을 계산할 수 있어야 하므로, Account
클래스에는 activity window의 시작 시간의 계좌 잔액을 포함하는 baselineBalance
속성을 가지고 있습니다.
위의 코드에서 볼 수 있듯, 아키텍처의 다른 계층에 대한 전혀 없는 도메인 객체를 만들었습니다. 우리는 적합하다고 생각되는 방식으로 코드를 자유롭게 모델링할 수 있습니다. 이 경우 이해하기 쉽도록 모델의 상태에 매우 가까운 "rich" behavior를 생성했습니다. (We're free to model the code how we see fit, in this case creating a "rich" behavior that is very close to the state of the model to make it easier to understand.)
필요한 경우 도메인 모델에 외부 라이브러리를 사용할 수 있기는 하지만, 이러한 종속성은 코드의 강제 변경을 방지하기 위해 비교적 안정된 것을 사용해야 합니다. 위의 경우, 예를 들어 롬볼 어노테이션을 포함했습니다.
이제 Account
클래스를 통해 단일 계좌로 돈을 인출하고 입금할 수 있지만, 두 계정 간에 돈을 이체할 수 있도록 확장하고 싶습니다. 따라서 이를 위한 유즈 케이스를 만들어 보겠습니다.
하지만 실제 유즈 케이스를 개발하기 전에, 해당 유즈 케이스에 대한 외부 API를 생성하는 것으로 시작하며 이는 곧 헥사고날 아키텍처의 입력 포트에 해당됩니다.
public interface SendMoneyUseCase {
boolean sendMoney(SencMoneyCommand 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
값 객체에 모아 두었습니다. 이를 통해 value object의 생성자에서 입력 유효성 검사를 수행할 수 있습니다. 위의 예제에서는 유효성 검사 메서드인 validateSelf()
메서드에서 유효성을 검사하는 Bean 유효성 검사 어노테이션 @NotNull
을 사용하기도 했습니다. 이렇게 하면 실제 유즈 케이스 코드가 노이즈가 많은 유효성 검사 코드로 오염되지 않습니다.
이제 이 인터페이스에 대한 구현을 시작해 보겠습니다.
유즈 케이스 구현에서는 도메인 모델을 사용하여 source 계좌에서 인출하고, target 계좌로 입금하는 로직을 작성합니다.
@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 (@sourceAccount.deposit(command.getMoney(), sourceAccountId)) {
accountLock.releaseAccount(sourceAccountId);
return false;
}
updateAccountStatePort.updateActivities(sourceAccount);
updateAccountStatePort.updateActivities(targetAccountId);
return true;
}
}
기본적으로 유즈 케이스의 구현은 데이터베이스에서 source 계좌로 target 계좌를 로드하고, 다른 트랜잭션이 동시에 발생하지 않도록 계좌에 lock을 걸고, 출금/입금을 수행한 다음, 마지막으로 계정의 새 상태를 데이터베이스에 다시 기록합니다.
또한 @Component
를 사용하여 이 서비스를 Spring bean으로 만들어 실제 구현에 대한 종속성 없이 SendMoneyUseCase
입력 포트에 액세스 해야 하는 모든 구성 요소에 주입할 수 있습니다.
데이터베이스에서 계좌를 로드하고 저장하기 위한 구현은 나중에 영속성 어댑터 내에서 구현할 인터페이스인 출력 포트 LockAccountPort
및 UpdateAccountStatePort
에 따라 달라집니다.
출력 포트 인터페이스의 형태는 유즈 케이스에 따라 결정됩니다. 유즈 케이스를 작성하는 동안 데이터베이스에서 특정 데이터를 로드해야 할 수 있으므로 이에 대한 출력 포트 인터페이스를 만듭니다. 물론 이러한 포트는 다른 유즈 케이스에서 재사용될 수 있습니다. 이 경우 출력 포트는 다음과 같습니다.
public interface LoadAccountPort {
Account loadAccount(AccountId accountId, LocalDateTime baselineDate);
}
public interface UpdateAccountStatePort {
void updateActivities(Account account);
}
도메인 모델, 유즈 케이스, 입출력 포트 순서로 애플리케이션의 핵심(즉, 육각형 안의 모든 것)을 완성했습니다. 하지만 이 코어를 외부 세계와 연결하지 않으면 아무 소용이 없습니다. 따라서 REST API를 통해 애플리케이션 코어를 노출하는 어댑터를 개발해 보겠습니다.
@RestController
@RequiredArgsConstructor
public class SendMoneyController {
private final SendMoneyUseCase sendMoneyUseCase;
@PostMapping(path = "/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}")
void sendMoneu(
@PathVariable("sourceAccountId") Long sourceAccountId,
@PathVariable("targetAccountId") Long targetAccountId,
@PathVariable("amount") Long amount) {
SendMoneyCommand command = new SendMoneyCommad(
new AccountId(sourceAccountId),
new AccountId(targetAccountId),
Money.of(amount));
sendMoneyUseCase.sendMoney(command);
}
}
Spring MVC에 익숙하다면 이것이 흔히 보던 웹 컨트롤러임을 알 수 있을 것입니다. 요청 경로에서 필요한 매개 변수를 읽고 SendMoneyCommand
에 넣은 다음, 유즈 케이스를 호출하기만 하면 됩니다. 좀 더 복잡한 시나리오에서는 웹 컨트롤러가 인증 및 권한 부여를 확인하고 JSON 입력에 대한 보다 정교한 매핑을 수행할 수도 있습니다.
위의 컨트롤러는 HTTP 요청을 유즈 케이스의 입력 포트에 매핑하여 유즈 케이스를 외부에 노출합니다. 이제 출력 포트를 연결하여 애플리케이션을 데이터베이스에 연결하는 방법을 살펴봅시다.
입력 포트가 유즈 케이스 서비스에 의해 구현되는 반면, 출력 포트는 Persistence Adapter(이하 영속성 어댑터)에 의해 구현됩니다. 코드 베이스에서 영속성을 관히하기 위해 선택한 도구로 Spring Data JPA를 사용한다고 가정해 보겠습니다. 출력 포트 LoadAccountPort
및 UpdateAccountStatePort
를 구현하는 지속성 어댑터는 다음과 같이 작성할 수 있습니다.
@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,
LoadDateTime 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
를 사용하여 계정 도메인 객체를 데이터베이스 내의 계정을 나타내는 AccountJpaEntity
객체로 매핑합니다.
이번에도 마찬가지로, @Component
를 사용하여 위의 유즈 케이스 서비스에 주입할 수 있는 Spring bean으로 만들었습니다.
저를 포함한 많은 사람들은 종종 이와 같은 아키텍처를 만들기 위해 노력하는게 가치있는 일인지 고민하곤 합니다. 결국 포트 인터페이스부터 만들어야 하고, 도메인 모델을 여러 상황에 맞춰 매핑해주어야 하는 등의 수고로움이 따르기 때문입니다. 웹 어댑터 내에 도메인 모델을 표현하기 위한 객체가 있을 수도 있고, 영속성 어댑터 안에 또 다른 도메인 모델의 표현이 필요할지도 모릅니다.
그래서, 도입해볼만한 가치가 있을까요?
전문 컨설턴트로서 제 대답은, 당연히 "상황에 따라 다르다" 입니다.
단순히 데이터를 저장하는 CRUD 애플리케이션을 구축하는 경우 이와 같은 아키텍처는 오버헤드가 될 수 있습니다. 반면 상태와 동작을 결합하는 풍부한 도메인 모델로 표현할 수 있는 풍부한 비즈니스 규칙이 포함된 애플리케이션을 개발하려는 경우, 이 아키텍처는 도메인 모델을 중심에 두기 때문에 그 장점이 빛을 발합니다.
위의 내용은 실제 코드에서 헥사고날 아키텍처가 어떻게 적용될 수 있을지에 대한 아이디어 수준까지만 설명하고 있습니다. 물론 다른 방법이 있을 수 있으므로 이것 저것 시도해 보시고, 요구 사항에 가장 맞는 방법을 찾아 나가시기를 바랍니다. 또한 웹 어댑터와 영속성 어댑터는 외부에 대한 어댑터의 한 예제일 뿐입니다. 다른 타사의 시스템이나 기타 사용자에게 노출되는 프론트엔드에 대한 어댑터도 있을 수 있습니다.
이 주제에 대해 더 자세히 알아보고 싶으시다면, 테스트, 매핑 전략 및 shortcut에 대해 더 자세히 설명하는 제 책을 한 번 읽어보시길 바랍니다.