Table of contents
- 모놀리스의 진짜 문제
- 마이크로서비스로 바로 가면 생기는 일
- 모듈러 모놀리스란
- 모듈 경계를 만드는 방법
- Spring Modulith
- 모듈 간 통신 구조
- 모듈 단위 테스트
- 모듈러 모놀리스에서 마이크로서비스로
- 모듈러 모놀리스의 한계
- 시리즈 회고
모놀리스의 진짜 문제
모놀리스는 나쁘다는 말을 자주 듣는다. 하나의 배포 단위에 모든 것이 들어가니 규모가 커지면 빌드가 느려지고, 작은 변경에도 전체를 배포해야 하며, 팀 간 코드 충돌이 잦아진다고. 그래서 마이크로서비스로 가야 한다는 논리가 뒤따른다.
그런데 한 발짝 물러서서 생각해보면, 이 문제들의 원인이 정말 하나의 배포 단위에 있는지 의심할 필요가 있다. 빌드가 느린 건 코드가 많아서이기도 하지만, 불필요한 의존성이 얽혀서 증분 빌드가 안 되는 경우가 더 흔하다. 작은 변경에 전체 배포가 필요한 건, 변경 범위를 격리할 수 있는 모듈 경계가 없기 때문이다. 팀 간 코드 충돌은 같은 패키지를 여러 팀이 건드리기 때문이지, 같은 리포지토리를 쓰기 때문이 아니다.
모놀리스의 진짜 문제는 크기가 아니라 결합(coupling)이다. 주문 코드가 결제 코드의 내부 클래스를 직접 참조하고, 상품 코드가 회원 테이블을 직접 JOIN하고, 모든 코드가 하나의 거대한 service 패키지에 뒤섞여 있는 것이 문제의 본질이다.
마이크로서비스로 바로 가면 생기는 일
결합 문제를 해결하는 가장 극적인 방법이 마이크로서비스다. 서비스를 물리적으로 분리하면 경계가 강제된다. 다른 서비스의 내부 클래스를 import할 수 없으니 자연스럽게 결합이 끊어진다.
하지만 분산 시스템은 공짜가 아니다.
네트워크는 신뢰할 수 없다. 로컬 메서드 호출은 밀리초 단위지만, 서비스 간 HTTP 호출은 수십~수백 밀리초가 걸리고, 타임아웃, 재시도, 서킷 브레이커(Circuit Breaker) 같은 복원력 패턴을 직접 구현해야 한다.
분산 트랜잭션은 어렵다. 모놀리스에서 @Transactional 하나로 묶이던 작업이, 마이크로서비스에서는 Saga 패턴이나 보상 트랜잭션(Compensating Transaction)으로 풀어야 한다. 구현 복잡도가 급격히 올라간다.
데이터 일관성을 보장하기 어렵다. 각 서비스가 자체 데이터베이스를 갖는 구조에서, 서비스 A의 데이터와 서비스 B의 데이터가 동시에 최신 상태라는 보장이 없다. 최종 일관성(eventual consistency)을 받아들여야 하고, 그에 맞는 UI/UX 설계가 필요하다.
운영 복잡성이 폭증한다. 서비스가 10개면 CI/CD 파이프라인이 10개이고, 모니터링 대상이 10개이며, 장애 추적 시 10개의 로그를 상관관계로 엮어야 한다. 작은 팀이 이 모든 것을 감당하기는 쉽지 않다.
마이크로서비스의 선구자인 Sam Newman도 Building Microservices에서 이렇게 말했다. 마이크로서비스를 기본 선택지로 삼지 말라고. 모놀리스부터 시작하고, 정당한 이유가 있을 때 분리하라고.
그 정당한 이유가 있을 때의 중간 지점이 바로 모듈러 모놀리스다.
모듈러 모놀리스란
모듈러 모놀리스(Modular Monolith)는 하나의 배포 단위 안에서 모듈 경계를 명확히 하는 아키텍처다. 배포는 모놀리스처럼 하나지만, 내부 구조는 마이크로서비스처럼 모듈로 나뉘어 있다. 각 모듈은 자체 도메인 로직을 캡슐화하고, 다른 모듈과는 정의된 인터페이스를 통해서만 소통한다.
전통적 모놀리스 — 모든 모듈이 서로 직접 참조한다. 경계가 없다.
flowchart LR
M1["주문"] <--> M2["결제"]
M1 <--> M3["상품"]
M1 <--> M4["회원"]
M2 <--> M3
M2 <--> M4
M3 <--> M4
모듈러 모놀리스 — 모듈 간 통신은 공개 API로만. 내부 구현은 캡슐화된다.
flowchart LR
subgraph OM["주문 모듈"]
O1["API"] --- O2["내부 로직"]
end
subgraph PM["결제 모듈"]
P1["API"] --- P2["내부 로직"]
end
subgraph CM["상품 모듈"]
C1["API"] --- C2["내부 로직"]
end
subgraph UM["회원 모듈"]
U1["API"] --- U2["내부 로직"]
end
O1 --> P1
O1 --> C1
P1 --> U1
마이크로서비스 — 각 서비스가 별도 프로세스로 배포된다. 네트워크 통신이 필수.
flowchart LR
MS1["주문 서비스"] -.->|HTTP/gRPC| MS2["결제 서비스"]
MS1 -.->|HTTP/gRPC| MS3["상품 서비스"]
MS2 -.->|HTTP/gRPC| MS4["회원 서비스"]
전통적 모놀리스는 모든 것이 뒤섞여 있다. 마이크로서비스는 물리적으로 분리되어 있다. 모듈러 모놀리스는 그 사이에 있다. 물리적으로는 하나이지만 논리적으로는 분리된 구조다.
모듈 경계를 만드는 방법
모듈 경계를 만드는 가장 기본적인 방법은 패키지 구조다.
com.example.shop/
├── order/ # 주문 모듈
│ ├── api/ # 다른 모듈에 공개하는 인터페이스
│ │ ├── OrderApi.kt
│ │ └── OrderDto.kt
│ ├── domain/ # 내부 도메인 모델
│ │ ├── Order.kt
│ │ └── OrderItem.kt
│ ├── application/ # 내부 서비스
│ │ └── OrderService.kt
│ └── infrastructure/ # 내부 인프라
│ └── JpaOrderRepository.kt
├── payment/ # 결제 모듈
│ ├── api/
│ │ ├── PaymentApi.kt
│ │ └── PaymentDto.kt
│ ├── domain/
│ ├── application/
│ └── infrastructure/
├── product/ # 상품 모듈
│ ├── api/
│ ├── domain/
│ ├── application/
│ └── infrastructure/
└── member/ # 회원 모듈
├── api/
├── domain/
├── application/
└── infrastructure/
핵심 규칙은 하나다. 다른 모듈의 api 패키지만 참조할 수 있다. 주문 모듈이 결제 모듈의 domain이나 application 패키지에 있는 클래스를 직접 import하면 안 된다. 반드시 payment.api.PaymentApi를 통해서만 소통해야 한다.
모듈의 공개 API는 이렇게 정의한다.
// payment/api/PaymentApi.kt — 결제 모듈의 공개 인터페이스
interface PaymentApi {
fun requestPayment(request: PaymentRequest): PaymentResult
fun cancelPayment(paymentId: String): CancelResult
fun getPaymentStatus(paymentId: String): PaymentStatus
}
// payment/api/PaymentDto.kt — 모듈 간 데이터 전달 객체
data class PaymentRequest(
val orderId: String,
val amount: Long,
val method: PaymentMethod
)
data class PaymentResult(
val paymentId: String,
val status: PaymentStatus
)
주문 모듈에서는 이 인터페이스만 사용한다.
// order/application/OrderService.kt
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val paymentApi: PaymentApi, // 결제 모듈의 공개 API만 의존
private val productApi: ProductApi // 상품 모듈의 공개 API만 의존
) {
@Transactional
fun placeOrder(command: PlaceOrderCommand): OrderId {
// 상품 정보 조회 — 상품 모듈의 API를 통해
val products = command.items.map { item ->
productApi.getProduct(item.productId)
?: throw ProductNotFoundException(item.productId)
}
val order = Order.create(command.customerId, products)
orderRepository.save(order)
// 결제 요청 — 결제 모듈의 API를 통해
val paymentResult = paymentApi.requestPayment(
PaymentRequest(
orderId = order.id.value,
amount = order.totalAmount().amount,
method = command.paymentMethod
)
)
order.updatePaymentStatus(paymentResult.status)
orderRepository.save(order)
return order.id
}
}
이 구조에서 결제 모듈의 내부 구현이 어떻게 바뀌든 주문 모듈은 영향을 받지 않는다. PaymentApi 인터페이스만 유지되면 된다.
Spring Modulith
패키지 구조로 모듈 경계를 잡는 것은 가능하지만, 규칙을 코드로 강제할 수 없다는 한계가 있다. 개발자가 실수로 다른 모듈의 내부 클래스를 import해도 컴파일러는 아무 경고도 주지 않는다.
Spring Modulith는 이 문제를 해결하기 위한 Spring 프로젝트다. 모듈 간의 의존성을 검증하고, 모듈 간 이벤트 기반 통신을 지원하며, 모듈 단위의 통합 테스트를 제공한다.
Spring Modulith를 사용하려면 먼저 의존성을 추가한다.
// build.gradle.kts
dependencies {
implementation("org.springframework.modulith:spring-modulith-starter-core")
testImplementation("org.springframework.modulith:spring-modulith-starter-test")
}
별도의 설정 없이도 Spring Modulith는 애플리케이션의 패키지 구조를 모듈로 인식한다. 메인 클래스가 com.example.shop에 있으면, 그 하위의 order, payment, product, member 패키지가 각각 하나의 모듈이 된다. 각 모듈의 루트 패키지에 있는 public 클래스만 다른 모듈에서 접근할 수 있고, 하위 패키지의 클래스는 내부로 간주된다.
아키텍처 검증
모듈 경계가 올바르게 지켜지는지를 테스트로 확인할 수 있다.
@Test
fun `모듈 구조를 검증한다`() {
val modules = ApplicationModules.of(ShopApplication::class.java)
modules.verify()
}
verify()는 모듈 간 순환 의존성이 있거나, 내부 클래스를 외부에서 참조하는 경우 테스트를 실패시킨다. CI에 이 테스트를 포함시키면 모듈 경계 위반이 커밋되기 전에 잡힌다.
모듈 구조를 문서화하는 것도 가능하다.
@Test
fun `모듈 구조를 문서화한다`() {
val modules = ApplicationModules.of(ShopApplication::class.java)
Documenter(modules)
.writeModulesAsPlantUml()
.writeIndividualModulesAsPlantUml()
}
모듈 간 이벤트 기반 통신
모듈 간의 직접 의존을 더 줄이고 싶다면, 이벤트를 사용할 수 있다. Spring Modulith는 Spring의 ApplicationEventPublisher를 그대로 활용하면서, 이벤트의 완료 여부를 추적하고 실패 시 재발행하는 기능을 제공한다.
이벤트를 정의한다.
// order/api/OrderEvents.kt — 주문 모듈이 발행하는 이벤트
data class OrderCompleted(
val orderId: String,
val customerId: String,
val totalAmount: Long
)
주문 모듈에서 이벤트를 발행한다.
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val eventPublisher: ApplicationEventPublisher
) {
@Transactional
fun completeOrder(orderId: String) {
val order = orderRepository.findById(orderId)
?: throw OrderNotFoundException(orderId)
order.complete()
orderRepository.save(order)
eventPublisher.publishEvent(
OrderCompleted(order.id.value, order.customerId.value, order.totalAmount().amount)
)
}
}
다른 모듈에서 이 이벤트를 구독한다.
// member/application/MemberPointHandler.kt
@Component
class MemberPointHandler {
@ApplicationModuleListener
fun onOrderCompleted(event: OrderCompleted) {
// 포인트 적립 로직
addPoints(event.customerId, calculatePoints(event.totalAmount))
}
}
@ApplicationModuleListener는 Spring Modulith가 제공하는 어노테이션으로, @EventListener와 비슷하지만 이벤트 처리 상태를 추적할 수 있다. 이벤트 핸들러가 실패하면 Spring Modulith가 이를 기록하고, 나중에 재처리할 수 있게 해준다.
이 구조에서 주문 모듈은 회원 모듈의 존재를 모른다. 이벤트를 발행할 뿐이다. 나중에 쿠폰 모듈을 추가하고 같은 이벤트를 구독하게 만들어도 주문 모듈의 코드는 한 줄도 바뀌지 않는다.
모듈 간 통신 구조
flowchart LR
subgraph ORDER["주문 모듈"]
OS["OrderService"]
OE["OrderCompleted 이벤트 발행"]
OS --> OE
end
subgraph EVENT["Spring ApplicationEventPublisher"]
EB["이벤트 버스"]
end
subgraph PAYMENT["결제 모듈"]
PH["PaymentHandler"]
PH -->|"@ApplicationModuleListener"| PS["결제 처리"]
end
subgraph MEMBER["회원 모듈"]
MH["MemberPointHandler"]
MH -->|"@ApplicationModuleListener"| MP["포인트 적립"]
end
subgraph NOTI["알림 모듈"]
NH["NotificationHandler"]
NH -->|"@ApplicationModuleListener"| NS["알림 발송"]
end
OE --> EB
EB --> PH
EB --> MH
EB --> NH
style ORDER fill:#4ecdc4,color:#fff
style EVENT fill:#f7c948,color:#333
style PAYMENT fill:#ff6b6b,color:#fff
style MEMBER fill:#45b7d1,color:#fff
style NOTI fill:#a78bfa,color:#fff
이벤트 발행자와 수신자가 직접 연결되지 않는다. 새로운 모듈이 추가되어도 발행자 코드는 변경할 필요가 없다.
모듈 단위 테스트
Spring Modulith는 모듈 단위의 통합 테스트를 지원한다. 전체 애플리케이션 컨텍스트를 올리는 대신, 특정 모듈과 그 모듈이 의존하는 모듈만 부트스트랩할 수 있다.
@ApplicationModuleTest
class OrderModuleTest {
@Test
fun `주문을 생성하면 OrderCompleted 이벤트가 발행된다`(
scenario: Scenario
) {
scenario.stimulate { orderService.placeOrder(testCommand()) }
.andWaitForEventOfType(OrderCompleted::class.java)
.toArriveAndVerify { event ->
assertThat(event.totalAmount).isEqualTo(30_000)
}
}
}
@ApplicationModuleTest는 해당 모듈의 범위 내에서만 Spring 컨텍스트를 구성한다. 전체 애플리케이션을 올리지 않으니 테스트 속도가 빠르고, 모듈의 독립성을 검증하는 효과도 있다.
모듈러 모놀리스에서 마이크로서비스로
모듈러 모놀리스의 가장 큰 전략적 이점은 마이크로서비스로의 점진적 전환 경로를 제공한다는 것이다. 모듈 경계가 명확하게 잡혀 있으면, 특정 모듈을 별도 서비스로 추출하는 것이 수월해진다.
전환 순서는 대략 이렇다.
-
모듈 간 통신을 이벤트 기반으로 전환한다. 직접 메서드 호출 대신 이벤트를 통한 비동기 통신으로 바꾼다. Spring Modulith의 이벤트 시스템으로 시작하면 된다.
-
모듈별 데이터를 분리한다. 같은 데이터베이스를 쓰더라도 스키마를 나누거나, 다른 모듈의 테이블을 직접 참조하지 않도록 변경한다.
-
가장 독립적인 모듈부터 추출한다. 다른 모듈에 대한 의존이 가장 적은 모듈을 먼저 별도 서비스로 분리한다. 보통 알림이나 로깅 같은 부가 기능 모듈이 후보가 된다.
-
프로세스 내 이벤트를 메시지 브로커로 교체한다.
ApplicationEventPublisher를 Kafka나 RabbitMQ로 바꾸면 된다. 이벤트의 발행/소비 구조는 이미 갖춰져 있으니 변경 범위가 작다.
이 모든 단계가 점진적으로 이루어진다는 것이 핵심이다. 빅뱅 방식으로 한 번에 마이크로서비스로 전환하는 것이 아니라, 필요한 부분만 필요한 시점에 분리하는 것이다. 그리고 모든 모듈을 반드시 마이크로서비스로 분리할 필요도 없다. 분리의 이점이 비용을 넘어서는 모듈만 꺼내면 된다.
모듈러 모놀리스의 한계
모듈러 모놀리스가 모든 문제를 해결하는 것은 아니다.
배포 독립성이 없다. 결제 모듈만 수정했더라도 전체 애플리케이션을 다시 배포해야 한다. 배포 빈도가 높고 모듈 간 릴리즈 주기가 다른 조직에서는 이 제약이 병목이 될 수 있다.
기술 스택 통일이 강제된다. 하나의 JVM 위에서 돌아가니, 특정 모듈만 다른 언어나 프레임워크로 만들 수 없다. 대부분의 경우 이것은 오히려 장점이지만, 기술 선택의 유연성이 필요한 상황에서는 제약이 된다.
단일 장애점이 존재한다. 하나의 모듈에서 메모리 누수가 발생하면 전체 애플리케이션이 영향을 받는다. 마이크로서비스라면 해당 서비스만 죽고 나머지는 동작하겠지만, 모놀리스에서는 그런 격리가 불가능하다.
이런 한계에도 불구하고, 대부분의 중소규모 프로젝트에서 모듈러 모놀리스는 마이크로서비스보다 합리적인 선택이다. 분산 시스템의 복잡성 없이도 모듈 간 결합을 끊을 수 있고, 나중에 정말 마이크로서비스가 필요해지면 그때 분리하면 된다.
시리즈 회고
6편에 걸쳐 소프트웨어 아키텍처의 주요 패턴을 훑었다. 1편에서 아키텍처가 필요한 이유를 짚었고, 2편의 레이어드에서 시작해 3편의 헥사고날, 4편의 클린과 어니언, 5편의 CQRS와 이벤트 드리븐을 거쳐 이번 편의 모듈러 모놀리스에 도착했다.
이 아키텍처들을 관통하는 원칙이 하나 있다. 결합을 줄이고 응집을 높인다. 레이어드는 수직 계층으로 관심사를 나눴고, 헥사고날은 포트와 어댑터로 안과 밖을 분리했으며, 클린과 어니언은 의존성 방향을 안쪽으로 강제했다. CQRS는 읽기와 쓰기의 관심사를 분리했고, 모듈러 모놀리스는 도메인 경계로 모듈을 나눴다. 이름과 형태는 다르지만, 변하는 것과 변하지 않는 것을 분리하려는 시도라는 점에서 뿌리가 같다.
아키텍처 선택에 정답은 없다. 팀의 규모, 도메인의 복잡도, 트래픽 패턴, 조직 구조 — 이런 요소들이 결합되어 최적의 아키텍처가 결정된다. 단순한 CRUD 애플리케이션에 헥사고날을 적용하면 과도한 추상화가 되고, 복잡한 도메인을 레이어드로만 풀면 서비스 계층이 비대해진다. 아키텍처를 고르는 것이 아니라, 문제를 이해하고 그 문제에 맞는 구조를 설계하는 것이 본질이다.




Loading comments...