Skip to content

Multimodule Gradle

Sechang Jang edited this page Feb 20, 2026 · 1 revision

멀티모듈 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
Loading

의존성은 반드시 아래 방향(상위 → 하위)으로만 흐릅니다. webdomain을 알지만, domainweb을 모릅니다.


각 모듈 상세

jpa-common — 모든 모듈의 공통 기반

// 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 — 인증 관심사 분리

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

userdomain에서 분리한 핵심 이유: 인증·권한 관심사는 웹 서버 외의 환경에서도 필요합니다. 예를 들어 배치 서버나 이벤트 처리 서버가 추가된다면, 그 서버들도 User를 참조해야 하지만 Business나 Reservation 같은 도메인 엔티티는 필요 없을 수 있습니다. user를 독립 모듈로 두면 그 서버가 user만 의존하면 됩니다.

domain — 핵심 비즈니스 엔티티

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 — 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 }

webbootJar가 활성화되어 있고, 나머지 모듈은 jar(일반 라이브러리)로 빌드됩니다. Docker 이미지도 web/build/libs/web-0.0.1-SNAPSHOT.jar 하나만 실행합니다.


실제로 겪은 어려움

문제 1: IntelliJ에서 QueryDSL Q클래스를 인식하지 못함

멀티모듈 환경에서 각 모듈의 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클래스가 인식됩니다.

문제 2: 모듈 간 의존성 방향 혼재 위험

초기에 편의상 domain에서 user의 클래스를 직접 import하려는 시도가 있었습니다. 이를 허용하면 jpa-common → user → domain 단방향이 깨지고 순환 참조 위험이 생깁니다.

해결: 의존성 방향을 주석으로 명시하고, PR 리뷰 시 import 경로를 직접 확인하는 방식으로 관리. domain 모듈의 엔티티가 User를 참조할 필요가 있을 경우, User의 UUID만 필드로 저장하고 실제 User 객체 조인은 web 레이어(QueryDSL)에서 처리합니다.


환경별 설정 분리 (Spring Profile)

멀티모듈 구조와 함께, 운영 환경별 설정도 명확하게 분리했습니다.

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
Loading

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
Loading

batch-server는 Reservation·BookingSlot을 다루므로 domain만 의존합니다. auth-server는 User·RefreshToken만 다루므로 user만 의존합니다. web에 엔티티가 있었다면 이런 분리가 불가능했을 것입니다.

Clone this wiki locally