rynl10n 0.2.2
rynl10n: ^0.2.2 copied to clipboard
앱 업데이트·심사 없이 번역을 배포하는 오픈소스 원격 로컬라이제이션 SDK. 빌드타임에 구운 번들(fallback)과 런타임 원격 오버레이의 2계층으로 항상 완전한 번역을 보장한다.
rynl10n (Flutter/Dart SDK) #
Flutter/Dart SDK. 순수 Dart 코어라 dart test로 검증 가능(Flutter 위젯 불요). 코어 알고리즘
(JCS·resolve·매칭·카나리·정수 매칭)은 M0 TS 참조 구현과 골든 벡터로 바이트·해시·동작 정합.
설치 #
# 앱 pubspec.yaml
dependencies:
rynl10n: ^0.2.0
pub.dev에 게시돼 있다 —
rynl10n0.2.0(2026-09-17 · 4개 SDK lockstep 6.5).dart pub add rynl10n으로도 된다. 빈 프로젝트에서pub get→t()왕복은 0.1.0 게시 직후(2026-08-27)에 확인했다.
코어를 함께 고치는 중이라면 경로 의존으로 참조한다. 패키지 tarball에는 test/가 없다
(.pubignore — 테스트가 패키지 밖 fixtures/golden/을 읽어 tarball 안에서는 돌지 않는다):
dependencies:
rynl10n:
path: ../RynL10n/sdks/flutter
사용 #
import 'dart:convert';
import 'package:rynl10n/rynl10n.dart';
final client = RynL10nClient(
bundle: Snapshot.fromJson(jsonDecode(bakedSnapshotJson)), // 빌드타임 bake된 fallback(실제로는 아래 parseBakedSnapshot)
store: myDeliveryStore, // 배포 플레인(정적) 접근 — 아래 "앱 적용 경로"
context: ClientContext(appVersion: '3.2.1'), // 어느 릴리스를 받을지(4.3)
locale: Localizations.localeOf(context).toLanguageTag(), // 그 안에서 어느 언어를 읽을지(3.1)
installId: localInstallId, // 카나리 버킷용 — 없으면 rollout<100 릴리스를 받지 않는다
);
client.refresh(manifest); // manifest는 보통 RemoteDeliveryStore.update()가 넘긴다
final s = client.t('cart.title', locale: 'ja'); // 동기 — 항상 번들 fallback
- 로케일 축은 매칭 축과 별개다:
context는 어느 릴리스를 받을지(4.3),locale은 그 안에서 어느 언어를 읽을지(3.1).t()에 로케일을 주면 그것이, 없으면locale이, 그것도 없으면 번들 기본 로케일이 쓰이며 어느 쪽이든 fallback 체인을 탄다. 코어는 플랫폼 API를 모르므로 기기 언어는 앱이 넘긴다 — Flutter는Localizations.localeOf(context).toLanguageTag()(위젯 밖이면PlatformDispatcher.instance.locale.toLanguageTag()), 순수 Dart는rynl10n_io.dart의ioDeviceLocale()(Platform.localeName의 POSIX 형식ko_KR.UTF-8을 BCP 47ko-KR로 정규화해 준다). - 갱신 이벤트:
client.onCatalogUpdated((info) => ...). Flutter는 이를ValueNotifier<int>로 감싸ValueListenableBuilder로 리빌드(어댑터는 위젯 레이어, 이 패키지는 위젯 의존성 없음). 단 맞는 릴리스가 사라져 번들로 되돌아갈 때는 이 이벤트가 오지 않는다(릴리스 id가 없어서). 되돌림까지 화면에 반영하려면update()가 끝난 뒤에도 리빌드를 건다. installId가 없으면 카나리(rollout 100% 미만) 릴리스의 오버레이를 하나도 받지 않는다(번들로 남고overlay_applied도 세지 않는다). 부분 배포를 쓸 거라면 첫 실행에 익명 UUID를 만들어shared_preferences등에 저장해 두고 넘긴다. 서버로는 보내지 않는다.- NFC 정규화는
package:unorm_dart, SHA-256은package:crypto— JCS 결정성이 타 플랫폼과 일치.
앱 적용 경로 (6.3 / 6.4) #
myDeliveryStore 자리를 앱이 손수 채울 필요가 없다. 두 조각이 SDK에 들어 있다 —
번들 로더와 배포 플레인 HTTP 구현. iOS·Android·Web과 같은 계약을 지키며 시나리오 테스트도
1:1로 맞춰 두었다.
번들은 이 패키지가 굽지 않는다 — Dart용 bake CLI나 Flutter 빌드 훅은 없다. iOS
(swift run rynl10n-bake … --stable-name <out>) 또는 Android(./gradlew rynl10nBake … -PstableName=true -Pout=<out>, JDK 21만 있으면 되고 Android SDK는 필요 없다) CLI로 앱의 assets/에 굽는다. 명령과 옵션은
Android 가이드 §3을 따른다. --stable-name을 빼면 파일명이 snapshot-<base>.json이
되어 아래 경로로 읽히지 않는다. 산출물은 자산으로 선언해야 번들에 실린다:
# 앱 pubspec.yaml
flutter:
assets:
- assets/rynl10n/
import 'dart:async'; // unawaited
import 'package:rynl10n/rynl10n.dart';
import 'package:rynl10n/rynl10n_io.dart'; // dart:io 기본 어댑터
// ① 번들 로더 — Flutter 자산은 문자열로 온다
final bundle = parseBakedSnapshot(
await rootBundle.loadString('assets/rynl10n/snapshot.json'),
source: 'assets/rynl10n/snapshot.json',
);
// ② 배포 플레인 HTTP — manifest(ETag) → 릴리스 자가 선택 → 필요한 산출물만 → 원자적 스왑
final store = RemoteDeliveryStore(
baseUrl: 'https://cdn.example.com', // 배포 플레인(정적) — 관리 API 아님
project: 'shop',
fetch: ioDeliveryFetch(),
cache: FileArtifactCache(await getApplicationCacheDirectory(), project: 'shop'),
);
final client = RynL10nClient(bundle: bundle, store: store, context: ClientContext(appVersion: '3.2.1'));
// 앱 시작 직후·포그라운드 복귀에 호출. 캐시가 없는 첫 실행이 오프라인이면 DeliveryException을
// 던지므로 삼킨다(화면은 번들로 그대로 뜬다). runApp 전에 await하지 않는다.
unawaited(store.update(client).catchError((_) => false));
- 동기 조회 / 비동기 다운로드 분리:
DeliveryStore는 동기 인터페이스라 화면이 네트워크를 기다리지 않는다.update()가 산출물을 먼저 캐시에 채운 뒤 동기 코어를 호출한다. - 불변 산출물은 영구 캐시(내용해시 URL). manifest만 ETag로 재검증하고, 오프라인이면 마지막
캐시로 진행한다. 캐시조차 없으면
DeliveryException(badStatus·unavailable·malformed). - HTTP·저장소 의존은 어댑터에만 있다. 코어(
rynl10n.dart)는DeliveryFetch·ArtifactCache를 주입받는 순수 Dart다. - 번들 로더의 파일 시스템 버전(
loadBakedSnapshot(Directory)·loadBakedLockfile)도 어댑터에 있다.
어댑터 진입점 고르기 #
| 진입점 | HTTP 백엔드 | Flutter Web | 추가 의존성 | 채우는 구멍 |
|---|---|---|---|---|
package:rynl10n/rynl10n_io.dart |
dart:io HttpClient |
✗ (컴파일 불가) | 없음 | ioDeliveryFetch · ioPushConnect · ioTelemetryPost · FileArtifactCache |
package:rynl10n/rynl10n_http.dart |
package:http |
✓ | http |
httpDeliveryFetch · httpPushConnect · httpTelemetryPost |
웹을 포함해 하나로 가려면 rynl10n_http.dart를 쓴다 — package:http가 플랫폼마다 백엔드를
알아서 고른다(웹=BrowserClient/fetch, 그 외=dart:io).
import 'package:rynl10n/rynl10n.dart';
import 'package:rynl10n/rynl10n_http.dart';
import 'package:web/web.dart' as web;
final store = RemoteDeliveryStore(
baseUrl: 'https://cdn.example.com',
project: 'shop',
fetch: httpDeliveryFetch(), // 웹·모바일·데스크톱·서버 공통
cache: CallbackArtifactCache( // 웹은 파일 시스템이 없다
read: (k) => web.window.localStorage.getItem('rynl10n:shop:$k'),
write: (k, v) => web.window.localStorage.setItem('rynl10n:shop:$k', v),
clear: () { // 이 프로젝트 키만 지운다 — localStorage.clear()는 오리진 전체를 지운다
final s = web.window.localStorage;
for (var i = s.length - 1; i >= 0; i--) {
final k = s.key(i);
if (k != null && k.startsWith('rynl10n:shop:')) s.removeItem(k);
}
},
),
);
package:web은 SDK가 끌고 오지 않는다 — 앱 pubspec.yaml에 web을 추가한다.
CallbackArtifactCache는 코어에 있고 의존성이 없다 — SDK가 저장소 패키지를 고르지 않기 위한
이음새라, 모바일에서 shared_preferences를 꽂는 데도 같은 클래스를 쓴다. 저장소가 던지는 실패
(사생활 모드·용량 초과)는 조용히 삼킨다.
갱신 자동화 — 폴링 · 실시간 푸시 (옵트인) #
update()를 앱이 직접 부르는 것 외에, 켜 두면 알아서 따라오는 두 갈래가 있다.
// ① 주기 폴링(기본 60초) — 갱신의 보장선. 배터리·트래픽은 앱이 정한다.
store.startPolling(client, interval: const Duration(seconds: 60));
// AppLifecycleState.paused 에서 store.stopPolling(), resumed 에서 다시 startPolling.
// ② 실시간 푸시(SSE) — publish 즉시 반영. 지연 단축용이라 폴링과 함께 켠다.
final push = ServerPushChannel(
endpoint: 'https://admin.example.com', // 알림(관리) 플레인 — CDN이 아니다
project: 'shop',
connect: ioPushConnect(), // 또는 httpPushConnect()
);
push.startUpdating(client, store); // 신호 → store.update(client)
// 백그라운드 전환 등에서 push.stop()
프레임은 "manifest가 바뀌었다"는 신호뿐이고, 번역 데이터는 여전히 배포 플레인에서 받는다 — 데이터 경로는 정적으로 유지된다(4.1). 연결이 끊기면 3초 → 최대 60초 백오프로 재연결하고, 그 사이 갱신은 폴링이 덮는다.
Flutter Web에서
httpPushConnect: 브라우저 백엔드는 XHR 기반이라 응답을 끝까지 모아 넘긴다 — SSE가 제때 도착하지 않는다. 웹에서는EventSource를PushConnect로 감싸 넣는다(코어는 함수 하나만 요구한다 — 예시는lib/src/http_adapter.dart문서 주석). 폴링이 보장선이라 그대로 둬도 기능은 깨지지 않고 지연만 남는다.
배포 건전성 텔레메트리 (옵트인, 9.3) #
대시보드 관측성 탭과 releases/{r}/health(카나리 판정의 입력)를 채우는 익명 집계다.
두 번 옵트인해야 한다 — 수집(RynL10nClient(telemetry: 'aggregate'))과 전송(리포터 생성).
final reporter = TelemetryReporter(
endpoint: 'https://admin.example.com', // 관리 플레인(업로드는 쓰기 경로)
project: 'shop',
post: ioTelemetryPost(), // 또는 httpTelemetryPost()
);
reporter.start(client); // 기본 5분 주기
// 백그라운드 전환처럼 확실히 올리고 싶은 시점: await reporter.flush(client);
올라가는 것은 서버가 정의한 5개 필드가 전부다(projectId·releaseId·event·count·
appVersionBucket). 그 외 필드는 서버가 배치째 거부하므로(프라이버시 가드) 키 이름·번역 값·기기
식별자는 구조적으로 나갈 수 없다. 카나리 버킷의 installId도 보내지 않는다.
appVersionBucket은 개별 빌드가 아니라 버전군이다(3.2.1 → 3.2).
전송 실패 시 카운트를 되돌려 다음 주기에 다시 올린다(실패 구간이 사라지면 거부율이 실제보다 낮게 보인다).
브라우저에서 반드시 확인할 것 — 배포 플레인 CORS #
배포 플레인이 앱과 다른 오리진이면(= CDN을 쓰면 거의 항상) 다음 응답 헤더가 필요하다.
셀프호스트 참조 서버는 이미 셋 다 보낸다(backend/src/storage/delivery-server.ts).
Access-Control-Allow-Origin— 없으면 브라우저가 응답 자체를 막는다.Access-Control-Expose-Headers: ETag— ETag는 CORS 안전목록 응답 헤더가 아니다. 노출하지 않으면 JS가etag를 읽지 못해 조건부 요청이 영영 성립하지 않는다(동작은 하지만 폴링마다 manifest를 전량 다시 받는다).Access-Control-Allow-Headers: If-None-Match— 이 요청 헤더는 preflight를 유발한다.
헤더가 없어도 SDK는 죽지 않는다 — etag가 null이면 조건부 요청을 생략하고, 응답이 막히면 마지막 캐시(또는 번들 fallback)로 진행한다.
검증 #
cd sdks/flutter && dart pub get && dart test # 골든 11 + 시나리오 5 + 앱 적용 경로 19 + http 어댑터 6
# + 폴링·푸시·텔레메트리 8 + 로케일 축 5 = 54 tests
M4 기능 #
- 정수 버전 매칭:
ClientContext(buildNumber: 4210)+VersionMatch('integer-range', '>=42 <50'). - 카나리(8.4): manifest
rollout<100이면sha256('$installId:$releaseId')버킷(0..99)< rollout만 오버레이 수신. 안전 기본값 rollout 100(전체). installId=기기 로컬 익명 난수(서버 미전송). installId를 넘기지 않으면 rollout<100 릴리스는 받지 않는다.