실패 메시지 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 를 남긴다 |
| R9 | state 형태가 여러 갈래 | 형태별 표준(아래 표)을 따른다 |
| 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 타입이 아니라 사용자 작업 기준으로 나뉘어 있다
- 매핑이
switchexpression 이라 코드 추가 시 컴파일 에러로 누락이 잡힌다 details필드에 "화면에 띄우지 말 것" 주석이 있다- R2 회귀 — 문자열 분기를 지웠다면 그게 서버 문자열이 아닌지 확인했다
- dead 채널이면 코드를 부여하지 않고 삭제했다 (R7)
관련#
- i18n-conventions —
context.i10n사용 규칙 - slang-i18n — 키 구조와 생성