문서 목차
이 페이지에서목차

failure-message-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)

관련#

검색어를 입력하면 문서를 찾습니다.

↑ ↓ 이동 · Enter 열기Esc 닫기