빌드 도구 의존성 관리 버전 충돌 시맨틱 버저닝 JPMS 모듈 재현·아티팩트 📝 문제풀이
◀ 심화 CH 18 📋 목차 심화 CH 20 ▶
🧱 심화 CHAPTER 19 · 5년차 트랙

빌드는 어떻게 짜이고, 의존성은 어떻게 정렬될까?

코드를 짜는 시간만큼이나 "빌드가 왜 이렇게 도는가", "이 라이브러리 버전은 대체 누가 정했는가"를 이해하는 게 시니어의 몫이에요. Maven과 Gradle의 철학 차이, 전이 의존성과 버전 충돌 해결 규칙, BOM, 시맨틱 버저닝, 그리고 자바 모듈 시스템(JPMS)까지 — 빌드 로그를 읽어내는 눈을 만듭니다.

🎯 이 장을 끝내면
🔧
빌드 도구
Maven vs Gradle, 무엇이 다른가
선언적 컨벤션이냐, 유연한 프로그래머빌리티냐.

빌드 도구가 하는 일은 결국 소스 컴파일 → 의존성 해석 → 테스트 → 패키징(JAR)의 자동화예요. 자바 진영의 양대 산맥인 MavenGradle은 같은 목표를 정반대의 철학으로 풉니다.

구분MavenGradle
설정 방식선언적 XML(pom.xml). "무엇을"만 적음Groovy/Kotlin DSL(build.gradle). 스크립트로 로직 표현 가능
실행 모델고정된 수명주기(lifecycle) phase: validate→compile→test→package→install→deploy태스크(task) 그래프(DAG). 태스크 간 의존성으로 구성
철학컨벤션 오버 컨피규레이션 — 표준 구조를 따르면 설정이 거의 없음유연성 — 커스텀 로직·태스크를 자유롭게 정의
속도기본적으로 매번 다시 수행(증분 약함)증분 빌드·빌드 캐시·데몬으로 반복 빌드가 빠름
확장플러그인(goal 바인딩)플러그인 + 임의 태스크
🖼️ 그림으로 보기 — Maven vs Gradle — 실행 모델
Maven 선언적 XML · 컨벤션 중심 • 설정: pom.xml (선언적 XML) • 실행: 고정 수명주기(lifecycle) • 표준 구조 따르면 설정 최소 수명주기 = 순차 실행 validate compile test package mvn package → 앞 단계 자동 실행 Gradle Groovy/Kotlin DSL · 유연 • 설정: build.gradle (DSL, 로직 가능) • 실행: 태스크 그래프(DAG) • 증분·캐시·데몬 → 반복 빌드 ↑ 태스크 그래프 = 의존 기반 :classes :test :jar :build
⚙️ Maven의 "수명주기"를 정확히 이해하기 Maven은 phase가 순서대로 묶여 있어요. mvn package를 치면 그 앞의 validate·compile·test자동으로 먼저 실행됩니다. 실제 일은 각 phase에 바인딩된 플러그인 goal(예: compiler:compile, surefire:test)이 해요. 그래서 Maven은 "적게 적고 표준을 따르면 알아서 도는" 도구예요. 대신 표준을 벗어난 커스텀 흐름을 만들기가 상대적으로 뻣뻣합니다.
💡
Gradle이 반복 빌드에서 빠른 이유.증분 빌드(입력·출력이 안 바뀐 태스크는 UP-TO-DATE로 건너뜀), ② 빌드 캐시(같은 입력의 결과물을 재사용 — 로컬·원격 공유 가능), ③ Gradle 데몬(JVM을 살려둬 워밍업 비용 절감). 세 가지가 합쳐져 "바뀐 부분만" 다시 만듭니다. CI에서 원격 캐시를 붙이면 팀 전체가 결과물을 공유해요.
📦
의존성 관리
전이 의존성, scope, 그리고 BOM
내가 안 적은 라이브러리가 왜 들어와 있을까?

라이브러리 하나를 추가하면 그 라이브러리가 쓰는 라이브러리, 또 그게 쓰는 라이브러리가 줄줄이 딸려와요. 이걸 전이 의존성(transitive dependency)이라고 합니다. 빌드 도구가 이 그래프를 자동으로 해석해 클래스패스를 구성해줘요.

📝
직접 선언
내가 pom/gradle에 적은 것
🔗
전이 의존성
그것들이 끌고 오는 의존성
🌳
의존성 트리
최종 해석된 클래스패스
🎯 BOM(Bill of Materials) — 버전을 한 곳에서 정렬 여러 라이브러리를 쓰다 보면 서로 다른 버전이 섞여 호환성 지옥이 오기 쉬워요. BOM은 "이 세트는 이 버전 조합으로 검증됐다"는 버전 목록표예요. BOM을 임포트하면 개별 의존성에 버전을 안 적어도 BOM이 정한 버전으로 정렬됩니다. 대표 예가 스프링 부트 BOM(spring-boot-dependencies)이에요. 부트 버전만 올리면 수백 개 라이브러리 버전이 검증된 조합으로 함께 맞춰집니다 — 그게 부트 업그레이드의 핵심 가치죠.
<!-- Maven: BOM을 dependencyManagement로 import → 버전 정렬 -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-dependencies</artifactId>
      <version>3.3.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
<!-- 이제 개별 의존성은 version 생략 가능 → BOM이 정한 버전 사용 -->

scope(Maven) / configuration(Gradle)은 "이 의존성이 언제 필요한가"를 정해요. 컴파일에만 필요한지, 런타임에만 필요한지, 테스트에만 필요한지에 따라 최종 산출물에 포함 여부가 달라집니다.

Maven scopeGradle configuration의미
compile(기본)implementation / api컴파일·런타임 모두 필요. api는 전이 노출, implementation은 은닉
runtimeruntimeOnly컴파일엔 불필요, 실행 시에만 필요(예: JDBC 드라이버)
providedcompileOnly컴파일엔 필요하나 런타임은 외부가 제공(예: 서블릿 API)
testtestImplementation테스트에서만 필요. 산출물에 미포함
🧭
exclude(의존성 제외)와 락파일. 원치 않는 전이 의존성(예: 취약한 로깅 라이브러리)은 exclude로 잘라내요. 또 "빌드할 때마다 해석 결과가 흔들리지 않게" 락파일로 확정 버전을 고정합니다 — Gradle의 gradle.lockfile(의존성 락킹), Maven 진영의 재현성 도구가 그 예예요. 락파일이 있으면 어제 통과한 빌드가 오늘도 동일한 의존성으로 재현됩니다.
⚔️
버전 충돌
같은 라이브러리, 다른 버전 — 누가 이길까
Maven과 Gradle의 규칙이 정반대라는 게 핵심.

전이 의존성을 따라가다 보면 같은 라이브러리의 서로 다른 버전이 그래프 곳곳에서 나타나요. 최종적으로는 하나만 선택돼야 하는데(자바 클래스패스에는 같은 클래스가 하나만 존재), 이때 충돌 해결 규칙이 도구마다 다릅니다.

도구기본 충돌 해결 규칙결과
Maven가장 가까운 정의 우선(nearest-wins) — 의존성 트리에서 루트에 더 가까운(depth 짧은) 버전을 선택버전 번호가 낮아도 가까우면 이김
Gradle최신 버전 선택(highest version wins) — 충돌하는 요청 중 가장 높은 버전을 선택깊이와 무관하게 제일 높은 버전이 이김
🔍 예시로 감 잡기 A가 lib:1.0을 직접(depth 1) 쓰고, B(depth 1)가 전이로 lib:2.0(depth 2)을 끌고 온다고 하자.
Maven → 직접 선언한 1.0더 가까우니 1.0 선택(nearest-wins).
Gradle → 요청된 것 중 가장 높은 2.0 선택(highest-wins). 그래서 "왜 내가 1.0을 적었는데 2.0이 들어왔지?"는 십중팔구 Gradle의 최신-선택 규칙 때문이에요. 원하는 버전을 강제하려면 Maven은 dependencyManagement/직접 선언, Gradle은 constraints·resolutionStrategy.force를 씁니다.
🖼️ 그림으로 보기 — 전이 의존성과 버전 충돌 해소
① 전이 의존성 (transitive) 내 프로젝트 A (직접 선언) B (전이) C (전이) A만 선언해도 → B · C 까지 딸려옴 ② 버전 충돌 해소 (Maven nearest-wins) 내 프로젝트 lib 1.0 직접 · depth 1 X (직접) lib 2.0 전이 · depth 2 ✓ 선택 (가까움) ✕ 제외 더 가까운 1.0 이 이김 · Gradle 이면 최신 2.0 선택
⚠️
"nearest-wins"는 버전 크기 비교가 아니에요. Maven은 트리에서의 거리(depth)로 정합니다 — 더 가까우면 낮은 버전이라도 이겨요. 같은 depth에서 충돌하면 pom에 먼저 선언된 것이 이깁니다. 이 규칙 때문에 원치 않는 구버전이 조용히 선택돼 런타임에 NoSuchMethodError가 나는 사고가 흔해요. 진단은 mvn dependency:tree(Gradle은 gradle dependencies)로 트리를 눈으로 확인하는 게 정석입니다.
🔢
시맨틱 버저닝
MAJOR.MINOR.PATCH가 말해주는 것
버전 번호는 "약속"이에요 — 무엇이 깨지는지 알려줍니다.

시맨틱 버저닝(SemVer)은 버전을 MAJOR.MINOR.PATCH 세 자리로 나눠 변경의 성격을 전달하는 규약이에요. 예: 3.4.1.

자리올리는 경우호환성
MAJOR (3.x.x)하위 호환이 깨지는(breaking) 변경(API 제거·시그니처 변경)호환 깨짐 — 코드 수정 필요할 수 있음
MINOR (x.4.x)하위 호환을 지키며 기능 추가✅ 호환 — 그냥 올려도 됨
PATCH (x.x.1)하위 호환되는 버그 수정✅ 호환 — 안전한 업그레이드
🖼️ 그림으로 보기 — 시맨틱 버저닝 구조
3 . 4 . 1 MAJOR 하위 호환 깨짐 (breaking) API 제거 · 시그니처 변경 ❌ 그냥 올리면 위험 MINOR 기능 추가 (하위호환 유지) 기존 코드 안 깨짐 ✅ 올려도 안전 PATCH 버그 수정 (하위호환) 동작 변화 없음 ✅ 안전한 업그레이드 버전 번호는 저자의 '호환성 약속' — MAJOR만 조심, MINOR·PATCH는 대체로 안전
💡
MAJOR가 올랐다 = "그냥 올리면 안 될 수도 있다". MAJOR 업그레이드는 마이그레이션 노트를 반드시 읽고 진행해야 해요. 반대로 PATCH·MINOR는 상대적으로 안전하니, 보안 패치는 빠르게 따라가는 게 좋습니다. 다만 SemVer는 라이브러리 저자의 약속일 뿐 자동 보장이 아니라, 현실에선 MINOR에서도 미묘한 파손이 생기기도 해요 — 그래서 테스트와 락파일이 안전벨트입니다.
🧩
JPMS 모듈
자바 모듈 시스템(Java 9)
패키지 단위 캡슐화 위에 "모듈"이라는 상위 경계를 얹다.

JPMS(Java Platform Module System, Project Jigsaw, Java 9)module-info.java로 모듈의 경계·의존·공개 범위를 명시하게 해요. public이면 어디서든 접근되던 기존 캡슐화의 한계를, 모듈 수준의 강한 캡슐화로 보강합니다.

// module-info.java
module com.example.order {
    requires com.example.common;   // 이 모듈이 의존하는 모듈
    requires java.sql;             // 플랫폼 모듈도 명시

    exports com.example.order.api; // 외부에 공개할 패키지
    // exports 안 한 패키지는 public 클래스라도 모듈 밖에서 접근 불가(강한 캡슐화)
}
키워드의미
requires이 모듈이 필요로 하는 다른 모듈(의존 선언)
exports이 모듈이 외부에 공개하는 패키지. 미공개 패키지는 public이어도 접근 불가
requires transitive내 모듈을 requires한 쪽에 이 의존을 전이 노출
opens런타임 리플렉션 접근을 허용(프레임워크용)
🛣️ 클래스패스 vs 모듈패스 클래스패스(classpath) — 전통 방식. 모든 JAR가 한 덩어리로 섞이고, 캡슐화 경계가 패키지·public까지. 같은 클래스가 여러 JAR에 있으면 순서로 결정(JAR hell).
모듈패스(module-path)module-info가 있는 JAR를 모듈로 올려, requires로 명시한 의존만 보이고 exports한 패키지만 접근돼요. 모듈패스에 있지만 module-info가 없는 일반 JAR는 자동 모듈(automatic module)로 취급돼 이름이 자동 부여되고 모든 것을 export합니다 — 점진적 이전용 장치예요.
⚠️
현실 채택은 제한적이에요. JDK 자체는 모듈화됐지만(그래서 jlink로 커스텀 런타임을 만들 수 있죠), 애플리케이션 레벨의 JPMS 채택은 여전히 낮은 편이에요. 대부분의 팀은 여전히 클래스패스로 빌드·배포하고, 모듈 경계는 빌드 도구의 멀티모듈 프로젝트나 아키텍처 규칙(ArchUnit 등)으로 관리합니다. JPMS는 "존재를 알고 원리를 이해하되, 강제 도입은 신중히"가 실무 감각이에요.
📚
재현 · 아티팩트
같은 입력 → 같은 결과물, 그리고 어디에 둘까
빌드 캐시·패키징 전략·저장소.

재현 가능 빌드(reproducible build)는 "같은 소스·같은 의존성이면 바이트 단위로 동일한 산출물이 나온다"를 목표로 해요. 타임스탬프·파일 순서 같은 비결정 요소를 제거하고, 락파일로 의존성 버전을 고정하며, 빌드 캐시로 동일 입력의 결과를 재사용합니다. 이게 되면 "내 PC에선 되는데" 문제와 공급망 검증이 훨씬 쉬워져요.

📦 fat/uber JAR vs 레이어드 JAR fat(uber) JAR — 앱 코드 + 모든 의존성을 하나의 실행 가능한 JAR로 묶음. 배포는 간단하지만 파일이 크고, 코드 한 줄 바꿔도 전체가 새 이미지가 돼요.
레이어드(layered) JAR — 스프링 부트가 지원. 내용을 의존성 / 스냅샷 의존성 / 리소스 / 앱 코드 레이어로 분리해, 자주 바뀌는 앱 코드만 상위 레이어로 둬요. 그래서 도커 이미지를 만들 때 레이어드 JAR를 쓰면 안 바뀐 의존성 레이어는 캐시 재사용 → 이미지 빌드·푸시가 빠르고 작아집니다. fat JAR는 "단순 배포", 레이어드는 "컨테이너 최적화"라고 기억하세요.
🏛️
아티팩트 저장소(Nexus / Artifactory). 팀이 만든 JAR와 외부 라이브러리를 사내에서 관리·배포하는 저장소예요. 역할은 셋 — ① 사내 산출물 배포(publish), ② Maven Central 등의 프록시·캐시(외부 장애·삭제에 대비, 빌드 가속), ③ 보안·라이선스 스캔과 접근 통제. SNAPSHOT(개발중, 덮어쓰기)과 릴리스(불변) 저장소를 나눠 운영하는 게 관례입니다.
reproducible build build cache fat/uber JAR layered JAR Nexus Artifactory SNAPSHOT / release
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·실무에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설명할 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드)으로 점검해보세요.
심화 CH 20 · 딥다이브 🔬 GC 딥다이브 — G1·ZGC 내부 구조와 튜닝 실전