[MSA] Spring Boot 멀티모듈 골격 잡기

Raha·2026년 6월 20일

Social Network Project

목록 보기
2/3

시작 전에 결정해야 했던 것들

0단계에서 "MSA + 헥사고날 + DDD"로 가기로 했으니, 프로젝트 뼈대를 어떻게 잡을지가 첫 과제였다.

결정해야 할 게 생각보다 많았다.

  • Gradle 단일 모듈로 갈지, 멀티모듈로 갈지
  • 모듈 이름을 user-service로 할지 user로 할지
  • Spring Initializr를 어떻게 활용할지
  • JAR로 패키징할지 WAR로 할지

하나씩 결론을 냈다.


왜 멀티모듈인가

단일 모듈로 시작하면 나중에 MSA로 분리할 때 공통 코드를 어디에 둘지가 문제가 된다. :common 모듈을 처음부터 두면 ApiResponse, ErrorCode 같은 공통 유틸을 여러 서비스가 공유할 수 있다.

common ← user, post, like, notification, feed

의존성 방향은 항상 서비스 → common. 반대 방향은 금지다.


Spring Initializr 활용 + 세팅 순서

멀티모듈을 Initializr로 한 번에 뽑는 건 안 된다. Initializr는 단일 모듈만 만들어준다. 그래서 이렇게 했다.

  1. start.spring.io에서 루트 프로젝트 하나만 생성 (Gradle - Groovy, Java 17, Spring Boot 3.5.15, 의존성 없음)
  2. 압축 풀고 gradlew, gradlew.bat, gradle/wrapper/만 가져오기
  3. src/ 폴더 삭제 (루트엔 코드 없어야 함)
  4. settings.gradle, build.gradle 직접 재작성
  5. 모듈 폴더와 각자의 build.gradle은 손으로 생성

Initializr에서 가장 중요하게 챙긴 건 Gradle Wrapper다. gradlew가 없으면 시스템에 Gradle이 설치돼 있어야 하고, 버전이 안 맞으면 이유 모를 에러가 생긴다.

JAR vs WAR는 고민할 것도 없었다. Docker 컨테이너에서 java -jar app.jar로 바로 띄우려면 JAR이다. WAR은 외부 WAS가 따로 있어야 한다.

모듈 이름-service 없이 도메인 이름만 쓰기로 했다. user-service보다 user가 짧고 패키지 경로도 깔끔해진다.


settings.gradle

rootProject.name = 'imon'

include 'admin'
include 'common'
include 'feed'
include 'like'
include 'notification'
include 'post'
include 'user'

루트 build.gradle

멀티모듈에서 루트 build.gradle의 역할은 "모든 서브모듈에 공통으로 적용할 것들을 한 곳에서 관리"다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.5.15' apply false  // 선언만, 적용은 서비스 모듈에서
    id 'io.spring.dependency-management' version '1.1.7'
}

allprojects {
    group = 'com.imon'
    version = '0.0.1-SNAPSHOT'
    repositories { mavenCentral() }
}

subprojects {
    apply plugin: 'java'
    apply plugin: 'io.spring.dependency-management'

    java {
        toolchain {
            languageVersion = JavaLanguageVersion.of(17)
        }
    }

    dependencyManagement {
        imports {
            mavenBom "org.springframework.boot:spring-boot-dependencies:3.5.15"
        }
    }

    dependencies {
        compileOnly 'org.projectlombok:lombok'
        annotationProcessor 'org.projectlombok:lombok'
        testCompileOnly 'org.projectlombok:lombok'
        testAnnotationProcessor 'org.projectlombok:lombok'
        testImplementation 'org.springframework.boot:spring-boot-starter-test'
        testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    }

    tasks.named('test') {
        useJUnitPlatform()
    }
}

여기서 중요한 포인트가 두 가지다.

apply false의 의미는 "이 플러그인을 클래스패스에는 올려두되, 지금 바로 적용하지는 않는다"는 뜻이다. Spring Boot 플러그인은 bootJar 태스크를 활성화하는데, :common은 실행 모듈이 아니라 라이브러리라서 bootJar가 필요 없다. 서비스 모듈에서만 id 'org.springframework.boot'를 적어서 켜면 된다.

sourceCompatibility 대신 toolchain을 쓴 이유는 JDK 버전을 더 명시적으로 고정할 수 있어서다. 시스템에 JDK가 여러 버전 깔려있어도 17로 강제된다.


:common 모듈 build.gradle

// 라이브러리 모듈 — Spring Boot 플러그인 미적용
// bootJar 태스크 없음, 일반 jar로 빌드되어 각 서비스 모듈에 포함됨

dependencies {
    // ApiResponse, ApiError에서 ResponseEntity / HttpStatus 사용
    // ErrorCode에서 HttpStatus 사용
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // 모든 서비스 PK 생성에 사용하는 Monotonic ULID 라이브러리
    implementation 'com.github.f4b6a3:ulid-creator:5.2.3'
}

:common에 뭘 넣을지 기준을 잡아뒀다. 이 기준이 없으면 나중에 뭐가 들어가야 하는지 헷갈린다.

:common 포함 기준 — 아래 조건을 모두 만족할 때만 추가한다.
  1. 모든(또는 대부분의) 서비스 모듈에서 필요하다
  2. 서비스마다 다른 구현이 필요하지 않다
  3. 도메인 로직이 아닌 인프라/공통 유틸 성격이다

현재 common 포함 항목:
  - UlidGenerator          → 모든 서비스가 동일한 PK 생성 전략 사용
  - ErrorCode (인터페이스) → 각 서비스가 자기 에러코드 enum을 구현
  - CommonErrorCode        → 인증, 입력값 오류 등 서비스 무관한 공통 에러
  - BusinessException      → 모든 서비스가 동일한 예외 체계 사용
  - ApiResponse, ApiError  → 모든 서비스가 동일한 응답 포맷 사용

BaseEntityGlobalExceptionHandler는 common에 두지 않는다. BaseEntity는 JPA에 종속된 코드라 JPA를 쓰는 서비스 모듈(persistence 패키지) 안에 위치하고, GlobalExceptionHandler는 Spring MVC 컨텍스트에 등록되는 빈이라 각 서비스의 컴포넌트 스캔 범위 안에 있어야 한다. 둘 다 서비스 모듈에 두는 게 자연스럽다.

서비스 전용 에러코드도 common에 섞지 않는다. UserErrorCode처럼 도메인별 에러는 해당 서비스 모듈 안에 *ErrorCode enum으로 정의하고 ErrorCode 인터페이스를 구현한다. 이렇게 하면 user 서비스가 post 서비스의 에러코드를 알게 되는 상황을 막을 수 있다.


:user 모듈 build.gradle

plugins {
    id 'org.springframework.boot'  // bootJar 활성화, 버전은 루트에서 관리
}

dependencies {
    implementation project(':common')

    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'

    runtimeOnly 'com.mysql:mysql-connector-j'
    runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
    runtimeOnly 'com.h2database:h2'
}

처음에 ulid-creator:user에도 중복 선언했는데, :common에 이미 있어서 제거했다. implementation은 컴파일 클래스패스까지 전파되지 않아서 직접 호출할 일이 있으면 명시해야 하지만, UlidGenerator:common 안에서 쓰고 서비스에서 직접 import 안 하기 때문에 중복이었다.


헥사고날 패키지 구조

헥사고날 아키텍처는 adapter, application, domain 세 계층으로 나뉜다. application 레이어를 명시적으로 분리해야 의존성 방향이 명확해진다.

user/src/main/java/com/imon/user/

├── adapter/
│   ├── in/
│   │   └── web/
│   │       ├── UserController.java
│   │       ├── GlobalExceptionHandler.java
│   │       └── dto/
│   │           ├── RegisterRequest.java
│   │           └── UserResponse.java
│   └── out/
│       └── persistence/
│           ├── BaseEntity.java
│           ├── UserJpaEntity.java
│           ├── UserJpaRepository.java
│           └── UserPersistenceAdapter.java
│
├── application/
│   ├── port/
│   │   ├── in/
│   │   │   ├── RegisterUseCase.java
│   │   │   └── GetUserUseCase.java
│   │   └── out/
│   │       └── UserRepository.java
│   └── service/
│       └── RegisterService.java
│
└── domain/
    └── model/
        └── User.java

의존성 방향은 adapter → application → domain 단방향이다. 도메인은 아무것도 의존하지 않는다.


막혔던 부분들

Gradle Wrapper 경로 문제: Windows PowerShell에서 ./gradlew가 안 됐다. ./이 아니라 .\을 써야 하고, gradlew는 루트에서 실행해야 한다.

# 틀린 것
./gradlew :user:compileJava

# 맞는 것 (루트에서, Windows)
.\gradlew :user:compileJava

common 의존성 전파: implementation은 런타임 클래스패스까지만 전파된다. 소비 모듈 컴파일 타임엔 보이지 않는다. api를 쓰면 컴파일까지 전파되는데, 이를 사용하려면 java 플러그인 대신 java-library 플러그인이 필요하다. 현재 구조에선 서비스가 공통 코드를 직접 import해서 쓰는 경우가 있어서, 이 부분을 고려해서 의존성 구성을 했다.


빌드 성공 확인

.\gradlew :user:compileJava
# BUILD SUCCESSFUL in 2s

다음 글에서는 DDD 패턴(Entity, Repository, UseCase, Command)을 이 폴더 구조에 실제로 매핑한 과정을 기록할 예정이다.

profile
Backend Developer | AI Integration & Product Minded

0개의 댓글