From debe5151f267a1269f3c5fb1e6c4edd2fc2cc1ab Mon Sep 17 00:00:00 2001 From: dogmania Date: Wed, 4 Feb 2026 18:25:34 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=E2=9C=A8=20Feat:=20:core:result=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/result/.gitignore | 1 + core/result/build.gradle.kts | 7 ++++++ core/result/consumer-rules.pro | 0 core/result/proguard-rules.pro | 21 ++++++++++++++++ .../twix/result/ExampleInstrumentedTest.kt | 24 +++++++++++++++++++ core/result/src/main/AndroidManifest.xml | 4 ++++ .../java/com/twix/result/ExampleUnitTest.kt | 17 +++++++++++++ settings.gradle.kts | 1 + 8 files changed, 75 insertions(+) create mode 100644 core/result/.gitignore create mode 100644 core/result/build.gradle.kts create mode 100644 core/result/consumer-rules.pro create mode 100644 core/result/proguard-rules.pro create mode 100644 core/result/src/androidTest/java/com/twix/result/ExampleInstrumentedTest.kt create mode 100644 core/result/src/main/AndroidManifest.xml create mode 100644 core/result/src/test/java/com/twix/result/ExampleUnitTest.kt 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/androidTest/java/com/twix/result/ExampleInstrumentedTest.kt b/core/result/src/androidTest/java/com/twix/result/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..6ab04911 --- /dev/null +++ b/core/result/src/androidTest/java/com/twix/result/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.twix.result + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.twix.result.test", appContext.packageName) + } +} \ 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/test/java/com/twix/result/ExampleUnitTest.kt b/core/result/src/test/java/com/twix/result/ExampleUnitTest.kt new file mode 100644 index 00000000..7f26179a --- /dev/null +++ b/core/result/src/test/java/com/twix/result/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.twix.result + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file 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") From 2d1af55559bbe4fadf89df86b7a4710eee6d2f37 Mon Sep 17 00:00:00 2001 From: dogmania Date: Wed, 4 Feb 2026 19:36:23 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=94=A5=20Remove:=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../twix/result/ExampleInstrumentedTest.kt | 24 ------------------- .../java/com/twix/result/ExampleUnitTest.kt | 17 ------------- 2 files changed, 41 deletions(-) delete mode 100644 core/result/src/androidTest/java/com/twix/result/ExampleInstrumentedTest.kt delete mode 100644 core/result/src/test/java/com/twix/result/ExampleUnitTest.kt diff --git a/core/result/src/androidTest/java/com/twix/result/ExampleInstrumentedTest.kt b/core/result/src/androidTest/java/com/twix/result/ExampleInstrumentedTest.kt deleted file mode 100644 index 6ab04911..00000000 --- a/core/result/src/androidTest/java/com/twix/result/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.twix.result - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.twix.result.test", appContext.packageName) - } -} \ No newline at end of file diff --git a/core/result/src/test/java/com/twix/result/ExampleUnitTest.kt b/core/result/src/test/java/com/twix/result/ExampleUnitTest.kt deleted file mode 100644 index 7f26179a..00000000 --- a/core/result/src/test/java/com/twix/result/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.twix.result - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file From 99fbb09202fe736935b147a4f9589e212675fb20 Mon Sep 17 00:00:00 2001 From: dogmania Date: Wed, 4 Feb 2026 19:36:49 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=E2=9C=A8=20Feat:=20:core:result=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/network/build.gradle.kts | 2 ++ core/ui/build.gradle.kts | 1 + 2 files changed, 3 insertions(+) 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/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) } From f74c70d84506aa53ec19e3cccd7ff0cf804915eb Mon Sep 17 00:00:00 2001 From: dogmania Date: Wed, 4 Feb 2026 19:38:03 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20AppError?= =?UTF-8?q?=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EB=A5=BC=20=ED=95=B8=EB=93=A4=EB=A7=81=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/twix/ui/base/BaseViewModel.kt | 49 +++++++++++++------ 1 file changed, 35 insertions(+), 14 deletions(-) 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" } + } } // 리소스 정리 From 05e2f3f6f3d78cdf2d453d7356c57a4beeee6b93 Mon Sep 17 00:00:00 2001 From: dogmania Date: Wed, 4 Feb 2026 19:38:40 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=A8=20Feat:=20SafeApiCall=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/twix/network/execute/ApiCall.kt | 50 +++++++++++++++++++ .../twix/network/model/error/ErrorResponse.kt | 10 ++++ 2 files changed, 60 insertions(+) create mode 100644 core/network/src/main/java/com/twix/network/execute/ApiCall.kt create mode 100644 core/network/src/main/java/com/twix/network/model/error/ErrorResponse.kt 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, +) From 7875cb2d2fbca8657711743989db02d9dcc74d40 Mon Sep 17 00:00:00 2001 From: dogmania Date: Wed, 4 Feb 2026 19:38:58 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=E2=9C=A8=20Feat:=20AppError=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/twix/result/AppError.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 core/result/src/main/java/com/twix/result/AppError.kt 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 +} From 5ce64a991fda7a93b1e195e2a1e90c61a31f2cde Mon Sep 17 00:00:00 2001 From: dogmania Date: Wed, 4 Feb 2026 19:39:08 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=E2=9C=A8=20Feat:=20AppResult=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../result/src/main/java/com/twix/result/AppResult.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 core/result/src/main/java/com/twix/result/AppResult.kt 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 +}