diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 6d2e7083..3567c328 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -40,6 +40,8 @@ android { } dependencies { + implementation(projects.core.result) + implementation(libs.bundles.ktor) implementation(libs.ktorfit.lib) ksp(libs.ktorfit.ksp) diff --git a/core/network/src/main/java/com/twix/network/execute/ApiCall.kt b/core/network/src/main/java/com/twix/network/execute/ApiCall.kt new file mode 100644 index 00000000..9b26e42a --- /dev/null +++ b/core/network/src/main/java/com/twix/network/execute/ApiCall.kt @@ -0,0 +1,50 @@ +package com.twix.network.execute + +import com.twix.network.model.error.ErrorResponse +import com.twix.result.AppError +import com.twix.result.AppResult +import io.ktor.client.plugins.ResponseException +import io.ktor.client.statement.bodyAsText +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 safeApiCall(crossinline call: suspend () -> T): AppResult = + 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(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)) + } catch (e: SerializationException) { + AppResult.Error(AppError.Serialization(e)) + } catch (e: Throwable) { + AppResult.Error(AppError.Unknown(e)) + } diff --git a/core/network/src/main/java/com/twix/network/model/error/ErrorResponse.kt b/core/network/src/main/java/com/twix/network/model/error/ErrorResponse.kt new file mode 100644 index 00000000..9113cd3f --- /dev/null +++ b/core/network/src/main/java/com/twix/network/model/error/ErrorResponse.kt @@ -0,0 +1,10 @@ +package com.twix.network.model.error + +import kotlinx.serialization.Serializable + +@Serializable +data class ErrorResponse( + val status: Int? = null, + val code: String? = null, + val message: String? = null, +) diff --git a/core/result/.gitignore b/core/result/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/result/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/result/build.gradle.kts b/core/result/build.gradle.kts new file mode 100644 index 00000000..2711e586 --- /dev/null +++ b/core/result/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + alias(libs.plugins.twix.android.library) +} + +android { + namespace = "com.twix.result" +} diff --git a/core/result/consumer-rules.pro b/core/result/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/core/result/proguard-rules.pro b/core/result/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/result/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/result/src/main/AndroidManifest.xml b/core/result/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/core/result/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/result/src/main/java/com/twix/result/AppError.kt b/core/result/src/main/java/com/twix/result/AppError.kt new file mode 100644 index 00000000..fd0ad732 --- /dev/null +++ b/core/result/src/main/java/com/twix/result/AppError.kt @@ -0,0 +1,33 @@ +package com.twix.result + +import java.io.IOException + +sealed interface AppError { + /** HTTP 4xx/5xx */ + data class Http( + val status: Int, // HTTP status (e.code()) + val code: String? = null, // 서버에서 반환하는 커스텀 코드 ex) G5000 + val message: String? = null, // 서버에서 반환하는 message + val rawBody: String? = null, // Http 에러 원문 + ) : AppError + + /** 네트워크 끊김/불가 */ + data class Network( + val cause: IOException? = null, + ) : AppError + + /** 타임아웃 */ + data class Timeout( + val cause: Throwable? = null, + ) : AppError + + /** 파싱/직렬화 */ + data class Serialization( + val cause: Throwable? = null, + ) : AppError + + /** 그 외 */ + data class Unknown( + val cause: Throwable, + ) : AppError +} diff --git a/core/result/src/main/java/com/twix/result/AppResult.kt b/core/result/src/main/java/com/twix/result/AppResult.kt new file mode 100644 index 00000000..5f90fe26 --- /dev/null +++ b/core/result/src/main/java/com/twix/result/AppResult.kt @@ -0,0 +1,11 @@ +package com.twix.result + +sealed interface AppResult { + data class Success( + val data: T, + ) : AppResult + + data class Error( + val error: AppError, + ) : AppResult +} diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index d5d0d5f3..b89048f7 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -11,4 +11,5 @@ android { dependencies { implementation(projects.domain) + implementation(projects.core.result) } diff --git a/core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt b/core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt index 3bc9fd5f..f240411a 100644 --- a/core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt +++ b/core/ui/src/main/java/com/twix/ui/base/BaseViewModel.kt @@ -3,6 +3,8 @@ package com.twix.ui.base import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger +import com.twix.result.AppError +import com.twix.result.AppResult import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -79,33 +81,52 @@ abstract class BaseViewModel( onStart: (() -> Unit)? = null, // 비동기 시작 전 처리해야 할 로직 ex) 로딩 onFinally: (() -> Unit)? = null, // 비동기 종료 후 리소스 정리 onSuccess: (D) -> Unit, // 비동기 메서드 호출이 성공했을 때 처리해야 할 로직 - onError: ((Throwable) -> Unit)? = null, // 비동기 메서드 호출에 실패했을 때 처리해야 할 로직 - block: suspend () -> Result, // 비동기 메서드 ex) 서버 통신 메서드 + onError: ((AppError) -> Unit)? = null, // 비동기 메서드 호출에 실패했을 때 처리해야 할 로직 + block: suspend () -> AppResult, // 비동기 메서드 ex) 서버 통신 메서드 ): Job = viewModelScope.launch { try { onStart?.invoke() - val result = block.invoke() - result.fold( - onSuccess = { data -> onSuccess(data) }, - onFailure = { t -> - if (t is CancellationException) throw t - - handleError(t) - onError?.invoke(t) - }, - ) + when (val result = block()) { + is AppResult.Success -> onSuccess(result.data) + is AppResult.Error -> { + // 공통 처리: 로깅 + handleError(result.error) + // 메서드별 처리: 특정 화면만의 UX ex) 다이얼로그/토스트 + onError?.invoke(result.error) + } + } + } catch (e: CancellationException) { + // 코루틴 취소는 에러로 취급하지 않기 + throw e } finally { onFinally?.invoke() } } /** - * 에러 핸들링 메서드 + * Throwable용 핸들러 ex) Intent 처리 중 발생한 예외 * */ protected open fun handleError(t: Throwable) { - logger.e { "에러 발생: ${t.stackTraceToString()}" } + // TODO: 크래시 리포트 + logger.e(t) { "Unhandled error while handling intent" } + } + + /** + * AppError용 핸들러 ex) 서버통신에서 발생한 에러 + * */ + protected open fun handleError(error: AppError) { + when (error) { + is AppError.Http -> + logger.e { + "HTTP error: status=${error.status}, code=${error.code}, message=${error.message}" + } + is AppError.Network -> logger.e(error.cause) { "Network error" } + is AppError.Timeout -> logger.e(error.cause) { "Timeout error" } + is AppError.Serialization -> logger.e(error.cause) { "Serialization error" } + is AppError.Unknown -> logger.e(error.cause) { "Unknown error" } + } } // 리소스 정리 diff --git a/settings.gradle.kts b/settings.gradle.kts index 9ba52aea..05195c9d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,3 +37,4 @@ include(":core:network") include(":core:analytics") include(":feature:main") include(":feature:task-certification") +include(":core:result")