140개 서비스를 하나로 합친 팀의 선택 — 모듈러 모놀리스(Modular Monolith)가 다시 주목받는 이유
솔직히 말하면, 저도 한때 마이크로서비스를 꽤 오래 맹신했습니다. "서비스 하나가 다운돼도 나머지는 살아있잖아요"라는 말이 너무 그럴싸했거든요. 그런데 실무에서 3명이서 40개 서비스를 운영해보고, 배포 파이프라인 디버깅에 하루를 통째로 날려본 뒤에야 슬슬 의심이 생기기 시작했습니다. CNCF 2025 서베이에서 마이크로서비스를 도입한 조직의 42%가 서비스를 다시 큰 단위로 통합하고 있다는 결과가 나왔을 때, 그건 저만의 경험이 아니었다는 걸 알았습니다.
이 글은 마이크로서비스를 운영하고 있거나 도입을 고민 중인 백엔드 개발자를 위해 썼습니다. 요즘 업계에서 **모듈러 모놀리스(Modular Monolith)**가 왜 다시 주목받는지, 어떻게 경계를 설계하고 코드로 강제하는지, 그리고 어떤 상황에선 여전히 마이크로서비스가 맞는지를 실제 사례와 코드로 풀어봅니다. 이 글을 끝까지 읽으시면 지금 당장 코드베이스에 경계를 그어볼 수 있는 구체적인 방법과 도구를 가져가실 수 있습니다.
Modular Monolith는 "마이크로서비스를 포기한 것"이 아닙니다. 단일 배포의 단순함을 유지하면서, 내부 코드는 마이크로서비스만큼 명확하게 분리하는 방식입니다. Twilio Segment는 140개 서비스를 하나로 합치면서 개발 생산성을 극적으로 끌어올렸고, Amazon Prime Video는 분산 마이크로서비스를 단일 프로세스로 전환해 인프라 비용을 90% 절감했습니다.
이 글의 핵심 3가지
- 모듈러 모놀리스 ≠ Big Ball of Mud — 경계 설계가 핵심이다
- NestJS·Spring·Rails 코드로 실제 경계를 강제하는 방법을 확인할 수 있다
- 언제 쓰고 언제 쓰지 말아야 하는지 판단 기준을 얻을 수 있다
핵심 개념
Big Ball of Mud vs. Modular Monolith — 경계가 전부다
전통적인 모놀리스와 Modular Monolith의 차이는 코드 양이 아니라 경계(Boundary)의 존재 여부입니다. Big Ball of Mud는 코드가 서로를 마음대로 참조하고, DB 테이블도 어디서든 접근 가능합니다. Modular Monolith는 다릅니다.
| 특성 | Big Ball of Mud | Modular Monolith | 마이크로서비스 |
|---|---|---|---|
| 배포 단위 | 단일 | 단일 | 독립적 |
| 코드 경계 | 없음 | 명시적 모듈 경계 | 서비스 경계 |
| DB 접근 | 공유 | 모듈별 격리 | 서비스별 DB |
| 모듈 간 통신 | 직접 참조 | 퍼블릭 API | 네트워크(HTTP/gRPC) |
| 모듈 단위 선택적 스케일링 | 불가 | 불가 | 가능 |
핵심 정의 Atlassian은 Modular Monolith를 "단일 프로세스가 별도의 모듈로 구성된 변형으로, 각 모듈은 독립적으로 작업할 수 있지만 배포하려면 모두 합쳐져야 하는 아키텍처"로 정의합니다. 마이크로서비스가 배포 수준의 분리라면, Modular Monolith는 논리적 코드 수준의 분리입니다.
팀 규모가 아키텍처를 결정한다
2025년 업계 컨센서스는 꽤 명확하게 수렴하고 있습니다.
| 팀 규모 | 권장 아키텍처 | 이유 |
|---|---|---|
| 1–10명 | 모놀리스 | 조율 오버헤드 > 독립 배포 이득 |
| 10–50명 | Modular Monolith | 경계 설계 + 단일 배포 단순성의 균형점 |
| 50명 이상 | 마이크로서비스 | 팀 간 독립 배포가 실질적 병목 해소 |
Gartner 2025 보고서는 중소형 앱에서 마이크로서비스를 선택한 팀의 60%가 후회한다고 밝혔습니다. 이 숫자가 충격적이라면, 아마 지금 그 60% 중 하나이거나, 운 좋게 아직 피한 쪽일 겁니다.
모듈 경계 설계의 원칙
모듈은 도메인(Domain) 기반으로 나누는 것이 출발점입니다.
Bounded Context란? DDD(Domain-Driven Design)의 핵심 개념으로, 특정 도메인 모델이 유효한 명시적 경계를 의미합니다. "주문(Order)"이라는 개념이 결제 맥락에서는 "청구 금액이 있는 요청"이고, 배송 맥락에서는 "보낼 주소가 있는 물건"일 수 있습니다. 이처럼 같은 단어가 서로 다른 의미를 가질 때, 각각을 별도의 Bounded Context(= 모듈)로 분리하는 것이 모듈 설계의 시작점입니다.
src/
├── modules/
│ ├── order/ ← 주문 도메인 모듈
│ │ ├── api/ ← 외부에 노출하는 퍼블릭 API
│ │ ├── domain/ ← 내부 도메인 로직
│ │ ├── infra/ ← DB, 외부 통신
│ │ └── index.ts ← 모듈 진입점 (이것만 public)
│ ├── payment/
│ │ ├── api/
│ │ ├── domain/
│ │ └── index.ts
│ └── inventory/
│ ├── api/
│ ├── domain/
│ └── index.ts
└── shared/ ← 전체 공유 유틸리티 (최소화)핵심 규칙 모듈 간 통신은 반드시
index.ts(또는 퍼블릭 API)를 통해서만 허용됩니다.order모듈이payment/domain/PaymentProcessor.ts를 직접 임포트하는 순간, 경계가 무너지기 시작합니다.
개념을 잡았으니, 이제 실제 코드로 이 경계를 어떻게 강제하는지 생태계별로 살펴봅니다.
실전 적용
예시 1: NestJS로 모듈 경계 강제하기
NestJS는 모듈 시스템이 언어 레벨에 내장되어 있어서 Modular Monolith 구현에 꽤 자연스럽습니다. 실무에서 자주 맞닥뜨리는 상황인데, 처음엔 "NestJS의 @Module() 데코레이터로 정의하는 단위 = Modular Monolith 모듈"이라고 착각하기 쉽습니다. 실제로는 도메인 기반으로 더 크게 묶어야 합니다.
아래 예시에서 @modules/order 같은 경로 alias를 쓰려면 먼저 tsconfig.json에 경로 매핑이 필요합니다.
// tsconfig.json — @modules/* alias 설정 (이 설정 없이는 임포트가 동작하지 않음)
{
"compilerOptions": {
"paths": {
"@modules/*": ["src/modules/*"]
}
}
}// modules/order/index.ts — 이것만 public
export { OrderService } from './api/order.service';
export { CreateOrderDto } from './api/dto/create-order.dto';
export { OrderCreatedEvent } from './api/events/order-created.event';
// domain/, infra/ 내부 구현체는 절대 export하지 않음
// modules/order/order.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([OrderEntity]), // Order 모듈만의 DB 테이블 등록
EventEmitterModule,
],
controllers: [OrderController],
providers: [OrderService, OrderRepository],
exports: [OrderService], // 외부에는 Service만 노출
})
export class OrderModule {}// modules/payment/api/payment.service.ts
// ✅ OK: Order 모듈의 퍼블릭 API만 사용
import { OrderService } from '@modules/order';
// ❌ 경계 위반: 내부 구현체 직접 참조
import { OrderRepository } from '@modules/order/infra/order.repository';이 규칙을 사람이 기억하기만 바라는 건 통하지 않습니다. ESLint로 자동 검사할 수 있습니다.
// .eslintrc.js — 모듈 간 직접 참조 금지
module.exports = {
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['@modules/*/domain/*', '@modules/*/infra/*'],
message: '모듈 내부 구현체는 직접 참조할 수 없습니다. index.ts를 통해 접근하세요.'
}
]
}]
}
};| 코드 패턴 | 허용 여부 | 이유 |
|---|---|---|
import { OrderService } from '@modules/order' |
✅ | 퍼블릭 API 경유 |
import { OrderEntity } from '@modules/order/domain' |
❌ | 내부 구현체 직접 참조 |
이벤트(OrderCreatedEvent) 발행/구독 |
✅ | 느슨한 결합 유지 |
예시 2: Spring Modulith로 경계 검증하기
NestJS가 TypeScript로 Order → Payment 경계 위반을 ESLint 정적 분석으로 잡아냈다면, Java 생태계에서는 Spring Modulith 2.0이 같은 문제를 패키지 구조와 테스트로 풉니다. 즉, 두 예시는 같은 도메인(주문과 결제 간 경계)을 다른 언어·도구로 해결하는 것입니다.
저도 처음엔 헷갈렸는데, Spring Modulith은 별도 설정 없이 패키지 구조만으로 모듈 경계를 인식하고, 테스트에서 위반을 자동으로 감지합니다.
// 패키지 구조만으로 모듈 정의
com.example.shop
├── order/ ← Order 모듈 (패키지 = 모듈)
│ ├── OrderService.java ← public: 외부 접근 가능
│ ├── internal/
│ │ └── OrderRepository.java ← package-private: 모듈 내부만
│ └── OrderCreatedEvent.java ← public: 이벤트 공개
├── payment/
│ ├── PaymentService.java
│ └── internal/
│ └── PaymentGateway.java// 모듈 경계 위반을 테스트로 검증
@Test
void 모듈_경계_위반이_없어야_한다() {
ApplicationModules modules = ApplicationModules.of(ShopApplication.class);
modules.verify(); // 경계 위반 시 테스트 실패
}# application.properties — 런타임 검증 활성화 (Spring Modulith 2.0 신기능)
spring.modulith.runtime-verification-enabled=trueruntime-verification-enabled=true를 켜면 애플리케이션 구동 시점에 모듈 간 의존성 위반을 자동으로 감지해 예외를 던집니다. 빌드 타임 테스트를 놓쳤더라도, 프로덕션 배포 전 기동 검증 단계에서 잡아낼 수 있는 안전망이 생기는 셈입니다.
import org.springframework.modulith.events.ApplicationModuleListener;
// 모듈 간 이벤트 기반 통신
@ApplicationModuleListener
public void on(OrderCreatedEvent event) {
// Payment 모듈이 Order 모듈을 직접 의존하지 않고 이벤트로 반응
paymentService.initiate(event.getOrderId(), event.getAmount());
}Spring Modulith 2.0 주요 신기능 런타임 검증(
runtime-verification-enabled), 강화된 이벤트 시스템, Observability(Micrometer 통합)가 추가됐습니다. JetBrains IntelliJ IDEA는 2026년 2월 공식 통합 지원을 시작해, IDE 내에서 모듈 경계를 시각적으로 확인할 수 있게 됐습니다.
여기서 한 가지 자주 받는 질문이 있습니다. "모듈별로 DB를 격리하면 모듈 간 데이터 일관성은 어떻게 유지하나요?" — 이벤트 기반 최종 일관성(Eventual Consistency) 패턴으로 처리하는 것이 핵심입니다. 이 주제는 이 글의 범위를 벗어나므로 아래 '다음 글' 섹션에 별도로 안내했습니다.
예시 3: Shopify의 Packwerk — Rails에서 경계 강제하기
Shopify는 280만 줄 Rails 코드베이스에서 Packwerk로 컴포넌트 경계를 정적 분석으로 강제합니다. Rails Engines를 미니 애플리케이션처럼 활용하는 방식입니다.
# package.yml — 각 패키지(모듈)의 경계 선언
enforce_dependencies: true
enforce_privacy: true
dependencies:
- packs/shared_kernel # 허용된 의존성만 명시# 실행: 의존성 위반 감지
$ bin/packwerk check
No offenses detected 🎉
# 위반이 있을 경우
packs/payment/app/services/payment_service.rb:15:3
Dependency violation: ::Order::InternalRepository
'packs/payment' does not specify a dependency on 'packs/order'Shopify는 6년간 이 여정을 걸었고, 2024년 Rails World 키노트에서 솔직하게 회고했습니다. "실제로 해결하려던 문제를 해결했는가?" — 이 질문이 핵심입니다.
세 생태계 모두 결국 같은 원칙을 다른 도구로 구현하는 겁니다. "외부에서 내부 구현체에 직접 접근하지 못하게 막고, 위반을 자동으로 감지한다." 도구가 다를 뿐, 방향은 동일합니다. 이 원칙이 실제 조직 규모에서 어떻게 작동하는지 보여주는 가장 극적인 사례가 다음입니다.
예시 4: Segment의 역전환 — 140개 → 1개
Twilio Segment의 사례는 가장 극적인 케이스입니다. 2016~2017년 하이퍼그로스 시기에 월 3개씩 새 destination을 추가하면서, 코드 저장소가 폭발했습니다. 결과적으로 3명의 엔지니어가 140개 이상의 서비스를 유지해야 했고, 대부분의 시간이 인프라 소방에 소진됐습니다.
문제 상황:
- 140+ 마이크로서비스
- 3명 엔지니어
- 인프라 관리 비용 > 기능 개발 시간
- 서비스 간 조율 복잡도: O(n²) 증가
해결책:
- 140개 서비스 → 단일 서비스로 통합
- 모노레포(Monorepo) 이전
- 내부는 destination별 모듈로 격리 유지Segment의 교훈 "마이크로서비스는 팀 간 조율 문제를 해결할 때 적합하다. Segment의 문제는 팀이 스스로와 충돌하는 것이었다." — 아키텍처 선택의 본질은 조직 구조와 일치시키는 것입니다.
Amazon Prime Video도 비슷한 경로를 걸었습니다. Video Quality Analysis 시스템을 분산 마이크로서비스에서 단일 프로세스로 전환해 인프라 비용 90% 절감을 달성했습니다. 네트워크 직렬화와 서비스 디스커버리 오버헤드를 제거한 것만으로도 이 정도 차이가 납니다.
장단점 분석
코드 예시들을 살펴보고 나면 자연스럽게 드는 질문이 있습니다. "그래서 이게 항상 맞는 선택인가?" 솔직히 말하면, 그렇지 않습니다. 어떤 상황에서 빛을 발하고 어떤 상황에서 함정이 되는지 정리해봤습니다.
장점
| 항목 | 내용 |
|---|---|
| 운영 단순성 | 단일 배포 아티팩트 — CI/CD 파이프라인이 한결 간결해집니다 |
| 디버깅 효율 | 분산 추적 없이 단일 프로세스 내에서 전체 흐름 추적 가능 (2025년 조사: 마이크로서비스 대비 35% 절감) |
| 개발 생산성 | IDE 리팩터링, 타입 안전 직접 호출이 가능합니다 |
| 점진적 진화 | 경계가 잘 설계됐다면 나중에 개별 서비스로 추출하는 비용이 낮습니다 |
| 트랜잭션 | 로컬 DB 트랜잭션으로 처리 가능 — 분산 트랜잭션(Saga 등)이 필요 없습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 경계 설계 난이도 | 올바른 도메인 경계 설정이 가장 어렵고 오래 걸림 | DDD Bounded Context 워크숍, 이벤트스토밍 활용 |
| 아키텍처 규율 | 팀 의지 없으면 서서히 Big Ball of Mud로 퇴화 | Packwerk/ArchUnit/ESLint 규칙으로 자동 강제 |
| 모듈 단위 선택적 스케일링 불가 | 특정 모듈만 스케일 아웃 불가, 전체가 함께 확장됨 | 트래픽 패턴이 비교적 균일한 서비스에 적합 |
| 결함 격리 취약 | 하나의 모듈 버그(OOM 등)가 전체 프로세스에 영향 | 모듈별 리소스 제한, 서킷브레이커 내부 적용 |
| 기술 스택 단일화 | 모듈별 다른 언어/런타임 사용 불가 | 다중 언어가 필수라면 마이크로서비스가 적합 |
실무에서 가장 흔한 실수
- 경계를 기술 레이어로 나누기 —
controllers/,services/,repositories/패키지로 나누는 것은 모듈러 모놀리스가 아닙니다. 도메인(Order, Payment, Inventory)으로 나눠야 합니다. - Shared 패키지 비대화 — "공통"이라는 이유로
shared/에 뭐든 넣다 보면 결국 모든 모듈이shared/에 의존하는 숨겨진 강결합이 생깁니다.shared/는 날짜 유틸, 로깅 설정처럼 진짜 범용 코드만 담는 것이 좋습니다. - 경계 강제 도구 없이 시작하기 — "팀원들이 알아서 지키겠지"는 통하지 않습니다. Packwerk, ArchUnit, ESLint custom rules 중 하나를 CI에 포함시키는 것을 권장합니다. 경계 위반은 조용히, 빠르게 누적됩니다.
마치며
이 글을 여기까지 읽으셨다면 이제 세 가지를 알게 된 겁니다. 모듈러 모놀리스가 "그냥 옛날 방식으로 회귀"가 아니라는 것, 경계를 코드와 도구로 강제하는 구체적인 방법이 존재한다는 것, 그리고 내 팀 규모와 상황에 따라 이 아키텍처가 맞을 수도 맞지 않을 수도 있다는 것. Modular Monolith는 "마이크로서비스를 포기한 것"이 아니라, 복잡성의 출처를 제대로 찾아서 필요한 곳에만 분산을 적용하는 선택입니다.
지금 바로 시작해볼 수 있는 3단계입니다.
- 현재 코드베이스의 도메인 경계 그려보기 — 팀 리드나 동료와 함께 화이트보드나 Miro에서 "우리 서비스의 핵심 도메인이 뭔지" 이야기해보시면 좋습니다. 주문, 결제, 사용자, 알림 — 이 경계들이 모듈의 초안이 됩니다. 혼자 시작하신다면 현재 코드베이스의 의존성 그래프를 먼저 시각화해보시는 것부터 출발할 수 있습니다.
- 경계 강제 도구 하나 CI에 추가하기 — 혼자서도 바로 시작할 수 있는 단계입니다. Java라면
ApplicationModules.of(App.class).verify()테스트 하나, TypeScript라면 ESLintno-restricted-imports규칙 추가로 시작할 수 있습니다. Spring Modulith를 쓴다면spring.modulith.runtime-verification-enabled=true설정도 함께 켜두시면 좋습니다. - 가장 독립적인 모듈 하나부터 리팩터링 — 알림(Notification)처럼 다른 도메인의 의존을 덜 받는 모듈부터 시작하는 것이 안전합니다. Strangler Fig 패턴처럼, 한 번에 전체를 바꾸려 하지 않고 하나씩 이동하는 방식이 실제로 성공하는 방식입니다.
참고 자료
- Under Deconstruction: The State of Shopify's Monolith | Shopify Engineering
- Deconstructing the Monolith | Shopify Engineering
- How Shopify Migrated to a Modular Monolith | InfoQ
- Goodbye Microservices: Segment Case Study | Twilio Blog
- To Microservices and Back Again | InfoQ
- Spring Modulith 공식 문서
- Migrating to Modular Monolith using Spring Modulith and IntelliJ IDEA | JetBrains Blog
- What Is a Modular Monolith? | Milan Jovanović
- Modular Monolith Architecture in Cloud Environments | MDPI Future Internet
- Modular Monolith Architecture with .NET | ABP.IO
- 2-Tier to 3-Tier Architecture Migration with Modular Monolith and GraphQL | TheT-Shaped Dev
- NestJS Modular Monolith with DDD | GitHub