From e804944f55ead4b44a0b3acf7ea0bf64465b5e08 Mon Sep 17 00:00:00 2001 From: unam98 Date: Sat, 4 Apr 2026 16:13:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20Gson=20=E2=86=92=20Kotlin=20Serializ?= =?UTF-8?q?ation=20=EC=A0=84=ED=99=98=20=EB=B0=8F=20kotlin.Result=20ProGua?= =?UTF-8?q?rd=20=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. ResponseInterceptor, ResultCall, FlowCallAdapter에서 Gson → kotlinx.serialization.json 전환 - BaseResponse/ErrorResponse를 Gson으로 파싱하면 R8 난독화 시 필드명 매핑 실패 - Kotlin Serialization은 컴파일 타임 코드 생성으로 난독화 영향 없음 2. kotlin.Result ProGuard 규칙 추가 - R8이 inline class인 kotlin.Result의 제네릭 Signature를 최적화하면서 Retrofit CallAdapter 생성 실패 (IllegalArgumentException) - https://github.com/square/retrofit/issues/3880 3. DTO 필드 보존 규칙 제거 - Gson 직접 사용이 없어져서 불필요 --- app/proguard-rules.pro | 9 ++--- .../data/network/calladapter/ResultCall.kt | 30 ++++++++--------- .../calladapter/flow/FlowCallAdapter.kt | 22 ++++++++----- .../interceptor/ResponseInterceptor.kt | 33 +++++++------------ 4 files changed, 39 insertions(+), 55 deletions(-) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 63b72c64..9c605736 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -11,14 +11,9 @@ -keepattributes SourceFile,LineNumberTable -keep public class * extends java.lang.Exception -# --- DTO --- -# Gson 리플렉션으로 역직렬화되는 DTO 클래스의 필드명 보존 -# (BaseResponse, ErrorResponse 등이 Gson과 Kotlin Serialization 양쪽에서 사용됨) --keepclassmembers class com.runnect.runnect.data.dto.** { ; } - # --- Retrofit + kotlin.Result --- -# Retrofit이 리턴 타입의 제네릭 정보를 리플렉션으로 읽으므로 Signature 유지 필요 -# kotlin.Result는 inline class라 R8이 타입 정보를 최적화할 수 있음 +# kotlin.Result는 inline class라 R8이 제네릭 타입 정보를 최적화함 +# Retrofit이 Call>의 타입 파라미터를 리플렉션으로 읽지 못해 CallAdapter 생성 실패 # https://github.com/square/retrofit/issues/3880 -keep class kotlin.Result { *; } -keepattributes Signature diff --git a/app/src/main/java/com/runnect/runnect/data/network/calladapter/ResultCall.kt b/app/src/main/java/com/runnect/runnect/data/network/calladapter/ResultCall.kt index 556da262..e121068d 100644 --- a/app/src/main/java/com/runnect/runnect/data/network/calladapter/ResultCall.kt +++ b/app/src/main/java/com/runnect/runnect/data/network/calladapter/ResultCall.kt @@ -1,8 +1,10 @@ package com.runnect.runnect.data.network.calladapter -import com.google.gson.Gson -import com.runnect.runnect.data.dto.response.base.ErrorResponse import com.runnect.runnect.domain.common.RunnectException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import retrofit2.Call import retrofit2.Callback import retrofit2.Response @@ -11,7 +13,7 @@ import okio.Timeout class ResultCall(private val call: Call) : Call> { - private val gson = Gson() + private val json = Json { ignoreUnknownKeys = true } override fun execute(): Response> { throw UnsupportedOperationException("ResultCall doesn't support execute") @@ -46,23 +48,17 @@ class ResultCall(private val call: Call) : Call> { } private fun parseErrorResponse(response: Response<*>): RunnectException { - val errorJson = response.errorBody()?.string() + val errorString = response.errorBody()?.string() return runCatching { - val errorBody = gson.fromJson(errorJson, ErrorResponse::class.java) - val message = errorBody?.run { - message ?: error ?: ERROR_MSG_COMMON - } - - RunnectException( - code = errorBody.status, - message = message - ) + val jsonObject = json.parseToJsonElement(errorString.orEmpty()).jsonObject + val status = jsonObject["status"]?.jsonPrimitive?.int ?: response.code() + val message = jsonObject["message"]?.jsonPrimitive?.content + ?: jsonObject["error"]?.jsonPrimitive?.content + ?: ERROR_MSG_COMMON + RunnectException(code = status, message = message) }.getOrElse { - RunnectException( - code = response.code(), - message = ERROR_MSG_COMMON - ) + RunnectException(code = response.code(), message = ERROR_MSG_COMMON) } } diff --git a/app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapter.kt b/app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapter.kt index 4d473b16..786f8baf 100644 --- a/app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapter.kt +++ b/app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapter.kt @@ -1,11 +1,12 @@ package com.runnect.runnect.data.network.calladapter.flow -import com.google.gson.Gson -import com.runnect.runnect.data.dto.response.base.ErrorResponse import com.runnect.runnect.domain.common.RunnectException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import retrofit2.Call import retrofit2.CallAdapter import retrofit2.Callback @@ -18,7 +19,7 @@ class FlowCallAdapter( private val responseType: Type ) : CallAdapter>> { - private val gson = Gson() + private val json = Json { ignoreUnknownKeys = true } override fun responseType() = responseType // Retrofit의 Call을 Result<>로 변환 @@ -58,15 +59,18 @@ class FlowCallAdapter( } ?: Result.failure(nullBodyException) } - // Response에서 오류를 파싱하여 RunnectException 객체를 생성 private fun parseErrorResponse(response: Response<*>): RunnectException { val errorBodyString = response.errorBody()?.string() - val errorResponse = errorBodyString?.let { - gson.fromJson(it, ErrorResponse::class.java) - } - val errorMessage = errorResponse?.message ?: errorResponse?.error ?: ERROR_MSG_COMMON - return RunnectException(response.code(), errorMessage) + return runCatching { + val jsonObject = json.parseToJsonElement(errorBodyString.orEmpty()).jsonObject + val message = jsonObject["message"]?.jsonPrimitive?.content + ?: jsonObject["error"]?.jsonPrimitive?.content + ?: ERROR_MSG_COMMON + RunnectException(response.code(), message) + }.getOrElse { + RunnectException(response.code(), ERROR_MSG_COMMON) + } } companion object { diff --git a/app/src/main/java/com/runnect/runnect/data/network/interceptor/ResponseInterceptor.kt b/app/src/main/java/com/runnect/runnect/data/network/interceptor/ResponseInterceptor.kt index 5b39ebec..b4b16436 100644 --- a/app/src/main/java/com/runnect/runnect/data/network/interceptor/ResponseInterceptor.kt +++ b/app/src/main/java/com/runnect/runnect/data/network/interceptor/ResponseInterceptor.kt @@ -1,11 +1,8 @@ package com.runnect.runnect.data.network.interceptor -import com.google.gson.Gson -import com.google.gson.JsonElement -import com.google.gson.JsonObject -import com.google.gson.JsonParseException -import com.google.gson.JsonSyntaxException -import com.runnect.runnect.data.dto.response.base.BaseResponse +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.Response @@ -18,14 +15,14 @@ import timber.log.Timber */ class ResponseInterceptor : Interceptor { - private val gson = Gson() + private val json = Json { ignoreUnknownKeys = true } override fun intercept(chain: Interceptor.Chain): Response { val originalResponse = chain.proceed(chain.request()) if (!originalResponse.isSuccessful) return originalResponse val bodyString = originalResponse.peekBody(Long.MAX_VALUE).string() - val newResponseBodyString = jsonToBaseResponse(bodyString)?.let { + val newResponseBodyString = extractData(bodyString)?.let { it.toResponseBody("application/json".toMediaTypeOrNull()) } ?: return originalResponse @@ -42,26 +39,18 @@ class ResponseInterceptor : Interceptor { } } - private fun jsonToBaseResponse(body: String): String? { + private fun extractData(body: String): String? { return try { - val jsonElement: JsonElement = gson.fromJson(body, JsonElement::class.java) - if (!isBaseResponse(jsonElement.asJsonObject)) { - return null - } - - val baseResponse = gson.fromJson(body, BaseResponse::class.java) - gson.toJson(baseResponse.data) - } catch (e: JsonSyntaxException) { - null // JSON 구문 분석 오류 발생 시 원래 형식을 반환 - } catch (e: JsonParseException) { - null // JSON 파싱 오류 발생 시 원래 형식을 반환 + val jsonObject = json.parseToJsonElement(body).jsonObject + if (!isBaseResponse(jsonObject)) return null + jsonObject["data"].toString() } catch (e: Exception) { - null // 기타 예외 발생 시 원래 형식을 반환 + null } } private fun isBaseResponse(jsonObject: JsonObject): Boolean { val requiredFields = listOf("status", "success", "message", "data") - return requiredFields.all { jsonObject.has(it) } + return requiredFields.all { it in jsonObject } } } \ No newline at end of file From 126e99f74efa7aec8139634a3a821632b764f134 Mon Sep 17 00:00:00 2001 From: unam98 Date: Sat, 4 Apr 2026 17:53:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20GsonConverterFactory=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20DTO=20=ED=95=84=EB=93=9C=EB=AA=85=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4=20=EA=B7=9C=EC=B9=99=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RetrofitV2/RetrofitFlow가 여전히 GsonConverterFactory를 사용하므로 DTO 필드명 난독화 방지 규칙이 필요. 이전 커밋에서 잘못 제거한 것을 복원. --- app/proguard-rules.pro | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 9c605736..dc7b0239 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -11,6 +11,11 @@ -keepattributes SourceFile,LineNumberTable -keep public class * extends java.lang.Exception +# --- DTO --- +# RetrofitV2/RetrofitFlow가 GsonConverterFactory를 사용하므로 +# Gson 리플렉션으로 직렬화되는 DTO 필드명 보존 필요 +-keepclassmembers class com.runnect.runnect.data.dto.** { ; } + # --- Retrofit + kotlin.Result --- # kotlin.Result는 inline class라 R8이 제네릭 타입 정보를 최적화함 # Retrofit이 Call>의 타입 파라미터를 리플렉션으로 읽지 못해 CallAdapter 생성 실패