From 1cb7748647fb7fe2886ed139ac5edfc6c619a3b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Mon, 24 Aug 2026 21:27:34 +0900 Subject: [PATCH] =?UTF-8?q?refactor(receiver):=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=20=EC=84=9C=EB=B2=84=20=EA=B1=B0=EC=A0=88=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=204=EC=A2=85=EC=9D=84=20ReceiverFailure.ServerRejecti?= =?UTF-8?q?on=20=ED=95=98=EB=82=98=EB=A1=9C=20=EC=95=95=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sealed 루트 ReceiverServerRejectionException 과 하위 3종(MasterKey·EmailAuth· DeliveryVerification)을 ReceiverFailure.ServerRejection 하나로 줄인다. 하위 타입을 나눈 명분은 KDoc 상 "사유 code 별 분기를 넣을 때 when 이 누락을 잡아준다" 였으나, 셋을 갈라 보는 프로덕션 코드가 없었다 — 소비처는 상위 타입만 보고 data 계층은 셋을 만들기만 했다. 어느 endpoint 가 거절했는지는 stack trace 에 남는다. 소비처는 루트로 좁혀 when 을 exhaustive 하게 만들었다. toErrorPayload 와 shouldReportInReceiverFlow 가 단일 타입 캐스팅이라 sealed 이득이 0 이었던 것을, 수신자 실패 유형이 늘면 두 곳이 컴파일 에러로 잡히게 했다. 판정 기준(4xx 대역·code allowlist·문구 유무)은 그대로라 동작은 변하지 않는다. 토스트·스낵바 문구를 좌우하는 노출 게이트를 건드리므로 ErrorPayloadTest 6케이스와 ReceiverFlowReportingTest 4케이스를 그대로 이행해 회귀 가드를 유지했다. 함께 수신자 열람 인증 화면의 주석을 정리했다. USER_DISPLAYABLE_SERVER_CODES 의 code 별 의미 나열은 BE 소유라 걷었다 — 한 번 틀려 #654 로 정정한 자리다. ErrorPayload KDoc 의 통일 소관 표기(#446)는 오참조라 제거했고(실제 소관은 #604), 판정 근거 주석은 실제로 판정하는 함수 자리로 옮겼다. AfternoteFailureReporting 의 판정표는 코드와 중복이라 걷고 근거만 압축했다. --- .../AppAndReceiverCompletionAndroidTest.kt | 4 +- .../ReceiverVerificationAndroidTest.kt | 4 +- .../reporting/AfternoteFailureReporting.kt | 66 ++++++++----------- .../reporting/ReceiverFlowReportingTest.kt | 10 ++- .../ReceiverAuthRepositoryImpl.kt | 12 ++-- ...ReceiverAuthRepositoryImplEmailAuthTest.kt | 12 ++-- .../ReceiverDeliveryVerificationException.kt | 20 ------ .../error/ReceiverEmailAuthException.kt | 23 ------- .../receiver/domain/error/ReceiverFailure.kt | 37 +++++++++++ .../error/ReceiverMasterKeyException.kt | 18 ----- .../error/ReceiverServerRejectionException.kt | 29 -------- .../repository/ReceiverAuthRepository.kt | 6 +- .../DocumentUploadUiState.kt | 55 +++++++++------- .../IdentityVerificationViewModel.kt | 7 +- .../deliveryverification/ErrorPayloadTest.kt | 14 ++-- 15 files changed, 124 insertions(+), 193 deletions(-) delete mode 100644 feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverDeliveryVerificationException.kt delete mode 100644 feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverEmailAuthException.kt create mode 100644 feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverFailure.kt delete mode 100644 feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverMasterKeyException.kt delete mode 100644 feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverServerRejectionException.kt diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt index 3435509b55..1b1fb44889 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt @@ -49,7 +49,7 @@ import com.afternote.feature.afternote.domain.model.LeaveMessageBlock import com.afternote.feature.home.presentation.HomeTabActions import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository -import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException +import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.model.AfterNoteListItem import com.afternote.feature.receiver.domain.model.AfterNotesListResult import com.afternote.feature.receiver.domain.model.DeliveryVerification @@ -344,7 +344,7 @@ class ReceiverRuntimeCompletionAndroidTest { val authRepository = CompletionReceiverAuthRepository() authRepository.verifyEmailResults.addLast( Result.failure( - ReceiverEmailAuthException( + ReceiverFailure.ServerRejection( status = 400, serverMessage = "인증번호가 만료되었습니다. 다시 발급해 주세요.", serverCode = 1902, diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverVerificationAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverVerificationAndroidTest.kt index faec6f0eef..0b4cef98fd 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverVerificationAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/ReceiverVerificationAndroidTest.kt @@ -12,7 +12,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.core.ui.theme.AfternoteTheme -import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException +import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl @@ -51,7 +51,7 @@ class ReceiverVerificationAndroidTest { val auth = FakeReceiverAuthRepository() auth.verifyEmailResults.addLast( Result.failure( - ReceiverEmailAuthException( + ReceiverFailure.ServerRejection( status = 400, serverMessage = "인증번호가 일치하지 않습니다.", serverCode = 1903, diff --git a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt index 30074dca2f..b6bf1c09d3 100644 --- a/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt +++ b/feature/afternote/presentation/src/main/kotlin/com/afternote/feature/afternote/presentation/reporting/AfternoteFailureReporting.kt @@ -1,7 +1,7 @@ package com.afternote.feature.afternote.presentation.reporting import com.afternote.core.common.reporting.ErrorReporter -import com.afternote.feature.receiver.domain.error.ReceiverServerRejectionException +import com.afternote.feature.receiver.domain.error.ReceiverFailure /** * 애프터노트 흐름에서 실패가 발생한 지점. @@ -76,11 +76,6 @@ enum class AfternoteFailureStage( /** 발신자 상세의 열람 인증 상태 조회. */ SENDER_STATUS_LOAD("sender_status_load"), - - // RECEIVED_EXPORT_DOWNLOAD·RECEIVED_EXPORT_SAVE 는 수신자 흐름 이전(#615)과 함께 - // feature:receiver 의 ReceiverFailureStage(`receiver_stage` 키)로 이동 — home_stage 와의 - // 이중 기록(#546 참고)이 그 키로 수렴된다. 위 수신자 단계들(RECEIVED_*·RECEIVER_* 등)의 - // 이동은 열람 실패 처리 통일(#611·#614 계열)과 함께 별도 판단. } /** @@ -104,44 +99,37 @@ fun ErrorReporter.recordAfternoteFailure( * `Receiver` 는 타입이 아니라 적용 맥락이다 — `Throwable` 확장이라 네트워크 타임아웃처럼 수신자와 * 무관한 예외도 들어오고, 그런 실패는 기록 대상(true)이다. 타입으로 좁히는 건 아래 제외 판정뿐이다. * - * 유일한 제외 대상은 "서버가 예상하고 처리한 거절"(이메일 미등록·인증번호 만료·마스터 키 오타 등)이다 — - * 앱이 정상 동작한 결과라 고칠 것이 없고, 무엇보다 보관 한도를 사용자 오류가 차지해 실제 장애를 밀어낸다. - * Crashlytics 는 non-fatal 을 **최근 8건만 보관하고 초과분은 오래된 것부터 버린다** — 이 수치가 코드 곳곳의 - * "보관 한도(최근 8건)" 서술의 근거다. + * 제외 대상은 "서버가 예상하고 처리한 거절" 하나다 — 앱이 정상 동작한 결과라 고칠 것이 없고, 무엇보다 + * Crashlytics 가 non-fatal 을 **최근 8건만 보관하고 초과분은 오래된 것부터 버려서**(코드 곳곳의 + * "보관 한도(최근 8건)" 서술이 이 수치를 가리킨다) 사용자 오류가 실제 장애를 밀어낸다. * https://firebase.google.com/docs/crashlytics/android/customize-crash-reports * - * 그 판정은 **4xx + 서버 문구** 두 조건을 모두 만족할 때만 성립하고, 나머지는 전부 기록한다. - * - * | | 문구 있음 | 문구 없음 | - * |---|---|---| - * | 4xx | 제외 | 기록 | - * | 5xx | 기록 | 기록 | - * - * 문구 유무만으로 가르지 않는 이유: 이 서버는 5xx 에도 `message` 를 싣는다 — 500 응답 body 에 내부 SQL - * 문구가 그대로 실려 온 실측(#511)이 있다. 문구만 보면 정작 잡으려던 장애가 통째로 제외된다. - * - * 반대로 status 만으로도 가를 수 없다: 문구 없는 4xx 는 서버가 안내한 거절이 아니라 이쪽이 잘못된 요청을 - * 보내고 있다는 신호(파라미터 누락·잘못된 id·만료 토큰)라, 제외하면 FE 버그가 묻힌다. 이 서버는 4xx·5xx - * 가리지 않고 문구를 싣는 것으로 관측돼(400·401·500 실측), 문구가 없다는 것 자체가 정상 경로가 아니다. - * - * 두 조건을 모두 요구하는 건 **확실할 때만 제외**하려는 것이다 — 판정이 빗나가면 기록을 더 하는 쪽으로 - * 빗나간다. 잡음은 콘솔에 보여서 나중에 좁힐 수 있지만, 제외한 건 보이지 않아 좁힐 기회조차 없다. + * 제외는 **4xx + 서버 문구** 둘 다일 때만 성립한다. 문구만 보면 5xx 장애가 통째로 빠지고(이 서버는 5xx 에도 + * `message` 를 싣는다 — 500 body 에 내부 SQL 이 실려 온 실측 #511), status 만 보면 문구 없는 4xx — + * 파라미터 누락·만료 토큰 같은 FE 버그 신호 — 가 묻힌다. 판정이 빗나갈 땐 기록을 더 하는 쪽으로 빗나가야 + * 한다: 잡음은 콘솔에 보여 나중에 좁힐 수 있지만, 제외한 건 보이지 않아 좁힐 기회조차 없다. * - * 사유 code 로 좁히지 않은 이유: 서버의 code 체계가 사용자 오류와 장애를 아직 분리하지 않는다. + * 사유 code 로 좁히지 않은 이유 — 서버의 code 체계가 사용자 오류와 장애를 아직 분리하지 않는다. * - * 다른 흐름에는 쓰지 않는다. 회원가입 이메일 인증은 사용자 오류가 code 1207 하나로만 와서 호출부가 - * 타입(`EmailVerificationException`)만 보고 거른다 — 문구 유무를 따질 필요가 없다. - * - * 화면 노출 게이트(`toErrorPayload`, DocumentUploadUiState.kt)는 이 술어를 쓰지 않는다 — 노출은 - * 사유 code allowlist 로 더 좁게 가른다. 그쪽을 넓히더라도 이 판정을 따라 넓히지 말 것. + * 화면 노출 게이트(`toErrorPayload`)는 이 술어를 쓰지 않는다 — 노출은 사유 code allowlist 로 더 좁게 + * 가른다. 그쪽을 넓히더라도 이 판정을 따라 넓히지 말 것. */ -fun Throwable.shouldReportInReceiverFlow(): Boolean { - val isExpectedUserRejection = - this is ReceiverServerRejectionException && - status in CLIENT_ERROR_STATUS_RANGE && - !serverMessage.isNullOrBlank() - return !isExpectedUserRejection -} +fun Throwable.shouldReportInReceiverFlow(): Boolean = + when (this) { + is ReceiverFailure -> !isExpectedUserRejection() + else -> true + } + +/** + * 루트로 좁혀 `when` 을 exhaustive 하게 만든다 — 수신자 실패 유형이 늘면 여기가 컴파일 에러로 잡힌다. + * 판정을 빼먹은 새 유형은 조용히 기록 대상이 되므로(안전한 쪽), 놓쳐도 티가 나지 않는다. + */ +private fun ReceiverFailure.isExpectedUserRejection(): Boolean = + when (this) { + is ReceiverFailure.ServerRejection -> { + status in CLIENT_ERROR_STATUS_RANGE && !serverMessage.isNullOrBlank() + } + } /** 4xx = 요청을 보낸 쪽 문제. 이 대역 밖(5xx·그 외)은 서버 문구가 실려 와도 장애로 보고 기록한다. */ private val CLIENT_ERROR_STATUS_RANGE = 400..499 diff --git a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/reporting/ReceiverFlowReportingTest.kt b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/reporting/ReceiverFlowReportingTest.kt index ddfacffc91..7c14c039b7 100644 --- a/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/reporting/ReceiverFlowReportingTest.kt +++ b/feature/afternote/presentation/src/test/kotlin/com/afternote/feature/afternote/presentation/reporting/ReceiverFlowReportingTest.kt @@ -1,8 +1,6 @@ package com.afternote.feature.afternote.presentation.reporting -import com.afternote.feature.receiver.domain.error.ReceiverDeliveryVerificationException -import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException -import com.afternote.feature.receiver.domain.error.ReceiverMasterKeyException +import com.afternote.feature.receiver.domain.error.ReceiverFailure import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -18,7 +16,7 @@ class ReceiverFlowReportingTest { @Test fun `4xx 에 서버 문구가 실렸으면 사용자 오류라 제외한다`() { val rejection = - ReceiverEmailAuthException( + ReceiverFailure.ServerRejection( status = 400, serverMessage = "인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", serverCode = 1902, @@ -30,7 +28,7 @@ class ReceiverFlowReportingTest { @Test fun `5xx 는 서버 문구가 실려 있어도 기록 대상이다`() { val outage = - ReceiverMasterKeyException( + ReceiverFailure.ServerRejection( status = 500, serverMessage = "서버 내부 오류: could not execute statement", serverCode = 1500, @@ -42,7 +40,7 @@ class ReceiverFlowReportingTest { @Test fun `4xx 라도 서버 문구가 없으면 기록 대상이다`() { val silentRejection = - ReceiverDeliveryVerificationException( + ReceiverFailure.ServerRejection( status = 409, serverMessage = null, serverCode = 1700, diff --git a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt index 5a21460bca..f840a23e12 100644 --- a/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt +++ b/feature/receiver/data/src/main/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImpl.kt @@ -11,9 +11,7 @@ import com.afternote.feature.receiver.data.dto.ReceiverAuthVerifyRequestDto import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyRequestDto import com.afternote.feature.receiver.data.dto.toDomain import com.afternote.feature.receiver.data.service.ReceiverAuthApiService -import com.afternote.feature.receiver.domain.error.ReceiverDeliveryVerificationException -import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException -import com.afternote.feature.receiver.domain.error.ReceiverMasterKeyException +import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.model.DeliveryVerification import com.afternote.feature.receiver.domain.model.ReceiverAuthPresignedUrl import com.afternote.feature.receiver.domain.model.ReceiverEmailAuthResult @@ -43,7 +41,7 @@ class ReceiverAuthRepositoryImpl try { api.verifyMasterKey(ReceiverAuthVerifyRequestDto(authCode)).requireData().toDomain() } catch (e: ApiException) { - throw ReceiverMasterKeyException(status = e.status, serverMessage = e.serverMessage, serverCode = e.code) + throw ReceiverFailure.ServerRejection(status = e.status, serverMessage = e.serverMessage, serverCode = e.code) } } @@ -52,7 +50,7 @@ class ReceiverAuthRepositoryImpl try { api.sendEmailAuthCode(ReceiverAuthCodeEmailSendRequestDto(email)).requireStatus() } catch (e: ApiException) { - throw ReceiverEmailAuthException(status = e.status, serverMessage = e.serverMessage, serverCode = e.code) + throw ReceiverFailure.ServerRejection(status = e.status, serverMessage = e.serverMessage, serverCode = e.code) } } @@ -68,7 +66,7 @@ class ReceiverAuthRepositoryImpl ).requireData() .toDomain() } catch (e: ApiException) { - throw ReceiverEmailAuthException(status = e.status, serverMessage = e.serverMessage, serverCode = e.code) + throw ReceiverFailure.ServerRejection(status = e.status, serverMessage = e.serverMessage, serverCode = e.code) } } @@ -92,7 +90,7 @@ class ReceiverAuthRepositoryImpl ).requireData() .toDomain() } catch (e: ApiException) { - throw ReceiverDeliveryVerificationException( + throw ReceiverFailure.ServerRejection( status = e.status, serverMessage = e.serverMessage, serverCode = e.code, diff --git a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt index e3db8f0141..8a72617216 100644 --- a/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt +++ b/feature/receiver/data/src/test/kotlin/com/afternote/feature/receiver/data/repositoryimpl/ReceiverAuthRepositoryImplEmailAuthTest.kt @@ -13,7 +13,7 @@ import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyDto import com.afternote.feature.receiver.data.dto.ReceiverEmailAuthVerifyRequestDto import com.afternote.feature.receiver.data.dto.ReceiverMessageDto import com.afternote.feature.receiver.data.service.ReceiverAuthApiService -import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException +import com.afternote.feature.receiver.domain.error.ReceiverFailure import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -25,7 +25,7 @@ import org.junit.Test import java.io.IOException /** - * 이메일 인증 두 endpoint 의 `ApiException` → [ReceiverEmailAuthException] 도메인 예외 변환 회귀 가드 (#407). + * 이메일 인증 두 endpoint 의 `ApiException` → [ReceiverFailure.ServerRejection] 도메인 예외 변환 회귀 가드 (#407). * * presentation 은 core:network 의 ApiException 을 직접 알면 안 되므로 (layer 규약) * Impl 이 serverMessage 를 보존해 변환하는지가 계약. 에러 메시지·code 값은 @@ -55,8 +55,8 @@ class ReceiverAuthRepositoryImplEmailAuthTest { val result = runBlocking { repository.sendEmailAuthCode("none@example.com") } val exception = result.exceptionOrNull() - assertTrue(exception is ReceiverEmailAuthException) - exception as ReceiverEmailAuthException + assertTrue(exception is ReceiverFailure.ServerRejection) + exception as ReceiverFailure.ServerRejection assertEquals("등록된 수신자 이메일이 아닙니다.", exception.serverMessage) assertEquals(404, exception.status) } @@ -80,8 +80,8 @@ class ReceiverAuthRepositoryImplEmailAuthTest { val result = runBlocking { repository.verifyEmailAuthCode("a@b.com", "123456") } val exception = result.exceptionOrNull() - assertTrue(exception is ReceiverEmailAuthException) - exception as ReceiverEmailAuthException + assertTrue(exception is ReceiverFailure.ServerRejection) + exception as ReceiverFailure.ServerRejection assertEquals("인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", exception.serverMessage) assertEquals(400, exception.status) } diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverDeliveryVerificationException.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverDeliveryVerificationException.kt deleted file mode 100644 index 5ac4aa923e..0000000000 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverDeliveryVerificationException.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.afternote.feature.receiver.domain.error - -/** - * 수신자가 사망진단서·가족관계증명서를 올려 전달 자격 심사를 신청하는 - * `submitDeliveryVerification` API 가 거절한 실패. - * - * 계약(문구·code 의 의미, 왜 흐름별로 나누는지)은 상위 [ReceiverServerRejectionException] 참고. - * - * 이 흐름의 `serverMessage` 예시 — 409 `"이미 대기 중인 인증 요청이 존재합니다."` - */ -class ReceiverDeliveryVerificationException( - status: Int, - serverMessage: String?, - serverCode: Int, -) : ReceiverServerRejectionException( - status = status, - serverCode = serverCode, - serverMessage = serverMessage, - serverMessageFallback = "delivery verification failed (serverCode=$serverCode)", - ) diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverEmailAuthException.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverEmailAuthException.kt deleted file mode 100644 index 83dbcf7fac..0000000000 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverEmailAuthException.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.afternote.feature.receiver.domain.error - -/** - * 수신자 본인 확인 이메일 인증(`receiver-auth/email` 계열) API 가 거절한 실패. - * - * 계약(문구·code 의 의미, 왜 흐름별로 나누는지)은 상위 [ReceiverServerRejectionException] 참고. - * - * 이 흐름의 `serverCode` 예시 — 1901 = 수신자 이메일 미등록(404 동반), - * 1902 = 인증번호 만료/미존재(400 동반), 1903 = 인증번호 불일치(400 동반). - * body 파싱 실패 시 HTTP status 폴백 값이 들어온다. - * `serverMessage` 예시 — `"등록된 수신자 이메일이 아닙니다."`, - * `"인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요."` - */ -class ReceiverEmailAuthException( - status: Int, - serverMessage: String?, - serverCode: Int, -) : ReceiverServerRejectionException( - status = status, - serverCode = serverCode, - serverMessage = serverMessage, - serverMessageFallback = "email auth failed (serverCode=$serverCode)", - ) diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverFailure.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverFailure.kt new file mode 100644 index 0000000000..a1189a33b2 --- /dev/null +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverFailure.kt @@ -0,0 +1,37 @@ +package com.afternote.feature.receiver.domain.error + +/** + * 수신자 흐름(본인 확인 이메일 인증·마스터 키 검증·전달 자격 심사)의 도메인 실패 루트. + * Retrofit·`BaseResponse` 같은 인프라 디테일은 Data 계층이 해석한 뒤 이 계열로 통일한다. + * + * 소비처는 이 루트로 좁힌 뒤 `when` 으로 가른다 — 실패 유형이 늘면 컴파일러가 소비처를 잡아준다. + */ +sealed class ReceiverFailure( + message: String?, +) : Exception(message) { + /** + * 서버가 응답을 내려주며 거절한 실패. + * + * 흐름별로 타입을 나누지 않는다 — 어느 endpoint 가 거절했는지는 stack trace 에 남고, 타입으로 + * 갈라 보던 프로덕션 코드가 없었다(#934 실측). 구분이 실제로 필요해지면 그때 필드를 더한다. + * + * @property status 거절 응답의 HTTP 상태 코드. 인프라 디테일을 걷어내는 이 계열에 굳이 남기는 건 + * "사용자 오류(4xx)"와 "장애(5xx)"를 가를 유일한 단서라서다 — 서버가 양쪽 모두에 [serverMessage] 를 + * 실어 보내고(실측 #511), 사유 `code` 체계도 둘을 분리하지 않는다. 텔레메트리 제외 판정과 + * 화면 노출 게이트(#651)가 이 값을 쓴다. + * @property serverCode 서버 봉투의 사유 code (BE `ErrorCode` 번호 — 1902 등). 화면 노출 게이트가 + * 표시 허용 allowlist 판정에 쓴다. 서버의 `@Valid` 바디 검증 실패는 enum code 가 아니라 + * **리터럴 400** 으로 온다(실측) — 형식 검증 문구가 allowlist 에 못 드는 건 이 때문이다. + * @property serverMessage 백엔드가 실제로 내려준 사용자 친화 message. + * **null 이면 서버가 message 미제공** — 호출처는 정적 R.string 으로 폴백한다. 클라가 만든 generic + * 문구("알 수 없는 서버 에러" 등)는 여기 들어오지 않는다. + * null 일 때 `Throwable.message` 로 쓰는 문구는 Logcat·Crashlytics 단서용이라 노출하지 않는다. + */ + class ServerRejection( + val status: Int, + val serverCode: Int, + val serverMessage: String?, + ) : ReceiverFailure( + serverMessage ?: "receiver request rejected (status=$status, serverCode=$serverCode)", + ) +} diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverMasterKeyException.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverMasterKeyException.kt deleted file mode 100644 index 4a904815fc..0000000000 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverMasterKeyException.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.afternote.feature.receiver.domain.error - -/** - * 마스터 키 검증(`receiver-auth/verify`) API 가 거절한 실패. - * - * 계약(문구·code 의 의미, 왜 흐름별로 나누는지)은 상위 [ReceiverServerRejectionException] 참고. - * 이 흐름의 거절은 대부분 키 오타라 서버가 안내 문구를 함께 내려준다. - */ -class ReceiverMasterKeyException( - status: Int, - serverMessage: String?, - serverCode: Int, -) : ReceiverServerRejectionException( - status = status, - serverCode = serverCode, - serverMessage = serverMessage, - serverMessageFallback = "master key verify failed (serverCode=$serverCode)", - ) diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverServerRejectionException.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverServerRejectionException.kt deleted file mode 100644 index 838bcf9494..0000000000 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/error/ReceiverServerRejectionException.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.afternote.feature.receiver.domain.error - -/** - * 수신자 흐름 API 가 응답을 내려주며 거절한 실패의 공통 상위 타입. - * Retrofit·`BaseResponse` 같은 인프라 디테일은 Data 계층에서 해석한 뒤 이 계열로 통일한다. - * - * 하위 타입은 흐름별로 나눈다 — 한 화면이 여러 endpoint 를 호출할 때 어느 단계가 거절됐는지 타입만 - * 보고 갈라내기 위해서다. `sealed` 라 향후 사유 code 별 분기를 넣을 때 `when` 이 누락을 잡아준다. - * - * @property status 거절 응답의 HTTP 상태 코드. 인프라 디테일을 걷어내는 이 계열에 굳이 남기는 건 - * "사용자 오류(4xx)"와 "장애(5xx)"를 가를 유일한 단서라서다 — 서버가 양쪽 모두에 [serverMessage] 를 - * 실어 보내고(실측 #511), 사유 `code` 체계도 둘을 분리하지 않는다. 텔레메트리 제외 판정과 - * 화면 노출 게이트(#651)가 이 값을 쓴다. - * @property serverCode 서버 봉투의 사유 code (BE `ErrorCode` 번호 — 1902 등). 화면 노출 게이트가 - * 표시 허용 allowlist 판정에 쓴다. 서버의 `@Valid` 바디 검증 실패는 enum code 가 아니라 - * **리터럴 400** 으로 온다(실측) — 형식 검증 문구가 allowlist 에 못 드는 건 이 때문이다. - * @property serverMessage 백엔드가 실제로 내려준 사용자 친화 message. - * **null 이면 서버가 message 미제공** — 호출처는 정적 R.string 으로 폴백한다. 클라가 만든 generic - * 문구("알 수 없는 서버 에러" 등)는 여기 들어오지 않는다. - * @param serverMessageFallback [serverMessage] 가 null 일 때 `Throwable.message` 로 쓸 개발자용 문구 — - * Logcat·Crashlytics 에 단서를 남기기 위한 것이라 사용자에게 노출하지 않는다. 하위 타입이 서버 - * `code` 를 이 문구에도 보간해 넣는다(로그 한 줄에서 사유가 보이도록). - */ -sealed class ReceiverServerRejectionException( - val status: Int, - val serverCode: Int, - val serverMessage: String?, - serverMessageFallback: String, -) : Exception(serverMessage ?: serverMessageFallback) diff --git a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverAuthRepository.kt b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverAuthRepository.kt index 64ed82a2f3..a9adb02b56 100644 --- a/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverAuthRepository.kt +++ b/feature/receiver/domain/src/main/java/com/afternote/feature/receiver/domain/repository/ReceiverAuthRepository.kt @@ -21,7 +21,7 @@ interface ReceiverAuthRepository { * 발신자가 발급한 마스터 키([authCode])로 수신자 신원을 확인한다. * * 성공하면 이 키가 이후 요청의 `X-Auth-Code` 헤더로 재사용된다 (사실상 수신자 세션). - * 실패는 `ReceiverMasterKeyException` 으로 매핑된다. + * 실패는 `ReceiverFailure.ServerRejection` 으로 매핑된다. */ suspend fun verifyMasterKey(authCode: String): Result @@ -29,14 +29,14 @@ interface ReceiverAuthRepository { * 수신자 본인 확인 — 수신자 레코드에 등록된 [email] 로 6자리 인증번호 발송. * * 발신자가 수신자 등록 시 email 을 넣지 않았으면 서버가 거절한다 (RECEIVER_EMAIL_NOT_FOUND). - * 실패는 `ReceiverEmailAuthException` 으로 매핑되어 serverMessage 에 안내 문구가 담긴다. + * 실패는 `ReceiverFailure.ServerRejection` 으로 매핑되어 serverMessage 에 안내 문구가 담긴다. */ suspend fun sendEmailAuthCode(email: String): Result /** * 수신자 본인 확인 — [email] 로 발송된 6자리 [authCode] 검증. * - * 실패(만료/불일치 등)는 `ReceiverEmailAuthException` 으로 매핑된다. + * 실패(만료/불일치 등)는 `ReceiverFailure.ServerRejection` 으로 매핑된다. */ suspend fun verifyEmailAuthCode( email: String, diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt index 050cce6154..25fcef3575 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/DocumentUploadUiState.kt @@ -2,20 +2,14 @@ package com.afternote.feature.receiver.presentation.deliveryverification import androidx.annotation.StringRes import androidx.compose.runtime.Immutable -import com.afternote.feature.receiver.domain.error.ReceiverServerRejectionException +import com.afternote.feature.receiver.domain.error.ReceiverFailure /** - * 화면이 표시할 에러 문구를 VM → UI 로 실어 나르는 상자 (payload = 운반되는 내용물). - * sealed 로 "i18n string resource" vs "서버 동적 message" 상호 배타 보장. + * 화면이 표시할 에러 문구를 VM → UI 로 실어 나른다. 리소스와 서버 동적 문구를 각각 nullable 필드로 + * 두면 둘 다 set 되는 상태가 가능해지므로, sealed 로 "하나만" 을 타입에 강제한다. * - * 두 경우를 각각 별도 nullable 필드로 두면 컨벤션 의존 (둘 다 set 되는 버그 가능). sealed 로 묶으면 - * 타입 자체가 "하나만 가능" 강제. - * - * 두 타입을 String 하나로 합칠 수 없는 이유: 리소스 ID → String 변환에는 Context 가 필요해 - * VM 에서 못 풀고(UI 의 stringResource 가 마지막에 한 번), 서버 동적 문구는 리소스가 될 수 없다. - * - * 쓰이는 곳이 열람 인증 흐름뿐인 건 서버 문구를 그대로 노출해도 되는 계약이 여기에만 있어서다. - * 같은 역할의 `core.ui.UiText.DynamicOrResource` 가 뒤늦게 들어와 중복이며, 통일은 #446 소관. + * 둘을 String 하나로 합칠 수는 없다 — 리소스 ID → String 변환에 Context 가 필요해 VM 에서 못 풀고 + * (UI 의 stringResource 가 마지막에 한 번), 서버 동적 문구는 리소스가 될 수 없다. */ sealed interface ErrorPayload { /** 클라이언트가 미리 정의한 generic 문구 (i18n 가능). 서버 message 미제공·5xx 장애 시 fallback. */ @@ -29,32 +23,43 @@ sealed interface ErrorPayload { ) : ErrorPayload } -/** - * status 만 가르고 code 를 안 보면 4xx 검증류 개발자 문구까지 화면에 실린다 — `@Valid` 실패의 - * "인증번호는 UUID 형식이어야 합니다."(리터럴 code=400, #600 실측)가 그 반례다. - */ +/** 실패를 화면 문구로 옮긴다 — 노출 가능 여부 판정은 [isUserDisplayableRejection] 이 가른다. */ internal fun Throwable.toErrorPayload( @StringRes fallbackRes: Int, ): ErrorPayload = - (this as? ReceiverServerRejectionException) - ?.takeIf { it.isUserDisplayableRejection() } - ?.serverMessage - ?.takeIf { it.isNotBlank() } + (this as? ReceiverFailure) + ?.displayTextOrNull() ?.let { ErrorPayload.Text(it) } ?: ErrorPayload.Res(fallbackRes) -/** 문구 유무는 보지 않는다 — 호출부 체인이 거른다. */ -private fun ReceiverServerRejectionException.isUserDisplayableRejection(): Boolean = +/** + * 화면에 그대로 실어도 되는 서버 문구. 루트로 좁혀 `when` 을 exhaustive 하게 만든다 — 수신자 실패 + * 유형이 늘면 여기가 컴파일 에러로 잡힌다. `else` 로 뭉개 두면 새 유형이 게이트를 거치지 않은 채 + * 흘러가 노출 규약이 조용히 빈다. + * + * null 은 "노출할 서버 문구 없음" 이고, 폴백 리소스는 [toErrorPayload] 한 곳에서만 적용한다. + */ +private fun ReceiverFailure.displayTextOrNull(): String? = + when (this) { + is ReceiverFailure.ServerRejection -> { + serverMessage?.takeIf { isUserDisplayableRejection() && it.isNotBlank() } + } + } + +/** + * status 만 보고 code 를 안 보면 4xx 검증류 개발자 문구까지 화면에 실린다 — `@Valid` 실패가 + * "인증번호는 UUID 형식이어야 합니다." 를 리터럴 code=400 으로 실어 보낸 것(#600 실측)이 그 반례다. + * 문구 유무는 여기서 보지 않는다 — 호출부 체인이 거른다. + */ +private fun ReceiverFailure.ServerRejection.isUserDisplayableRejection(): Boolean = status in DISPLAYABLE_CLIENT_ERROR_RANGE && serverCode in USER_DISPLAYABLE_SERVER_CODES /** 등재 code 라도 5xx 봉투면 장애다 — code 게이트와 독립으로 대역을 한 번 더 본다. */ private val DISPLAYABLE_CLIENT_ERROR_RANGE = 400..499 /** - * 1900 유효하지 않은 인증번호(마스터 키) · 1901 미등록 수신자 이메일 · 1902 인증번호 만료/미존재 · - * 1903 인증번호 불일치 · 2008 이미 대기 중인 인증 요청. BE `ErrorCode.java`@release 전수 대조로 - * 문구가 사용자 안내인 것만 골랐다(2026-08-01 기준) — 서버가 표시 가능 여부를 알려주지는 않으므로, - * 신규 code 는 문구를 확인한 뒤에만 등재한다. 미등재 기본값은 폴백. + * 서버는 표시 가능 여부를 알려주지 않는다 — BE `ErrorCode` 의 실제 문구가 사용자 안내인 것만 골라 + * 등재한 목록이다. 신규 code 는 문구를 확인한 뒤에만 더한다. 미등재 기본값은 폴백. */ private val USER_DISPLAYABLE_SERVER_CODES = setOf(1900, 1901, 1902, 1903, 2008) diff --git a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt index 2769ea233b..8f03aa2f5f 100644 --- a/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt +++ b/feature/receiver/presentation/src/main/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/IdentityVerificationViewModel.kt @@ -7,7 +7,6 @@ import com.afternote.feature.afternote.presentation.R import com.afternote.feature.afternote.presentation.reporting.AfternoteFailureStage import com.afternote.feature.afternote.presentation.reporting.recordAfternoteFailure import com.afternote.feature.afternote.presentation.reporting.shouldReportInReceiverFlow -import com.afternote.feature.receiver.domain.error.ReceiverServerRejectionException import com.afternote.feature.receiver.domain.repository.IdentityVerificationRepository import com.afternote.feature.receiver.domain.repository.ReceiverAuthRepository import dagger.hilt.android.lifecycle.HiltViewModel @@ -21,10 +20,8 @@ import javax.inject.Inject /** * 수신자 본인 확인 이메일 인증(designs 3·4) ViewModel — 인증번호 발송 + 코드 검증 (이슈 #215, #407). * - * [ReceiverAuthRepository.sendEmailAuthCode]·[ReceiverAuthRepository.verifyEmailAuthCode] 로 - * 실 API(`receiver-auth/email` 계열) 를 호출한다. 서버가 안내한 4xx 거절(이메일 미등록·인증번호 - * 만료/불일치 등) 의 문구는 [ReceiverServerRejectionException.serverMessage] 를 그대로 노출하고, - * 그 외(5xx 장애·인프라 실패)는 정적 리소스로 폴백 — 판정은 [toErrorPayload] 가 가른다 (#651). + * [ReceiverAuthRepository.sendEmailAuthCode]·[ReceiverAuthRepository.verifyEmailAuthCode] 로 실 API 를 + * 호출한다. 실패 문구를 서버 문구로 낼지 정적 리소스로 낼지는 [toErrorPayload] 가 가른다 (#651). * * 검증 성공 시 [IdentityVerificationRepository.markVerified] 로 캐시를 켜고 isVerified 신호 발행 → * UI 가 마스터 키(5) 단계로 이동. 이메일 인증은 신원 확인까지만 담당하며 마스터 키를 대신 획득하지 diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ErrorPayloadTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ErrorPayloadTest.kt index bc536de6fb..5add55c386 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ErrorPayloadTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/deliveryverification/ErrorPayloadTest.kt @@ -1,8 +1,6 @@ package com.afternote.feature.receiver.presentation.deliveryverification -import com.afternote.feature.receiver.domain.error.ReceiverDeliveryVerificationException -import com.afternote.feature.receiver.domain.error.ReceiverEmailAuthException -import com.afternote.feature.receiver.domain.error.ReceiverMasterKeyException +import com.afternote.feature.receiver.domain.error.ReceiverFailure import org.junit.Assert.assertEquals import org.junit.Test import java.io.IOException @@ -18,7 +16,7 @@ class ErrorPayloadTest { @Test fun `allowlist 에 등재된 4xx 사유 code 는 서버 문구를 그대로 노출한다`() { val rejection = - ReceiverEmailAuthException( + ReceiverFailure.ServerRejection( status = 400, serverMessage = "인증번호가 만료되었거나 존재하지 않습니다. 다시 요청해주세요.", serverCode = 1902, @@ -33,7 +31,7 @@ class ErrorPayloadTest { @Test fun `allowlist 미등재 4xx 는 서버 문구가 있어도 fallback 리소스로 폴백한다`() { val validationRejection = - ReceiverMasterKeyException( + ReceiverFailure.ServerRejection( status = 400, serverMessage = "인증번호는 UUID 형식이어야 합니다.", serverCode = 400, @@ -45,7 +43,7 @@ class ErrorPayloadTest { @Test fun `5xx 는 서버 문구가 실려 있어도 fallback 리소스로 폴백한다`() { val outage = - ReceiverDeliveryVerificationException( + ReceiverFailure.ServerRejection( status = 500, serverMessage = "서버 내부 오류: could not execute statement", serverCode = 1500, @@ -57,7 +55,7 @@ class ErrorPayloadTest { @Test fun `allowlist 에 등재된 code 라도 5xx 봉투면 fallback 리소스로 폴백한다`() { val outageWithKnownCode = - ReceiverDeliveryVerificationException( + ReceiverFailure.ServerRejection( status = 500, serverMessage = "internal error while checking pending verification", serverCode = 2008, @@ -69,7 +67,7 @@ class ErrorPayloadTest { @Test fun `서버 문구 없는 4xx 는 등재 code 라도 fallback 리소스로 폴백한다`() { val silentRejection = - ReceiverMasterKeyException( + ReceiverFailure.ServerRejection( status = 404, serverMessage = null, serverCode = 1900,