아키텍처 Kotlin · Spring

MSA로 시작했다가
Modular Monolith로 바꾼 이야기

S-Class 백엔드를 처음 설계했을 때 교과서적인 선택을 했다. 인증, 계정, 학습관리, 과외매칭, 알림, 결제 — 6개 도메인을 각각 독립된 레포로 나누고, 공통 패키지까지 포함해 7개 레포 MSA로 시작했다.

그런데 운영을 하면서 직접 문제들이 보이기 시작했다. 그걸 무시하지 않고 구조를 바꾸는 것이 맞다고 판단했다.

뭐가 문제였나

처음엔 레포 분리가 깔끔해 보였다. 그런데 실제로 개발하다 보니 세 가지가 계속 걸렸다.

1. 레포 경계가 오히려 발목을 잡았다

과외매칭 기능을 수정하면 학습관리 레포도 같이 바꿔야 하는 일이 반복됐다. 두 도메인이 공유하는 개념이 많아서 코드도 중복됐다. "독립 배포"를 위해 나눴는데, 정작 항상 같이 배포해야 하는 구조가 됐다.

🔍
핵심 발견 과외매칭과 학습관리는 테이블 구조가 상당 부분 겹쳤다. DB 추상화로 공통화 가능한 수준이었고, 이건 MSA로 분리해서 얻을 수 있는 이득이 없다는 뜻이었다.

2. 공통 패키지 관리가 병목이 됐다

7번째 레포인 공통 패키지를 수정하면 나머지 6개 레포가 버전을 올려야 했다. 공통 예외 클래스 하나 바꾸는 게 6개 PR이 되는 상황. 혼자 개발하는 상황에서 이 오버헤드는 컸다.

3. 월 20만원이 그냥 나갔다

서비스별로 독립된 인프라를 유지하는 비용이 월 20만원 수준이었다. 트래픽이 충분히 커서 모듈별 독립 스케일링이 필요한 상황이 아닌데도 이 비용이 고정으로 나가고 있었다.

💭
판단 포인트 MSA가 주는 이점(독립 배포, 독립 스케일링)을 실제로 누리고 있는지 따져봤다. 답은 "아니오"였다. 이점 없이 복잡도와 비용만 지불하고 있는 구조였다.

전환 방향: Domain 추상화 기반 Modular Monolith

레포를 합치되, 기존 MSA에서 얻으려 했던 것은 포기하지 않았다. 모듈 경계는 유지하고, 독립 배포는 그대로 가져가는 구조가 목표였다.

Api-Supporters Api-Lms Api-Backoffice Batch
↓ 의존
Domain Infrastructure Common

각 Api 모듈은 독립적인 bootJar를 만들어 별도 App Runner 서비스로 배포된다.
Domain/Infrastructure/Common은 plain jar로 Api에 포함되어 코드 공유가 일어난다.

Before — 7개 레포 MSA
  • 레포 7개 (도메인 6 + 공통 1)
  • 공통 변경 시 6개 PR
  • 중복 코드 도메인 간 산재
  • 항상 같이 배포되는 서비스들
  • 인프라 비용 월 20만원+
After — Modular Monolith
  • 레포 1개, 모듈 7개
  • 공통 변경 → 동일 레포 수정
  • Domain 모듈에서 코드 공유
  • 독립 배포·스케일링 유지
  • 인프라 비용 70%↓
70%↓
인프라 비용 절감
7→1
레포 수
7
독립 모듈 유지

레이어 설계와 커스텀 어노테이션

레포를 합치면서 모듈 내 레이어 역할을 어떻게 강제할 것인가가 두 번째 과제였다. 레포 경계가 없어졌으니 코드 내부의 경계를 더 명확하게 잡아야 했다.

Spring의 @Service는 역할이 너무 광범위하다. 이 클래스가 비즈니스 로직을 담당하는지, 외부 연동을 담당하는지, 오케스트레이션을 담당하는지 어노테이션만 보고는 알 수 없다. 그래서 레이어마다 의미 있는 어노테이션을 직접 만들었다.

// Controller → UseCase → DomainService → Adaptor → Repository
//  Api 모듈      Api 모듈   Domain 모듈   Domain 모듈  Domain 모듈
@UseCase
Api 모듈

1 API = 1 UseCase. 트랜잭션 경계를 여기서 선언하고, DomainService/Adaptor를 조합해 오케스트레이션만 한다. 비즈니스 로직을 직접 구현하지 않는다.

@DomainService
Domain 모듈

비즈니스 로직(검증, 상태변경)이 있을 때만 생성한다. 단순 CRUD는 Adaptor 직접 호출로 충분하다. 조회 책임은 갖지 않는다.

@Adaptor
Domain 모듈

Repository를 래핑. 읽기/쓰기 모두 담당. 조회 실패 시 도메인 예외를 던져 Repository를 외부에 노출하지 않는다.

@EventHandler
Api 모듈

트랜잭션 이벤트 리스너 클래스용. 알림 발송 등 트랜잭션 커밋 후 비동기 처리가 필요한 로직을 분리한다.

모두 @Component를 메타 어노테이션으로 포함해 Spring 빈으로 자동 등록된다. 어노테이션 이름만 봐도 이 클래스가 어느 레이어에서 무슨 역할을 하는지 즉시 알 수 있어서 코드 탐색 비용이 줄었다.

지금 돌아보면

MSA가 나쁜 선택이었다고 생각하지 않는다. 다만 그 시점의 서비스 규모와 팀 규모에 맞지 않았다. 문제는 처음 설계할 때 그걸 충분히 따져보지 않고 "좋은 패턴"이니까 적용했다는 점이었다.

직접 운영하면서 중복 코드가 쌓이고, 비용 청구서를 보면서 그게 느껴졌다. 그 불편함을 방치하지 않고 구조를 바꾼 것이 이 경험에서 가장 의미 있는 부분이라고 생각한다.

향후 전환 경로 특정 모듈의 트래픽이 폭발적으로 증가하거나 팀이 나뉠 시점이 오면, 해당 Api 모듈을 꺼내 독립 서비스로 분리하는 경로는 열려 있다. 지금의 모듈 경계가 그 기반이 된다.
← 목록으로 App Runner 삽질기 →