도입
멀티 모듈 프로젝트가 커지면 의존성 버전 관리가 복잡해집니다.
app, domain, infra, batch, api 같은 모듈이 각자 라이브러리 버전을 직접 적기 시작하면, 같은 라이브러리도 모듈마다 다른 버전으로 흩어지기 쉽습니다.
BOM을 사용하면 각 모듈이 버전을 직접 고르는 대신, 프로젝트 전체 또는 제품군 전체가 사용할 의존성 버전 조합을 하나의 기준으로 선언할 수 있습니다.
그래서 BOM을 이해하면 모듈 설계에서 의존성 버전 책임을 어디에 둘 것인지 이해하는 일에 가깝습니다.
필요성
단일 모듈에서는 build.gradle 이나 pom.xml 에 라이브러리와 버전을 직접 적어도 큰 문제가 없어 보입니다. 하지만 멀티 모듈에서는 같은 의존성이 여러 모듈에서 반복되고, 전이 의존성까지 섞이면서 버전 충돌이 훨씬 쉽게 발생합니다.
이때 BOM은 “어떤 모듈이 어떤 라이브러리를 쓰는가”와 “그 라이브러리의 버전은 무엇이어야 하는가”를 분리합니다. 각 모듈은 필요한 라이브러리만 선언하고, 버전은 BOM 또는 platform이 관리하게 만드는 방식입니다.
즉 BOM은 의존성을 줄이는 도구가 아니라, 의존성 버전의 기준점을 중앙화해 모듈 간 버전 흔들림을 줄이는 도구입니다.
- 멀티 모듈 프로젝트에서 같은 라이브러리 버전이 여러 곳에 반복될 때
- Spring Boot, Jackson, Netty, Reactor 같은 라이브러리 묶음의 호환 버전을 맞춰야 할 때
- 사내 공통 모듈이나 starter 가 여러 서비스에 배포될 때
- 의존성 버전 업그레이드를 모듈별 수정이 아니라 플랫폼 단위로 관리하고 싶을 때
정의
Maven 문맥에서 BOM은 보통 packaging=pom 인 POM 파일이며, 내부의 dependencyManagement 에 여러 의존성의 버전이 선언됩니다. 소비 프로젝트는 이 BOM을 import 해서 버전 정보를 가져옵니다.
Gradle 문맥에서는 BOM을 platform 또는 enforcedPlatform 으로 가져올 수 있고, 자체 BOM 역할을 하는 모듈은 java-platform 플러그인과 dependency constraints 로 만들 수 있습니다.
중요한 점은 BOM이 의존성을 자동으로 추가하지 않는다는 것입니다. BOM은 버전을 관리할 뿐이고, 실제로 어떤 라이브러리를 쓸지는 각 모듈이 직접 dependency 로 선언해야 합니다.
"BOM의 본질은 의존성을 추가하는 데 있지 않고
의존성이 등장했을 때 어떤 버전을 써야 하는지 결정하는 기준표를 제공하는 데 있습니다."
핵심 원리
BOM을 사용하지 않으면 각 모듈은 보통 group:name:version 형태로 의존성을 선언합니다. 이 방식은 단순하지만 모듈이 늘어날수록 버전 반복과 불일치가 쉽게 생깁니다.
BOM을 사용하면 각 모듈은 group:name 만 선언하고, 버전은 BOM에서 가져오게 할 수 있습니다. 이렇게 하면 버전 변경이 필요할 때 각 모듈을 전부 수정하는 대신 BOM 또는 platform 만 수정하면 됩니다.
결국 BOM은 모듈 설계에서 “사용 책임”과 “버전 정책 책임”을 분리하는 장치입니다. 모듈은 필요한 라이브러리를 고르고, BOM은 그 라이브러리들의 버전 정합성을 관리합니다.
Without BOM
:app -> jackson-databind:2.17.1
:domain -> jackson-databind:2.16.2
:batch -> jackson-databind:2.15.4
With BOM
:platform -> jackson-databind:2.17.1
:app -> jackson-databind
:domain -> jackson-databind
:batch -> jackson-databind
구성 요소
| 요소 | 역할 | 실무 해석 |
|---|---|---|
| BOM | 의존성 버전 목록 | 라이브러리 버전의 기준표 |
| Maven dependencyManagement | 의존성 버전 중앙화 | 버전을 관리하지만 의존성을 자동 추가하지 않음 |
| Gradle platform | BOM 또는 platform 을 dependency constraints 로 소비 | 버전 권장값 또는 제약으로 사용 |
| Gradle java-platform | 직접 platform 모듈 생성 | 사내 BOM 역할을 하는 모듈을 만들 때 사용 |
| Dependency Constraints | 의존성이 등장했을 때 적용할 버전 제약 | 그 자체로 dependency 를 추가하지 않음 |
| Actual Dependency | 실제로 사용하는 라이브러리 선언 | 버전은 BOM에서 가져오고 모듈은 사용 여부만 선언 |
기본 구조
shop-platform/
├── settings.gradle.kts
├── build.gradle.kts
├── platform/
│ └── build.gradle.kts // java-platform, dependency constraints
├── app/
│ └── build.gradle.kts // platform 사용, 실제 dependency 선언
├── domain/
│ └── build.gradle.kts
├── infra/
│ └── build.gradle.kts
└── batch/
└── build.gradle.kts
이 구조에서는 platform 모듈이 버전 정책을 담당하고, app, domain, infra, batch 같은 모듈은 필요한 라이브러리를 선택하는 책임만 가집니다.
기본 구현
// app/build.gradle.kts
plugins {
`java-library`
}
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}"))
// 버전은 BOM에서 제공
implementation("com.fasterxml.jackson.core:jackson-databind")
implementation("org.slf4j:slf4j-api")
}
// platform/build.gradle.kts
plugins {
`java-platform`
}
dependencies {
constraints {
api("com.fasterxml.jackson.core:jackson-databind:2.17.2")
api("org.slf4j:slf4j-api:2.0.13")
api("org.junit.jupiter:junit-jupiter:5.10.3")
}
}
// app/build.gradle.kts
plugins {
`java-library`
}
dependencies {
implementation(platform(project(":platform")))
implementation("com.fasterxml.jackson.core:jackson-databind")
implementation("org.slf4j:slf4j-api")
testImplementation("org.junit.jupiter:junit-jupiter")
}
jackson-databind 버전을 선언했다고 해서 모든 모듈에 jackson-databind 가 자동으로 추가되는 것은 아닙니다.Maven 구현
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
여기서 중요한 점은 dependencyManagement 안에 적었다고 해서 실제 의존성이 추가되는 것은 아니라는 점입니다. 실제 dependencies 에서 해당 라이브러리를 선언할 때, 버전을 BOM에서 가져오는 구조입니다.
패턴 1. 외부 BOM 가져오기
대표적인 예는 Spring Boot BOM입니다. Spring Boot는 여러 라이브러리의 호환 버전 조합을 관리하므로, 소비 프로젝트는 BOM을 가져온 뒤 개별 의존성의 버전을 생략할 수 있습니다.
이 방식의 장점은 검증된 생태계 단위 버전 조합을 사용할 수 있다는 점입니다. 단점은 BOM이 관리하는 버전 정책을 따라가게 되므로, 특정 라이브러리만 독립적으로 올리거나 내릴 때는 별도 조정이 필요하다는 점입니다.
dependencies {
implementation(platform("com.fasterxml.jackson:jackson-bom:${jacksonVersion}"))
implementation("com.fasterxml.jackson.core:jackson-databind")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
}
패턴 2. 사내 Platform 모듈 만들기
외부 BOM은 외부 생태계 버전 정합성을 맞춰 주지만, 사내 프로젝트 전체의 버전 정책까지 모두 해결해 주지는 않습니다. 예를 들어 사내 공통 라이브러리, 표준 로깅, 테스트 라이브러리, observability stack, database driver 버전은 별도로 관리해야 할 수 있습니다.
이때 :platform 모듈을 만들고, 각 서비스와 모듈이 이 platform을 가져오게 하면 조직 또는 제품군 단위의 버전 정책을 일관되게 유지할 수 있습니다.
// platform/build.gradle.kts
plugins {
`java-platform`
}
javaPlatform {
allowDependencies()
}
dependencies {
api(platform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}"))
constraints {
api("com.mycompany:common-logging:${commonLoggingVersion}")
api("com.mycompany:common-test:${commonTestVersion}")
api("org.testcontainers:junit-jupiter:${testcontainersVersion}")
}
}
패턴 3. platform 과 enforcedPlatform 구분
platform 은 권장 버전 제약에 가깝고, enforcedPlatform 은 그래프의 다른 버전을 덮어쓰는 강한 요구에 가까우므로 재사용 모듈에서는 특히 조심해야 한다Gradle에서 platform 은 BOM의 버전을 dependency constraints처럼 다룹니다. 다른 강한 제약이나 conflict resolution 결과에 따라 실제 버전이 BOM과 달라질 수도 있습니다.
반면 enforcedPlatform 은 BOM의 버전을 강하게 적용해 의존성 그래프의 다른 버전을 덮어쓸 수 있습니다. 이 방식은 강력하지만, 소비자 프로젝트에도 강제 버전이 전파될 수 있으므로 라이브러리 모듈이나 재사용 컴포넌트에서는 신중해야 합니다.
dependencies {
// 일반적인 권장 방식
implementation(platform(project(":platform")))
// 매우 강한 버전 고정이 필요할 때만 신중히 사용
implementation(enforcedPlatform(project(":platform")))
}
패턴 4. BOM 과 Version Catalog 구분
Version Catalog는 libs.jackson.databind 처럼 의존성 좌표를 일관된 이름으로 참조하게 해 줍니다. 즉 사람이 쓰기 편한 별칭과 버전 선언을 제공하는 역할이 큽니다.
반면 BOM 또는 platform은 의존성 그래프에 어떤 모듈이 등장했을 때 그 버전을 어떤 기준으로 정렬할지에 더 직접적으로 관여합니다.
실무에서는 둘을 함께 쓰는 경우도 많습니다. Version Catalog는 좌표 이름을 관리하고, BOM은 생태계 단위 버전 정합성을 맞추는 식입니다.
한계와 주의점
BOM을 도입했다고 해서 의존성 설계가 자동으로 좋아지는 것은 아닙니다. 모듈이 필요 이상으로 많은 라이브러리를 직접 참조하거나, 공통 모듈이 모든 의존성을 끌어안고 있다면 BOM은 그 문제를 숨길 뿐입니다.
또 BOM은 버전 조합을 제안하거나 강제할 수 있지만, 실제 런타임 호환성을 보장하려면 테스트가 필요합니다. 특히 여러 외부 BOM을 동시에 가져오면 서로 다른 버전 정책이 충돌할 수 있습니다.
마지막으로 enforcedPlatform 은 강력한 만큼 전파 영향도 큽니다. 애플리케이션 내부에서는 유용할 수 있지만, 외부 소비자가 있는 라이브러리에서는 소비자의 의존성 그래프까지 강하게 바꿀 수 있으므로 주의해야 합니다.
- BOM은 실제 dependency 를 자동으로 추가하지 않음
- BOM은 버전 정책일 뿐 모듈 경계를 대신 설계해 주지 않음
- 여러 BOM을 함께 쓰면 같은 라이브러리에 대해 버전 정책이 충돌할 수 있음
enforcedPlatform은 소비자 의존성 그래프까지 영향을 줄 수 있음- 버전 정합성과 실제 런타임 호환성은 테스트로 확인해야 함
자주 하는 실수
- BOM을 import 하면 그 안의 모든 라이브러리가 자동으로 추가된다고 생각함
- BOM과 parent POM을 같은 개념으로 이해함
- Gradle Version Catalog와 BOM을 같은 역할로 봄
- 모든 모듈에 같은 BOM을 선언하지 않아 일부 configuration 에는 버전 제약이 적용되지 않음
platform과enforcedPlatform의 강도 차이를 모르고 사용함- 사내 platform 모듈에 constraints 가 아니라 일반 dependency 를 무분별하게 넣음
- 여러 BOM을 가져오면서 충돌 우선순위를 확인하지 않음
실무 루틴
- 먼저 프로젝트 전체의 버전 정책 소유자 를 정한다. 보통
:platform모듈이나 루트 dependency management 가 담당한다. - Spring Boot, Jackson, Netty 같은 생태계 BOM은 직접 버전 조합을 맞추기보다 우선 가져오는 쪽을 검토한다.
- 사내 공통 라이브러리와 테스트 도구 버전은 별도 platform constraints 로 관리한다.
- 각 기능 모듈은 가능하면 버전 없는 dependency 선언만 남긴다.
- 애플리케이션 모듈은
platform을 기본으로 쓰고, 강제 고정이 꼭 필요할 때만enforcedPlatform을 검토한다. - 업그레이드 전후에는
dependencies,dependencyInsight로 실제 선택 버전을 확인한다.
디버깅
dependencyInsight 로 어떤 constraint 또는 BOM이 최종 버전에 영향을 줬는지 본다.Gradle 점검 명령어
./gradlew :app:dependencies --configuration runtimeClasspath
./gradlew :app:dependencyInsight --dependency jackson-databind --configuration runtimeClasspath
Maven 점검 명령어
mvn help:effective-pom
mvn dependency:tree
요약
- ✅ BOM은 Bill of Materials 이며, 빌드 도구에서는 의존성 버전 표준표에 가깝다.
- ✅ BOM은 실제 dependency 를 자동으로 추가하지 않고, 버전을 관리한다.
- ✅ Maven에서는
dependencyManagement와type=pom,scope=import로 BOM을 가져온다. - ✅ Gradle에서는
platform또는enforcedPlatform으로 BOM을 가져온다. - ✅ Gradle의
java-platform플러그인으로 사내 platform 모듈을 만들 수 있다. - ✅ 모듈은 필요한 라이브러리만 선언하고, 버전은 BOM 또는 platform 이 관리하게 하는 편이 좋다.
- ✅
platform과enforcedPlatform은 강도가 다르므로 구분해서 써야 한다. - ✅ BOM은 모듈 경계 설계를 대신하지 않으며, 실제 호환성은 테스트와 dependency graph 확인으로 검증해야 한다.