본문 바로가기
안드로이드

Gradle이 재컴파일 범위를 결정하는 방법: ABI 해싱 완전 해부

by 안드뽀개기 2026. 6. 22.
반응형

40개 모듈짜리 프로젝트에서 private 함수 하나만 고쳤는데 빌드가 10분씩 걸린다면 뭔가 잘못된 것이다. 반대로 private 함수를 수정했는데 의존하는 모듈이 전혀 재컴파일되지 않았다면, Gradle이 옳은 판단을 내린 것이다. 이 판단의 근거가 ABI 해싱이다.


모듈 간 의존성과 재컴파일의 연쇄

모듈 A가 모듈 B에 의존한다고 가정한다. B의 코드가 바뀌었을 때, A는 언제 다시 컴파일돼야 하는가? B의 내부 구현이 어떻게 바뀌든, A가 사용하는 B의 공개 인터페이스가 그대로라면 A를 재컴파일할 이유가 없다. 이 원칙이 ABI(Application Binary Interface) 개념의 핵심이다.

ABI는 모듈이 외부에 노출하는 계약 전부다. 공개 클래스 이름, 함수 시그니처(이름·파라미터·반환 타입), 공개 프로퍼티 타입, 공개 인터페이스와 상위 클래스가 여기에 포함된다. private 함수 본문, 지역 변수, 주석, 포맷팅은 ABI에 속하지 않는다.


해싱 파이프라인: kotlinc → ASM → SHA-256

Gradle은 .kt 파일을 직접 분석하지 않는다. Kotlin 컴파일러가 먼저 소스를 JVM 바이트코드(.class)로 변환하고, 그 이후에 Gradle이 개입한다.

.kt 소스
    ↓ kotlinc
.class 바이트코드
    ↓ ASM (바이트코드 분석 라이브러리)
ABI 스켈레톤 (공개 시그니처만 남긴 뼈대)
    ↓ SHA-256
해시값 → ~/.gradle/caches/ 저장

ASM은 .class 파일을 실행 없이 파싱하는 바이트코드 조작 라이브러리다. Gradle은 ASM을 이용해 public/internal 선언만 남기고 나머지를 제거한 스켈레톤을 만든다. 그 스켈레톤에 SHA-256을 적용해 해시를 구하고, 이전 빌드의 해시와 비교한다. 해시가 같으면 의존 모듈 전체 재컴파일을 건너뛴다.


ABI 경계를 코드로 확인하기

class PaymentRepository {

    // ABI에 포함됨 — 외부 모듈이 호출 가능
    fun processPayment(amount: Double): PaymentResult {
        val fee = calculateFee(amount)
        return executeCharge(amount + fee)
    }

    // ABI에 포함되지 않음 — Gradle 재컴파일 판단에 영향 없음
    private fun calculateFee(amount: Double): Double {
        return amount * 0.029 // 수수료율 변경
    }

    private fun executeCharge(total: Double): PaymentResult {
        return api.chargeV2(total) // 엔드포인트 변경
    }
}

calculateFee 수수료율을 바꾸거나 executeCharge 내부 구현을 교체해도, processPayment 시그니처가 동일하다면 ABI 스켈레톤은 변하지 않는다. 해시가 같으니 :payment-feature, :checkout 등 의존 모듈은 재컴파일 대상에서 제외된다.

반면 반환 타입을 바꾸거나 파라미터를 추가하면 스켈레톤이 달라지고 해시도 달라진다.

// 이전: ABI = fun processPayment(Double): PaymentResult
// 이후: ABI = fun processPayment(Double, Currency): PaymentResult
fun processPayment(amount: Double, currency: Currency): PaymentResult { ... }

파라미터 하나가 늘었으니, 이 모듈에 의존하는 모든 모듈이 재컴파일된다.


internal의 함정

Kotlin의 internal 키워드가 ABI에 포함된다는 점은 많은 개발자가 놓친다. internal 선언은 모듈 내부에서만 접근 가능하지만, JVM 바이트코드 레벨에서는 public으로 컴파일된다. Gradle의 ABI 추출기는 바이트코드를 기준으로 동작하므로 internal 변경도 해시에 영향을 준다.

// 이 선언은 ABI에 포함된다
internal fun internalHelper(config: OldConfig): Unit { ... }

// 시그니처가 바뀌면 해시도 바뀐다
internal fun internalHelper(config: NewConfig): Unit { ... }

순수한 내부 구현 변경이라고 생각했는데 의존 모듈이 재컴파일되는 상황이라면, internal 함수 시그니처가 바뀐 건 아닌지 먼저 확인할 가치가 있다.


모듈 설계에 미치는 실질적 영향

ABI 해싱의 효과를 최대한 누리려면, 모듈의 공개 API 크기를 의도적으로 줄이는 것이 유효하다. 외부로 드러나는 클래스와 함수가 적을수록 ABI가 안정적으로 유지되고, 내부 변경이 의존 모듈로 전파되지 않는다.

인터페이스를 별도 :api 모듈로 분리하는 패턴이 실용적이다. 구현 모듈은 인터페이스 모듈에 의존하고, 소비자는 :api 모듈에만 의존하게 한다. 구현 세부 사항이 아무리 바뀌어도 :api의 ABI가 그대로면 재컴파일 연쇄가 멈춘다.

:payment-api       <- 인터페이스 선언만
:payment-impl      <- :payment-api 구현
:checkout          <- :payment-api에만 의존

이 구조에서 :payment-impl 내부를 전면 재작성해도, :payment-api의 해시가 같다면 :checkout은 재컴파일되지 않는다.


이 메커니즘이 효과 없는 상황

ABI 해싱은 JVM 바이트코드 레벨의 시그니처 변화를 감지하는 메커니즘이다. 런타임 동작 변화나 리소스 변경은 감지하지 못한다. 함수 시그니처가 같더라도 내부 로직이 완전히 달라졌다면, 의존 모듈은 재컴파일 없이도 다른 동작을 마주하게 된다.

annotation processor나 KSP를 사용하는 경우도 주의가 필요하다. 어노테이션 변경이 생성 코드에 영향을 주면, ABI 해싱이 재컴파일을 막아주지 않는다. 생성된 코드 자체의 ABI가 바뀌기 때문이다.

모듈 수가 적거나 빌드 시간이 이미 충분히 짧다면, 이 최적화보다 코드 가독성과 단순한 모듈 구조를 우선시하는 것이 합리적이다. 모듈 분리 자체에도 관리 비용이 따른다.

※ 본 글은 정보 제공 목적이며 특정 제품·서비스의 추천이 아닙니다.

반응형