“이 클래스가 어디에 있어야 하지?”
프로젝트 초반, 코드를 어디에 두어야 할지 자주 막혔습니다. MemberService는 인증 모듈에 있어야 할까요, 매칭 모듈에 있어야 할까요? JWT 토큰 검증 로직을 채팅 서비스에서도 쓰고 싶은데, 복사해야 할까요? BaseEntity를 여러 도메인이 공유하는데, 이게 왜 매칭 패키지 안에 있는 거죠?
단일 모듈로 개발을 시작할 때 이런 질문들은 처음엔 사소해 보입니다. 하지만 도메인이 늘어나고 팀원이 함께 작업하게 되면, 이 사소한 혼란이 커다란 기술 부채로 쌓입니다. 썸블링 백엔드가 멀티모듈 아키텍처를 선택한 이유는 바로 이 문제를 구조적으로 해결하기 위해서입니다.
단일 모듈의 실제 고통
처음 2주는 단일 모듈로 빠르게 개발했습니다. 회원가입, 로그인, 소울 테스트, 매칭까지 기능을 올리는 속도 자체는 빨랐습니다. 문제는 기능 수가 늘어나면서 서서히 드러났습니다.
도메인 경계가 흐려집니다. MatchingService가 MemberRepository를 직접 조회하기 시작했고, AuthService가 매칭 상태를 확인하는 쿼리를 실행했습니다. “일단 되게 만들자”는 압박 속에서 경계는 점점 무너졌습니다.
빌드 캐시가 무의미해집니다. 공통 유틸 클래스 하나를 수정하면 전체를 다시 컴파일해야 합니다. 기능이 늘어날수록 빌드 시간은 선형적으로 증가합니다.
테스트 격리가 어렵습니다. 채팅 기능만 단위 테스트하려 해도 인증, 매칭, 결제 관련 빈들이 모두 로드됩니다. @SpringBootTest 한 번에 20초.
이 문제들을 인식하고 나서 모듈 분리를 결정했습니다.
모듈 구조 설계
썸블링 백엔드는 6개 모듈로 구성됩니다.
aro-server/
├── aro-core # 공통 도메인, BaseEntity, ApiResponse, 예외, 유틸
├── aro-auth # JWT + OAuth2 인증/세션, Member 엔티티
├── aro-matching # Soul Test 분석 + 매칭 엔진, DailyMatch
├── aro-chat # WebSocket 채팅, ChatRoom, ChatMessage
├── aro-api # REST 컨트롤러 (bootJar 진입점, 포트 8080)
├── aro-admin # 관리자 API (별도 bootJar, 포트 8081)
└── aro-infra # Redis, DB 설정, 외부 연동 (S3, Firebase)
각 모듈의 책임을 명확히 정의하는 것이 핵심입니다.
graph TD
API["aro-api<br/> REST 컨트롤러<br/>(bootJar 진입점 :8080)"]
ADMIN["aro-admin<br/>관리자 API<br/>(bootJar :8081)"]
AUTH["aro-auth<br/>JWT · OAuth2<br/>Member 엔티티"]
MATCH["aro-matching<br/>Soul Test · 매칭 엔진<br/>DailyMatch"]
CHAT["aro-chat<br/>WebSocket 채팅<br/>ChatRoom · Message"]
INFRA["aro-infra<br/>Redis · DB 설정<br/>S3 · Firebase"]
CORE["aro-core<br/>BaseEntity · ApiResponse<br/>ErrorCode · 유틸"]
API --> AUTH
API --> MATCH
API --> CHAT
API --> INFRA
ADMIN --> AUTH
ADMIN --> MATCH
ADMIN --> CHAT
ADMIN --> INFRA
AUTH --> CORE
AUTH --> INFRA
MATCH --> CORE
MATCH --> INFRA
CHAT --> CORE
CHAT --> INFRA
INFRA --> CORE
style CORE fill:#7F5AF0,stroke:#7F5AF0,color:#fff
style API fill:#FF6B9D,stroke:#FF6B9D,color:#fff
style ADMIN fill:#FF6B9D,stroke:#FF6B9D,color:#fff
aro-core: 어떤 도메인에도 속하지 않는 것들만 담습니다. BaseEntity(감사 필드), ApiResponse<T>(공통 응답), ErrorCode enum, 글로벌 예외 처리, 날짜/문자열 유틸 클래스. 이 모듈은 Spring Boot의 자동 설정 없이 순수 라이브러리로 동작합니다.
aro-auth: Member, MemberAuth, MemberIdentity 엔티티와 JWT 생성/검증, OAuth2 소셜 로그인, SMS 인증 로직이 여기에 있습니다. Spring Security 설정도 이 모듈에서 정의하고, aro-api에서 임포트합니다.
aro-matching: SoulTest, SoulTestResult, MatchingProfile, DailyMatch 엔티티와 OCEAN 점수 계산, 코사인 유사도 매칭, 일일 배치 스케줄러가 여기에 있습니다. 매칭 알고리즘의 핵심 비즈니스 로직입니다.
aro-chat: ChatRoom, ChatMessage, ChatRoomMember 엔티티와 WebSocket STOMP 설정, 메시지 처리 로직이 여기에 있습니다.
aro-api: 컨트롤러만 있습니다. 비즈니스 로직은 없고, 각 모듈의 서비스를 조합해 HTTP 응답을 만드는 얇은 레이어입니다. 유일하게 bootJar가 생성되는 진입점 모듈입니다.
aro-infra: RedisConfig, JpaConfig, S3 업로드 클라이언트, Firebase Admin SDK 초기화가 여기에 있습니다. 인프라 설정 변경이 도메인 모듈에 영향을 주지 않도록 격리합니다.
의존성 방향: 가장 중요한 규칙
멀티모듈의 핵심은 의존성 방향을 단방향으로 강제하는 것입니다. 썸블링의 규칙은 단순합니다: 의존성은 항상 안쪽(core 방향)으로만 향한다.
aro-api → aro-auth, aro-matching, aro-chat, aro-infra
aro-admin → aro-auth, aro-matching, aro-chat, aro-infra
aro-auth → aro-core, aro-infra
aro-matching → aro-core, aro-infra
aro-chat → aro-core, aro-infra
aro-infra → aro-core
aro-core → (없음)
이 규칙의 실제 의미는 이렇습니다: aro-matching은 aro-auth를 절대 의존하지 않습니다. 매칭 엔진이 인증 모듈을 알면 안 된다는 뜻입니다. 만약 매칭 알고리즘이 회원의 구독 상태를 확인해야 한다면? 그 로직은 aro-api 레이어에서 처리합니다. 컨트롤러가 인증 서비스에서 구독 상태를 가져오고, 그것을 매칭 서비스에 파라미터로 넘기는 방식입니다.
이 규칙을 지키는 것이 처음엔 불편합니다. “그냥 matching에서 auth 의존하면 되는데 왜 돌아가야 해?” 하는 생각이 자주 들었습니다. 하지만 이 불편함이 결국 도메인 경계를 명확히 만들고, 각 모듈을 독립적으로 테스트 가능하게 합니다.
순환 의존성과의 싸움
멀티모듈 분리 과정에서 가장 고통스러웠던 것은 순환 의존성 발견이었습니다.
초기에 aro-auth에 Member 엔티티를 두고 aro-matching에서 이를 참조했습니다. 그런데 나중에 매칭 결과에 따라 회원 상태를 변경해야 하는 로직이 생겼을 때, aro-auth가 aro-matching의 결과를 참조해야 하는 상황이 됐습니다. 순환 의존성이 생긴 겁니다.
해결책은 두 가지였습니다:
1. 공유 엔티티를 core로 이동: Member 엔티티처럼 여러 모듈이 참조해야 하는 것은 aro-core로 올립니다. 단, core는 최대한 가볍게 유지해야 합니다. 모든 것을 core에 넣으면 core가 비대해지고 다시 단일 모듈과 다를 바 없어집니다.
2. 이벤트 기반 통신: 도메인 간 상태 변경이 필요할 때 Spring의 ApplicationEvent를 활용합니다. aro-matching은 매칭 완료 이벤트를 발행하고, aro-auth는 그 이벤트를 구독해 회원 상태를 업데이트합니다. 의존성 방향을 유지하면서 결합을 줄이는 방법입니다.
sequenceDiagram
participant M as aro-matching
participant E as ApplicationEvent
participant A as aro-auth
M->>M: 매칭 완료 처리
M->>E: MatchCompletedEvent 발행
Note over E: Spring Event Bus
E-->>A: 이벤트 구독
A->>A: 회원 상태 업데이트
Note over M,A: 의존성 방향 유지<br/>matching → core ← auth
Gradle Kotlin DSL 빌드 설정
Groovy DSL 대신 Kotlin DSL을 선택했습니다. 타입 안전성과 IDE 자동 완성 때문입니다. buildSrc를 활용해 공통 설정을 한 곳에서 관리합니다.
// buildSrc/src/main/kotlin/common-conventions.gradle.kts
plugins {
id("java")
id("org.springframework.boot") apply false
id("io.spring.dependency-management")
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:3.3.0"))
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
각 모듈의 build.gradle.kts는 이 컨벤션 플러그인을 적용하고 모듈 고유의 의존성만 선언합니다.
// aro-matching/build.gradle.kts
plugins {
id("common-conventions")
}
dependencies {
implementation(project(":aro-core"))
implementation(project(":aro-infra"))
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.querydsl:querydsl-jpa:5.1.0:jakarta")
}
aro-api만 bootJar를 생성하고, 나머지 모듈은 bootJar { enabled = false }, jar { enabled = true }로 설정합니다. 이렇게 하면 aro-matching이 단독 실행 가능한 fat jar를 만들지 않고, aro-api의 클래스패스에 포함되는 일반 jar만 생성합니다.
모듈 분리의 실제 효과
6개 모듈로 분리한 뒤 체감한 변화들입니다.
빌드 캐시 효율: aro-matching 코드만 변경하면 aro-core, aro-infra, aro-auth, aro-chat은 재빌드하지 않습니다. Gradle의 빌드 캐시와 결합하면 증분 빌드 시간이 크게 줄어듭니다.
테스트 격리: aro-matching 테스트에는 @SpringBootTest 대신 @DataJpaTest나 순수 단위 테스트를 씁니다. 인증이나 채팅 빈을 로드할 필요가 없습니다.
팀 병렬 작업: 한 명이 aro-chat을 개발하는 동안 다른 한 명이 aro-matching을 수정해도 충돌 지점이 명확히 줄어듭니다.
도메인 이해도: 새로운 팀원이 합류했을 때 “채팅 관련 코드는 aro-chat 모듈 안에 있어”라고 말할 수 있습니다. 전체 코드베이스를 파악하지 않아도 특정 도메인에 집중할 수 있습니다.
아직 남은 과제
멀티모듈이 모든 문제를 해결하지는 않습니다. 현재도 씨름 중인 과제들이 있습니다.
교차 도메인 트랜잭션이 어렵습니다. 매칭 수락과 채팅방 생성이 하나의 트랜잭션이어야 할 때, 서로 다른 모듈에 걸친 트랜잭션을 어떻게 관리할지는 아직도 고민 중입니다. 현재는 aro-api 레이어에서 여러 서비스를 호출하고, 실패 시 보상 트랜잭션으로 처리하는 방식을 씁니다.
모듈 수가 늘어날수록 settings.gradle.kts와 각 모듈의 의존성 선언을 관리하는 overhead도 증가합니다. 아직 6개 모듈이라 관리 가능한 수준이지만, 프로젝트가 커지면 별도의 컨벤션 체계가 필요할 것입니다.
멀티모듈은 은총알이 아닙니다. 하지만 도메인 경계를 코드 레벨에서 강제하는 가장 효과적인 방법 중 하나임은 분명합니다.
GitHub 계정으로 로그인하여 댓글을 남길 수 있습니다. 허위사실, 욕설, 사칭 등의 댓글은 통보 없이 삭제될 수 있습니다.