Flutter에서 국제화(i18n)와 현지화(l10n)를 어떻게 구현하나요?

질문

Flutter 앱에서 다양한 언어와 지역에 맞는 국제화(i18n)와 현지화(l10n)를 어떻게 구현하나요? 주요 접근 방식과 권장 패키지를 설명해주세요.

답변

Flutter에서 국제화(Internationalization, i18n)와 현지화(Localization, l10n)를 구현하면 앱을 다양한 언어와 지역에 맞게 조정할 수 있습니다. 이는 글로벌 사용자 기반을 확보하는 데 중요한 요소입니다.

1. Flutter 국제화 기본 개념

국제화(i18n)는 앱이 다양한 언어와 지역을 지원할 수 있도록 준비하는 과정이며, 현지화(l10n)는 특정 언어와 지역에 맞게 앱의 콘텐츠를 조정하는 과정입니다.

2. Flutter 국제화 구현 방법

2.1 flutter_localizations 패키지 사용하기

Flutter SDK에서 제공하는 기본 국제화 패키지입니다.

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  intl: ^0.17.0

2.2 앱 설정하기

// main.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/app_localizations.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // 지원할 언어 목록
      supportedLocales: [
        Locale('en', ''), // 영어
        Locale('ko', ''), // 한국어
        Locale('ja', ''), // 일본어
        Locale('zh', ''), // 중국어
      ],
      // 로컬라이제이션 델리게이트
      localizationsDelegates: [
        // 앱 전용 로컬라이제이션
        AppLocalizations.delegate,
        // 기본 Material 위젯 로컬라이제이션
        GlobalMaterialLocalizations.delegate,
        // RTL 등 텍스트 방향 로컬라이제이션
        GlobalWidgetsLocalizations.delegate,
        // 기본 Cupertino 위젯 로컬라이제이션
        GlobalCupertinoLocalizations.delegate,
      ],
      // 기기 언어와 일치하는 언어를 없을 때 사용할 로케일
      localeResolutionCallback: (locale, supportedLocales) {
        for (var supportedLocale in supportedLocales) {
          if (supportedLocale.languageCode == locale?.languageCode) {
            return supportedLocale;
          }
        }
        // 기본 언어로 영어 사용
        return supportedLocales.first;
      },
      home: HomePage(),
    );
  }
}

2.3 ARB(Application Resource Bundle) 파일 생성

각 언어별로 메시지를 정의합니다.

project/
  lib/
    l10n/
      intl_en.arb (영어)
      intl_ko.arb (한국어)
      intl_ja.arb (일본어)
      intl_zh.arb (중국어)

영어 메시지 파일 예시:

// intl_en.arb
{
  "@@locale": "en",
  "helloWorld": "Hello World",
  "@helloWorld": {
    "description": "A greeting"
  },
  "hello": "Hello {name}",
  "@hello": {
    "description": "A greeting with a name parameter",
    "placeholders": {
      "name": {
        "type": "String",
        "example": "Jane"
      }
    }
  },
  "itemCount": "{count, plural, =0{No items} =1{1 item} other{{count} items}}",
  "@itemCount": {
    "description": "The number of items",
    "placeholders": {
      "count": {
        "type": "int",
        "format": "compact"
      }
    }
  }
}

한국어 메시지 파일 예시:

// intl_ko.arb
{
  "@@locale": "ko",
  "helloWorld": "안녕하세요",
  "hello": "{name}님 안녕하세요",
  "itemCount": "{count, plural, =0{항목 없음} =1{1개 항목} other{{count}개 항목}}"
}

2.4 로컬라이제이션 클래스 구현

수동으로 구현하거나 코드 생성 도구를 사용할 수 있습니다. 여기서는 코드 생성 접근 방식을 보여줍니다.

# pubspec.yaml
dependencies:
  # ...기존 의존성들

flutter:
  generate: true # 로컬라이제이션 코드 생성 활성화

생성을 위한 l10n.yaml 설정 파일:

# l10n.yaml
arb-dir: lib/l10n
template-arb-file: intl_en.arb
output-localization-file: app_localizations.dart

2.5 생성된 로컬라이제이션 사용하기

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // AppLocalizations 인스턴스 가져오기
    final localizations = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(
        title: Text(localizations.helloWorld),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              localizations.hello('Flutter 개발자'),
              style: TextStyle(fontSize: 24),
            ),
            SizedBox(height: 20),
            Text(
              localizations.itemCount(5),
              style: TextStyle(fontSize: 18),
            ),
          ],
        ),
      ),
    );
  }
}

3. 고급 국제화 기능

3.1 로케일 변경하기

provider 패키지와 함께 사용하여 앱 내에서 언어를 변경할 수 있는 기능을 제공할 수 있습니다.

// locale_provider.dart
import 'package:flutter/material.dart';

class LocaleProvider extends ChangeNotifier {
  Locale _locale = Locale('en', '');

  Locale get locale => _locale;

  void setLocale(Locale locale) {
    _locale = locale;
    notifyListeners();
  }
}

// main.dart
import 'package:provider/provider.dart';
import 'locale_provider.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => LocaleProvider(),
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer<LocaleProvider>(
      builder: (context, localeProvider, child) {
        return MaterialApp(
          locale: localeProvider.locale,
          supportedLocales: [
            Locale('en', ''),
            Locale('ko', ''),
            Locale('ja', ''),
            Locale('zh', ''),
          ],
          localizationsDelegates: [
            AppLocalizations.delegate,
            GlobalMaterialLocalizations.delegate,
            GlobalWidgetsLocalizations.delegate,
            GlobalCupertinoLocalizations.delegate,
          ],
          // ...기타 설정
          home: HomePage(),
        );
      },
    );
  }
}

// 언어 선택 화면
class LanguageSelectionPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final localeProvider = Provider.of<LocaleProvider>(context);

    return Scaffold(
      appBar: AppBar(
        title: Text(AppLocalizations.of(context)!.languageSelection),
      ),
      body: ListView(
        children: [
          _buildLanguageItem(context, 'English', Locale('en', ''), localeProvider),
          _buildLanguageItem(context, '한국어', Locale('ko', ''), localeProvider),
          _buildLanguageItem(context, '日本語', Locale('ja', ''), localeProvider),
          _buildLanguageItem(context, '中文', Locale('zh', ''), localeProvider),
        ],
      ),
    );
  }

  Widget _buildLanguageItem(BuildContext context, String language,
                          Locale locale, LocaleProvider provider) {
    final isSelected = provider.locale.languageCode == locale.languageCode;

    return ListTile(
      title: Text(language),
      trailing: isSelected ? Icon(Icons.check) : null,
      onTap: () {
        provider.setLocale(locale);
      },
    );
  }
}

3.2 날짜, 시간, 숫자 형식 현지화

intl 패키지를 사용하여 날짜, 시간, 숫자를 현지화할 수 있습니다.

import 'package:intl/intl.dart';

class FormattingExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 현재 로케일 가져오기
    final locale = Localizations.localeOf(context);
    final now = DateTime.now();

    // 날짜 형식
    final dateFormatter = DateFormat.yMMMMd(locale.toString());
    final formattedDate = dateFormatter.format(now);

    // 시간 형식
    final timeFormatter = DateFormat.Hms(locale.toString());
    final formattedTime = timeFormatter.format(now);

    // 화폐 형식
    final currencyFormatter = NumberFormat.currency(
      locale: locale.toString(),
      symbol: _getCurrencySymbol(locale.languageCode),
    );
    final formattedCurrency = currencyFormatter.format(1234.56);

    // 숫자 형식
    final numberFormatter = NumberFormat.decimalPattern(locale.toString());
    final formattedNumber = numberFormatter.format(9876543.21);

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text('날짜: $formattedDate'),
        Text('시간: $formattedTime'),
        Text('화폐: $formattedCurrency'),
        Text('숫자: $formattedNumber'),
      ],
    );
  }

  String _getCurrencySymbol(String languageCode) {
    switch (languageCode) {
      case 'ko': return '₩';
      case 'ja': return '¥';
      case 'zh': return '¥';
      default: return '\$';
    }
  }
}

3.3 RTL(Right-to-Left) 언어 지원

아랍어나 히브리어와 같은 RTL 언어를 지원하기 위한 설정입니다.

// main.dart
import 'package:flutter/material.dart';

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // ... 기존 설정들
      supportedLocales: [
        Locale('en', ''),
        Locale('ko', ''),
        Locale('ar', ''), // 아랍어 (RTL)
        Locale('he', ''), // 히브리어 (RTL)
      ],
      localizationsDelegates: [
        // ... 기존 델리게이트들
      ],
      // RTL 언어를 자동으로 감지하고 UI 방향 조정
      builder: (context, child) {
        return Directionality(
          textDirection: _getTextDirection(Localizations.localeOf(context)),
          child: child!,
        );
      },
    );
  }

  TextDirection _getTextDirection(Locale locale) {
    // RTL 언어 목록
    const rtlLanguages = ['ar', 'fa', 'he', 'ur'];

    if (rtlLanguages.contains(locale.languageCode)) {
      return TextDirection.rtl;
    }

    return TextDirection.ltr;
  }
}

3.4 텍스트 스타일과 폰트 크기 조정

언어별로 다른 폰트와 텍스트 스타일을 적용할 수 있습니다.

class LocalizedText extends StatelessWidget {
  final String text;
  final TextStyle? baseStyle;

  LocalizedText(this.text, {this.baseStyle});

  @override
  Widget build(BuildContext context) {
    final locale = Localizations.localeOf(context).languageCode;

    // 언어별 텍스트 스타일 조정
    TextStyle adjustedStyle = baseStyle ?? TextStyle();

    switch (locale) {
      case 'ko':
        // 한국어는 약간 더 작은 폰트 크기
        adjustedStyle = adjustedStyle.copyWith(
          fontFamily: 'NotoSansKR',
          fontSize: adjustedStyle.fontSize != null
                    ? adjustedStyle.fontSize! * 0.95
                    : 14.0,
        );
        break;
      case 'ja':
        // 일본어는 다른 폰트와 줄 높이
        adjustedStyle = adjustedStyle.copyWith(
          fontFamily: 'NotoSansJP',
          height: 1.2,
        );
        break;
      case 'zh':
        // 중국어는 다른 폰트
        adjustedStyle = adjustedStyle.copyWith(
          fontFamily: 'NotoSansSC',
        );
        break;
      case 'ar':
      case 'he':
        // RTL 언어는 다른 폰트
        adjustedStyle = adjustedStyle.copyWith(
          fontFamily: 'Scheherazade',
        );
        break;
      default:
        // 기본 폰트
        adjustedStyle = adjustedStyle.copyWith(
          fontFamily: 'Roboto',
        );
    }

    return Text(
      text,
      style: adjustedStyle,
    );
  }
}

4. 효율적인 현지화 워크플로우

4.1 easy_localization 패키지 사용하기

easy_localization 패키지는 JSON/YAML 파일을 사용한 간단한 현지화 솔루션을 제공합니다.

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  easy_localization: ^3.0.1
// main.dart
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await EasyLocalization.ensureInitialized();

  runApp(
    EasyLocalization(
      supportedLocales: [
        Locale('en', ''),
        Locale('ko', ''),
        Locale('ja', ''),
        Locale('zh', ''),
      ],
      path: 'assets/translations', // JSON 파일 경로
      fallbackLocale: Locale('en', ''),
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      localizationsDelegates: context.localizationDelegates,
      supportedLocales: context.supportedLocales,
      locale: context.locale,
      home: HomePage(),
    );
  }
}

// 사용 예시
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('hello_world'.tr()), // 'hello_world' 키의 번역 사용
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(
              'hello'.tr(args: ['Flutter 개발자']), // 매개변수가 있는 번역
              style: TextStyle(fontSize: 24),
            ),
            SizedBox(height: 20),
            Text(
              'item_count'.tr(namedArgs: {'count': '5'}), // 이름이 있는 매개변수 사용
              style: TextStyle(fontSize: 18),
            ),

            // 언어 변경 버튼들
            ElevatedButton(
              onPressed: () => context.setLocale(Locale('en', '')),
              child: Text('English'),
            ),
            ElevatedButton(
              onPressed: () => context.setLocale(Locale('ko', '')),
              child: Text('한국어'),
            ),
          ],
        ),
      ),
    );
  }
}

JSON 파일 예시:

// assets/translations/en.json
{
  "hello_world": "Hello World",
  "hello": "Hello {}",
  "item_count": "{count} items"
}

// assets/translations/ko.json
{
  "hello_world": "안녕하세요",
  "hello": "{}님 안녕하세요",
  "item_count": "{count}개 항목"
}

4.2 현지화 관리 도구

대규모 앱에서는 번역을 관리하기 위한 도구를 사용하는 것이 좋습니다:

  1. POEditor: 온라인 현지화 관리 플랫폼
  2. Lokalise: 팀 기반 번역 관리 도구
  3. Phrase: 대규모 번역 프로젝트 관리 플랫폼

이러한 도구를 사용하면 ARB 또는 JSON 파일을 쉽게 관리하고 번역자와 협업할 수 있습니다.

4.3 자동 번역 API 통합

Google Translate API나 DeepL API를 사용하여 초기 번역을 자동화할 수 있습니다.

// 예시: Google Translate API 통합 (실제 구현 시 서버 측에서 수행하는 것이 더 적합)
Future<Map<String, dynamic>> generateInitialTranslations(
    Map<String, dynamic> sourceTranslations, String targetLanguage) async {
  final Map<String, dynamic> translations = {};
  final apiKey = 'YOUR_GOOGLE_TRANSLATE_API_KEY';

  for (var key in sourceTranslations.keys) {
    final sourceText = sourceTranslations[key];
    final response = await http.post(
      Uri.parse('https://translation.googleapis.com/language/translate/v2'),
      body: {
        'q': sourceText,
        'target': targetLanguage,
        'key': apiKey,
      },
    );

    final data = jsonDecode(response.body);
    final translatedText = data['data']['translations'][0]['translatedText'];
    translations[key] = translatedText;
  }

  return translations;
}

5. i18n 모범 사례

5.1 번역문에서 하드코딩된 문자열 피하기

// 잘못된 방법
Text('Welcome to $appName'); // 하드코딩된 'Welcome to' 문자열

// 좋은 방법
Text(localizations.welcomeTo(appName));

5.2 문맥 정보 제공하기

ARB 파일에 설명과 예시를 추가하여 번역자에게 문맥 정보를 제공합니다.

{
  "signin": "Sign in",
  "@signin": {
    "description": "Label for button on login screen",
    "context": "Used as a verb, not as a noun"
  }
}

5.3 다양한 복수형 규칙 처리

언어마다 복수형 규칙이 다르므로 ICU 메시지 구문을 사용합니다.

{
  "itemCount": "{count, plural, =0{No items} =1{1 item} =2{2 items} few{A few items} many{Many items} other{{count} items}}",
  "@itemCount": {
    "description": "Number of items",
    "placeholders": {
      "count": {
        "type": "int"
      }
    }
  }
}

5.4 자동화된 검증

번역 누락이나 오류를 감지하기 위한 검증 스크립트를 구현합니다.

// 번역 파일 검증 유틸리티
Future<bool> validateTranslations() async {
  final directory = Directory('lib/l10n');
  final baseFile = File('${directory.path}/intl_en.arb');
  final baseJson = jsonDecode(await baseFile.readAsString());
  final baseKeys = baseJson.keys.where((k) => !k.startsWith('@')).toList();

  bool isValid = true;

  final files = directory.listSync().where(
    (file) => file.path.endsWith('.arb') && !file.path.endsWith('intl_en.arb')
  );

  for (final file in files) {
    final content = await File(file.path).readAsString();
    final json = jsonDecode(content);
    final keys = json.keys.where((k) => !k.startsWith('@')).toList();

    // 누락된 키 확인
    final missingKeys = baseKeys.where((key) => !keys.contains(key)).toList();

    if (missingKeys.isNotEmpty) {
      print('${file.path} is missing translations for: ${missingKeys.join(', ')}');
      isValid = false;
    }

    // 추가 키 확인
    final extraKeys = keys.where((key) => !baseKeys.contains(key)).toList();

    if (extraKeys.isNotEmpty) {
      print('${file.path} contains extra keys: ${extraKeys.join(', ')}');
      // 추가 키는 오류로 간주하지 않음, 단지 경고
    }
  }

  return isValid;
}

요약

Flutter에서 국제화와 현지화를 구현하는 주요 방법:

  1. 기본 접근 방식: flutter_localizations 패키지와 ARB 파일을 사용하여 Flutter 기본 국제화 지원을 활용

  2. 코드 생성: flutter gen-l10n 명령어로 타입 안전한 로컬라이제이션 클래스 생성

  3. 타사 패키지: easy_localization, intl_utils 등의 패키지를 사용하여 편리한 현지화 기능 제공

  4. 고급 기능: 날짜/시간 형식, RTL 언어 지원, 언어별 텍스트 스타일 조정 등 구현

  5. 워크플로우 최적화: 번역 관리 도구와 자동화된 검증 스크립트를 사용하여 현지화 프로세스 효율화

국제화는 전 세계 사용자에게 앱을 제공하기 위한 필수 과정이며, Flutter는 이를 위한 강력한 도구와 패키지를 제공합니다. 앱의 규모와 요구 사항에 맞는 접근 방식을 선택하는 것이 중요합니다.

results matching ""

    No results matching ""