포스트

모듈러 모놀리스 - 경계를 코드로 강제하는 방법

“마이크로서비스로 가기 전에 모듈러 모놀리스부터”라는 조언은 널리 퍼졌는데, 실제로 무엇을 하라는 것인지는 덜 구체적이다. 패키지를 도메인별로 나누는 것은 누구나 하고, 그것만으로는 몇 달 뒤 다시 얽힌다. 경계가 유지되려면 지켜지는지 검사되어야 한다는 것이 이 패턴의 실질이다.

무엇이 모듈인가

모듈은 폴더가 아니라 공개 API와 내부 구현이 구분된 단위다. 세 가지가 있어야 한다.

  1. 공개 계약: 다른 모듈이 쓸 수 있는 타입과 메서드.
  2. 숨겨진 내부: 엔티티, 저장소, 구현 클래스. 밖에서 참조 불가.
  3. 명시적 의존 방향: 누가 누구를 알 수 있는지.

자바의 패키지 접근 제어가 이것을 절반쯤 지원한다. order.internal 패키지의 클래스를 public으로 두지 않으면 다른 패키지에서 못 쓴다. 문제는 스프링과 JPA가 public을 요구하는 경우가 많아 실제로는 거의 다 열려 있다는 것이다.

경계가 무너지는 방식

세 가지가 반복된다.

엔티티 직접 참조. 주문 모듈이 회원 엔티티를 그대로 받아 쓴다. 편하고, 그 순간 두 모듈은 하나가 된다. 회원 테이블 스키마를 바꾸면 주문이 깨진다.

저장소 직접 호출. 주문 서비스가 MemberRepository를 주입받는다. 서비스 계층을 건너뛰므로 회원 모듈의 불변식 검증이 적용되지 않는다.

순환 의존. A가 B를 알고 B가 A를 안다. 컴파일은 되고, 나중에 분리는 불가능해진다.

셋 다 코드 리뷰에서 잡자고 하면 결국 샌다. 사람이 매번 확인해야 하는 규칙은 바쁠 때 무너진다.

검사로 강제하기

ArchUnit이 이 규칙들을 테스트로 만든다.

1
2
3
4
5
6
7
8
@ArchTest
static final ArchRule 모듈_내부는_외부에서_참조할_수_없다 =
    noClasses().that().resideOutsideOfPackage("..order..")
        .should().dependOnClassesThat().resideInAPackage("..order.internal..");

@ArchTest
static final ArchRule 모듈_사이에_순환이_없다 =
    slices().matching("com.example.(*)..").should().beFreeOfCycles();

이것이 실질적인 차이를 만드는 지점이다. 규칙이 빌드를 깨면 지켜지고, 문서에만 있으면 안 지켜진다.

Spring Modulith는 같은 일을 스프링 관례 위에서 한다. 최상위 패키지를 모듈로 보고, 하위 패키지를 내부로 취급하며, 모듈 간 통신을 이벤트로 하도록 돕는다. 문서 생성과 모듈 단위 테스트도 따라온다.

기존 코드에 도입하기

이미 얽힌 코드베이스에 규칙을 켜면 위반이 수백 건 나온다. 전부 고칠 때까지 기다리면 도입이 안 된다.

ArchUnit을 480건 위반 위에 도입한 경험에서 쓴 방식은 동결(freeze) 이었다. 현재 위반 목록을 기준선으로 저장하고, 새 위반만 빌드를 깨게 한다. 기존 것은 기록으로 남아 줄어드는 것만 허용된다. 480건에서 75건으로 줄어든 것은 한 번의 리팩터링이 아니라 이 방식으로 시간을 두고 내려간 결과다.

규칙을 고르는 순서도 중요하다. 처음부터 엄격한 계층 규칙을 걸면 위반이 너무 많아 무의미해진다. 순환 금지 → 내부 패키지 접근 금지 → 계층 규칙 순으로 하나씩 늘리는 편이 실제로 도입된다.

모듈 간 통신

직접 호출과 이벤트 중 무엇을 쓸지가 다음 질문이다.

 직접 호출도메인 이벤트
결합호출자가 대상을 안다발행자는 구독자를 모른다
트랜잭션같은 트랜잭션같거나 분리(@TransactionalEventListener)
디버깅스택 트레이스에 보인다흐름이 끊겨 보인다
분리 용이성낮음높음

“모든 것을 이벤트로”는 과하다. 흐름을 따라가기 어려워지고, 스택 트레이스가 끊긴다. 동기적으로 결과가 필요하면 직접 호출, 부수 효과이고 실패해도 주 흐름이 유지돼야 하면 이벤트가 기본 기준이다.

이벤트로 넘길 때는 정합성 수준을 함께 정해야 한다. 모든 도메인이 100% 정합성을 요구하지는 않는다에서 다룬 것이 그 판단이다.

이 설명이 깨지는 곳

  • 모듈러 모놀리스가 마이크로서비스의 전 단계인 것은 아니다. 많은 경우 종착지로 충분하다. 분리는 배포·확장·조직의 요구가 실재할 때 한다.
  • 데이터베이스를 나누지 않으면 진짜 경계가 아니라는 주장이 있다. 맞는 지적이고, 테이블 수준 접근 제한(모듈별 스키마, 뷰)으로 일부 보완할 수 있다.
  • 검사 규칙이 많아지면 그 자체가 부담이 된다. 규칙마다 “이것을 어기면 무엇이 나빠지는가”를 답할 수 있어야 한다.
  • 모듈 경계는 도메인 이해와 함께 바뀐다. 처음 그은 선이 틀렸다면 고치는 것이 맞고, 규칙이 그 변경을 막으면 안 된다.

무엇을 재면 확인되는가

이 패턴의 효과는 성능이 아니라 변경 비용으로 나타난다.

  1. 모듈 간 순환 수와 내부 접근 위반 수의 추이. 줄고 있는가.
  2. 기능 하나를 바꿀 때 수정한 모듈 수. 경계가 맞으면 대개 하나다.
  3. 모듈 단위 테스트가 다른 모듈 없이 돌 수 있는가. 못 돌면 경계가 실재하지 않는 것이다.

3번이 가장 정직한 검사다. Spring Modulith의 모듈 테스트가 이것을 자동화한다.

실무와의 접점

JSP 기반 시스템의 구조 전환과 ArchUnit 도입이 이 글의 두 축이다. 전자는 경계를 다시 긋는 일이었고, 후자는 그 경계가 유지되게 하는 일이었다. 둘 중 어려운 쪽은 후자였다. 경계를 긋는 것은 한 번의 작업이고, 유지하는 것은 계속되는 작업이기 때문이다. 검사를 빌드에 넣는 것이 유일하게 지속된 방법이었다.

monticker에서 처음부터 모듈러 모놀리스로 시작한 경험은 반대 방향의 참고가 된다. 처음부터 경계를 두면 규칙 도입 비용이 거의 없다.

정리

  • 모듈은 폴더가 아니라 공개 계약과 숨겨진 내부가 구분된 단위다.
  • 경계는 엔티티 직접 참조, 저장소 직접 호출, 순환 의존으로 무너진다.
  • 사람이 매번 확인하는 규칙은 바쁠 때 무너진다. 빌드를 깨야 지켜진다.
  • 기존 코드에는 현재 위반을 동결하고 새 위반만 막는 방식으로 도입한다.
  • 규칙은 순환 금지부터 하나씩 늘린다.
  • 동기 결과가 필요하면 직접 호출, 부수 효과면 이벤트가 기본 기준이다.
  • 경계가 실재하는지는 모듈 단위 테스트가 혼자 도는지로 확인된다.

참고

Spring과 JVM 백엔드 아키텍처와 마이그레이션
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다