Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ publishDate: "2025-12-01" # optional, 예약 발행

관련 글처럼 자동 생성되는 주요 링크를 추가하거나 바꿀 때도 실제 HTML이 달라지는 글의 `updated`를 함께 기록합니다. 글의 태그·제목·설명을 수정하면 다른 글의 관련 링크도 달라질 수 있으므로 빌드 결과를 비교해 영향을 받은 글을 확인합니다. 변경이 없는 글의 날짜는 유지하며 빌드 날짜로 일괄 갱신하지 않습니다.

홈·목록 등 고정 페이지의 주요 내용이나 링크가 바뀌면 `src/data/page-updates.ts`에 확인한 변경일을 기록합니다. 기록이 없는 페이지는 사이트맵의 `lastmod`를 생략합니다.

목록·태그·시리즈의 필터는 `/tags/#filters:tag=java`처럼 문서 fragment에 저장합니다. 새 내부 링크는 `collectionFilterPath`로 만들며, 기존 `?tag=` 주소도 필터와 `noindex` 처리를 유지합니다. 필터 fragment는 일반 목차 앵커와 별개로 처리합니다.

`robots.txt`는 기존 목록·태그·시리즈의 쿼리 주소만 수집을 제한합니다. 개별 글과 쿼리가 없는 목록은 허용합니다. 이 규칙은 불필요한 재수집을 줄이기 위한 것이며, Search Console에서 과거 URL을 즉시 지우는 기능은 아닙니다.

## 프로젝트 구조

```text
Expand Down
55 changes: 49 additions & 6 deletions content/posts/en/i18n-03-rollout-killswitch.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "Service Internationalization (3) - Zero-Downtime Rollout with a GrowthBook Kill Switch"
date: "2026-06-17"
updated: "2026-06-17"
updated: "2026-10-28"
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"
Expand All @@ -17,7 +17,9 @@ In parts 1 and 2, I moved copy into a message catalog and got the language packs
- 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.
So the goal was singular. **Turn it on gradually, but retain a way to roll it back without a deploy.** That mechanism is the GrowthBook kill switch.

This article follows the decision from **the server reading a flag to the client using the same locale for its first render**. The prerequisites are [locale precedence](/posts/i18n-01-foundation/) and [language-pack delivery and caching through GCS](/posts/i18n-02-language-pack-gcs/).

## Funneling locale decisions into one place: a single choke point

Expand All @@ -43,6 +45,19 @@ export const resolveEffectiveLocale = ({

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.

### What the flag changes, and what it preserves

The kill switch changes the **effective locale for this render**, not the user’s saved language preference. Keeping the saved preference allows the user’s choice to take effect again when the feature is enabled.

| Flag | User preference | System or browser setting | Decision for this render |
|---|---|---|---|
| Off | `en` or `ja` | Any value | `ko` |
| On | Explicit `en` | `ja-JP` | `en` |
| On | `system` | Supported system locale is available | System locale |
| On | `system` | System locale cannot be resolved | `Accept-Language`, then the fallback locale |

These branches follow directly from the function above. Checking the flag first prevents a lower-priority setting from overriding the disabled state.

## 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`.**
Expand All @@ -58,13 +73,27 @@ The server reads the flag to decide `html lang` and the first render, and the cl

## 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.**
The value of a kill switch is that you can **restore the effective locale without deploying rollback code.**

- **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
- **Cutoff** - Turn the flag off when a translation problem appears. Requests that read the new flag value return to `ko` without waiting 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.

### An already open page may still have the previous value

The code above does not guarantee an immediate update in every open browser. The server passes its flag value to the provider, so an existing document can retain the old value. Propagation depends on flag caching and on when a new request or render occurs.

Before a rollout, check these boundaries.

1. **Targeting**: Do the server and client evaluate the same user or group?
2. **First render**: Do `html lang`, the message catalog, and the client’s initial locale agree?
3. **Flag caching**: When does a new request see the disabled value, and where can an older value remain cached?
4. **Existing pages**: Are refreshed pages and already open pages checked separately?
5. **Preserved data**: Does disabling the feature retain the user’s language preference and the Korean language pack?

Fetching a language pack from a CDN and selecting a locale are separate decisions. A cached non-Korean pack must not override an effective locale of `ko`.

## 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."
Expand All @@ -77,7 +106,21 @@ test('forces ko when i18n is disabled, regardless of language/system', () => {
})
```

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.
This test checks the function’s rule that a disabled flag produces an effective locale of `ko`. It does not prove that the server and browser receive the same flag, so a rendered-page check is needed as well.

The following test example covers more combinations of user and browser preferences. The disabled flag must take precedence in every case.

```ts
test.each([
{ languageCode: 'en', systemLocale: 'ja-JP', acceptLanguage: 'en-US' },
{ languageCode: 'ja', systemLocale: 'en-US', acceptLanguage: 'ja-JP' },
{ languageCode: 'system', systemLocale: 'ja-JP', acceptLanguage: 'en-US' },
])('keeps ko while the kill switch is off: %o', (input) => {
expect(resolveEffectiveLocale({ ...input, i18nEnabled: false })).toBe('ko')
})
```

After the unit test, open the first request for an `en` user with the feature enabled and disabled. Compare the initial HTML with the hydrated page: the locale should not change unexpectedly, and raw translation keys should not appear.

## Wrap-up

Expand All @@ -87,4 +130,4 @@ The core of a zero-downtime rollout was ultimately **a structure you can roll ba
- 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.
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. [Backend locale handling](/posts/i18n-04-backend-locale/) covers the boundary between stored language preferences and API responses. [Localized document rendering](/posts/i18n-05-document-render/) continues that flow into generated documents.
55 changes: 49 additions & 6 deletions content/posts/ko/i18n-03-rollout-killswitch.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: "서비스 국제화 적용기 (3) - GrowthBook 킬스위치로 무중단 점진 롤아웃"
date: "2026-06-17"
updated: "2026-06-17"
updated: "2026-10-28"
description: "번역을 다 만들었어도 전원에게 한 번에 켜면 위험하다. 로케일 결정을 단일 차단점으로 모으고 GrowthBook 킬스위치로 i18n을 무중단·점진 롤아웃한 이야기."
tags: ["i18n", "nextjs", "growthbook", "feature-flag"]
series: "서비스 국제화 적용기"
Expand All @@ -17,7 +17,9 @@ draft: false
- 일본어·영어는 같은 문장도 길이가 달라 레이아웃이 깨질 수 있고
- 번역 자체가 어색하거나 틀린 곳이 뒤늦게 발견될 수 있다

그래서 목표는 하나였다. **점진적으로 켜되, 문제가 생기면 배포 없이 즉시 되돌린다.** 그 장치가 GrowthBook 킬스위치다.
그래서 목표는 하나였다. **점진적으로 켜되, 문제가 생기면 배포 없이 되돌릴 수 있게 한다.** 그 장치가 GrowthBook 킬스위치다.

이 글은 **서버가 플래그를 읽고 로케일을 결정한 뒤, 같은 결정을 클라이언트의 첫 렌더까지 전달하는 흐름**을 다룬다. 로케일 우선순위는 [국제화 기반 설계](/posts/i18n-01-foundation/), 번역 파일의 배포와 캐시는 [GCS 언어팩 구성](/posts/i18n-02-language-pack-gcs/)에서 이어진다.

## 로케일 결정을 한 곳으로 모은다

Expand All @@ -43,6 +45,19 @@ export const resolveEffectiveLocale = ({

플래그가 꺼지면 사용자 설정이 `en`이든 `ja`든, 시스템 언어가 뭐든 **무조건 한국어로 떨어진다.** 로케일이 흘러갈 수 있는 유일한 문(門)에 스위치를 단 셈이다. 플래그 값은 GrowthBook에서 `i18n_enabled` 하나로 관리한다.

### 플래그가 바꾸는 것과 남겨두는 것

킬스위치는 사용자가 저장한 언어 설정을 지우는 기능이 아니다. 저장된 설정은 그대로 두고, 이번 렌더에 사용할 **유효 로케일**만 바꾼다. 다시 켰을 때 사용자의 선택을 복원할 수 있어야 하기 때문이다.

| 플래그 | 사용자 설정 | 시스템·브라우저 설정 | 이번 렌더의 결정 |
|---|---|---|---|
| 꺼짐 | `en` 또는 `ja` | 어떤 값이든 | `ko` |
| 켜짐 | 명시적인 `en` | `ja-JP` | `en` |
| 켜짐 | `system` | 지원하는 시스템 로케일 있음 | 시스템 로케일 |
| 켜짐 | `system` | 시스템 로케일을 결정하지 못함 | `Accept-Language`, 이후 기본 로케일 |

위 표는 앞의 함수에서 읽을 수 있는 분기 규칙이다. 플래그 분기를 가장 먼저 검사하므로, 꺼져 있을 때는 하위 우선순위가 결과를 뒤집을 수 없다.

## 서버와 클라이언트가 같은 결정을 하게

Next.js App Router라 로케일은 서버(레이아웃)와 클라이언트(프로바이더) 양쪽에서 쓰인다. 두 곳이 서로 다른 로케일을 고르면 hydration이 깨진다. 그래서 **둘 다 같은 `resolveEffectiveLocale`에 같은 플래그를 먹인다.**
Expand All @@ -58,13 +73,27 @@ const i18nEnabled = flagResult.values[FEATURE_FLAG_KEYS.I18N_ENABLED]?.enabled ?

## 되돌릴 수 있어야 진짜 무중단

킬스위치의 진짜 값어치는 **배포 없이 즉시 되돌릴 수 있다**는 데 있다.
킬스위치의 값어치는 **롤백용 코드를 다시 배포하지 않고도 유효 로케일을 되돌릴 수 있다**는 데 있다.

- **점진 롤아웃** - GrowthBook에서 특정 비율·특정 사용자 그룹에만 먼저 켠다
- **즉시 차단** - 번역 사고가 발견되면 플래그를 끄는 순간 전원이 `ko`로 안전 복귀한다. 롤백 배포를 기다릴 필요가 없다
- **차단** - 번역 사고가 발견되면 플래그를 끈다. 새 플래그 값을 읽은 요청은 `ko`로 돌아가므로 롤백 배포를 기다리지 않아도 된다

"기능을 못 되돌리면 못 켠다"가 원칙이었고 킬스위치가 그 원칙을 지켜줬다.

### 열린 화면까지 즉시 바뀐다는 뜻은 아니다

위 코드만으로는 이미 열린 모든 브라우저에 변경 사항이 즉시 전파된다고 보장할 수 없다. 서버가 읽은 플래그를 프로바이더에 전달하므로, 열린 문서에는 이전 값이 남아 있을 수 있다. 실제 반영 시점은 플래그 캐시와 새 요청·새 렌더가 발생하는 시점에 달려 있다.

운영 시에는 다음 경계를 확인해야 한다.

1. **대상 식별**: 서버와 클라이언트가 같은 사용자·그룹을 기준으로 플래그를 평가하는가.
2. **첫 렌더**: 서버의 `html lang`, 메시지 카탈로그, 클라이언트 초기 로케일이 같은가.
3. **플래그 캐시**: 꺼진 값을 새 요청이 언제 읽는지, 캐시 때문에 이전 값이 남는 구간이 있는가.
4. **기존 화면**: 새로고침한 화면과 이미 열려 있던 화면을 구분해 확인했는가.
5. **데이터 보존**: 차단 과정에서 사용자 언어 설정이나 한국어 언어팩을 삭제하지 않는가.

언어팩을 CDN에서 다시 받는 문제와 어떤 로케일을 선택할지는 별개다. 언어팩이 캐시에 남아 있어도 유효 로케일이 `ko`면 한국어 메시지로 렌더해야 한다.

## 테스트는 ko로 고정

로케일이 테스트마다 흔들리면 스냅샷·E2E가 불안정해진다. 그래서 테스트 환경의 로케일은 기본 `ko`로 고정했다. 그리고 "플래그가 꺼지면 무조건 ko"라는 **불변식 자체를 유닛 테스트로 박아뒀다.**
Expand All @@ -77,7 +106,21 @@ test('forces ko when i18n is disabled, regardless of language/system', () => {
})
```

이 테스트가 초록불인 한, 누가 로케일 로직을 건드려도 "킬스위치 off = 전원 ko"라는 안전판은 깨지지 않는다.
이 테스트는 "플래그 off = 유효 로케일 ko"라는 함수의 규칙을 확인한다. 서버와 브라우저가 실제로 같은 플래그를 받는지까지 증명하지는 않으므로, 화면 검증도 함께 필요하다.

다음은 위 함수의 차단 규칙을 더 넓게 확인하는 테스트 예제다. 사용자 설정과 브라우저 언어를 바꿔도 꺼진 플래그가 우선해야 한다.

```ts
test.each([
{ languageCode: 'en', systemLocale: 'ja-JP', acceptLanguage: 'en-US' },
{ languageCode: 'ja', systemLocale: 'en-US', acceptLanguage: 'ja-JP' },
{ languageCode: 'system', systemLocale: 'ja-JP', acceptLanguage: 'en-US' },
])('keeps ko while the kill switch is off: %o', (input) => {
expect(resolveEffectiveLocale({ ...input, i18nEnabled: false })).toBe('ko')
})
```

이 단위 테스트 다음에는 `en` 사용자의 첫 요청을 켜진 상태와 꺼진 상태에서 각각 열어본다. 초기 HTML과 hydration 뒤 화면에서 로케일이 바뀌거나 번역 키 자체가 노출되지 않는지 확인하는 순서다.

## 마치며

Expand All @@ -87,4 +130,4 @@ test('forces ko when i18n is disabled, regardless of language/system', () => {
- GrowthBook `i18n_enabled` 하나로 서버·클라이언트를 동시에 제어하고
- "off면 무조건 ko" 불변식을 테스트로 고정

여기까지가 프론트엔드 이야기다. 그런데 프론트에서 아무리 잘 번역해도, 서버가 한국어 응답이나 한국어 문서를 내려주면 화면에 다시 한국어가 섞인다. 마지막 편에서 백엔드가 사용자 언어·시간대를 어떻게 저장하고 응답·문서까지 로케일을 흘려보냈는지 다룬다.
여기까지가 프론트엔드 이야기다. 그런데 프론트에서 아무리 잘 번역해도, 서버가 한국어 응답이나 한국어 문서를 내려주면 화면에 다시 한국어가 섞인다. [백엔드 로케일 처리](/posts/i18n-04-backend-locale/)에서는 사용자 언어·시간대를 저장하고 응답으로 전달하는 경계를, [다국어 문서 렌더링](/posts/i18n-05-document-render/)에서는 그 로케일을 문서까지 이어가는 과정을 다룬다.
9 changes: 5 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
enableGlobalVirtualStore: false
overrides:
'uuid@11.1.0': 11.1.1
'mermaid>dompurify': 3.4.17
13 changes: 13 additions & 0 deletions public/robots.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
User-agent: *
Allow: /
# Reader-only legacy filters. Article URLs and unfiltered collections stay crawlable.
Disallow: /posts?
Disallow: /posts/?
Disallow: /tags?
Disallow: /tags/?
Disallow: /series?
Disallow: /series/?
Disallow: /en/posts?
Disallow: /en/posts/?
Disallow: /en/tags?
Disallow: /en/tags/?
Disallow: /en/series?
Disallow: /en/series/?
Sitemap: https://dev.devy.dev/sitemap.xml
Loading
Loading