헥사고날 아키텍처 Hexagonal Architecture 란?
TIL

헥사고날 아키텍처 Hexagonal Architecture 란?

사이드 프로젝트를 하면서 헥사고날 아키텍처를 접하게 되었는데,
새로운 아키텍처를 사용하면서 이런 부분이 왜 개선이 되었구나를 직접 느끼게 되어
그 내용에 대해 정리해본다.

 

 

전통적인 웹-도메인-영속성 구조에서는 한 계층의 변화가 다른 계층에도 영향을 끼칠 수 있다. 사실 평소에 개발하면서 느꼈던 것이었다. 예를 들어 다른 Controller가 같은 Service를 사용하면 Controller의 변경이 Service 계층에 영향을 주면서 애플리케이션의 유연성과 확장성을 떨어뜨린다. 

전통적인 레이어드 아키텍처

 

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

클린아키텍처

클릭 아키텍처의 주요 개념
- 비즈니스 로직을 구현한 계층은 어떤 의존성도 가지지 않는 최상위 계층이어야 하며, 외부 계층의 변경에 영향을 받지 않아야 한다.
- 해당 계층으로의 입출력을 추상화 계층으로 감싸면, 모든 외부 의존성의 방향을 도메인 계층을 향하도록 만들 수 있다.
- 그로 인해 비즈니스 로직을 구현할때 외부 계층에 대한 결정(REST API, DB 등)을 미루거나 쉽게 변경할 수 있고, 비즈니스 로직 구현에 우선적으로 집중할 수 있다.
- 외부 계층의 상세 구현에 영향을 받지 않고 비즈니스 로직의 테스트가 가능하며, 모든 계층의 컴포넌트들은 독립적으로 각 계층의 역할에 맞는 테스트를 할 수 있다.

 

클린아키텍처를 구현한 헥사고날 아키텍처는 어떻게 이 문제를 해결하는지 코드와 함께 작성해본다.


헥사고날 아키텍처

소프트웨어 설계에 사용되는 아키텍처 패턴중 하나로 여러 소프트웨어 환경에 쉽게 연결할 수 있도록, 느슨하게 결합된 애플리케이션 구성요소를 만드는 것을 목표로 하는 아키텍처다. 사실 이렇게만 봤을 때는 크게 와닿지 않았는데, 이 구조로 직접 개발을 해보고 나니 왜 '느슨하게 결합된'이라고 하는지 명확하게 깨달을 수 있었다.

'느슨하게 결합'할 수 있게 해주는 데에는 헥사고날 아키텍처에 있는 '포트 Port'와 '어댑터 Adapter'라고 부르는 요소의 역할이 크다. 이 두개의 이름을 붙여 포트 & 어댑터 아키텍처라고도 부른다. 포트와 어댑터가 외부와 애플리케이션 코어를 연결하여 경계를 느슨하게 만든다.

헥사고날 아키텍처

 

어댑터(Adapter) 

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

  1. 인커밍 어댑터(Incoming Adapter): 사용자 인터페이스(UI), 테스트 또는 외부 시스템으로부터의 요청을 애플리케이션 코어로 주도하는데 사용. (ex) Spring web MVC의 Controller가 이 역할을 한다고 볼 수 있음
  2. 아웃고잉 어댑터(Outgoing Adapter): 애플리케이션 코어에서 외부에 데이터를 전달하는 역할을 담당. 예를 들어, 데이터베이스에 데이터를 저장하거나 외부 시스템에 메시지를 전송하는 등의 역할을 한다. (ex) Spring web MVC의 Repository가 이 역할을 한다고 볼 수 있음

 

포트(Port)

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

  1. 인커밍 포트(Incoming Port): 외부 요청이 애플리케이션 코어로 들어오는 경로를 정의. 예를 들어, 웹 요청, 스케쥴링 이벤트 등이 인커밍 포트를 통해 애플리케이션 코어로 들어올 수 있다. (ex) Spring web MVC의 Controller와 Service 사이의 인터페이스 (아래에서는 UseCase)
  2. 아웃고잉 포트(Outgoing Port): 애플리케이션 코어가 외부 세계에 서비스를 제공하기 위한 경로를 정의한다. 예를 들어, 데이터베이스, 메시징 시스템, 웹서비스 등에 데이터를 전송하거나 요청하는 경우에 사용한다. (ex) Spring web MVC의 Repository와 Service 사이의 인터페이스

코드

인커밍 어댑터

Controller에 해당. 인커밍 어댑터는 포트를 통해 도메인 로직 내부에 접근할 수 있음.

@RestController
@RequestMapping("/api/v1/member")
class MyAlilmHistoryContoller(
    private val myAlilmHistoryUseCase: MyAlilmHistoryUseCase
) {
    @GetMapping("/my-alilm-history")
    fun myAlilmHistory(
        @AuthenticationPrincipal customMemberDetails: CustomMemberDetails
    ) : ResponseEntity<MyAlilmHistoryResponse> {
        val result = myAlilmHistoryUseCase.myAlilmHistory(
        	MyAlilmHistoryUseCase.MyAlilmHistoryCommand(customMemberDetails.member)
        )
        val response = MyAlilmHistoryResponse.from(result)

        return ResponseEntity.ok(response)
    }
}

 

인커밍 포트

인터페이스인 유스케이스 

- 유스케이스(UseCase): 도메인 로직을 실행하는 코드의 집합으로, 인터페이스로 정의해 어떤 작업을 수행하고 어떤 결과를 반환해야 하는지에 대한 규칙을 정의한다
- 커맨드(Command): 유스케이스 메서드의 입력객체
interface MyAlilmHistoryUseCase {
    fun myAlilmHistory(command: MyAlilmHistoryCommand): List<MyAlilmHistoryResult>
}

 

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

인커밍 어댑터인 Controller 객체는 도메인 로직을 담당하는 애플리케이션 코어(Service)에 접근하기 위해 인커밍 포트 인터페이스를 통한다. 이 과정에서 컨트롤러는 포트가 사용하는 입력 객체(Command)로의 매핑 작업을 수행하며, 이 객체는 생성 시점에서 생성자를 통해 입력의 유효성을 검사한다.

 

어댑터인 Controller는 애플리케이션 코어의 구체적인 사항을 알 필요 없이 사용하는 포트만 알면 접근할 수 있다. 이렇게 함으로써 헥사고날 아키텍처는 외부 요구사항의 변경이 애플리케이션 코어 즉, 비즈니스 로직에 영향을 미치는 것을 방지한다. 그 결과, 각 계층은 자신의 책임에만 집중하면 되므로 결합도는 낮아지고 응집도는 높아지게 된다.


도메인 로직

서비스 코드

@Service
@Transactional
class MyAlilmHistoryService(
    val loadMyAlilmHistoryPort: LoadMyAlilmHistoryPort
) : MyAlilmHistoryUseCase {

    override fun myAlilmHistory(command: MyAlilmHistoryUseCase.MyAlilmHistoryCommand): List<MyAlilmHistoryUseCase.MyAlilmHistoryResult> {
        ...
    }

}

 

아웃고잉 포트

애플리케이션 코어에서 외부 세계로 향하는 통로

interface LoadMyAlilmHistoryPort {

    fun loadMyAlilmHistory(member: Member, dayLimit: Long): List<MyAlilmHistory>

    data class MyAlilmHistory(
        val alilm: Alilm,
        val product: Product
    ) {
        companion object {
            ...
        }
    }
}

 

아웃고잉 어댑터

@Component
class AlilmAndProductAdapter(
    private val alilmRepository: AlilmRepository,
    private val alilmMapper: AlilmMapper,
    private val productMapper: ProductMapper
) : LoadMyAlilmHistoryPort {

    override fun loadMyAlilmHistory(member: Member, dayLimit: Long): List<LoadMyAlilmHistoryPort.MyAlilmHistory> {
        return alilmRepository
               .findAlilmAndProductByMemberId(...)
               .map {
                   LoadMyAlilmHistoryPort.MyAlilmHistory(
                       alilm = alilmMapper.mapToDomainEntity(it.alilmJpaEntity),
                       product = productMapper.mapToDomainEntity(it.productJpaEntity)
                   )
               }
    }
}

 

✔️  아웃고잉 포트, 아웃고잉 어댑터는 어떻게 결합도를 낮췄을까?

인커밍 포트인 UseCase의 구현체(Service)는 한 개의 아웃고잉 포트 인터페이스를 의존하고 있다. 이처럼 어플리케이션 코어가 직접적으로 외부 세계(DB, 외부 서비스)에 의존하는 것이 아닌 결합도를 낮추기 위해 아웃고잉 포트를 사용한다.

 

또한 아웃고잉 어댑터를 보면 한 개의 아웃고잉 포트의 인터페이스를 구현하고 있다. 이는 ISP, 인터페이스 분리 원칙을 지킨 것으로 볼 수 있다.


헥사고날 아키텍처를 사용하고 느낀 장단점?

이렇게 헥사고날 아키텍처로 애플리케이션을 개발하면서 가장 크게 달라진 것은 API 하나를 개발할 때 일단 API request dto부터 만드는게 아닌 도메인 로직부터 고민한다는 점이었다. 어차피 클라이언트로부터 들어올 값들은 포트와 어댑터를 통해 도메인 로직에 크게 영향을 받지않고 세팅할 수 있다. 그 반대로 DB와 연결할 때도 마찬가지로, 테이블의 스키마가 당장 중요하지는 않게 느껴졌다. 도메인 모델과 로직에 좀 더 집중을 할 수 있는 것이다. 도메인을 우선적으로 고려하고, 그 다음 순서로 외부에서 어떻게 요청이 들어오고 DB를 어떻게 호출할지 고민하게 되었다.

 

또한, 테스트할 때 편리하다는 생각이 들었다. 포트는 인터페이스이기 때문에 쉽게 모킹이 가능했기 때문이다.

 

다른 기술블로그에서는 모듈을 교체할 때 큰 효과를 봤다고 설명한다. 새로운 ORM으로 바꿔끼울 때 그저 도메인 로직은 인터페이스인 Port만 의존하고 있었기 때문에 그 구현체만 바꿔끼우면 쉽게 교체가 가능하다고 한다.

모듈 교체하기 (네이버페이 공식 블로그)

 

물론 헥사고날 아키텍처가 무조건 좋다는 것은 아니다. 장점이 있는만큼 단점도 있다.

 

사용하면서 기존의 아키텍처보다 공수가 더 든다고 생각했던 점은 계층별로 사용할 모델을 너무 많이 필요로 한다는 것이었다. 네이버페이 공식 블로그에서도 이를 잘 설명해주고 있는데

  • 웹 계층: ProductResponse
  • 어플리케이션 계층: Product
  • 영속성 계층: ProductRow

위와 같이 계층별로 모델이 필요하다. 계층끼리 영향을 최대한 받지 않아야 하고 웹 계층의 모델이 바뀐다고해서 어플리케이션 계층의 모델이 바뀌면 안되기 때문이다. 그치만 이렇게 하면 만약 계층별 모델이 거의 비슷하다면 코드의 중복이 많다고 느껴질 수도 있고, 모델별로 변환하는 로직이 불필요하다는 생각이 들 수도 있다. 기존의 레이어드 아키텍처처럼 계층끼리 서로 공유하는 모델이 더 낫다고 느껴질 수도 있다. 그렇지만 그렇게 바꾸면 클린 아키텍처의 큰 의미인 의존성을 최소화하고, 외부의 영향을 받지 않도록 해야한다는 것이 사라진다는 생각이 들었다. 그럼 결국 클라이언트가 바뀌면 도메인 로직이 바뀔 수 있을 것이다.

 

 

Reference

https://blog.naver.com/naverfinancial/223155125321

 

프로젝트에 새로운 아키텍처 적용하기 | 네이버파이낸셜 기술블로그

네이버페이는 외부 쇼핑몰이 주문형페이의 시스템을 이용해서 결제 및 주문을 진행할 수 있는 프로세스를 ...

blog.naver.com

 

https://velog.io/@coconenne/%ED%97%A5%EC%82%AC%EA%B3%A0%EB%82%A0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%96%B4%EB%8C%91%ED%84%B0%EC%99%80-%ED%8F%AC%ED%8A%B8-%EA%B2%B0%ED%95%A9%EB%8F%84%EB%A5%BC-%EB%82%AE%EC%B6%B0%EB%B3%B4%EC%9E%90

 

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

학습 계기 최근 항상 의문이었던 계층간 책임에 대해 자세히 알아보기 시작하였다. 전통적인 웹-도메인-영속성 구조에서는 한 계층의 변화가 다른 계층에도 영향을 끼칠 수 있다는 것을 알게 됐

velog.io

 

728x90
반응형