카테고리 없음

java.lang.NoSuchMethodError / ClassNotFoundException / NoSuchFieldError 해결 방법

Hdev&Shoes 2025. 12. 23. 21:10
728x90

 

 

릴리즈 빌드에서만 터지는 ProGuard / R8 오류 완전 정리

 

안드로이드 앱을 개발할 때 디버그에서는 잘 동작하는데, 릴리즈 빌드로 올리면 갑자기 크래시가 나는 경우가 많다. 특히 minifyEnabled true 로 코드 난독화/최적화를 켜면 java.lang.NoSuchMethodError, ClassNotFoundException, NoSuchFieldError 같은 오류가 대표적이다. 이 글에서는 릴리즈에서 자주 터지는 ProGuard / R8 관련 오류가 언제/왜 발생하는지, 어떻게 해결/예방하는지를 실제 예제 코드와 함께 정리한다.

 

 


1. 릴리즈에서만 터지는 ProGuard / R8 오류는 왜 발생할까?

디버그 빌드는 보통 minifyEnabled false 이기 때문에, 코드 난독화나 최적화가 거의 없다. 반면 릴리즈 빌드는 minifyEnabled true + shrinkResources true 를 켜서 크기를 줄이는데, 이 과정에서 다음과 같은 일이 발생한다.

  • 클래스/메서드/필드 이름이 난독화되어 원래 이름과 달라진다.
  • 사용하지 않는다고 판단된 코드가 제거(shrink)된다.
  • 리플렉션(Reflection), 직렬화, Annotation 기반 프레임워크 등은 런타임에 문자열로 이름을 찾기 때문에, 난독화/제거되면 NoSuchMethodError, ClassNotFoundException 등이 발생한다.
핵심: 릴리즈에서만 터지는 이유는 대부분 ProGuard / R8 이 코드 이름을 바꾸거나 제거했는데, 런타임에는 여전히 원래 이름/구조를 기대하기 때문이다.

1.1 build.gradle 릴리즈 설정 예시

android {
    buildTypes {
        debug {
            minifyEnabled false
            shrinkResources false
        }
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile(
                'proguard-android-optimize.txt'
            ), 'proguard-rules.pro'
        }
    }
}

위와 같이 설정하면 디버그에서는 괜찮다가, 릴리즈에서만 “갑자기” 터질 가능성이 생긴다. 특히 Retrofit, Gson, Hilt, Glide, Firebase, Room 같이 리플렉션/코드 생성에 의존하는 라이브러리에서 자주 발생한다.


2. 대표적인 오류 사례와 해결 방법

실제로 릴리즈에서 자주 보는 오류 패턴과, 각각을 어떻게 해결하는지 예제를 통해 정리해보자.

2.1 Retrofit + Gson: NoSuchFieldError / Json 파싱 실패

Gson 은 기본적으로 필드 이름을 그대로 사용해서 JSON 을 매핑한다. 그런데 ProGuard / R8 이 DTO 의 필드명을 난독화해버리면, Gson 이 해당 필드를 찾지 못해 NPE, IllegalStateException, 파싱 실패 등이 발생할 수 있다.

// 서버 응답 DTO
data class UserResponse(
    val id: String,
    val name: String,
    val age: Int
)

디버그 때는 잘 동작하지만, 릴리즈에서 ProGuard 가 id, name, age 필드를 a, b, c 처럼 바꿔버리면 Gson 이 JSON 키와 매칭하지 못한다.

// ProGuard / R8 난독화 후 (개념적으로)
data class UserResponse(
    val a: String,   // 원래 id
    val b: String,   // 원래 name
    val c: Int       // 원래 age
)

해결 방법 1: DTO 에 @Keep 또는 @SerializedName 사용

import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName

@Keep // 이 클래스와 필드는 난독화/제거 금지
data class UserResponse(
    @SerializedName("id")
    val id: String,

    @SerializedName("name")
    val name: String,

    @SerializedName("age")
    val age: Int
)

해결 방법 2: proguard-rules.pro 에 keep 규칙 추가

# Gson 모델 클래스는 난독화하지 않기
-keepclassmembers class com.example.app.data.remote.model.** {
    <fields>;
}

# 또는 패키지 단위로 통째로 유지
-keep class com.example.app.data.remote.model.** { *; }
TIP: 서버 DTO 패키지를 따로 분리해 두고, 해당 패키지만 통째로 keep 하는 전략이 관리하기 가장 편하다.

2.2 리플렉션 기반 프레임워크: ClassNotFoundException / NoSuchMethodError

리플렉션을 직접 쓰거나, 프레임워크가 내부적으로 리플렉션을 사용하는 경우에도 문제가 많이 생긴다. 대표적으로 Hilt, Dagger, EventBus, Glide, Firebase, Room, DataBinding 등이 있다.

// 리플렉션 예시
val clazz = Class.forName("com.example.app.feature.DeepLinkHandler")
val method = clazz.getDeclaredMethod("handle", String::class.java)
method.invoke(clazz.newInstance(), "myapp://home")

ProGuard / R8 이 DeepLinkHandler 클래스를 삭제하거나, handle 메서드 이름을 바꿔버리면 아래와 같은 에러가 릴리즈에서만 발생한다.

java.lang.ClassNotFoundException: com.example.app.feature.DeepLinkHandler
java.lang.NoSuchMethodException: handle [class java.lang.String]

해결 방법: 리플렉션 대상은 무조건 keep

# 리플렉션으로 사용하는 클래스/메서드는 유지
-keep class com.example.app.feature.DeepLinkHandler {
    public *;
}

라이브러리마다 공식 문서에 ProGuard / R8 설정 예제가 있으니, Hilt, Retrofit, Glide, Firebase, Room 등을 쓴다면 반드시 “라이브러리명 + proguard” 로 검색해서 기본 룰을 먼저 복붙해 두는 것이 좋다.

2.3 릴리즈에서만 앱이 바로 죽는 경우: Logcat / mapping.txt 확인

릴리즈 APK 를 설치했더니 앱이 바로 죽어서 어디서 터지는지 모를 때는 다음 순서로 확인한다.

  1. 테스트 기기에서 USB 디버깅 켜고, adb logcat 으로 크래시 로그 확인
  2. 릴리즈 빌드에 대해 Crashlytics 를 붙여서 실제 사용자 환경 로그 수집
  3. 난독화된 스택트레이스를 mapping.txt 로 디코딩해서 원래 클래스/메서드 이름 확인
# mapping.txt 를 사용해서 스택트레이스 디코딩 (예시)
retrace.bat mapping.txt stacktrace.txt
주의: 릴리즈 빌드마다 mapping.txt 를 보관해두면, 나중에 특정 버전에서 발생한 크래시를 분석할 때 큰 도움이 된다.

3. 처음부터 예방하는 ProGuard / R8 설정 전략

릴리즈 직전에 “minify 켰다가” 대량의 크래시를 맞는 것보다, 개발 초기부터 ProGuard / R8 을 켜둔 상태에서 개발하는 것이 훨씬 안전하다. 아래 체크리스트를 기준으로 설정해보자.

3.1 개발 단계부터 minifyEnabled 켜두기

buildTypes {
    debug {
        minifyEnabled true        // 개발 단계에서부터 켜두기
        shrinkResources false     // 리소스 shrink 는 나중에 켜도 됨
        proguardFiles getDefaultProguardFile(
            'proguard-android-optimize.txt'
        ), 'proguard-rules.pro'
    }
    release {
        minifyEnabled true
        shrinkResources true
        proguardFiles getDefaultProguardFile(
            'proguard-android-optimize.txt'
        ), 'proguard-rules.pro'
    }
}

이렇게 해두면, 개발 도중에 이미 ProGuard / R8 관련 문제를 발견할 수 있어서 “출시 직전 릴리즈에서만 터지는 버그”를 미리 잡을 수 있다.

3.2 DTO, 리플렉션 대상, 엔티티 패키지에 공통 keep 룰 적용

# 서버 통신 DTO
-keep class com.example.app.data.remote.model.** { *; }

# Room Entity / DAO
-keep class androidx.room.** { *; }
-keep class * extends androidx.room.RoomDatabase

# Hilt / Dagger / Glide / Gson / Retrofit / Firebase 등
# => 각 라이브러리 공식 문서의 ProGuard 예제를 그대로 가져다 쓴다.
TIP: “모든 걸 다 keep” 하면 크기는 커지지만, 빠르게 안정화가 필요할 때 임시로 쓸 수 있는 전략이다. 이후에 문제가 없는 부분부터 점진적으로 최적화를 다시 켜면 된다.

3.3 테스트 전략: 릴리즈 빌드로 QA 하기

  1. CI 에서 assembleRelease 를 빌드하고 테스트 기기에 설치
  2. QA / 스테이징 환경은 항상 릴리즈 빌드 기준으로 테스트
  3. Crashlytics 를 스테이징에도 붙여서 ProGuard 관련 크래시를 조기에 발견

마무리

디버그에서는 잘 되는데 릴리즈에서만 터지는 오류는 대부분 ProGuard / R8 난독화/최적화와 관련이 있다. 리플렉션, Gson/Retrofit, Room, Hilt 등 동적으로 클래스를 사용하는 라이브러리를 쓸수록 이런 문제는 더 자주 나타난다.

이 글에서 소개한 DTO / 엔티티 keep 규칙, 리플렉션 대상 keep, 개발 단계부터 minify 켜두기 전략을 적용하면, “출시 직전에 릴리즈에서만 터지는” 악몽 같은 버그를 미리 방지할 수 있다.

✅ 핵심 요약:
  • 릴리즈에서만 터지는 NoSuchMethodError, ClassNotFoundException 은 대부분 ProGuard / R8 이 원인이다.
  • 서버 DTO, Room Entity, 리플렉션 대상 클래스는 @Keep 또는 proguard-rules.pro 에 -keep 규칙을 반드시 추가한다.
  • 개발 초기부터 minifyEnabled true 로 개발하면 출시 직전 대형 사고를 줄일 수 있다.
  • 라이브러리별 공식 ProGuard 룰을 꼭 적용하고, Crashlytics + mapping.txt 로 크래시를 분석한다.

 

 

 카카오톡 오픈채팅 링크

https://open.kakao.com/o/seCteX7h

728x90