헥사고날 아키텍처 적용하기
로직과의 사투/CS

헥사고날 아키텍처 적용하기

들어가며

우리가 개발하는 어플리케이션은 생애주기 간 필연적으로 수정된다. 기반 기술이 변경(이를테면, DB의 변경, Client 라이브러리의 변경 등)되기도 하며, 요구사항이 바뀌기도 한다. 그렇기에 핵심 코드는 견고하게 관리하고 주변 요소들의 수정엔 크게 영향을 받지 않는 설계가 필요하다. 이를 가능하게 해주는 것이 헥사고날 아키텍처 (Hexagonal Architecture) 또는 포트와 어댑터 아키텍처(Ports and Adapters Architecture)다.

대부분 레거시 코드의 경우 레이어드 아키텍처일 확률이 매우 높다. 레이어드 아키텍처는 이해하기 쉽고, 첫 개발의 시작 속도가 빠르단 장점이 있지만, 단점 또한 명확하다. 우선, DB Layer의 토대로 각 계층이 쌓이기 때문에 개발자로 하여금 데이터베이스 주도 개발을 하게 만든다. 요구사항을 받아들고 데이터베이스부터 그리게 되는 습관이 생기는 것이다. 또한, 상호 동일한 계층 또는 아래 계층의 컴포넌트에 손 쉽게 접근이 가능하기 때문에 상호 의존하는 코드가 늘어나게 된다. 이는 곧 전체 시스템을 복잡하게 엮이게 만들며 스파게티 코드라고 불릴만한 코드가 양산된다.

레이어드 아키텍처

또 하나의 극명한 단점은 요구사항이 생기고 바뀔 때마다 서비스 클래스의 코드에 땜질을하게 되는 것이다. 이렇게 서비스 클래스가 비대해져 시간이 지날수록 가독성은 떨어지고 테스트 코드를 쓰는 것 또한 불가능에 가깝게 만든다. 추가로, 인프라 관련 코드가 모두 엮여있기 때문에 인프라의 변경 시 대응하기 쉽지 않다.

그렇다면 대체 어떤 이점이 있길래 헥사고날 아키텍처는 이런 단점들을 극복하게 할 수 있는 걸까? 우선, 헥사고날 아키텍처는 철저히 DIP(의존성 역전)를 통해 이뤄진다. 각 계층에서 상위 계층은 하위 계층에 직접적으로 의존하지 않고 추상화된 계층에 의존하게 된다. 예를 들어, Data Access 계층이 추상화되어 있고 제공되는 인터페이스만으로 외부와 메시지를 주고 받는다면, 실제 DAO가 ORM 중 MyBatis, JPA, jOOQ 어느 것으로 구현되어 있는 지는 Service 계층에서 알 필요가 없다. 요청에 응답할 수 있는 메시지만 있으면 된다. 또한 도메인에 집중할 수 있게 하는 설계로 서비스 코드가 비대해지는 것을 막을 수 있다. 도메인 로직을 견고하게 유지해 주변 코드를 수정해도 코어 로직은 건드리지 않아도 된다.

헥사고날 아키텍처 (Hexagonal Architecture, there are always two sides to every story - Pablo martinez)

그렇다면 헥사고날 아키텍처를 구성하는 요소 중 포트와 어댑터는 어떤 것일까? 추상적이고 복잡해보일 순 있으나 간단하게 생각하면 쉽다. 포트는 인터페이스라고 생각하면 편하다. Java의 인터페이스로 구성되는 포트는 실제 어플리케이션 레벨에서 구현체가 아닌 인터페이스만을 참조하게 하여 코딩한다. 어댑터의 경우 디자인 패턴의 어댑터 패턴에서도 알 수 있듯 어떤 인터페이스를 클라이언트에서 요구하는 형태의 인터페이스에 적응시켜주는 역할을 한다. MVC 패턴을 적용했다면 일반적으로 Controller가 해당 역할을 하게 된다. 헥사고날 아키텍처가 적용된 프로젝트 구조 중 많은 프로젝트에서 Controller가 Adapter로 적용되는 이유가 이것이다.

외부에서 요청을 받아야만 동작하는 포트와 어댑터를 Primary 또는 Driving Side로 표현된다.

어플리케이션이 호출할 때 동작하는 포트와 어댑터를 Secondary또는 Driven Side로 표현된다.

구현

다소 추상적일 수 있는 설명은 두고 실제 패키지 구조 및 코드를 보면서 이해해 보자.

└── com
    └── kangfru
        └── portandadapter
            ├── refund
            │   ├── application
            |   |   ├── in
            |   |   |   ├── RefundUseCase.java
            |   |   ├── out
            |   |   |   ├── RefundOutPort.java
            │   ├── controller
            │   │   ├── RefundController.java
            │   ├── service
            │   │   ├── RefundServiceImpl.java
            │   ├── infrastructure
            │   |   ├── adapter
            │   │   │   ├── RefundAdapter.java
            │   |   ├── repository
            └────────────── RefundRepository.java (...etc)

우선 이해의 첫 출발선으로 도메인 모델부터 확인해보자.

public class RefundInfo {  
    // 가장 중요하게 사용될 도메인 모델로서 최대한 라이브러리를 배제하고 POJO를 이용해 만든다.  
    private final String someField;  
    .  
    .  
    .  
    .and so on  

    public static RefundInfo from(RefundRequest refundRequest) {  
        return RefundInfo.builder()
                .someField(refundRequest.getField())
                .build();
    }  

    public void validate() throws InvalidResourceException {  
        // some validate 
    }  

    public RefundInfo calculate() {  
        return RefundInfo.builder()
                .someFeild(this.someField)
                .needCalculateField(this.someNumberField.add(this.otherNumber))
    }  

}

주석으로 코멘트도 달아두었듯 도메인모델은 최대한 라이브러리(lombok 정돈 괜찮다.) 나 프레임워크 종속들을 최대한 배제하고 POJO로만 작성할 수 있게 한다. 앞서 언급하였듯 도메인 모델은 최대한 견고하게 유지하여야 한다. 그렇게 해야만 핵심 코드는 견고하게 관리하고 주변 요소들의 수정엔 크게 영향을 받지 않는 설계가 도출될 수 있다.

다음으로는 UseCase (Input port) 및 OutPort를 확인해보자.

public interface RefundUseCase {  

    RefundInfo refund(RefundRequest refundRequest);  

}
public interface RefundOutPort {  

    RefundInfo save(RefundInfo refundInfo);  

}

실제 비즈니스 코드에 비해 굉장히 간소화되어 있지만 이해하는덴 어려울 것이 없을 것이다. UseCase는 일반적으로 스프링의 MVC 패턴에서 Service 라고 이해하면 쉽다. UseCase의 정의는
유스케이스(use case)는 행위자(actor)가 관심을 가지고 있는 유용한 일을 달성하기 위한 시나리오의 집합을 명시한다.
위와 같으며 이에 따라 요구사항들을 적절하게 처리할 수 있는 인터페이스를 정의한다.

다음은 UseCase와 OutPort의 각 구현체를 확인해보자.

public class RefundServiceImpl implements RefundUseCase {  

    private final RefundOutPort refundOutPort;  

    @Override  
    public RefundInfo refund(RefundRequest refundRequest) throws Exception {  
        RefundInfo refundInfo = RefundInfo.from(refundRequest);  

        refundInfo.validate();  
        refundInfo.calculate();  

        return refundOutPort.save(refundInfo);  
    }  
}

RefundUseCase의 구현체로 RefundServiceImpl로 명명 지었으나, 명칭이 반드시 정답인 것은 아니다. 기존 레이어드 아키텍처에 익숙한 사람들에겐 Service란 명칭이 좀 더 직관적으로 와닿을 것이기 때문에 RefundServiceImpl로 작성했다. (실제 필자 역시 UseCase도 그저 Service란 명칭으로 interface 를 작성한다.)
각 UseCase의 요구사항에 맞는 메소드들을 오버라이드하여 작성한다.

아래는 OutPort를 구현한 Adapter로 실제 RefundServiceImpl 에서 사용할 땐 아래 RefundAdapter를 런타임 의존성에 의해 주입되게 한다. (Spring DI)

public class RefundAdapter implements RefundOutPort {  

    private final RefundRepository refundRepository;  

    @Override  
    public RefundInfo save(RefundInfo refundInfo) {  
        return refundRepository.save(RefundEntity.from(refundInfo)).toModel();  
    }  
}

지금까지의 프로젝트 구조를보면 아래와 같이 구성되었다.


점선은 계층 간의 경계를 나타낸다. 도메인으로 향하는 의존성만 존재하고 밖으로 나가는 의존성은 없는 것을 확인할 수 있다. 각 계층이 의존성 역전을 통해 interface만을 의존하고 있는 것을 확인할 수 있는데, 이는 테스트 코드를 작성함에 있어서도 특장점이 존재한다. Service 계층을 테스트한다고 할 때, RefundOutPort를 구현하는 mock 구현체를 따로 작성해 사용하면 손쉽게 비즈니스 로직에 대한 테스트를 진행할 수 있다. (앞선 글 - 의존성 역전을 통한 Mockito 프레임워크없이 단위 테스트 하기를 통해 확인할 수 있다.)
또한 테스트뿐만 아니라 인프라스트럭쳐가 변경될 때도 큰 장점이있다. 예를 들어 DB를 변경하게 되어 Oracle 에서 NoSql 로변경해야할 소요가 생겼다고 가정하면, RefundAdapter만 새로 만들어 (NoSqlRefundAdapter 등) 갈아끼우면 가장 중요한 도메인 로직은 전혀 건들이지 않고 infrastructure 를 변경할 수 있다.
신규 infrastructure가 추가될 때도 마찬가지로 사용할 수 있는데 예를 들어, 메시지 큐등이 추가된다고 가정하면 OutPort interface를 새로 만들어 도메인 로직의 변경 없이 메시지 큐를 추가할 수 있게 된다.

마치며

우리가 개발하는 어플리케이션은 생애주기간 반드시, 필히 변경된다. 그럴때마다 혼재되어 있는 서비스 코드 내에서 업무 로직을 찾아 헤매고 있다면, 헥사고날 아키텍처에 관심을 가지고 실무에 적용해볼만한 기회라고 생각한다. 헥사고날 아키텍처는 도메인 기반위에 쌓여 있기 때문에 어디를 수정해야할 지 손쉽게 파악할 수 있게 만든다. 또한, 기반 기술(Oracle, JPA, MySql 등)을 먼저 선택하고 개발하는 것이 아닌 어플리케이션의 설계 그 자체의 설계에 집중할 수 있게 만들어 견고하고 확장 용이한 아키텍처를 만들 수 있다. 관심을 가지고 사용한다면 필히 개발 인생에 도움이 될 것이다.


참고
지속 가능한 소프트웨어 설계 패턴: 포트와 어댑터 아키텍처 적용하기(https://engineering.linecorp.com/ko/blog/port-and-adapter-architecture)
프로젝트에 새로운 아키텍처 적용하기 (https://medium.com/naverfinancial/%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%83%88%EB%A1%9C%EC%9A%B4-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-99d70df6122b)
유일한 멀티모듈 헥사고날 아키텍처 : 메시지 허브 적용기 (https://tech.kakaobank.com/posts/2311-hexagonal-architecture-in-messaging-hub/)

'로직과의 사투 > CS' 카테고리의 다른 글

[Network] 자주 찾게 되는 네트워크 상식  (0) 2023.01.31
미들웨어와 WAS 그리고 Web Server  (0) 2021.08.09
빅오 표기법  (0) 2021.06.21