| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- Rag
- Network
- SRE
- docker
- 기록으로 실력을 쌓자
- CKA 기출문제
- 코틀린 코루틴의 정석
- AI
- tucker의 go 언어 프로그래밍
- AWS EKS
- LLM
- Spring
- MySQL
- Java
- 정보처리기사 실기 기출문제
- 오블완
- kotlin
- Kubernetes
- kotlin coroutine
- 티스토리챌린지
- 바이브코딩
- 공부
- golang
- minikube
- CKA
- Claude
- go
- aws
- CloudWatch
- PETERICA
- Today
- Total
피터의 개발이야기
[Gradle] compileOnly는 "배포 안 함"이 아니다 — AAR과 POM은 서로 다른 것을 배포한다 본문
[Gradle] compileOnly는 "배포 안 함"이 아니다 — AAR과 POM은 서로 다른 것을 배포한다
기록하는 백앤드개발자 2026. 7. 10. 23:35ㅁ 들어가며
라이브러리를 만들어 Maven에 올려본 적이 있다면 compileOnly를 한 번쯤 써봤을 것이다.
"컴파일할 때만 필요하고 배포물에는 안 들어간다"는 설명은 어디에나 있다. 맞는 말이다.
그런데 이 문장에는 숨은 주어가 있다. 누가 배포하지 않는다는 것인가?
compileOnly가 보장하는 건 "내 바이너리에 안 들어간다"이지, "내 소비자의 바이너리에 안 들어간다"가 아니다.
그리고 이 둘 사이를 갈라놓는 것이 POM이다.
라이선스 이슈를 대응하기 위해 내 AAR에는 안 넣되고, 소비자에게는 전달하고 싶은 경우 사용할 수 있는 방법이다.
ㅁ 라이브러리는 두 개를 배포한다
Maven 저장소에 올라가는 것을 뜯어보자.
com/example/mylib/1.0.0/
├── mylib-1.0.0.aar ← 바이너리 (코드)
├── mylib-1.0.0.pom ← 메타데이터 (의존성 선언)
└── mylib-1.0.0.module ← Gradle Module Metadata (선택)
- AAR은 "내가 무엇을 담고 있는가"를 말한다.
- POM은 "나를 쓰려면 무엇이 더 필요한가"를 말한다.
소비자의 Gradle이 implementation 'com.example:mylib:1.0.0'을 만나면,
AAR을 다운로드하기 전에 POM부터 읽는다.
거기 적힌 <dependency>들을 의존성 그래프에 편입시키고, 함께 다운로드한다.
즉 POM은 실행 가능한 지시문이다. 단순한 설명서가 아니다.
ㅁ 각 스코프는 POM에 어떻게 기록되는가
| Gradle 스코프 | 내 컴파일 | 내 런타임 | POM 기록 | 소비자 컴파일 | 소비자 런타임 |
| api | O | O | compile | O | O |
| implementation | O | O | runtime | X | O |
| compileOnly | O | X | 없음 | X | X |
| compileOnlyApi | O | X | compile | O | X |
| runtimeOnly | X | O | runtime | X | O |
- implementation은 POM에 runtime 스코프로 기록된다. 소비자의 컴파일 클래스패스에는 안 올라가지만 APK에는 들어간다. 캡슐화는 되지만 배포는 된다.
- compileOnly만이 유일하게 POM에 아무 흔적도 남기지 않는다. Gradle의 apiElements/runtimeElements 어느 configuration에도 속하지 않기 때문이다. maven-publish 플러그인이 POM을 만들 때 참조하는 게 바로 이 configuration들이다.
- compileOnlyApi는 헷갈리기 쉽다. 이름은 compileOnly 계열이지만 POM에는 compile로 기록된다. 소비자의 컴파일 클래스패스에 전파된다. 어노테이션 라이브러리처럼 "소비자도 컴파일 시점엔 필요하지만 런타임엔 없어도 되는" 것에 쓴다. AGP(Android Gradle Plugin)에서의 지원 여부는 버전마다 다르니 확인이 필요하다.
여담으로, Maven에는 Gradle의 compileOnly에 정확히 대응하는 스코프가 없다. 가장 가까운 건 provided인데, 이건 "런타임 환경이 제공해줄 것"이라는 의미라 뉘앙스가 다르다. maven-publish는 provided를 생성하지 않는다.
ㅁ 번들과 전파를 구분하라
여기서 두 개념을 분리해야 한다. 실무에서 가장 많이 섞이는 지점이다.
번들 (bundling)
내 AAR의 classes.jar 안에 서드파티 .class 파일이 물리적으로 들어감
→ "내 바이너리가 그 코드를 담고 있다"
전파 (propagation)
내 POM의 dependency 선언 때문에 소비자 빌드에 자동 유입됨
→ "내 바이너리엔 없지만, 내 선언 때문에 소비자가 받아간다"
| 번들 | 전파 | |
| 어디에 기록 | AAR classes.jar, libs/ | POM <dependencies> |
| 확인 방법 | jar tf | cat *.pom |
| compileOnly | X | X |
| implementation | X | O |
| shadow/fat-jar | O | X |
일반적인 Gradle 라이브러리는 번들하지 않는다. implementation으로 선언해도 상대 라이브러리의 클래스가 내 jar에 복사되지는 않는다. 복사는 Shadow 플러그인 같은 걸 명시적으로 써야 일어난다.
그래서 "우리 AAR에 그 코드 없습니다"는 대부분의 경우 참이지만, 동시에 대부분의 경우 의미 없는 문장이다.
원래 없다. 중요한 건 전파 여부다.
ㅁ 그래서 build.gradle과 publish.gradle은 무엇을 하는가
build.gradle → "내가 어떻게 컴파일되는가"를 정의
publish.gradle → "내가 어떻게 배포되는가"를 정의
- 이상적인 세계에서는 후자가 전자로부터 자동 유도된다. AGP 7.0부터는 이렇게 쓴다.
// build.gradle
android {
publishing {
singleVariant("release") {
withSourcesJar()
}
}
}
// publish.gradle
afterEvaluate {
publishing {
publications {
release(MavenPublication) {
from components.release // ← 여기가 핵심
groupId = 'com.example'
artifactId = 'mylib'
version = '1.0.0'
}
}
}
}
- from components.release가 하는 일이 중요하다.
이건 AGP가 만들어준 SoftwareComponent를 publication에 연결하는 것이다.
그 컴포넌트는 apiElements와 runtimeElements를 알고 있고,
maven-publish는 그걸 읽어서 POM의 <dependencies>를 자동 생성한다.
api선언 →<scope>compile</scope>implementation선언 →<scope>runtime</scope>compileOnly선언 → 아무것도 안 나옴
진실의 원천이 하나다. build.gradle만 고치면 POM이 따라온다.
ㅁ pom.withXml — 수동으로 POM을 조작하기
publishing {
publications {
release(MavenPublication) {
// from components.release 가 없다!
def output = variant.getPackageLibraryProvider().get().getArchivePath()
artifact(output) {
builtBy variant.getAssembleProvider().get()
}
pom.withXml {
Node dependenciesNode = asNode().appendNode('dependencies')
def addDependencyNode = { group, name, ver ->
Node d = dependenciesNode.appendNode('dependency')
d.appendNode('groupId', group)
d.appendNode('artifactId', name)
d.appendNode('version', ver)
d.appendNode('scope', 'compile')
}
addDependencyNode('org.jetbrains.kotlin', 'kotlin-stdlib', kotlinVersion)
addDependencyNode('com.android.installreferrer', 'installreferrer', '1.0')
}
}
}
}
여기서 from components.release 대신 artifact(output)으로 AAR 파일을 raw로 붙였다.
SoftwareComponent가 없으니 maven-publish는 의존성 정보를 알 방법이 없다.
POM의 <dependencies>는 텅 비어서 나온다.
그래서 pom.withXml로 직접 XML 노드를 박아 넣는다.
asNode()가 POM의 루트를 반환하고, 거기에 <dependencies>를 통째로 만들어 붙이는 것이다.
ㅁ 왜 이렇게 하는가
주로 세 가지 이유다.
하나. 레거시. AGP 3.x 시절에는 components.release가 없었다. pom.withXml이 유일한 방법이었고, 그 코드가 살아남았다.
둘. compileOnly로 선언한 것을 POM에는 넣고 싶을 때. 내 AAR에는 안 넣되, 소비자에게는 전달하고 싶은 경우다. Gradle 스코프만으로는 이 조합이 안 나온다.
셋. 세밀한 제어. 버전 범위, optional, exclusions 등을 직접 쓰고 싶을 때.
ㅁ 영향도
pom.withXml로 scope=compile을 박아 넣으면 어떻게 되는가.
build.gradle: compileOnly 'com.android.installreferrer:installreferrer:1.0'
→ 내 AAR에 없음. 내 컴파일에만 쓰임.
publish.gradle: <dependency>
<groupId>com.android.installreferrer</groupId>
<artifactId>installreferrer</artifactId>
<version>1.0</version>
<scope>compile</scope> ← Maven compile = transitive
</dependency>
→ 소비자가 자동으로 받아감. APK에 들어감.
- 같은 라이브러리가 이중생활을 한다.
내 빌드에서는 compileOnly, 소비자 빌드에서는 compile. 번들은 아니지만 전파는 된다.
- 의도한 것이라면 정당한 설계다.
특히 재배포 제한이 있는 라이브러리(Google Play Services 계열 등)를 fat-jar로 번들하지 않으면서
소비자에게 필요한 의존성을 알려주는 방법이 된다.
번들하면 라이선스 위반 소지가 있지만, POM 선언은 "Google Maven에서 직접 받아가세요"라는 안내에 불과하다.
문제는 의도하지 않았을 때 이 구조가 조용하다는 점이다.
ㅁ pom.withXml의 함정 다섯 가지
진실의 원천이 두 개가 된다
build.gradle에서 의존성 버전을 올려도 publish.gradle의 하드코딩된 문자열은 그대로다.
실제로 이런 어긋남을 자주 본다.
// build.gradle
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.8.22"
// publish.gradle
addDependencyNode('org.jetbrains.kotlin', 'kotlin-stdlib', kotlinVersion)
// ^^^^^^^^^^^^^ 아티팩트 이름이 다름
내가 컴파일한 것과 소비자가 받아가는 것이 다르다.
Gradle Module Metadata가 있으면 POM이 무시된다
.module 파일이 함께 발행되면, Gradle 소비자는 POM이 아니라 .module을 읽는다.
POM은 Maven 소비자용 폴백이 된다.
즉 from components.release와 pom.withXml을 동시에 쓰면,
pom.withXml로 추가한 의존성이 Gradle 사용자에게는 보이지 않는다.
Maven 사용자에게만 보인다. 재현하기 까다로운 종류의 버그다.
앞의 예제 코드처럼 artifact(output)만 쓰면 SoftwareComponent가 없어 .module이 생성되지 않으므로 이 문제는 안 생긴다. 대신 다른 것들을 다 잃는다.
의존성 관리 기능이 죽는다
.module이 없으면 Gradle이 제공하는 것들이 함께 사라진다.
- variant-aware 의존성 해석
platform()/ BOM을 통한 버전 정렬- 능력(capability) 기반 충돌 감지
- rich version constraint (
strictly,prefer,reject)
툴이 못 본다
의존성 스캐너, SBOM 생성기, 라이선스 검증 도구는 대개 build.gradle의 선언을 파싱하거나 Gradle 의존성 그래프를 조회한다.
pom.withXml 클로저 안에서 Groovy 코드로 만들어지는 노드는 빌드를 실행해야만 알 수 있다.
./gradlew dependencies에도 안 나온다. 그 태스크는 configuration을 보지, POM을 보지 않는다.
scope=compile은 생각보다 강하다
Maven의 compile 스코프는 전파된다. 소비자의 컴파일 클래스패스와 런타임 클래스패스에 모두 올라가고, 그 소비자의 소비자에게까지 전파된다.
대부분의 경우 runtime이 더 적절하다. 소비자가 그 API를 직접 호출할 게 아니라면.
ㅁ 어떻게 쓸 것인가
| 원하는 것 | 쓸 것 |
| 내 구현에만 필요, 소비자는 몰라도 됨 | implementation |
| 소비자가 내 API를 쓰려면 타입이 필요 | api |
| 컴파일에만 필요, 런타임엔 없어도 됨 (어노테이션 등) | compileOnly |
| 위와 같지만 소비자 컴파일에도 필요 | compileOnlyApi |
| 선택적 기능. 쓰려면 소비자가 직접 추가 | compileOnly + 리플렉션 + 문서화 |
| 내 바이너리엔 없지만 소비자는 반드시 받아야 함 | implementation 또는 명시적 POM 선언 |
마지막 줄이 pom.withXml이 필요해지는 지점이다. 그리고 그때조차, 가능하면 이렇게 쓰는 게 낫다.
// pom.withXml 대신
publishing {
publications {
release(MavenPublication) {
from components.release
pom {
withXml {
// 정말 불가피한 경우에만
}
}
}
}
}
from components.release로 기본 의존성을 자동 생성시키고, 정말 예외적인 항목만 손대는 것이다.
그리고 pom.withXml을 썼다면 README에 반드시 적어야 한다. 코드를 읽는 사람도, 스캐너도 그 안을 들여다보지 못한다.
ㅁ 마무리
하나. compileOnly가 보장하는 건 "내 바이너리에 없다"이지 "소비자에게 안 간다"가 아니다. 그 차이를 POM이 만든다.
둘. 번들과 전파는 다른 문제다. 일반적인 Gradle 라이브러리는 애초에 번들하지 않으므로, "우리 AAR에 없습니다"는 대개 참이면서 동시에 무의미한 문장이다.
셋. pom.withXml은 build.gradle과 별개의 진실을 만든다. 강력하지만, 그 강력함이 정확히 위험한 이유다.
배포되는 것을 알고 싶으면 build.gradle이 아니라 POM을 열어보면 된다. 5초면 끝난다.
'Programming > Spring' 카테고리의 다른 글
| [Spring] WebClient에서 URI.create()를 사용하지 않을 때 500 Internal Server Error가 발생하는 이유 (0) | 2025.05.28 |
|---|---|
| [Spring] Spring Boot 설정 로드 우선순위 (0) | 2025.03.13 |
| [Spring] MultipartFile.transferTo(): 파일 업로드의 효율적 방법 (1) | 2025.02.13 |
| [Spring] netty tcpClient 설정방법, Read Timeout을 1초로 설정했는데 1.5초가 걸리는 이유 (0) | 2025.02.07 |
| [Spring] @Cacheable 사용법 (1) | 2024.11.27 |
