Skip to content

수신자 도메인 예외가 HTTP status·서버 code 를 나른다 — 노출·기록 판정을 data 로 내리고 도메인 어휘로 번역 #1053

Closed

Description

@OnlyReadOneBook

수신자 흐름의 도메인 예외가 HTTP 상태 코드와 BE ErrorCode 번호를 그대로 들고 다니고, 그 값을 presentation 이 직접 해석한다. 인프라 디테일을 걷어내라고 만든 계층이 그걸 실어 나르는 통로가 돼 있다.

퍼머링크 기준 커밋: efbb1256(2026-08-24 origin/develop).


상황 — 도메인 예외와 인프라 예외의 필드가 사실상 같다

// core:network
class ApiException(
    val status: Int, val code: Int, val serverMessage: String?, override val message: String,
) : IOException(message)

// feature/receiver/domain  ← 도메인인데 필드가 같다
sealed class ReceiverServerRejectionException(
    val status: Int, val serverCode: Int, val serverMessage: String?, serverMessageFallback: String,
) : Exception(serverMessage ?: serverMessageFallback)

ReceiverAuthRepositoryImpl 은 ApiException 의 세 필드를 그대로 복사해 도메인 예외로 옮긴다. 번역이 아니라 이름만 바꾼 재포장이다.

진짜 문제 — 판정이 presentation 에 있다

도메인이 값을 나르는 이유는 화면이 그 값으로 판정하기 때문이다. 실측상 소비처는 둘 뿐이고 둘 다 presentation 이다.

위치 판정
DocumentUploadUiState.kt:48 status in 400..499 && serverCode in setOf(1900, 1901, 1902, 1903, 2008) — 서버 문구를 화면에 노출할지
AfternoteFailureReporting.kt:141 status in 400..499 && !serverMessage.isNullOrBlank() — 텔레메트리에 기록할지

화면이 HTTP 대역과 BE ErrorCode allowlist 를 안다. 도메인에서 필드만 걷어내면 화면이 판정 근거를 잃으므로, 필드 제거가 아니라 판정을 data 로 내리는 것이 해법이다.

같은 레포에 올바른 대조군이 있다

애프터노트 저장 흐름은 반대로 한다 — AfternoteAuthoringFailureMapper 가 data 에서 ApiException.code == 475 를 판정해 도메인 어휘로 번역한다.

// data 가 서버 code 를 해석하고
if (throwable is ApiException && throwable.code == RECEIVERS_REQUIRED_SERVER_CODE) {
    return AfternoteAuthoringValidationException(AfternoteAuthoringValidationKind.RECEIVERS_REQUIRED)
}

// 도메인 예외는 HTTP 도 code 도 모른다
class AfternoteAuthoringValidationException(val kind: AfternoteAuthoringValidationKind) : Exception(...)

같은 규약인데 두 모듈이 반대 방향이다. 수신자 쪽을 이 형태로 맞춘다.

왜 지금까지 남았나

현행 KDoc 이 이유를 자백한다.

인프라 디테일을 걷어내는 이 계열에 굳이 남기는 건 "사용자 오류(4xx)"와 "장애(5xx)"를 가를 유일한 단서라서다

당시엔 맞는 판단이었다. 서버가 4xx·5xx 양쪽에 message 를 싣고(#511 실측) 사유 code 체계도 둘을 분리하지 않아, 화면이 가르려면 status 가 필요했다. 다만 그 판정을 화면이 해야 할 이유는 없다 — data 가 판정해 결과만 넘기면 된다.

방향

sealed class ReceiverFailure : Exception() {
    /** 서버가 안내 문구와 함께 거절 — 화면에 그대로 노출해도 되는 것으로 data 가 이미 판정했다. */
    class UserRejection(val displayMessage: String) : ReceiverFailure()

    /** 장애·형식 오류 등 사용자에게 설명할 수 없는 실패. 화면은 정적 폴백, 텔레메트리는 기록. */
    class Outage(cause: Throwable?) : ReceiverFailure()
}
  • status·serverCode 는 data 계층에 머문다
  • toErrorPayload 는 UserRejection.displayMessage 를 그대로 쓰고, shouldReportInReceiverFlow 는 is Outage 만 보면 된다

주의 — 두 판정의 기준이 다르다. 합치면 회귀한다

노출과 기록은 의도적으로 다른 폭을 쓴다.

노출(toErrorPayload) 기록(shouldReportInReceiverFlow)
기준 4xx + code allowlist 4xx + 문구 유무
폭 좁게 넓게
이유 미등재 code 문구가 화면에 새면 안 됨 확실할 때만 제외 — 빗나가면 기록하는 쪽으로

위 스케치의 2분류로는 이 차이를 담지 못한다. 착수 시 판정 결과를 하나로 합칠지, 두 축을 각각 남길지 먼저 정해야 한다.

노출 allowlist 는 사용자가 보는 문구를 직접 좌우한다 — 잘못 옮기면 서버 내부 문구가 화면에 새거나(#651 이 고친 회귀) 안내가 통째로 사라진다. ErrorPayloadTest 6케이스가 그 회귀 가드이므로 반드시 함께 이행한다.

범위 밖 / 인접 이슈

이슈 관계
#934 파생. 그 이슈가 "문제 2 — 추상화가 추상화를 하지 않는다"로 진단했으나 작업 목록에는 담지 않았다(거긴 sealed 압축이 범위). 4-A 가 하위 타입 3개를 ServerRejection 1개로 줄이므로 그 뒤에 착수한다
#604 인접·비차단. 축이 다르다 — 그쪽은 문구 운반 타입(ErrorPayload → UiText), 이쪽은 판정 계층. 다만 toErrorPayload 를 양쪽이 건드리므로 순서 조율 필요
#645 인접·비차단. 그쪽은 ApiException 의 변환 지점(OkHttp 파이프라인 밖으로), 이쪽은 network → domain 경계. 선후 무관
#651 현행 allowlist 판정을 만든 이슈. 회귀 금지 기준
#767 판단거리. TimeLetterServerRejectionException 이 같은 모양으로 신설된다 — 머지되면 같은 규약에 태울지 결정

작업

  • 노출·기록 두 판정을 하나로 합칠지 두 축으로 남길지 결정 (위 표)
  • status·serverCode 해석을 feature/receiver/data 로 이전
  • 도메인 예외를 결과 어휘로 재정의 — HTTP 필드 제거
  • toErrorPayload(receiver presentation) 갱신 — allowlist 상수 이동
  • shouldReportInReceiverFlow(afternote presentation) 갱신
  • ErrorPayloadTest 6케이스 · ReceiverFlowReportingTest 4케이스 이행 — 노출 규약 회귀 가드
  • ReceiverAuthRepositoryImplEmailAuthTest 갱신

Activity

  1. OnlyReadOneBook commented on Aug 25, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    실측 — 서버 문구에는 받아 쓸 이유가 되는 정보가 없다

    serverMessage 를 나르는 근거는 «서버만 아는 동적 사유가 있다» 인데, BE 실코드를 세어 보니 그런 문구가 하나도 없습니다.

    $ grep -cE '^\s+[A-Z_]+\(HttpStatus' ErrorCode.java
    122
    
    $ grep -nE '%s|%d|\{\}|" *\+' ErrorCode.java
    (없음)
    

    122개 전부 고정 문자열입니다. 포맷 자리표시자도 문자열 연결도 없습니다. 즉 서버 문구는 code → 문구 상수 매핑이고, FE 가 code 만 알면 재현할 수 있는 정보입니다. 받아 쓰는 대가로 치르는 것(allowlist 유지·code 지식 누수·서버가 문구를 바꾸면 앱 표시가 조용히 따라 바뀜·i18n 불가)에 대응하는 이득이 없습니다.

    CoreAuthFailure 가 같은 결론을 이미 적어 뒀습니다 — "표시 문구는 호출처 리소스가 갖는다(BE#92 — 서버 message 는 사용자 노출용이라는 규정이 없다)".

    규모 — 옮길 대상은 5개다

    122개 중 화면에 실리는 것은 allowlist 등재분뿐입니다.

    private val USER_DISPLAYABLE_SERVER_CODES = setOf(1900, 1901, 1902, 1903, 2008)

    나머지 117개는 이미 폴백 리소스로 내려앉고 있습니다. 이 5개를 타입이나 리소스로 옮기면 allowlist 가 통째로 사라지고, 그와 함께 serverCode 의 presentation 소비처도 없어집니다.

    선례 — #611 에서 한 건을 이 방식으로 옮겼습니다

    #1171 이 DELIVERY_CONDITION_NOT_MET(2009) 하나를 도메인 타입으로 승격했습니다. 이 이슈가 겨냥하는 형태의 축소판이라 참고가 될 것 같습니다.

    • data 의 번역이 code 를 보고 ReceiverFailure.DeliveryConditionNotMet 을 세운다 → BE 번호를 아는 것은 data 까지
    • presentation 은 is DeliveryConditionNotMet 하나로 판정 → serverCode 비교가 사라짐
    • 서버 문구를 싣지 않는다 → allowlist 를 묻지 않으므로 등재도 불필요
    • 리포팅 판정도 «문구 유무 + status 대역» 이라는 간접 신호에서 타입이라는 직접 신호로 바뀐다

    ApiException KDoc 이 그 간접 신호의 한계를 이미 적어 뒀습니다 — "[serverMessage] 유무만으로는 «서버가 예상하고 처리한 사용자 오류» 와 «장애» 를 가를 수 없다". 사유가 타입으로 서 있으면 그 조합을 되짚을 필요가 없습니다.

    남는 판단

    ServerRejection 을 없앨지, 사유가 특정되지 않은 거절의 자리로 남길지가 결정 지점입니다. 후자면 serverMessage 는 «FE 가 사유를 모르는 거절» 에 한해 마지막 단서로 남고, allowlist 대신 «타입으로 안 갈린 것은 폴백» 이라는 더 단순한 규칙이 섭니다.

  2. changed the title [-]refactor(receiver): 수신자 도메인 예외가 HTTP status·서버 code 를 나른다 — 노출·기록 판정을 data 로 내리고 도메인 어휘로 번역[/-] [+]수신자 도메인 예외가 HTTP status·서버 code 를 나른다 — 노출·기록 판정을 data 로 내리고 도메인 어휘로 번역[/+] on Aug 28, 2026
  3. added
    refactor동작 변경 없이 코드 구조·책임·경계를 개선하는 작업
    area:receiver수신자 관리 기능 영역
    and removed
    enhancementNew feature or request
    on Aug 28, 2026
  4. OnlyReadOneBook commented on Aug 28, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    PR #1339 가 이 이슈를 구현한다. 블로커 #934 가 열려 있어 merge-order-guard 때문에 auto-close(Closes)를 걸 수 없어 Refs 로 연결했다 — PR 머지 후 이 이슈는 수동으로 닫는다.

  5. added
    pr-open이 이슈를 구현하는 PR 이 열려 있다 — base 가 기본 브랜치가 아니어도 드러낸다
    on Aug 30, 2026
  6. OnlyReadOneBook commented on Aug 30, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    PR #1581 이 이 이슈의 잔여분을 구현한다.

    착수 전 origin/develop 실코드 대조 결과, 본문 「작업」 7개 중 6개는 PR #1339(2026-08-29 머지)가 이미 이행했다. 본문이 지목한 심볼 상당수는 지금 존재하지 않는다 — ReceiverServerRejectionException·toErrorPayload·ErrorPayload·ErrorPayloadTest·DocumentUploadUiState 의 serverCode allowlist·AfternoteFailureReporting 의 status in 400..499 && !serverMessage.isNullOrBlank() 전부 사라졌고, presentation 은 ReceiverFailure 타입만 본다.

    남아 있던 것은 「status·serverCode 해석을 feature/receiver/data 로 이전」의 미완분이다. 수신자 저장소가 서버를 부르는 11개 호출 중 4개(이메일 인증 계열)만 번역을 갖고 있었고, 나머지 7개는 ApiException(HTTP status·BE ErrorCode 번호·서버 문구)을 그대로 domain·presentation 으로 넘겼다 — verifyMasterKey·getPresignedUrl·getDeliveryVerificationStatus·getSenderMessage·getReceivedRecordBoxes·getReceivedAfterNotes·getReceivedAfternoteDetail·문서 업로드.

    PR #1581 은 메서드마다 포개던 try/catch (ApiException) 를 Result<T>.mapReceiverFailure() 로 바꿔 저장소 전량에 붙이고, endpoint 전량을 훑는 회귀 가드를 세운다. 같은 서버 사유(403 code=2009)가 페이징 경로와 단발 조회에서 다른 타입으로 나오던 차이도 함께 사라진다.

    블로커 #934 가 열려 있어 merge-order-guard 때문에 Closes 를 걸 수 없어 Refs 로 연결했다 — PR 머지 후 이 이슈는 수동으로 닫는다.

  7. OnlyReadOneBook commented on Sep 2, 2026

    @OnlyReadOneBook
    CollaboratorAuthor

    #934 와의 blocked_by 를 해제합니다. 이 관계의 근거였던 #934 4-A(수신자 예외 3종 → sealed 루트 1개)는 PR #1339 가 ReceiverFailure 로 이미 이행했습니다 — develop 에 ReceiverServerRejectionException·하위 3종 0건.

    #934 의 남은 단계(core:domain sealed 루트·UserCancelledAuthException·AfternoteFailure)는 이 이슈의 잔여분(수신자 저장소 전량 번역, PR #1581)에 필요하지 않습니다. #934 는 그대로 열려 있고 범위도 줄지 않았습니다. PR #1581 을 Closes #1053 으로 올립니다.

  8. removed
    pr-open이 이슈를 구현하는 PR 이 열려 있다 — base 가 기본 브랜치가 아니어도 드러낸다
    on Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area:receiver수신자 관리 기능 영역refactor동작 변경 없이 코드 구조·책임·경계를 개선하는 작업

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions