모듈러 모놀리스에서 마이크로서비스로: 경계 설계가 서비스 추출의 성패를 가른다
"마이크로서비스로 전환해야 할 것 같은데, 어디서부터 손대야 할지 모르겠다." 솔직히 저도 처음 이 고민을 했을 때 막막했습니다. 결제 서비스를 분리하면 좋겠다 싶어서 Kubernetes 클러스터를 늘리고 코드를 쪼갰는데, 두 달 뒤에 남은 건 급증한 AWS 청구서와 팀원들의 피로감뿐이었습니다. 결제와 주문이 공유 DB 조인 쿼리 수백 개로 뒤엉켜 있다는 걸 분리하고 나서야 깨달은 거죠.
마이크로서비스 도입 현황을 분석한 여러 자료에 따르면, 도입한 조직의 42%가 일부 서비스를 다시 더 큰 배포 단위로 통합했습니다. Amazon Prime Video 팀은 분산 마이크로서비스에서 단일 프로세스로 전환해 인프라 비용을 90% 줄였고, Shopify는 모듈러 모놀리스로 Black Friday 분당 30TB 트래픽을 무중단 처리했습니다. 업계가 "마이크로서비스 회귀(Microservices Regression)"를 이야기하기 시작한 겁니다.
이 글에서는 모듈러 모놀리스의 경계를 설계하고, 준비가 됐을 때 서비스를 안전하게 추출하는 전략을 단계별로 풀어봅니다. 백엔드 이야기만은 아닙니다 — Nx 모노레포의 모듈 경계 설계, Module Federation 도입 여부 결정, 번들 크기 최적화 모두 같은 원칙에서 출발하기 때문에, 프론트엔드 중심으로 일하는 분들께도 직접적으로 와닿는 내용이 될 겁니다.
핵심 개념
전통적 모놀리스 vs 모듈러 모놀리스 — 무엇이 다른가
모놀리스가 무조건 나쁜 건 아닙니다. 문제는 코드가 "스파게티"처럼 서로 얽히는 방식입니다. 전통적 모놀리스에서는 UserService가 직접 OrderRepository를 호출하고, ProductModule이 InventoryDB 테이블을 직접 조인하는 일이 흔합니다. 처음엔 빠른데, 6개월 뒤에 어디 하나 건드리면 예상치 못한 곳이 터집니다.
프론트엔드 개발자 분들께 비유하자면, UserProfile 컴포넌트가 OrderHistory의 내부 훅을 직접 import하고, CartContext가 UserContext의 내부 상태를 직접 접근하는 상황과 같습니다. 당장은 동작하지만, 리팩터링할 때 뭘 바꾸면 어디가 깨지는지 알 수 없는 상태가 됩니다.
모듈러 모놀리스는 단일 배포 단위를 유지하면서 내부를 독립적인 모듈로 엄격하게 나눕니다. 각 모듈은 명시적인 Facade 인터페이스를 통해서만 통신하고, 자신의 DB 스키마만 소유합니다.
// ❌ 전통적 모놀리스 — 다른 모듈의 내부를 직접 참조
import { OrderRepository } from '../order/repositories/order.repository';
import { inventoryDb } from '../inventory/db/connection';
class UserService {
constructor(private orderRepo: OrderRepository) {}
async getUserWithOrders(userId: string) {
const inventory = await inventoryDb.query(
`SELECT * FROM inventory WHERE user_id = ?`, [userId]
);
return { user: await this.orderRepo.findByUser(userId), inventory };
}
}// ✅ 모듈러 모놀리스 — Facade를 통한 명시적 인터페이스만 허용
// order/order.facade.ts (공개 인터페이스)
export class OrderFacade {
constructor(private orderService: OrderService) {}
async getOrderSummaryByUser(userId: string): Promise<OrderSummaryDto> {
return this.orderService.getSummary(userId);
}
}
// user/user.service.ts
import { OrderFacade } from '../order/order.facade'; // ✅ Facade만 import
class UserService {
constructor(private orderFacade: OrderFacade) {}
async getUserDashboard(userId: string) {
const orderSummary = await this.orderFacade.getOrderSummaryByUser(userId);
return { user: await this.findById(userId), orderSummary };
}
}경계를 설계하는 세 가지 원칙
Bounded Context(경계 컨텍스트): DDD(도메인 주도 설계)에서 도메인 모델이 유효한 명확한 경계를 의미합니다. 쉽게 말하면, "Order(주문)"라는 단어가 팀마다 다른 의미를 가질 때 각각을 독립된 컨텍스트로 분리합니다. 결제팀의 Order는 금액과 승인 상태가 핵심이고, 배송팀의 Order는 물류 주소와 배송 상태가 핵심입니다 — 같은 단어지만 전혀 다른 모델인 거죠.
모듈러 모놀리스를 만든다고 해서 나중에 자동으로 마이크로서비스로 분리되는 건 아닙니다. 경계 설계 단계에서 아래 세 가지를 지켜야 추출이 수월해집니다.
| 원칙 | 나쁜 예 | 좋은 예 |
|---|---|---|
| 모듈 간 직접 DB 참조 금지 | SELECT * FROM orders JOIN inventory ON ... |
OrderFacade.getInventoryStatus() 호출 |
| 명시적 Facade 인터페이스 | import { InternalUserModel } from '../user/models' |
import { UserFacade } from '../user' |
| 논리적 데이터 분리 선행 | 물리적 DB 분리부터 시도 | 스키마 prefix 분리 후 물리적 분리 |
실무에서 자주 맞닥뜨리는 함정은 물리적 DB 분리를 먼저 하려다가 공유 조인 쿼리가 수백 개라는 걸 뒤늦게 발견하는 경우입니다. 논리적 분리를 먼저 — 같은 DB에서 스키마 prefix나 네임스페이스로 소유권을 명확히 한 뒤 물리적으로 분리하는 순서가 훨씬 안전합니다.
그러면 언제 마이크로서비스로 추출하는 게 맞을까요? Shopify가 Black Friday 대응 아키텍처에서 실제로 적용하는 추출 기준이 네 가지입니다. 이 중 하나라도 해당되지 않는다면 모듈 경계만 잘 그어두는 것으로 충분합니다:
- 독립적 스케일이 필요할 때 — 해당 모듈만 트래픽이 폭증해 별도로 확장해야 하는 상황
- 프라이버시·보안 격리가 필요할 때 — PCI-DSS 같은 규제 요건으로 완전한 격리가 필요한 경우
- 다른 기술 스택에 의존할 때 — 메인 서버와 다른 언어나 런타임이 필요한 컴포넌트
- 기능이 성숙하고 안정화됐을 때 — 도메인 모델이 안정적이어서 경계가 자주 바뀔 일이 없을 때
프론트엔드에서의 모듈 경계: Nx로 강제하기
백엔드 이야기만 하면 아쉬우니, 프론트엔드 쪽도 같이 살펴봅니다. Nx 모노레포를 쓴다면 enforce-module-boundaries 규칙으로 빌드 타임에 경계 위반을 잡을 수 있습니다.
// libs/order-module/src/index.ts — 공개 API만 export
export { OrderSummaryComponent } from './components/OrderSummary';
export { useOrderStatus } from './hooks/useOrderStatus';
export type { OrderDto } from './types';
// 내부 구현체는 절대 export하지 않음// nx.json — 모듈 간 허용된 의존성만 통과시키는 경계 규칙
{
"pluginsConfig": {
"@nx/eslint-plugin": {
"rules": {
"enforce-module-boundaries": [
"error",
{
"depConstraints": [
{
"sourceTag": "scope:app",
"onlyDependOnLibsWithTags": ["scope:feature", "scope:ui", "scope:util"]
},
{
"sourceTag": "scope:feature",
"onlyDependOnLibsWithTags": ["scope:ui", "scope:util"]
},
{
"sourceTag": "scope:ui",
"onlyDependOnLibsWithTags": ["scope:util"]
}
]
}
]
}
}
}
}
scope:feature라이브러리가 다른scope:feature라이브러리를 직접 import하려 하면 빌드가 깨집니다. ArchUnit이 Java 백엔드에서 아키텍처 규칙을 테스트로 강제하는 것처럼, Nx의enforce-module-boundaries는 프론트엔드 모노레포에서 같은 역할을 합니다.
실전 적용
Strangler Fig 패턴으로 점진적 서비스 추출
"빅뱅 리라이트"는 대부분 실패합니다. 저도 한 번 경험해봤는데 — 6개월짜리 전면 재작성 프로젝트가 결국 절반만 완성된 채 원래 시스템과 병렬 운영되는 상황이 됐습니다. Strangler Fig 패턴은 기존 모놀리스를 살려두면서 새 서비스로 트래픽을 조금씩 옮기는 방식입니다. 무화과나무(Strangler Fig)가 숙주 나무를 감싸며 자라다가 결국 대체하는 것처럼, 기존 코드를 건드리지 않고도 새 기능을 끼워 넣다가 결국 완전히 대체하게 됩니다.
API Gateway 또는 NGINX가 Intercepting Facade 역할을 맡아, 특정 경로만 신규 서비스로 라우팅하고 나머지는 레거시 모놀리스로 보냅니다:
# /etc/nginx/conf.d/strangler-fig.conf
upstream monolith {
server legacy-app:3000;
}
upstream new-payment-service {
server payment-service:3001;
}
server {
listen 80;
# 새 서비스가 준비된 경로만 신규 서비스로 라우팅
location /api/v2/payments {
proxy_pass http://new-payment-service;
proxy_set_header X-Migration-Phase "strangler-fig";
}
# 나머지는 여전히 레거시 모놀리스로
location / {
proxy_pass http://monolith;
}
}레거시 모놀리스와 신규 서비스는 데이터 모델이 다를 수밖에 없습니다. 이 간극을 메우는 게 Anti-Corruption Layer입니다:
// payment-service/src/acl/legacy-order.adapter.ts
interface LegacyOrderModel {
order_id: number; // 레거시는 숫자 ID + snake_case
user_id_fk: number;
total_amount: string; // 문자열로 저장된 금액
created_dt: string;
}
interface PaymentOrderDto {
orderId: string; // 신규 서비스는 UUID + camelCase
userId: string;
amount: Money; // dinero.js 같은 금액 라이브러리로 구현 가능
createdAt: Date;
}
export class LegacyOrderAdapter {
translate(legacy: LegacyOrderModel): PaymentOrderDto {
return {
orderId: `order-${legacy.order_id}`,
userId: `user-${legacy.user_id_fk}`,
amount: Money.fromString(legacy.total_amount, 'KRW'),
createdAt: new Date(legacy.created_dt),
};
}
}Anti-Corruption Layer(ACL): 레거시 시스템의 데이터 모델이 신규 서비스의 도메인 모델을 오염시키지 않도록 격리하는 번역 계층입니다. 레거시의
user_id_fk(숫자)가 신규 서비스의userId(문자열 UUID)로 변환되는 지점이 바로 ACL입니다.
| 단계 | 트래픽 분기 | 내부 구현 방식 | 리스크 |
|---|---|---|---|
| 초기 | 100% → 레거시 모놀리스 | — | 없음 |
| 파일럿 | 내부 트래픽 → 신규 서비스 | 쿠키·요청 헤더 기반 내부 사용자 분기 | 낮음 |
| 점진 이전 | 10% → 50% → 90% → 신규 서비스 | Feature Flag + 카나리 배포 | 모니터링 필수 |
| 완료 | 100% → 신규 서비스, 레거시 경로 제거 | — | 없음 |
도메인 이벤트로 모듈 간 결합도 제거
직접 함수 호출 대신 이벤트를 사용하면, 나중에 서비스를 물리적으로 분리할 때 Kafka나 RabbitMQ로 이벤트 브로커만 교체하면 됩니다. OrderService와 InventoryEventHandler 코드는 건드릴 필요가 없어집니다.
우리 팀에서 이 패턴을 처음 도입했을 때 가장 많이 들은 이야기가 "모놀리스 단계에서 왜 이벤트까지 써야 하나?"였는데 — 실제로 서비스 추출 시점에 인터페이스가 그대로 유지되는 걸 경험하고 나서는 의문이 사라졌습니다.
// shared/events/order-events.ts
export interface OrderPlacedEvent {
eventType: 'ORDER_PLACED';
orderId: string;
userId: string;
items: { productId: string; quantity: number; price: number }[];
placedAt: Date;
}
// order/order.service.ts
export class OrderService {
constructor(
private readonly orderRepo: OrderRepository,
private readonly eventBus: EventBus, // 인터페이스에만 의존
) {}
async placeOrder(dto: PlaceOrderDto): Promise<Order> {
const order = await this.orderRepo.save(Order.create(dto));
// 직접 InventoryService를 호출하지 않고 이벤트 발행
await this.eventBus.publish<OrderPlacedEvent>({
eventType: 'ORDER_PLACED',
orderId: order.id,
userId: dto.userId,
items: order.items,
placedAt: new Date(),
});
return order;
}
}
// inventory/inventory.event-handler.ts
@EventHandler('ORDER_PLACED')
export class InventoryEventHandler {
constructor(private inventoryService: InventoryService) {}
async handle(event: OrderPlacedEvent): Promise<void> {
await this.inventoryService.decreaseStock(event.items);
}
}EventBus 인터페이스를 추상화해두면 구현체 교체가 자유로워집니다:
// shared/events/in-process-event-bus.ts (모놀리스 단계)
export class InProcessEventBus implements EventBus {
private handlers = new Map<string, EventHandler[]>();
async publish<T>(event: T & { eventType: string }): Promise<void> {
const handlers = this.handlers.get(event.eventType) ?? [];
await Promise.all(handlers.map(h => h.handle(event)));
}
}
// shared/events/kafka-event-bus.ts (서비스 추출 후 교체)
export class KafkaEventBus implements EventBus {
async publish<T>(event: T & { eventType: string }): Promise<void> {
await this.kafkaProducer.send({
topic: event.eventType,
messages: [{ value: JSON.stringify(event) }],
});
}
}모놀리스 단계에서는 InProcessEventBus가 같은 프로세스 내에서 핸들러를 직접 호출하고, 분리 준비가 되면 KafkaEventBus로만 교체하면 됩니다.
아키텍처 경계를 코드로 강제하기
경계를 코드 리뷰로만 지키는 건 한계가 있습니다. 마감이 다가오거나 새 팀원이 합류하면 경계가 조용히 무너지기 쉬운데, CI에서 자동으로 잡는 방법이 훨씬 안전합니다.
TypeScript / Node.js 팀이라면: Nx + ESLint
앞서 설정한 nx.json의 depConstraints가 이 역할을 합니다. 허용되지 않은 모듈 간 import를 빌드 타임에 오류로 잡아줍니다:
# 경계 위반 시 이런 오류가 납니다
ESLint: A project tagged with 'scope:feature' can only depend on libs
tagged with 'scope:ui', 'scope:util' (@nx/enforce-module-boundaries)Java / Kotlin 백엔드 팀이라면: ArchUnit
// src/test/java/com/example/ArchitectureTest.java
// @AnalyzeClasses: 분석할 패키지 지정 / @ArchTest: JUnit처럼 동작하는 아키텍처 검증 테스트
@AnalyzeClasses(packages = "com.example")
public class ArchitectureTest {
@ArchTest
static final ArchRule order_should_not_access_inventory_internals =
noClasses()
.that().resideInAPackage("..order..")
.should().accessClassesThat()
.resideInAPackage("..inventory.internal..")
.because("모듈 간 통신은 반드시 Facade를 통해야 합니다");
}이 Java 코드를 세세히 이해하지 못해도 괜찮습니다. 핵심은 "아키텍처 규칙을 테스트 코드로 표현할 수 있다"는 것입니다. CI에서 이 테스트가 돌면, 누군가 실수로 경계를 넘는 import를 추가했을 때 PR 단계에서 빌드가 자동으로 깨집니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 낮은 운영 복잡도 | 단일 배포 단위로 네트워크 오버헤드, 분산 트랜잭션이 없습니다 |
| 명확한 추출 경로 | 모듈 경계가 곧 서비스 경계가 되어, 준비됐을 때 추출이 자연스럽습니다 |
| 점진적 전환 | 증명된 필요에 따라 서비스를 추출하며, 가설적 스케일에 미리 대비할 필요가 없습니다 |
| 비용 효율 | 마이크로서비스 대비 인프라·운영 비용이 대폭 줄어듭니다 (3.75~6배 차이) |
| 테스트 용이성 | 인프로세스 테스트로 통합 테스트 작성이 단순해집니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 경계 설계 실패 | 잘못된 경계를 설정하면 나중에 추출 비용이 급증합니다 | EventStorming으로 도메인 이벤트 기반 경계 먼저 도출 |
| 배포 커플링 | 모든 모듈이 동시에 배포되어 개별 배포 독립성이 없습니다 | LaunchDarkly, Unleash 같은 Feature Flag로 기능 단위 릴리즈 분리 |
| 팀 경계 충돌 | 모듈 경계와 팀 구조가 맞지 않으면 Conway's Law로 경계가 무너집니다 | 팀 구조와 모듈 경계를 함께 설계 |
| 전환 시점 판단 | 언제 마이크로서비스로 전환할지 판단이 어렵습니다 | 앞서 소개한 Shopify 4가지 조건 모두 충족 시 추출 |
| 데이터 분리 난이도 | 공유 DB에서 모듈별 스키마 분리가 가장 어렵습니다 | 논리적 분리(스키마 prefix) 먼저, 물리적 분리는 나중에 |
Conway's Law: "소프트웨어 구조는 그것을 만든 조직의 커뮤니케이션 구조를 닮는다." 백엔드팀과 프론트엔드팀이 같은 주문 모듈을 함께 수정하는 상황을 상상해보세요 — 양 팀 모두 편의에 따라 경계를 조금씩 넘다 보면, 아무리 잘 설계된 경계도 흐릿해집니다. 모듈 경계 = 팀 경계가 이상적인 이유가 여기 있습니다.
실무에서 가장 흔한 실수
-
너무 이른 서비스 추출 — 모듈 경계가 검증되기 전에 물리적으로 분리하면, 잘못된 경계가 영구화됩니다. 앞서 소개한 Shopify 4가지 조건(독립 스케일 필요, 보안 격리 필요, 다른 기술 스택 의존, 기능이 성숙·안정화)을 모두 충족할 때 추출하는 것을 권장합니다.
-
Facade 없이 이벤트만 도입 — 도메인 이벤트로 결합도를 줄이면서도 이벤트 페이로드에 모듈 내부 타입을 그대로 노출하면, 이벤트가 또 다른 형태의 결합이 됩니다. Facade와 이벤트를 함께 설계하는 것이 좋습니다.
-
DB 공유 조인 쿼리를 나중에 해결하려는 생각 — "서비스 분리하고 나서 DB도 분리하면 되지"라는 생각이 가장 흔한 함정입니다. 저는 이 실수를 두 번 했는데, 두 번째에는 공유 조인이 300개가 넘어서 결국 서비스 분리를 포기하고 처음부터 논리적 분리부터 다시 해야 했습니다. 논리적 분리를 먼저 진행하는 것이 필수입니다.
마치며
모듈러 모놀리스의 핵심은 "지금 당장 마이크로서비스가 아니어도 괜찮다, 단 경계는 지금 그려두어야 한다"는 데 있습니다.
많은 팀이 선택하는 현실적인 패턴은 "모듈러 모놀리스 코어 + 독립 확장이 필요한 병목 서비스 2~5개 추출"입니다. 42%의 팀이 마이크로서비스를 되돌리는 상황에서, 처음부터 경계를 잘 그린 모듈러 모놀리스로 시작하는 것이 오히려 미래 확장에 더 유리한 경우가 많습니다.
지금 바로 시작해볼 수 있는 3단계:
-
현재 코드베이스 의존성 시각화 — 백엔드라면
madge(Node.js) 또는 ArchUnit의 PlantUML 다이어그램 출력으로, 프론트엔드 모노레포라면nx graph명령어로 의존성 그래프를 확인해보시면 예상치 못한 순환 의존성이 보이기 시작합니다. -
가장 독립적인 모듈 하나 골라 Facade 명문화 —
index.ts또는모듈명.facade.ts에 공개 API만 export하고, 내부 구현체 import를 금지하는 Nxenforce-module-boundaries규칙이나 ArchUnit 테스트를 추가해보시면 됩니다. -
도메인 이벤트 인터페이스 설계 → InProcessEventBus 연결 → 추후 KafkaEventBus로 교체 — 서비스 추출 준비가 됐을 때 구현체만 바꾸면 되도록, 인터페이스 추상화를 미리 갖춰두는 것이 나중에 큰 자산이 됩니다.
참고 자료
- The Modular Monolith 2026 Complete Guide — Spring Modulith, ArchUnit Fitness Functions, and Lessons from Shopify's 30TB/min Architecture
- Modular Monolith: 42% Ditch Microservices in 2026 | byteiota
- Rethinking Microservices in 2026: When Modular Monolith Architecture Actually Wins
- Strangler Fig Pattern Explained: The Safer Path from Monolith to Microservices
- Strangler fig pattern — AWS Prescriptive Guidance
- Strangler Fig Pattern — Azure Architecture Center | Microsoft Learn
- Is the Modular Monolith Shopify's Best-kept Secret to Scaling? | Educative
- How to Use Domain-Driven Design Boundaries When Splitting a Monolith on GCP
- A Comparative Study of Micro-Frontend and Modular Monolith Frontend Architectures
- MSA의 Trade-Off 분석: 모듈러 모놀리스와 마이크로커널 아키텍처 | SK Devocean
- 플랫폼 아키텍처 의사결정 가이드: 모놀리스와 마이크로서비스 사이
- GitHub — kgrzybek/modular-monolith-with-ddd