모놀리식 시스템을 Modular Monolith로 전환하는 설계 전략

· 약 9분 읽기 · 조회 2 #아키텍처#Modular Monolith#Spring Modulith#ArchUnit#레거시 전환

수정 하나에 화면 다섯 개가 깨지는 모놀리식 시스템을 고치는 방법이 꼭 MSA일 필요는 없다. 배포 단위는 하나로 두고 안쪽을 업무 단위 모듈로 나눠 경계를 강제하는 Modular Monolith는 운영 부담을 늘리지 않고 결합도부터 낮춘다. 이 글은 레거시 Spring 모놀리스를 모듈 단위로 나누는 기준, 모듈 사이 통신과 데이터 소유 규칙, 경계를 코드로 지키는 방법, 단계별 전환 순서를 정리한 것이다.

목공소에서 장인이 큰 장의 서랍과 칸을 하나씩 맞추는 장면

한눈에 보기

단계 목표 끝났다는 기준
1. 현황 측정 패키지·테이블 의존 관계 파악 패키지 의존 그래프, 테이블별 사용 모듈 목록
2. 경계 정하기 업무 능력 단위로 모듈 나누기 모듈 목록과 모듈별 소유 테이블
3. 코드 재배치 기능별 패키지, 공개 API 분리 모듈마다 api·internal 패키지
4. 데이터 분리 다른 모듈 테이블 직접 접근 제거 교차 테이블 접근 0건
5. 경계 강제 규칙을 테스트로 고정 빌드에서 경계 위반이 실패

배포 단위 하나 안에 주문·결제·회원 모듈이 있고, 각 모듈은 공개 API·내부 구현·소유 테이블로 이루어지며, 주문 모듈은 결제 모듈의 공개 API만 호출하고 결제 완료는 이벤트로 회원 모듈에 전달되며, 주문 모듈이 회원 테이블을 직접 조회하는 길은 막혀 있다

왜 바로 MSA로 가지 않는가

경계가 없는 모놀리스를 그대로 쪼개면 네트워크로 엉킨 분산 모놀리스가 된다. 함수 호출이던 의존이 HTTP 호출이 되고, 조인 하나가 서비스 두 개의 데이터를 맞추는 일이 된다.

MSA가 따로 요구하는 것도 많다.

  • 서비스마다 배포 파이프라인, 모니터링, 로그 수집이 필요하다.
  • 서비스 사이 데이터 정합성을 보상 트랜잭션이나 메시지로 맞춰야 한다.
  • 공공 SI처럼 서버 증설과 운영 인력이 계약으로 묶인 환경에서는 서비스 수가 곧 운영 비용이다.

Modular Monolith는 이 비용 없이 "모듈 사이 의존을 통제한다"는 MSA의 핵심만 먼저 얻는다. 경계가 실제로 지켜지면, 나중에 떼어 낼 모듈이 생겨도 그 모듈 하나만 옮기면 된다.

모듈 경계는 업무 능력으로 정한다

기술 계층(controller, service, dao)으로 나눈 패키지는 모듈이 아니다. 기능 하나를 고치려면 모든 계층 패키지를 건드려야 하기 때문이다.

나누는 기준

  • 업무 능력 단위로 나눈다. 주문, 결제, 회원, 정산처럼 업무 담당자가 따로 있고 규칙이 따로 바뀌는 단위다.
  • 같이 바뀌는 것은 같은 모듈에 둔다. 지난 6개월 커밋에서 늘 함께 수정된 파일 묶음이 좋은 후보다.
  • 테이블 소유자가 하나로 정해지는지 본다. 한 테이블을 두 모듈이 똑같이 쓰고 고친다면 경계가 틀렸거나 테이블을 나눠야 한다.
  • 처음부터 잘게 나누지 않는다. 5~8개로 시작해 필요할 때 나누는 편이 합치는 것보다 싸다.

패키지 구조

com.example.app
├── order
│   ├── api          // 다른 모듈이 써도 되는 것: OrderApi, OrderDto
│   └── internal     // 모듈 안에서만: 서비스, 리포지토리, 엔티티
├── payment
│   ├── api
│   └── internal
└── member
    ├── api
    └── internal

api 패키지에는 인터페이스와 DTO만 둔다. 엔티티를 공개 API로 내보내면 다른 모듈이 그 엔티티의 필드 구조에 묶이고, 지연 로딩이 모듈 밖에서 터진다.

데이터는 소유 모듈만 만진다

코드 경계보다 깨지기 쉬운 것이 데이터 경계다. 다른 모듈의 테이블을 조인하는 SQL 한 줄이 패키지 구조를 무력화한다.

  • 테이블마다 소유 모듈을 하나 정한다. 쓰기는 소유 모듈만 한다.
  • 다른 모듈의 데이터가 필요하면 그 모듈의 공개 API를 부른다.
  • 조회 화면처럼 여러 모듈 데이터를 합쳐야 하면, 조회 전용 모듈이 각 API를 불러 합치거나 이벤트로 받은 사본 테이블을 따로 둔다.
  • DB 하나를 쓰더라도 모듈별 스키마나 테이블 접두어를 두면 위반을 찾기 쉽다. 가능하면 DB 계정 권한까지 나눈다.

외래 키도 모듈 경계를 넘지 않게 한다. 주문 테이블은 회원 ID 값만 갖고, 회원 테이블과 외래 키로 묶지 않는다.

모듈 사이 통신: 직접 호출과 이벤트

방식 쓰는 경우 주의할 점
공개 API 직접 호출 결과가 지금 필요하다 (재고 확인, 결제 승인) 호출 방향이 한쪽으로만 흘러야 한다. 순환이 생기면 경계를 다시 본다
프로세스 안 이벤트 결과를 기다릴 필요가 없다 (포인트 적립, 알림) 커밋 시점과 유실 가능성을 정해야 한다

직접 호출은 같은 JVM 안의 메서드 호출이라 빠르고 단순하다. 다만 결제가 주문을 부르고 주문이 다시 결제를 부르는 순환이 생기면, 둘은 사실 한 모듈이거나 한쪽을 이벤트로 바꿔야 한다.

이벤트를 쓰면 발행하는 모듈은 누가 듣는지 모른다. 결합은 줄지만 트랜잭션 단계와 비동기 실행 때문에 이벤트가 사라질 수 있다. 이 함정은 Transactional Event 글에, 잃으면 안 되는 이벤트를 테이블로 보장하는 방법은 Outbox·Inbox 글에 정리했다. 모듈을 나눌 때 트랜잭션을 어디서 시작하고 끝낼지는 트랜잭션 경계 글의 기준을 따른다.

경계를 코드로 강제하기

문서로 정한 경계는 마감 직전에 가장 먼저 깨진다. 규칙을 테스트로 만들어 빌드에서 실패하게 해야 한다.

ArchUnit (Spring 버전과 무관)

레거시 Spring 4·5 프로젝트에도 JUnit 테스트로 붙일 수 있다.

@AnalyzeClasses(packages = "com.example.app")
class ModuleBoundaryTest {

    @ArchTest
    static final ArchRule 내부_패키지는_모듈_밖에서_못_쓴다 =
        noClasses().that().resideOutsideOfPackage("com.example.app.payment..")
            .should().dependOnClassesThat()
            .resideInAPackage("com.example.app.payment.internal..");

    @ArchTest
    static final ArchRule 모듈_사이_순환_금지 =
        slices().matching("com.example.app.(*)..").should().beFreeOfCycles();
}

Spring Modulith (Spring Boot 3 이상)

메인 패키지 바로 아래 패키지를 모듈로 보고, 그 아래 하위 패키지는 기본적으로 내부로 취급한다. 지원하는 Boot 버전은 공식 문서에서 확인한다.

class ModularityTest {
    @Test
    void 모듈_경계를_검증한다() {
        ApplicationModules.of(Application.class).verify();
    }
}

verify()는 모듈 사이 순환 의존과 내부 타입 접근이 있으면 실패한다. 데이터 경계는 이 테스트가 잡지 못하므로, MyBatis 매퍼 XML이나 JPA 쿼리에서 다른 모듈 테이블 이름을 찾는 검사를 따로 둔다.

레거시 SI 모놀리스 전환 순서

JBoss 위에서 돌아가는 Spring 프로젝트를 예로 들면, 배포 구조는 그대로 두고 안쪽만 바꾼다.

1. 측정부터 한다

  • 패키지 의존 그래프를 뽑는다. ArchUnit이나 IDE 의존성 분석 도구로 충분하다.
  • 매퍼 XML을 훑어 테이블별로 어느 패키지가 읽고 쓰는지 표로 만든다.

2. 위반을 허용 목록으로 얼린다

  • 지금 있는 위반을 모두 고친 뒤 규칙을 켜려면 영원히 못 켠다.
  • 현재 위반을 목록으로 남기고 새 위반만 막는다. ArchUnit은 FreezingArchRule로 기존 위반을 기록해 두고 새 위반만 실패시킬 수 있다.

3. 가장 독립적인 모듈부터 옮긴다

  • 들어오는 의존이 적은 모듈(코드 관리, 공지)부터 api·internal로 나눈다.
  • 주문·결제처럼 얽힌 핵심 모듈은 마지막에 한다.

4. 교차 테이블 접근을 API 호출로 바꾼다

  • 조인 SQL 하나를 바꿀 때마다 허용 목록에서 지운다.
  • 목록이 0이 되면 그 모듈의 데이터 경계가 완성된다.

5. 필요하면 빌드 모듈로 올린다

  • 패키지 규칙이 안정되면 Maven 멀티 모듈로 나눠 컴파일 단계에서 의존을 막는다. 배포는 여전히 WAR 하나다.

잘 되고 있는지 보는 지표

지표 보는 방법 방향
경계 위반 허용 목록 건수 ArchUnit 고정 위반 파일 줄 수 매 스프린트 줄어야 한다
교차 테이블 접근 수 매퍼 XML 검사 결과 0을 향해
모듈 사이 순환 순환 검사 테스트 항상 0
여러 모듈을 건드린 변경 비율 커밋별 수정 모듈 수 낮아져야 경계가 맞다

마지막 지표가 내려가지 않으면 경계를 업무가 아니라 코드 모양으로 나눴다는 신호다.

자주 하는 실수

  • 공통 모듈에 다 넣는다. common이 업무 로직을 품기 시작하면 모든 모듈이 거기에 묶여 다시 하나가 된다.
  • 엔티티를 공개 API로 내보낸다. 다른 모듈이 필드와 연관 관계에 의존하게 되어 내부를 바꿀 수 없다.
  • 데이터 경계를 뒤로 미룬다. 패키지만 나누고 조인 SQL을 남기면 모듈은 이름만 남는다.
  • 규칙을 테스트 없이 문서로만 둔다. 마감 주에 들어온 import 하나가 경계를 무너뜨린다.
  • 모듈을 처음부터 너무 잘게 나눈다. 모듈 사이 호출이 늘고 한 기능을 고치는 데 모듈 네 개를 건드리게 된다.

점검표

  • 모듈 목록이 업무 능력 단위로 정해져 있다
  • 테이블마다 소유 모듈이 하나다
  • 모듈마다 api와 internal 패키지가 나뉘어 있다
  • 공개 API에 엔티티가 없고 인터페이스와 DTO만 있다
  • 다른 모듈 테이블을 직접 읽는 SQL이 허용 목록에만 남아 있다
  • 모듈 사이 순환 의존이 없다
  • 경계 규칙이 ArchUnit 또는 Spring Modulith 테스트로 빌드에 들어가 있다
  • 잃으면 안 되는 모듈 간 이벤트는 Outbox로 보장한다
  • 허용 목록 건수를 스프린트마다 기록한다

Modular Monolith로 가는 핵심은 배포를 쪼개는 것이 아니라, 모듈마다 공개 API와 소유 데이터를 정하고 그 경계를 테스트로 깨지지 않게 만드는 것이다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기