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 수정이 섞입니다.
더 나은 순서는 반대입니다.
- 현재 소비자를 조사합니다.
- 앱과 인프라 모듈이 실제로 쓰는 bounded context를 확인합니다.
- 넓은 의존성을 직접 context 의존성으로 바꿉니다.
- architecture test로 되돌아가는 길을 막습니다.
- 마지막에 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 하나가 편한 시점이 있고, 그 편함이 위험해지는 시점이 있습니다. 그 시점을 지나면 패키지 규칙만으로는 부족합니다. 빌드 그래프와 테스트가 경계를 같이 지켜줘야 합니다.