Modular Monolith
모듈러 모놀리스
Modular Monolith는 마이크로서비스의 모듈성과 모놀리식의 단순함을 결합한 아키텍처입니다. 단일 배포 단위 내에서 명확한 모듈 경계를 유지하여 마이크로서비스로의 점진적 전환이 가능합니다.
모듈러 모놀리스
Modular Monolith는 마이크로서비스의 모듈성과 모놀리식의 단순함을 결합한 아키텍처입니다. 단일 배포 단위 내에서 명확한 모듈 경계를 유지하여 마이크로서비스로의 점진적 전환이 가능합니다.
Modular Monolith는 전통적인 모놀리식의 장점(단순한 배포, 로컬 함수 호출)을 유지하면서 마이크로서비스의 장점(모듈 독립성, 명확한 경계)을 취하는 아키텍처입니다. "잘 설계된 모놀리스"라고도 불립니다.
| 특성 | Traditional Monolith | Modular Monolith | Microservices |
|---|---|---|---|
| 배포 단위 | 1개 | 1개 | N개 (서비스별) |
| 모듈 경계 | 없거나 약함 | 명확함 | 물리적 분리 |
| 통신 방식 | 함수 호출 | 인터페이스 통한 호출 | 네트워크 (HTTP/gRPC) |
| 데이터베이스 | 공유 | 논리적 분리 (스키마) | 물리적 분리 |
| 운영 복잡도 | 낮음 | 낮음 | 높음 |
src/
├── modules/
│ ├── catalog/ # 상품 카탈로그 모듈
│ │ ├── api/ # Public API (다른 모듈이 사용)
│ │ │ ├── CatalogApi.ts
│ │ │ └── types.ts
│ │ ├── domain/
│ │ │ ├── Product.ts
│ │ │ └── Category.ts
│ │ ├── infrastructure/
│ │ │ └── CatalogRepository.ts
│ │ └── index.ts # 모듈 진입점
│ │
│ ├── orders/ # 주문 모듈
│ │ ├── api/
│ │ │ ├── OrderApi.ts
│ │ │ └── types.ts
│ │ ├── domain/
│ │ │ ├── Order.ts
│ │ │ └── OrderItem.ts
│ │ ├── application/
│ │ │ └── PlaceOrderUseCase.ts
│ │ ├── infrastructure/
│ │ │ └── OrderRepository.ts
│ │ └── index.ts
│ │
│ └── payments/ # 결제 모듈
│ ├── api/
│ ├── domain/
│ ├── infrastructure/
│ └── index.ts
│
├── shared/ # 공유 인프라 (최소화)
│ ├── database/
│ └── messaging/
│
└── main.ts # 애플리케이션 진입점
// modules/catalog/api/CatalogApi.ts
// 다른 모듈이 사용할 수 있는 공개 인터페이스
export interface ProductDTO {
id: string;
name: string;
price: number;
stockQuantity: number;
}
export interface CatalogApi {
getProduct(productId: string): Promise<ProductDTO | null>;
getProducts(productIds: string[]): Promise<ProductDTO[]>;
checkAvailability(productId: string, quantity: number): Promise<boolean>;
reserveStock(productId: string, quantity: number): Promise<boolean>;
}
// modules/catalog/index.ts (모듈 진입점)
// 외부에 공개할 것만 export
export type { CatalogApi, ProductDTO } from './api/CatalogApi';
export { createCatalogModule } from './CatalogModule';
// modules/catalog/CatalogModule.ts
import { CatalogApi, ProductDTO } from './api/CatalogApi';
import { ProductRepository } from './infrastructure/ProductRepository';
export function createCatalogModule(db: Database): CatalogApi {
const repository = new ProductRepository(db);
return {
async getProduct(productId: string): Promise<ProductDTO | null> {
const product = await repository.findById(productId);
if (!product) return null;
// Domain → DTO 변환 (내부 구현 숨김)
return {
id: product.id,
name: product.name,
price: product.price.amount,
stockQuantity: product.stock.quantity,
};
},
async getProducts(productIds: string[]): Promise<ProductDTO[]> {
const products = await repository.findByIds(productIds);
return products.map(p => ({
id: p.id,
name: p.name,
price: p.price.amount,
stockQuantity: p.stock.quantity,
}));
},
async checkAvailability(productId: string, quantity: number): Promise<boolean> {
const product = await repository.findById(productId);
return product ? product.stock.isAvailable(quantity) : false;
},
async reserveStock(productId: string, quantity: number): Promise<boolean> {
const product = await repository.findById(productId);
if (!product || !product.stock.isAvailable(quantity)) {
return false;
}
product.stock.reserve(quantity);
await repository.save(product);
return true;
}
};
}
// modules/orders/application/PlaceOrderUseCase.ts
import { CatalogApi } from '../../catalog'; // 모듈 public API만 import
import { OrderRepository } from '../infrastructure/OrderRepository';
import { Order } from '../domain/Order';
export class PlaceOrderUseCase {
constructor(
private catalogApi: CatalogApi, // 인터페이스에 의존
private orderRepository: OrderRepository
) {}
async execute(input: PlaceOrderInput): Promise<PlaceOrderResult> {
// 1. 상품 정보 조회 (Catalog 모듈 API 통해)
const products = await this.catalogApi.getProducts(
input.items.map(i => i.productId)
);
// 2. 재고 확인
for (const item of input.items) {
const available = await this.catalogApi.checkAvailability(
item.productId,
item.quantity
);
if (!available) {
throw new Error(`Product ${item.productId} out of stock`);
}
}
// 3. 주문 생성 (Order 모듈 내부 도메인 로직)
const order = Order.create({
customerId: input.customerId,
items: input.items.map(item => {
const product = products.find(p => p.id === item.productId)!;
return {
productId: item.productId,
productName: product.name,
quantity: item.quantity,
unitPrice: product.price,
};
}),
});
// 4. 재고 예약 (Catalog 모듈 API 통해)
for (const item of input.items) {
await this.catalogApi.reserveStock(item.productId, item.quantity);
}
// 5. 주문 저장
await this.orderRepository.save(order);
return { orderId: order.id, total: order.calculateTotal() };
}
}
// main.ts
import { createCatalogModule } from './modules/catalog';
import { createOrderModule } from './modules/orders';
import { createPaymentModule } from './modules/payments';
import { Database } from './shared/database';
async function bootstrap() {
const db = new Database(process.env.DATABASE_URL);
// 모듈 생성 (의존성 주입)
const catalogApi = createCatalogModule(db);
const paymentApi = createPaymentModule(db);
const orderApi = createOrderModule(db, catalogApi, paymentApi);
// Express 라우터 설정
const app = express();
app.use('/api/catalog', createCatalogRoutes(catalogApi));
app.use('/api/orders', createOrderRoutes(orderApi));
app.use('/api/payments', createPaymentRoutes(paymentApi));
app.listen(3000);
}
bootstrap();
// tests/architecture.test.ts
import { filesOfProject } from 'ts-arch';
describe('Module Boundaries', () => {
it('Orders module should not access Catalog internals', async () => {
const rule = filesOfProject()
.inFolder('modules/orders')
.shouldNot()
.dependOnFiles()
.inFolder('modules/catalog/domain')
.inFolder('modules/catalog/infrastructure');
await expect(rule).toPassAsync();
});
it('Modules should only import from public API', async () => {
const rule = filesOfProject()
.inFolder('modules/orders')
.should()
.onlyDependOn()
.filesMatching('modules/catalog/index.ts')
.or()
.filesMatching('modules/catalog/api/*');
await expect(rule).toPassAsync();
});
});
"마이크로서비스로 가고 싶은데 팀 규모가 작아서 고민이에요. 운영 복잡도를 감당할 수 있을까요?"
"Modular Monolith로 시작하시죠. 모듈 경계를 잘 정의해두면 나중에 팀이 커졌을 때 필요한 모듈만 마이크로서비스로 분리할 수 있어요. 지금은 단일 배포로 운영 부담을 줄이면서 마이크로서비스 전환을 준비하는 거예요."
"주문 모듈에서 상품 테이블을 JOIN해서 조회하면 더 빠르지 않나요?"
"모듈 경계 위반이에요. Catalog 모듈의 DB는 Catalog만 접근해야 해요. 나중에 Catalog를 별도 서비스로 분리할 때 JOIN이 있으면 분리가 불가능해져요. CatalogApi를 통해 데이터를 가져오세요."
"이번 주문 기능 개발에 결제팀 작업이 필요한가요?"
"아니요, PaymentApi 인터페이스는 이미 정의되어 있어서 저희 Orders 모듈에서 독립적으로 개발 가능해요. 모듈 경계가 명확해서 팀 간 의존성이 최소화됩니다."
경계 침범 주의: 모듈 경계가 무너지면 그냥 모놀리스가 됩니다. 다른 모듈의 internal 코드를 import하거나 DB를 직접 접근하면 안 됩니다. Architecture Test로 경계를 지속적으로 검증하세요.
순환 의존성 금지: Module A → Module B → Module A 형태의 순환 의존은 모듈 분리를 불가능하게 합니다. 의존 방향을 단방향으로 유지하세요.
공유 커널 최소화: shared 폴더의 코드가 커지면 모듈 독립성이 약해집니다. 정말 필요한 인프라 코드만 공유하세요.
팁: DDD의 Bounded Context를 모듈 경계로 사용하면 자연스럽게 좋은 모듈 설계가 됩니다. Event Storming으로 도메인을 분석한 뒤 모듈을 정의하세요.