From ff653dc5175d27aa66ed00499cdfd6c08745fe3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=ED=98=81=EC=A4=80?= Date: Mon, 6 Jul 2026 17:04:06 +0900 Subject: [PATCH] =?UTF-8?q?docs(post):=20=EA=B5=AD=EC=A0=9C=ED=99=94=205?= =?UTF-8?q?=EB=B6=80=EC=9E=91=20=EC=98=81=EB=AC=B8=20=EB=B2=88=EC=97=AD=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '서비스 국제화 적용기' 5부작(ko)의 영문 번역을 추가한다. series "Service Internationalization"으로 묶고 draft:false로 발행해, 기존에 '번역 없음' 폴백이 뜨던 영어 페이지를 실제 번역으로 채운다. mermaid 라벨·코드 주석·표·내부 링크를 보존해 번역했다. --- content/posts/en/i18n-01-foundation.md | 87 +++++++++++++ content/posts/en/i18n-02-language-pack-gcs.md | 103 ++++++++++++++++ .../posts/en/i18n-03-rollout-killswitch.md | 89 ++++++++++++++ content/posts/en/i18n-04-backend-locale.md | 86 +++++++++++++ content/posts/en/i18n-05-document-render.md | 114 ++++++++++++++++++ 5 files changed, 479 insertions(+) create mode 100644 content/posts/en/i18n-01-foundation.md create mode 100644 content/posts/en/i18n-02-language-pack-gcs.md create mode 100644 content/posts/en/i18n-03-rollout-killswitch.md create mode 100644 content/posts/en/i18n-04-backend-locale.md create mode 100644 content/posts/en/i18n-05-document-render.md diff --git a/content/posts/en/i18n-01-foundation.md b/content/posts/en/i18n-01-foundation.md new file mode 100644 index 0000000..ebcc014 --- /dev/null +++ b/content/posts/en/i18n-01-foundation.md @@ -0,0 +1,87 @@ +--- +title: "Service Internationalization (1) - Stripping Out Hardcoded Korean" +date: "2026-05-19" +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." +tags: ["i18n", "nextjs", "react", "typescript"] +series: "Service Internationalization" +seriesOrder: 1 +draft: false +--- + +## Background + +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. + +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. + +## First things first: how to decide the locale + +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. + +- `ko` / `en` / `ja` - the language the user explicitly chose +- `system` - follows the device/browser language + +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). + +```mermaid +flowchart TB + A["Request comes in"] --> B{"LANGUAGE_CODE"} + B -->|"ko / en / ja"| C["That value is the locale"] + B -->|"system"| D["Browser / WebView
system language"] + D --> E{"Supported language?"} + E -->|"ko / en / ja"| C + E -->|"Unsupported"| F["Fall back to en"] + C --> G["effectiveLocale resolved"] + F --> G +``` + +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. + +## Messages are data, not code + +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. + +```tsx +// Before + + +// After + +``` + +```json +// ko.json +{ "booking": { "submit": "예약하기" } } +// ja.json +{ "booking": { "submit": "予約する" } } +``` + +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. + +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. + +```bash +node scripts/validate-i18n-messages.mjs +# Compares ko/en/ja for missing/extra keys and fails on any mismatch +``` + +## Flowing the locale through the entire system + +The locale wasn't just a matter of UI copy. We had to propagate the resolved `effectiveLocale` consistently to every corner of the system. + +- **`html lang`** - sets the document language to the locale (accessibility, SEO) +- **dayjs locale** - matches date/time formatting to the locale +- **`Accept-Language` header** - sent along on BFF/RPC/API calls so the server responds in the same language too +- **native WebView** - when the app opens a WebView, it passes the system language via the `system_locale` query param and the `Accept-Language` header + +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. + +## Wrap-up + +Stage one of internationalization wasn't a flashy feature but **cleanup**. + +- Separated the stored value (`system | ko | en | ja`) from the display value (`effectiveLocale`) +- Migrated strings into a git JSON message catalog and guarded against missing keys with CI +- Propagated the locale consistently across html, dayjs, Accept-Language, and the native WebView + +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. diff --git a/content/posts/en/i18n-02-language-pack-gcs.md b/content/posts/en/i18n-02-language-pack-gcs.md new file mode 100644 index 0000000..f1c21ea --- /dev/null +++ b/content/posts/en/i18n-02-language-pack-gcs.md @@ -0,0 +1,103 @@ +--- +title: "Service Internationalization (2) - Serving Language Packs: Bundle, GCS Runtime Load, and Cache" +date: "2026-06-03" +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." +tags: ["i18n", "nextjs", "gcs", "typescript"] +series: "Service Internationalization" +seriesOrder: 2 +draft: false +--- + +## The problem: bundling translations into the app means a deploy for every copy fix + +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. + +So we set the goal like this: **change copy without a deploy, but no matter what happens, never let the screen break.** + +## The choice: runtime load + bundle fallback + +Bundling and runtime loading come with a trade-off. + +- **Bundle** - Fast and certain. It ships with the app, so it's always present. The catch is that changing it requires a redeploy. +- **Runtime load** - Copy can be updated any time. The catch is that if the load fails, the screen goes blank. + +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. + +## Publishing the git JSON to GCS as versioned packs + +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. + +```js +// scripts/publish-i18n-gcs.mjs (gist) +for (const locale of ['ko', 'en', 'ja']) { + const body = await readFile(`.../messages/${locale}.json`, 'utf-8') + const objectPath = `intl/${locale}_${timestamp}.json` // versioned pack + await bucket.file(objectPath).save(body, { contentType, resumable: false }) + files[locale] = { path: `/${objectPath}`, timestamp, size: body.length } +} + +// pointer to the latest pack +await bucket.file('intl/metadata.json') + .save(JSON.stringify({ lastUpdated: timestamp, files })) +``` + +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. + +## The runtime loader: read metadata, and on failure fall back to the bundle + +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. + +```ts +const loadFromGcs = async () => { + if (!BUCKET_NAME) return bundledMessages // bucket not configured → bundle + + const bucket = new Storage().bucket(BUCKET_NAME) + const metadata = await downloadJson(bucket, 'intl/metadata.json') + + const entries = await Promise.all( + SUPPORTED_LOCALES.map(async (locale) => { + try { + const path = metadata.files?.[locale]?.path + if (!path) return [locale, bundledMessages[locale]] + return [locale, await downloadJson(bucket, path)] + } catch { + return [locale, bundledMessages[locale]] // fall back to bundle for this locale only + } + }), + ) + return Object.fromEntries(entries) +} +``` + +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." + +## We deployed, but the old copy still won't change + +This is where a real headache showed up. Hitting GCS on every request is slow and expensive, so we wrapped it in `unstable_cache`. + +```ts +export const loadLanguagePacks = () => + unstable_cache( + async () => { + try { return await loadFromGcs() } catch { return bundledMessages } + }, + ['i18n-language-packs'], + { tags: ['i18n-language-packs'], revalidate: 600 }, // ← this TTL is the key + )() +``` + +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.** + +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. + +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. + +## Wrap-up + +What we learned from serving language packs was, in the end, **stacking layers of fallback**. + +- git JSON (SSOT) → publish as GCS versioned packs + a metadata pointer +- Runtime load, but with bundle fallback at both the per-locale and full-failure level +- Put a TTL safety net on the persistent cache to prevent the "won't change even after deploying" incident + +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. diff --git a/content/posts/en/i18n-03-rollout-killswitch.md b/content/posts/en/i18n-03-rollout-killswitch.md new file mode 100644 index 0000000..3a4869c --- /dev/null +++ b/content/posts/en/i18n-03-rollout-killswitch.md @@ -0,0 +1,89 @@ +--- +title: "Service Internationalization (3) - Zero-Downtime Rollout with a GrowthBook Kill Switch" +date: "2026-06-17" +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." +tags: ["i18n", "nextjs", "growthbook", "feature-flag"] +series: "Service Internationalization" +seriesOrder: 3 +draft: false +--- + +## Background + +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. + +- Some screen with a missing translation might still be lurking somewhere, so Korean could pop out unexpectedly +- The same sentence differs in length across Japanese and English, so layouts can break +- Awkward or wrong translations might surface only later + +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. + +## Funneling locale decisions into one place: a single choke point + +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. + +So I funneled the decision into a single `resolveEffectiveLocale`, and injected the flag (`i18nEnabled`) into it. + +```ts +export const resolveEffectiveLocale = ({ + languageCode, systemLocale, acceptLanguage, i18nEnabled = true, +}: ResolveArgs): Locale => { + // i18n kill switch - if off, ko no matter what. The single choke point for locale decisions. + if (!i18nEnabled) return 'ko' + + const code = normalizeLanguageCode(languageCode) + if (code !== 'system') return code + + return normalizeLocale(systemLocale) + ?? resolveLocaleFromAcceptLanguage(acceptLanguage) + ?? FALLBACK_LOCALE +} +``` + +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. + +## Making the server and client reach the same decision + +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`.** + +```tsx +// Server: the layout reads the flag and passes it down +const i18nEnabled = flagResult.values[FEATURE_FLAG_KEYS.I18N_ENABLED]?.enabled ?? false +// ... + +``` + +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. + +## True zero-downtime means being able to roll back + +The real worth of a kill switch is that you can **roll it back instantly without a deploy.** + +- **Gradual rollout** - In GrowthBook, turn it on first for only a certain percentage or a certain user group +- **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 + +"If you can't roll a feature back, you can't turn it on" was the principle, and the kill switch upheld that principle. + +## Pin tests to ko + +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." + +```ts +test('forces ko when i18n is disabled, regardless of language/system', () => { + expect(resolveEffectiveLocale({ languageCode: 'en', systemLocale: 'ja-JP', i18nEnabled: false })).toBe('ko') + expect(resolveEffectiveLocale({ languageCode: 'ja', i18nEnabled: false })).toBe('ko') + expect(resolveEffectiveLocale({ languageCode: 'en', i18nEnabled: true })).toBe('en') +}) +``` + +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. + +## Wrap-up + +The core of a zero-downtime rollout was ultimately **a structure you can roll back.** + +- Funnel locale decisions into the `resolveEffectiveLocale` single choke point +- Control server and client at once with a single GrowthBook `i18n_enabled` +- Pin the "if off, always ko" invariant with a test + +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. diff --git a/content/posts/en/i18n-04-backend-locale.md b/content/posts/en/i18n-04-backend-locale.md new file mode 100644 index 0000000..cf485cd --- /dev/null +++ b/content/posts/en/i18n-04-backend-locale.md @@ -0,0 +1,86 @@ +--- +title: "Service Internationalization (4) - The Backend's Part: User Language, Timezone, and Document Locale" +date: "2026-06-26" +description: "Translate the front end alone and the server keeps mixing Korean back in through its responses and documents. This is the story of how the backend stored each user's language and timezone, carried them along in the authentication context, and pushed the locale all the way through to responses and documents." +tags: ["i18n", "spring-boot", "java", "backend"] +series: "Service Internationalization" +seriesOrder: 4 +draft: false +--- + +## Background + +The previous three posts were about the front end. But no matter how well the front end translates the copy, if the server sends back Korean error messages or issues Korean PDF documents, Korean creeps right back onto the screen. Internationalization isn't only a front-end job — it's finished only when **the backend, too, knows "what language this user speaks" and reflects it in responses and documents**. + +## Store the user's language and timezone + +First, users had to be able to pick and save a language and a timezone. So I set up a single locale settings API. + +```java +@PatchMapping("/user/locale") +public ResponseEntity> updateUserLocale( + @AuthenticationPrincipal AuthenticatedUserDto user, + @RequestBody @Valid UpdateUserLocaleRequest request +) { + UserLocaleResponse result = userInfoService.updateUserLocale( + user.getId(), request.timezoneCode(), request.languageCode()); + return ResponseEntity.ok(ApiResponse.success(result)); +} +``` + +The request and response carry a language code and a timezone code together. + +```java +public record UpdateUserLocaleRequest(String timezoneCode, String languageCode) {} +public record UserLocaleResponse(String timezoneCode, String languageCode) {} +``` + +The language value isn't accepted as an arbitrary string; it's normalized into a `Language` enum. A single enum holds the display name, the simple code, and the standard language code together, so wherever a conversion happens, there's just one source of truth. + +```java +public enum Language { + KOREAN("한국어", "KR", "ko"), + ENGLISH("영어", "EN", "en"), + JAPANESE("일본어", "JP", "ja"), + // ... + private final String code; // display name + private final String simpleCode; // KR / EN / JP + private final String languageCode; // ko / en / ja (standard) +} +``` + +## The authentication principal carries the language + +Once the language was stored, rather than digging through the DB again on every request, I had the **authentication context carry the language along**. I loaded the language code onto the authentication principal (`AuthenticatedUserDto`). + +```java +public class AuthenticatedUserDto { + private Language lang; + private String languageCode; // "system" if not set + // ... +} +``` + +Now any service can read the language straight from the user it received via `@AuthenticationPrincipal`. When there's no value, it's left as `"system"`, and the actual locale is resolved by the same rule as the front end (system language → en when unsupported). + +## Push the locale through to responses and documents + +Now that we know the user's language, we reflect it in the actual output. + +- **API responses** - the server reads the `Accept-Language` header the front end sent in Part 1, and responds in the same language +- **Issued documents** - for documents that go out as PDFs, such as enrollment certificates and level test reports, we propagate the language too. For example, the level test report link gets the language appended via `?lang=`, and the enrollment certificate issuance request payload also carries the language value + +```java +// propagate the language into the level test report link +extras.put("reportLink", appUrl + "/level-test/report?lang=" + dto.getLanguage()); +``` + +Because a document, once issued, stays in the user's hands as is, a mismatch like "the screen is in Japanese but the issued PDF is in Korean" stands out especially. That's why it was important to carry the language along every issuance path without exception. + +## Standardize time on UTC + +A locale carries not just language but also a timezone. Until then, parts of the server ran on KST, and once overseas users came in, reservation and class times started to drift. So I standardized **storage and computation on UTC**, and reorganized things so that timezone conversion happens only in the display layer (the user's `timezoneCode`). Time-difference bugs usually come from "computing in local time somewhere," so gathering the baseline into a single place — UTC — reduced those traps. + +## Wrap-up + +Now the backend knows the user's language too, and carries the locale along the API response and issued-document paths. But then — how do we actually render **the documents themselves** in multiple languages? For a long time, enrollment certificates and reports had their text baked into images, so building English and Japanese versions meant redrawing the background image. In the final post, I'll cover the story of moving documents that used to be baked into images over to HTML rendering to support multiple languages. diff --git a/content/posts/en/i18n-05-document-render.md b/content/posts/en/i18n-05-document-render.md new file mode 100644 index 0000000..a316c67 --- /dev/null +++ b/content/posts/en/i18n-05-document-render.md @@ -0,0 +1,114 @@ +--- +title: "Service Internationalization (5) - From Image-Baked Documents to HTML: Multilingual PDF Rendering" +date: "2026-07-04" +description: "For a long time, the language of our certificates and reports was baked into images. This is the story of moving image-based documents to HTML templates plus Cloud Run rendering, so that adding a new language became a single JSON file." +tags: ["i18n", "cloud-run", "pdf", "weasyprint"] +series: "Service Internationalization" +seriesOrder: 5 +draft: false +--- + +## Background: the language was baked into images + +In [Part 4](/posts/i18n-04-backend-locale) we got the backend to carry the locale all the way down the document-issuance path. But the documents themselves couldn't make use of that locale. Issued documents like completion certificates and reports had long been **image-based**. + +- A completion certificate was a structure that stamped only values like name and date, by coordinate, on top of **a single background PNG** +- A level-test report glued together explanations, cards, and graph fragments entirely from **pre-made PNG slices** (25 slices for the explanation area, 10 for the bar chart, and so on) + +```mermaid +flowchart LR + L["Per-language background PNG
text baked into pixels"] --> S["Generator
stamps only values by coordinate"] + S --> P["PDF"] + N["Add a new language"] -.->|"redesign the whole
background PNG"| L +``` + +There was no way this could go multilingual. The document's **"content" and "presentation" were fused into a single lump inside the image**, so changing the language (content) meant redrawing the picture (presentation) from scratch. For the English and Japanese editions, a designer had to create brand-new per-language background PNGs, and even fixing a single typo meant reworking an image. + +## Rebuilding it in HTML: splitting content from presentation + +There was only one direction. **Pull apart the content and presentation that had been mashed together in the images.** Put the presentation (layout) in HTML/CSS templates, put the content (text) in per-language JSON, and leave images to hold **only language-independent assets (logos, seals, illustrations)**. + +```mermaid +flowchart LR + T["HTML template
shared layout across languages"] --> W["WeasyPrint"] + J["Per-language JSON strings"] --> W + A["Language-independent assets
logos, seals"] --> W + W --> P["PDF"] + N["Add a new language"] -.->|"one JSON file"| J +``` + +The real service's structure is shaped exactly like this. + +``` +templates/certificate.html # layout (HTML/CSS, A4) +strings/certificate/{ko,en,ja}.json # per-language strings +assets/ # logos, seals (language-independent) +fonts/ # Pretendard (+ Noto CJK for Japanese) +``` + +As a result, the only images left were things like the logo and the seal, while the title, tables, notices, certification text, dates, and company name all became text. Even the bar chart, which used to be assembled from 10 images, turned into a single CSS `height: N%` div. Once presentation lives in code, values get injected as template variables too. + +The biggest payoff is this. **Adding a new language = adding one JSON file.** No back-and-forth with a designer, no reworking a background PNG. + +```json +// strings/certificate/ja.json +{ + "title": "受講証明書", + "mail_subject": "{name}様の受講証明書", + "date_format": "{year}年{month}月{day}日", + "lang_names": { "ENGLISH": "英語", "JAPANESE": "日本語" } +} +``` + +## Choosing a renderer: WeasyPrint vs. a headless browser + +There were broadly two ways to bake HTML into a PDF. + +| Approach | Pros | Cons | +|------|------|------| +| **WeasyPrint** (Python) | Supports page breaks, headers, and page numbers; no browser needed (lightweight container) | Some modern CSS unsupported | +| **Puppeteer/Playwright** (Chromium) | Perfect modern CSS, renders 100% identically to the web | Bundling all of Chromium makes the container heavy | + +Our documents are fixed A4 layouts, so we didn't need fancy CSS, and the existing generator was already in Python. So we picked **WeasyPrint, which had the lowest migration cost**. One gotcha was fonts. Because WeasyPrint **renders with the fonts installed in the container**, we bundled Pretendard for Korean and solved the Japanese glyphs by embedding Noto CJK in the Docker image. Without the font, characters break into tofu (□). + +## Rendering pipeline: the backend only fires an event + +Rendering is handled not by the backend (Spring) but by a separate Cloud Run service (`document-render`). PDF rendering is heavy on font and layout computation and the Python (WeasyPrint) ecosystem is favorable here, so rather than wedge it into the Java process, we split it into its own service. Document issuance isn't a task that waits for an immediate response either, so the backend just publishes an issuance event and moves on to the next thing without blocking. + +```mermaid +flowchart TB + BE["Backend
(Spring)"] -->|"① publish issuance event
with locale included"| PS["Pub/Sub topic"] + PS --> EV["Eventarc"] + EV -->|"② POST /"| R["document-render
Jinja2 + WeasyPrint"] + R -->|"③ select template & strings
by locale, then render PDF"| GCS["GCS upload"] + GCS -->|"④ callback after upload
(PDF URL + locale echo)"| BE + BE -->|"⑤ send per-locale mail template
+ PDF link"| MAIL["User email"] +``` + +The key point is that render → upload → callback → mail runs in a single line, and the locale threads through the entire pipeline on top of it. + +1. The backend publishes an issuance event to Pub/Sub - at this point it carries the `locale` along in the payload +2. Eventarc receives the event and does a `POST /` to the render service +3. The render service selects the template and strings by `locale` and renders the PDF with WeasyPrint +4. It uploads the finished PDF to GCS, and **once the upload finishes it calls back to the backend** - passing along the PDF's URL and the `locale` +5. The backend uses the echoed `locale` to pick the per-language mail template (`..._en`, `..._ja`) and sends the mail carrying the link to the PDF uploaded to GCS + +One thing we cared about was backward compatibility. If the payload has no `locale`, or an unsupported value, the render service **falls back to `ko`**. That means older-version events that didn't carry a locale still work as Korean documents just as before. And in dev, a preview endpoint like `GET /preview?locale=ja` lets you check the result straight in the browser, keeping the design iteration cycle short. + +## Consolidating scattered generators into one + +The document generators were originally scattered across several places. Since they were all small Python programs, we **consolidated them into a single rendering service**. The idea was to manage fonts, locale strings, the preview harness, and CI as one set. The more documents there are, the higher the cost of "redoing font setup and locale handling every time" — so we gathered all of that in one place. + +The migration was smooth, too. Because the backend only publishes to a Pub/Sub topic, cutover was just **repointing the Eventarc trigger to the new service**. No backend deploy needed. If something goes wrong, pointing the trigger back at the old function is an instant rollback. The kill-switch principle from [Part 3](/posts/i18n-03-rollout-killswitch) — "you only turn it on if you can turn it back" — ended up applying at the infrastructure level too. + +## Wrapping up + +Over these five parts, we've gone through internationalization one piece at a time. + +1. **Foundation** - separating stored values from displayed values, moving strings into a message catalog +2. **Serving the language pack** - GCS runtime loading + bundle fallback, cache TTL +3. **Zero-downtime rollout** - a single choke point + a GrowthBook kill switch +4. **Backend** - user language and time zone, propagating the auth context, response and document locale +5. **Document rendering** - image-based → HTML (WeasyPrint), consolidating generators, "a new language = one JSON file" + +In truth, the Japan launch is still in progress. Filling in translations, widening the rollout, and moving the remaining documents to HTML are all still ongoing. But the direction has become clear: instead of nailing language into the code, changing things so language can be **handled as data**. Once we moved text into JSON and documents from images into HTML, the cost of adding one more language dropped sharply. A designer used to have to redraw a background image; now a single string file is enough. On top of this skeleton, laying down the next language is far lighter than it was the first time.