1. ChatGPT App에서 위젯에 별도의 i18n‑아키텍처가 필요한 이유
일반적인 Next.js 애플리케이션에서는 URL(/en/..., /ru/...)이나 라우터에 의존해 언어를 경로에 묶는 경우가 많습니다. ChatGPT 위젯에서는 이야기가 다릅니다. UI는 샌드박스 안의 iframe 내부에서 동작하고, URL은 여러분이 제어하지 않습니다. 언어는 ChatGPT에서 상태로 전달됩니다. 예를 들어 openai/locale이나 useOpenAiGlobal('locale') 같은 훅을 통해 오며, 주소창으로부터 오지 않습니다.
결과적으로 특이한 상황이 생깁니다. Next.js 관점에서 여러분의 위젯은 대략 /widget이라는 하나의 페이지이지만, 내부적으로는 플랫폼이 지시하는 어떤 언어로든 렌더링할 수 있어야 합니다. 언어 전환은 내비게이션이 아니라 상태로 처리해야 합니다. 이는 자동으로 “하나의 UI, 여러 사전” 아키텍처로 나아가게 하고, 문자열을 코드에 보관하는 것은 막다른 길이라는 점을 다시 한 번 강조합니다.
게다가 같은 ChatGPT 대화 안에서도 서로 다른 국가의 사용자를 위해 여러분의 App이 실행될 수 있습니다. “이 App은 러시아어만”이라고 한 번 정해놓고 잊을 수 없습니다. 위젯은 비즈니스 로직을 바꾸지 않고도 새로운 locale에 쉽게 재초기화될 수 있어야 하며 — 바로 이를 위해 깔끔한 i18n 레이어가 필요합니다.
2. 핵심 원칙: 코드에 실제 텍스트 문자열을 두지 마라
UI 로컬라이제이션의 철학을 한 줄로 요약하면 이렇습니다: React 컴포넌트에 필요한 것은 실제 텍스트가 아니라 키입니다.
다음 대신:
// 나쁨: 문자열이 컴포넌트에 하드코딩됨
<button>선물 고르기</button>
위젯은 다음처럼 되어야 합니다:
// 좋음: 컴포넌트는 키만 알고 있음
<button>{t('buttons.pick_gift')}</button>
그리고 실제 텍스트 “선물 고르기”와 “Pick a gift”는 ru.json과 en.json 사전에 저장됩니다.
“그냥 if (locale === 'ru')면 되지 않나?”라고 생각할 수 있습니다.
첫째, 확장성입니다. 세 번째 언어를 추가하는 순간 if/else는 난장판이 됩니다. 둘째, 관심사 분리입니다. 번역가나 프로덕트는 코드를 건드리지 않고 JSON 파일에서 텍스트를 바꿀 수 있고, 개발자는 UI 카피를 망가뜨릴 위험 없이 컴포넌트를 리팩터링할 수 있습니다. 셋째, 일관성입니다. 텍스트의 단일 진실 공급원을 두면, 어떤 버튼에는 “구매”, 다른 버튼에는 “결제”처럼 작성자 기분에 따라 표현이 뒤섞이는 일을 막을 수 있습니다.
ChatGPT App 세계에서는 특히 유용합니다. 때로는 LLM으로 번역을 생성해 사전에 추가하고 싶을 수 있습니다. 모든 텍스트를 JSON 파일에 보관하는 것이 컴포넌트 곳곳에 흩뿌리는 것보다 훨씬 편합니다.
3. GiftGenius 위젯을 위한 사전 구조화
학습용 애플리케이션 GiftGenius — 선물 추천 위젯을 계속 발전시켜 봅시다. 최소 두 개 언어가 필요합니다: ru와 en. 기본 구조를 만듭니다:
/app
/widget
GiftWidget.tsx
/locales
/en
widget.json
/ru
widget.json
locales/en/widget.json의 가장 단순한 내용:
{
"title": "GiftGenius",
"forms": {
"recipient": {
"label": "Recipient",
"placeholder": "Who is this gift for?"
},
"budget": {
"label": "Budget",
"placeholder": "For example, 50"
}
},
"buttons": {
"pick_gift": "Find gifts",
"try_again": "Try again"
},
"errors": {
"no_gifts": "No gifts found for your criteria."
}
}
그리고 이에 대응하는 locales/ru/widget.json:
{
"title": "GiftGenius",
"forms": {
"recipient": {
"label": "받는 사람",
"placeholder": "누구를 위해 선물을 찾을까요?"
},
"budget": {
"label": "예산",
"placeholder": "예: 50"
}
},
"buttons": {
"pick_gift": "선물 찾기",
"try_again": "다시 시도"
},
"errors": {
"no_gifts": "선택하신 조건에 맞는 선물을 찾지 못했습니다."
}
}
두 언어 모두에서 키 구조가 동일하다는 점에 유의하세요. 이는 매우 중요합니다. 컴포넌트는 문자열이 아니라 키에 의존합니다. 한 언어에서 errors.no_gifts를 추가하는 것을 잊으면, 반쯤 번역된 UI가 아니라 이해하기 쉬운 오류를 얻게 됩니다.
실제 프로젝트에서는 widget, checkout, errors 등 영역별로 사전을 나누는 것이 합리적입니다. 학습용 앱에서는 복잡도를 낮추기 위해 언어당 하나의 파일이면 충분합니다.
4. Apps SDK 위젯에서 locale을 어디서 가져오는가
일반적인 브라우저 앱에서는 navigator.language를 살펴볼 수 있습니다. ChatGPT 위젯에서는 그렇게 할 수도 있지만 권장하지 않습니다. ChatGPT가 이미 사용자의 선호 로캘을 계산해 Apps SDK 컨텍스트로 전달합니다. 이는 window.openai의 locale 필드일 수 있으며, 직접 읽거나 useOpenAiGlobal('locale') 같은 편리한 훅으로 읽을 수 있습니다.
일반적인 Apps SDK 스타터에서는 ChatGPT로부터 전달된 전역 데이터를 이용할 수 있는 위젯의 루트 컴포넌트가 있습니다. 예를 들어:
"use client";
import { useOpenAiGlobal } from "openai-apps-sdk/react";
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
// ...
}
위 예시는 설명을 위한 것이며, 정확한 API는 SDK 버전에 따라 다를 수 있습니다. 하지만 핵심 아이디어는 같습니다. locale은 브라우저가 아니라 ChatGPT에서 오는 외부의 진실입니다.
리전(userLocation)도 _meta["openai/userLocation"]를 통해 전달됩니다. 이는 나중에 가격을 포맷팅하고 통화를 고려할 때 필요합니다. 텍스트에는 locale만 있으면 충분합니다. 보통 BCP‑47 형식(en, en-US, ru-RU 등)으로 전달됩니다.
5. 최소 i18n 레이어 작성: 컨텍스트 + useT 훅
위젯이 자급자족하도록 하고 react-i18next 교과서가 되지 않도록, 가벼운 자체 i18n 레이어를 구현해 봅시다. 작은 ChatGPT 위젯에는 이 정도면 충분하고, 원리는 인기 있는 라이브러리와 동일합니다.
먼저 타입을 정의하고 app/widget/i18n.tsx에 컨텍스트를 만듭니다:
"use client";
import React, { createContext, useContext } from "react";
type Messages = Record<string, any>;
type I18nContextValue = {
locale: string;
messages: Messages;
};
const I18nContext = createContext<I18nContextValue | null>(null);
이제 locale과 사전을 받는 프로바이더를 만듭니다:
type Props = {
locale: string;
messages: Messages;
children: React.ReactNode;
};
export function I18nProvider({ locale, messages, children }: Props) {
return (
<I18nContext.Provider value={{ locale, messages }}>
{children}
</I18nContext.Provider>
);
}
핵심은 키로부터 문자열을 꺼내는 useT 훅입니다:
export function useT() {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useT must be used within I18nProvider");
function t(path: string): string {
return path.split(".").reduce((obj: any, part) => obj?.[part], ctx.messages)
?? path;
}
return { t, locale: ctx.locale };
}
forms.recipient.label 같은 중첩 키를 지원하며, 번역이 없으면 키 자체를 반환합니다. 빈 문자열을 조용히 보여주는 것보다 훨씬 낫습니다.
6. i18n 프로바이더를 위젯 루트 컴포넌트에 통합
앞서 useOpenAiGlobal에서 locale만 읽던 GiftWidgetRoot를 봤습니다. 이제 이 루트 컴포넌트에서 I18nProvider를 사용하고 사전 로딩을 추가해 봅시다. 이전에는 대략 다음과 같았다고 가정합니다:
"use client";
export function GiftWidgetRoot() {
return (
<div>
<h1>GiftGenius</h1>
{/* 폼과 결과 */}
</div>
);
}
사전 로딩과 프로바이더를 추가합니다. 단순화를 위해 locale에 따라 동기 require/import를 사용하겠습니다. 다만 Next.js 16에서는 사전이 크다면(dynamic import를 통한) 비동기 임포트도 사용할 수 있습니다.
"use client";
import { useOpenAiGlobal } from "openai-apps-sdk/react";
import { I18nProvider } from "./i18n";
import { GiftWidget } from "./GiftWidget";
function loadMessages(locale: string) {
if (locale.startsWith("ru")) {
return require("/locales/ru/widget.json");
}
return require("/locales/en/widget.json");
}
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
const messages = loadMessages(locale);
return (
<I18nProvider locale={locale} messages={messages}>
<GiftWidget />
</I18nProvider>
);
}
이제 GiftWidget 컴포넌트는 언어에 대해 전혀 생각하지 않고, t 함수만 있다는 사실만 알면 됩니다:
"use client";
import { useT } from "./i18n";
export function GiftWidget() {
const { t } = useT();
return (
<div>
<h1>{t("title")}</h1>
<label>{t("forms.recipient.label")}</label>
{/* 나머지 UI */}
</div>
);
}
내일 ChatGPT가 locale = "de-DE"로 위젯을 생성하더라도, locales/de/widget.json과 loadMessages의 한 줄만 추가하면 나머지 코드는 건드릴 필요가 없습니다. 바로 이것이 우리가 모든 작업을 이렇게 설계한 이유입니다.
7. 로컬라이즈되는 포맷: 숫자, 날짜, 통화
이미 텍스트는 사전으로 분리했고 위젯을 I18nProvider로 감쌌습니다. 하지만 텍스트는 UX의 절반일 뿐입니다. 미국 사용자는 12/31/2025 형식을 기대하고, 독일 사용자는 31.12.2025 형식을 기대합니다. 숫자와 통화도 마찬가지입니다. 러시아 사용자에게 “1,234.56 USD” 같은 가격을 보여 주는 것은 여러분의 “스마트” 어시스턴트가 사실 세심하지 않다는 신호입니다.
다행히 브라우저(그리고 ChatGPT 샌드박스)에는 표준 Intl API가 있습니다. 현재 locale을 사용하는 유틸을 i18n.tsx에 추가해 봅시다:
export function useFormatters() {
const { locale } = useT();
const formatCurrency = (value: number, currency: string) =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
maximumFractionDigits: 2,
}).format(value);
const formatDate = (date: Date) =>
new Intl.DateTimeFormat(locale).format(date);
return { formatCurrency, formatDate };
}
이제 예산이나 선물 가격을 표시하는 컴포넌트(예를 들어 currency가 지정된 상태로 MCP 서버에서 이미 받아온다고 가정)에서 다음처럼 사용할 수 있습니다:
import { useFormatters } from "./i18n";
type GiftCardProps = {
name: string;
price: number;
currency: string;
};
export function GiftCard({ name, price, currency }: GiftCardProps) {
const { formatCurrency } = useFormatters();
return (
<div>
<div>{name}</div>
<div>{formatCurrency(price, currency)}</div>
</div>
);
}
포맷팅을 더 “똑똑하게” 만들고 싶다면(예: userLocation 기반으로 통화를 선택), locale과 리전을 조합하면 됩니다. 이는 MCP‑Gateway에서 이미 논의한 아키텍처 방향과 일치합니다. locale은 텍스트 언어에, userLocation은 비즈니스 규칙과 통화에 영향을 줍니다.
8. 언어 변경에 대한 반응: ChatGPT가 실행 중에 locale을 바꾸면?
일반 웹에서는 사용자가 스스로 “EN / RU”를 눌러 언제 언어를 바꿔야 할지 명확합니다. ChatGPT App에서는 모델이 이론적으로 사용자에게 더 편한 언어를 선택할 수도 있고(혹은 사용자가 설정에서 인터페이스 언어를 바꿀 수도 있고), 그러면 openai/locale이 변경됩니다.
SDK가 훅이나 이벤트로 반응형 신호를 준다면, 코드 패턴은 다음과 같습니다:
export function GiftWidgetRoot() {
const locale = useOpenAiGlobal("locale") ?? "en";
const messages = useMemo(() => loadMessages(locale), [locale]);
return (
<I18nProvider locale={locale} messages={messages}>
<GiftWidget />
</I18nProvider>
);
}
여기서 loadMessages는 locale이 바뀔 때마다 다시 실행되며, 전체 UI는 새로운 번역으로 자동 리렌더링됩니다. 실제 시나리오 대부분에서는 세션 동안 로캘이 안정적이지만, 올바른 반응형 모델을 깔아 두는 것은 여전히 유용합니다.
9. 복잡한 문장: 플레이스홀더와 복수형
locale의 반응성은 해결했습니다. 다음 질문은 동적인 텍스트 조각 — 수량, 이름 등 — 을 어떻게 처리할까입니다. 선물 앱에서는 “마샤에게 선물 3개를 찾았습니다” 같은 문장이 될 수 있습니다.
이런 문장을 처리하는 가장 간단한 방법은 t()에서 플레이스홀더를 지원하고 값을 즉시 치환하는 것입니다. 이를 위해 useT가 두 번째 인자로 값 객체를 받도록 수정합니다:
type Values = Record<string, string | number>;
export function useT() {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error("useT must be used within I18nProvider");
function t(path: string, values?: Values): string {
let text =
path.split(".").reduce((obj: any, part) => obj?.[part], ctx.messages) ??
path;
if (values) {
Object.entries(values).forEach(([key, value]) => {
text = text.replace(`{{${key}}}`, String(value));
});
}
return text;
}
return { t, locale: ctx.locale };
}
이제 widget.json에 문자열을 추가합니다:
"results": {
"summary": "Found {{count}} gifts for {{name}}"
}
그리고 사용합니다:
const { t } = useT();
<p>{t("results.summary", { count, name: recipientName })}</p>
복수형은 여러 방법이 있습니다. (one, few, many) 같은 여러 키를 만들고 직접 선택하거나, 복수 규칙을 제대로 지원하는 react-intl/i18next 같은 라이브러리를 도입할 수 있습니다. 학습용 위젯에서는 수 범위에 따른 수동 선택(예: if count === 1, if count < 5 등)도 충분히 합리적입니다.
10. Apps SDK용 Next.js 템플릿 구조에서 i18n은 어디에 둘까
Next.js 16과 공식 Apps SDK 템플릿 관점에서, 위젯은 보통 app/의 특화된 엔트리포인트(예: app/widget/page.tsx 또는 ChatGPT 내부에서 Apps SDK가 렌더링하는 별도 컴포넌트)입니다.
전형적인 패턴:
// app/widget/page.tsx
"use client";
import { GiftWidgetRoot } from "./GiftWidgetRoot";
export default function WidgetPage() {
return <GiftWidgetRoot />;
}
i18n 레이어는 전적으로 클라이언트 영역에 존재합니다 — 지금까지 작성한 모든 것은 클라이언트 컴포넌트입니다. ChatGPT 환경에서는 어차피 iframe 내부에서 클라이언트 렌더링이 이루어지므로, 고전적인 SSR‑i18n 패턴(서버에서 로컬라이즈된 HTML 렌더링)은 잠시 잊어도 됩니다. 이는 삶을 크게 단순화합니다. navigator.language 대신 openai/locale을 사용할 뿐 일반 SPA와 동일하게 작업하면 됩니다.
하나의 App에 여러 위젯(예: 메인 마스터와 “작은 인라인 위젯”)이 있고 번역을 공유해야 한다면, I18nProvider를 별도 모듈로 분리해 재사용하면 됩니다.
11. 현지화 미니 테스트
시스템에 i18n 레이어가 도입되는 즉시 별도 테스트를 시작하는 것이 좋습니다 — 그렇지 않으면 키 오타 하나가 “반쯤 번역된 UI”를 낳습니다. 아키텍처를 만들었으니 테스트도 해야겠죠.
첫째, loadMessages와 useT에 대한 간단한 단위 테스트(React Testing Library 또는 React 없이 t 함수만 테스트해도 됨)를 작성하는 것이 의미 있습니다. 이런 테스트는 키 오타를 잡아내고, 여러분이나 번역가가 실수로 사전의 필요한 가지를 삭제했을 때 도움을 줍니다.
둘째, ChatGPT 밖에서 위젯을 로컬로 실행하는 모드를 마련해, UI 버튼이나 쿼리 파라미터로 locale을 강제로 지정할 수 있게 하면 편리합니다. 이는 여러분과 QA 모두에게 유용합니다. 독일어 번역을 확인하려고 Dev Mode와 ChatGPT 전체를 띄울 필요는 없으니까요. 이런 기본 테스트와 다양한 locale에 대한 로컬 실행이 있으면, UI와 텍스트를 훨씬 더 안심하고 발전시킬 수 있고, 다음 단계로 도구 설명의 로컬라이제이션으로 넘어갈 수 있습니다.
이 모든 것이 모델 동작과 어떻게 연결되는가
도구 설명의 깊은 로컬라이제이션은 다음 강의에서 자세히 다루겠지만, 지금부터도 중요한 연결고리를 볼 수 있습니다. 위젯과 도구는 사용자와 같은 언어로 “말해야” 합니다. 이미 여러분은 openai/locale에 맞춰 UI를 구성하고 있습니다. MCP 서버도 같은 신호를 바탕으로 올바른 카탈로그와 텍스트를 선택합니다. 당연히 suggest_gifts의 설명과 recipient, budget 같은 필드도 사용자 언어로 모델에게 설명하는 것이 합리적입니다 — 이는 이상한 tool‑call과 잘못된 인자를 줄여 줍니다.
즉, 위젯의 i18n 아키텍처는 단순한 화장질이 아닙니다. UI 레이어, MCP 레이어, 그리고 모델이 같은 로캘 컨텍스트를 공유하는 전체 시스템의 첫 번째 벽돌입니다.
12. 위젯 현지화에서 흔한 실수
오류 №1: JSX에 문자열을 그대로 하드코딩.
아주 흔한 이야기입니다. 한 언어로 빠르게 프로토타이핑을 시작했다가, 갑자기 “영어도 필요” 요구가 옵니다. 그 결과 UI는 러시아어 문자열로 가득 차고, 영어를 추가하려는 시도가 프로젝트 전역 검색/치환으로 변합니다. 가능한 한 빨리 사전과 t() 함수를 도입할수록 나중에 문제가 줄어듭니다.
오류 №2: 여기저기 if(locale === 'ru').
이런 조건은 “빠른 해결책”처럼 보이지만, 세 번째 언어가 등장하거나 ru-RU, ru, ru-UA 같은 변형이 생기는 순간 바로 깨집니다. locale.split('-')[0] 같은 정규화를 포함한 loadMessages(locale)를 한 번 제대로 작성하고 더는 신경 쓰지 않는 편이, 검사를 코드 전체에 바르는 것보다 훨씬 낫습니다.
오류 №3: 비즈니스 로직과 텍스트를 섞음.
때로 개발자는 컴포넌트 내부에서 비즈니스 분기와 텍스트 선택을 동시에 처리하는 복잡한 조건을 만듭니다. 예를 들어 “선물이 없으면 이 문구, 예산이 작으면 다른 문구” 같은 방식입니다. 그 결과 카피를 바꾸기 어렵고, 로직은 퍼져나가며, 번역이 TypeScript로 침투합니다. 컴포넌트는 errors.no_gifts, errors.budget_too_low 같은 키만 반환하고, 텍스트는 별도로 편집되도록 하는 편이 훨씬 좋습니다.
오류 №4: 로캘에 맞는 날짜/통화 포맷팅을 하지 않음.
독일 사용자에게 $1,234.56 대신 1.234,56 $를 보여 주지 않는 것은 버그가 아니라 UX 안티 패턴입니다. 하지만 사용자는 이를 “이 서비스는 나를 위한 것이 아니다”라고 느낍니다. 한 지역에 익숙하면 Intl.NumberFormat과 Intl.DateTimeFormat을 잊기 쉽습니다. 따라서 포맷터를 useFormatters() 같은 훅으로 분리하고, 수동 문자열 연결 대신 항상 이를 사용하도록 하는 것이 유익합니다.
오류 №5: locale 변경 가능성을 고려하지 않음.
일부 개발자는 마운트 시 locale을 한 번 읽고 이후에는 상수로 취급합니다. 대부분의 경우 동작하겠지만, ChatGPT나 플랫폼이 로캘을 바꾸면(예: 사용자가 인터페이스 언어를 전환) 위젯은 이전 언어에 머무르게 됩니다. locale을 반응형 상태의 일부로 보고, useMemo/useEffect 등에 연결하는 편이 바람직합니다.
오류 №6: 언어마다 다른 사전 구조를 가짐.
한 언어의 번역은 A가, 다른 언어의 번역은 B가 맡으면서 widget.en.json과 widget.ru.json의 구조가 갈라지는 경우가 있습니다. 한쪽에는 forms.budget.placeholder가 있고, 다른 쪽에는 forms.budget.label만 있는 식입니다. 런타임에서는 undefined와 이상한 오류로 이어집니다. 항상 하나의 “정준” 파일(보통 영어)을 두고, 다른 언어는 그 구조를 상속하도록 하세요. 새로운 사전을 생성할 때 키 일치 여부를 검사하는 스크립트를 작성하는 것도 좋습니다.
오류 №7: 처음부터 무거운 i18n 프레임워크로 모든 것을 해결하려 함.
react-i18next나 next-intl처럼 인기 있는 솔루션은 강력하고 유용하지만, 작은 ChatGPT 위젯에는 과할 수 있습니다. 종종 가벼운 자체 레이어(I18nProvider, useT, JSON 사전)로 시작하고, 애플리케이션이 충분히 성장해 복잡한 복수형, ICU 포맷 등이 정말 필요해질 때 본격적인 라이브러리로 마이그레이션하는 편이 낫습니다.
GO TO FULL VERSION