다양한 언어에 맞는 올바른 복수형을 어떻게 선택하나요?

JavaScript의 Intl.PluralRules를 사용하여 언어별 규칙에 따라 한 개 항목, 두 개 항목, 몇 개 항목, 많은 항목 중에서 선택하세요

소개

수량과 함께 텍스트를 표시할 때는 개수에 따라 다른 메시지가 필요합니다. 영어에서는 "1 file"이라고 쓰지만 "2 files"라고 씁니다. 가장 간단한 방법은 숫자와 단어를 연결하고 필요할 때 "s"를 추가하는 것입니다.

function formatFileCount(count) {
  return count === 1 ? `${count} file` : `${count} files`;
}

이 접근 방식은 세 가지 측면에서 실패합니다. 첫째, 0에 대해 잘못된 영어를 생성합니다("0 files"는 "no files"가 되어야 합니다). 둘째, "1 child, 2 children" 또는 "1 person, 2 people"과 같은 복잡한 복수형에서 문제가 발생합니다. 셋째, 가장 중요한 것은 다른 언어들이 이 코드로는 처리할 수 없는 완전히 다른 복수형 규칙을 가지고 있다는 점입니다.

JavaScript는 이 문제를 해결하기 위해 Intl.PluralRules를 제공합니다. 이 API는 전 세계 전문 번역 시스템에서 사용하는 유니코드 CLDR 표준을 따라 모든 언어에서 모든 숫자에 대해 어떤 복수형을 사용할지 결정합니다.

다양한 언어에 서로 다른 복수형이 필요한 이유

영어는 두 가지 복수형을 사용합니다. "1 book"과 "2 books"라고 씁니다. 개수가 정확히 1일 때와 다른 숫자일 때 단어가 변경됩니다.

다른 언어들은 다르게 작동합니다. 폴란드어는 복잡한 규칙에 따라 세 가지 형태를 사용합니다. 러시아어는 네 가지 형태를 사용합니다. 아랍어는 여섯 가지 형태를 사용합니다. 일부 언어는 모든 수량에 대해 하나의 형태만 사용합니다.

다음은 다양한 언어에서 수량에 따라 "사과"를 의미하는 단어가 어떻게 변하는지 보여주는 예시입니다:

영어: 1 apple, 2 apples, 5 apples, 0 apples

폴란드어: 1 jabłko, 2 jabłka, 5 jabłek, 0 jabłek

러시아어: 1 яблоко, 2 яблока, 5 яблок, 0 яблок

아랍어: 0개, 1개, 2개, 몇 개, 많은 개, 기타 수량에 따라 6가지 다른 형태를 사용합니다

Unicode CLDR은 모든 언어에서 각 형태를 사용해야 하는 정확한 규칙을 정의합니다. 이러한 규칙을 암기하거나 애플리케이션에 하드코딩할 수 없습니다. 이러한 규칙을 알고 있는 API가 필요합니다.

CLDR 복수형 카테고리란 무엇인가

Unicode CLDR 표준은 모든 언어를 포괄하는 6가지 복수형 카테고리를 정의합니다:

  • zero: 일부 언어에서 정확히 0개의 항목에 사용됨
  • one: 단수형에 사용됨
  • two: 쌍수형이 있는 언어에서 사용됨
  • few: 일부 언어에서 소량에 사용됨
  • many: 일부 언어에서 더 큰 수량이나 분수에 사용됨
  • other: 기본 형태로, 다른 범주가 적용되지 않을 때 사용됨

모든 언어는 other 범주를 사용합니다. 대부분의 언어는 총 2~3개의 범주만 사용합니다. 범주는 수량과 직접적으로 대응하지 않습니다. 예를 들어, 폴란드어에서 숫자 5는 many 범주를 사용하지만, 0, 25, 1.5도 마찬가지입니다.

어떤 숫자가 어떤 범주에 매핑되는지에 대한 구체적인 규칙은 언어마다 다릅니다. JavaScript는 Intl.PluralRules API를 통해 이러한 복잡성을 처리합니다.

사용할 복수형을 결정하는 방법

Intl.PluralRules 객체는 특정 언어에서 숫자가 속하는 복수 범주를 결정합니다. 로케일로 PluralRules 객체를 생성한 다음, 숫자와 함께 select() 메서드를 호출합니다.

const rules = new Intl.PluralRules('en-US');
console.log(rules.select(0));  // "other"
console.log(rules.select(1));  // "one"
console.log(rules.select(2));  // "other"
console.log(rules.select(5));  // "other"

영어에서 select()는 숫자 1에 대해 "one"를 반환하고 그 외 모든 것에 대해 "other"를 반환합니다.

폴란드어는 더 복잡한 규칙을 가진 3가지 카테고리를 사용합니다:

const rules = new Intl.PluralRules('pl-PL');
console.log(rules.select(0));   // "many"
console.log(rules.select(1));   // "one"
console.log(rules.select(2));   // "few"
console.log(rules.select(5));   // "many"
console.log(rules.select(22));  // "few"
console.log(rules.select(25));  // "many"

아랍어는 6개의 범주를 사용합니다:

const rules = new Intl.PluralRules('ar-EG');
console.log(rules.select(0));   // "zero"
console.log(rules.select(1));   // "one"
console.log(rules.select(2));   // "two"
console.log(rules.select(3));   // "few"
console.log(rules.select(11));  // "many"
console.log(rules.select(100)); // "other"

select() 메서드는 범주를 식별하는 문자열을 반환합니다. 이 문자열을 사용하여 표시할 적절한 메시지를 선택합니다.

복수 범주를 메시지에 매핑하는 방법

복수 범주를 결정한 후 사용자에게 표시할 올바른 메시지를 선택해야 합니다. 각 범주를 해당 메시지에 매핑하는 객체를 생성한 다음 범주 문자열을 사용하여 메시지를 조회합니다.

const messages = {
  one: '{count} file',
  other: '{count} files'
};

function formatFileCount(count, locale) {
  const rules = new Intl.PluralRules(locale);
  const category = rules.select(count);
  const message = messages[category];
  return message.replace('{count}', count);
}

console.log(formatFileCount(1, 'en-US'));  // "1 file"
console.log(formatFileCount(5, 'en-US'));  // "5 files"

이 패턴은 모든 언어에서 작동합니다. 폴란드어의 경우 해당 언어가 사용하는 세 가지 범주 모두에 대한 메시지를 제공합니다:

const messages = {
  one: '{count} plik',
  few: '{count} pliki',
  many: '{count} plików'
};

function formatFileCount(count, locale) {
  const rules = new Intl.PluralRules(locale);
  const category = rules.select(count);
  const message = messages[category];
  return message.replace('{count}', count);
}

console.log(formatFileCount(1, 'pl-PL'));   // "1 plik"
console.log(formatFileCount(2, 'pl-PL'));   // "2 pliki"
console.log(formatFileCount(5, 'pl-PL'));   // "5 plików"
console.log(formatFileCount(22, 'pl-PL'));  // "22 pliki"

코드 구조는 언어에 관계없이 동일하게 유지됩니다. messages 객체만 변경됩니다. 이러한 분리를 통해 번역가는 코드를 수정하지 않고도 해당 언어에 맞는 올바른 메시지를 제공할 수 있습니다.

누락된 복수 범주를 처리하는 방법

메시지 객체에 가능한 6개의 카테고리가 모두 포함되지 않을 수 있습니다. 대부분의 언어는 2개 또는 3개만 사용합니다. select()가 메시지 객체에 없는 카테고리를 반환하면 other 카테고리로 대체됩니다.

function formatFileCount(count, locale, messages) {
  const rules = new Intl.PluralRules(locale);
  const category = rules.select(count);
  const message = messages[category] || messages.other;
  return message.replace('{count}', count);
}

const englishMessages = {
  one: '{count} file',
  other: '{count} files'
};

console.log(formatFileCount(1, 'en-US', englishMessages));  // "1 file"
console.log(formatFileCount(5, 'en-US', englishMessages));  // "5 files"

이 패턴은 메시지 객체가 불완전한 경우에도 코드가 작동하도록 보장합니다. other 카테고리는 모든 언어에 항상 존재하므로 안전한 대체 옵션입니다.

서수와 함께 복수 규칙을 사용하는 방법

Intl.PluralRules 생성자는 카테고리 결정 방식을 변경하는 type 옵션을 허용합니다. 기본 타입은 항목 개수를 세는 데 사용되는 "cardinal"입니다. "1st", "2nd", "3rd"와 같은 서수의 복수형을 결정하려면 type: "ordinal"를 설정하세요.

const cardinalRules = new Intl.PluralRules('en-US', { type: 'cardinal' });
console.log(cardinalRules.select(1));  // "one"
console.log(cardinalRules.select(2));  // "other"
console.log(cardinalRules.select(3));  // "other"

const ordinalRules = new Intl.PluralRules('en-US', { type: 'ordinal' });
console.log(ordinalRules.select(1));   // "one"
console.log(ordinalRules.select(2));   // "two"
console.log(ordinalRules.select(3));   // "few"
console.log(ordinalRules.select(4));   // "other"

기수 규칙은 "1 item, 2 items"를 결정합니다. 서수 규칙은 "1st place, 2nd place, 3rd place, 4th place"를 결정합니다. 문법적 패턴이 다르기 때문에 반환되는 범주도 다릅니다.

분수 수량 형식 지정 방법

select() 메서드는 소수와 함께 작동합니다. 언어마다 분수가 복수 카테고리에 매핑되는 방식에 대한 특정 규칙이 있습니다.

const rules = new Intl.PluralRules('en-US');
console.log(rules.select(1));    // "one"
console.log(rules.select(1.0));  // "one"
console.log(rules.select(1.5));  // "other"
console.log(rules.select(0.5));  // "other"

영어에서 1.0은 단수형을 사용하지만 1.5는 복수형을 사용합니다. 일부 언어는 분수에 대해 다른 규칙을 가지고 있으며, 분수를 별도의 범주로 취급합니다.

const messages = {
  one: '{count} file',
  other: '{count} files'
};

const rules = new Intl.PluralRules('en-US');
const count = 1.5;
const category = rules.select(count);
const message = messages[category];
console.log(message.replace('{count}', count));  // "1.5 files"

소수를 select()에 직접 전달하세요. 메서드가 올바른 언어 규칙을 자동으로 적용합니다.

재사용 가능한 복수형 포맷터 생성 방법

애플리케이션 전체에서 동일한 패턴을 반복하는 대신, 복수형 선택 로직을 캡슐화하는 재사용 가능한 함수를 생성하세요.

class PluralFormatter {
  constructor(locale) {
    this.locale = locale;
    this.rules = new Intl.PluralRules(locale);
  }

  format(count, messages) {
    const category = this.rules.select(count);
    const message = messages[category] || messages.other;
    return message.replace('{count}', count);
  }
}

const formatter = new PluralFormatter('en-US');

const fileMessages = {
  one: '{count} file',
  other: '{count} files'
};

const itemMessages = {
  one: '{count} item',
  other: '{count} items'
};

console.log(formatter.format(1, fileMessages));  // "1 file"
console.log(formatter.format(5, fileMessages));  // "5 files"
console.log(formatter.format(1, itemMessages));  // "1 item"
console.log(formatter.format(3, itemMessages));  // "3 items"

이 클래스는 PluralRules 객체를 한 번 생성하고 여러 포맷 작업에 재사용합니다. Intl.NumberFormat로 개수를 포맷한 후 메시지에 삽입하는 것과 같은 고급 기능을 지원하도록 확장할 수 있습니다.

번역 시스템과 복수형 규칙 통합 방법

전문 번역 시스템은 복수형 범주에 대한 플레이스홀더가 있는 메시지를 저장합니다. 텍스트를 번역할 때 해당 언어에 필요한 모든 복수형을 제공합니다.

const translations = {
  'en-US': {
    fileCount: {
      one: '{count} file',
      other: '{count} files'
    },
    downloadComplete: {
      one: 'Download of {count} file complete',
      other: 'Download of {count} files complete'
    }
  },
  'pl-PL': {
    fileCount: {
      one: '{count} plik',
      few: '{count} pliki',
      many: '{count} plików'
    },
    downloadComplete: {
      one: 'Pobieranie {count} pliku zakończone',
      few: 'Pobieranie {count} plików zakończone',
      many: 'Pobieranie {count} plików zakończone'
    }
  }
};

class Translator {
  constructor(locale, translations) {
    this.locale = locale;
    this.translations = translations[locale] || {};
    this.rules = new Intl.PluralRules(locale);
  }

  translate(key, count) {
    const messages = this.translations[key];
    if (!messages) return key;

    const category = this.rules.select(count);
    const message = messages[category] || messages.other;
    return message.replace('{count}', count);
  }
}

const translator = new Translator('en-US', translations);
console.log(translator.translate('fileCount', 1));         // "1 file"
console.log(translator.translate('fileCount', 5));         // "5 files"
console.log(translator.translate('downloadComplete', 1));  // "Download of 1 file complete"
console.log(translator.translate('downloadComplete', 5));  // "Download of 5 files complete"

const polishTranslator = new Translator('pl-PL', translations);
console.log(polishTranslator.translate('fileCount', 1));   // "1 plik"
console.log(polishTranslator.translate('fileCount', 2));   // "2 pliki"
console.log(polishTranslator.translate('fileCount', 5));   // "5 plików"

이 패턴은 번역 데이터를 코드 로직에서 분리합니다. 번역가는 해당 언어가 사용하는 각 복수형 범주에 대한 메시지를 제공합니다. 코드는 규칙을 자동으로 적용합니다.

로케일이 사용하는 복수형 범주 확인 방법

resolvedOptions() 메서드는 PluralRules 객체에 대한 정보를 반환하지만 로케일이 사용하는 카테고리를 나열하지는 않습니다. 로케일이 사용하는 모든 카테고리를 찾으려면 숫자 범위를 테스트하고 반환된 고유 카테고리를 수집하세요.

function getPluralCategories(locale) {
  const rules = new Intl.PluralRules(locale);
  const categories = new Set();

  for (let i = 0; i <= 100; i++) {
    categories.add(rules.select(i));
    categories.add(rules.select(i + 0.5));
  }

  return Array.from(categories).sort();
}

console.log(getPluralCategories('en-US'));  // ["one", "other"]
console.log(getPluralCategories('pl-PL'));  // ["few", "many", "one"]
console.log(getPluralCategories('ar-EG'));  // ["few", "many", "one", "other", "two", "zero"]

이 기법은 범위 전체에서 정수와 반값을 테스트합니다. 특정 로케일에 대해 메시지 객체에 포함해야 하는 범주를 캡처합니다.