새로운 프로젝트를 준비하며 학습을 위해 MSA까지 고려하기로 했다. 하지만 시간적인 여유도 그렇고, 여러가지 사정으로 인해 처음부터 MSA로 구현할 수는 없었다.
따라서 MSA 구조로 추후 확장하는 것을 고려해 멀티 모듈 구조로 먼저 프로젝트를 구성해보기로 했다. AI에게 멀티 모듈 구조로 프로젝트를 구현해달라고 해도 됐지만, 멀티 모듈이 뭔지 정확하게 이해하고 있지 못 하는 상태였기 때문에 직접 학습해서 아키텍처들에 대해 이해를 가져가기로 했다.
이 글에서는 멀티모듈의 개념과 학습하며 시청했던 컨퍼런스 3개에 대한 개념 정리를 기록해두려 한다.
관련 있는 책임들을 묶고, 공개된 인터페이스로만 외부와 상호작용하며 독립적으로 개발되고 교체되어 재사용할 수 있는 소프트웨어 구성 단위
설명이 긴데 정보 은닉, 높은 응집도, 낮은 결합도가 목적임을 생각하면 이해가 쉽다.
모듈이라는 개념 자체는 논리적인 개념에 해당하고 어떻게 표현하는가는 상황마다 조금씩 다르다. Gradle 멀티 모듈에서의 모듈은 자신만의 build.gradle과 src 폴더를 가진 서브프로젝트 폴더이다. Spring Modulith에서의 모듈은 메인 애플리케이션 패키지 바로 아래의 하위 패키지를 말한다.
코드를 재사용 가능한 여러 빌드 모듈로 나눈 프로젝트 구조 기법
멀티 모듈은 코드/빌드 구조에 대한 개념이고 모놀리스, 모듈러 모놀리스, MSA는 배포/런타임 아키텍처에 대한 개념이다. 그러니까 아예 다른 축의 개념이기 때문에 멀티 모듈이면서 모놀리스일 수 있고 멀티 모듈이면서 MSA일 수 있다.
애플리케이션 전체가 하나의 배포 단위(아티팩트)로 빌드, 배포 되는 구조
모놀리스의 기준은 배포 단위가 하나라는 것이다. 서버를 스케일 아웃해서 여러 인스턴스로 띄워도 아티팩트 자체는 하나이므로 여전히 모놀리스 구조라고 할 수 있다.
경계가 명확한 모듈들로 내부가 분리되어 경계가 지켜지는 모놀리스
모듈이 여러 개인 모놀리스이면서 모듈 간의 경계(의존 규칙)이 지켜지는 구조를 말한다.
의존 규칙이라 함은
이다.
의존이라는 단어를 한 번 더 생각해보면 좋다.
⇒ 변경이 발생했을 때 영향이 한 모듈 안에 머물게 해야 하기 떄문이다.
애플리케이션을 독립적으로 배포하고 확장할 수 있는 여러 서비스로 나눈 아키텍처
모듈 경계를 구현하는 방법은 크게 두 가지가 있다. Gradle을 사용해 물리적인 멀티 모듈 구조를 구현하는 방식과 Spring Modulith 라이브러리를 사용해 논리적인 모듈 경계를 구현하는 방식이다.
Gradle은 빌드 자동화 도구이다. 의존성 관리도 해주고, 작성한 코드로 컴파일 + 테스트 + .class 파일들을 모아 plain JAR 파일로 만들어준다.
Gradle 멀티 모듈은 하나의 루트 프로젝트 아래에 서로 의존하는 서브프로젝트들을 두어 구성한다. settings.gradle에 서브프로젝트들을 등록하고, 각 모듈 내의 build.gradle에서 의존 관계를 선언하면 Gradle이 빌드 순서, 클래스패스를 결정해준다.
애플리케이션/
├── settings.gradle // 서브프로젝트 등록
├── build.gradle // 루트 (공통 설정)
├── domain/ // domain 모듈
│ ├── build.gradle
│ └── src/main/java/com/example/domain/...
├── application/ // application 모듈
│ ├── build.gradle
│ └── src/main/java/com/example/application/...
└── api/ // api 모듈 (실행 애플리케이션)
├── build.gradle
└── src/main/java/com/example/api/
└── Application.java // @SpringBootApplication
만약 의존관계를 선언하지 않은 모듈의 클래스를 import한다면 컴파일 타임에 에러가 발생한다.
Spring에서 만든 공식적인 라이브러리인 Spring Modulith를 사용해 모듈 경계를 구현하는 방식이다. 별도로 빌드 모듈을 분리하지 않아도 메인 패키지 바로 아래의 하위 패키지들을 각각 하나의 논리적 모듈로 인식한다.
src/main/java
├── com.example
│ └── Application.java // 메인 애플리케이션
├── com.example.order // order 모듈
│ ├── OrderService.java // 패키지 루트 = 공개 API
│ └── internal... // 하위 패키지 = 비공개
└── com.example.inventory // inventory 모듈
각 모듈은 패키지 최상단에 위치한 타입만 외부에 공개하고, 하위 패키지의 내부 구현은 외부에서 참조할 수 없다. 모듈 경계가 지켜졌는지는 ArchUnit으로 짠 테스트로 검증한다.
추가로 Spring Modulith는 다른 모듈의 공개 API를 호출하는 것보다는 이벤트 발행/구독으로 연동하는 것을 권장하기 때문에 @ApplicationModuleListener를 지원한다. 트랜잭션 커밋 이후에 별도의 트랜잭션에서 비동기로 실행되는 리스너이며, spring-modulith-starter-jpa까지 의존성으로 추가하면 이벤트 발행 레지스트리(이벤트 발행 사실을 데이터베이스에 기록해주는 런타임 컴포넌트)까지 자동으로 추가해준다. 덕분에 커밋 후 리스너 실행 전에 애플리케이션이 죽어도 이벤트가 유실되지 않는다.
이렇게 모듈 간 통신이 이미 이벤트로 되어 있다면, 나중에 인프로세스 이벤트를 Kafka 같은 외부 브로커로 내보내는 것만으로 통신 방식을 바꿀 수 있어 MSA 전환 비용이 크게 줄어든다.
배타적이지 않다. Gradle 멀티 모듈로 계층을 물리적으로 분리하고, 그 위에 Spring Modulith를 얹어 도메인 간 참조 규칙을 검증하게 하는 식으로 같이 쓸 수 있다. (참고: 카카오뱅크 기술블로그, MSA로의 여정에서 만난 Spring Modulith를 체리픽 해본 후기 — https://tech.kakaobank.com/posts/2507-legacy-to-modular-monolith-with-spring-modulith/)
처음에 나는 모듈화의 목적을 재사용이라고만 생각했다. 하지만 이렇다면 실행 애플리케이션 모듈(api 같은 것)은 아무도 재사용하지 않는데 왜 모듈인가?는 설명이 되지 않는다.
그러니까 모듈은 변경과 의존을 통제하는 단위에 가깝다.
여러 배포 단위가 같은 코드를 써야 하는 상황을 생각해봤을 때, 해결할 수 있는 방법은 세 가지가 있다.
멀티 모듈로 같은 코드를 어떻게 공유할 것이냐는 문제는 해결됐다.
하지만 공유가 쉬워지면 코드 중복을 줄이고자 사람들은 자연스럽게 공통 모듈에 이것저것 몰아넣게 된다. 개발자는 코드 중복을 줄여야 한다는 말을 자주 듣기 때문에.. 나라도 그럴 것 같다.
하지만 그러면 공통 모듈이 엄청나게 커지고 의존이 많이 되어 공통 모듈을 고치면 어디에까지 영향을 미칠지 가늠할 수 없어 손대지 못하는 코드 덩어리가 된다.
실제로 여러 조직이 서로 다른 이름으로 같은 증상을 겪었다. 공통 모듈의 저주(스파게티, 의존성 덩어리, 공통 설정 강제), Too many connections, NoClassDefFoundError, Copy & Paste와 방어적 if-else, common 수정 기피, 개발 독립성과 배포 독립성이 안 나뉘는 문제, 히스토리 파악 곤란... 이름은 다 달라도 전부 모두가 의존하는 뚱뚱한 모듈 하나에서 출발한다.
즉 멀티 모듈이라는 도구 자체는 공유 문제를 풀어주지만, 경계 없이 쓰면 오히려 새로운 문제(경계 없는 공유)를 만들게 된다. 이를 막기 위해서는 공통 모듈을 최소화할 뿐만 아니라, 모듈 경계와 아키텍처 구조를 잘 수립해두어야 한다.
레이어드 아키텍처를 배운 사람이라면 자연스럽게 Controller, Service, Repository를 각각 모듈로 쪼개는 방식을 생각할 수 있다. 하지만 모듈 경계는 요청이 처리되는 단계(수평)가 아니라, 추상화와 재사용 수준(수직) 축이어야 한다. 레이어는 역할의 축이고 모듈은 포장의 축이라 서로 독립적인 개념이기 때문이다.
프로젝트/
├── settings.gradle
├── build.gradle
├── xx-app-external-api/ // 애플리케이션 계층 (외부 API 서버)
├── xx-app-batch/ // 애플리케이션 계층 (배치)
├── core-web/ // 내부 모듈 계층 (Web Filter로 보안, 로깅)
├── xxx-client/ // 내부 모듈 계층 (외부 시스템 통신)
├── xxx-event-publisher/ // 내부 모듈 계층 (이벤트 발행)
├── order-domain/ // 도메인 계층 (도메인별로 하나씩)
├── yaml-importer/ // 독립 계층 (yaml 자동 로딩)
├── data-dynamo-reactive/ // 독립 계층 (DynamoDB 매핑)
└── common/ // 공통 계층 (순수 자바 타입, 유틸)
실행 가능한 앱. 하위 전 계층에 의존할 수 있다.
xx-app-batch, xx-app-worker, xx-app-internal-api, xx-app-external-api처럼 역할별로 이름을 붙인다.
시스템과 관련은 있지만 도메인 비즈니스는 모르는 기술 코드.
예시로는 core-web(Web Filter로 보안과 로깅을 처리), xxx-client(외부 시스템 통신), xxx-event-publisher(SQS나 로깅으로 이벤트 처리)가 있다.
1모듈 1인프라 원칙을 지킨다. 발표에서는 RDBMS 도메인 모듈에 기능 하나 때문에 Redis 의존성을 얹으면 의존성 관리가 힘들어진다고 설명한다. 왜냐면 모듈은 소비자가 의존할 때 선택할 수 있는 최소의 단위이다. 만약 RDBMS와 Redis를 하나로 묶어서 모듈을 만든다면 RDBMS만 필요한 소비자도 Redis 의존성, 자동 설정, 커넥션까지 떠안게 된다.
정말로 인프라 두 개가 필요한 도메인이면 도메인을 나눠 애플리케이션 계층에서 조합하거나 인프라 책임이 없는 중간 모듈로 조율하는 것을 권장한다.
프로젝트에 대한 의존이 없는, 오픈소스로 떼어내도 될 정도의 모듈.
yaml-importer(스프링의 EnvironmentPostProcessor 사이클을 이용해 yaml을 자동으로 로딩하는 도구), data-dynamo-reactive(Reactor용 DynamoDB 매핑) 같은 예시가 있다.
순수 자바 타입과 유틸만. 의존성이 0이고, 가능하면 아예 안 쓰는 걸 권장한다.
레이어는 역할의 축, 모듈은 포장의 축이라 서로 독립이기 때문에, 오히려 공유될 서비스일수록 하위(도메인) 모듈에 두어야 한다. 이 코드를 어떤 앱들이 쓰는가를 기준으로 생각해보면 좋다.
여기서 또 갈리는 부분은 애플리케이션 비즈니스(요청을 검증하고 흐름을 조립하는 것)와 도메인 비즈니스(생성, 변경, 소멸 같은 진짜 규칙)의 구분이다. 전자는 애플리케이션 계층, 후자는 도메인 계층에 둔다.
Application.java는 항상 최상위 패키지에 둔다. 모든 모듈이 같은 상위 패키지(예: com.baemin.xxx)를 쓰게 하기 위해서다.implementation으로 선언해 숨긴다. A를 의존하는 B가 A를 implementation으로 선언하면, B를 의존하는 C는 A에 접근하지 못한다.@ConditionalOnMissingBean이다. 하위 모듈이 기본 Bean을 등록해두고, 상위 모듈이 같은 타입의 Bean을 정의하면 그쪽이 우선한다. 다른 하나는 Customizer 패턴이다. List<Jackson2ObjectMapperBuilderCustomizer>처럼 커스터마이저 목록을 주입받아서, Bean 자체를 통째로 갈아끼우지 않고도 필요한 부분만 조정할 수 있게 한다.도메인이 저장소 인터페이스만 알고 실제 JPA 구현은 따로 감싸둔다면 나중에 데이터베이스를 바꿔도 도메인 코드는 건드리지 않아도 된다. 하지만 이 발표에서는 그렇게 분리하지 않고 도메인 모듈 내에서 즉시 JPA를 사용한다. 인프라 교체는 실제로 잘 일어나지 않는데, 그 드문 상황을 위해 인터페이스를 만들어두면 평소엔 형식적인 코드만 늘어난다는 게 이유다.
한 줄로 정리하면, 이 사례는 추상화/재사용 수준을 기준으로 변경 전파의 방향을 통제한 사례다. 위(구체적, 자주 변함)에서 아래(범용적, 잘 안 변함)로만 의존하게 하면, 자주 변하는 것의 변경이 아래로까지 전파되지 않는다.
인프콘 2022에서 발표된 멀티모듈 세션이다.
이 발표는 실제로 겪은 네 가지 문제에서 출발한다. 커넥션이 감당 못 할 정도로 몰리는 문제(Too many connections), 하위 버전을 참조하고 있어서 업그레이드가 곤란해지는 NoClassDefFoundError, 방어적인 if-else가 붙는 문제, 그리고 전체를 다 같이 빌드하고 배포해야 하는 문제다.
Too many connections라는 에러는 커넥션 풀 자체가 너무 많이 사용되면 발생하는 에러다. 공통 모듈에 인프라 설정이 들어있으면 그 인프라가 필요 없는 앱까지 의존성으로 그 설정을 강제로 물려받는다. 스프링의 자동 설정은 클래스패스에 관련 클래스가 있기만 해도 실행되기 때문에 DB를 안 쓰는 앱도 커넥션 풀을 할당받게 하는 것이다.
NoClassDefFoundError도 비슷한 배경에서 나온다. 공통 모듈을 새 버전으로 올려도 이걸 쓰는 앱들이 전부 한 번에 버전을 맞춰 올리는 게 아니다. 어떤 앱은 컴파일할 때 있던 클래스가 실행 시점엔 없거나 다른 버전으로 바뀌어 있어서 이 에러를 만난다. 그래서 공통 모듈을 고칠 때마다 그걸 쓰는 앱 전부가 버전을 맞춰야 하는 부담이 계속 쌓인다.
버전을 맞춰야 하므로 자연스럽게 한 서버만 빌드하고 배포하는 것이 아니라 모든 서버를 의존성 순서에 맞춰 빌드하고 배포하게 된다. 빌드와 배포에 의존성이 생기므로 자연스럽게 배포 속도가 느려지게 된다.
모듈을 나눌 때 항상 지켜야 하는 원칙이 있다. 각 모듈이 딱 하나의 역할과 책임만 지고, 모듈끼리 주고받는 관계가 꼬여있지 않아야 한다는 것이다.
이 원칙을 지키면서 실제로 정해야 하는 건 도메인으로 나눌 것인가, 기술로 나눌 것인가다. 기술 기준으로 나누면 애매한 지점이 생긴다. 한 도메인이 MySQL과 MongoDB를 같이 쓰면, 그 코드를 어느 모듈에 둬야 할지 기준이 안 선다. 처음엔 이게 커넥션 문제의 연장선이라고 생각했는데, 사실 이건 별개의 문제다. 커넥션은 결과로 나타나는 증상이고, 본질은 분류 기준(기술)이 코드가 실제로 뭉쳐 있어야 하는 단위(도메인)와 어긋난다는 점이다.
그래서 이 발표의 결론은 도메인, 그러니까 Bounded Context를 기준으로 나누는 것이다. CORE나 COMMON 같은 이름의 모듈은 아예 없애고 약간의 중복을 감수한다. 그리고 각 도메인 안에서 다시 역할별로 boot, data, infra, cloud 네 그룹으로 나눈다.
여기서 그룹과 모듈을 헷갈리기 쉬운데, boot/data/infra/cloud는 모듈이 아니라 폴더에 가깝다. 그 아래에 실제 모듈(프로젝트)이 여러 개 들어간다.한 Gradle 빌드 안에서 그룹 폴더 아래에 서브 프로젝트를 만드는 것이고, 실행 가능한 앱은 boot 그룹에만 있다.
한 줄로 정리하면, 이 사례는 변경 주기를 기준으로 한 사례이다. 잦은 변화(boot), 데이터와 밀접(data), 한번 만들면 잘 안 변하지만 변하면 파급이 큰 것(infra), 실행 환경(cloud)처럼 변화의 빈도와 파급이 다른 것들을 같은 모듈에 두지 않는다.
인프콘 2023에서 발표된 우리는 이렇게 모듈을 나눴어요: 멀티 모듈을 설계하는 또 다른 관점이다.
이 발표를 관통하는 동기는 공통 모듈을 고치는 게 두려워지는 경험이다. 어디까지 영향이 갈지 가늠이 안 되니 아무도 손을 안 대고 그러다 보면 개발 독립성과 배포 독립성이 뒤섞이고, 나중엔 이 코드가 왜 이렇게 짜였는지 히스토리조차 파악하기 힘들어진다.
기준은 이해관계자(액터)와 SRP다. 변경 요청은 결국 사람(이해관계자)에게서 오므로, 액터가 다르면 변경의 이유와 시점이 다르고, 그래서 서로 다른 액터를 섬기는 코드는 겉보기에 비슷해도 묶으면 안 된다(SRP).
모듈은 세 단계를 거쳐 분리한다.
한 줄로 정리하면, 이 사례는 변경의 이유(액터)라는 축으로 자르되, 확실한 것(시스템 무관 유틸)부터 점진적으로 떼어낸 사례다.
세 사례를 연달아 보면서 처음엔 오히려 혼란스러웠다. 5계층, 4그룹, 3단계 분리 등등... 기준이 전부 달라서 그래서 뭐가 맞는 건데?라는 생각이 먼저 들었다.
심지어 나는 바운디드 컨텍스트 기준으로 모듈을 나눴었는데, 발표들을 보고 나서 내 방식이 틀린 건가 하는 의심까지 했다. 그런데 멀티 모듈의 정의로 돌아가 보면 멀티 모듈은 한 빌드 안의 서브프로젝트들이라는 물리적 구성이지, 무엇을 기준으로 나누는지는 정의에 없다. 그러니까 정답이 있다기보다는 같은 원칙을 각자의 상황(팀, 프로젝트 등)에 맞게 적용하면 된다.
다만 맞는 방향은 있다. 아래 세 가지 방향이다.
세 사례는 서로 다른 용어를 쓰지만 결국 같은 원칙을 말하고 있다. 사례①은 추상화와 재사용 수준을, 사례②는 boot 그룹을 변화가 잦은 것으로 구분하는 데서 보이듯이 변경 주기를, 사례③은 수준(변경 속도)과 액터를 기준으로 삼는다. 표현은 다 다르지만, 결국 같은 이유로 같은 시점에 변하는 코드들을 묶고 다른 이유로 변하는 코드들은 떼어놓으라는 이야기다. 그래야 소비자가 필요한 것만 골라 의존할 수 있다.
아티클이나 연사자마다 기준이 달라 보이는 이유는 각 프로젝트마다 무엇이 함께 변하는지가 다르기 때문이다. 정해진 답이 있다기보다는 우리 팀에서는 무엇이 같이 바뀌는가를 스스로 관찰해야 한다.
공통 모듈을 얼마나 허용할지는 사례마다 다르다. 사례①은 순수 자바 타입 정도로 한정하고 가능하면 아예 안 쓰는 걸 권장한다. 사례②는 아예 삭제하고 약간의 중복을 감수한다. 사례③은 그 중간쯤에서, 시스템에 전혀 종속되지 않는 것만 global-utils로 남기는 절충안을 택했다.
공통 모듈은 최소화를 하되, 팀 상황에 맞게 구성하는 게 맞는 것 같다.
사례① 사례② 모두 도메인이나 데이터 모듈이 저장소를 직접 다룬다. 인터페이스로 감싸서 구현을 숨기는 대신, 실제 구현에 바로 의존하는 쪽을 택했다는 공통점이 있다. 대신 그 인프라를 다른 기술과 섞지 않고 한 모듈에 하나씩만 격리한다. 인프라 교체가 실제로 드물게 일어난다고 보고 형식적인 인터페이스를 미리 만들어두기보다는 필요한 인프라만 모듈 단위로 나누는 쪽을 택한 것이지만, 이는 팀 별로 선택에 가까운 것 같다.
무엇이 함께 변하는가를 기준으로 프로젝트에 먼저 적용해보려 한다.
우리는 추후 주문 서버, 상품 서버처럼 도메인 단위로 서비스를 분리할 가능성을 고려해서 멀티 모듈 구조를 구현하기로 했다. 그렇다면 우리에게 예정된 가장 큰 변경은 서비스 분리이며 변경은 도메인 단위로 일어난다. 그래서 이를 기준으로 프로젝트 아키텍처에 적용해보고자 한다.