의존성 관리 — scope, transitive, BOM
스프링 프로젝트에서 의존성 관리의 핵심인 scope 분류, 전이 의존성 충돌 해결, BOM 활용법을 체계적으로 정리합니다.
지난 글에서 Gradle의 태스크 시스템과 증분 빌드를 살펴봤습니다. Maven과 Gradle 모두 의존성 선언 방식에서 scope라는 개념을 사용합니다. 이 글에서는 의존성 관리의 세 가지 핵심인 scope, 전이 의존성, BOM을 정리합니다. 이를 이해하지 못하면 빌드 오류와 JAR 크기 문제, 런타임 버전 충돌을 맞닥뜨렸을 때 원인을 파악하기가 어렵습니다.
Scope — 의존성의 사용 범위
scope는 “이 라이브러리가 언제 필요한가”를 정의합니다. scope를 잘못 설정하면 불필요한 라이브러리가 최종 JAR에 포함되거나, 반대로 런타임에 필요한 클래스가 없어서 오류가 납니다.
Gradle scope (Configuration)
dependencies {
// implementation: 컴파일·런타임 모두 필요. 최종 JAR에 포함.
// 의존하는 모듈의 소비자에게 노출되지 않음 (internal)
implementation("org.springframework.boot:spring-boot-starter-web")
// api: implementation과 같지만 소비자에게도 노출됨
// 라이브러리 모듈에서만 사용 (java-library 플러그인 필요)
api("com.google.guava:guava:32.1.3-jre")
// compileOnly: 컴파일 시에만. 최종 JAR에 미포함.
// 예: Lombok (어노테이션 처리 후 코드가 생성됨)
compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")
// runtimeOnly: 런타임에만 필요. 컴파일 클래스패스 미포함.
// 예: JDBC 드라이버, H2
runtimeOnly("com.h2database:h2")
runtimeOnly("org.postgresql:postgresql")
// testImplementation: 테스트 코드에서만 사용
testImplementation("org.springframework.boot:spring-boot-starter-test")
// testRuntimeOnly: 테스트 런타임에만 필요
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
implementation과 api의 차이는 멀티 모듈에서 중요합니다. api로 선언하면 내 모듈을 사용하는 모든 모듈이 그 의존성에 접근할 수 있지만, implementation은 내 모듈 내부에서만 사용합니다. 라이브러리 누수를 막으려면 api보다 implementation을 기본으로 사용하세요.
Maven scope
<dependencies>
<!-- compile (기본): 컴파일·런타임 모두. 최종 JAR 포함 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- provided: 컴파일 시에만. 컨테이너(서버)가 제공 -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<scope>provided</scope>
</dependency>
<!-- runtime: 런타임에만 필요 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- test: 테스트 시에만 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- import: BOM을 가져올 때만 사용 (type=pom 필수) -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2023.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
전이 의존성 — 내가 선언하지 않은 라이브러리
spring-boot-starter-web 하나를 선언했는데, 실제로 수십 개의 JAR가 클래스패스에 들어옵니다. 이것이 전이 의존성(Transitive Dependency) 입니다.
starter-web이 spring-webmvc를 가져오고, spring-webmvc가 spring-core를, spring-core가 또 다른 것들을 가져옵니다. 이 연쇄가 전이 의존성입니다.
의존성 트리 확인
# Gradle — 컴파일 클래스패스 트리 출력
$ ./gradlew dependencies --configuration compileClasspath
# 특정 라이브러리가 어디서 왔는지 추적
$ ./gradlew dependencyInsight \
--dependency jackson-databind \
--configuration compileClasspath
# Maven
$ mvn dependency:tree
$ mvn dependency:tree -Dincludes=com.fasterxml.jackson.core
출력 예시:
compileClasspath - Compile classpath for source set 'main'.
+--- org.springframework.boot:spring-boot-starter-web -> 3.3.0
| +--- org.springframework.boot:spring-boot-starter -> 3.3.0
| | +--- org.springframework.boot:spring-boot -> 3.3.0
| | | \--- org.springframework:spring-core:6.1.8
| +--- org.springframework:spring-webmvc:6.1.8
| \--- com.fasterxml.jackson.core:jackson-databind:2.17.1
충돌 해결
두 의존성이 같은 라이브러리의 다른 버전을 요구할 때 충돌이 발생합니다.
// Gradle — 버전 강제 지정
configurations.all {
resolutionStrategy {
force("com.fasterxml.jackson.core:jackson-databind:2.17.1")
}
}
// 또는 dependency constraints
dependencies {
constraints {
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.1") {
because("CVE-2023-xxxx 보안 패치")
}
}
}
<!-- Maven — dependencyManagement로 버전 통제 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.1</version>
</dependency>
</dependencies>
</dependencyManagement>
전이 의존성 제외
특정 라이브러리를 완전히 제외해야 할 때:
// Gradle — 특정 전이 의존성 제외
implementation("org.springframework.boot:spring-boot-starter-logging") {
exclude(group = "ch.qos.logback", module = "logback-classic")
}
// 로그백 대신 log4j2 사용
implementation("org.springframework.boot:spring-boot-starter-log4j2")
<!-- Maven -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
BOM — Bill of Materials
BOM(Bill of Materials) 은 “이 라이브러리들은 이 버전 조합을 쓰면 검증됨”을 선언하는 특별한 POM입니다. BOM 하나만 가져오면 수십~수백 개의 라이브러리 버전이 한꺼번에 관리됩니다.
Spring Boot BOM 계층
Spring Boot는 세 겹의 BOM 계층을 가집니다.
- spring-boot-dependencies: 핵심 BOM. 600여 개 라이브러리 버전 정의
- spring-boot-starter-parent (Maven): spring-boot-dependencies를 import한 부모 POM
- io.spring.dependency-management (Gradle): BOM을 Gradle에서 사용할 수 있게 하는 플러그인
// Gradle — Spring Boot BOM이 아닌 다른 BOM도 추가 가능
dependencyManagement {
imports {
// Spring Cloud BOM 추가
mavenBom("org.springframework.cloud:spring-cloud-dependencies:2023.0.2")
// Testcontainers BOM 추가
mavenBom("org.testcontainers:testcontainers-bom:1.19.8")
}
}
dependencies {
// 버전 없이 선언 — Spring Cloud BOM이 버전 결정
implementation("org.springframework.cloud:spring-cloud-starter-gateway")
testImplementation("org.testcontainers:postgresql")
}
BOM에서 버전 재정의
BOM이 지정한 버전보다 더 새로운 버전을 써야 할 때:
// Gradle — ext로 버전 재정의 (Spring Boot BOM이 이 방식 지원)
extra["jackson.version"] = "2.17.2" // BOM 기본값 재정의
// 또는 직접 명시
implementation("com.fasterxml.jackson.core:jackson-databind:2.17.2")
<!-- Maven — properties로 BOM 버전 재정의 -->
<properties>
<jackson-bom.version>2.17.2</jackson-bom.version>
</properties>
의존성 잠금 (Dependency Locking)
재현 가능한 빌드를 위해 의존성 버전을 파일에 잠글 수 있습니다.
// build.gradle.kts — 의존성 잠금 활성화
dependencyLocking {
lockAllConfigurations()
}
# 현재 의존성 버전을 파일에 기록
$ ./gradlew dependencies --write-locks
# 이후 빌드에서 잠금 파일과 다른 버전이 있으면 실패
# gradle/dependency-locks/ 디렉토리에 lockfile 생성
잠금 파일을 git에 커밋해 두면, CI 환경에서도 항상 같은 버전 조합으로 빌드됩니다.
실무 권장 사항
의존성 관리에서 실무적으로 중요한 규칙들:
dependencies {
// 1. starter가 있으면 starter를 써라
// (spring-webmvc 직접 대신 spring-boot-starter-web)
implementation("org.springframework.boot:spring-boot-starter-web")
// 2. 버전은 BOM에 맡겨라
// (직접 버전 명시는 충돌 위험)
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
// 3. scope를 정확히 설정하라
// (테스트 라이브러리가 runtime에 포함되지 않게)
testImplementation("org.springframework.boot:spring-boot-starter-test")
// 4. 보안 취약점은 constraints로 패치하라
constraints {
implementation("org.yaml:snakeyaml:2.2") {
because("CVE-2022-1471 패치")
}
}
}
보안 취약점이 포함된 전이 의존성은 ./gradlew dependencyInsight로 추적하고, constraints로 패치 버전을 강제합니다.
정리
의존성 관리의 핵심은 세 가지입니다. scope로 라이브러리의 사용 범위를 명확히 구분하고, 전이 의존성 충돌을 트리 분석으로 파악하며, BOM으로 검증된 버전 조합을 사용합니다. Spring Boot의 BOM은 이 세 가지를 한꺼번에 해결해주는 가장 편리한 도구입니다. Chapter 2가 마무리됩니다. 다음 Chapter 3부터는 스프링 코어의 핵심인 IoC 컨테이너로 들어갑니다.
지난 글: Gradle 기초 — build.gradle과 태스크
읽어주셔서 감사합니다. 😊