안녕하세요, 카카오뱅크 고객인증개발팀 서버개발자 Kj입니다.

저는 지난 9월 15일, 카카오뱅크 사내 기술 컨퍼런스인 Kode Runner에서 ‘유일한 멀티모듈 헥사고날 아키텍처: 메시지 허브 적용기’라는 제목으로 발표를 했었는데요. 기존의 관점과 달리 멀티모듈로 헥사고날 아키텍처(Hexagonal Architecture)가 적용되었다는 점에서 많은 분들이 흥미로워 하셨습니다.

올 4월에 메시지 허브 시스템을 개발하고 4~5개월 동안 운영하면서 직접 몸소 체험한 경험을 바탕으로, 멀티모듈 헥사고날 아키텍처를 적용하며 얻은 장점과 경험을 이 글을 통해 소개드리고자 합니다.

헥사고날 아키텍처의 특징은?

hexagonal-architecture-image
소프트웨어 아키텍처

헥사고날 아키텍처는 계층형 아키텍처로, 시스템을 유연하고 확장 가능하게 만드는 장점이 있습니다.

기본적으로 3가지 구성 요소로 이루어져 있습니다.

  1. 프라이머리 어댑터(driving adapter): 외부 요청을 받아들이고 코어 컴포넌트를 호출합니다.
  2. 코어 컴포넌트(application core): 시스템의 비즈니스 로직을 수행하고, 세컨더리 어댑터를 이용해서 데이터베이스에 저장하거나 외부 시스템과의 연동을 진행합니다.
  3. 세컨더리 어댑터(driven adapter): 외부 리소스나 데이터베이스와의 상호작용을 담당합니다.

헥사고날 아키텍처는 클린 아키텍처를 추구합니다. 저수준 컴포넌트(코어)가 변경되더라도 고수준 컴포넌트(어댑터)에 영향이 없도록 설계되어 있습니다. 또한 포트 앤 어댑터 방식으로 인터페이스와 구현체를 분리하여 다양한 서버나 인프라의 연결을 쉽게 구성할 수 있다는 장점을 가지고 있습니다.

메시지 허브 시스템에 헥사고날 끼얹기

message-hub-system-role-image
메시지 허브 시스템의 역할

메시지 허브 시스템에 대해 간략하게 설명드리겠습니다.

카카오뱅크는 약관 수정에 따른 고지나 26주 적금 관련 푸시 알림 등 다양한 형태의 메시지와 알림을 고객에게 보냅니다. 메시지나 알림의 종류에 따라 보내야 할 대상, 메시지 내용 그리고 발송 채널 등이 달라집니다. 이러한 다양한 요구사항을 유기적으로 연결하여 안정적으로 메시지를 발송하기 위한 미들웨어 성격의 시스템이 바로 메시지 허브 시스템입니다.

앞서 설명드린 것처럼 다양한 서비스로부터 대상 고객을 추출하고 해당 고객에게 보낼 메시지와 발송 채널을 정의한 후 UMS(Unified Messaging System)를 통해 메시지를 발송하고 있습니다.

message-hub-system-architecture-image
메시지 허브 시스템의 구조

메시지 허브 시스템은 총 4개의 서비스로 구성되어 있습니다.

  1. api: 전반적인 컨트롤 타워 역할을 하며 이벤트 버스를 통해 로더, 컨슈머에 데이터를 전달합니다.
  2. data-api: 다양한 데이터 소스에 접근하여 이벤트를 발생시키는 역할을 합니다.
  3. loader: 발생된 이벤트를 구독하여, 고객정보 조회 시스템을 통해 고객 정보를 가공하고, 다음 목적지인 메시지 큐에 전달합니다.
  4. consumer: 메시지 큐를 소비하고 최종 목적지인 UMS로 전달합니다.

이와 같이, 이 시스템은 목적에 따라서 4개의 마이크로 서비스하나의 도메인으로 구성되어 있으면서, 다양한 인프라를 사용하고 있어서, 헥사고날 아키텍처를 적용하기 좋은 구조였습니다.

message-hub-hexagonal-architecture-image
헥사고날 아키텍처로 본 메시지 허브 시스템의 구조

직면한 어려움

1개의 헥사고날로 4개의 서비스를 구성하기는 어렵지 않았습니다. 포트 앤 어댑터 방식으로 구성했기 때문입니다.

하지만 이 구조에서는 스케일 아웃 전략을 어떻게 수립해야 할지 고민이 되었습니다. 모든 서비스(api, data-api, loader, consumer)에서 데이터베이스로 접근하게 되었고, 커넥션 풀이 제한된 환경이어서 무작정 스케일 아웃을 할 수 없었습니다. 또한 event broker에서 수신된 메시지는 모든 서비스(api, data-api, loader, consumer)에서 수신되게 되어, 중복된 메시지 처리를 해야 하거나 loader에서만 수신하게 만들기 위해서는 추가 작업이 필요해졌습니다.

일반적으로 많은 분들이 이 시점에서 각각의 헥사고날을 구성하는 걸 먼저 떠올리셨을 겁니다. 이렇게 개별적인 헥사고날 구성할 경우, 비즈니스 로직과 세컨더리 어댑터의 중복이 발생합니다. 이 과정에서 개발과 운영에 많은 리소스를 필요로 하게 됩니다.

이러한 문제점을 해결하기 위해서 저는 하나의 프로젝트, 하나의 소스에서 멀티 헥사고날을 만드는 방향으로 고민하였습니다.

“메시지허브에 어울리는 헥사고날로 만들어보자"

멀티모듈 헥사고날로 리팩토링하다

Step1. 멀티모듈로 분리

먼저 소스를 공유하는 형태를 구성하기 위해서 한 프로젝트 내의
패키지 구조를 여러 모듈로 분리해 냈습니다. 그 결과, 프라이머리 어댑터 모듈, 코어 모듈, 그리고 세컨더리 어댑터 모듈로 구분하게 되었습니다. 각 모듈에 대해 설명드리면 다음과 같습니다.

  • 프라이머리 어댑터 모듈은 그 자체로 서비스를 구성하는 역할을 하게 되며, 외부에 요청을 수행하는 프라이머리 어댑터로 총 4개의 모듈(api, data-api, loader, consumer) 각각이 서비스가 된 것입니다.

  • 코어 모듈은 포트 양쪽의 인터페이스와 실제 비즈니스 로직을 하나의 모듈로 구성했습니다.

  • 세컨더리 어댑터는 실제 포트 아웃에 해당하는 구현체를 모듈화 했습니다.

이렇게 함으로써 헥사고날의 구성이 패키지에서 모듈로 변경되었고, 3개의 논리적인 모듈이 1개의 헥사고날을 만드는 형태가 되었습니다. 해당 형태를 구성하기 위한 자세한 내용은 이어진 Step2,3에서 설명드리겠습니다.

multimodule-hexagonal-architecture-image
메시지허브 멀티모듈 헥사고날 구조

Step2. 멀티 모듈 의존성

패키지에서 모듈로 변경하면서, 헥사고날은 의존성 설정이 필요해졌습니다. 프라이머리 어댑터는 서비스의 주체가 됐고, 서비스별로 사용하는 코어 로직과 세컨더리 어댑터가 프로젝트 성격에 의해 추가되어야 합니다.

  • 즉, 1개의 프라이머리 어댑터 + 1개의 코어 모듈 + 1개 이상의 세컨더리 어댑터로 = 1개의 헥사고날을 만들게 되는 형태입니다.

멀티모듈로 헥사고날을 빌드하기 위해 프라이머리 어댑터에서는 프로젝트(컴포넌트 스캔) 범위를 설정하게 됩니다. 이때 1개의 코어모듈과 어떤 세컨더리 어댑터를 사용할지 선택하고, 의존성을 추가합니다. 또한 세컨더리 어댑터 모듈도 빌드를 위해 코어 모듈 의존성을 추가합니다.

고수준의 모듈이 저수준의 모듈의 변경에 영향을 받는 것은 클린 아키텍처에 위반되지만, 의존성을 추가하는 것은 클린 아키텍처에 위반이 되지 않습니다. 따라서 클린 아키텍처에 기반한 시스템을 구성하기 위해서는 프라이머리 어댑터에서 코어 모듈 인터페이스만 호출해야 하며, 프라이머리 어댑터에서 세컨더리 어댑터로 직접 호출해서는 안됩니다.

@SpringBootApplication
@EnableJpaAuditing
@EnableAsync
@EnableScheduling
@ComponentScan(
    basePackages = {
      "demo.api",
      "demo.core",
      "demo.redis",
      "demo.persistence"
    },
    basePackageClasses = {
      KafkaModule.class
    }
)

public class ApiApplication {
  public static void main(String[] args) { SpringApplication.run(ApiApplication.class, args); 
  }
}
project(':demo-api'){
    dependencies{
        implementation project(':demo-core')
        implementation project(':demo-infra-redis')
        implementation project(':demo-infra-jpa')
        implementation project(':demo-infra-kafka')
    }
}
project(':demo-infra-redis'){
    dependencies{
        implementation project(':demo-core')
    }
}
project(':demo-infra-jpa'){
    dependencies{
        implementation project(':demo-core')
    }
}
project(':demo-infra-kafka'){
    dependencies{
        implementation project(':demo-core')
    }
}

Step3. 선택적 빌드

코어모듈에서는 서비스별로 사용하는 세컨더리 어댑터를 프로젝트 범위에 포함하도록 설정해야 합니다. 이를 위해서는 다음과 같은 2가지 선택지가 있었습니다.

  1. 빌드 타임 방식: 사용하지 않는 코어모듈과 세컨더리 어댑터를 제거해서 빌드
  2. 런 타임 방식: Lazy loading을 통해서 사용하지 않는 어댑터를 로딩하지 않는 방법

no-use-secondary-adapter-image
사용하지 않는 세컨더리 어댑터 제거

금융서비스 운영 환경에서는 배포를 쉽게 할 수 없어서 런타임에 확인하는 것보다 빌드타임에 확인하는 것이 안전하다고 판단했습니다. 따라서 빌드 시 사용하는 코어 로직과 어댑터를 프로젝트 범위에 포함하는 방식을 선택했습니다.

이를 위해 서비스별 설정 파일에 각각의 서비스 이름(api, data-api, loader, consumer)을 명명했습니다. 그리고 코어 모듈에서는 ConditionalOnProperty 어노테이션과 서비스명을 이용해서 선택적으로 빌드할 수 있도록 구조화했습니다.

service-name-setting-image
서비스별 설정 파일에 서비스 이름(api, data-api, loader, consumer) 설정

// 1. 빌드 타임 방식
@RequiredArgsConstructor
@ConditionalOnExpression
        ("'${spring.application.name}'.equals('api') "
            + "|| '${spring.application.name}'.equals('consumer') "
            + "|| '${spring.application.name}'.equals('data-loader')")
@Service
public class RedisCoreService implements RedisUseCase {
  ...
}
// 2. 런 타임 방식
@ComponentScan(
    basePackages = {
        "com.kakaobank.kabangplace.core"
    },
    lazyInit = true
)
public interface CoreModule {
}

최종 형태

지금까지 설명드린 리펙토링을 통해 서로 다른 헥사고날 4개를 만들었고, 필요에 따라 코어로직과 세컨더리 어댑터를 공유할 수 있게 됐습니다. 그 결과, 1개의 프로젝트 소스에서 서로 다른 헥사고날을 만드는 멀티모듈 헥사고날이 완성되었습니다.

9-complete-multimodule-hexagonal-image
완성된 멀티모듈 헥사고날의 아키텍처

운영을 통해 느낀 점

각 헥사고날은 공유되는 부분과 독립적인 부분으로 구성되어 있습니다. 앞서 설명드렸던 스케일 아웃 전략에 대한 고민을 기억하실까요?

  • 🤔 고민1. 모든 서비스가 데이터베이스를 접근해서 API서버에서 제한적으로 스케일 아웃을 할 수밖에 없었던 부분

  • 💡 해결안. 먼저 api모듈에서만 데이터베이스 모듈을 의존성을 추가해서 접근할 수 있게 되었고, 3개의 서비스(data-api, loader, consumer)에서는 의존성을 추가하지 않아서 접근할 수 없게 만들었습니다. 그 결과 api는 데이터베이스 커넥션으로 제한받았던 스케일 아웃 전략을 쉽게 구성할 수 있게 되었습니다.

  • 🤔 고민2. 모든 서비스가 이벤트 브로커 메시지를 수신하게 되어서, loader에서만 메시지를 수신하게 해야 했던 부분

  • 💡 해결안. 선택적 빌드를 통해서 loader에서만 메시지를 처리할 수 있는 독립적인 부분이 만들어져서 해결할 수 있었습니다.

추가적으로 메시지 허브 시스템의 경우, 메시지가 많아지면서 consumer의 지속적인 스케일 아웃이 예상됐는데, 멀티모듈 헥사고날 아키텍처를 통해 스케일 아웃 전략을 쉽게 수립할 수 있게 됐습니다.

또한 재사용성과 유지 보수성을 개선할 수 있었고, 신규 서비스로 분리 또는 병합할 때도 적은 리소스로 개발할 수 있게 됐습니다. 새로운 서비스를 구성할 때도 기존에 공유되는 로직과 세컨더리 어댑터를 활용할 수 있어서 신규 서비스 구성에 대한 부담을 줄일 수 있었습니다.

마무리하며

멀티모듈 핵사고날은 클린 아키텍처와 멀티모듈 구조의 장점을 결합하여, 개발, 확장, 유지 보수의 편의성을 제공합니다. 기존의 헥사고날보다는 코어와 세컨더리 어댑터를 공유할 수 있게 되다 보니, 개발에 대한 허들은 분명 존재합니다. 하지만 프로젝트 관리와 유지 보수에 대한 장점 때문에 헥사고날을 여러 개 만들어서 운영하는 것보다는 멀티모듈로 만드는 것이 리소스와 리스크 측면에서 확실하게 이점을 가지고 있습니다.

따라서 “다양한 인프라를 사용하고 스케일 아웃 전략을 유연하게 수립하고 싶을 때” 이러한 아키텍처를 도입하는 것을 추천드리며, 글을 마치겠습니다.