시스템이 커지면 서비스를 나누고 싶어진다. 주문, 결제, 회원, 알림, 정산을 각각 독립 서비스로 만들면 더 깔끔해질 것 같다. 각 팀이 자신의 서비스를 소유하고, 독립적으로 배포하고, 장애도 격리할 수 있을 것처럼 보인다.
하지만 모델 경계가 정리되지 않은 상태에서 마이크로서비스로 나누면 문제가 더 커질 수 있다. 코드 안에서 해결하던 결합이 네트워크 호출과 배포 의존성으로 바뀐다. 모놀리스의 복잡성이 분산 환경으로 옮겨갈 뿐이다.
그래서 많은 경우 좋은 출발점은 모듈러 모놀리스다. 하나의 배포 단위 안에서 모듈 경계를 명확히 나누고, 모델의 독립성을 먼저 확보하는 방식이다.
모놀리스는 나쁜 것이 아니다
모놀리스라는 단어는 종종 부정적으로 쓰인다. 하지만 모놀리스 자체가 문제는 아니다. 문제는 경계가 없는 모놀리스다.
모든 코드가 모든 코드를 참조하고, 모든 테이블을 모든 기능이 직접 수정하고, 공통 모듈이 모든 도메인의 규칙을 빨아들이는 구조가 문제다. 이런 구조를 Big Ball of Mud라고 부를 수 있다.
반면 하나의 애플리케이션으로 배포되더라도 내부 모듈 경계가 명확하다면 이야기가 달라진다.
subscription module
payment module
authorization module
notification module
settlement module
각 모듈은 자신의 도메인 모델을 갖고, 다른 모듈의 내부 구현을 직접 알지 않는다. 필요한 상호작용은 명시적인 인터페이스나 이벤트로 한다.
이것이 모듈러 모놀리스의 핵심이다.
Bounded Context를 모듈로 표현하기
DDD에서 Bounded Context는 모델의 경계다. 모듈러 모놀리스에서는 이 경계를 코드 모듈로 표현할 수 있다.
구독 서비스라면 다음과 같은 구조를 생각할 수 있다.
com.example.subscription
application
domain
infrastructure
com.example.payment
application
domain
infrastructure
com.example.authorization
application
domain
infrastructure
패키지를 나누는 것만으로 충분하지는 않다. 중요한 것은 의존성 규칙이다. 구독 모듈이 결제 모듈의 내부 Entity를 직접 사용한다면 경계는 무너진다.
모듈 간에는 공개된 API나 이벤트를 통해 통신하도록 제한해야 한다.
public interface PaymentFacade {
PaymentResult pay(PaymentRequest request);
}
구독 모듈은 결제 모듈의 내부 테이블이나 Entity를 알지 않고, 결제 모듈이 공개한 인터페이스만 사용한다.
내부 호출도 계약이다
모듈러 모놀리스에서는 같은 프로세스 안에서 메서드 호출로 모듈 간 통신을 할 수 있다. 네트워크 호출이 아니므로 단순하고 빠르다. 하지만 그렇다고 경계가 없어지는 것은 아니다.
내부 호출도 계약이다. 결제 모듈이 어떤 인터페이스를 공개하고, 구독 모듈이 어떤 결과를 기대하는지 명확해야 한다.
PaymentResult result = paymentFacade.pay(request);
subscription.applyPaymentResult(result, now);
여기서 중요한 것은 PaymentResult가 구독 모듈이 이해할 수 있는 언어여야 한다는 점이다. 결제 모듈의 내부 Entity나 PG 응답 DTO가 그대로 넘어오면 모듈 경계가 흐려진다.
모듈러 모놀리스에서도 ACL과 Published Language의 감각이 필요하다.
데이터베이스를 공유할 때의 현실
모듈러 모놀리스에서는 하나의 데이터베이스를 공유하는 경우가 많다. 이때 주의해야 할 점은 다른 모듈의 테이블을 마음대로 조인하고 수정하지 않는 것이다.
물리적으로 같은 DB에 있어도 논리적으로는 소유권을 나누어야 한다.
subscription 모듈은 subscription 관련 테이블을 소유한다.
payment 모듈은 payment 관련 테이블을 소유한다.
다른 모듈은 소유하지 않은 테이블을 직접 수정하지 않는다.
조회 화면 때문에 조인이 필요할 수 있다. 이 경우에는 Query 전용 모델을 두거나, 모듈 간 공개된 조회 API를 사용하거나, 별도 읽기 모델을 만들 수 있다. 무조건 금지할 수는 없지만, 소유권을 무시한 직접 접근이 습관이 되면 경계는 금방 무너진다.
DB가 하나인 것과 모델이 하나인 것은 다르다.
마이크로서비스로 가기 전 연습장
모듈러 모놀리스는 마이크로서비스의 전 단계가 될 수 있다. 서비스로 나누기 전에 먼저 다음을 검증할 수 있다.
모듈 경계가 타당한가?
모듈 간 의존성이 지나치게 많지는 않은가?
이 컨텍스트는 독립적으로 변경될 수 있는가?
어떤 데이터가 공유되어야 하는가?
어떤 상호작용은 이벤트로 처리해도 되는가?
모듈 경계가 안정되면 나중에 특정 모듈을 별도 서비스로 분리하기 쉬워진다. 반대로 모듈러 모놀리스 안에서도 경계가 계속 흔들린다면, 마이크로서비스로 나누어도 문제가 해결되지 않을 가능성이 높다.
마이크로서비스는 경계를 더 선명하게 만들 수 있지만, 잘못된 경계를 고쳐주지는 않는다.
작은 팀에는 특히 유용하다
마이크로서비스는 운영 비용이 크다. 서비스별 배포, 모니터링, 로그 추적, 장애 대응, API 버전 관리, 데이터 동기화가 필요하다. 작은 팀이 이 모든 비용을 감당하기는 어렵다.
반면 모듈러 모놀리스는 하나의 애플리케이션으로 운영하면서도 내부 경계를 훈련할 수 있다. 배포는 단순하게 유지하고, 설계는 도메인 경계를 따라 나눌 수 있다.
작은 팀이 DDD를 시작한다면 모듈러 모놀리스는 좋은 선택이다. 처음부터 분산 시스템의 복잡성을 떠안지 않고도 Bounded Context와 모듈 경계를 코드에 반영할 수 있기 때문이다.
경계를 강제하는 장치가 필요하다
모듈러 모놀리스의 어려움은 경계가 물리적으로 강제되지 않는다는 점이다. 같은 코드베이스 안에 있기 때문에 마음만 먹으면 다른 모듈의 내부 클래스를 import할 수 있다.
따라서 경계를 지키기 위한 장치가 필요하다.
패키지 의존성 규칙
모듈별 공개 API와 내부 API 구분
아키텍처 테스트
코드 리뷰 기준
모듈 소유권
공통 모듈 사용 제한
특히 공통 모듈은 조심해야 한다. 모든 모듈이 사용하는 common 패키지는 빠르게 비대해질 수 있다. 공통화는 중복을 줄이지만, 잘못하면 모든 모듈을 묶는 거대한 결합점이 된다.
중복을 무조건 없애기보다, 서로 다른 컨텍스트의 모델을 억지로 공통화하고 있지는 않은지 봐야 한다.
마치며
모듈러 모놀리스는 마이크로서비스를 포기한 구조가 아니다. 오히려 좋은 마이크로서비스로 가기 전에 모델과 모듈의 경계를 검증하는 현실적인 구조다.
중요한 것은 하나의 애플리케이션으로 배포되느냐, 여러 서비스로 배포되느냐가 아니다. 중요한 것은 도메인 모델의 경계가 명확한가, 모듈 간 의존성이 통제되는가, 각 모듈이 자신의 언어와 책임을 갖고 있는가다.
서비스부터 나누면 복잡성은 네트워크로 이동한다. 모듈부터 나누면 복잡성은 코드 안에서 관찰되고 다듬어진다.
DDD를 시작하는 많은 팀에게 필요한 것은 거대한 마이크로서비스 전환이 아니라, 먼저 경계 있는 모놀리스를 만드는 일일 수 있다.