헥사고날 아키텍처: 어댑터와 포트! 결합도를 낮춰보자

김상운(개발둥이)·2023년 7월 2일
2
post-thumbnail

학습 계기

최근 항상 의문이었던 계층간 책임에 대해 자세히 알아보기 시작하였다.

전통적인 웹-도메인-영속성 구조에서는 한 계층의 변화가 다른 계층에도 영향을 끼칠 수 있다는 것을 알게 됐다. 예를 들어, 스프링 MVC 패턴에서 Controller에서 사용하는 DTO를 Service 계층의 메서드의 파라미터로 바로 보내게 되면 문제가 발생한다. 만약 다른 Controller가 같은 서비스를 사용한다면, Controller의 변경이 Service 계층에 영향을 끼치게 된다. 이는 애플리케이션의 유연성과 확장성을 떨어뜨린다.

또한, Controller에서 Repository에 직접 접근해도 되는지에 대한 명확한 가이드라인이 필요하다는 생각을 했다. 이런 질문들을 가지고 계층간 책임과 결합도에 대해 다시 생각해보게 됐고, 이 과정에서 헥사고날 아키텍처를 접하게 됐다.

헥사고날 아키텍처는 이런 문제에 대한 해결책을 제공한다. 각 계층이 본연의 책임을 가지고 독립적으로 동작하도록 설계되어 있어, 외부의 변화에 덜 민감하고 내부 로직의 변화가 외부에 영향을 미치는 것을 최소화한다. 이를 통해 유지보수와 확장성이 향상되고, 더 나은 소프트웨어 설계를 가능하게 한다.

따라서 해당 포스팅에서 헥사고날 아키텍쳐에서 어떠한 방식으로 계층간의 결합도는 떨어트리고 응집도를 높이는지에 대해 작성해보겠다.


헥사고날 아키텍쳐란?

  • 소프트웨어 설계에 사용되는 아키텍처 패턴중 하나로 여러 소프트웨어 환경에 쉽게 연결할 수 있도록, 느슨하게 결합된 애플리케이션 구성요소를 만드는 것을 목표로 하는 아키텍처다.
  • 애플리케이션 코어를 외부 라이브러리 및 외부 세계로부터 분리 할 때 포트와 어댑터라고 부르는 인터페이스를 사용하기 때문에 포트&어댑터 아키텍처라고도 부릅니다
  • 육각형은 아무 의미가 없다. 중요한건 외부에서 도메인의 비즈니스 로직에 접근하기 위해서는 어댑터를 사용하여 포트 를 통해 접근하여야 한다.

우리는 여기서 포트와 어댑터에 대해 알아보겠습니다.

어댑터(adapter)?

어댑터는 포트를 통해 애플리케이션 코어와 외부 세계를 연결한다. 어댑터는 특정 외부 기술이나 프레임워크에 의존적인 로직을 담당하며, 이를 통해 애플리케이션 코어는 외부와의 결합도를 최소화하고, 어댑터를 통한 교환 가능성을 확보한다.

어댑터도 두 가지 유형이 있다

  • 인커밍 어댑터(Incoming Adapter, 주도하는)는 주로 사용자 인터페이스(UI), 테스트 또는 외부 시스템으로부터의 요청을 애플리케이션 코어로 주도하는데 사용된다.

    • spring web MVC 의 Controller
  • 아웃고잉 어댑터(Incoming Adapter, 주도되는)는 애플리케이션 코어에서 외부에 데이터를 전달하는 역할을 담당한다. 예를 들어, 데이터베이스에 데이터를 저장하거나 외부 시스템에 메시지를 전송하는 등의 역할을 한다.

    • spring web MVC 의 JPA Repository

포트(port)?

포트는 애플리케이션 코어의 경계를 정의하며, 애플리케이션 코어가 제공해야 할 기능을 나타내며 어댑터를 통해 애플리케이션 코어에 접근하는 인터페이스이다.

포트는 두 가지 유형이 있다

  • 인커밍 포트 (Incoming Port, 주도하는)는 외부 요청이 애플리케이션 코어로 들어오는 경로를 정의한다. 예를 들어, 웹 요청, GUI 이벤트, 스케쥴링 이벤트 등이 인커밍 포트를 통해 애플리케이션 코어로 들어올 수 있다.

    • spring web MVC 의 Controller 와 service 사이의 인터페이스
  • 아웃고잉 포트 (Outgoing Port, 주도되는)는 애플리케이션 코어가 외부 세계에 서비스를 제공하기 위한 경로를 정의한다. 예를 들어, 데이터베이스, 메시징 시스템, 웹 서비스 등에 데이터를 전송하거나 요청하는 경우에 사용한다.

    • spring web MVC 의 JPA Repository 와 service 사이의 인터페이스

헥사고날 아키텍쳐에서 외부 세계인 애플리케이션 코어(비즈니스 도메인 로직)의 밖에서의 접근은 어댑터를 통해 애플리케이션 코어의 경계인 포트를 통해 접근해야 한다.


코드로 알아보자😀!

책 "만들면서 배우는 클린 아키텍쳐의" 예제
패키지 구조만 보도록하자! adapter(in, out), application(port in, out), domain

  • 흐름의 순서 대로 설명하겠다.
  • 구체적인 코드를 보기보다는 전체적인 구조를 보도록하자!

인커밍 어댑터

  • controller 에 해당하며, 도메인 로직에서는 외부 세계에 해당한다.
  • 이 어댑터는 포트를 통해 도메인 로직 내부에 접근할 수 있다.
@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);
	}

}

인커밍 포트

  • 유스 케이스(service)의 인터페이스
  • SendMoneyUseCase 는 인커밍포트로, 어댑터가 어플리케이션 코어에 접근하는 인터페이스
public interface SendMoneyUseCase {

	boolean sendMoney(SendMoneyCommand command);

}
  • 유스 케이스 메서드의 입력 객체 SendMoneyCommand
@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 의 구체적인 행위의 느낌이다.

인커밍 어댑터, 포트는 어떻게 결합도를 낮췄을까?

위 코드를 살펴보면, 어댑터 패키지에 위치한 컨트롤러 객체는 도메인 로직을 담당하는 애플리케이션 코어에 접근하기 위해 포트 패키지에 위치한 포트 인터페이스를 통한다.

이 과정에서 컨트롤러는 포트가 사용하는 입력 객체로의 매핑 작업을 수행하며, 이 객체는 생성 시점에서 생성자를 통해 입력의 유효성을 검사한다.

외부 세계인 어댑터에서 애플리케이션 내부 세계로 들어가기 위해선 포트의 인터페이스를 사용하며 입력객체로의 매핑이 필요하다.

하지만 포트는 어디까지나 도메인 로직이 아닌, 애플리케이션 코어에 접근하는 인터페이스이다. 실제 구현체가 도메인 로직을 수행하는 애플리케이션 코어 내부에 위치한다. 즉, 어댑터는 특정 포트를 통해 애플리케이션 코어에 접근하고, 그 포트를 통해 실행하고자 하는 도메인 로직에 해당하는 구현체를 호출하게 된다. 따라서 이를 호출하는 어댑터는 애플리케이션 코어의 구체적인 사항을 알 필요 없이 사용하는 포트만 알면 된다.
이렇게 함으로써 헥사고날 아키텍처는 외부 요구사항(예를 들어 사용자 인터페이스 또는 데이터베이스 접근 방식)의 변경이 애플리케이션 코어 즉, 비즈니스 로직에 영향을 미치는 것을 방지한다. 그 결과, 각 계층은 자신의 책임에만 집중하면 되므로 결합도는 낮아지고 응집도는 높아지게 된다.

유스케이스(인커밍 포트의 구현체)

  • 유스케이스는 도메인 로직을 실행하는 코드의 집합으로, 어떤 작업을 수행하고 어떤 결과를 반환해야 하는지에 대한 규칙을 정의한다.
  • 도메인 로직 실행을 wrapping 하고 있다.
    • 도메인 로직: 특정 비즈니스 영역의 규칙을 나타낸다. 은행 잔고가 양수일 때만 돈을 뽑을 수 있는 규칙이 그 예다. 이 규칙들은 주로 도메인 모델을 통해 구현된다.
    • 비즈니스 로직: 애플리케이션의 비즈니스 규칙을 구현한다. 예컨대 사용자의 인출 요청을 받아 도메인 로직으로 유효성을 체크하고, 결과를 데이터베이스에 저장하고 사용자에게 반환하는 전체 과정을 다룬다.
@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

profile
공부한 것을 잊지 않기 위해, 고민했던 흔적을 남겨 성장하기 위해 글을 씁니다.

0개의 댓글