こんにちは。
InflabのRallitチームでフロントエンド開発を担当している、GOHSUE、Doni、Lucyです。
2024年初め、年間プランニングの場で、CTOのHyangLoから予想外の発表がありました。
今年、Inflearnのグローバルサービスをリリースする予定です。Rallitチームにも参加してもらいます。
えっ、私たちもですか?
こうして、Rallitを含むInflabの開発チームは、レガシーコードの整理から国際化作業まで、Inflearnのグローバルサービスリリースに向けた新たな挑戦を始めることになりました。
プロローグ
本記事では、i18nをサービスに適用する流れ全体ではなく、既存サービスを多言語化する際に、i18nを効率よく適用する方法に絞ってお話しします。
具体的な適用の流れは、続編の記事で取り上げる予定です。
i18nは
internationalizationの略語です。「i」と「n」の間に18文字あるため、i18nと呼ばれます。
また、ソフトウェアやWebサイトをさまざまな言語・地域に対応できるよう設計・開発するプロセスのことも指します。
Next.jsの多言語対応
本題に入る前に、Next.jsベースのプロジェクトで多言語対応によく使われる next-i18next ライブラリについて見ていきます。
next-i18nextは、Next.jsアプリケーションでi18n機能を実装するためのライブラリです。
主な特徴
- useTranslationフックで翻訳関数(t)を提供
- 言語ファイルをJSONで管理
- サーバーサイドレンダリングに対応
- 動的な翻訳と名前空間に対応
簡単な使用例は次のとおりです。
// public/en/sample.json
{
"안녕하세요": "hi"
}
import { useTranslation } from 'next-i18next';
const Component = () => {
const { t } = useTranslation();
return (
<p>{t('안녕하세요')}</p> // hiと表示される
);
}
それでは、このライブラリを活用して、既存サービスにどのように効率よく国際化を適用したのかを見ていきましょう。
言語リソースのキー
既存のInflearn担当チームがレガシーコードのReact移行を進めていたため、Rallitチームは、すでにReactに移行済みのコードへのi18n適用を最初に担当することになりました。他のチームとも一緒に作業するため、まずは効率的な規約を決めておく必要がありました。中でも特に重要だった初期の決定事項が、次の問いです。
言語リソース(翻訳用のJSONファイル)のキーをどのように定義するか?
答えを探るため、さまざまな開発者と交流してヒントを集めました。中でも印象に残っている意見があります。
次に国際化をやるなら、絶対にキーは韓国語にします。
多くの開発者がこの意見に同意しており、理由を尋ねると、次のような答えが共通して返ってきました。
- 英語のキーを使うと、対応する翻訳キーを探すのが面倒
- それによって開発生産性が下がる
こうした知見をもとに、Rallitチームで言語リソースのキー定義に関する規約案を3つまとめました。それぞれのメリット・デメリットを分析したうえで、PMとPDに提案しました。
規約の種類
サービスの国際化に向けた言語リソースのキー定義方式を、3つに分類して分析しました。
1. 韓国語キー方式
韓国語のテキストをそのままキーとして使う方式です。
{
"MY 카테고리": "MY 카테고리",
"나만의 강의 카테고리를 구성해보세요!": "나만의 강의 카테고리를 구성해보세요!"
}
// 実装例
<Text>{t("MY 카테고리")}</Text> // MY 카테고리
<Text>{t("나만의 강의 카테고리를 구성해보세요!")}</Text> // 나만의 강의 카테고리를 구성해보세요!
メリット
- キーを定義する手間を省ける
- 素早く多言語化できる
- コードが直感的で読みやすい
デメリット
- テキストが重複する可能性がある
- 元のテキストを変更するとキーも修正が必要
- 言語間で1対1に対応させにくいケースがある(改行など)
2. 役割ベースのキー方式
{ページ | ドメイン}.{セクション}.{役割}.{サブ} という形式の構造化された韓国語キー
{
"계정정보.마이카테고리.제목": "MY 카테고리",
"계정정보.마이카테고리.설명": "나만의 강의 카테고리를 구성해보세요!"
}
// 実装例
<Text>{t("계정정보.마이카테고리.제목")}</Text> // MY 카테고리
<Text>{t("계정정보.마이카테고리.설명")}</Text> // 나만의 강의 카테고리를 구성해보세요!
メリット
- 体系的なキー構造
- 明確な命名規則
デメリット
- キーを手動で定義する必要がある
- 規約の学習コストがある
- UI変更時にキーの修正が必要
3. 英語の役割ベースのキー方式
{page|domain}.{section}.{role}.{sub} という形式の構造化された英語キー
{
"my.settings.account.mycategory.title": "MY 카테고리",
"my.settings.account.mycategory.description": "나만의 강의 카테고리를 구성해보세요!"
}
// 実装例
<Text>{t("my.settings.account.mycategory.title")}</Text> // MY 카테고리
<Text>{t("my.settings.account.mycategory.description")}</Text> // 나만의 강의 카테고리를 구성해보세요!
メリット
- 体系的なキー構造
- テキストの変更に左右されないキー管理
- 標準化された命名
デメリット
- キーを手動で定義する必要がある
- 規約を覚える必要がある
- UI変更時にキーの修正が必要
- 非開発職にとって直感的でない(URL構造の理解が必要)
決定事項
最終的に1. 韓国語キー方式を採用しました。
主な決め手は次のとおりです。
- 開発チームとデザインチームの素早い作業スピードを確保できる
- 直感的なキー管理により生産性が上がる
- プロジェクトの時間的制約を考慮
この決定は、素早い実装とチーム全体の効率を優先した結果でした。
自動化スクリプト
背景とアイデア
i18next-parserやi18next-scannerは、i18nが適用されたコードから翻訳キーを抽出し、JSONファイルを生成する機能を提供しています。
規約も決まったので、あとは既存の韓国語をすべてtで囲めば完了です。
……いつ終わるんでしょう?
ため息をついていたとき、ふと良いアイデアが浮かびました。
韓国語だけを抜き出してtでラップするスクリプトを書けば、すごく楽じゃないですか?
結局のところ、私たちは繰り返し作業を減らしたくて開発を始めた人間ですからね。
ASTを活用したコード解析
では……膨大なサービス全体のソースコードから、翻訳対象となる韓国語テキストをどうやって見つけ出し、修正すればよいのでしょうか?
AST解析で該当箇所を見つければいいですね。
バックエンド開発者のWoodが投げかけたこの一言が、スクリプトの出発点になりました。
AST(Abstract Syntax Tree)は、コードを意味単位に分解してツリー構造にしたものです。
各ノードはコードの特定の部分を表し、それらの関係を階層的に表現します。
Babelのライブラリ
私たちはBabelが提供するライブラリを使って、ASTベースでソースコードをパースし、走査し、修正することにしました。スクリプトの中身を見ていく前に、理解の助けとしてこれらのライブラリを簡単に紹介します。
1. @babel/parser
@babel/parser は、ソースコードをパースしてASTを生成します。
import * as parser from '@babel/parser';
const code = `
const add = (a, b) => a + b;
`;
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
});
以下は、こうして生成されたASTツリーの一部です。
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "add"
},
"init": {
"type": "ArrowFunctionExpression",
"id": null,
"expression": true,
"generator": false,
"async": false,
"params": [
{
"type": "Identifier",
"name": "a"
},
{
"type": "Identifier",
"name": "b"
}
],
"body": {
"type": "BinaryExpression",
"left": {
"type": "Identifier",
"name": "a"
},
"operator": "+",
"right": {
"type": "Identifier",
"name": "b"
}
}
}
}
],
"kind": "const"
}
],
}
2. @babel/traverse
@babel/traverse は、@babel/parser で生成したASTを走査したり、ノードを修正したりするためのライブラリです。
上でパースしたツリーを見ると、単純なコードなのにツリー構造は複雑に見えます。このツリーの走査や修正を助けるのが @babel/traverse です。
traverse はVisitorパターンを使っており、特定のノードタイプに対して実行する関数を定義できます。
import traverse from '@babel/traverse';
traverse(ast, {
FunctionDeclaration: function(path) {
path.node.id.name = "x";
},
});
3. @babel/generator
@babel/generator は、ASTをソースコードに変換します。これを使えば、traverse で修正したツリーをソースコードとして保存し直せます。
import * as generator from '@babel/generator';
const output = generator(ast, {
retainLines: true,
concise: true,
}).code;
スクリプトの設計
ここからは、自動化スクリプトをどのように構築したのかを本格的に説明します。
アイデアを具体化する中で、必要な機能を大きく2つに分けました。
1. T Wrapper
- コンポーネント内の韓国語テキストを検出し、自動でt関数でラップ
- 手作業なら何日もかかる作業を自動化
2. Extractor
- js、jsx、ts、tsxファイルにある韓国語の値を抽出
- i18nextの設定に必要な言語リソースファイルを自動生成
それでは、それぞれのスクリプトがどのように動作するのかを詳しく見ていきましょう。
1. T Wrapper
ASTを活用して韓国語テキストを見つけ、t関数でラップするスクリプトです。
1. 変換の全体像
まずは、t関数でラップする処理の大まかな流れを見てみましょう。
wrapByT(code: string) {
const ast = parser.parse(code, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
});
const functionLikes = this.getFunctionLikes(ast);
[
this.makeBlockStatement,
this.wrapStringLiteral,
this.wrapJSXText,
this.wrapConditionalExpression,
this.wrapTemplateLiteral,
this.insertTranslationHook,
].forEach((process) => process(functionLikes));
this.insertImportDeclaration(ast);
const output = generator(ast, {
retainLines: true,
concise: true,
}).code;
return { code: output };
}
- Babel parserを使ってASTを生成します。
-
ASTから
ArrowFunctionExpression、FunctionDeclaration、FunctionExpressionのノードを抽出します。 - 各processを実行し、対象となるノード(FunctionLikes)をt関数でラップします。
- tでラップされたコンポーネントがあれば、ファイルの先頭にimport文を追加します。
- 最後に、変換後のコードを返します。
2. 変換対象
FunctionLike
t関数でラップするには、next-i18next(または他のi18nライブラリ)の useTranslation フックを呼び出す必要があります。Reactでは、フックはコンポーネントまたはカスタムフックの中でしか使用できません。
そのため、t関数でラップする際に確認すべき対象はシンプルです。ノードにJSXElementが含まれているか、カスタムフックであれば、子ノードを走査してtでラップします。それ以外は無視します。
private needTranslation(path: NodePath<PreFunctionLike>): boolean {
const isJSXElement = has(path, (node): node is t.JSXElement => t.isJSXElement(node));
const isHook = t.isIdentifier(path.node.id) && /use[A-Z][a-zA-Z0-9]/.test(path.node.id.name);
return isJSXElement || isHook;
}
こうした関数を FunctionLike と名付け、FunctionLike ノードだけを対象にt関数でラップします。
(付録)アロー関数の暗黙的なreturnを明示的なreturnに変換
FunctionLikeを抽出できたので、次はその中でuseTranslationフックを呼び出す必要があります。
ところが、これまで使っていたESLintルールの影響で、ReactElementを返す関数の多くは、BlockStatementを使わずに直接返す形で書かれています。
アロー関数の暗黙的なreturnのままではフックを呼び出せないため、明示的なreturnを持つブロック文に変換する必要があります。
フックを呼び出すための下準備として、FunctionLikeノードを順に走査し、すべてブロック文に変換します。
// as-is: Implicit Return (暗黙的なreturn)
const Component = () => <p>안녕하세요</p>;
// to-be: Block Statement with Explicit Return (ブロック文と明示的なreturn)
const Component = () => {
return <p>안녕하세요</p>;
}
3. 動作の仕組み
1. 韓国語を見つけてt関数でラップ
これで下準備はすべて整ったので、いよいよ韓国語を見つけてt関数でラップしていきます。
1. StringLiteral、JSXText
コンポーネント内にある、韓国語を含む文字列リテラル・JSXの属性値・一般的な文字列関数(isStringLiteral)をすべて見つけて、t関数でラップします。
// as-is
const Component = () => (
const message = "안녕하세요";
<div>
{message}
<span>반갑습니다</span>
<input type="text" placeholder="안녕하세요" />
</div>
)
// to-be
const Component = () => (
const message = t("안녕하세요");
<div>
{message}
<span>{t("반갑습니다")}</span>
<input type="text" placeholder={t("안녕하세요")} />
</div>
)
2. ConditionalExpression
コンポーネント内では三項演算子もよく使われます。この場合は、ConditionalExpressionを見つけてt関数でラップする必要があります。
ConditionalExpressionノードには、consequentとalternateという2つの子ノードがあります。
この2つのノードを検査し、ノードのvalueに韓国語が含まれていればt関数でラップします。
// as-is
const message = isKorean ? "안녕하세요" : "hello";
// to-be
const message = isKorean ? t("안녕하세요") : "hello";
3. TemplateLiteral
最後に残ったのが、テンプレートリテラルです。
テンプレートリテラルは、これまでとは違って追加のロジックが必要です。その説明の前に、i18nのinterpolationを簡単に紹介します。
i18nのinterpolationは、動的な値を翻訳に反映できる機能です。
例えば、次のようなコードがあるとします。
nameは動的に渡すことができ、nameが変わるたびに翻訳結果も変わります。
{
"introduce": "My name is {{name}}"
}
const doniIntroduction = t("introduce", { name: "Doni" });
const gohsueIntroduction = t("introduce", { name: "Gohsue" });
const lucyIntroduction = t("introduce", { name: "Lucy" });
console.log(doniIntroduction); // My name is Doni
console.log(gohsueIntroduction); // My name is Gohsue
console.log(lucyIntroduction); // My name is Lucy
TemplateLiteralでは、翻訳に動的な値を反映しなければなりません。
そこで、t関数でラップする際は、第2引数に動的な値を渡します。
今回の多言語化の目標は素早い適用と生産性なので、第2引数として渡す動的な値のkeyには、コードで使われている表現をそのまま使います。
// as-is
const message = `${user.name}님 ${time}에 만나요`;
// to-be
const message = t("{{user.name}}님 {{time}}에 만나요", { "user.name": user.name, time });
2. useTranslationフックの適用
韓国語を含むすべての文字列を見つけてtでラップしたので、次はuseTranslationフックを呼び出す必要があります。
このとき、条件が2つあります。
- useTranslationフックは、最上位の関数でのみ呼び出すようにします。
- 関数内にtでラップされたノードがある場合に、useTranslationフックを呼び出します。
この2つの条件をどちらも満たすFunctionLikeノードに対して、useTranslationフックを呼び出します。
functionLikes
.filter(this.isTopLevelFunction.bind(this))
.filter((path) =>
has(path, (node): node is t.Identifier => t.isIdentifier(node) && node.name === 't'),
)
// as-is
const Component = () => {
const handleClick = () => {
alert(t('반갑습니다'));
}
return (
<button onClick={handleClick}>t('안녕하세요')</button>
);
}
// to-be
const Component = () => {
const {t} = useTranslation();
const handleClick = () => {
// const {t} = useTranslation(); ❌
alert(t('반갑습니다'));
}
return (
<button onClick={handleClick}>t('안녕하세요')</button>
);
}
3. import文の追加
wrapByT関数の最後のステップとして、ファイルの先頭にimport文を追加します。
find(ast, t.isProgram)
.filter(
(path) =>
!has(
path,
(node): node is t.ImportDeclaration =>
t.isImportDeclaration(node) && node.source.value === this.importModuleName,
),
)
.forEach((path) => {
this.isWrappedByT = true;
const importDeclaration = t.importDeclaration(
[t.importSpecifier(t.identifier('useTranslation'), t.identifier('useTranslation'))],
t.stringLiteral(this.importModuleName),
);
path.unshiftContainer('body', importDeclaration);
});
import { useTranslation } from 'next-i18next';
4. i18n-automation.config.yaml
先ほどのコードに見慣れない部分がありましたね。this.importModuleName です。
t wrap と extractor で生産性は確保できましたが、チームごとに状況が異なっていたため、追加の設定が必要でした。
それまでのt-wrapは、対象のパスは設定できても、t-wrapを実行したくないパスは指定できませんでした。
そこで、リポジトリごとに設定できるYAMLファイルを追加しました。
各パッケージのルートに i18n-automation.config.yaml を作成し、必要な設定を追加するだけです。
# Reactならreact-i18nextを、Next.jsならnext-i18nextをimportします。
runOn: react # default: next
extract:
targetPaths:
- ./src
tWrap:
targetPaths: #default: [./src]
- ./src
ignorePaths: #default: []
- utils
- pages # パスにpagesを含むものはすべて無視。./src/pagesだけを無視したい場合は`src/pages`と明示
- test
runOn の値によって、importModuleNameが react-i18next と next-i18next のどちらになるかが決まります。import文を追加する際には、このimportModuleNameを使って各リポジトリに合ったモジュールをimportします。
これで targetPaths に加えて ignorePaths も設定できるようになり、メンバーの生産性をさらに高められました。
4. コマンド化
作ったスクリプトをメンバーが手軽に使えるよう、コマンドを用意しました。以下のようにscriptsに追加して使えます。
"scripts": {
"t-wrap": "i18n-automation t-wrap",
}
pnpm t-wrap
5. Demo
2. Extractor
T Wrapperで変換されたコードから翻訳が必要なテキストを抽出し、言語リソースファイルを生成するスクリプトです。
1. 変換対象
Extractorの対象は、T Wrapperとは少し異なります。
- T Wrapper
- Reactのフックのルールに従い、FunctionLikeノードのみを処理
- Extractor
- プロジェクトの全ファイルを対象に韓国語テキストを抽出
- コンポーネントやフック以外にも、定数や変数などに含まれるすべての韓国語を処理
このようにExtractorの範囲のほうが広いのは、実際のサービスで韓国語が使われている場所が、想定よりはるかに多岐にわたるためです。
例えば、次のようなコンポーネントがあるとします。
const DEFAULT_PROFILE = {
name: "랠릿셀 FE",
description: "랠릿셀 FE 개발자입니다."
}
const ERROR_MESSAGE = "오류가 발생했습니다.";
const Component = () => {
const { t } = useTranslation();
const handleClick = () => {
alert(ERROR_MESSAGE);
}
return (
<button onClick={handleClick}>t('안녕하세요')</button>
);
}
このコンポーネントでは、Extractorはt関数に渡された韓国語テキストだけでなく、それ以外のすべての韓国語テキストも抽出します。
// public/locales/ko/common.json
{
"랠릿셀 FE": "랠릿셀 FE",
"랠릿셀 FE 개발자입니다.": "랠릿셀 FE 개발자입니다.",
"오류가 발생했습니다.": "오류가 발생했습니다.",
"안녕하세요": "안녕하세요"
}
2. 動作の仕組み
ExtractorはASTを走査しながら、次のノードを解析します。
- StringLiteral:通常の文字列
- JSXText:JSX内のテキスト
- JSXAttribute:JSXの属性値
- TemplateLiteral:テンプレートリテラル
見つかった韓国語テキストは、next-i18next.config.js で設定された各言語フォルダーにJSONファイルとして生成されます。
このとき、韓国語テキストはキーと値が同じになるように設定されるので、後から他の言語に翻訳しやすくなります。
他のノードは韓国語をそのまま抽出すればよいのですが、TemplateLiteralには動的な値が含まれるため、追加の処理が必要です。
const message = `${user.name}님 ${time}에 만나요`;
このようなTemplateLiteralがある場合は、先ほど紹介したi18nextのinterpolation機能を活用するため、次のような形式に変換する必要があります。
// public/locales/ko/common.json
{
"{{user.name}}님 {{time}}에 만나요": "{{user.name}}님 {{time}}에 만나요"
}
このキーを使えば、後から動的な値も簡単に翻訳に反映できます。🙌
3. コマンド化
T wrapper と同様です。
"scripts": {
"extract": "i18n-automation extract",
}
pnpm extract
4. Demo
エピローグ
extract でサービスが対応する言語のリソースファイルを生成し、t-wrap でテキストをt関数でラップすれば、翻訳作業は完了です。
たった2回のスクリプト実行で、国際化が適用されました。✨
ASTを活用した自動化スクリプトの開発によって、手作業なら何日もかかっていたはずのi18n適用作業を大幅に短縮できました。実際、ある同僚はこのスクリプトを使って、特定ドメイン全体の翻訳をわずか5時間で終えたそうです。手作業だったら、何日もかかっていたはずです。
こうした成果を可能にした主な要素をまとめると、次のとおりです。
- 効率的なキー定義方式
- 韓国語をキーとして使い、直感性を確保
- 開発チームとデザインチームの生産性を向上
- 不要なキー定義の手間を省略
- 自動化された変換プロセス
- ASTを活用した正確なコード解析
- さまざまな形の韓国語テキストを自動変換
- Reactコンポーネントの特性を考慮した処理
- 体系的なリソース管理
- 言語リソースファイルの自動生成
- 一貫した形式での翻訳キー管理
- 将来の拡張性を考慮した構造設計
こうした自動化ツールを開発したことで、私たちは当初の想定よりはるかに早くサービスの国際化を進めることができました。現在はlocizeを導入し、言語リソースを効率よく管理しています。
今回のプロジェクトを通じて、適切な自動化ツールの開発がどれほど大きな生産性向上につながるのかを、改めて実感しました。
Inflearnのグローバルページが気になる方は
👉 こちらからどうぞ
※この記事は2025年2月6日に執筆されたものです。




