최근 항상 의문이었던 계층간 책임에 대해 자세히 알아보기 시작하였다.
전통적인 웹-도메인-영속성 구조에서는 한 계층의 변화가 다른 계층에도 영향을 끼칠 수 있다는 것을 알게 됐다. 예를 들어, 스프링 MVC 패턴에서 Controller에서 사용하는 DTO를 Service 계층의 메서드의 파라미터로 바로 보내게 되면 문제가 발생한다. 만약 다른 Controller가 같은 서비스를 사용한다면, Controller의 변경이 Service 계층에 영향을 끼치게 된다. 이는 애플리케이션의 유연성과 확장성을 떨어뜨린다.
또한, Controller에서 Repository에 직접 접근해도 되는지에 대한 명확한 가이드라인이 필요하다는 생각을 했다. 이런 질문들을 가지고 계층간 책임과 결합도에 대해 다시 생각해보게 됐고, 이 과정에서 헥사고날 아키텍처를 접하게 됐다.
헥사고날 아키텍처는 이런 문제에 대한 해결책을 제공한다. 각 계층이 본연의 책임을 가지고 독립적으로 동작하도록 설계되어 있어, 외부의 변화에 덜 민감하고 내부 로직의 변화가 외부에 영향을 미치는 것을 최소화한다. 이를 통해 유지보수와 확장성이 향상되고, 더 나은 소프트웨어 설계를 가능하게 한다.
따라서 해당 포스팅에서 헥사고날 아키텍쳐에서 어떠한 방식으로 계층간의 결합도는 떨어트리고 응집도를 높이는지에 대해 작성해보겠다.
우리는 여기서 포트와 어댑터에 대해 알아보겠습니다.
어댑터는 포트를 통해 애플리케이션 코어와 외부 세계를 연결한다. 어댑터는 특정 외부 기술이나 프레임워크에 의존적인 로직을 담당하며, 이를 통해 애플리케이션 코어는 외부와의 결합도를 최소화하고, 어댑터를 통한 교환 가능성을 확보한다.
어댑터도 두 가지 유형이 있다
인커밍 어댑터(Incoming Adapter, 주도하는)는 주로 사용자 인터페이스(UI), 테스트 또는 외부 시스템으로부터의 요청을 애플리케이션 코어로 주도하는데 사용된다.
아웃고잉 어댑터(Incoming Adapter, 주도되는)는 애플리케이션 코어에서 외부에 데이터를 전달하는 역할을 담당한다. 예를 들어, 데이터베이스에 데이터를 저장하거나 외부 시스템에 메시지를 전송하는 등의 역할을 한다.
포트는 애플리케이션 코어의 경계를 정의하며, 애플리케이션 코어가 제공해야 할 기능을 나타내며 어댑터를 통해 애플리케이션 코어에 접근하는 인터페이스이다.
포트는 두 가지 유형이 있다
인커밍 포트 (Incoming Port, 주도하는)는 외부 요청이 애플리케이션 코어로 들어오는 경로를 정의한다. 예를 들어, 웹 요청, GUI 이벤트, 스케쥴링 이벤트 등이 인커밍 포트를 통해 애플리케이션 코어로 들어올 수 있다.
아웃고잉 포트 (Outgoing Port, 주도되는)는 애플리케이션 코어가 외부 세계에 서비스를 제공하기 위한 경로를 정의한다. 예를 들어, 데이터베이스, 메시징 시스템, 웹 서비스 등에 데이터를 전송하거나 요청하는 경우에 사용한다.
헥사고날 아키텍쳐에서 외부 세계인 애플리케이션 코어(비즈니스 도메인 로직)의 밖에서의 접근은 어댑터를 통해 애플리케이션 코어의 경계인 포트를 통해 접근해야 한다.
책 "만들면서 배우는 클린 아키텍쳐의" 예제
패키지 구조만 보도록하자! adapter(in, out), application(port in, out), domain
@WebAdapter
@RestController
@RequiredArgsConstructor
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);
}
}
public interface SendMoneyUseCase {
boolean sendMoney(SendMoneyCommand command);
}
@Value
@EqualsAndHashCode(callSuper = false)
public
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();
}
}
헥사고날 아키텍쳐에서 유스케이스는 애플리케이션의 비즈니스 로직을 나타내며, 사용자나 시스템이 시스템에서 수행하려는 기능을 표현하는데, 이를 도메인 용어로 "유스케이스"라고 한다. service 의 구체적인 행위의 느낌이다.
위 코드를 살펴보면, 어댑터 패키지에 위치한 컨트롤러 객체는 도메인 로직을 담당하는 애플리케이션 코어에 접근하기 위해 포트 패키지에 위치한 포트 인터페이스를 통한다.
이 과정에서 컨트롤러는 포트가 사용하는 입력 객체로의 매핑 작업을 수행하며, 이 객체는 생성 시점에서 생성자를 통해 입력의 유효성을 검사한다.
외부 세계인 어댑터에서 애플리케이션 내부 세계로 들어가기 위해선 포트의 인터페이스를 사용하며 입력객체로의 매핑이 필요하다.
하지만 포트는 어디까지나 도메인 로직이 아닌, 애플리케이션 코어에 접근하는 인터페이스이다. 실제 구현체가 도메인 로직을 수행하는 애플리케이션 코어 내부에 위치한다. 즉, 어댑터는 특정 포트를 통해 애플리케이션 코어에 접근하고, 그 포트를 통해 실행하고자 하는 도메인 로직에 해당하는 구현체를 호출하게 된다. 따라서 이를 호출하는 어댑터는 애플리케이션 코어의 구체적인 사항을 알 필요 없이 사용하는 포트만 알면 된다.
이렇게 함으로써 헥사고날 아키텍처는 외부 요구사항(예를 들어 사용자 인터페이스 또는 데이터베이스 접근 방식)의 변경이 애플리케이션 코어 즉, 비즈니스 로직에 영향을 미치는 것을 방지한다. 그 결과, 각 계층은 자신의 책임에만 집중하면 되므로 결합도는 낮아지고 응집도는 높아지게 된다.
@RequiredArgsConstructor
@UseCase
@Transactional
public class SendMoneyService implements SendMoneyUseCase {
private final LoadAccountPort loadAccountPort;
private final UpdateAccountStatePort updateAccountStatePort;
@Override
public boolean sendMoney(SendMoneyCommand command) {
~~~ 생략
Account sourceAccount = loadAccountPort.loadAccount(
command.getSourceAccountId(),
baselineDate);
Account targetAccount = loadAccountPort.loadAccount(
command.getTargetAccountId(),
baselineDate);
~~~ 생략
//도메인 로직
if (!sourceAccount.withdraw(command.getMoney(), targetAccountId)) {
accountLock.releaseAccount(sourceAccountId);
return false;
}
~~~ 생략
updateAccountStatePort.updateActivities(sourceAccount);
updateAccountStatePort.updateActivities(targetAccount);
return true;
}
}
위 코드에서 ~Port 로 끝나는 필드가 아웃고잉 포트이며, 유스케이스에서 외부 세계인 DB 에 접근할 때 사용하는 인터페이스이다. 아래에서 설명할태니 꼭 기억하자!
public interface LoadAccountPort {
Account loadAccount(AccountId accountId, LocalDateTime baselineDate);
}
public interface UpdateAccountStatePort {
void updateActivities(Account account);
}
@RequiredArgsConstructor
@PersistenceAdapter
class AccountPersistenceAdapter implements
LoadAccountPort,
UpdateAccountStatePort {
private final SpringDataAccountRepository 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);
}
~ 생략
}
인커밍 포트의 구현체인 SendMoneyUsecase 는 두 개의 아웃고잉 포트 인터페이스를 의존하고 있다. 이처럼 유스케이스가 직접적으로 외부 세계에(DB, 외부 서비스) 의존하는 것이 아닌 결합도를 낮추기 위해 아웃고잉 포트를 사용한다.
또한 아웃고잉 어댑터를 보면 두 개의 아웃고잉 포트의 인터페이스를 구현하고 있다. 이는 ISP, 인터페이스 분리 원칙을 지킨것으로 볼 수 있다.
아웃고잉 포트는 애플리케이션 코어가 외부 세계에 데이터를 전달하거나 명령을 내리는 방법을 정의한다. 여기에는 데이터베이스로의 데이터 저장, 외부 API 호출 등이 포함된다. 포트는 이러한 연산을 추상화한 인터페이스로, 애플리케이션 코어는 이 인터페이스를 통해 필요한 기능을 사용한다.
애플리케이션 코어는 이러한 포트 인터페이스의 구현체를 사용하지 않는다. 대신 이 인터페이스를 구현하는 구체적인 클래스, 즉 '어댑터'가 필요하다. 이러한 어댑터는 외부 세계(예: 데이터베이스, 외부 서비스)와의 통신을 담당한다.
어댑터와 애플리케이션 코어 사이에 포트를 두는 것으로, 애플리케이션 코어는 외부 세계의 변화에 영향을 받지 않게 되고, 어댑터 역시 포트 인터페이스의 정의를 따르므로 결합도를 낮출 수 있다. 이러한 방식으로 헥사고날 아키텍처는 외부와의 통신 방법이 변경되어도 애플리케이션 코어의 코드는 변하지 않으며, 따라서 책임 분리와 응집도 높이는데 도움이 된다.
헥사고날 아키텍처는 인커밍 어댑터, 인커밍 포트, 유스케이스, 아웃고잉 포트, 아웃고잉 어댑터를 활용하여 계층 간의 결합도를 낮추고 응집도를 높이는 역할을 한다.
첫 번째로, 인커밍 어댑터는 애플리케이션 코어에 접근하는 방법을 정의한다. 그리고 이는 인커밍 포트를 통해 이루어진다. 인커밍 포트는 애플리케이션 코어와 인커밍 어댑터 간의 계약을 설정하며, 이를 통해 애플리케이션 코어는 어댑터의 구체적인 구현에 의존하지 않게 된다. 이는 결합도를 낮추는 데 기여한다.
두 번째로, 유스케이스는 비즈니스 로직으로 애플리케이션의 도메인 로직을 캡슐화한다. 유스케이스는 애플리케이션 코어에서 정의하고, 이 로직은 특정 UI, 데이터베이스, 프레임워크 등과는 독립적이어야 한다. 이는 코어 로직의 응집도를 높이고, 플랫폼과의 결합도를 줄이는 데 도움이 된다.
마지막으로, 아웃고잉 포트는 애플리케이션 코어가 외부 세계에 데이터를 전송하거나 명령을 내리는 방법을 정의한다. 아웃고잉 어댑터는 이 포트를 통해 외부와 통신하며, 이 방식으로 애플리케이션 코어는 외부 세계의 변화에 영향을 받지 않는다.
이런 방식으로 헥사고날 아키텍처는 애플리케이션의 계층 간의 결합도를 낮추고, 각 계층의 책임에 집중할 수 있게 응집도를 높인다. 이러한 특징은 헥사고날 아키텍처가 유연하고 확장 가능하며, 변경에 강한 애플리케이션을 구축하는 데 도움을 준다.
[티스토리]헥사고날 아키텍쳐란
[책]만들면서 배우는 클린 아키텍쳐
chat GPT