로컬라이제이션
라벨 카탈로그와 Intl 포맷 두 축으로 패키지 로케일을 커스터마이즈하는 방법, 그리고 패키지별 지원 현황을 정리합니다.
reopt designUpdated
두 개의 축
reopt 패키지의 로케일 커스터마이즈는 서로 다른 두 축으로 나뉩니다. 둘 중 하나만 바꾸면 화면이 반쪽만 번역됩니다.
1. 라벨 카탈로그
버튼, 빈 상태 문구, aria-label처럼 패키지가 소유한 정적 문자열. 패키지가 기본 카탈로그를 내장하고, 소비자는 messages 또는 labels prop으로 부분 덮어쓰기를 합니다. 모든 머지는 얕은 병합이라 넘기지 않은 키는 기본값이 그대로 남습니다.
2. Intl 포맷
날짜, 시각, 요일·월 이름, 숫자, 상대 시간처럼 런타임에 계산되는 값. 이건 카탈로그가 아니라 BCP-47 locale 문자열과 timeZone이 결정합니다. 카탈로그를 한국어로 바꿔도 locale을 넘기지 않으면 날짜는 브라우저 로케일을 따릅니다.
라벨 카탈로그를 쓰는 이유는 번들 때문입니다. 패키지는 번역 런타임을 싣지 않고 plain object만 export 하므로, 쓰지 않는 로케일은 트리셰이킹으로 사라집니다.
패키지별 지원 현황
현재 구현 수준입니다. 카탈로그는 ko/en 두 벌이 모두 export되고 타입 계약이 있는 상태, 부분은 카탈로그가 한쪽만 있거나 일부 문자열이 아직 하드코딩된 상태, props만은 컴포넌트 prop으로 문자열을 받지만 미리 만들어 둔 카탈로그가 없는 상태입니다.
| 패키지 | 수준 | 카탈로그 export | 주입 지점 | 기본 언어 | Intl |
|---|---|---|---|---|---|
@reopt-ai/opt-calendar | 카탈로그 | koMessages / enMessages | messages · locale · timeZone | 영어 | DateTimeFormat 전면 |
@reopt-ai/opt-editor | 카탈로그 | koMessages / enMessages | messages | 영어 | 없음 |
@reopt-ai/opt-chat | 카탈로그 | ko*Labels / en*Labels | 컴포넌트별 labels | 한국어 | RelativeTimeFormat · NumberFormat |
@reopt-ai/opt-devtool | 부분 | koDevtoolLabels / enDevtoolLabels | labels | 한국어 | 없음 |
@reopt-ai/opt-datagrid | 부분 | koLabels | labels | 영어 | 없음 (컬럼 formatter 담당) |
@reopt-ai/opt-ui | 부분 | ko/en 3쌍 (combobox · starRating · breadcrumb) | 컴포넌트별 labels | 혼재 (영어 기본 · 한국어 fallback 33개 파일) | ko-KR + KST 고정 |
@reopt-ai/opt-charts | props만 | 없음 | 컴포넌트별 labels | 영어 | 없음 |
@reopt-ai/opt-ui-primitives | props만 | 없음 | aria-label 등 소비자 제공 | — | NumberFormat(undefined) 자동 |
@reopt-ai/opt-shell | 미지원 | 없음 | 레시피가 자식 컴포넌트에 위임 | — | 없음 |
기본 언어가 패키지마다 다릅니다. opt-calendar · opt-editor · opt-datagrid는 영어가 기본이고, opt-chat · opt-devtool은 한국어가 기본입니다. 한 화면에 여러 패키지를 얹으면 아무것도 주입하지 않은 상태에서 언어가 섞이므로, 앱 진입점에서 한 번에 카탈로그를 정하세요.
opt-calendar — 카탈로그 + locale + 시간대
로케일 지원이 가장 넓은 패키지입니다. 라벨은 messages, 요일·월·시각 표기는 locale, 절대 시각 계산은 spec의 timeZone이 각각 담당합니다. 세 값은 독립이라 "한국어 라벨 + 미국 시간대" 같은 조합도 됩니다.
import { Calendar, koMessages } from "@reopt-ai/opt-calendar";
<Calendar
store={store}
// 1. 라벨 카탈로그 (부분 덮어쓰기 가능 — Partial<CalendarMessages>)
messages={koMessages}
// 2. 요일/월/시각 표기 (Intl.DateTimeFormat에 그대로 전달)
locale="ko-KR"
// 3. 주 시작 요일 — 로케일과 별개 정책
weekStartsOn={1}
/>;
// 일부 키만 바꾸기
<Calendar store={store} messages={{ today: "오늘", newEvent: "일정 추가" }} />;시간대는 spec 레벨입니다. CalendarSpec에 timeZone을 두지 않으면 Intl.DateTimeFormat().resolvedOptions().timeZone으로 브라우저 시간대를 채웁니다. 이벤트마다 timeZone을 따로 줄 수도 있습니다.
formatCount(template, count)는 {count} 보간과 {count, plural, one {…} other {…}} 한 가지 형태만 지원합니다. 러시아어처럼 복수형이 3갈래 이상인 언어는 이 헬퍼로 처리되지 않으니, 앱의 ICU 런타임에서 문자열을 만들어 넘기세요.
opt-editor — 네임스페이스 키 + t()
에디터 메시지는 blockToolbar.delete 처럼 점으로 구분된 평면 키입니다. 값 안의 {변수}는 t(key, vars)가 치환합니다.
import { Editor, koMessages, useEditorMessages } from "@reopt-ai/opt-editor";
// 주입 — Editor / EditorProvider 어느 쪽이든 messages prop
<Editor store={store} catalog={catalog} messages={koMessages} />;
// 커스텀 블록 안에서 읽기
function MyBlockToolbar() {
const t = useEditorMessages();
return <button>{t("blockToolbar.convertTo", { label: "인용" })}</button>;
}누락된 키는 영어(enMessages)로 폴백하므로 부분 번역 카탈로그를 그대로 넘겨도 화면이 비지 않습니다. Provider 밖에서 useEditorMessages()를 호출해도 영어로 동작합니다.
opt-chat — 컴포넌트별 라벨 그룹
opt-chat은 하나의 큰 카탈로그 대신 컴포넌트 단위 라벨 그룹을 export합니다(koMessageLabels, koPromptInputLabels, koConversationLabels …). 기본값은 한국어이고, 영어로 쓰려면 en*Labels를 명시적으로 넘깁니다.
import {
PromptInput,
Message,
enPromptInputLabels,
enMessageLabels,
} from "@reopt-ai/opt-chat";
<PromptInput labels={enPromptInputLabels} onSubmit={handleSubmit} />;
<Message labels={{ ...enMessageLabels, retry: "Try again" }} />;시간 표기는 Commit의 locale prop(기본 ko-KR)이 Intl.RelativeTimeFormat으로 내려갑니다. 라벨만 영어로 바꾸고 locale을 그대로 두면 "3일 전"이 남습니다.
opt-datagrid · opt-devtool
두 패키지 모두 labels prop 한 곳으로 모여 있고, 값 안의 {count}, {title}, {row}는 패키지가 보간합니다. 기본 언어는 반대입니다 — datagrid는 영어, devtool은 한국어.
import { DataGrid, koLabels } from "@reopt-ai/opt-datagrid";
import { OptDevtool, enDevtoolLabels } from "@reopt-ai/opt-devtool";
// 영어 기본 → 한국어 카탈로그 주입
<DataGrid columns={columns} rows={rows} labels={koLabels} />;
// 한국어 기본 → 영어 카탈로그 주입
<OptDevtool labels={enDevtoolLabels} />;opt-datagrid의 영어 기본값은 컴포넌트 내부 상수라 심볼로 export되지 않습니다. 영어로 쓸 땐 labels를 아예 넘기지 마세요.
opt-ui — 컴포넌트별 labels prop
opt-ui는 아직 통합 카탈로그가 없습니다. 대신 63개 컴포넌트 파일이 각각 labels prop과 XxxLabels 타입을 노출하고, 내부에서 { ...DEFAULT_LABELS, ...labels }로 병합합니다. 미리 만들어 둔 카탈로그는 Combobox · StarRating · Breadcrumb 3쌍뿐입니다.
import { Pagination, ComboboxEmpty, enComboboxLabels } from "@reopt-ai/opt-ui";
// 함수형 라벨도 있다 — 숫자 위치를 언어마다 다르게 둘 수 있게
<Pagination
currentPage={page}
totalPages={20}
onPageChange={setPage}
labels={{
previous: "이전 페이지",
next: "다음 페이지",
count: (start, end, total) => `전체 ${total}건 중 ${start}–${end}`,
}}
/>;
// 한국어 기본값을 영어 카탈로그로 교체
<ComboboxEmpty>{enComboboxLabels.empty}</ComboboxEmpty>;주의: Shell 33개 파일은 기본값이 한국어입니다 (AuthForm, ActivityFeed, TaskList, WizardForm, SettingsForm …). 영어 제품을 만든다면 이 컴포넌트들엔 labels를 반드시 넘겨야 합니다.
날짜·숫자 헬퍼(formatOptShortDate, formatOptTime, formatOptNumber)는 OPT_UI_DEFAULT_LOCALE = "ko-KR"과 KST에 고정되어 있습니다. Node와 브라우저의 ICU 빌드 차이로 SSR hydration이 어긋나지 않게 의도적으로 고정한 값이라, 다른 로케일이 필요하면 이 헬퍼를 쓰지 말고 앱에서 Intl.DateTimeFormat을 직접 호출하세요.
Next.js 앱에 한 번만 배선하기
패키지 카탈로그는 앱의 i18n 라이브러리와 무관합니다. 앱 로케일을 한 번 읽어 각 패키지 카탈로그로 매핑하는 지점을 하나만 두세요. 이 사이트의 플레이그라운드가 쓰는 방식과 같습니다.
"use client";
import { useLocale } from "next-intl";
import { koMessages as koCalendar } from "@reopt-ai/opt-calendar";
import { koMessages as koEditor } from "@reopt-ai/opt-editor";
import { koLabels as koGrid } from "@reopt-ai/opt-datagrid";
export function ProductScreen() {
const locale = useLocale(); // "ko" | "en"
const isKo = locale === "ko";
return (
<>
{/* 영어가 기본인 패키지 — 한국어일 때만 카탈로그를 넘긴다 */}
<Calendar
store={calendarStore}
messages={isKo ? koCalendar : undefined}
locale={isKo ? "ko-KR" : "en-US"}
/>
<Editor
store={editorStore}
catalog={catalog}
messages={isKo ? koEditor : undefined}
/>
<DataGrid
rows={rows}
columns={columns}
labels={isKo ? koGrid : undefined}
/>
</>
);
}undefined를 넘기면 패키지 기본값이 그대로 쓰이므로 분기마다 카탈로그를 두 벌 만들 필요가 없습니다. 반대로 기본이 한국어인 패키지(opt-chat, opt-devtool)는 영어일 때 en*Labels를 넘기는 방향으로 뒤집습니다.
ko·en 외의 로케일
패키지가 싣고 다니는 카탈로그는 ko·en 두 벌뿐이지만, 타입이 공개되어 있어 어떤 로케일이든 직접 만들 수 있습니다. 타입을 만족시키면 키 누락은 컴파일 단계에서 걸립니다.
import type { CalendarMessages } from "@reopt-ai/opt-calendar";
import type { EditorMessages } from "@reopt-ai/opt-editor";
// 전체를 채우면 Partial이 아닌 완전한 카탈로그가 된다
export const jaCalendarMessages: CalendarMessages = {
calendarLabel: "カレンダー",
today: "今日",
newEvent: "予定を追加",
// … 나머지 키도 타입이 강제한다
};
// 일부만 바꾸려면 Partial
export const jaEditorMessages: Partial<EditorMessages> = {
"blockToolbar.delete": "ブロックを削除",
};Intl 쪽은 카탈로그와 별개로 BCP-47 태그만 바꾸면 됩니다 — locale="ja-JP"를 넘기면 요일·월·시각 표기는 브라우저 ICU가 처리합니다.
아직 커스텀되지 않는 값
정직하게 남겨 둡니다. 아래 값들은 현재 prop으로 바꿀 수 없습니다.
| 위치 | 하드코딩된 값 | 회피법 |
|---|---|---|
opt-editor 슬래시 커맨드 keywords | 블록 13종의 검색 키워드가 한국어 고정 (["제목", "헤딩"] 등) | 표시 이름(blockType.*)은 번역되므로 목록은 정상. 검색어만 한국어로 매칭됨 |
opt-chat Reasoning | "생각하는 중…", "잠시 생각함", "N초 동안 생각함" | 라벨 카탈로그에 없음. 다른 문구가 필요하면 Reasoning 파트를 직접 구성 |
opt-chat Context 비용 표시 | Intl.NumberFormat("en-US") 고정 | USD 통화 표기라 의도된 고정 |
opt-ui 포맷 헬퍼 | ko-KR · KST 고정 | SSR 결정성을 위한 의도적 고정. 앱에서 Intl 직접 사용 |
| 전체 | RTL(오른쪽→왼쪽) 레이아웃 | dir="rtl" 대응 스타일이 없음. 아랍어·히브리어는 미지원 |
라벨 계약을 지키는 규칙
하드코딩이 다시 늘어나지 않도록 ESLint가 두 룰로 감시합니다. 둘 다 Shell·Surface 파일에만 적용되고 warn 레벨입니다.
| 룰 | 검사 내용 |
|---|---|
opt-ui/require-labels | 사용자에게 보이는 문자열을 가진 컴포넌트는 labels prop을 노출해야 함 |
opt-ui/require-labels-no-hardcode | JSX 텍스트에 하드코딩된 문자열 금지 — Labels 패턴으로 옮길 것 |
의도된 예외는 주석으로 사유를 남깁니다. 사유가 없으면 require-suppress-explanation에 걸립니다.
// eslint-disable-next-line opt-ui/require-labels-no-hardcode -- 브랜드명은 번역 대상이 아니다
<span>reopt design</span>