Skip to content

Commit ff653dc

Browse files
committed
docs(post): 국제화 5부작 영문 번역 추가
'서비스 국제화 적용기' 5부작(ko)의 영문 번역을 추가한다. series "Service Internationalization"으로 묶고 draft:false로 발행해, 기존에 '번역 없음' 폴백이 뜨던 영어 페이지를 실제 번역으로 채운다. mermaid 라벨·코드 주석·표·내부 링크를 보존해 번역했다.
1 parent e20bb63 commit ff653dc

5 files changed

Lines changed: 479 additions & 0 deletions

File tree

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
---
2+
title: "Service Internationalization (1) - Stripping Out Hardcoded Korean"
3+
date: "2026-05-19"
4+
description: "Frontend internationalization, kicked off ahead of our launch in Japan. Here's how we settled on a locale model, managed our messages, and flowed the locale through the entire system."
5+
tags: ["i18n", "nextjs", "react", "typescript"]
6+
series: "Service Internationalization"
7+
seriesOrder: 1
8+
draft: false
9+
---
10+
11+
## Background
12+
13+
Once launching in Japan landed on the roadmap, the internationalization (i18n) work we'd been putting off suddenly became urgent. The problem was that the service had grown for years on the assumption that it "only ever ran in Korean." UI copy was baked into components as string literals, dates used the Korean format, prices were fixed to won, and in some places we piped the Korean messages the server handed down straight onto the screen.
14+
15+
It started as "we just need to add English and Japanese, right?" but in practice, **finding and stripping out all the hardcoded Korean** was the job in itself. This post covers that first step - settling on a locale model, moving messages into a managed system, and flowing the locale through the whole system.
16+
17+
## First things first: how to decide the locale
18+
19+
The very first thing we settled was "what should we consider this user's language to be." We gave the user setting (`LANGUAGE_CODE`) four possible values.
20+
21+
- `ko` / `en` / `ja` - the language the user explicitly chose
22+
- `system` - follows the device/browser language
23+
24+
When it's `system`, we read the system language from the browser (or the native WebView), and if it's an unsupported language, we fall back to `en`. This is how we **separated the stored value from the language actually used on screen**. The stored value is one of `system | ko | en | ja`, and for display we always compute and use a resolved `effectiveLocale` (one of ko/en/ja).
25+
26+
```mermaid
27+
flowchart TB
28+
A["Request comes in"] --> B{"LANGUAGE_CODE"}
29+
B -->|"ko / en / ja"| C["That value is the locale"]
30+
B -->|"system"| D["Browser / WebView<br/>system language"]
31+
D --> E{"Supported language?"}
32+
E -->|"ko / en / ja"| C
33+
E -->|"Unsupported"| F["Fall back to en"]
34+
C --> G["effectiveLocale resolved"]
35+
F --> G
36+
```
37+
38+
Because we split the stored value from the display value, a user who chose "follow the system" sees the app change along with their device language, while a user who explicitly picked a specific language keeps that choice.
39+
40+
## Messages are data, not code
41+
42+
The second thing we did was move the strings baked into the screen into a **message catalog**. We keep per-locale JSON (`ko.json` / `en.json` / `ja.json`), and components reference a key instead of a string.
43+
44+
```tsx
45+
// Before
46+
<button>예약하기</button>
47+
48+
// After
49+
<button>{t('booking.submit')}</button>
50+
```
51+
52+
```json
53+
// ko.json
54+
{ "booking": { "submit": "예약하기" } }
55+
// ja.json
56+
{ "booking": { "submit": "予約する" } }
57+
```
58+
59+
There's one important principle here. **The JSON in git is the single source of truth** (SSOT). Translated copy is managed in JSON inside the code repository, and the delivery channel (GCS, which we'll cover in the next post) is just a transport layer that carries it around.
60+
61+
The catch is that once keys grow into the hundreds or thousands, **missing keys across locales** are inevitable. If a key exists only in `ko.json` but is missing from `ja.json`, Korean pops out in that spot or the raw key string is exposed. So we attached a **validation script** to CI that catches any mismatch between the key sets of the three locales.
62+
63+
```bash
64+
node scripts/validate-i18n-messages.mjs
65+
# Compares ko/en/ja for missing/extra keys and fails on any mismatch
66+
```
67+
68+
## Flowing the locale through the entire system
69+
70+
The locale wasn't just a matter of UI copy. We had to propagate the resolved `effectiveLocale` consistently to every corner of the system.
71+
72+
- **`html lang`** - sets the document language to the locale (accessibility, SEO)
73+
- **dayjs locale** - matches date/time formatting to the locale
74+
- **`Accept-Language` header** - sent along on BFF/RPC/API calls so the server responds in the same language too
75+
- **native WebView** - when the app opens a WebView, it passes the system language via the `system_locale` query param and the `Accept-Language` header
76+
77+
Flowing `Accept-Language` all the way to the server especially mattered. No matter how well you translate the copy on the frontend, if the server hands down a Korean error message, Korean creeps back onto the screen. Replacing the spots where "the lesson-history badge exposed a backend status value verbatim" with locale messages was part of the same effort.
78+
79+
## Wrap-up
80+
81+
Stage one of internationalization wasn't a flashy feature but **cleanup**.
82+
83+
- Separated the stored value (`system | ko | en | ja`) from the display value (`effectiveLocale`)
84+
- Migrated strings into a git JSON message catalog and guarded against missing keys with CI
85+
- Propagated the locale consistently across html, dayjs, Accept-Language, and the native WebView
86+
87+
Once we'd moved the copy into JSON, the next question remained. **Do we ship this language pack together with the app, or fetch it separately at runtime?** After all, we can't redeploy the app every time we fix a single typo. The next post covers the story of serving the language pack via bundling, GCS runtime loading, and fallback.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
---
2+
title: "Service Internationalization (2) - Serving Language Packs: Bundle, GCS Runtime Load, and Cache"
3+
date: "2026-06-03"
4+
description: "If you ship translations together with the app, every one-line copy fix means a redeploy. This post covers the structure that loads language packs from GCS at runtime and falls back to the bundle, plus the cache problem where copy wouldn't change even after deploying."
5+
tags: ["i18n", "nextjs", "gcs", "typescript"]
6+
series: "Service Internationalization"
7+
seriesOrder: 2
8+
draft: false
9+
---
10+
11+
## The problem: bundling translations into the app means a deploy for every copy fix
12+
13+
In [part 1](/posts/i18n-01-foundation) we moved the on-screen copy into per-locale JSON. So when do we load this JSON? The easiest option is to **include it in the build bundle**. But then every typo, every one-line copy fix, forces us to rebuild and redeploy the app. And translations are one of the things that change most often while a service is running.
14+
15+
So we set the goal like this: **change copy without a deploy, but no matter what happens, never let the screen break.**
16+
17+
## The choice: runtime load + bundle fallback
18+
19+
Bundling and runtime loading come with a trade-off.
20+
21+
- **Bundle** - Fast and certain. It ships with the app, so it's always present. The catch is that changing it requires a redeploy.
22+
- **Runtime load** - Copy can be updated any time. The catch is that if the load fails, the screen goes blank.
23+
24+
We decided to use **both**. Normally we fetch the latest pack at runtime, but if that fails, we fall back to the bundle catalog included in the build. It's a structure that gives us freshness and stability at the same time.
25+
26+
## Publishing the git JSON to GCS as versioned packs
27+
28+
As mentioned before, **the JSON in git is the single source of truth**. A separate publish script carries this JSON over to GCS. It uploads a timestamped versioned pack together with a `metadata.json` that points to which pack is the latest.
29+
30+
```js
31+
// scripts/publish-i18n-gcs.mjs (gist)
32+
for (const locale of ['ko', 'en', 'ja']) {
33+
const body = await readFile(`.../messages/${locale}.json`, 'utf-8')
34+
const objectPath = `intl/${locale}_${timestamp}.json` // versioned pack
35+
await bucket.file(objectPath).save(body, { contentType, resumable: false })
36+
files[locale] = { path: `/${objectPath}`, timestamp, size: body.length }
37+
}
38+
39+
// pointer to the latest pack
40+
await bucket.file('intl/metadata.json')
41+
.save(JSON.stringify({ lastUpdated: timestamp, files }))
42+
```
43+
44+
Because it's a timestamped versioned pack + pointer (`metadata.json`) structure, a rollback is just reverting the metadata. For authentication we use Workload Identity/ADC instead of a key file.
45+
46+
## The runtime loader: read metadata, and on failure fall back to the bundle
47+
48+
The load happens on the server (an App Router server component). It reads `metadata.json`, fetches each locale's pack, and **on a per-locale failure, falls back to the bundle for that locale only**. A full failure, such as an unconfigured bucket or a failure of the metadata itself, falls back wholesale to the bundle catalog.
49+
50+
```ts
51+
const loadFromGcs = async () => {
52+
if (!BUCKET_NAME) return bundledMessages // bucket not configured → bundle
53+
54+
const bucket = new Storage().bucket(BUCKET_NAME)
55+
const metadata = await downloadJson(bucket, 'intl/metadata.json')
56+
57+
const entries = await Promise.all(
58+
SUPPORTED_LOCALES.map(async (locale) => {
59+
try {
60+
const path = metadata.files?.[locale]?.path
61+
if (!path) return [locale, bundledMessages[locale]]
62+
return [locale, await downloadJson(bucket, path)]
63+
} catch {
64+
return [locale, bundledMessages[locale]] // fall back to bundle for this locale only
65+
}
66+
}),
67+
)
68+
return Object.fromEntries(entries)
69+
}
70+
```
71+
72+
Thanks to this structure, even if only the ja pack is uploaded incorrectly, only ja drops to the bundle while ko and en keep using the latest pack as-is. The key point is: "an incident in one locale doesn't bring the whole thing down."
73+
74+
## We deployed, but the old copy still won't change
75+
76+
This is where a real headache showed up. Hitting GCS on every request is slow and expensive, so we wrapped it in `unstable_cache`.
77+
78+
```ts
79+
export const loadLanguagePacks = () =>
80+
unstable_cache(
81+
async () => {
82+
try { return await loadFromGcs() } catch { return bundledMessages }
83+
},
84+
['i18n-language-packs'],
85+
{ tags: ['i18n-language-packs'], revalidate: 600 }, // ← this TTL is the key
86+
)()
87+
```
88+
89+
At first we set `revalidate: false`. The thinking was, "we can just revalidate language packs by tag when we publish." But **even after deploying, the old copy kept showing up.**
90+
91+
The cause was the cache store. Our deployment uses `cache-handler` to **persist the Next cache in Redis**. `revalidate: false` effectively means "cache indefinitely," so even after redeploying the app, the old catalog still sitting in Redis kept being served. Even though the build changed, the cache key was the same, so it was never invalidated.
92+
93+
The fix was simple. We **gave it a TTL** (`revalidate: 600`). We revalidate immediately by tag on publish, but even if that's missed, it naturally rolls over to the new pack within at most 10 minutes. In effect, we added one more safety net — an "expiry" — to the persistent cache.
94+
95+
## Wrap-up
96+
97+
What we learned from serving language packs was, in the end, **stacking layers of fallback**.
98+
99+
- git JSON (SSOT) → publish as GCS versioned packs + a metadata pointer
100+
- Runtime load, but with bundle fallback at both the per-locale and full-failure level
101+
- Put a TTL safety net on the persistent cache to prevent the "won't change even after deploying" incident
102+
103+
Now copy can be changed without a deploy, and no matter what happens, the screen renders. What's left is **how to safely turn this internationalization on for real users**. In the next part, I'll cover the story of rolling out i18n gradually with zero downtime using a GrowthBook kill switch.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
title: "Service Internationalization (3) - Zero-Downtime Rollout with a GrowthBook Kill Switch"
3+
date: "2026-06-17"
4+
description: "Even with all translations ready, flipping the switch for everyone at once is risky. The story of funneling locale decisions into a single choke point and rolling out i18n with zero downtime, gradually, via a GrowthBook kill switch."
5+
tags: ["i18n", "nextjs", "growthbook", "feature-flag"]
6+
series: "Service Internationalization"
7+
seriesOrder: 3
8+
draft: false
9+
---
10+
11+
## Background
12+
13+
In parts 1 and 2, I moved copy into a message catalog and got the language packs served from GCS. So can I just turn it on for everyone now? No. Opening up i18n to everyone in one shot is risky.
14+
15+
- Some screen with a missing translation might still be lurking somewhere, so Korean could pop out unexpectedly
16+
- The same sentence differs in length across Japanese and English, so layouts can break
17+
- Awkward or wrong translations might surface only later
18+
19+
So the goal was singular. **Turn it on gradually, but if something goes wrong, roll it back instantly without a deploy.** That mechanism is the GrowthBook kill switch.
20+
21+
## Funneling locale decisions into one place: a single choke point
22+
23+
For a kill switch to work properly, the point that decides "what is this request's locale" must be **exactly one place**. If everyone decides the locale on their own all over the codebase, then even with the kill switch off, something somewhere still renders in Japanese.
24+
25+
So I funneled the decision into a single `resolveEffectiveLocale`, and injected the flag (`i18nEnabled`) into it.
26+
27+
```ts
28+
export const resolveEffectiveLocale = ({
29+
languageCode, systemLocale, acceptLanguage, i18nEnabled = true,
30+
}: ResolveArgs): Locale => {
31+
// i18n kill switch - if off, ko no matter what. The single choke point for locale decisions.
32+
if (!i18nEnabled) return 'ko'
33+
34+
const code = normalizeLanguageCode(languageCode)
35+
if (code !== 'system') return code
36+
37+
return normalizeLocale(systemLocale)
38+
?? resolveLocaleFromAcceptLanguage(acceptLanguage)
39+
?? FALLBACK_LOCALE
40+
}
41+
```
42+
43+
When the flag is off, whether the user's setting is `en` or `ja`, whatever the system language is, it **falls back to Korean unconditionally.** It's as if I put a switch on the one gate every locale must pass through. The flag value is managed by a single `i18n_enabled` in GrowthBook.
44+
45+
## Making the server and client reach the same decision
46+
47+
Because it's the Next.js App Router, the locale is used on both the server (the layout) and the client (the provider). If the two pick different locales, hydration breaks. So **both feed the same flag into the same `resolveEffectiveLocale`.**
48+
49+
```tsx
50+
// Server: the layout reads the flag and passes it down
51+
const i18nEnabled = flagResult.values[FEATURE_FLAG_KEYS.I18N_ENABLED]?.enabled ?? false
52+
// ...
53+
<I18nProvider i18nEnabled={i18nEnabled} /* ... */>
54+
```
55+
56+
The server reads the flag to decide `html lang` and the first render, and the client provider computes the same `effectiveLocale` from the same flag. When the flag is off, both server and client are `ko`, so the render results don't diverge.
57+
58+
## True zero-downtime means being able to roll back
59+
60+
The real worth of a kill switch is that you can **roll it back instantly without a deploy.**
61+
62+
- **Gradual rollout** - In GrowthBook, turn it on first for only a certain percentage or a certain user group
63+
- **Instant cutoff** - If a translation incident is found, the moment you flip the flag off, everyone safely reverts to `ko`. No need to wait for a rollback deploy
64+
65+
"If you can't roll a feature back, you can't turn it on" was the principle, and the kill switch upheld that principle.
66+
67+
## Pin tests to ko
68+
69+
If the locale wavers from test to test, snapshots and E2E become flaky. So I pinned the locale in the test environment to `ko` by default. And I **nailed down the invariant itself in a unit test** - "if the flag is off, always ko."
70+
71+
```ts
72+
test('forces ko when i18n is disabled, regardless of language/system', () => {
73+
expect(resolveEffectiveLocale({ languageCode: 'en', systemLocale: 'ja-JP', i18nEnabled: false })).toBe('ko')
74+
expect(resolveEffectiveLocale({ languageCode: 'ja', i18nEnabled: false })).toBe('ko')
75+
expect(resolveEffectiveLocale({ languageCode: 'en', i18nEnabled: true })).toBe('en')
76+
})
77+
```
78+
79+
As long as this test stays green, no matter who touches the locale logic, the safety net of "kill switch off = everyone ko" won't break.
80+
81+
## Wrap-up
82+
83+
The core of a zero-downtime rollout was ultimately **a structure you can roll back.**
84+
85+
- Funnel locale decisions into the `resolveEffectiveLocale` single choke point
86+
- Control server and client at once with a single GrowthBook `i18n_enabled`
87+
- Pin the "if off, always ko" invariant with a test
88+
89+
That's the frontend story so far. But no matter how well you translate on the frontend, if the server hands down a Korean response or a Korean document, Korean gets mixed back into the screen. In the final part, I'll cover how the backend stored the user's language and time zone and carried the locale all the way through to responses and documents.

0 commit comments

Comments
 (0)