Gradle 의존성 해석의 구조와 Nexus 단일화 — init script 한 파일로 끝내기
Gradle의 저장소 목록은 전역 하나가 아니다. "무엇을 해석하느냐"에 따라 서로 다른 저장소 목록을 보며, 목록이 5개면 Nexus를 꽂는 지점도 5개여야 한다. 이 구조를 이해하면, 스코프별로 흩어져 있던 Nexus 설정과 patch 파일들을 **init script 한 파일(
~/.gradle/init.d/, postinstall 자동 배포)**로 단일화할 수 있다.
1. 배경 — 모든 다운로드가 Nexus를 경유해야 한다
사내망에서는 보안 정책상 외부 Maven 저장소(repo.maven.apache.org · dl.google.com ·
plugins.gradle.org 등)로의 직접 접근이 제한되고, 대신 이들을 프록시하는 Nexus 그룹
저장소가 모든 아티팩트를 서빙한다.
따라서 목표는 단순하다: Gradle의 모든 다운로드가 Nexus를 경유하게 만드는 것. 문제는 "모든 다운로드"가 한 개의 수도꼭지가 아니라는 점이다.
React Native Firebase 업그레이드 중 새 아티팩트(dagger 등)를 받는 단계에서 403을
만난 것이 계기였다. 당장은 gradle.properties에
react.exclusiveEnterpriseRepository=<nexus 주소> 한 줄로 해결됐지만, 왜 이걸로
해결되는지를 설명할 수 없었다. 여기서부터 Gradle이 의존성을 어떻게 가져오는지
리서치가 시작됐다.
2. 핵심 개념 — 해석 스코프마다 저장소 목록이 따로 있다
Gradle의 의존성 처리는 항상 2단계다.
- 선언 수집:
repositories { }와dependencies { }블록은 실행 시점에 다운로드하지 않는다. "어디서(저장소 목록)" · "무엇을(모듈 목록)"을 등록만 한다. - 해석(resolution): 등록이 끝난 뒤, 저장소 목록을 선언 순서대로 조회해 처음 찾은 곳에서 받는다. 앞에서 찾으면 뒤 저장소에는 요청 자체가 나가지 않는다.
여기서 중요한 것: 저장소 목록은 전역 하나가 아니다. RN + Expo 프로젝트 기준으로 아래 4+1개 스코프가 각자 독립된 목록을 가지며, 서로 상속되지 않는다. 목록마다 Nexus를 따로 꽂아야 하고, 하나라도 빠지면 그 스코프의 다운로드가 막혀 빌드가 깨진다.
| # | 스코프 | 무엇을 해석하나 |
|---|---|---|
| ① | plugins DSL | settings.gradle의 plugins { id(...) } |
| ② | 루트 buildscript | AGP·google-services 등 빌드 플러그인 classpath |
| ③ | 서브프로젝트 자체 buildscript | 라이브러리가 자기 build.gradle에 선언한 buildscript |
| ④ | 일반 의존성 | implementation/api — 물량 최다 |
| ⑤ | 포함 빌드(composite build) | includeBuild로 소스 컴파일되는 빌드들 (RN gradle-plugin + expo 플러그인 빌드 3개) |
주의할 점 하나: 커버 범위는 "1차 의존성 vs 전이 의존성"으로 나뉘는 게 아니다. 한 스코프의 저장소 목록은 그 스코프에서 해석되는 **전체 그래프(직접 + 전이)**에 쓰인다. 경계는 깊이가 아니라 스코프다.
3. 스코프별 상세 — 반직관적인 세 가지
③ 서브프로젝트 buildscript — "남의 것"에 미리 끼워넣기
일부 라이브러리는 자기 build.gradle에 자체 buildscript를 선언한다.
// node_modules/@amplitude/analytics-react-native/android/build.gradle
buildscript {
repositories {
google() // ← 접근 불가 저장소. 이 목록만으로 자기 classpath를 해석
mavenCentral()
}
dependencies { classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:..." }
}이 해석에 루트의 저장소 목록(②)은 전혀 참조되지 않는다. 해결은 평가 순서를 이용한다. 루트(또는 init script)가 먼저 평가되며 목록 맨 앞에 Nexus를 선등록해 두면, 라이브러리의 블록은 그 뒤에 병합된다. 해석은 순서대로 조회하고 처음 찾은 곳에서 끝나므로, Nexus(그룹 프록시)가 모든 아티팩트를 갖고 있는 한 뒤의 저장소에는 조회가 도달하지 않는다. buildscript classpath는 블록 종료 즉시 해석돼 사후 개입이 불가능하므로, "미리 앞에 넣어두기"가 이 스코프에서 쓸 수 있는 사실상 유일한 방법이다.
④ 일반 의존성 — RN 플러그인의 주입을 끄는 공식 스위치
가장 물량이 많은 스코프인데, 여기엔 복병이 있다. RN gradle 플러그인이 모든 프로젝트의 일반 의존성 저장소 목록에 외부 저장소 4개(sonatype-snapshots · mavenCentral · google · jitpack)를 강제 주입한다.
react.exclusiveEnterpriseRepository는 플러그인이 공식 선언한 public property로,
주입하는 코드 자신이 이 속성을 읽고 분기한다. 즉 "주입된 걸 지우는" 게 아니라
"애초에 주입하지 않게 하는" 방식이라 실행 순서 경쟁이 원천적으로 없다 — 1절의
응급 처치가 통했던 이유가 이것이다.
⑤ 포함 빌드 — 메인 빌드의 어떤 설정도 닿지 않는 별개 빌드
includeBuild로 소스에서 컴파일되는 빌드(RN gradle-plugin, expo 플러그인 빌드들)는
저장소 목록을 자기 settings/build 파일에서만 읽는다. 이유는 두 겹이다.
- 시점: 포함 빌드의 플러그인 해석은 메인 루트
build.gradle평가 이전에 끝난다. ②③은 시점상 개입 자체가 불가능하다. - 빌드 경계(더 근본): 저장소 목록은 빌드 경계를 넘지 않는다. 포함 빌드는 자기 settings의 pluginManagement만 본다 — 실측으로, 포함 빌드 해석 실패 시 검색 목록에 메인 빌드 ①의 Nexus는 아예 나타나지 않았다.
그래서 처음에는 레포에 커밋 가능한 수단이 patch-package뿐이었고, 포함 빌드마다 settings·build 파일에 Nexus를 삽입하는 patch 4개가 이 스코프를 커버하고 있었다.
4. 캐시 — 설정 누락이 즉시 드러나지 않는 이유
저장소 설정이 빠져 있어도 빌드가 한동안 멀쩡할 수 있다. Gradle 캐시가 네트워크 조회
자체를 생략하기 때문이다. ~/.gradle/caches/modules-2/ 아래에는 성격이 다른 두
저장소가 있다.
| 위치 | 역할 | 비유 |
|---|---|---|
files-2.1/ | 다운로드한 아티팩트 본체 (jar·pom) | 창고 |
metadata-2.x/ | "이 모듈은 이 저장소에 있음/없음"이라는 출처 기록 | 공책 |
어떤 저장소든 자기 차례가 오면 항상 같은 3단계를 거친다.
- 공책(metadata)부터 확인 — "이 모듈, 이 저장소에 있음/없음" 기록이 있는가?
- 기록이 있으면 네트워크 없이 결정 — '있음' 기록 + 창고 파일이면 그대로 사용, '없음' 기록이면 다음 저장소로.
- 기록이 없을 때만 네트워크 확인(HEAD) — 저장소 접근이 막혀 있다면 실패하는 건 오직 이 단계뿐.
핵심은 공책의 키가 (모듈 × 저장소 ID) 쌍이라는 것. "창고에 파일이 있다"와 "그 저장소에서 받은 기록이 있다"는 다르다. 그래서 캐시를 지우거나, 저장소 목록이 바뀌거나, 새 아티팩트가 필요해지는 순간 커버 안 된 스코프부터 무너진다.
이건 추론이 아니라 Gradle 소스(v8.13.0)에 그대로 구현돼 있다.
// modulecache/ModuleComponentAtRepositoryKey.java
public class ModuleComponentAtRepositoryKey {
private final String repositoryId; // ← 저장소 id
private final ModuleComponentIdentifier componentId; // ← 모듈 좌표// ivyresolve/CachingModuleComponentRepository.java — "기록 있으면 무통신" 분기점
CachedMetadata cachedMetadata = moduleMetadataCache.getCachedModuleDescriptor(delegate, id);
if (cachedMetadata == null) {
return; // 기록 없음 → 네트워크 HEAD로 진행
}
if (cachedMetadata.isMissing()) {
result.missing(); // "여기 없음" 기록 → 다음 저장소로
return;
}
result.resolved(state); // 기록 있음 → 네트워크 없이 사용 ✅저장소 id는 URL 등 구성에서 유도되므로("Two repositories with the same configuration will share the same id"), Nexus 칸과 mavenCentral 칸은 영원히 다른 칸이다.
교훈: 저장소 라우팅은 캐시 상태와 무관하게 성립해야 한다. 캐시는 수동 삭제· Gradle 업그레이드·새 장비 셋업에서 언제든 사라진다. 라우팅 검증은 캐시를 신뢰하지 말고
--refresh-dependencies로 실제 네트워크 경로를 확인해야 한다.
5. 단일화 — init script 한 파일로 전 스코프 커버
스코프별로 커버하면 설정이 네 군데(①~④) + patch 4개(⑤)로 흩어진다. 취향이 아니라 구조의 문제다 — 해석 스코프들이 독립된 목록을 갖고 상속되지 않으므로, 목록마다 따로 꽂아야 하기 때문이다. 하지만 모든 목록에 꽂는 주체는 하나로 만들 수 있다.
~/.gradle/init.d/에 놓인 init script는 머신의 모든 빌드(메인 + 포함 빌드 전부)에
자동 적용된다. "저장소 목록은 빌드 경계를 넘지 않는다"는 ⑤의 제약이 여기서 사라진다.
핵심 발견은 주입이 두 층 필요하다는 것 (실측):
- settings 층 (
beforeSettings) — 각 빌드의 pluginManagement에 Nexus 주입. 포함 빌드가 자기 build script에 적용하는 플러그인(kotlin.jvm 등)의 해석 경로 커버. - 프로젝트 층 (
allprojects) — 각 프로젝트의 repositories + buildscript.repositories에 Nexus 선점. 포함 빌드 자신의 컴파일 의존성 (kotlin-stdlib 등)은 pluginManagement가 아니라 자기 build.gradle.kts의repositories {}로 해석되기 때문 — 1층만 주입하면 여기서 실패한다.
프로젝트 층 주입은 프로젝트 로드 직후·빌드 스크립트 평가 전에 실행되므로 Nexus가 목록 맨 앞을 선점한다(③과 동일 원리). 그 결과 두 층 주입이 ①~⑤ 전 스코프를 커버한다:
gradle.properties(④) ·build.gradleallprojects(③) 설정 → 불필요.- 커밋돼 있던 ①② Nexus 선언 → 제거.
- "유지 불가피"로 보였던 Nexus 주입 전용 patch 4개 → 전부 제거.
전부 실측으로 검증했다: 레포 Nexus 설정 0개 상태에서 --refresh-dependencies
BUILD SUCCESSFUL · 외부 저장소 직접 조회 0건.
6. 배포 — postinstall로 자동 설치
init script의 약점은 "레포 밖 파일"이라는 것이다. 파일이 없는 머신·CI는 캐시가 식는 순간 빌드가 깨진다. 그래서 배포를 레포가 책임지게 했다.
- 레포에 Gradle 저장소 설정 0개. 단일 원본은 레포의
scripts/아래 init script 하나. package.json의postinstall이 이 파일을~/.gradle/init.d/로 멱등 복사한다 (patch-package && node scripts/install-gradle-init.js). →yarn install만 하면 셋업 끝. 새 머신·CI도 별도 수동 단계 없음.- 원본을 수정하면 팀원 전원이 다음 install에서 자동으로 최신본을 받는다 — patch의 장점(설치만으로 적용)과 init script의 장점(빌드 경계를 넘는 구조적 커버)을 합치면서 머신 간 드리프트를 막는다.
7. 403을 만나면 — 트러블슈팅 순서
- 에러의 URL을 본다. Nexus가 아닌 직접 경로면 스코프 커버 밖의 해석이다.
어느 configuration에서 실패했는지(
configuration 'classpath'= buildscript 스코프,...CompileClasspath= 일반 의존성)로 스코프를 특정한다. - init script가 설치돼 있는지 확인한다 (
yarn install한 번이면 복구). - Nexus가 404를 주는 경우는 인프라팀에 프록시 대상 저장소 추가를 요청한다.
- patch 제거/변경을 테스트할 땐 반드시
rm -rf node_modules && yarn install후 빌드한다 — patch-package는 install 시점에 적용되므로 patch 파일만 지운 빌드는 착시다.
# 캐시 무시하고 실제 네트워크 경로 확인
./gradlew :app:dependencies --configuration debugRuntimeClasspath --refresh-dependencies
# 정상 상태: Downloading 로그가 Nexus로만 찍혀야 한다
./gradlew app:assembleDebug --info | grep -i downloading마치며
정리하면 세 문장이다.
- Gradle은 "무엇을 해석하느냐"에 따라 서로 다른 저장소 목록을 본다. 목록이 5개면 Nexus를 꽂는 지점도 5개여야 한다.
- 캐시는 설정 누락을 가릴 수 있다. 저장소 라우팅은 캐시 상태와 무관하게 성립해야
하고, 검증은
--refresh-dependencies로 한다. - 스코프별로 흩어진 설정과 patch는 모든 빌드에 적용되는 init script 한 파일 + 자동 배포로 단일화할 수 있다.