youngseo's TECH blog

[책 리뷰] MVC 패턴에서 벗어난, Hexagonal 아키텍처 본문

회고

[책 리뷰] MVC 패턴에서 벗어난, Hexagonal 아키텍처

jeonyoungseo 2025. 2. 14. 20:13

만들면서 배우는 클린 아키텍처 책을 읽고 적은 내용입니다.

Book Contents

전통적인 계층형 아키텍처(layered architecture, 소위 MVC 패턴) 스타일과 이 스타일의 단점을 논하는 것에서부터 시작하여, 도메인 중심 아키텍처의 장점을 설명하고 있다. 아키텍처의 경계를 강제하는 방법과 아키텍처가 주는 이점을 배울 수 있다. 


Layered Architecture의 모습

우리가 알고있는 가장 대표적인 계층형(Layered) 아키텍처의 모습이다.

MVC 패턴

 |-- domain
    |  |
    |  |-- Account
    |  |-- Activity
    |  |-- AccountRepository
    |  |-- AccountService
    |
    |--persistence
    |  |-- AccountRepositoryImpl
    |
    |--web
       |-- AccountController

Layered Architecture가 가진 문제

  •  데이터베이스 주도 설계를 유도한다. 웹 계층은 도메인/비즈니스 계층에 의존하고, 비즈니스 계층은 영속성 계층에 의존하고, 영속성 계층은 DB에 의존하게 되어 결국 DB에 의존도가 높아지게 된다.
  • 특정 계층을 선택하면 그 위, 아래로만 접근 가능하다.
  • usecase를 숨긴다. 도메인 로직이 여러 계층에 걸쳐 흩어지기 쉬워서 적당한 위치를 찾기 어렵다.
  • 따라서 테스트 코드 작성도 어렵고, 개발자들의 동시작업도 어려워진다.

대안

그렇다면, 뭘 어떻게 해결해야 하는 것일까? 아래 두 관점을 기준으로 해결할 수 있다.

  • SRP(단일 책임 원칙, Single Responsibility Principle)
    • 하나의 컴포넌트는 오로지 하나의 일만 해야 하고, 그것을 올바르게 수행해야 한다.
    • 컴포넌트를 변경하는 이유는 오직 하나 뿐이어야 한다.
  • DIP(의존성 역전 규칙, Dependency Inversion Principle)
    • 코드상의 어떤 의존성이든 그 방향을 바꿀 수 (역전 시킬 수) 있어야 한다.
    • 그 말은 즉, A → B 의 의존성을 B → A 로 바꿀 수 있어야 함을 의미한다.

클린 아키텍처 (by. 로버트 마틴)에 의하면, 이 두 관점을 충족하기 위해서는 모든 의존성이 도메인 로직을 향해 안쪽 방향으로 향해야 한다. 이에 대한 구체적인 모델인 헥사고날 아키텍처에 대해 알아보자.


Hexagonal(육각형) 아키텍처

육각형 아키텍처는 애플리케이션 코어가 각 어댑터와 상호 작용하기 위해 특정 포트를 제공하기 때문에 ‘포트와 어댑터’ 아키텍처 라고도 불린다.

위 그림에서 볼 수 있듯이, 육각형에서 외부로 향하는 의존성이 없기 때문에 로버트 마틴이 클린 아키텍처에서 제시한 의존성 규칙이 그대로 적용된다. 크게 어댑터 / 어플리케이션 (포트와 유스케이스) / 엔티티 으로 나누어져 있다.

아래 코드 예시를 통해 더 직관적으로 파악해볼 수 있다.

account
    |-- adapter
    |   |
    |   |-- in
    |        |-- web
    |             |-- AccountController
    |    
    |   |-- out
    |        |-- persistence
    |             |-- AccountPersistenceAdapter
    |             |-- SpringDataAccountRepository
    |
    |-- domain
    |   |
    |   |-- Account
    |   |-- Activity
    |   |-- Application
    |        |-- SendMoneyService (SendMoneyUseCase에 대한 구현)
    |        |-- port
    |             |-- in
    |             |    |-- SendMoneyUseCase (interface)
    |             |-- out
    |                  |-- LoadAccountPort (interface)
    |                  |-- UpdateAccountStatePort (interface)

UseCase 구현하기

  • usecase는 신성하다.
  • usecase는 domain(entity)와 가장 가깝게 맞닿아있다. 외부 시스템(DB, 외부 API 등)과는 독립적으로 비즈니스 로직을 담는, 이 아키텍처의 핵심이 되는 곳이다.
  • 되도록, usecase 마다 서로 다른 입출력 모델을 가져야 한다.
    • 유스케이스 간 입출력 모델을 공유하게 되면 유스케이스들 간에 결합이 생길 수 있다. 이러한 공유로 인한 영향은 유스케이스로 넘기는 데이터 클래스가 변경되면, 두 유스케이스 모두 영향을 받는다는 특징이 있다. 그러면 ‘변경할 이유’를 공유하게 되므로 SRP 에 반하게 된다.

Port 구현하기

  • 위 그림에서 어뎁터가 유스케이스를 바로 호출할 수도 있을 것이다. 물론 가능하다. 그런데 port 라는 간섭 계층이 이 둘 사이에 존재한다.
    • port 계층은, 애플리케이션 코어가 외부 세계와 통신할 수 있는 곳에 대한 명세로, 포트를 통해 우리는 외부와 어떤 통신이 이루어지는지 알 수 있다. 개발자들끼리의 소통을 위한 하나의 수단이 될 수도 있을 것이다.
  • port 는 인터페이스 형태로 구현한다. application 레벨의 핵심은 in/out 어댑터에 의존성을 갖지 않는 것이다. 따라서 인터페이스를 이용해 비즈니스 로직에서 외부 시스템과의 상호작용을 추상화한다.

Adapter 구현하기

  • MVC 아키텍처에서 Controller에 있을 법한, 입력값 유효성 검증 로직(ex. email 양식 검증)은 usecase에서 처리하지 않는다. usecase는 domain(entity)과 가장 가까운 로직이기 때문에, domain 스키마로 문제없이 변환할 수 있을 정도의 상태로 받아와야 한다. 따라서 입력값 유효성 검증 로직은 port~adapter 구간에서 수행한다.
  • adapter는 크게 두 개 (in port와 out port)로 나뉜다. port명에서도 알 수 있듯이, 밖으로 나가는 것과 안으로 들어오는 것을 의미한다. 
    • 웹 어댑터 (보통 in port 사용) = http 요청
      • HTTP 요청을 자바 객체로 매핑
      • 권한 검사 / 인증 / 입력 유효성 검증
      • 유스케이스 호출
    • 영속성 어댑터 (보통 out port 사용) = DB 연결
      • 도메인 엔티티를 DB 모델로 변환 및 저장.
      • 데이터 조회, 업데이트, 삭제
  • adapter 도 어찌되었던 usecase와 비슷하게, 웬만하면 잘 쪼개는 것이 좋다.

Domain(Entity) 구현하기

  • 엔티티(Entity)와 값 객체(Value Object)를 통해 도메인을 표현한다.
  • 앞에서 입출력 검증은 usecase가 아닌 adapter에 해야 한다고 했다. 그러면 비즈니스 로직 검증은 어떻게 구현할까? usecase에 구현해야 한다고 해도, domain의 속성 값이 필요하게 되므로 이런 부분은 domain entity에서 처리해도 좋다.

테스트

  • 크게 시스템 테스트 > 통합 테스트 > 단위 테스트 으로 나뉜다.
  • 도메인 엔티티 테스트
    • 단위 테스트를 이용해서 도메인 엔티티에 녹아 있는 비즈니스 규칙을 검증
  • 유스케이스 테스트
    • 트랜잭션이 성공했을 때 모든 것이 기대한 대로 동작하는지 검증
    • 이 때 유스케이스는 상태가 없기 때문에(stateless) then 섹션에서 특정 상태를 검증할 수 없다. 따라서 서비스가 (모킹된) 의존 대상의 특성 메서드와 상호작용했는지 여부를 검증한다.
    • 그래서 모든 동작 검증하는 대신에 중요한 핵심만 골라 집중해서 테스트하는 것이 좋다.
    • 테스트가 또 너무 많으면 클래스가 조금이라도 바뀔 때마다 테스트를 변경해야 한다.
  • 어댑터 테스트
    • 통합 테스트가 적절하다.

아키텍처 경계 강제

  • 아키텍처 경계를 강제한다는 것은 의존성이 올바른 방향을 향하도록 강제하는 것을 의미한다.
  • 가장 단순한 방법으로는 접근 제한자 (private, public, protected, package-private(default))를 쓸 수 있을 것이다.
    • 예를 들면 default 제한자 자바 패키지들을 통해 클래스들을 응집적인 ‘모듈’로 만들어준다. 이러한 모듈 내에 잇는 클래스들은 서로 접근 가능하지만, 패키지 바깥에서는 접근할 수 없다.
    • 따라서 모듈의 진입점으로 활용될 클래스들만 public 으로 만들면 된다. 자료구조적으로 설명하면, In-node 만 통과할 수 있도록 만들면 된다.

 


회고

직접 헥사고날 아키텍처로 기능을 개발하며 느낀 것은, 이전부터 습관처럼 고착되어 있던 프로그래밍 설계 구상의 방향성이 바뀌었다는 것이다. 이전에는 MVC의 Controller -> Service -> Repository 순서로 http 요청이 들어오는 순서대로 구현했다면, 헥사고날 아키텍처를 사용하고 나서는 의식적으로 usecase 부터 도메인 주도로 개발하게 되는 것이 신기했다. 그러면 자연스레 헥사고날 계층 간의 의존도를 낮추는 프로그래밍을 하게 된다. 
물론, 헥사고날에도 한계가 존재한다. 일예로, 이런 일이 있었다. port는 interface이므로 adapter의 의존성이 최대한 배제되도록 구현해야 한다. 이 때 'gradle 라이브러리'를 사용하는 것은 외부 라이브러리이므로, (external system) adapter로 구현을 하게 되었고, 이 때 연동되는 out port의 interface 파라미터에 의도치 않게 라이브러리 인자가 필요했던 상황이 존재했다. gradle 의존도를 interface에서 끌고 오는 것이 아키텍처가 의도한 바는 아니지만, 라이브러리 내용을 모조리 구현하는 데에는 한계가 있어 결국 port에도 gradle 의존성을 추가할 수 밖에 없었다.. 현업과 관련한 글을 살펴봐도, kakao pay 헥사고날 아키텍처 도입기를 보면 오히려 Hexagonal Architecture가 제공하는 구조적 장점보다, 운영과 개발 과정에서의 비효율성이 더 컸다는 사실을 볼 수 있었는데, 이런 부분들도 존재할 수 있으니 유념해 사용하는 게 좋을 것 같다.

 

 

추가적으로 참고하면 좋을 아티클

- https://reflectoring.io/spring-hexagonal/

- https://reflectoring.io/spring-boot-gradle-multi-module/