Bounded Context 기준으로 Kotlin/Spring 도메인 모듈을 나눈 이유

큰 도메인 모듈 하나로는 경계를 지키기 어렵습니다. s-class-backend에서 bounded context 기준으로 domain module을 나누며 배운 점을 정리합니다.

Sample code

처음부터 도메인 모듈을 잘게 나누자는 이야기는 아닙니다. 작은 서비스에서는 domain 하나가 더 단순합니다. 문제는 시간이 지나면서 domain이 경계가 아니라 공용 창고가 될 때 생깁니다.

s-class-backend에서도 비슷한 순간이 있었습니다. 기능은 늘어났고, 앱도 나뉘었고, 도메인도 커졌습니다. 그런데 의존성은 여전히 넓게 열려 있었습니다. 어떤 앱이 어떤 도메인을 실제로 쓰는지 Gradle 그래프만 보고는 잘 보이지 않았습니다.

이 글은 그 구조를 bounded context 단위의 domain module로 나누면서 얻은 기준을 정리한 기록입니다.

하나의 Domain 모듈이 숨기는 것

Domain 모듈은 초반에는 편합니다. 앱 모듈은 하나만 의존하면 되고, 개발자는 필요한 클래스를 어디서든 가져다 쓸 수 있습니다.

하지만 시간이 지나면 이 편함이 비용이 됩니다.

  • 기능 경계보다 패키지 위치가 먼저 보입니다.
  • 앱이 실제로 필요한 도메인보다 더 넓게 의존합니다.
  • 도메인 간 호출이 쉬워져서 우회 경로가 생깁니다.
  • 경계 위반을 리뷰에서만 잡아야 합니다.

문제는 코드가 당장 깨지지 않는다는 점입니다. 오히려 너무 잘 컴파일됩니다. 그래서 구조가 무너지는 속도보다 알아차리는 속도가 늦습니다.

모듈 이름이 경계를 드러내게 만들기

전환 방향은 단순했습니다. 도메인을 패키지가 아니라 Gradle module 경계로 드러내는 것입니다.

val domainModules =
    listOf(
        "access",
        "billing",
        "catalog",
        "commerce",
        "commission",
        "diagnostics",
        "file",
        "generation",
        "identity",
        "integration",
        "learning",
        "notification",
        "organization",
        "people",
        "reporting",
        "wallet",
    )

이 구조의 핵심은 이름 자체가 설계 문서 역할을 하게 만드는 데 있습니다. learning은 수업과 학습 흐름을, billing은 결제 청구를, organization은 조직 경계를 다룹니다. 앱 모듈은 필요한 bounded context만 의존해야 합니다.

graph LR
    MainApp["apps/main"]
    Backoffice["apps/backoffice"]
    Learning["domain/learning"]
    Catalog["domain/catalog"]
    Billing["domain/billing"]
    Organization["domain/organization"]
    Persistence["SClass-Persistence"]

    MainApp --> Learning
    MainApp --> Catalog
    MainApp --> Billing
    Backoffice --> Organization
    Backoffice --> Billing
    Persistence --> Learning
    Persistence --> Catalog
    Persistence --> Billing
    Persistence --> Organization

중요한 건 모든 관계를 없애는 게 아닙니다. 필요한 의존성은 남깁니다. 대신 필요하지 않은 의존성이 보이면 바로 드러나게 만듭니다.

Compatibility Layer는 마지막에 걷어낸다

이런 전환에서 가장 위험한 순서는 공용 aggregator를 먼저 지우는 것입니다. 그러면 컴파일 에러가 한 번에 쏟아지고, 진짜 경계 정리와 단순 import 수정이 섞입니다.

더 나은 순서는 반대입니다.

  1. 현재 소비자를 조사합니다.
  2. 앱과 인프라 모듈이 실제로 쓰는 bounded context를 확인합니다.
  3. 넓은 의존성을 직접 context 의존성으로 바꿉니다.
  4. architecture test로 되돌아가는 길을 막습니다.
  5. 마지막에 compatibility layer를 제거합니다.

이렇게 하면 한 PR이 구조 전체를 흔들지 않습니다. 각 단계가 컴파일되고, 테스트되고, 리뷰될 수 있습니다.

테스트는 정책을 코드로 남기는 장치다

모듈 분리는 파일 이동만으로 끝나지 않습니다. 시간이 지나면 다시 편한 쪽으로 흐릅니다. 그래서 architecture test가 필요합니다.

테스트가 검증해야 하는 건 세부 구현이 아니라 정책입니다.

  • 앱 모듈은 필요한 domain module만 의존합니다.
  • domain module이 infrastructure에 의존하지 않습니다.
  • persistence adapter가 domain 경계를 우회하지 않습니다.
  • 새 bounded context가 생길 때 settings와 테스트 정책이 함께 갱신됩니다.

리뷰에서 “이건 경계 위반 아닌가요?”라고 매번 묻는 대신, 빌드가 같은 질문을 하게 만듭니다.

얻은 것과 잃은 것

얻은 건 명확성입니다. Gradle module graph만 봐도 어느 앱이 어느 도메인을 쓰는지 더 잘 보입니다. 변경 영향 범위도 줄어듭니다. 새 기능이 들어올 때 어느 bounded context에 놓을지 먼저 생각하게 됩니다.

잃은 것도 있습니다. 모듈 수가 늘고, 의존성 선언이 늘고, 처음에는 이동 비용이 생깁니다. 작은 코드베이스에서는 과한 구조일 수 있습니다.

그래서 기준은 “모듈을 많이 만들자”가 아닙니다. 경계를 지켜야 하는 비용이 모듈 관리 비용보다 커졌을 때 나눌 가치가 생깁니다.

정리

bounded context 단위의 domain module 분리는 아키텍처를 예쁘게 보이게 하려는 작업이 아니었습니다. 의존성이 설계 의도를 숨기지 못하게 만드는 작업에 가까웠습니다.

Domain 하나가 편한 시점이 있고, 그 편함이 위험해지는 시점이 있습니다. 그 시점을 지나면 패키지 규칙만으로는 부족합니다. 빌드 그래프와 테스트가 경계를 같이 지켜줘야 합니다.