LogoSkills

failure-message-i18n

도메인·데이터 계층의 실패를 번역 가능한 사용자 문구로 전달하는 규칙 — 에러 코드 enum(사용자가 하려던 일 기준) + details(로그 전용) + 프레젠테이션 switch 매핑, failure.toString() 을 화면에 띄우지 않기, 서버 문자열 분기는 유지하고 내가 만든 문자열 분기만 타입 판별로 교체(R1~R10 정책). Failure ...

실패 메시지 i18n — 에러 코드 + 프레젠테이션 매핑#

한마디로#

화면 글자는 다국어로 바꿔 놨는데, 에러가 났을 때 뜨는 문구만 한국어로 남아 있는 일이 흔합니다. 에러 문구가 화면이 아니라 앱 안쪽 깊은 곳에서 만들어지기 때문입니다. 그 안쪽은 번역기를 쓸 수 없는 자리라, 문자열을 바꾸는 게 아니라 "무엇이 실패했는지를 번호로 올려 보내고 화면에서 번역" 하도록 구조를 바꿔야 합니다.

무엇을·언제#

  • 무엇을 해 주나요: 실패를 코드(번호) 로 표현해 화면까지 올리고, 화면이 그 코드를 번역하게 만드는 3단계 전환 방법과, 전수 이관 시 지켜야 할 정책 10가지를 제공합니다.
  • 언제 쓰이나요: 토스트·다이얼로그에 한국어가 박혀 나올 때, SomethingFailure: ... 같은 클래스 이름이 사용자에게 보일 때, 다국어 지원을 완성할 때.
  • 좋은 점: 문자열 일괄 치환으로 접근하면 비즈니스 로직이 조용히 깨집니다(문자열로 분기하던 코드가 있음). 이 규칙은 그 지뢰를 먼저 표시합니다.

핵심 용어#

용어쉬운 설명
Failure"무엇이 실패했다"를 나타내는 객체
도메인·데이터 계층화면이 아닌 앱 안쪽 로직. 번역기(context)를 쓸 수 없다
에러 코드실패 종류를 나타내는 열거값
details원인 진단용 원문. 화면에 띄우지 않는다
exhaustive switch모든 경우를 다뤘는지 컴파일러가 검사하는 분기

문제#

Failure/Exception 의 message 에 한국어가 하드코딩되고, 프레젠테이션이 그 값을 그대로 토스트로 띄우는 패턴입니다.

// ❌ 도메인 — 한국어 하드코딩
const ConsoleClassChatInvalidFileFailure('파일 데이터가 없습니다.');

// ❌ BLoC — Failure.toString() 을 사용자 문구로 사용
emit(ConsoleClassChatError(message: failure.toString()));

// ❌ 프레젠테이션 — 그대로 화면에
showAppToast(context, .error, state.message);

화면 문자열을 context.i10n 으로 전환해도 이 경로는 남아 다국어가 적용되지 않습니다. failure.toString() 은 더 나쁩니다 — 번역이 안 될 뿐 아니라 클래스 이름이 사용자에게 그대로 노출됩니다.

왜 단순 치환이 안 되는가#

도메인·데이터 계층에는 BuildContext 가 없습니다. context.i10n 을 그 계층으로 내리는 것은 Clean Architecture 위반입니다. 그래서 문자열 치환이 아니라 에러 표현 방식을 바꿉니다.

표준 — 타입/코드 기반 Failure#

Failure 를 처음부터 타입 기반으로 두고(UserNotFoundFailure, InvalidEmailFormatFailure …), 프레젠테이션에서 분기해 번역합니다.

// ✅ 선례
if (failure is InvalidEmailFormatFailure) {
  errorMessage = context.i10n.login.error_invalid_email;
} else if (failure is UserNotFoundFailure) {
  errorMessage = context.i10n.login.error_user_not_found;
}

새 설계가 아니라 이 패턴의 확산입니다. feature 모듈이 공용 규칙을 따르지 않고 자체 문자열 Failure 를 만든 것이 문제입니다.

적용 방법 (3단계)#

1. 에러 코드 enum — "사용자가 하려던 일" 기준#

/// lib/src/presentation/blocs/{feature}/{feature}_error_code.dart
enum ConsoleClassChatErrorCode {
  loadListFailed,
  loadDetailFailed,
  chatRoomNotFound,
  createFailed,
}

코드를 나누는 기준은 "어떤 Failure 였는가"가 아니라 "사용자가 하려던 일이 무엇이었는가" 입니다. 같은 NetworkFailure 라도 목록 조회 중이면 "목록을 불러오지 못했다", 생성 중이면 "만들지 못했다"가 사용자에게 유용합니다. Failure 타입을 1:1 로 옮기면 사용자에게 의미 없는 분류가 됩니다.

2. state 는 코드 + details 를 나른다#

final class ConsoleClassChatError extends ConsoleClassChatState {
  const ConsoleClassChatError({required this.code, this.details});

  final ConsoleClassChatErrorCode code;

  /// 원인 세부 (로그·디버그 전용).
  /// ⚠️ 화면에 그대로 띄우지 말 것.
  final String? details;
}
// ✅ BLoC 은 toString() 을 details 로 강등한다
emit(ConsoleClassChatError(
  code: .loadListFailed,
  details: failure.toString(),
));

3. 프레젠테이션이 번역한다#

abstract final class ConsoleClassChatErrorMessages {
  static String of(BuildContext context, ConsoleClassChatErrorCode code) {
    final i10n = context.i10n.console.class_chat.error;

    return switch (code) {
      .loadListFailed => i10n.load_list_failed,
      .chatRoomNotFound => i10n.chat_room_not_found,
      // ... 모든 코드 (switch 가 exhaustive 라 누락이 컴파일 에러로 잡힌다)
    };
  }
}

// 소비
showAppToast(context, .error, ConsoleClassChatErrorMessages.of(context, state.code));

i18n 키는 {scope}.error.{snake_case} 하위에 모읍니다(기본 로케일만 추가하면 나머지는 fallback_strategy: base_locale 로 자동 폴백).

도메인 계층의 한국어는 남겨도 되는가#

됩니다 — details 로만 흐르고 화면에 닿지 않는다면. 개발자 로그는 모국어가 오히려 읽기 쉽습니다. 다만 그 문자열이 화면 경로로 새지 않는지 아래 검출로 확인하세요.

정책 R1~R10 — 전수 이관 시 리뷰 근거#

#위험규칙
R1 서버가 내려준 문구가 그대로 화면 문구 클라이언트가 그 문자열을 코드로 흡수하지 않는다. 기본 코드 문구를 띄우고 서버 원문은 details 로만 넘긴다. 구체성이 후퇴하므로 화면별 허용 여부는 제품 확인 필요
R2 도메인 문자열이 비즈니스 로직 분기 조건 서버 응답 문자열 분기는 유지 — 지우면 기능이 조용히 사라진다 ② 클라이언트가 자기가 만든 문자열 분기는 같은 커밋에서 타입 판별로 교체 ③ PR 체크리스트에 회귀 항목 고정
R3 동적 값이 문구에 박힘 ('$page 페이지 로드 실패') 사용자에게 필요한 값이면 번역 파라미터 ( {page} ), 진단용이면 details 로 강등
R4 i18n 스코프 신설 + 이름 충돌 신설 스코프는 상위 네임스페이스 하나로 통일한다. 기존 키의 이동은 별건으로 분리
R5 기존 flat error 키와 두 벌 생성 기존 flat 키를 {scope}.error.*이관하고 flat 키는 삭제 . 서버 원문을 끼워 넣던 {error} 파라미터는 제거(R1)
R6 Failure.toString() 이 의도된 문구 공급자 toString() 은 그대로 둔다 — 로그용이다. 화면 경로만 전환하므로 기존 회귀 테스트를 고칠 필요가 없다
R7 dead 에러 채널 (emit 된 적 없는 errorMessage 등) 기본은 삭제 . 코드를 부여하면 "여태 안 보이던 에러가 갑자기 보이는" 동작 변경 이며 그건 제품 결정이다 — 필요하면 후속 이슈로 분리
R8 공용 타입 shadowing shadowing 제거는 이 작업 범위 밖. hide 로 우회하고 TODO 를 남긴다
R9state 형태가 여러 갈래형태별 표준(아래 표)을 따른다
R10 화면 하드코딩 문구와 중복 같은 실패를 가리키는 문구는 함께 이관한다. 무관한 화면 라벨은 범위 밖

R2 판별 기준 — 서버 문자열인가, 내 문자열인가#

// ✅ 유지 — 서버/SDK 가 준 문자열. 지우면 인증 실패를 못 알아본다.
} on Exception catch (error) {
  if (error.toString().contains('Authentication')) return right(false);

// ❌ 교체 — 내가 만든 Failure 를 내가 문자열로 다시 알아보는 중.
//    그 문구를 번역/수정하는 순간 컴파일 에러 없이 항상 false 가 된다.
bool _isAuthFailure(Failure f) => f.error.contains('로그인이 필요');

// ✅ 교체 결과 — 타입으로 판별
bool _isAuthFailure(Failure f) => f is LoginRequiredFailure;

R9 state 형태별 표준#

형태전환 방법
① Failure 를 그대로 보유 state.failure 프레젠테이션에서 타입 switch 로 번역
message: String XxxError(message:) code + details 로 교체 (위 3단계)
③ 평면 다중 필드 errorMessage/hasError 병렬 errorCode 로 통합 후 ②와 동일
④ 위젯 로컬 상태 useState<String?>(null) 위젯 안에서 context.i10n 을 직접 쓸 수 있으므로 코드 도입 불필요 — 문자열만 i18n 화

스코프 소유권을 먼저 정하라#

여러 작업자가 같은 실패를 각자 다른 스코프에 만들면 키가 두 벌 생기고 되돌리기 어렵습니다. 착수 전에 "어느 스코프를 누가 소유하는가" 표를 만들어 SSOT 로 두고, 자기 소유가 아닌 스코프에 키를 만들지 마세요.

⚠️ 키 생성기의 key_case 설정을 확인하세요. snake 라면 기존 camelCase 스코프(bookList)가 Dart 에서 book_list 로 나오므로, book_list 를 새로 만들면 두 벌이 됩니다.

금지: common_error.server_error({error}) 처럼 서버 원문을 끼워 넣는 파라미터를 신규 코드가 참조하는 것 (R1 위반).

타입 추가 시 하류 호환#

공용 Failure 는 수십 개 패키지가 소비하므로, 새 타입을 기존 타입의 하위 클래스로 만들면 is UnexpectedFailure / .error 소비 경로를 하나도 바꾸지 않고 타입 판별 수단만 추가할 수 있습니다.

class LoginRequiredFailure extends UnexpectedFailure {
  const LoginRequiredFailure({required this.featureName})
    : super('$featureName 기능을 사용하려면 로그인이 필요합니다.');  // R6: 로그 문자열 유지
  final String featureName;
}

검출#

# state 메시지를 그대로 띄우는 곳
grep -rn  " showAppToast(.*\.message "   --include= " *.dart "   | grep -v test

# Failure.toString() 을 사용자 문구로 쓰는 곳
grep -rn  " message: failure.toString() "   --include= " *.dart "

⚠️ 단일행 grep 은 멀티라인 생성자를 놓친다#

인자가 다음 줄로 넘어간 생성자를 전부 놓칩니다(전수 조사에서 반복 확인). 집계에는 rg -U --multiline 을 쓰세요.

# ❌ 놓침 — 인자가 줄바꿈되면 매칭 안 됨
grep -rnE  " (Failure|Exception)\s*(\.\w+\s*)?\(\s*(message:\s*)?[ ' \ " ][^ ' \ " ]*[가-힣] "   --include= " *.dart " 

 # ✅ 보정 — 여는 괄호 다음의 줄바꿈을 허용
rg -U --multiline -n  " (Failure|Exception)\s*(\.\w+\s*)?\(\s*(\n\s*)?(message:\s*)?[ ' \ " ][^ ' \ " ]*[-] "   \
   < 패키지 > /lib/src --glob  ' !*_test.dart '

실측 규모 감각: 중형 모노레포 1곳에서 Failure/Exception 생성자 한국어 668건 / 185파일 / 34패키지, 메시지 상수 파일 24개, state.message 토스트 소비 20곳이었습니다. 전수 이관은 Story 단위로 쪼개세요.

체크리스트#

  • state.message 를 토스트/Text 에 그대로 넘기는 곳이 없다
  • failure.toString() 이 사용자 문구로 쓰이는 곳이 없다
  • 에러 코드가 Failure 타입이 아니라 사용자 작업 기준으로 나뉘어 있다
  • 매핑이 switch expression 이라 코드 추가 시 컴파일 에러로 누락이 잡힌다
  • details 필드에 "화면에 띄우지 말 것" 주석이 있다
  • R2 회귀 — 문자열 분기를 지웠다면 그게 서버 문자열이 아닌지 확인했다
  • dead 채널이면 코드를 부여하지 않고 삭제했다 (R7)

관련#