Friday, May 4, 2018

안드로이드 빌드 실패의 주범, Gradle 의존성 충돌의 해결

평화로운 오후, 잘 작동하던 안드로이드 프로젝트가 갑자기 붉은 에러 메시지를 뿜어내며 빌드에 실패합니다. 어제까지 아무 문제 없었는데, 대체 왜? 이런 경험은 안드로이드 개발자라면 누구나 한 번쯤 겪는 악몽 같은 순간입니다. 코드는 한 줄도 바꾸지 않았지만, 보이지 않는 곳에서 발생한 변화가 프로젝트 전체를 멈춰 세운 것입니다. 이 미스터리한 빌드 실패의 배후에는 대부분 '의존성(Dependency)' 문제가 숨어 있습니다.

현대의 앱 개발은 수많은 외부 라이브러리와의 협업입니다. 구글 플레이 서비스, Firebase, 네트워크 통신, 이미지 로딩 등 모든 기능을 직접 구현하는 대신, 검증된 라이브러리를 가져와 사용함으로써 개발 속도와 안정성을 크게 높일 수 있습니다. 하지만 이 편리함은 '의존성 관리'라는 새로운 숙제를 안겨줍니다. 내가 추가한 라이브러리가 또 다른 라이브러리를 불러오고, 그 라이브러리가 또 다른 라이브러리를 불러오는 복잡한 '의존성 트리'가 형성되면서 예기치 못한 충돌이 발생하기 때문입니다. 이 글에서는 안드로이드 개발의 보이지 않는 암초, Gradle 의존성 충돌의 원인을 깊이 파헤치고, 이를 해결하기 위한 체계적인 전략과 실용적인 팁을 상세하게 다룹니다.

1. 모든 문제의 시작: Gradle과 의존성의 세계

문제를 해결하려면 먼저 도구를 이해해야 합니다. 안드로이드 프로젝트에서 의존성 관리를 책임지는 핵심 도구는 바로 'Gradle'입니다. Gradle은 Groovy 또는 Kotlin DSL(Domain-Specific Language)을 사용하는 강력한 빌드 자동화 시스템입니다. 개발자가 작성한 코드와 리소스, 그리고 외부 라이브러리들을 하나로 묶어 최종적인 APK 또는 AAB 파일을 만들어내는 모든 과정을 담당합니다.

1.1. 알아야 할 핵심 Gradle 파일

안드로이드 스튜디오에서 프로젝트를 열면 여러 `build.gradle` 파일이 보입니다. 이 파일들이 바로 의존성 관리의 중심입니다.

  • `settings.gradle(.kts)`: 프로젝트에 포함될 모듈을 정의합니다. 멀티 모듈 프로젝트에서 어떤 모듈들이 빌드에 참여할지 결정하는 최상위 설정 파일입니다.
  • `build.gradle(.kts)` (Project-level): 프로젝트 전반에 적용될 설정을 담고 있습니다. 이곳에서는 주로 빌드 프로세스 자체에 필요한 플러그인과 그 저장소를 정의합니다. 대표적인 것이 바로 안드로이드 그래들 플러그인(AGP)과 구글 서비스 플러그인입니다.
  • `build.gradle(.kts)` (Module-level, 예: app 모듈): 개별 모듈(예: 앱)에 대한 상세한 빌드 설정을 담당합니다. SDK 버전, 애플리케이션 ID 등을 설정하고, 가장 중요하게는 `dependencies` 블록을 통해 앱 기능 구현에 필요한 라이브러리들을 직접 추가하는 곳입니다. 대부분의 의존성 문제는 이 파일에서 시작됩니다.

// Project-level: build.gradle
// 빌드 시스템 자체를 위한 플러그인 정의
plugins {
    id 'com.android.application' version '8.2.1' apply false
    id 'org.jetbrains.kotlin.android' version '1.9.0' apply false
    // Firebase 나 다른 Google 서비스 연동에 필수적인 플러그인
    id 'com.google.gms.google-services' version '4.4.1' apply false 
}


// Module-level: app/build.gradle
plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    // 프로젝트 레벨에 정의된 플러그인을 여기서 적용
    id 'com.google.gms.google-services' 
}

android {
    // ... 안드로이드 관련 설정
}

dependencies {
    // 앱 코드에서 직접 사용하는 라이브러리들
    implementation 'androidx.core:core-ktx:1.12.0'
    implementation 'androidx.appcompat:appcompat:1.6.1'
    implementation 'com.google.android.material:material:1.11.0'

    // Firebase 라이브러리
    implementation 'com.google.firebase:firebase-analytics'

    // 네트워크 라이브러리
    implementation 'com.squareup.retrofit2:retrofit:2.9.0'
}

위 코드에서 `implementation` 키워드는 해당 라이브러리를 현재 모듈 내부에서만 사용하겠다는 의미입니다. 과거에는 `compile`을 사용했지만, 이제는 `implementation`과 `api`로 구분하여 의존성 범위를 더 명확하게 제어하는 것이 권장됩니다. 이는 불필요한 의존성 전파를 막아 빌드 시간을 단축하고 충돌 가능성을 줄여줍니다.

2. 흔하지만 치명적인 에러: MultiDex의 함정

어느 날 갑자기 `com.android.builder.dexing.DexArchiveBuilderException: ...` 이나 `trouble writing output: Too many method references: 70000; max is 65536.` 와 같은 에러 메시지를 마주했다면, 당신은 안드로이드 개발의 유서 깊은 장벽, '64K 메소드 제한'에 부딪힌 것입니다.

2.1. 왜 64K 메소드 제한이 생기는가?

안드로이드 앱은 DEX(Dalvik Executable) 파일 형식으로 컴파일됩니다. 이 DEX 파일 하나가 참조할 수 있는 총 메소드의 개수는 65,536개(2의 16제곱)로 제한되어 있습니다. 과거에는 이 정도면 충분했지만, 앱 기능이 복잡해지고 구글 플레이 서비스, Firebase, 각종 서드파티 라이브러리들이 추가되면서 이 제한은 아주 쉽게 초과됩니다. 특히 Google Play Services 라이브러리 하나만으로도 수만 개의 메소드가 추가될 수 있습니다.

2.2. MultiDex로 장벽 넘기

이 문제를 해결하기 위해 구글은 'MultiDex'라는 해결책을 제공합니다. 이름 그대로, 앱의 코드를 여러 개의 DEX 파일로 분할하여 64K 제한을 우회하는 기술입니다. MultiDex 설정은 매우 간단합니다.

  1. 모듈 수준의 `build.gradle` 파일의 `defaultConfig` 블록에 `multiDexEnabled true`를 추가합니다.
  2. `dependencies` 블록에 MultiDex 지원 라이브러리를 추가합니다. (Android 5.0, API 21 이상에서는 런타임 지원이 기본 내장되어 이 의존성 추가가 필수는 아니지만, 호환성을 위해 추가하는 것이 안전합니다.)

// Module-level: app/build.gradle
android {
    defaultConfig {
        ...
        // 1. MultiDex 활성화
        multiDexEnabled true
    }
    ...
}

dependencies {
    // 2. MultiDex 지원 라이브러리 추가
    implementation 'androidx.multidex:multidex:2.0.1'
    ...
}

이것만으로 대부분의 MultiDex 관련 빌드 에러는 해결됩니다. 하지만 이미 MultiDex 설정이 되어있는데도 불구하고 비슷한 오류가 발생한다면, 문제는 더 깊은 곳, 즉 의존성 충돌에 있을 가능성이 높습니다. 특정 라이브러리 버전 간의 미묘한 비호환성이 MultiDex 시스템을 혼란스럽게 만들 수 있기 때문입니다.

3. 의존성 지옥: 전이 의존성과 버전 충돌

프로젝트가 커지고 사용하는 라이브러리가 많아질수록 '의존성 지옥(Dependency Hell)'에 빠질 확률이 높아집니다. 문제의 핵심에는 '전이 의존성(Transitive Dependencies)'이 있습니다.

3.1. 전이 의존성이란 무엇인가?

전이 의존성은 내가 추가한 라이브러리가 내부적으로 필요로 하는 또 다른 라이브러리를 의미합니다. 예를 들어, `firebase-analytics`를 프로젝트에 추가했다고 가정해봅시다. 이 라이브러리가 제대로 동작하려면 구글의 다른 여러 라이브러리(`play-services-measurement-api`, `firebase-common` 등)가 필요합니다. Gradle은 이 모든 하위 의존성을 자동으로 다운로드하여 프로젝트에 포함시켜 줍니다. 이는 매우 편리한 기능이지만, 동시에 복잡성의 근원이 됩니다.

문제는 여러 라이브러리가 동일한 하위 라이브러리의 '서로 다른 버전'을 요구할 때 발생합니다.

  • 라이브러리 A는 라이브러리 C의 1.0 버전을 필요로 함
  • 라이브러리 B는 라이브러리 C의 2.0 버전을 필요로 함

이 경우, Gradle은 자체적인 버전 해결 전략(Version Resolution Strategy)에 따라 둘 중 하나의 버전을 선택합니다. 보통은 더 높은 버전을 선택하지만, 이 선택이 항상 올바른 결과를 보장하지는 않습니다. 만약 2.0 버전에서 1.0 버전에 있던 API가 삭제되거나 변경되었다면, 라이브러리 A는 런타임에 필요한 클래스나 메소드를 찾지 못해 `ClassNotFoundException`이나 `NoSuchMethodError` 같은 심각한 오류를 발생시키며 앱을 중단시킬 수 있습니다. 이것이 바로 '버전 충돌'의 실체입니다.

3.2. 의존성 충돌 진단하기: 의존성 트리 분석

의존성 문제를 해결하기 위한 첫걸음은 현재 내 프로젝트의 의존성 구조를 정확히 파악하는 것입니다. 가장 강력한 도구는 Gradle이 제공하는 의존성 트리 출력 명령어입니다.

터미널(또는 Android Studio의 Terminal 창)에서 다음 명령어를 실행하세요.


# Windows
.\gradlew app:dependencies

# Mac / Linux
./gradlew app:dependencies

이 명령어는 `app` 모듈의 모든 의존성을 트리 형태로 출력해줍니다. 결과는 매우 길고 복잡해 보이지만, 핵심 정보를 담고 있습니다.


...
debugImplementation - Implementation only dependencies for 'debug' sources. (n)
+--- com.squareup.retrofit2:retrofit:2.9.0 (n)
+--- com.squareup.retrofit2:converter-gson:2.9.0 (n)
|    \--- com.squareup.retrofit2:retrofit:2.9.0 (n)
\--- com.google.firebase:firebase-analytics:21.5.1 (n)
     +--- com.google.android.gms:play-services-measurement:21.5.1 (n)
     |    +--- androidx.collection:collection:1.1.0 (n)
     |    +--- androidx.legacy:legacy-support-core-utils:1.0.0 (n)
     |    \--- com.google.android.gms:play-services-measurement-impl:21.5.1 (n)
     |         \--- com.google.android.gms:play-services-ads-identifier:18.0.1 -> 18.1.0 (c)
     +--- com.google.android.gms:play-services-measurement-api:21.5.1 (n)
     |    +--- com.google.android.gms:play-services-measurement-sdk-api:21.5.1 (n)
     |    \--- com.google.firebase:firebase-common:20.4.2 (n)
     |         \--- com.google.android.gms:play-services-basement:18.2.0 -> 18.3.0 (c)
...

위 출력에서 눈여겨볼 부분은 `->` 기호입니다. `play-services-ads-identifier:18.0.1 -> 18.1.0` 부분은 Gradle이 프로젝트 내의 다른 의존성 요구사항에 따라 18.0.1 버전 대신 18.1.0 버전으로 강제 업그레이드했음을 의미합니다. 대부분의 경우 이는 문제가 없지만, 충돌이 의심될 때 이 부분을 중심으로 살펴보면 원인을 찾는 데 큰 도움이 됩니다.

4. 의존성 충돌 해결을 위한 실전 전략

문제의 원인을 파악했다면, 이제 해결할 차례입니다. 의존성 충돌을 다루는 몇 가지 효과적인 전략이 있습니다.

4.1. 최신 해결책: Bill of Materials (BoM) 사용

특히 Firebase나 Google Play Services처럼 여러 라이브러리로 구성된 라이브러리 그룹을 사용할 때, 각 라이브러리 버전을 일일이 맞추는 것은 매우 번거롭고 실수하기 쉽습니다. 이를 해결하기 위해 구글은 **BOM(Bill of Materials)** 이라는 개념을 도입했습니다.

BOM은 '자재 명세서'라는 뜻으로, 서로 호환되는 라이브러리 버전들의 목록을 담고 있는 특별한 의존성입니다. BOM을 사용하면, 그룹에 속한 개별 라이브러리의 버전을 명시할 필요 없이 BOM이 알아서 호환되는 최적의 버전을 찾아 적용해 줍니다.

사용법은 간단합니다. `dependencies` 블록에 `platform` 키워드를 사용해 원하는 BOM을 추가하고, 하위 라이브러리들은 버전을 제거하고 선언하면 됩니다.


dependencies {
    // 1. Firebase BoM을 'platform'으로 추가합니다.
    // 이 BoM이 다른 Firebase 라이브러리들의 버전을 관리합니다.
    implementation(platform("com.google.firebase:firebase-bom:32.7.4"))

    // 2. 이제 개별 Firebase 라이브러리를 추가할 때 버전을 명시하지 않습니다.
    // BoM이 정의한 호환 버전을 자동으로 가져옵니다.
    implementation("com.google.firebase:firebase-analytics")
    implementation("com.google.firebase:firebase-auth")
    implementation("com.google.firebase:firebase-firestore")
}

BOM을 사용하면 버전 관리의 복잡성이 대폭 감소하고, 라이브러리 간 호환성 문제를 근본적으로 예방할 수 있습니다. Firebase, Google Cloud, Ktor 등 여러 최신 라이브러리들이 BOM을 지원하므로, 적극적으로 활용하는 것이 좋습니다.

4.2. 특정 버전 명시 및 제외 (Exclusion)

BOM으로 해결되지 않는 서드파티 라이브러리 간의 충돌이 발생할 수 있습니다. 이럴 때는 좀 더 직접적인 방법이 필요합니다.

전략 1: 버전 명시하여 덮어쓰기

의존성 트리 분석 결과, 라이브러리 A가 오래된 버전의 라이브러리 C를 끌고 와서 문제가 된다고 판단되면, `dependencies` 블록에 최신 버전의 라이브러리 C를 직접 명시하여 Gradle이 해당 버전을 사용하도록 강제할 수 있습니다.


dependencies {
    implementation 'com.some.library:a:1.0.0' // 이 라이브러리가 com.problem:c:1.2.0 을 가져옴
    implementation 'com.another.library:b:2.0.0' // 이 라이브러리는 com.problem:c:1.5.0 을 가져옴
    
    // 문제의 원인이 되는 'com.problem:c'의 버전을 직접 1.5.0으로 명시한다.
    // Gradle은 이 명시적 선언을 우선적으로 적용한다.
    implementation 'com.problem:c:1.5.0'
}

전략 2: 특정 전이 의존성 제외

특정 라이브러리가 가져오는 하위 의존성을 아예 배제하고 싶을 때도 있습니다. `exclude` 키워드를 사용하면 됩니다. 이는 특정 라이브러리가 프로젝트에 이미 포함된 다른 버전과 충돌하거나, 더 나은 대체 라이브러리를 사용하고자 할 때 유용합니다.


dependencies {
    // 'library-a'가 가져오는 'buggy-library' 모듈을 의존성에서 제외
    implementation('com.example:library-a:1.0') {
        exclude group: 'com.unwanted', module: 'buggy-library'
    }

    // 제외한 라이브러리 대신, 안정적인 버전을 직접 추가
    implementation 'com.unwanted:buggy-library:2.0-stable'
}

5. 보이지 않는 지휘자: Google Services Gradle 플러그인

의존성 문제를 겪다 보면, 라이브러리 버전뿐만 아니라 프로젝트 수준의 `build.gradle` 파일에 정의된 플러그인 버전이 문제의 원인인 경우가 종종 있습니다. 특히 `com.google.gms:google-services` 플러그인이 대표적입니다.

많은 개발자들이 이 플러그인을 Firebase 라이브러리의 일부로 오해하지만, 이 플러그인의 역할은 다릅니다. 이 플러그인은 라이브러리가 아니라 **빌드 도구**입니다. 빌드 시점에 `google-services.json` 파일을 파싱하여, 앱이 Google 서비스와 통신하는 데 필요한 리소스, API 키, 설정 값들을 안드로이드 리소스와 코드에 자동으로 주입하는 역할을 합니다.


// Project-level build.gradle
plugins {
    // 이 플러그인의 버전은 Android Gradle Plugin(AGP) 버전 및
    // 사용하는 Firebase/Play Services 라이브러리 버전과 호환되어야 한다.
    id 'com.google.gms.google-services' version '4.3.15' apply false
}

과거에 실제로 `com.google.gms:google-services:3.3.0` 버전이 특정 시점에 다른 라이브러리들과의 호환성 문제로 광범위한 빌드 실패를 일으켰던 사례가 있습니다. 당시 해결책은 의외로 간단하게 플러그인 버전을 `3.2.0`으로 다운그레이드하는 것이었습니다. 이처럼 라이브러리를 아무리 업데이트해도 문제가 해결되지 않는다면, 빌드 과정 자체를 관장하는 플러그인, 특히 Google Services 플러그인과 Android Gradle 플러그인(AGP)의 버전을 의심해보고, 공식 문서를 통해 권장되는 호환 버전을 확인하는 것이 중요합니다.

체계적인 문제 해결 워크플로우

갑작스러운 빌드 에러에 당황하지 않고 체계적으로 대응하기 위한 문제 해결 흐름을 정리하면 다음과 같습니다.

  1. 에러 메시지 정독하기: 당황해서 에러 로그를 훑어보지 말고, 처음부터 끝까지 차분히 읽어보세요. `Duplicate class`, `Could not resolve`, `Failed to transform`, `NoSuchMethodError` 등 핵심 키워드를 파악하는 것이 문제 해결의 첫 단추입니다.
  2. 기본적인 청소: Android Studio 메뉴에서 `Build > Clean Project` 를 실행하고, 이어서 `Build > Rebuild Project` 를 시도합니다. 때로는 오래된 빌드 캐시가 문제를 일으키기도 합니다.
  3. 캐시 무효화 및 재시작: 더 강력한 방법으로 `File > Invalidate Caches...`를 선택하고, 'Clear file system cache and Local History' 옵션을 체크한 후 재시작합니다. 이는 Gradle과 IDE의 상태를 완전히 초기화합니다.
  4. 의존성 트리 확인: 터미널에서 `./gradlew app:dependencies` 명령어를 실행하여 현재 의존성 구조를 시각적으로 확인하고, 버전 충돌(`->`)이 발생하는 부분을 집중적으로 분석합니다.
  5. 최신 동기화 및 BoM 적용: 사용하는 라이브러리 그룹(특히 Firebase)에 BoM을 적용하지 않았다면, 이번 기회에 적용하는 것을 적극 권장합니다. 모든 라이브러리를 최신 안정 버전으로 업데이트하고 Gradle을 다시 동기화해보세요.
  6. 점진적 다운그레이드: 만약 최신 버전으로 업데이트한 직후 문제가 발생했다면, 문제가 되는 것으로 의심되는 라이브러리나 플러그인(예: `google-services` 플러그인)을 직전의 안정적인 버전으로 하나씩 다운그레이드하며 원인을 찾아냅니다.
  7. 공식 문서와 커뮤니티 활용: 문제가 특정 라이브러리와 관련이 있다면, 해당 라이브러리의 GitHub 저장소 'Issues' 탭이나 공식 릴리즈 노트를 확인하세요. 전 세계의 다른 개발자들도 동일한 문제를 겪고 해결책을 공유하고 있을 가능성이 높습니다.

안드로이드 개발에서 의존성 관리는 피할 수 없는 과제이자, 안정적인 애플리케이션을 만들기 위한 필수적인 기술입니다. 복잡한 에러 메시지 뒤에는 언제나 논리적인 원인이 존재합니다. Gradle의 작동 방식을 이해하고, 의존성 트리를 분석하는 도구를 손에 익히고, BoM과 같은 현대적인 관리 기법을 적극적으로 활용한다면, 더 이상 예상치 못한 빌드 실패에 좌절하지 않고 능숙하게 대처하는 한 단계 높은 개발자로 성장할 수 있을 것입니다.


0 개의 댓글:

Post a Comment