From d19ddc42d67b7376d79d4934d92fe0cb558feafd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 21:32:54 +0900 Subject: [PATCH] =?UTF-8?q?refactor(afternote):=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EC=98=88=EC=99=B8=203=EC=A2=85=EC=9D=84?= =?UTF-8?q?=20AfternoteFailure=20sealed=20=EB=A3=A8=ED=8A=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=95=95=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AfternoteAuthoringValidationException 과 UseCase 파일에 얹혀 있던 MemorialVideoSaveException·MemorialPhotoSaveException 을 AfternoteFailure sealed 루트 아래 AuthoringValidation·MediaSave 둘로 줄인다. 매체별로 예외 타입을 나눌 이유가 없었다 — 화면은 둘을 갈라 보지 않고 handleSaveFailure 의 generic 폴백으로 같이 처리하며, 갈라 보던 코드도 없다. 매체는 MediaKind 필드로 구분한다. MediaSave 에 data class 를 쓰지 않은 건 생성된 equals/hashCode 가 선언 프로퍼티만 덮어 cause 를 무시하기 때문이다. 원본 예외를 cause 로 나르는 것이 이 타입의 계약이고 ResolveMemorialMediaForSaveUseCaseTest 가 그것을 단언한다. message 는 진단 문구(영문)로 통일했다. 기존 한국어 문구("영상 업로드에 실패했습니다.")는 handleSaveFailure 가 검증 실패가 아닌 실패를 전부 generic 리소스로 폴백시켜 화면에 닿지 않는 값이었다 — Crashlytics 에만 남으므로 나머지 도메인 예외와 정책을 맞췄다. 소비처는 루트로 좁혀 when 을 exhaustive 하게 만들었다. handleSaveFailure 가 검증 실패를 가려낼 때 MediaSave 가 "검증 실패 아님(null)" 으로 명시되고, 실패 유형이 늘면 그 자리가 컴파일 에러로 잡힌다. 기존 else -> null 은 그 구분을 삼켰다. --- .../author/AfternoteAuthoringFailureMapper.kt | 8 ++-- .../AfternoteAuthoringFailureMapperTest.kt | 10 ++--- .../AfternoteAuthoringValidationException.kt | 14 ------- .../domain/error/AfternoteFailure.kt | 40 +++++++++++++++++++ .../repository/author/AfternoteRepository.kt | 2 +- .../ResolveMemorialMediaForSaveUseCase.kt | 15 ++----- .../ResolveMemorialMediaForSaveUseCaseTest.kt | 17 +++++--- .../author/editor/AfternoteEditorViewModel.kt | 32 +++++++++++---- .../author/editor/state/AfternoteSaveState.kt | 2 +- 9 files changed, 91 insertions(+), 49 deletions(-) delete mode 100644 feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/AfternoteAuthoringValidationException.kt create mode 100644 feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/AfternoteFailure.kt diff --git a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/AfternoteAuthoringFailureMapper.kt b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/AfternoteAuthoringFailureMapper.kt index 1023224d15..992053c82a 100644 --- a/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/AfternoteAuthoringFailureMapper.kt +++ b/feature/afternote/data/src/main/kotlin/com/afternote/feature/afternote/data/repositoryimpl/author/AfternoteAuthoringFailureMapper.kt @@ -1,8 +1,8 @@ package com.afternote.feature.afternote.data.repositoryimpl.author import com.afternote.core.network.model.ApiException -import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationException import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationKind +import com.afternote.feature.afternote.domain.error.AfternoteFailure import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import retrofit2.HttpException @@ -21,10 +21,10 @@ private data class ApiErrorBodyDto( * 저장 API 실패 Throwable을 도메인 검증 예외로 치환할 수 있으면 치환한다. */ internal fun mapAuthoringFailure(throwable: Throwable): Throwable { - if (throwable is AfternoteAuthoringValidationException) return throwable + if (throwable is AfternoteFailure.AuthoringValidation) return throwable if (throwable is ApiException && throwable.code == RECEIVERS_REQUIRED_SERVER_CODE) { - return AfternoteAuthoringValidationException(AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED) + return AfternoteFailure.AuthoringValidation(AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED) } if (throwable is HttpException && throwable.code() == HTTP_BAD_REQUEST) { @@ -34,7 +34,7 @@ internal fun mapAuthoringFailure(throwable: Throwable): Throwable { apiErrorJson.decodeFromString(body) }.getOrNull() if (parsed?.code == RECEIVERS_REQUIRED_SERVER_CODE) { - return AfternoteAuthoringValidationException(AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED) + return AfternoteFailure.AuthoringValidation(AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED) } } diff --git a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/repositoryimpl/author/AfternoteAuthoringFailureMapperTest.kt b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/repositoryimpl/author/AfternoteAuthoringFailureMapperTest.kt index a57ddcc793..e50066f23c 100644 --- a/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/repositoryimpl/author/AfternoteAuthoringFailureMapperTest.kt +++ b/feature/afternote/data/src/test/java/com/afternote/feature/afternote/data/repositoryimpl/author/AfternoteAuthoringFailureMapperTest.kt @@ -1,8 +1,8 @@ package com.afternote.feature.afternote.data.repositoryimpl.author import com.afternote.core.network.model.ApiException -import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationException import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationKind +import com.afternote.feature.afternote.domain.error.AfternoteFailure import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Assert.assertEquals @@ -21,17 +21,17 @@ import retrofit2.Response class AfternoteAuthoringFailureMapperTest { @Test fun `이미 검증 예외면 그대로 반환`() { - val original = AfternoteAuthoringValidationException(AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED) + val original = AfternoteFailure.AuthoringValidation(AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED) assertSame(original, mapAuthoringFailure(original)) } @Test fun `ApiException 475면 RECEIVERS_REQUIRED 검증 예외로 치환`() { val result = mapAuthoringFailure(ApiException(status = 400, code = 475, serverMessage = null, message = "x")) - assertTrue(result is AfternoteAuthoringValidationException) + assertTrue(result is AfternoteFailure.AuthoringValidation) assertEquals( AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED, - (result as AfternoteAuthoringValidationException).kind, + (result as AfternoteFailure.AuthoringValidation).kind, ) } @@ -50,7 +50,7 @@ class AfternoteAuthoringFailureMapperTest { @Test fun `HttpException 400 + 바디 code 475면 검증 예외로 치환`() { val result = mapAuthoringFailure(httpException(code = 400, body = """{"code":475}""")) - assertTrue(result is AfternoteAuthoringValidationException) + assertTrue(result is AfternoteFailure.AuthoringValidation) } @Test diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/AfternoteAuthoringValidationException.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/AfternoteAuthoringValidationException.kt deleted file mode 100644 index e9ebc8477c..0000000000 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/AfternoteAuthoringValidationException.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.afternote.feature.afternote.domain.error - -/** - * 저장(생성/수정) API가 거절한 검증 실패. - * HTTP 상태·Retrofit·에러 바디 형식은 Data 계층에서 해석한 뒤 이 타입으로 통일한다. - */ -enum class AfternoteAuthoringValidationKind { - /** 서버 코드 475 — 수신자 최소 1명 필요. */ - RECEIVERS_REQUIRED, -} - -class AfternoteAuthoringValidationException( - val kind: AfternoteAuthoringValidationKind, -) : Exception("Afternote authoring validation: ${kind.name}") diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/AfternoteFailure.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/AfternoteFailure.kt new file mode 100644 index 0000000000..0e533ed0a2 --- /dev/null +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/error/AfternoteFailure.kt @@ -0,0 +1,40 @@ +package com.afternote.feature.afternote.domain.error + +import com.afternote.feature.afternote.domain.repository.author.MediaKind + +/** + * 애프터노트 작성·저장 흐름의 도메인 실패 루트. + * HTTP 상태·Retrofit·에러 바디 형식 같은 인프라 디테일은 Data 계층이 해석한 뒤 이 계열로 통일한다. + * + * 소비처는 이 루트로 좁힌 뒤 `when` 으로 가른다 — 실패 유형이 늘면 컴파일러가 소비처를 잡아준다. + * `message` 는 Logcat·Crashlytics 단서용 진단 문구다. 표시 문구는 화면 리소스가 갖는다. + */ +sealed class AfternoteFailure( + message: String?, + cause: Throwable?, +) : Exception(message, cause) { + /** 저장(생성/수정) API 가 거절한 검증 실패. 화면은 [kind] 로 입력 항목별 안내를 고른다. */ + class AuthoringValidation( + val kind: AfternoteAuthoringValidationKind, + ) : AfternoteFailure("afternote authoring validation: ${kind.name}", null) + + /** + * 추모 미디어(영상·영정 사진)를 저장 페이로드에 실을 URL 로 해석하지 못한 실패. + * + * 매체별로 타입을 나누지 않고 [media] 로 구분한다 — 화면은 둘을 같은 문구로 처리하고 + * (`AfternoteEditorViewModel.handleSaveFailure` 의 generic 폴백), 갈라 보던 코드가 없었다(#934 실측). + * + * `data class` 를 쓰지 않는다 — 생성된 equals/hashCode 가 선언 프로퍼티만 덮어 [cause] 를 무시한다. + * 원본 예외를 cause 로 나르는 것이 이 타입의 계약이다. + */ + class MediaSave( + val media: MediaKind, + cause: Throwable, + ) : AfternoteFailure("memorial media save failed: ${media.name}", cause) +} + +/** 저장 API 가 거절한 검증 사유. */ +enum class AfternoteAuthoringValidationKind { + /** 서버 코드 475 — 수신자 최소 1명 필요. */ + RECEIVERS_REQUIRED, +} diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/AfternoteRepository.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/AfternoteRepository.kt index 1a007639f5..450ed0d71c 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/AfternoteRepository.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/repository/author/AfternoteRepository.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.Flow /** * 작성/수정 호출의 서버 검증 실패는 - * [com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationException]으로 + * [com.afternote.feature.afternote.domain.error.AfternoteFailure.AuthoringValidation]으로 * Result.failure에 담겨 전달된다. */ interface AfternoteRepository { diff --git a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCase.kt b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCase.kt index 2f1d6edfde..57c37cfd21 100644 --- a/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCase.kt +++ b/feature/afternote/domain/src/main/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCase.kt @@ -1,23 +1,16 @@ package com.afternote.feature.afternote.domain.usecase.editor +import com.afternote.feature.afternote.domain.error.AfternoteFailure import com.afternote.feature.afternote.domain.repository.author.MediaInput import com.afternote.feature.afternote.domain.repository.author.MediaKind import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository import javax.inject.Inject -class MemorialVideoSaveException( - cause: Throwable, -) : Exception("영상 업로드에 실패했습니다.", cause) - -class MemorialPhotoSaveException( - cause: Throwable, -) : Exception("영정 사진 업로드에 실패했습니다.", cause) - /** * 추모 영상·영정 사진의 *서버 저장 직전 정리* 도메인 로직. * * 호출부가 로컬/원격을 확정한 [MediaInput] 을 Repository 가 저장 페이로드에 실을 URL 로 해석해 주고, - * 본 UseCase 는 그 실패를 매체별 도메인 예외로 wrap 한다. 도메인 본문이 `"content://"` 같은 인프라 + * 본 UseCase 는 그 실패를 [AfternoteFailure.MediaSave] 로 wrap 한다. 도메인 본문이 `"content://"` 같은 인프라 * 형식 디테일 문자열을 직접 비교하지 않는다. */ class ResolveMemorialMediaForSaveUseCase @@ -38,12 +31,12 @@ class ResolveMemorialMediaForSaveUseCase val resolvedVideoUrl = memorialMediaUploadRepository .resolve(video, MediaKind.VIDEO) - .getOrElse { return Result.failure(MemorialVideoSaveException(it)) } + .getOrElse { return Result.failure(AfternoteFailure.MediaSave(MediaKind.VIDEO, it)) } val resolvedPhotoUrl = memorialMediaUploadRepository .resolve(photo, MediaKind.PHOTO) - .getOrElse { return Result.failure(MemorialPhotoSaveException(it)) } + .getOrElse { return Result.failure(AfternoteFailure.MediaSave(MediaKind.PHOTO, it)) } return Result.success( ResolvedMemorialMediaForSave( diff --git a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt index c2fddbb7bc..153f1423c1 100644 --- a/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt +++ b/feature/afternote/domain/src/test/java/com/afternote/feature/afternote/domain/usecase/editor/ResolveMemorialMediaForSaveUseCaseTest.kt @@ -1,5 +1,6 @@ package com.afternote.feature.afternote.domain.usecase.editor +import com.afternote.feature.afternote.domain.error.AfternoteFailure import com.afternote.feature.afternote.domain.repository.author.MediaInput import com.afternote.feature.afternote.domain.repository.author.MediaKind import com.afternote.feature.afternote.domain.repository.author.MemorialMediaUploadRepository @@ -16,9 +17,9 @@ import org.junit.Test * 검증 핵심: * 1. 입력 [MediaInput] 과 [MediaKind] 를 Repository 에 그대로 전달하는지 (로컬/원격 확정은 호출부 책임) * 2. 매체별 resolve 결과를 서로 뒤바꾸지 않고 각 필드에 담는지 (null 은 미첨부) - * 3. 영상 resolve 실패면 [MemorialVideoSaveException] 으로 wrap 하고 **사진은 resolve 하지 않은 채** + * 3. 영상 resolve 실패면 [AfternoteFailure.MediaSave] (media=VIDEO) 로 wrap 하고 **사진은 resolve 하지 않은 채** * short-circuit 하는지 (non-local return) - * 4. 사진 resolve 실패면 [MemorialPhotoSaveException] 으로 wrap 하고, 두 경우 모두 원본 예외를 cause 로 보존하는지 + * 4. 사진 resolve 실패면 media=PHOTO 로 wrap 하고, 두 경우 모두 원본 예외를 cause 로 보존하는지 * * 외부 라이브러리(mockk 등) 없이 호출 인자/횟수를 기록하는 직접 작성 fake를 사용한다. */ @@ -91,7 +92,7 @@ class ResolveMemorialMediaForSaveUseCaseTest { } @Test - fun `영상 resolve 실패면 MemorialVideoSaveException 으로 wrap 하고 사진은 resolve 하지 않음`() { + fun `영상 resolve 실패면 MediaSave(VIDEO) 로 wrap 하고 사진은 resolve 하지 않음`() { val videoError = IllegalStateException("video resolve failed") val repo = FakeMemorialMediaUploadRepository().apply { videoResult = Result.failure(videoError) } @@ -101,14 +102,16 @@ class ResolveMemorialMediaForSaveUseCaseTest { } assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is MemorialVideoSaveException) + val videoFailure = result.exceptionOrNull() + assertTrue(videoFailure is AfternoteFailure.MediaSave) + assertEquals(MediaKind.VIDEO, (videoFailure as AfternoteFailure.MediaSave).media) assertSame(videoError, result.exceptionOrNull()?.cause) // 원본 예외를 cause 로 보존 // short-circuit — 사진 resolve 미호출 assertEquals(0, repo.calls.count { it.second == MediaKind.PHOTO }) } @Test - fun `사진 resolve 실패면 MemorialPhotoSaveException 으로 wrap`() { + fun `사진 resolve 실패면 MediaSave(PHOTO) 로 wrap`() { val photoError = IllegalStateException("photo resolve failed") val repo = FakeMemorialMediaUploadRepository().apply { photoResult = Result.failure(photoError) } @@ -118,7 +121,9 @@ class ResolveMemorialMediaForSaveUseCaseTest { } assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is MemorialPhotoSaveException) + val photoFailure = result.exceptionOrNull() + assertTrue(photoFailure is AfternoteFailure.MediaSave) + assertEquals(MediaKind.PHOTO, (photoFailure as AfternoteFailure.MediaSave).media) assertSame(photoError, result.exceptionOrNull()?.cause) } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt index 43bb7afea9..74c9e40d9f 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/AfternoteEditorViewModel.kt @@ -8,8 +8,8 @@ import androidx.navigation.toRoute import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.UserRepository -import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationException import com.afternote.feature.afternote.domain.error.AfternoteAuthoringValidationKind +import com.afternote.feature.afternote.domain.error.AfternoteFailure import com.afternote.feature.afternote.domain.model.author.CreateAfternoteInput import com.afternote.feature.afternote.domain.model.author.SaveAfternoteCommand import com.afternote.feature.afternote.domain.repository.author.AfternoteRepository @@ -521,12 +521,8 @@ class AfternoteEditorViewModel private fun handleSaveFailure(e: Throwable) { val validationError = when (e) { - is AfternoteAuthoringValidationException -> { - when (e.kind) { - AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED -> { - AfternoteValidationError.RECEIVERS_REQUIRED - } - } + is AfternoteFailure -> { + e.toValidationError() } is AfternoteValidationException -> { @@ -620,3 +616,25 @@ class AfternoteEditorViewModel // endregion } + +/** + * 루트로 좁혀 `when` 을 exhaustive 하게 만든다 — 실패 유형이 늘면 여기가 컴파일 에러로 잡힌다. + * `else` 로 뭉개 두면 새 유형이 검증 실패로도 장애로도 분류되지 않은 채 조용히 흘러간다. + * + * null 은 "입력 검증 실패가 아님" 을 뜻한다 — 호출부가 그 경우에만 텔레메트리에 기록한다. + */ +private fun AfternoteFailure.toValidationError(): AfternoteValidationError? = + when (this) { + is AfternoteFailure.AuthoringValidation -> { + when (kind) { + AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED -> { + AfternoteValidationError.RECEIVERS_REQUIRED + } + } + } + + // 미디어 해석 실패는 사용자가 입력을 고쳐 푸는 검증 실패가 아니라 업로드 장애다 — 기록 대상. + is AfternoteFailure.MediaSave -> { + null + } + } diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteSaveState.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteSaveState.kt index f4d70edbac..45ffbf83cb 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteSaveState.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/author/editor/state/AfternoteSaveState.kt @@ -13,7 +13,7 @@ import com.afternote.feature.afternote.presentation.author.editor.receiver.model * * 현재 이 예외를 던지는 곳은 없다 — 저장 전 로컬 검증은 `AfternoteEditorValidator` 결과를 * `validationError` 상태에 바로 넣고 반환하는 경로를 쓴다. 서버가 거절한 검증(수신자 필수 등)은 - * domain 의 `AfternoteAuthoringValidationException` 이 맡는다. + * domain 의 `AfternoteFailure.AuthoringValidation` 이 맡는다. */ class AfternoteValidationException( val validationError: AfternoteValidationError,