카카오뱅크의 수신상품 개발 과정에서 도메인 경계 분리 문제를 해결하기 위해 시도했던 경험을 공유합니다. 기존의 레거시 모놀리스(Monolith) 아키텍처를 MSA로 전환하는 과정에서 겪은 실무적 고민들과 모듈러 모놀리스 아키텍처, Spring Modulith를 활용한 불확실한 환경 변화에 대비한 안정적인 서버 운영 전략을 확인해보세요!

안녕하세요, 카카오뱅크 뱅킹서버개발팀에서 수신상품을 담당하고 있는 Kaya입니다.

MSA 전환, 누구나 한 번쯤은 고민해본 주제일 텐데요. 저희 팀도 예외는 아니었습니다. 빠르게 성장하는 서비스 속에서 레거시 모놀리스(Monolith)의 한계를 체감하며 “이제 정말 MSA로 가야 하나?“라는 고민을 시작했습니다.

하지만 막상 현실에 부딪히니 생각보다 복잡한 문제들이 산적해 있었습니다. 새로운 상품이 출시될 때마다 “이걸 어느 서버에 둬야 하지?”, “도메인 경계는 어떻게 나누지?” 같은 질문들이 끊임없이 따라왔습니다. 이론상으로는 명확해 보이는 MSA 전환이지만, 실제로는 비즈니스 요구사항과 기술적 제약, 그리고 한정된 개발 리소스 사이에서 현실적인 답을 찾아야 하는 과정이었습니다. 그런 과정에서 저희가 만난 것이 바로 모듈러 모놀리스 아키텍처와 Spring Modulith였습니다. 완벽한 해결책은 아니지만, 지금 상황에서 가장 실용적인 접근 방식을 제시해주었습니다.

이 글을 쓰는 이유는 비슷한 고민을 하고 계실 많은 분들과 저희의 경험을 나누고 싶어서입니다. 특히 MSA 전환을 고민하는 개발팀이나 도메인 경계 설정에 어려움을 겪고 있는 분들, 단순히 Spring Modulith가 궁금하신 분들께도 도움이 되었으면 합니다. 또한 기술 아키텍처 의사결정을 해야 하는 상황에 계시거나, 금융권처럼 안정성이 중요한 대규모 서비스를 개발하시는 분들에게도 참고가 될 것이라 생각합니다.

이 글에서는 저희가 실무에서 마주한 모놀리스와 마이크로서비스 사이의 딜레마와 그 해결 과정을 솔직하게 담아보았습니다. Spring Modulith가 만능 해결책이라고 주장하려는 것은 아닙니다. 다만 MSA 전환이라는 큰 방향성을 가지고 있지만 당장 모든 것을 분리하기 어려운 상황에서, 저희가 어떤 고민을 했고 어떤 선택을 했는지 그 과정을 공유해드리고자 합니다.​​​​​​​​​​​​​​​​

수신상품의 이주 여정

먼저 카카오뱅크의 수신상품이 어떤 여정을 통해 고객들에게 제공되고 있는지 설명드리기 위한 몇 가지 기본 개념을 소개하겠습니다.

은행에서 ‘수신상품’이란?

카카오뱅크는 현재 입출금통장을 기반으로 ‘세이프박스’, ‘저금통’, ‘기록통장’ 같은 간편한 저축 상품과 ‘한달적금’, ‘26주적금’, ‘자유적금’, ‘정기예금’과 같은 기간형 저축 상품들을 고객들에게 제공하고 있습니다. 이와 같이 금융기관이 고객으로부터 자금을 받아 예금, 적금 등 다양한 형태로 예치하는 행위를 ‘수신(受信)‘이라 하며, 이러한 은행의 저축 상품들을 통틀어 수신상품이라 칭합니다.

모놀리스 vs MSA

소프트웨어 개발을 시작하기 전 가장 먼저 해야 할 일은 시스템의 아키텍처와 기술 스택을 정하는 것입니다. 가장 많이 비교되는 두 가지 소프트웨어 아키텍처 개념을 간단히 설명하겠습니다.

모놀리스(Monolith)는 하나의 단위로 개발되는 단일 구조 아키텍처를 말하며, 모든 코드가 한 번에 배포됩니다.
MSA(Micro Service Architecture)는 소프트웨어 애플리케이션을 여러 개의 독립적으로 배포 가능한 서비스 모음으로 설계하는 방식입니다. 각 서비스는 서로 통신하기 위해 일반적으로 HTTP/REST API, Message Brokers와 같은 방법을 사용합니다.

하나의 큰 덩어리로 이루어진 모놀리스 구조는 초기에 빠르게 시스템을 개발할 수 있는 장점이 있는 반면, 시스템의 규모가 커질수록 유지보수나 협업이 어려워집니다. 반면 MSA 구조는 확장성과 유연성이 높지만, 시스템이 쪼개져 있기 때문에 운영과 인프라 관리의 복잡도가 커집니다.

초기 카카오뱅크 구조의 한계와 MSA 전환

카카오뱅크의 초기 채널 서버 시스템은 모든 서비스가 하나의 모놀리스 서버에서 동작하는 구조였습니다. 이 구조는 초기에는 효율적이었지만, 조직과 기능이 커지면서 변화가 필요했습니다. 서비스가 커질수록 코드의 양이 많아지며 빌드와 테스트의 속도가 급격히 느려졌습니다. 초기에는 하나의 팀 안에서 모든 개발자가 협업했지만, 조직이 세분화되면서 작은 변경이 여러 팀에 영향을 주었고, 그 영향 범위를 파악하기 어려워졌습니다.

0-spaghetti-code.png
[그림 1] 생성형 AI로 만든 '스파게티 코드' 이미지

일반적으로 코드베이스를 공유하는 개발방법은 다음과 같은 문제에 봉착하곤 합니다. 단일 코드베이스를 공유하다 보면, 테스트 서버에 오류가 발생할 경우 모든 팀의 배포가 지연되는 문제가 종종 발생합니다. 게다가 조직이 분리된 이후에도 여전히 공통의 코드베이스를 사용하다 보니, 코드와 DB에 대한 접근 제어가 체계적으로 분리되지 않게 됩니다. 저희도 이로 인해 서로의 영역이 명확히 구분되지 않아 작업에 영향을 미치는 상황이 자주 발생했습니다.

코드가 점점 많아지면서 기존의 복잡한 코드를 분석하기보다는 안타깝게도 비슷한 기능을 새로 작성하는 방식이 반복되었습니다. 그 결과, 동일한 기능을 수행하는 코드가 여러 군데 중복되어 존재하게 되었고, 서로 얽히고 복잡해진 코드들은 점점 유지보수를 어렵게 만들었습니다. 이렇게 복잡하게 얽힌 코드를 흔히 ‘스파게티 코드’ 라고 합니다. 이러한 문제들을 해결하기 위해서는 서비스 간 경계를 명확히 하고, 팀별로 자율성과 책임을 부여할 수 있는 구조가 필요했습니다. 이것이 바로 ‘MSA 전환의 출발점’이 되었습니다.

수신상품도 MSA 물결에 탑승?

이런 과정에서 카카오뱅크만의 특색을 담은 상품이었던 ‘26주 적금’의 한달 버전인 ‘카카오뱅크 한달적금’이 수신상품 최초로 신규 MSA 서버에서 출시되었습니다. 드디어 MSA 전환의 첫 발을 뗀 셈이었죠.

1-onemonth-release.png
[그림 2] 2023년 10월에 출시된 '카카오뱅크 한달적금' 상품

한달적금의 성공적인 출시 이후, 자연스럽게 다른 상품들도 기존 모놀리식 시스템에서 분리하여 새로운 MSA 서버로 이주해야 한다는 논의가 시작되었습니다. 하지만 여기서 예상치 못한 고민들이 생겨났습니다. 기존에 운영 중인 상품들과 앞으로 출시될 신규 상품들을 어떻게 관리할 것인가? 각각을 별도의 서비스로 분리할 것인지, 아니면 어떤 기준으로 묶어서 관리할 것인지에 대한 명확한 답이 없었습니다.

전사적으로 MSA 전환이 진행되고 있는 상황에서, 저희도 이 흐름에 따라가야 한다는 압박감은 있었지만 동시에 불확실함도 컸습니다. ‘과연 MSA로 전환하는 것만으로 우리가 겪고 있던 복잡성과 기술 부채 문제들이 해결될 수 있을까?’ 라는 의문을 품고 가능한 모든 선택지를 검토해보기 시작했습니다.​​​​​​​​​​​​​​​​

첫 번째 고민: 어디로 이주할 것인가 (Monolith vs. MSA)

계속해서 추가되는 수신 상품들을 ‘어디에서’ ‘어떻게’ 개발하고 관리할지에 대한 고민이 저희 팀의 가장 큰 과제였으며, 이를 위해 우선적으로 고려해본 두 가지 선택지는 다음과 같습니다.

  1. 신규 MSA 서버로 분리하여 개발
  2. 이미 개발되어 있는 MSA 서버(현재 한달적금이 운영 중인 서버)에 통합하여 개발

모든 상품마다 MSA 서버를 신규로 생성하여 운영한다면, 각각의 상품 도메인의 특성이 잘 드러나고, 상품마다 프로젝트의 기술 스택도 다양하게 구성할 수 있는 장점이 있습니다. 하지만 동시에 신규 서버 생성부터 레포지토리 관리, 모니터링 파편화 등 한정된 리소스로 수많은 MSA 서버를 운영하고 관리하기란 쉽지 않은 일이었습니다. 아래 표는 하나의 팀에서 5명의 개발자가 5개의 상품을 관리할 경우를 예로 들어, 코드베이스를 1개로 통합했을 때와 5개로 분리했을 때의 장단점과 운영 부담을 정리한 것입니다.

1개의 코드베이스 (Monolith) 5개의 코드베이스 (MSA)
장점 팀간 결합도가 증가(개발, 코드리뷰, 배포, 모니터링 한 곳에서) 상품 간 독립적으로 개발 가능
단점 상품 간 코드 충돌 및 의존 가능성 증가 팀 내 관리포인트 파편화, 운영 및 인프라 비용 부담
1인당 운영 부담 1/5 1

거대한 모놀리스 서버의 단점을 보완하고자 시작된 MSA 서비스 분리였지만, 한 팀에서 관리하는 ‘수신상품’이라는 단위까지 여러 MSA 서버로 쪼개는 것은 리소스 대비 부담이라는 생각이 들었습니다. 이에 따라 ‘수신상품’을 하나의 큰 경계로 정의하고, ‘한달적금 상품이 올라가 있는 서버’를 수신상품 전용 모놀리스 서버로 재구성하여 향후 출시될 다른 수신상품들도 해당 서버에 이주하기로 결정했습니다.

하지만 이 결정에 있어 과거의 모놀리스 구조에서 겪었던 문제들이 반복되지 않도록, 최소한의 안전장치가 필요하다고 판단했습니다. ‘수신상품’이라는 독립성은 확보했지만, ‘개별 상품 간 의존성’이 점점 얽히게 된다면 언젠가 다시 구조적 한계에 부딪힐 수 있기 때문입니다.

MSA의 ‘유연함’과 모놀리스의 ‘단순함’을 동시에 취할 수 있는 구조는 없을까?” 🤔

이러한 고민 끝에 저희는 모놀리스, MSA 아키텍처가 아닌 ‘모듈러 모놀리스’라는 새로운 대안에 주목했습니다.

‘모듈러 모놀리스’ 살펴보기

중간지대의 아키텍처, 모듈러 모놀리스

‘모듈러 모놀리스 아키텍처’(Modular Monolith Architecture)는 모놀리스와 MSA의 장점을 절충한 새로운 대안과 같은 아키텍처입니다. 하나의 애플리케이션으로 배포되는 모놀리스 형태를 유지하면서 내부적으로는 독립적인 모듈 단위로 도메인을 분리하여 모듈 간에 명시적인 의존성을 기반으로 느슨하게 결합된 구조를 가집니다. 즉, 모놀리스에서 MSA로 전환하는 과도기에 있으면서 도메인 분리는 필요하지만 운영의 복잡도는 낮추고 싶은 상황에 적합하다고 할 수 있겠습니다.

모듈러 모놀리스를 적용하면, 다음과 같은 장점을 누릴 수 있습니다.

  1. 도메인 분리
    • 모듈러 모놀리스 구조에서는 모듈을 통해 도메인을 분리하며, 각 모듈은 명시적 의존 관계나 공개된 인터페이스를 통해서만 접근 가능합니다. • 모듈 간 내부 로직 접근이 불가능해 의존성 혼란을 방지하고, 유지보수가 용이합니다.

  2. 운영 복잡도 감소
    • MSA와 달리 하나의 애플리케이션으로 배포되어 운영 복잡도가 줄어들고, 같은 코드 베이스 내에서 개발되어 팀의 생산성이 향상됩니다.
    • 네트워크 통신에 따른 분산 트랜잭션을 고민할 필요가 없습니다.

모듈러 모놀리스에서 ‘경계 강제’하는 법

모듈러 모놀리스가 올바르게 동작하려면 경계 강제가 필요합니다. 강제성이 없는 모듈 구조는 일반 모놀리스와 차이가 없기 때문입니다. 모듈을 분리하는 방식은 크게 두 가지가 있습니다.

  1. 물리적인 모듈 분리 (feat. 빌드 도구의 활용)
    물리적인 모듈 분리는 Gradle이나 Maven 같은 빌드 도구를 활용해 도메인별로 모듈을 생성하고 독립적으로 관리하는 방식입니다. 각 모듈은 명시적으로 의존성을 선언해야만 다른 모듈을 참조할 수 있기 때문에, 모듈 간의 경계를 자연스럽게 강제할 수 있다는 장점이 있습니다. 또한 build.gradle이나 pom.xml 설정을 통해 필요한 의존만 제한적으로 허용함으로써, 불필요한 결합을 사전에 방지할 수 있습니다.

  2. 논리적인 모듈 분리
    논리적 모듈 분리는 하나의 프로젝트 안에서 패키지 구조나 접근 제어 등을 통해 책임과 역할을 구분하는 방식입니다. 상대적으로 도입이 간편하고 유연하게 설계할 수 있지만, 경계를 명확히 정의하고 강제하기 어렵다는 단점이 있습니다. ArchUnit과 같은 아키텍처 테스트 도구를 활용하면 구조를 유지할 수 있지만, 원하는 규칙을 일일이 코드로 정의하고 테스트로 검증해야 해서 설계를 강제하려는 노력에 적지 않은 공수가 들어갑니다.

저희는 모듈의 경계를 명확히 하고자 하는 필요성이 분명했습니다. 그래서 물리적 모듈 분리를 위한 ‘빌드 설정 비용’과 ‘테스트 코드로 논리적 경계를 강제하는 비용’을 비교해보았습니다. 그 결과, 두 경우의 공수가 비슷하다는 결론에 도달했습니다. 그렇다면 보다 명확하고 직관적인 경계를 설정할 수 있는 물리적 모듈 분리가 더 나을 것이라고 판단했습니다.

두 번째 고민: 어떤 기준으로 모듈을 분리할 것인가?

여기서 또 하나의 고민이 등장합니다. 모듈러 모놀리스 아키텍처의 물리적 모듈 분리를 위해서는 하나의 프로젝트 안에서 도메인을 어느 수준까지 분리해 각각의 물리적 모듈로 나눌 것인가에 대한 판단 기준이 필요하기 때문입니다.

방식 1. 모듈 당 1개의 상품 할당

처음 가장 단순하게 생각해본 방식은 1모듈 1상품 방식이었습니다.

2-module-example-each-goods.png
[그림 3] 물리적 모듈 분리 방식 예시: 1모듈 1상품

상품별로 모듈을 만들면, 도메인을 확실하게 분리할 수 있습니다. 하지만 수신상품의 경우 공통된 로직이 많아 모듈별로 중복 생성되는 코드가 많아진다는 단점이 있었고, 단일 모듈로 분리하기엔 규모가 작은 도메인인 경우 오버엔지니어링처럼 느껴지기도 했습니다.

방식 2. 특정 기준의 모듈에 N개의 상품 할당

또 다른 방법은 특정 기준에 따라 하나의 모듈에 여러 상품을 묶어서 관리하는 방식입니다.

3-module-example-grouping-goods.png
[그림 4] 물리적 모듈 분리 방식 예시: 특정한 기준으로 묶어서 관리

이러한 구조는 충분한 시간과 예측 가능한 미래가 보장된다면 가장 최적의 방식이라고 생각합니다. 모든 팀원들이 힘을 합쳐 같은 시기에 수신 상품을 해당 서버에 이관할 수 있다면 말이죠!

하지만 현실적으로 한정된 리소스로 인해 다음 개발 일정과 맞물린 상품들만 신규 MSA 서버에 추가될 수 있었고, 언제 어떻게 합쳐질지 모르는 상품들을 미리 분류해 놓는 것이 정말 좋은 방법일까?라는 의문이 들었습니다. 불확실한 미래에 특정한 기준을 정하기란 쉽지 않았고, 모듈을 분리해 관리하다가 나중에 다른 구조로 상품을 묶어야 하는 상황이 생긴다면 복잡해질 수 있을 것이라고 생각했습니다. 무엇보다도 조직 구조, 업무의 우선순위 변경과 같은 예측할 수 없는 외부 상황에도 유연하게 대응할 수 있는 선택지가 필요했습니다.

세 번째 고민: 모듈 분리를 위한 ‘구조’를 어떻게 만들 것인가?

혹시 Spring Modulith 들어봤니?

초기 도입 단계에서는 모듈을 분리하는 기준을 명확하게 정하는 것보다, 모듈을 쉽게 분리하고 필요시 적은 비용으로 되돌릴 수 있는 구조를 만드는 것이 훨씬 중요하다고 생각했습니다. 그래서 처음부터 과도하게 물리적으로 모듈을 분리하지 않고, 논리적으로 모듈을 분리하는 방향으로 접근하기로 했습니다. 이 과정에서 우연한 기회로 Spring에서 공식으로 제공하는 라이브러리인 Spring Modulith를 알게 되었습니다.

4-suggestion-spring-modulith.png
[그림 5] 팀 채널에서 동료로부터 제안받은 Spring Modulith 라이브러리

Spring Modulith를 통한 논리적인 모듈 분리

Spring은 자바 기반의 애플리케이션 개발을 위한 프레임워크로, 대규모 시스템 개발에 있어 매우 유용한 도구들을 제공합니다. Spring Modulith는 이 Spring 프레임워크를 기반으로 애플리케이션을 도메인 기반의 모듈 방식으로 개발하고 테스트할 수 있도록 도와주는 도구입니다. 2023년 8월에 정식 버전이 릴리즈되었으며, 2025년 6월 기준 최신 버전은 1.4.0입니다.

이 글에서는 Spring Modulith 라이브러리의 기능과 구체적인 사용 방법보다는 Spring Modulith가 어떤 방식으로 모듈 경계를 설정하고 이를 강제하는지에 대해 전반적인 개념 위주로 소개해보겠습니다.

📌 패키지 단위 모듈로 ‘경계 설정’

Spring Modulith에서는 같은 코드베이스 안에서 패키지 단위로 논리적으로 모듈을 분리합니다. 각 모듈은 하나의 독립된 경계로 인식됩니다.

5-spring-modulith.png
[그림 6] spring modulith의 논리적 모듈 분리

보통 MainApplication 클래스가 위치한 기준 패키지를 중심으로 하위 패키지들이 각각 하나의 모듈로 인식되며, 위 그림에서 모듈 A모듈 B는 Spring Modulith가 인식하는 논리적인 모듈입니다. 각 모듈은 내부 구현 클래스를 감추고, 패키지 최상단에 위치한 일부 클래스만 public으로 외부에 공개합니다. 이 클래스들이 Public API로, 모듈 간 통신은 반드시 이 API를 통해서만 가능합니다.

이렇게 Public API만을 노출함으로써 모듈 간의 상호작용을 명확히 제한하고, 모듈 내부 변경이 다른 모듈에 영향을 주지 않도록 할 수 있습니다. 결과적으로 캡슐화 + Public API = 모듈의 경계 설정이 이루어지게 됩니다.

📌 테스트 코드 검증을 통한 ‘경계 강제’

또한 Spring Modulith에서는 간단한 테스트 코드를 통해서 경계를 강제할 수 있습니다.

ApplicationModules.of(MainApplication::class.java).verify()

위와 같이 ApplicationModules 클래스를 불러와 verify() 메서드로 검증하면, Public API를 제외하고 모듈 간 의존 관계가 있다면 테스트가 실패하게 됩니다. 즉, 이 테스트를 통해 모듈 간의 의존성을 명확히 하고, 경계를 강제하여 모듈 간의 결합도를 낮출 수 있습니다. 이렇게 함으로써 모듈 간의 독립성을 유지하고, 서로의 변경이 다른 모듈에 영향을 주지 않도록 할 수 있습니다.

💡 결론: Spring Modulith, 사용해볼 만하다!

Spring Modulith는 단순한 패키지 분리를 넘어서, 모듈 간 경계와 의존성에 대한 구조적 제약을 테스트 코드로 검증할 수 있는 기능을 제공합니다. 도입을 고려했던 ArchUnit과 같은 구조 검증 도구보다 사용이 간편하고, 무엇보다 Spring에서 제공하는 ‘공식 라이브러리’라는 점에서 안정성과 통합성이 큰 장점으로 다가왔습니다. 특히 저희 팀의 경우 ‘수신상품’이라는 하나의 큰 도메인을 한 프로젝트 내에서 관리할 때, 다음과 같은 고민이 있었습니다:

  1. 물리적인 모듈로 쪼개기엔 기준이 애매함
  2. 단순한 코드 분리만으로 도메인의 경계를 강제하는 것이 걱정됨

Spring Modulith를 통해 하나의 코드베이스에 수신상품을 모아두고 논리적으로 모듈을 분리함으로써 ‘도메인 간 최소한의 경계’를 설정할 수 있습니다. 이를 통해 이후 필요할 때 별도의 물리적 모듈 혹은 MSA 서버로 자연스럽게 분리할 수 있는 유연함을 가져갈 수 있다고 생각했습니다. 결론적으로, Spring Modulith는 저희 팀의 요구에 맞춰 유연하게 모듈을 관리하고 경계를 설정할 수 있는 강력한 도구로, 사용해볼 만한 가치가 충분히 있다고 판단했습니다.

Spring Modulith 도입 사례 소개

카카오뱅크의 수신상품을 관리하는 프로젝트에 Spring Modulith를 도입한 실제 사례를 바탕으로, 구체적인 활용 방법을 소개해드리겠습니다.

⚠️ Disclaimer: 설명을 위해 첨부된 그림이나 모듈 구조는 실제 프로젝트를 일부 단순화한 것입니다.

6-project-overview.png
[그림 7] 수신상품 프로젝트 구조 예시

먼저, 이 프로젝트는 Kotlin과 Spring Boot를 기반으로 개발되었습니다. Gradle 멀티모듈을 이용한 헥사고날 아키텍처를 적용하여 애플리케이션 계층과 어댑터 계층을 물리적으로 분리하고, Port 인터페이스로만 통신하여 외부 의존성으로부터 도메인을 보호하는 구조입니다. 또한, 애플리케이션 설정 및 실행을 담당하는 부트스트랩 모듈과 공통 로깅 처리를 위한 모듈을 별도로 구성하였습니다.

사례 1. 모듈 간 참조 방지

수신상품이 모여 있는 상품(goods)이라는 하나의 메인 패키지 안에서, 한달적금(onemonth)과 세이프박스(safebox)가 함께 존재하면서 한달적금 모듈과 세이프박스 모듈의 코드는 서로 참조될 수 없는 구조를 강제하는 상황을 생각해봅시다.

Spring Modulith는 각각의 모듈이 공개된(Public) API를 제외하고 참조될 수 없다란 특징을 가집니다. 이 개념을 역으로 이용해서 각각의 모듈은 공개된 API를 가지지 않도록 설계하고, 결국 서로 절대 참조되서는 안되는 형태를 만들면 됩니다.

7-spring-modulith-example-seperation.png
[그림 8] 모듈 간 참조 방지: 모듈 분리

먼저 goods 패키지 하위에 onemonthsafebox 패키지를 만들어 별도의 모듈로 분리합니다. 패키지 최상단에 위치한 클래스가 없으므로 각 모듈은 공개된 API 없이 모두 캡슐화됩니다. 실제 도메인의 비즈니스 로직은 하위 application 패키지에 위치하며, 외부 어댑터는 port패키지에 있는 인터페이스를 통해서만 접근할 수 있습니다.

🔎 한달적금세이프박스계좌 개설 유즈 케이스를 예시로 조금 더 살펴보겠습니다.

한달적금과 세이프박스 모두 각자 계좌를 개설하기 위한 CreateOneMonthAccountService, CreateSafeboxAccountService라는 유즈케이스 서비스를 가지고 있습니다. 또한 각 클래스들은 CreateAccountPort라는 인터페이스를 의존하여 외부 아웃 어댑터와 통신합니다.

8-spring-modulith-example-invasion-possible.png
[그림 9] 사례 1. 모듈 간 참조 방지: 경계 침범 가능성

현재 구조상 같은 코드베이스에 접근 가능하게 되어 있기 때문에 한달적금 서비스에서 ..onemonth.port.CreateAccountPort가 아닌 같은 이름의 세이프박스 개설 인터페이스..safebox.port.CreateAccountPort를 호출하게 될 가능성이 있습니다.

9-spring-modulith-example-test-verify.png
[그림 10] 사례 1. 모듈 간 참조 방지: 테스트를 통해 검증

이 때 테스트 코드를 통해 onemonth모듈에서 safebox모듈을 의존하고 있다는 에러메시지를 발견하고 저희는 코드를 수정하여 모듈 간 참조를 방지할 수 있게 되었습니다.

사례 2. 공통 로직은 OPEN으로 공개

만약 공개하고 싶은 모듈이 있다면 Spring Modulith에서 제공하는 @ApplicationModule, @PackageInfo 어노테이션을 사용해 모듈의 타입을 OPEN으로 지정해줄 수도 있습니다.

10-spring-modulith-example-open.png
[그림 11] 사례 2. 모듈 공개: OPEN 타입 지정

🔎 은행 기관의 전사서명 로직을 예로 들어보겠습니다.

우선, 카카오뱅크는 온라인 금융 거래시 본인임을 증명하고 거래 내용이 위변조되지 않았음을 확인하기 위해 ‘전자서명’이 필요합니다. 이는 모든 수신상품에 공통적으로 들어가는 로직입니다. 이 때 한달적금, 세이프박스 패키지에 중복되게 코드를 작성할 수도 있겠지만 공통으로 사용할 수 있는 모듈이 있다면 좋겠다고 생각했습니다.

저희는 common이라는 모듈을 만들고, Spring Modulith의 @ApplicationModule(type=ApplicationModule.Type.OPEN)을 이용하여 해당 모듈을 공개시켜주었습니다. 따라서 OPEN 타입으로 지정된 모듈은, 다른 모듈이 공개된 API 뿐 아니라 내부까지 접근할 수 있도록 허용됩니다.

package com.example.modulith.goods.onemonth.application // onemonth 모듈

import com.example.modulith.goods.onemonth.port.CreateAccountPort
import com.example.modulith.goods.common.port.VerifyElectronicSignaturePort // common 모듈 참조

@UseCase
class CreateOneMonthAccountService(
    private val createAccountPort: CreateAccountPort,
    private val verifyElectronicSignaturePort: VerifyElectronicSignaturePort,
) {
  fun createAccount() {
    // 전자서명 검증 로직
    verifyElectronicSignaturePort.verify()
    
    // 계좌 개설 로직
    createAccountPort.create()
  }
}

다음과 같이 onemonth 모듈에서 common 모듈에 있는 전자서명 인터페이스를 호출하여도, 검증 테스트는 통과합니다.

사례 3. 모듈 탐지 전략

Spring Modulith의 모듈 탐지 전략(ApplicationModuleDetectionStrategy) 은 크게 두 가지가 있습니다.

- ExplicitlyAnnotated : 어노테이션으로 명시한 패키지를 모듈로 인식
- DirectSubPackage : 지정된 패키지 하위를 모듈로 인식 (default)

  1. DirectSubPackage : 기본적으로는 directSubPackage전략을 따라 테스트 코드 작성시 지정된 패키지 하위 패키지를 모듈로 탐지합니다. ApplicationModules.of(MainApplication::class.java)라면 MainApplication 클래스 하위 패키지를 모두 모듈로 인식합니다.

  2. ExplicitlyAnnotated : explicitlyAnnotated 전략은 @ApplicationModule 어노테이션이 붙은 패키지를 모듈로 인식합니다. 만약 기본 설정을 변경하고 싶다면 스프링 애플리케이션의 프로퍼티 설정에 명시해주면 됩니다.

spring:
  modulith:
    detection-strategy: explicitly-annotated

또한 프로젝트에 CustomApplicationModuleDetectionStrategy과 같은 커스텀한 모듈 탐지 전략을 담은 인터페이스를 등록할 수도 있습니다. Spring Modulith에서 제공하는 ApplicationModuleDetectionStrategy 인터페이스를 직접 구현하고, 애플리케이션 프로퍼티 설정으로 명시해주면 됩니다.

package com.example.modulith.config

class CustomApplicationModuleDetectionStrategy : ApplicationModuleDetectionStrategy {
    override fun getModuleBasePackages(basePackage: JavaPackage): Stream<JavaPackage> {
        // 커스텀 로직 구현
    }
}
spring:
  modulith:
    detection-strategy: com.example.modulith.config.CustomApplicationModuleDetectionStrategy

저희는 Spring Modulith를 적용하기에 앞서, 다음과 같은 조건을 만족하는 🔎 수신상품 프로젝트의 모듈 탐지 조건 3가지를 필요로 했습니다.

1️⃣ 물리적으로 분리된 모듈의 공통 도메인을 ‘하나의 모듈’로 간주

아래 [그림 12] 속 프로젝트는 adapter-in, adapter-out, application 모듈이 물리적으로 나눠져있지만, 내부적으로 onemonth, safebox와 같은 도메인은 하나의 논리적인 모듈로 탐지되어야 했습니다. 그렇지 않으면 모두 별도의 논리적 모듈로 간주되어 application에 위치한 CreateAccountPort인터페이스를 adapter-outAccountCreateAdapter클래스에서 구현하면 모듈의 침범으로 인식될 수 있기 때문입니다.

11-modulith-detection-strategy.png
[그림 12] 사례 3. 모듈 탐지 전략 - 물리적 모듈을 하나의 논리적 모듈로 간주

2️⃣ 추가되는 상품에 대해서도 별도 설정 없이 ‘모듈 탐지 전략’을 자동으로 적용

또한 ‘저금통’, ‘26주적금’과 같은 다른 상품이 추가되었을 때, 의식적으로 경계 침범을 신경쓰거나, 탐지하는 모듈을 추가해주지 않더라도 테스트 코드가 자동으로 경계 침범을 막아주어야 했습니다. 모듈 간 경계 침범 방지에 대해 모르는 팀원이 이 프로젝트에서 개발을 하게 되더라도, 테스트 코드로 탐지하여 자연스럽게 프로젝트의 컨벤션과 구조를 따를 수 있도록 하고 싶었습니다.

3️⃣ 모듈로 탐지하고 싶지 않은 코드들(인프라, 로깅 등)은 제외

그리고 상품(goods)모듈 내에서 관리하는 수신상품 도메인들의 경계 침범만을 탐지하고, 인프라, 로깅 등과 같은 공통 모듈은 모듈 탐지에서 제외하고 싶었습니다.

수신상품 프로젝트의 커스텀 모듈 탐지 전략, GoodsModuleDetectionStrategy 의 구현

위의 모듈 탐지 조건 3가지를 생각하며, 순차적으로 다음과 같은 접근 방식을 시도해보았습니다.

🔎 초기 접근: ExplicitlyAnnotated 전략과 한계점

처음에는 명시적으로 모듈에 @ApplicationModule 어노테이션을 붙여, 어노테이션이 붙은 모듈만 모듈로 탐지해 테스트를 수행하도록 하는 전략을 택했습니다. 이렇게 하니 adapter-in, adapter-out, application에 존재하는 모든 도메인 패키지에 @ApplicationModule 어노테이션 설정을 위한 package-info 파일을 생성해주어야 했습니다.

그리고 ‘저금통’, ‘26주적금’ 처럼 추가되는 도메인에 @ApplicationModule을 붙이는 것을 생략 할 경우 모듈 테스트에서 탐지 되지 않아서, 결과적으로는 코드의 침범을 막아주지 못했습니다.

🔎 커스텀 탐지 전략: DirectSubPackage 전략으로 GoodsModuleDetectionStrategy 클래스 직접 구현

Spring Modulith의 directSubPackage 전략이 지정된 패키지를 기준으로 그 하위 패키지를 모듈로 탐지하는 방식이라는 점에 착안하여, 모듈 탐지 전략을 직접 구현해보기로 했습니다.

물리적 Gradle 모듈 패키지 구조 비고
bootstrap com.example.goods MainApplication 클래스 위치
adapter-in com.example.goods.{domain}.adapter-in..
adapter-out com.example.goods.{domain}.adapter-out..
application com.example.goods.{domain}.application..

먼저 모든 패키지 구조를 지정된 패키지 하위 패키지로 인식할 수 있도록 구성하였습니다. bootstrap 모듈에 존재하는 MainApplication.class의 패키지인 com.example.goods을 모든 모듈의 상위 패키지 구조로 설정하였습니다. 그리고 apdater-in, adapter-out, application 모듈 하위의 도메인들도 표와 같은 패키지 구조를 따르도록 하였습니다. 그림으로 나타내면 아래와 같습니다.

12-spring-modulith-pacakage-strategy.png
[그림 13] 프로젝트 모듈 구조 with Spring Modulith

이렇게 구성한 패키지 구조로 모듈이 탐지되도록 GoodsModuleDetectionStrategy 클래스를 구현하였고, 모듈의 경계를 강제하는 테스트코드를 작성하였습니다.

class GoodsModuleDetectionStrategy : ApplicationModuleDetectionStrategy {
    override fun getModuleBasePackages(basePackage: JavaPackage): Stream<JavaPackage> {
        return basePackage.directSubPackages.stream().filter {
            it.name.contains("goods") 
        }.directSubPackages.stream()
    }
}
class ApplicationModulithTest : FunSpec({
    test("하위 도메인 모듈에서 common을 제외하고 모듈 간 경계 침범이 이루어지지 않는다.") {
        val modules = ApplicationModules.of(MainApplication::class.java).verify()
        println(modules)
    }
})

MainApplication를 읽어올 때 basePackage(com.example.goods)의 directSubPackages를 모두 불러와 goods가 포함된 패키지만 탐지하도록 하였습니다. 이렇게 설정하면 전제 조건이었던 로깅 모듈과 같은 불필요한 모듈의 탐지도 제외할 수 있었습니다.

추후에 저금통, 26주적금 같은 상품이 추가되더라도 com.example.goods.moneybox처럼 패키지 구조만 맞추어 개발한다면, 별도의 설정 없이 Spring Modulith에서 탐지하는 논리적 모듈이 되어 도메인 간 경계를 강제할 수 있게 됩니다. 그리고 나중에 물리적으로 모듈 자체를 분리하거나 별도의 MSA 서버로 떼어내더라도, 각각 상품끼리의 코드 의존성이 없기 때문에 자연스럽게 분리할 수 있게 됩니다.

🔎 프로젝트 운영 전략: ADR 방식을 통한 문서화

이렇게 결정된 적용 방안에 대해서는, 이후 새로운 팀원이 이 프로젝트에 참여할 때 히스토리를 파악하기 용이하도록 프로젝트에서 기술적 의사 결정을 기록하는 ADR(Architectural Decision Records)를 작성해 기록하고 있습니다.

13-spring-modulith-adr.png
[그림 14] 프로젝트의 기술적 의사결정을 기록하는 ADR 문서

ADR로 기록해둔 문서를 통해 프로젝트의 기술적 의사 결정 과정을 투명하게 공유하고, 의사 결정의 배경과 이유를 체계적으로 관리할 수 있습니다. 이 문서 방식을 통해 새로운 팀원이 수신상품을 개발하는 팀에 합류하더라도 일관된 정보 제공하여 빠르게 프로젝트에 적응하고, 협업 효율성을 높일 수 있습니다.

짧은 적용 후기: 그래서 추천하나요? 네! 🙆‍♀️

지금까지 간략하게 Spring Modulith를 실무에 적용해본 경험을 소개해드렸습니다. 이 글에서는 깊이 다루지 않았지만 Spring Modulith는 다양한 추가 기능을 제공합니다. 조금 더 들여다 본다면, DDD 관점에서 도메인 간 결합을 느슨하게 하기 위해 이벤트를 발행하고 검증하는 기능, 도메인의 의존 관계를 UML로 시각화해주는 기능도 포함되어 있습니다. 무엇보다도 릴리즈 이후 Spring에서 꾸준히 업데이트하며 관리하는 라이브러리이기에 앞으로도 잘 활용해볼 만한 기술이라고 생각합니다.

에필로그: 아키텍처를 탐색하며 얻은 깨달음 🌱

돌이켜보면 저는 늘 ‘정답’을 찾으려는 사람이었습니다. 전사적으로 MSA 전환이 추진되던 시기에 수신상품을 담당하며 어떤 아키텍처가 최선일지에 대해 오랜 시간 고민했는데요. 하지만 경험을 쌓으며 점차 소프트웨어 개발에는 ‘정답’이라는 것이 존재하지 않는다는 사실을 받아들이게 되었습니다.

MSA, 헥사고날 아키텍처 등 다양한 대안이 떠올랐지만, 모든 상황에 만능처럼 적용되는 기술은 없었습니다. 실제로 선택해야 하는 상황이 오면, 다시금 모놀리식이나 레이어드 아키텍처와 같은 익숙한 방식들을 고민하게 되더라고요. 그 과정에서 저희 팀에 적절했던 기술이 바로 Spring Modulith였습니다. 도입 초기엔 ‘왜 만들어졌는지’ 그 의도를 완전히 이해하지 못한 채 사용하기도 했지만, 팀에겐 도메인 경계를 강제할 수 있는 가벼운 수단이 필요했고, Spring Modulith는 그 요구에 적절히 부합했습니다. 다음에는 하나의 상품 내부에 존재하는 다양한 하위 도메인 간의 경계를 지켜야 하는 상황에서 적용해본다면 더 큰 효과를 볼 수 있을 것 같습니다.

이번 경험을 통해 배운 것은, 정답보다는 상황에 맞는 적절한 선택이 중요하다는 점입니다. 아직 대중화되지 않은 기술이었지만 팀원들과 함께 문서를 찾아보고, 직접 적용해보며 배워나갔던 시간은 저에게 큰 의미가 있었습니다. 라이브러리 버전이 업데이트될 때마다 새롭게 추가된 기능을 팀 내에서 어떻게 활용할 수 있을지 함께 고민해보는 과정도 좋은 경험이 되었습니다.

DDD, MSA, 헥사고날 아키텍처처럼 매년 새로운 개념들이 등장하지만, 결국 무엇이 ‘가장 옳은 선택’인지 단정짓기는 어렵습니다. 프로젝트마다 상황도 다르고, 미래는 언제나 예측하기 어렵기 때문이죠. 그래도 언제나 새로운 기술을 적용해보려고 고민하는 과정은 개발자들에게 의미있고 값진 경험인 것 같습니다. 제가 쓴 이 글이 지금도 모놀리스와 MSA 사이에서 고민하고 계신 분들에게 새로운 대안을 발견하는 계기가 되었으면 좋겠습니다.