Skip to content

refactor: 네트워크 레이어 Gson → Kotlin Serialization 전환#381

Closed
unam98 wants to merge 1 commit intodevelopfrom
feature/replace-gson-with-kotlinx-serialization
Closed

refactor: 네트워크 레이어 Gson → Kotlin Serialization 전환#381
unam98 wants to merge 1 commit intodevelopfrom
feature/replace-gson-with-kotlinx-serialization

Conversation

@unam98
Copy link
Copy Markdown
Collaborator

@unam98 unam98 commented Apr 4, 2026

작업 배경

R8 난독화(#379) 적용 후 릴리즈 빌드에서 모든 API 응답이 실패하는 버그가 발생했다.

원인: ResponseInterceptor, ResultCall, FlowCallAdapter에서 Gson()을 직접 생성하여 BaseResponse/ErrorResponse를 역직렬화하고 있었는데, 이 DTO 클래스들은 @Serializable + @SerialName (Kotlin Serialization)으로만 선언되어 있어 Gson이 인식하는 @SerializedName이 없었다. R8이 필드명을 난독화하면 Gson의 리플렉션 기반 매핑이 깨져서 모든 응답 파싱이 실패했다.

근본 원인: 하나의 DTO 클래스를 두 개의 직렬화 라이브러리(Gson + Kotlin Serialization)에서 혼용하는 구조적 문제.

변경 사항

파일 변경 내용
ResponseInterceptor.kt Gson().fromJson(body, BaseResponse::class.java)Json.parseToJsonElement(body).jsonObject로 교체. BaseResponse의 data 필드를 JsonObject에서 직접 추출
ResultCall.kt Gson().fromJson(errorJson, ErrorResponse::class.java)Json.parseToJsonElement()로 에러 응답 파싱 교체
FlowCallAdapter.kt 동일하게 Gson → kotlinx.serialization.json 교체

솔루션 선택 근거

3가지 대안 중 Kotlin Serialization 전환을 선택:

방안 설명 채택 여부
DTO에 @SerializedName 이중 부착 모든 필드에 두 라이브러리 어노테이션을 동시에 달기 ❌ 유지보수 복잡도 증가, 실수 유발
ProGuard rule로 DTO 필드명 보존 -keepclassmembers class ...dto.** { <fields>; } ❌ 난독화 범위 축소, 근본 해결 아님
Gson 직접 사용을 Kotlin Serialization으로 교체 문제가 되는 3곳에서만 Gson → kotlinx.serialization.json ✅ 근본 원인 해결

장점 및 기대효과

1. R8 난독화 안전성 확보

  • Kotlin Serialization은 컴파일 타임에 직렬화 코드를 생성하므로, R8이 필드명을 난독화해도 정상 동작
  • DTO 필드 보존을 위한 별도 ProGuard rule이 불필요 → 난독화 커버리지 100% 유지

2. 직렬화 라이브러리 단일화

  • 네트워크 레이어에서 Gson 직접 호출(new Gson()) 제거
  • DTO 클래스에 @SerializedName/@SerialName 이중 선언 불필요
  • 향후 Gson/Kotlin Serialization 혼용으로 인한 동일 유형의 버그 재발 방지

3. 성능 개선

  • Gson: 런타임 리플렉션 → 객체 생성 시 오버헤드
  • Kotlin Serialization: 컴파일 타임 코드 생성 → 리플렉션 없이 직접 접근, 더 빠름

4. 유지보수 비용 절감

  • Interceptor/CallAdapter에서 Gson 인스턴스 생성·관리 코드 제거
  • 에러 파싱 로직이 단순해짐 (JsonObject에서 직접 필드 접근)

영향 범위

  • ResponseInterceptor — 모든 성공 응답의 BaseResponse 언래핑에 영향
  • ResultCallRetrofitV2 인스턴스를 사용하는 API의 에러 응답 파싱에 영향
  • FlowCallAdapterRetrofitFlow 인스턴스를 사용하는 API의 에러 응답 파싱에 영향
  • Retrofit의 GsonConverterFactory 자체는 그대로 유지 (DTO 본문 직렬화는 변경 없음)

Test Plan

  • ./gradlew assembleRelease 빌드 성공
  • 릴리즈 빌드에서 API 정상 응답 확인
  • 카카오/구글 로그인 정상 동작 확인
  • 에러 응답 시 에러 메시지 정상 파싱 확인 (4xx/5xx)
  • 토큰 만료 → 자동 갱신 → 재요청 흐름 정상 확인

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Chores
    • Refactored internal network error handling and JSON response parsing to improve code stability and maintainability

ResponseInterceptor, ResultCall, FlowCallAdapter에서 Gson()을
직접 생성하여 BaseResponse/ErrorResponse를 파싱하던 코드를
kotlinx.serialization.json으로 교체.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 4, 2026

📝 Walkthrough

Walkthrough

This pull request migrates JSON parsing from Gson to Kotlinx Serialization across three network utility files. Error response handling and JSON extraction logic were refactored to use Kotlinx Serialization's JSON parsing API with unknown key ignorance enabled, while maintaining equivalent error handling behavior and fallback mechanisms.

Changes

Cohort / File(s) Summary
Gson to Kotlinx Serialization Migration
app/src/main/java/com/runnect/runnect/data/network/calladapter/ResultCall.kt, app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapter.kt, app/src/main/java/com/runnect/runnect/data/network/interceptor/ResponseInterceptor.kt
Replaced Gson-based JSON parsing with Kotlinx Serialization for error response and base response handling. Updated error extraction to use parseToJsonElement() with ignoreUnknownKeys = true, added runCatching for parse exception handling, and removed ErrorResponse DTO dependency. Helper method renamed from jsonToBaseResponse to extractData in ResponseInterceptor.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 From Gson's grasp, we hippity-hop,
To Kotlinx lands where serialization won't stop,
With ignoreUnknownKeys, our errors flow free,
Three files now lighter—how graceful to see! ✨
A migration so smooth, with fallbacks so kind,
This refactor's a treasure for developers to find.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: migrating the network layer from Gson to Kotlin Serialization.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/replace-gson-with-kotlinx-serialization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (3)
app/src/main/java/com/runnect/runnect/data/network/interceptor/ResponseInterceptor.kt (1)

47-49: 파싱 실패를 완전히 무시하지 말고 최소한의 진단 로그를 남겨주세요.

Line 47-49는 릴리즈에서 재발 시 원인 추적이 어렵습니다. 응답 본문 전체는 제외하고 예외 타입/메시지만 debug 레벨로 남기면 운영 가시성이 좋아집니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/runnect/runnect/data/network/interceptor/ResponseInterceptor.kt`
around lines 47 - 49, The catch block in ResponseInterceptor (inside the
intercept method of class ResponseInterceptor) currently swallows exceptions by
returning null; instead log the exception type and message at debug level before
returning null to preserve minimal diagnostic visibility (do not log the full
response body). Update the catch (e: Exception) branch to call your logger/debug
utility (e.g., using the interceptor's logger or Android Log/Timbre) to record
e::class.simpleName and e.message, then return null as before.
app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapter.kt (1)

62-73: ResultCall과 예외 코드 결정 규칙을 맞추는 것을 권장합니다.

현재는 여기서 HTTP status만 사용하고, ResultCall은 payload status를 반영합니다. 동일 API가 호출 경로(Flow vs Result)에 따라 다른 RunnectException.code를 만들 수 있어 처리 분기가 흔들릴 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapter.kt`
around lines 62 - 73, parseErrorResponse currently uses only the HTTP status to
set RunnectException.code, which diverges from ResultCall's behavior that
prefers the payload "status"; update parseErrorResponse to parse the errorBody
JSON for a numeric "status" field and use that value as the RunnectException
code when present and valid, falling back to response.code() otherwise, and
still extract "message"/"error" for the exception message; touch the
parseErrorResponse function and RunnectException creation logic so FlowCall and
ResultCall produce consistent exception codes.
app/src/main/java/com/runnect/runnect/data/network/calladapter/ResultCall.kt (1)

50-63: FlowCallAdapter와 에러 파싱 정책을 공통화하는 것을 권장합니다.

현재 ResultCall은 payload status를 사용하고, FlowCallAdapter는 HTTP status를 사용해 동일 API라도 예외 코드가 달라질 수 있습니다. 공통 유틸로 합치면 정책 드리프트를 막을 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/runnect/runnect/data/network/calladapter/ResultCall.kt`
around lines 50 - 63, The parseErrorResponse implementation in ResultCall
(function parseErrorResponse) uses the payload "status" while FlowCallAdapter
uses the HTTP status, causing policy drift; extract this parsing logic into a
shared utility (e.g., an ErrorParser or ErrorResponseUtil with a function like
parseError(response: Response<*>) : RunnectException) and update both
ResultCall.parseErrorResponse and FlowCallAdapter to call that single utility so
they share the same precedence (decide and document whether payload.status
overrides HTTP status or vice versa) and return a consistent RunnectException;
ensure the util handles null/invalid bodies the same way as the current
getOrElse fallback (use response.code() and ERROR_MSG_COMMON).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapter.kt`:
- Around line 67-69: The code in FlowCallAdapter.kt that sets val message from
jsonObject["message"] or jsonObject["error"] can yield "null" or empty strings;
normalize by extracting the raw content, trimming it, and only using it if
non-blank and not the literal "null", otherwise fall back to the other field or
ERROR_MSG_COMMON — update the selection logic around
jsonObject["message"]?.jsonPrimitive?.content and
jsonObject["error"]?.jsonPrimitive?.content to apply .trim(), a check for
isNotBlank() and != "null" before accepting the value so that MESSAGE uses a
sanitized string or ERROR_MSG_COMMON as fallback.

In
`@app/src/main/java/com/runnect/runnect/data/network/calladapter/ResultCall.kt`:
- Around line 54-59: The current parsing of errorString via
json.parseToJsonElement and subsequent extraction of status/message is brittle:
any parsing error or a single malformed field causes the whole getOrElse to
swallow server details; also values like "null" or empty strings can be returned
as real messages. Update the block around
json.parseToJsonElement(errorString.orEmpty())/jsonObject and the status/message
extraction so you first try-catch JSON parsing, verify the element is a
JsonObject, then individually extract status by checking the presence of
"status" and using jsonPrimitive.intOrNull or toIntOrNull with a fallback to
response.code(), and extract message by checking "message" then "error",
ensuring the jsonPrimitive is a non-empty, non-"null" string before using it; if
parsing fails or values are invalid, fall back to ERROR_MSG_COMMON, and
construct RunnectException(code = status, message = message) with those robustly
derived values.

In
`@app/src/main/java/com/runnect/runnect/data/network/interceptor/ResponseInterceptor.kt`:
- Around line 45-46: The current isBaseResponse check in ResponseInterceptor.kt
incorrectly requires the "data" key, which skips unwrapping when a valid
BaseResponse has a null/omitted data field; update isBaseResponse to detect
BaseResponse by its other identifiers (e.g., status/code/message fields) and not
require "data", and in the unwrapping logic (where jsonObject["data"].toString()
is used) safely read the data with a nullable-safe/optional access (e.g.,
opt/getNullable) so if "data" is absent you treat it as null/JsonNull (returning
a null payload) rather than skipping unwrapping—ensure references:
isBaseResponse(...) and the jsonObject["data"] unwrap point are adjusted
accordingly.

---

Nitpick comments:
In
`@app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapter.kt`:
- Around line 62-73: parseErrorResponse currently uses only the HTTP status to
set RunnectException.code, which diverges from ResultCall's behavior that
prefers the payload "status"; update parseErrorResponse to parse the errorBody
JSON for a numeric "status" field and use that value as the RunnectException
code when present and valid, falling back to response.code() otherwise, and
still extract "message"/"error" for the exception message; touch the
parseErrorResponse function and RunnectException creation logic so FlowCall and
ResultCall produce consistent exception codes.

In
`@app/src/main/java/com/runnect/runnect/data/network/calladapter/ResultCall.kt`:
- Around line 50-63: The parseErrorResponse implementation in ResultCall
(function parseErrorResponse) uses the payload "status" while FlowCallAdapter
uses the HTTP status, causing policy drift; extract this parsing logic into a
shared utility (e.g., an ErrorParser or ErrorResponseUtil with a function like
parseError(response: Response<*>) : RunnectException) and update both
ResultCall.parseErrorResponse and FlowCallAdapter to call that single utility so
they share the same precedence (decide and document whether payload.status
overrides HTTP status or vice versa) and return a consistent RunnectException;
ensure the util handles null/invalid bodies the same way as the current
getOrElse fallback (use response.code() and ERROR_MSG_COMMON).

In
`@app/src/main/java/com/runnect/runnect/data/network/interceptor/ResponseInterceptor.kt`:
- Around line 47-49: The catch block in ResponseInterceptor (inside the
intercept method of class ResponseInterceptor) currently swallows exceptions by
returning null; instead log the exception type and message at debug level before
returning null to preserve minimal diagnostic visibility (do not log the full
response body). Update the catch (e: Exception) branch to call your logger/debug
utility (e.g., using the interceptor's logger or Android Log/Timbre) to record
e::class.simpleName and e.message, then return null as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0d716246-d522-4c0a-993e-1df3b37a371e

📥 Commits

Reviewing files that changed from the base of the PR and between 7417e63 and 3ad12ff.

📒 Files selected for processing (3)
  • app/src/main/java/com/runnect/runnect/data/network/calladapter/ResultCall.kt
  • app/src/main/java/com/runnect/runnect/data/network/calladapter/flow/FlowCallAdapter.kt
  • app/src/main/java/com/runnect/runnect/data/network/interceptor/ResponseInterceptor.kt

Comment on lines +54 to +59
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)
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

에러 필드 추출을 조금 더 견고하게 처리해주세요.

Line 55-58에서 status/message 파싱 중 하나만 어긋나도 전체가 getOrElse로 빠져 실제 서버 메시지를 잃을 수 있습니다. "null"/빈 문자열도 그대로 메시지로 노출될 수 있습니다.

🔧 Proposed fix
-            val status = jsonObject["status"]?.jsonPrimitive?.int ?: response.code()
-            val message = jsonObject["message"]?.jsonPrimitive?.content
-                ?: jsonObject["error"]?.jsonPrimitive?.content
-                ?: ERROR_MSG_COMMON
+            val status = jsonObject["status"]?.jsonPrimitive?.content
+                ?.toIntOrNull()
+                ?: response.code()
+            val message = sequenceOf(
+                jsonObject["message"]?.jsonPrimitive?.content,
+                jsonObject["error"]?.jsonPrimitive?.content
+            ).firstOrNull { !it.isNullOrBlank() && it != "null" }
+                ?: ERROR_MSG_COMMON
             RunnectException(code = status, message = message)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
val status = jsonObject["status"]?.jsonPrimitive?.content
?.toIntOrNull()
?: response.code()
val message = sequenceOf(
jsonObject["message"]?.jsonPrimitive?.content,
jsonObject["error"]?.jsonPrimitive?.content
).firstOrNull { !it.isNullOrBlank() && it != "null" }
?: ERROR_MSG_COMMON
RunnectException(code = status, message = message)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/java/com/runnect/runnect/data/network/calladapter/ResultCall.kt`
around lines 54 - 59, The current parsing of errorString via
json.parseToJsonElement and subsequent extraction of status/message is brittle:
any parsing error or a single malformed field causes the whole getOrElse to
swallow server details; also values like "null" or empty strings can be returned
as real messages. Update the block around
json.parseToJsonElement(errorString.orEmpty())/jsonObject and the status/message
extraction so you first try-catch JSON parsing, verify the element is a
JsonObject, then individually extract status by checking the presence of
"status" and using jsonPrimitive.intOrNull or toIntOrNull with a fallback to
response.code(), and extract message by checking "message" then "error",
ensuring the jsonPrimitive is a non-empty, non-"null" string before using it; if
parsing fails or values are invalid, fall back to ERROR_MSG_COMMON, and
construct RunnectException(code = status, message = message) with those robustly
derived values.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 코드 유지

1번 코멘트와 동일한 사유입니다. 기존 Gson 코드의 에러 파싱 동작을 그대로 유지하는 것이 이 PR의 목적이며, 정규화 로직 추가는 범위 밖입니다.

status 파싱에 대해: jsonPrimitive.int는 JSON 값이 숫자가 아닐 때 예외를 던지고, runCatching이 이를 잡아서 response.code()로 폴백합니다. 기존 Gson 코드에서 errorBody.status가 실패할 때 getOrElse로 빠지는 것과 동일한 동작입니다.

@unam98
Copy link
Copy Markdown
Collaborator Author

unam98 commented Apr 4, 2026

bugfix/replace-gson-with-kotlinx 브랜치로 대체 (kotlin.Result 규칙 포함)

@unam98 unam98 closed this Apr 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant