Skip to content

서버 통신 로직 및 예외 처리 구현#56

Merged
chanho0908 merged 7 commits intodevelopfrom
feat/#55-safe-api-call
Feb 5, 2026
Merged

서버 통신 로직 및 예외 처리 구현#56
chanho0908 merged 7 commits intodevelopfrom
feat/#55-safe-api-call

Conversation

@dogmania
Copy link
Member

@dogmania dogmania commented Feb 4, 2026

이슈 번호

작업내용

  • 서버 통신에 활용되는 safeApiCall 메서드를 구현했습니다.
  • AppError, AppResult 기반 에러 핸들링 로직을 구현했습니다.

리뷰어에게 추가로 요구하는 사항 (선택)

뷰모델에서 Repository 메서드 호출할 때 BaseViewModel launchResult 호출하시면 됩니다!
그리고 RepositoryImpl에서 로직 구현할 때도 safeApiCall 기반으로 통신해주세요

// Repository
override suspend fun getGoal(id: Long): AppResult<Goal> =
    safeApiCall { api.getGoal(id).toDomain() }

// ViewModel
launchAppResult(
    onStart = { reduce { copy(isLoading = true) } },
    onFinally = { reduce { copy(isLoading = false) } },
    onSuccess = { goal ->
        setGoal(goal)
    },
    onError = { error ->
        emitSideEffect(GoalEditorSideEffect.ShowToast("메시지", ToastType.ERROR))
    },
    block = { goalRepository.getGoal(goalId) },
)

@dogmania dogmania requested a review from chanho0908 February 4, 2026 10:46
@dogmania dogmania self-assigned this Feb 4, 2026
@coderabbitai
Copy link

coderabbitai bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

새로운 core/result 모듈을 신규 생성하여 AppErrorAppResult 타입을 정의했습니다. core/network에는 safeApiCall 함수를 추가하여 API 호출 시 발생하는 다양한 예외(CancellationException, ResponseException, SocketTimeoutException, IOException, SerializationException)를 AppError 변형으로 매핑합니다. ErrorResponse 데이터 클래스를 도입하여 서버 에러 응답을 구조화했으며, BaseViewModel의 launchResult 메서드를 수정하여 기존 Result 타입 대신 AppResult를 사용하도록 변경했습니다. 빌드 설정에서 모듈 간 의존성을 추가하여 새 아키텍처를 통합했습니다.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes


상세 리뷰 분석

긍정적 측면 ✨

1. 잘 구조화된 에러 처리 아키텍처

  • AppError를 sealed interface로 정의하여 타입 안전한 에러 처리를 구현했습니다. Http, Network, Timeout, Serialization, Unknown으로 구분함으로써 각 에러 유형을 명확히 합니다.
  • safeApiCall에서 예외 유형별로 적절한 AppError 변형으로 매핑하는 방식이 체계적입니다.

2. BaseViewModel의 일관된 리팩토링

  • Result 타입에서 AppResult로 변경하면서 onError 콜백의 파라미터도 Throwable에서 AppError로 변경한 점이 좋습니다.
  • 새로운 handleError(error: AppError) 메서드를 추가하여 에러 처리의 단일 책임을 명확히 했습니다.

3. 모듈 구조의 명확한 계층화

  • 결과 타입을 별도 모듈로 분리하여 다른 모듈들이 이를 의존할 수 있게 구성했습니다.

개선 고려사항 🤔

1. ResponseException 파싱 안전성 검토

try {
    val errorBody = response.body?.string()
    val parsed = errorJson.decodeFromString<ErrorResponse>(errorBody.orEmpty())
    // 사용
} catch (e: Exception) {
    // 폴백 처리
}

질문: ResponseException에서 response body 파싱 시 실패한다면 어떻게 처리되나요? 만약 에러 응답이 JSON이 아닌 경우나 변신(serialization) 실패 시, 원본 body 정보가 손실될 수 있습니다. 다음과 같은 개선은 어떨까요?

AppError.Http(
    status = status,
    code = parsed?.code,
    message = parsed?.message,
    rawBody = errorBody  // 파싱 실패해도 원본 유지
)

2. errorJson 설정의 문서화

  • ignoreUnknownKeys = trueisLenient = true 설정 이유가 코드 주석으로 명시되면 유지보수가 용이합니다.
  • 서버에서 새로운 필드를 추가할 때도 클라이언트가 안정적으로 작동하도록 의도한 것으로 보이는데, 이를 명확히 하면 좋습니다.

3. BaseViewModel의 cancellation 처리

catch (e: CancellationException) {
    throw e  // 재발생
}

질문: 현재 구현에서 CancellationException을 재발생시키는데, 이것이 상위 coroutine scope로 전파되나요? 아니면 finally 블록이 실행되고 Job은 정상 종료되나요? 의도한 동작이 명확하도록 주석을 추가하면 나중에 혼동을 방지할 수 있습니다.

4. Unknown 에러의 필수 Throwable

data class Unknown(val cause: Throwable) : AppError

다른 AppError 변형은 optional cause를 가지는데 Unknown만 필수입니다. 이것이 의도된 설계인지, 아니면 일관성을 위해 optional로 변경해야 하는지 확인이 필요합니다.

5. ErrorResponse의 확장성

  • 현재 ErrorResponse는 status, code, message를 정의했는데, 실제 API 응답에 다른 필드들이 있을 수 있습니다.
  • 향후 새로운 필드 추가 시 AppError.Http와의 매핑 전략을 미리 정의하면 좋습니다.

확인 사항 ✓

  • core/result 모듈의 외부 의존성은 코틀린 표준 라이브러리뿐인가요?
  • Http 에러의 rawBody 최대 크기에 제약이 있을까요? (매우 큰 응답의 경우 메모리 고려)
  • 기존 코드에서 Result 타입을 사용 중인 다른 모듈들의 마이그레이션 계획은 있나요?

전반적으로 새로운 에러 처리 아키텍처가 타입 안전하고 체계적으로 설계되었다는 점이 매우 좋습니다. 위의 질문들은 디테일을 다듬기 위한 제안이므로, 개발 맥락에 따라 조정하시면 될 것 같습니다. 🚀

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 변경사항의 주요 목표를 명확하게 설명하고 있습니다. 서버 통신 로직과 예외 처리 구현이라는 핵심 변경사항을 잘 반영하고 있습니다.
Description check ✅ Passed PR 설명이 변경사항과 충분히 관련되어 있습니다. 이슈 번호, 구현된 작업 내용, 사용 가이드라인이 명확하게 제시되어 있습니다.
Linked Issues check ✅ Passed PR이 연결된 이슈 #55의 모든 요구사항을 충족합니다. BaseViewModel 수정, AppError 구현, safeApiCall 메서드 구현이 모두 완료되어 있습니다.
Out of Scope Changes check ✅ Passed 모든 변경사항이 이슈 #55의 범위 내에 있습니다. 의존성 추가, 모듈 생성, 에러 처리 구현이 모두 서버 통신 로직 구현과 직접 관련된 필수 변경사항입니다.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/#55-safe-api-call

Important

Action Needed: IP Allowlist Update

If your organization protects your Git platform with IP whitelisting, please add the new CodeRabbit IP address to your allowlist:

  • 136.113.208.247/32 (new)
  • 34.170.211.100/32
  • 35.222.179.152/32

Reviews will stop working after February 8, 2026 if the new IP is not added to your allowlist.


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

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt (1)

80-105: ⚠️ Potential issue | 🟠 Major

launchResult에서 예외 처리 불일치 - 일관된 에러 핸들링이 필요합니다

현재 launchResult 메서드는 CancellationException만 처리하고 다른 예외는 전파되고 있어요. 이는 같은 클래스의 init 블록(36-45줄)에서 적용한 예외 처리 패턴과 다릅니다.

문제점:

  • block() 실행 중 예외 발생 시 handleErroronError 콜백이 호출되지 않음
  • 예외가 viewModelScope로 전파되어 코루틴 스코프가 취소될 수 있음
  • 호출 측에서 onError 콜백을 통한 에러 처리를 기대하지만 예기치 않게 동작하지 않을 수 있음

이미 AppError.Unknown(cause: Throwable)handleError(error: AppError) 오버로드가 존재하므로, init 블록의 패턴(try-catch-all + handleError 호출)을 launchResult에도 동일하게 적용하면 됩니다:

🛡️ 제안 변경
             } catch (e: CancellationException) {
                 // 코루틴 취소는 에러로 취급하지 않기
                 throw e
+            } catch (t: Throwable) {
+                val appError = AppError.Unknown(t)
+                handleError(appError)
+                onError?.invoke(appError)
             } finally {
                 onFinally?.invoke()
             }
🤖 Fix all issues with AI agents
In `@core/network/src/main/java/com/twix/network/execute/ApiCall.kt`:
- Around line 8-45: The catch block in safeApiCall is importing and catching
kotlinx.io.IOException which differs from the java.io.IOException that
AppError.Network expects (see AppError.kt); update ApiCall.kt to import
java.io.IOException instead of kotlinx.io.IOException so the catch (e:
IOException) in safeApiCall correctly captures Ktor/Java IO errors and maps them
to AppError.Network.
- Around line 27-32: The two runCatching blocks that compute raw (calling
e.response.bodyAsText()) and parsed (calling
errorJson.decodeFromString<ErrorResponse>(it)) swallow CancellationException and
prevent coroutine cancellation from propagating; update those runCatching usages
so they rethrow CancellationException (or explicitly check and throw if caught)
instead of converting it to null, ensuring CancellationException is not
swallowed when reading bodyAsText() or decoding JSON in the raw/parsed logic.
🧹 Nitpick comments (1)
core/network/src/main/java/com/twix/network/model/error/ErrorResponse.kt (1)

5-10: 잘 구현된 에러 응답 모델입니다!

모든 필드를 nullable로 설정하여 다양한 서버 에러 응답 형식을 유연하게 처리할 수 있도록 설계되었습니다.

한 가지 고려해볼 점이 있습니다: 만약 서버 API 스펙에서 특정 필드(예: message)가 항상 반환된다고 보장된다면, 해당 필드를 non-nullable로 변경하면 사용하는 측에서 불필요한 null 체크를 줄일 수 있습니다. 현재 서버 스펙이 다양하거나 불확실한 경우라면 현재 방식이 적절합니다.

서버 에러 응답 스펙이 어떻게 정의되어 있나요?

Comment on lines +8 to +45
import kotlinx.io.IOException
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.net.SocketTimeoutException
import kotlin.coroutines.cancellation.CancellationException

val errorJson =
Json {
ignoreUnknownKeys = true
isLenient = true
}

suspend inline fun <T> safeApiCall(crossinline call: suspend () -> T): AppResult<T> =
try {
AppResult.Success(call())
} catch (e: CancellationException) {
throw e
} catch (e: ResponseException) {
val status = e.response.status.value
val raw = runCatching { e.response.bodyAsText() }.getOrNull()

val parsed =
runCatching {
raw?.let { errorJson.decodeFromString<ErrorResponse>(it) }
}.getOrNull()

AppResult.Error(
AppError.Http(
status = status,
code = parsed?.code,
message = parsed?.message,
rawBody = raw,
),
)
} catch (e: SocketTimeoutException) {
AppResult.Error(AppError.Timeout(e))
} catch (e: IOException) {
AppResult.Error(AppError.Network(e))
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find AppError definition
fd -t f -e kt -e java | xargs rg "class AppError|sealed.*AppError|data class Network" -B 2 -A 5 | head -100

Repository: YAPP-Github/Twix-Android

Length of output: 1353


🏁 Script executed:

# Search for AppError definition more specifically
find . -type f \( -name "*.kt" -o -name "*.java" \) -exec rg "AppError" {} + -l | head -20

Repository: YAPP-Github/Twix-Android

Length of output: 96


🏁 Script executed:

# Look for the AppError class definition
rg "class AppError|sealed.*AppError" -t kt -t java -A 10

Repository: YAPP-Github/Twix-Android

Length of output: 94


🏁 Script executed:

cat core/result/src/main/java/com/twix/result/AppError.kt

Repository: YAPP-Github/Twix-Android

Length of output: 827


🏁 Script executed:

# Check if there's any usage of kotlinx.io.IOException in the codebase
rg "kotlinx\.io\.IOException|java\.io\.IOException" --color=never

Repository: YAPP-Github/Twix-Android

Length of output: 241


🏁 Script executed:

# Check the imports and structure of ApiCall.kt
cat core/network/src/main/java/com/twix/network/execute/ApiCall.kt

Repository: YAPP-Github/Twix-Android

Length of output: 1666


kotlinx.io.IOExceptionjava.io.IOException으로 변경 필요

현재 코드의 문제점:

  • AppError.Networkjava.io.IOException을 기대합니다 (AppError.kt에서 java.io.IOException import)
  • 그런데 ApiCall.kt에서는 kotlinx.io.IOException을 catch하고 있습니다
  • 이 둘은 서로 다른 타입이므로, Ktor에서 발생하는 java.io.IOException은 catch 블록에 걸리지 않습니다
  • 결과적으로 네트워크 오류가 catch (e: Throwable) 블록으로 빠져 AppError.Unknown으로 처리됩니다

import를 다음과 같이 수정하면 네트워크 오류가 올바르게 AppError.Network로 처리됩니다:

-import kotlinx.io.IOException
+import java.io.IOException
📝 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
import kotlinx.io.IOException
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.net.SocketTimeoutException
import kotlin.coroutines.cancellation.CancellationException
val errorJson =
Json {
ignoreUnknownKeys = true
isLenient = true
}
suspend inline fun <T> safeApiCall(crossinline call: suspend () -> T): AppResult<T> =
try {
AppResult.Success(call())
} catch (e: CancellationException) {
throw e
} catch (e: ResponseException) {
val status = e.response.status.value
val raw = runCatching { e.response.bodyAsText() }.getOrNull()
val parsed =
runCatching {
raw?.let { errorJson.decodeFromString<ErrorResponse>(it) }
}.getOrNull()
AppResult.Error(
AppError.Http(
status = status,
code = parsed?.code,
message = parsed?.message,
rawBody = raw,
),
)
} catch (e: SocketTimeoutException) {
AppResult.Error(AppError.Timeout(e))
} catch (e: IOException) {
AppResult.Error(AppError.Network(e))
import java.io.IOException
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import java.net.SocketTimeoutException
import kotlin.coroutines.cancellation.CancellationException
val errorJson =
Json {
ignoreUnknownKeys = true
isLenient = true
}
suspend inline fun <T> safeApiCall(crossinline call: suspend () -> T): AppResult<T> =
try {
AppResult.Success(call())
} catch (e: CancellationException) {
throw e
} catch (e: ResponseException) {
val status = e.response.status.value
val raw = runCatching { e.response.bodyAsText() }.getOrNull()
val parsed =
runCatching {
raw?.let { errorJson.decodeFromString<ErrorResponse>(it) }
}.getOrNull()
AppResult.Error(
AppError.Http(
status = status,
code = parsed?.code,
message = parsed?.message,
rawBody = raw,
),
)
} catch (e: SocketTimeoutException) {
AppResult.Error(AppError.Timeout(e))
} catch (e: IOException) {
AppResult.Error(AppError.Network(e))
🤖 Prompt for AI Agents
In `@core/network/src/main/java/com/twix/network/execute/ApiCall.kt` around lines
8 - 45, The catch block in safeApiCall is importing and catching
kotlinx.io.IOException which differs from the java.io.IOException that
AppError.Network expects (see AppError.kt); update ApiCall.kt to import
java.io.IOException instead of kotlinx.io.IOException so the catch (e:
IOException) in safeApiCall correctly captures Ktor/Java IO errors and maps them
to AppError.Network.

Comment on lines +27 to +32
val raw = runCatching { e.response.bodyAsText() }.getOrNull()

val parsed =
runCatching {
raw?.let { errorJson.decodeFromString<ErrorResponse>(it) }
}.getOrNull()
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "ApiCall.kt" | head -20

Repository: YAPP-Github/Twix-Android

Length of output: 132


🏁 Script executed:

git ls-files | grep -E "network|ApiCall" | head -20

Repository: YAPP-Github/Twix-Android

Length of output: 1001


🏁 Script executed:

cat -n core/network/src/main/java/com/twix/network/execute/ApiCall.kt | head -50

Repository: YAPP-Github/Twix-Android

Length of output: 2016


🌐 Web query:

Kotlin runCatching CancellationException coroutine cancellation propagation

💡 Result:

runCatching { ... } catches all Throwable from its block—this includes CancellationException. In coroutines, CancellationException is the mechanism used to stop work and propagate cancellation through suspension points; swallowing it breaks cancellation propagation (the coroutine may keep running until the next cancellable suspension/check, and parent/child cancellation behavior becomes surprising). (kotlinlang.org)

What to do instead

If you use runCatching inside coroutines, rethrow cancellation:

val result = runCatching {
    doWork()
}.onFailure { e ->
    if (e is CancellationException) throw e
}

or equivalently:

val result = try {
    Result.success(doWork())
} catch (e: CancellationException) {
    throw e
} catch (t: Throwable) {
    Result.failure(t)
}

Kotlin’s coroutine docs explicitly warn that catching CancellationException can break cancellation propagation and that you should rethrow it. (kotlinlang.org)

Why it matters (rule of thumb)

  • CancellationException is treated specially by coroutine exception handling (it’s generally ignored by handlers) because it represents normal cancellation, not an error. (kotlinlang.org)
  • Therefore, any “catch-all” (catch (Throwable) / runCatching) in coroutine code should always let CancellationException escape. (medium.com)

Citations:


🏁 Script executed:

rg "runCatching" --type kotlin -A 3 | head -60

Repository: YAPP-Github/Twix-Android

Length of output: 1164


🏁 Script executed:

cat -n core/network/src/main/java/com/twix/network/execute/ApiCall.kt | sed -n '20,42p'

Repository: YAPP-Github/Twix-Android

Length of output: 980


runCatching이 코루틴 취소를 막을 수 있어요

runCatchingCancellationException을 포함한 모든 Throwable을 잡기 때문에, bodyAsText()나 JSON 파싱 중에 취소가 발생해도 예외가 getOrNull()로 인해 null로 변환되어 취소 신호가 전파되지 않습니다. 이는 코루틴 취소 의도를 무효화하고 상위 코루틴까지 취소가 전파되지 않는 문제를 일으킵니다.

Kotlin 공식 문서에서도 코루틴 내에서 runCatching을 사용할 때는 반드시 CancellationException을 재throw할 것을 권장하고 있습니다. 두 runCatching 호출 모두에 다음과 같이 취소 예외 처리를 추가해 주세요.

🔧 제안 변경
-        val raw = runCatching { e.response.bodyAsText() }.getOrNull()
+        val raw =
+            runCatching { e.response.bodyAsText() }
+                .onFailure { if (it is CancellationException) throw it }
+                .getOrNull()
 ...
-        val parsed =
-            runCatching {
-                raw?.let { errorJson.decodeFromString<ErrorResponse>(it) }
-            }.getOrNull()
+        val parsed =
+            runCatching {
+                raw?.let { errorJson.decodeFromString<ErrorResponse>(it) }
+            }
+                .onFailure { if (it is CancellationException) throw it }
+                .getOrNull()
📝 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 raw = runCatching { e.response.bodyAsText() }.getOrNull()
val parsed =
runCatching {
raw?.let { errorJson.decodeFromString<ErrorResponse>(it) }
}.getOrNull()
val raw =
runCatching { e.response.bodyAsText() }
.onFailure { if (it is CancellationException) throw it }
.getOrNull()
val parsed =
runCatching {
raw?.let { errorJson.decodeFromString<ErrorResponse>(it) }
}
.onFailure { if (it is CancellationException) throw it }
.getOrNull()
🤖 Prompt for AI Agents
In `@core/network/src/main/java/com/twix/network/execute/ApiCall.kt` around lines
27 - 32, The two runCatching blocks that compute raw (calling
e.response.bodyAsText()) and parsed (calling
errorJson.decodeFromString<ErrorResponse>(it)) swallow CancellationException and
prevent coroutine cancellation from propagating; update those runCatching usages
so they rethrow CancellationException (or explicitly check and throw if caught)
instead of converting it to null, ensuring CancellationException is not
swallowed when reading bodyAsText() or decoding JSON in the raw/parsed logic.

@chanho0908 chanho0908 merged commit 5b518ac into develop Feb 5, 2026
8 checks passed
Copy link
Member

@chanho0908 chanho0908 left a comment

Choose a reason for hiding this comment

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

확인!! 고생했어 현수야 :)

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.

서버 통신 로직 및 예외 처리 구현

2 participants