-
Notifications
You must be signed in to change notification settings - Fork 1
Multimodule Gradle
이전 프로젝트에서 단일 Gradle 모듈로 모든 도메인을 운영했을 때 두 가지 문제를 직접 경험했습니다.
첫째, 불필요한 의존성 확산: 특정 기능에만 필요한 라이브러리가 전체 모듈에 포함되어, 빌드 시간이 늘어나고 불필요한 클래스패스가 형성됐습니다.
둘째, 엔티티 간 강결합으로 인한 확장 장벽: 도메인 엔티티들이 하나의 모듈 안에 뒤섞여 있다 보니, 일부 기능만 다른 서버로 분리하거나 재활용하려 할 때 참조 관계가 걸림돌이 됐습니다.
Timefit에서는 이 경험을 바탕으로 처음부터 하나의 질문을 기준으로 모듈 경계를 설계했습니다.
"이 모듈이 다른 아키텍처에서도 독립적으로 재사용될 수 있는가?"
timefit/
├── jpa-common/ ← 공통 JPA 기반 (BaseEntity, QueryDSL 설정)
├── user/ ← User 엔티티 + RefreshToken 엔티티
├── domain/ ← 핵심 비즈니스 도메인 엔티티
└── web/ ← HTTP 레이어 (Controller, Service, Security)
graph LR
subgraph 기반["기반 레이어"]
JPA["jpa-common\nBaseEntity\nQueryDSL 설정\nDayOfWeek, BusinessRole"]
end
subgraph 도메인["도메인 레이어"]
USER["user\nUser Entity\nRefreshToken Entity\nUserRepository"]
DOMAIN["domain\nBusiness, Menu, Reservation\nBookingSlot, Review, Wishlist\n도메인 Repository"]
end
subgraph 애플리케이션["애플리케이션 레이어"]
WEB["web\nController, FacadeService\nQueryService, CommandService\nSecurity, Swagger"]
end
JPA -->|api| USER
JPA -->|api| DOMAIN
USER -->|implementation| DOMAIN
DOMAIN -->|implementation| WEB
USER -->|implementation| WEB
JPA -->|implementation| WEB
의존성은 반드시 아래 방향(상위 → 하위)으로만 흐릅니다. web이 domain을 알지만, domain은 web을 모릅니다.
// jpa-common/build.gradle
plugins { id 'java-library' }
dependencies {
api 'org.springframework.boot:spring-boot-starter-data-jpa'
api 'org.springframework.boot:spring-boot-starter-validation'
api 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
api 'com.fasterxml.jackson.core:jackson-annotations'
}
// QueryDSL Q클래스 생성 경로 설정
def querydslDir = "$buildDir/generated/querydsl"
sourceSets { main.java.srcDirs += [querydslDir] }api 키워드를 사용한 이유: implementation은 해당 모듈 내부에서만 의존성을 쓸 수 있지만, api는 이 모듈을 의존하는 상위 모듈에도 전파됩니다. JPA, QueryDSL, Validation은 user, domain, web 모두에서 필요하므로 api로 노출했습니다.
포함 내용:
-
BaseEntity:createdAt,updatedAt(@CreatedDate,@LastModifiedDate+@EntityListeners(AuditingEntityListener.class)) -
BusinessRole: 사업자 역할 enum (OWNER,MANAGER,MEMBER) -
DayOfWeek: 요일 enum (영업시간 도메인 공용)
user/
└── src/main/java/timefit/user/
├── entity/
│ ├── User.java ← 회원 엔티티 (BUSINESS / CUSTOMER role)
│ ├── UserRole.java ← 역할 enum
│ └── RefreshToken.java ← Refresh Token 저장 엔티티 (jti 기반 Rotation)
└── repository/
├── UserRepository.java
└── RefreshTokenRepository.java
user를 domain에서 분리한 핵심 이유: 인증·권한 관심사는 웹 서버 외의 환경에서도 필요합니다. 예를 들어 배치 서버나 이벤트 처리 서버가 추가된다면, 그 서버들도 User를 참조해야 하지만 Business나 Reservation 같은 도메인 엔티티는 필요 없을 수 있습니다. user를 독립 모듈로 두면 그 서버가 user만 의존하면 됩니다.
domain/
└── src/main/java/timefit/domain/
├── business/ ← Business, BusinessCategory
├── businesshours/ ← BusinessHours (요일별 전체 영업 범위)
├── operatinghours/ ← OperatingHours (예약 가능 세부 시간대)
├── menu/ ← Menu
├── booking/ ← BookingSlot
├── reservation/ ← Reservation (스냅샷 필드 포함)
├── review/ ← Review
└── wishlist/ ← Wishlist
각 도메인 패키지 안에 entity/와 repository/가 함께 위치합니다. Repository가 web이 아닌 domain에 있는 이유는, Repository는 DB와 소통하는 인터페이스이므로 도메인의 관심사이지, HTTP 레이어의 관심사가 아니기 때문입니다.
// web/build.gradle
dependencies {
implementation project(':user')
implementation project(':jpa-common')
implementation project(':domain')
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'com.auth0:java-jwt:4.0.0'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'
}
// web 모듈만 실행 가능한 bootJar 생성
bootJar { enabled = true }
jar { enabled = false }web만 bootJar가 활성화되어 있고, 나머지 모듈은 jar(일반 라이브러리)로 빌드됩니다. Docker 이미지도 web/build/libs/web-0.0.1-SNAPSHOT.jar 하나만 실행합니다.
멀티모듈 환경에서 각 모듈의 QueryDSL Q클래스가 build/generated/querydsl 경로에 생성됩니다. IntelliJ 기본 빌드 방식(IntelliJ IDEA 빌드)은 이 경로를 소스 루트로 자동 인식하지 못해서, QReservation, QMenu 등을 import할 수 없는 컴파일 오류가 발생했습니다.
해결: IntelliJ 빌드 방식을 Gradle로 변경.
Settings → Build, Execution, Deployment
→ Build Tools → Gradle
→ Build and run using: Gradle (변경)
→ Run tests using: Gradle (변경)
Gradle 빌드 방식으로 전환하면 sourceSets { main.java.srcDirs += [querydslDir] } 설정이 정상적으로 반영되어 Q클래스가 인식됩니다.
초기에 편의상 domain에서 user의 클래스를 직접 import하려는 시도가 있었습니다. 이를 허용하면 jpa-common → user → domain 단방향이 깨지고 순환 참조 위험이 생깁니다.
해결: 의존성 방향을 주석으로 명시하고, PR 리뷰 시 import 경로를 직접 확인하는 방식으로 관리. domain 모듈의 엔티티가 User를 참조할 필요가 있을 경우, User의 UUID만 필드로 저장하고 실제 User 객체 조인은 web 레이어(QueryDSL)에서 처리합니다.
멀티모듈 구조와 함께, 운영 환경별 설정도 명확하게 분리했습니다.
graph TD
subgraph yml["application.yml (공통)"]
COMMON["JWT 설정\nActuator/Prometheus\nSwagger\nMail SMTP"]
end
subgraph dev["--- spring.profiles: dev ---"]
DEV["HikariCP: max 150\nTomcat threads: max 400\nP6Spy 쿼리 로깅 ON\nshow-sql: true\nddl-auto: update"]
end
subgraph prod["--- spring.profiles: prod ---"]
PROD["HikariCP: max 50\nTomcat threads: max 100\nP6Spy 로깅 OFF\nshow-sql: false\nddl-auto: update"]
end
yml --> dev
yml --> prod
dev와 prod의 핵심 차이:
| 항목 | dev | prod (m3.medium 기준) |
|---|---|---|
| HikariCP max-pool-size | 150 | 50 |
| Tomcat threads.max | 400 | 100 |
| P6Spy 쿼리 로깅 | ON | OFF |
| show-sql | true | false |
| 로그 보관 기간 | 30일 | 90일 |
prod 값이 dev보다 낮은 이유는 단순히 "운영이라 더 보수적으로" 설정한 것이 아닙니다. m3.medium (1 vCPU, 3.75GB RAM) 사양에서 실제 부하 테스트로 측정한 최적값입니다. Tomcat 100 / HikariCP 50은 CPU × 100 / Tomcat × 0.5 공식으로 산정했고, k6 테스트에서 이 설정이 VU 1000 환경에서도 Thread Usage 7% 수준으로 안정적임을 확인했습니다. (상세: 05_load_test_optimization.md)
현재는 단일 서버에서 전부 실행하지만, 이 모듈 구조는 다음 확장 시나리오를 염두에 두고 설계했습니다.
graph TB
subgraph Current["현재 (단일 서버)"]
WEB1["web\n(모든 요청 처리)"]
end
subgraph Future["확장 시나리오"]
WEB2["web\n(API 서버)"]
BATCH["batch-server\n(슬롯 정리, 통계)"]
AUTH["auth-server\n(인증 전담)"]
end
subgraph Modules["재사용 모듈"]
JPA2["jpa-common"]
USR2["user"]
DOM2["domain"]
end
WEB2 --> DOM2
WEB2 --> USR2
WEB2 --> JPA2
BATCH --> DOM2
BATCH --> JPA2
AUTH --> USR2
AUTH --> JPA2
batch-server는 Reservation·BookingSlot을 다루므로 domain만 의존합니다. auth-server는 User·RefreshToken만 다루므로 user만 의존합니다. web에 엔티티가 있었다면 이런 분리가 불가능했을 것입니다.
시스템
백엔드 포트폴리오