InfoGrab DocsInfoGrab Docs

복수형(Pluralization)

요약

복수형 처리는 국제화 결함의 가장 흔한 원인 중 하나입니다. GitLab은 복수형 처리에 GNU gettext를 사용합니다. Gettext는 카운트 하나를 평가해 어떤 복수 형태를 사용할지 결정합니다. 각 대상 언어는 PO 파일 헤더(Plural-Forms)에 자체 복수형 규칙을 정의하며, 이 규칙이 카운트를 올바른 msgstr[] 슬롯에 매핑합니다.

복수형 처리는 국제화 결함의 가장 흔한 원인 중 하나입니다. 영어에는 복수 형태가 두 가지(단수형과 기타)뿐이지만, 더 많은 형태를 가진 언어도 많습니다. 폴란드어와 우크라이나어는 네 가지, 아랍어는 여섯 가지 형태를 사용합니다. 잘못된 복수형 처리는 수백만 명의 사용자에게 문법이 깨진 문장을 보여 줍니다.

GitLab에서 복수형 처리 방식#

GitLab은 복수형 처리에 GNU gettext를 사용합니다. n_()(Ruby/HAML)와 n__()(JavaScript) 함수는 카운트에 따라 올바른 복수 형태를 선택합니다.

# Ruby/HAML
n_('Apple', 'Apples', count)
// JavaScript
n__('Apple', 'Apples', count)

Gettext는 카운트 하나를 평가해 어떤 복수 형태를 사용할지 결정합니다.

ngettext(singular, plural, count)
                            ↑
                     one number only

각 대상 언어는 PO 파일 헤더(Plural-Forms)에 자체 복수형 규칙을 정의하며, 이 규칙이 카운트를 올바른 msgstr[] 슬롯에 매핑합니다. 번역자는 해당 언어가 요구하는 만큼의 형태를 제공합니다.

n_()과 n__()을 사용해야 하는 경우#

단어 형태가 카운트에 따라 바뀌면 n_() 이나 n__()을 사용합니다.

판단 기준은 명사나 동사가 수에 따라 다르게 굴절하는지 여부입니다.

// Correct: "day" changes form based on count
n__('Last day', 'Last %d days', count)

// Correct: "issue" changes form
n__('%d issue', '%d issues', count)

n_()과 n__()은 같은 문자열의 복수 형태 중 하나를 선택할 때만 사용합니다. 서로 다른 문자열 사이의 로직을 제어하는 데에는 사용하지 않습니다.

문자열에 카운트 변수가 있다면 명사에 복수형 처리가 필요한지 확인합니다. 흔한 실수는 카운트를 %{variable}로 전달하면서 n__() 대신 __() 나 s__()를 사용해 명사가 항상 단수형으로 표시되는 경우입니다.

// Incorrect: "days" is always singular regardless of count
s__('TrialWidget|%{daysLeft} days left in trial')

// Correct: Noun pluralizes with the count
sprintf(n__('TrialWidget|%{daysLeft} day left in trial',
            'TrialWidget|%{daysLeft} days left in trial', daysLeft), { daysLeft })

구조가 서로 다른 문자열에는 if/else로 문자열을 분리해 사용합니다.

# Preferred: Different strings handled with conditional logic
if selected_projects.one?
  selected_projects.first.name
else
  n_("Project selected", "%d projects selected", selected_projects.count)
end

# Avoid: Mixing a variable name with a count-based selection
format(n_("%{project_name}", "%d projects selected", count), project_name: 'GitLab')

0 상태 처리#

0인 경우를 처리하려고 독립적인 0 상태 문구를 one 슬롯에 넣지 않습니다.

# Avoid — two conceptually different ideas in one plural string
msgid "MlModelRegistry|· No other versions"
msgid_plural "MlModelRegistry|· %d versions"

여기서 단수 슬롯은 "one version"을 표현하지 않습니다. "no versions"를 표현합니다. 이 두 메시지는 같은 개념의 두 형태가 아니라 서로 다른 두 개념입니다.

중국어, 일본어, 한국어처럼 복수 형태가 하나뿐인 언어는 other 카테고리만 사용합니다. 번역자에게는 슬롯이 하나만 주어지므로 두 개념을 모두 표현할 수 없습니다. 구조상 한쪽 의미는 번역이 불가능합니다.

0 상태에는 별도의 문자열을 사용하고, 카운트가 있는 형태에는 n__()를 사용합니다.

// Preferred: Zero handled as its own string
if (count === 0) {
  s__('MlModelRegistry|No other versions')
} else {
  sprintf(n__('MlModelRegistry|%{count} version', 'MlModelRegistry|%{count} versions', count), {
    count,
  })
}

전체 문장 복수형 처리#

번역자가 필요한 컨텍스트를 온전히 얻도록 문장 전체를 복수형으로 처리합니다.

// Preferred: Whole-sentence pluralization
n__('Last day', 'Last %d days', days.length)

// Avoid: Single-word extraction with sentence construction around it
const pluralize = n__('day', 'days', days.length)
if (days.length === 1) {
  return sprintf(s__('Last %{pluralize}'), { pluralize })
}
return sprintf(s__('Last %{dayNumber} %{pluralize}'), { dayNumber: days.length, pluralize })

언어마다 복수 형태의 수가 다릅니다. 전체 문장 복수형 처리는 언어의 복수형 규칙과 무관하게 번역자가 올바른 결과를 만들 수 있도록 보장합니다.

n_()과 n__()을 사용하지 않아야 하는 경우#

문자열에 숫자가 있다고 해서 자동으로 복수형 문자열이 되지는 않습니다. 문자열이 수량이 아니라 위치, 순서, 식별자를 나타낸다면 단수입니다.

// These are NOT plural. They label a single step's position.
__('Step %{currentStep}')
__('Step %{currentStep} of %{stepsTotal}')

// "Step" never changes form regardless of the number.
// "Step 1", "Step 5", "Step 42" are always singular.

구분 기준은 수를 세는 경우(복수형 처리가 필요)와 이름을 붙이거나 순서를 매기는 경우(복수형 처리가 불필요)의 차이입니다. 항상 하나의 대상을 가리키면서 숫자를 식별자로 넣는다면 n__()가 아니라 __()를 사용합니다.

복수형 문자열에서의 보간#

복수형 함수는 형태를 선택합니다. 이름 있는 플레이스홀더를 치환하지는 않습니다.

JavaScript에서 n__()는 %d를 카운트로 치환하고 %{count}와 %s는 출력에 그대로 남깁니다. 이름 있는 플레이스홀더를 쓰려면 n__()의 결과를 sprintf()에 전달하거나 Vue 템플릿의 GlSprintf에 전달합니다.

// n__() substitutes %d
n__('%d issue', '%d issues', count)
// => '3 issues'

// n__() leaves %{count} in the output
n__('%{count} issue', '%{count} issues', count)
// => '%{count} issues'

// sprintf() substitutes %{count}
sprintf(n__('%{count} issue', '%{count} issues', count), { count })
// => '3 issues'

Vue 템플릿에서 GlSprintf는 슬롯의 값으로 이름 있는 플레이스홀더를 채웁니다.

<gl-sprintf :message="n__('%{count} issue', '%{count} issues', issuesCount)">
  <template #count>{{ numberToMetricPrefix(issuesCount) }}</template>
</gl-sprintf>

이름 있는 플레이스홀더를 권장합니다. 번역자에게 읽기 쉬운 변수 이름을 제공하고, 보간되는 다른 모든 문자열에 대한 GitLab 관례와 일치하기 때문입니다. 카운트가 하나뿐이고 문자열에 다른 변수가 없다면 %d도 허용됩니다. 변수가 둘 이상인 문자열에는 항상 이름 있는 %{placeholder} 구문을 사용합니다.

Vue에서의 사용#

Vue에서는 런타임 카운트에 의존하는 복수형 문자열을 정적 상수로 정의하지 않습니다. 대신 count 인수를 받는 함수로 정의합니다.

// .../feature/constants.js
import { __, n__, sprintf } from '~/locale';

export const I18N = {
  // Static strings that are always singular do not need a function
  someDaysRemain: __('Some days remain'),
  daysRemaining(count) { return n__('%d day remaining', '%d days remaining', count); },
  // A named placeholder needs sprintf(), because n__() substitutes only %d
  itemsSelected(count) {
    return sprintf(n__('%{count} item selected', '%{count} items selected', count), { count });
  },
};

컴포넌트 템플릿에서 해당 함수를 사용합니다.

// .../feature/components/days_remaining.vue
import { I18N } from '../constants';

export default {
  props: {
    days: { type: Number, required: true },
  },
  i18n: I18N,
};
<template>
  <div>
    <span>{{ $options.i18n.someDaysRemain }}</span>
    <span>{{ $options.i18n.daysRemaining(days) }}</span>
  </div>

</template>

Ruby와 HAML에서의 사용#

n_()는 어떤 값도 치환하지 않습니다. n_() 호출 뒤에 형식을 적용합니다. 이름 있는 플레이스홀더에는 format()을, 결과가 HTML로 렌더링되는 곳에는 safe_format()을, %d에는 %를 사용합니다.

format(n_('There is a mouse.', 'There are %{count} mice.', size), count: size)
# => When size == 1: 'There is a mouse.'
# => When size == 2: 'There are 2 mice.'

n_('There is a mouse.', 'There are %d mice.', size) % size
# => When size == 2: 'There are 2 mice.'

단수 형태에서 %d 안티패턴#

숫자가 의미를 더하지 않는다면 단수 형태에 %d를 쓰지 않습니다. 예를 들어 Last 1 day보다 Last day가 더 자연스럽게 읽힙니다.

// Preferred: Singular form omits the number
n__('Last day', 'Last %d days', count)

// Avoid: "Last 1 day" is unnatural
n__('Last %d day', 'Last %d days', count)

단수 형태에서 %d를 사용할 때의 문제#

단수 형태에 %d를 포함하면 one 복수형 카테고리가 숫자 1과 동일하지 않은 언어에서 문제가 생깁니다.

one 카테고리는 문자 그대로의 카운트가 아니라 문법 범주입니다. Unicode CLDR Plural Rules 명세에 따르면 one 카테고리는 해당 언어에서 문법적으로 1처럼 동작하는 모든 숫자를 나타내며, 숫자 1에만 국한되지 않습니다.

예를 들면 다음과 같습니다.

  • 프랑스어에서는 0이 one 카테고리를 사용합니다.
  • 우크라이나어에서는 11을 제외하고 1로 끝나는 모든 숫자가 one 카테고리를 사용합니다. 1, 21, 31, 41, 51...

단수 형태에 %d가 없으면 우크라이나어를 담당하는 번역자가 하드코딩된 숫자를 번역문에 그대로 옮길 수 있습니다.

# Source string sent to translators (no placeholder in singular form)
one: You have 1 new message
other: You have %d new messages

# Ukrainian translation — translator mirrors the hardcoded 1
one: У вас є 1 нове повідомлення
other: У вас є %d нових повідомлень

우크라이나어에서 one 카테고리는 1, 21, 31, 41과 11을 제외하고 1로 끝나는 모든 숫자에 적용됩니다. 새 메시지가 21개인 사용자에게는 "You have 1 new message"가 표시됩니다. 이 번역은 숫자 1에는 맞지만 one 카테고리의 다른 모든 숫자에는 틀립니다.

이는 이론상의 위험이 아닙니다. GitLab 문자열을 작업하던 커뮤니티 번역자가 Crowdin에서 다음 코멘트로 이 문제를 정확히 지적했습니다. "Singular tag needs to be removed for the sentence to seem natural in ptBR". 이 번역자는 단수 형태에 하드코딩된 숫자 때문에 번역문이 부자연스럽게 읽힌다는 점을 정확히 파악한 것입니다.

Crowdin을 비롯한 번역 검사 도구는 검증할 플레이스홀더가 없기 때문에 이 오류를 잡지 못합니다. 문자열은 모든 검사를 통과하고 결함은 조용히 배포됩니다.

두 형태 모두에 플레이스홀더를 두는 편이 하드코딩된 숫자보다 낫습니다. 이름 있는 %{count} 플레이스홀더를 권장합니다. 번역자에게 읽기 쉬운 변수 이름을 제공하고 GitLab 관례와도 일치합니다.

// Avoid: Hardcoded number in singular form
n__('Timeago|1 second ago', 'Timeago|%s seconds ago', n)

// Preferred: Named placeholder, substituted by sprintf()
sprintf(n__('Timeago|%{count} second ago', 'Timeago|%{count} seconds ago', n), { count: n })

// Acceptable: %d, substituted by n__() itself
n__('Timeago|%d second ago', 'Timeago|%d seconds ago', n)

코드베이스의 Timeago| 문자열은 %s를 사용하며, n__()는 이를 그대로 둡니다. timeago.js 라이브러리는 n__()가 반환된 뒤 timeago_utility.js에서 %s를 치환합니다. 그 밖의 모든 곳에서는 %{count} 나 %d를 사용합니다.

숫자 없는 자연스러운 단수 형태가 필요하면 one 슬롯 안이 아니라 복수형 호출 바깥에서 별도 문자열로 처리합니다.

Ruby/HAML에서는 다음과 같습니다.

# Preferred: Separate handling for the exact count of 1
if count == 1
  s_('SecurityProfiles|Last scan successful')
else
  format(n_('SecurityProfiles|Last %{count} scan successful',
            'SecurityProfiles|Last %{count} scans successful', count), count: count)
end

JavaScript에서는 다음과 같습니다.

// Preferred: Separate handling for the exact count of 1
if (count === 1) {
  s__('SecurityProfiles|Last scan successful')
} else {
  sprintf(n__('SecurityProfiles|Last %{count} scan successful',
              'SecurityProfiles|Last %{count} scans successful', count), { count })
}

이렇게 하면 다른 모든 카운트에 대한 올바른 복수형 처리를 유지하면서 번역자와 사용자에게 자연스러운 단수 형태를 제공합니다.

하나의 문자열에 여러 독립 복수형#

Gettext는 하나의 문자열에서 서로 독립적인 여러 복수형을 처리하지 못합니다. ngettext 함수는 카운트를 하나만 받으므로 두 명사를 각각 독립적으로 복수형 처리할 수 없습니다.

GitLab 코드베이스의 다음 문자열을 예로 듭니다.

IncidentManagement|%{hours} hours, %{minutes} minutes remaining

hours와 minutes는 서로 다른 카운트에 따라 복수형이 결정됩니다. 복수 형태가 여섯 가지인 아랍어에서는 36가지 조합이 필요합니다. Gettext는 이를 표현할 수 없습니다.

분할 후 결합#

문자열을 복수형 처리되는 부분들로 나눈 뒤, 복수형 처리를 하지 않는 연결 문자열로 합칩니다.

const hoursText = sprintf(n__('%{count} hour', '%{count} hours', hours), { count: hours });
const minutesText = sprintf(n__('%{count} minute', '%{count} minutes', minutes), {
  count: minutes,
});

sprintf(s__('IncidentManagement|%{hours}, %{minutes} remaining'), {
  hours: hoursText,
  minutes: minutesText
});

결합하기 전에 각 부분에서 카운트를 치환합니다. sprintf()는 치환하는 값을 다시 검사하지 않으므로, hoursText 안에 남은 %{count}는 출력에 그대로 나타납니다.

각 n__() 호출은 복수형 하나를 독립적으로 처리합니다. 연결 문자열은 번역자가 어순을 제어할 수 있게 해 주는 일반적인 번역 대상 문자열입니다.

Note

ICU MessageFormat이나 Mozilla Fluent 같은 다른 국제화 프레임워크는 여러 인라인 복수형 선택자를 기본으로 지원합니다. GitLab은 gettext를 사용하므로 분할 후 결합 패턴이 올바른 접근 방식입니다.

번역된 문자열에 Rails pluralize 사용 금지#

Rails의 pluralize 헬퍼와 String#pluralize는 영어 규칙만으로 단어를 굴절시킵니다. 로캘을 인식하지 못합니다. 번역된 단어를 둘 중 하나에 전달하면 함수가 번역문에 영어 s를 덧붙이며, 영어가 아닌 모든 로캘에서 깨진 출력이 나옵니다.

# Avoid: Rails pluralize applied to a translated word
pluralize(count, _('day'))
# In Japanese, _('day') is "日", and pluralize appends "s":
# => "30 日s"

번역된 문자열에 String#pluralize를 호출할 때도 같은 문제가 생깁니다. 예를 들면 s_('ChatMessage|Failed job').pluralize(count) 입니다.

대신 n_로 문장 전체를 복수형 처리해 각 로캘의 복수형 규칙이 적용되도록 합니다.

# Preferred: whole-sentence n_ with a named %{count} placeholder
format(n_('Your group %{group_name} will be removed in %{count} day.',
          'Your group %{group_name} will be removed in %{count} days.',
          count), group_name: name, count: count)

n_() 방식은 플레이스홀더로 삽입되는 별도의 카운트 문구도 피할 수 있으며, 그런 문구는 문장 분할의 한 형태입니다.

CLDR 복수형 카테고리#

Unicode Common Locale Data Repository(CLDR)는 여섯 가지 복수형 카테고리를 정의합니다. 모든 언어가 이를 전부 사용하지는 않습니다.

카테고리 예시 언어
zero 아랍어, 웨일스어
one 영어, 프랑스어, 독일어, 우크라이나어
two 아랍어, 웨일스어, 슬로베니아어
few 폴란드어, 우크라이나어, 체코어, 아랍어
many 폴란드어, 우크라이나어, 아랍어
other 모든 언어(필수)

이 카테고리 이름은 문자 그대로의 설명이 아니라 기억을 돕는 이름입니다. one 카테고리는 숫자 1이 아닙니다. 문법적으로 1처럼 동작하는 모든 숫자입니다. 폴란드어의 few 카테고리는 2-4로 끝나는 숫자를 포함하지만 12-14는 제외합니다.

언어별 정확한 규칙은 Unicode CLDR Plural Rules 명세를 참고합니다.

언어별 복수형 수#

언어 형태 수 사용 카테고리
중국어 1 other
일본어 1 other
한국어 1 other
영어 2 one, other
프랑스어 2 one, other
독일어 2 one, other
체코어 3 one, few, other
폴란드어 4 one, few, many, other
우크라이나어 4 one, few, many, other
아랍어 6 zero, one, two, few, many, other

관련 주제#

복수형(Pluralization)

GitLab v19.4
원문 보기

요약

복수형 처리는 국제화 결함의 가장 흔한 원인 중 하나입니다. GitLab은 복수형 처리에 GNU gettext를 사용합니다. Gettext는 카운트 하나를 평가해 어떤 복수 형태를 사용할지 결정합니다. 각 대상 언어는 PO 파일 헤더(Plural-Forms)에 자체 복수형 규칙을 정의하며, 이 규칙이 카운트를 올바른 msgstr[] 슬롯에 매핑합니다.

복수형 처리는 국제화 결함의 가장 흔한 원인 중 하나입니다. 영어에는 복수 형태가 두 가지(단수형과 기타)뿐이지만, 더 많은 형태를 가진 언어도 많습니다. 폴란드어와 우크라이나어는 네 가지, 아랍어는 여섯 가지 형태를 사용합니다. 잘못된 복수형 처리는 수백만 명의 사용자에게 문법이 깨진 문장을 보여 줍니다.

GitLab에서 복수형 처리 방식#

GitLab은 복수형 처리에 GNU gettext를 사용합니다. n_()(Ruby/HAML)와 n__()(JavaScript) 함수는 카운트에 따라 올바른 복수 형태를 선택합니다.

# Ruby/HAML
n_('Apple', 'Apples', count)
// JavaScript
n__('Apple', 'Apples', count)

Gettext는 카운트 하나를 평가해 어떤 복수 형태를 사용할지 결정합니다.

ngettext(singular, plural, count)
                            ↑
                     one number only

각 대상 언어는 PO 파일 헤더(Plural-Forms)에 자체 복수형 규칙을 정의하며, 이 규칙이 카운트를 올바른 msgstr[] 슬롯에 매핑합니다. 번역자는 해당 언어가 요구하는 만큼의 형태를 제공합니다.

n_()과 n__()을 사용해야 하는 경우#

단어 형태가 카운트에 따라 바뀌면 n_() 이나 n__()을 사용합니다.

판단 기준은 명사나 동사가 수에 따라 다르게 굴절하는지 여부입니다.

// Correct: "day" changes form based on count
n__('Last day', 'Last %d days', count)

// Correct: "issue" changes form
n__('%d issue', '%d issues', count)

n_()과 n__()은 같은 문자열의 복수 형태 중 하나를 선택할 때만 사용합니다. 서로 다른 문자열 사이의 로직을 제어하는 데에는 사용하지 않습니다.

문자열에 카운트 변수가 있다면 명사에 복수형 처리가 필요한지 확인합니다. 흔한 실수는 카운트를 %{variable}로 전달하면서 n__() 대신 __() 나 s__()를 사용해 명사가 항상 단수형으로 표시되는 경우입니다.

// Incorrect: "days" is always singular regardless of count
s__('TrialWidget|%{daysLeft} days left in trial')

// Correct: Noun pluralizes with the count
sprintf(n__('TrialWidget|%{daysLeft} day left in trial',
            'TrialWidget|%{daysLeft} days left in trial', daysLeft), { daysLeft })

구조가 서로 다른 문자열에는 if/else로 문자열을 분리해 사용합니다.

# Preferred: Different strings handled with conditional logic
if selected_projects.one?
  selected_projects.first.name
else
  n_("Project selected", "%d projects selected", selected_projects.count)
end

# Avoid: Mixing a variable name with a count-based selection
format(n_("%{project_name}", "%d projects selected", count), project_name: 'GitLab')

0 상태 처리#

0인 경우를 처리하려고 독립적인 0 상태 문구를 one 슬롯에 넣지 않습니다.

# Avoid — two conceptually different ideas in one plural string
msgid "MlModelRegistry|· No other versions"
msgid_plural "MlModelRegistry|· %d versions"

여기서 단수 슬롯은 "one version"을 표현하지 않습니다. "no versions"를 표현합니다. 이 두 메시지는 같은 개념의 두 형태가 아니라 서로 다른 두 개념입니다.

중국어, 일본어, 한국어처럼 복수 형태가 하나뿐인 언어는 other 카테고리만 사용합니다. 번역자에게는 슬롯이 하나만 주어지므로 두 개념을 모두 표현할 수 없습니다. 구조상 한쪽 의미는 번역이 불가능합니다.

0 상태에는 별도의 문자열을 사용하고, 카운트가 있는 형태에는 n__()를 사용합니다.

// Preferred: Zero handled as its own string
if (count === 0) {
  s__('MlModelRegistry|No other versions')
} else {
  sprintf(n__('MlModelRegistry|%{count} version', 'MlModelRegistry|%{count} versions', count), {
    count,
  })
}

전체 문장 복수형 처리#

번역자가 필요한 컨텍스트를 온전히 얻도록 문장 전체를 복수형으로 처리합니다.

// Preferred: Whole-sentence pluralization
n__('Last day', 'Last %d days', days.length)

// Avoid: Single-word extraction with sentence construction around it
const pluralize = n__('day', 'days', days.length)
if (days.length === 1) {
  return sprintf(s__('Last %{pluralize}'), { pluralize })
}
return sprintf(s__('Last %{dayNumber} %{pluralize}'), { dayNumber: days.length, pluralize })

언어마다 복수 형태의 수가 다릅니다. 전체 문장 복수형 처리는 언어의 복수형 규칙과 무관하게 번역자가 올바른 결과를 만들 수 있도록 보장합니다.

n_()과 n__()을 사용하지 않아야 하는 경우#

문자열에 숫자가 있다고 해서 자동으로 복수형 문자열이 되지는 않습니다. 문자열이 수량이 아니라 위치, 순서, 식별자를 나타낸다면 단수입니다.

// These are NOT plural. They label a single step's position.
__('Step %{currentStep}')
__('Step %{currentStep} of %{stepsTotal}')

// "Step" never changes form regardless of the number.
// "Step 1", "Step 5", "Step 42" are always singular.

구분 기준은 수를 세는 경우(복수형 처리가 필요)와 이름을 붙이거나 순서를 매기는 경우(복수형 처리가 불필요)의 차이입니다. 항상 하나의 대상을 가리키면서 숫자를 식별자로 넣는다면 n__()가 아니라 __()를 사용합니다.

복수형 문자열에서의 보간#

복수형 함수는 형태를 선택합니다. 이름 있는 플레이스홀더를 치환하지는 않습니다.

JavaScript에서 n__()는 %d를 카운트로 치환하고 %{count}와 %s는 출력에 그대로 남깁니다. 이름 있는 플레이스홀더를 쓰려면 n__()의 결과를 sprintf()에 전달하거나 Vue 템플릿의 GlSprintf에 전달합니다.

// n__() substitutes %d
n__('%d issue', '%d issues', count)
// => '3 issues'

// n__() leaves %{count} in the output
n__('%{count} issue', '%{count} issues', count)
// => '%{count} issues'

// sprintf() substitutes %{count}
sprintf(n__('%{count} issue', '%{count} issues', count), { count })
// => '3 issues'

Vue 템플릿에서 GlSprintf는 슬롯의 값으로 이름 있는 플레이스홀더를 채웁니다.

<gl-sprintf :message="n__('%{count} issue', '%{count} issues', issuesCount)">
  <template #count>{{ numberToMetricPrefix(issuesCount) }}</template>
</gl-sprintf>

이름 있는 플레이스홀더를 권장합니다. 번역자에게 읽기 쉬운 변수 이름을 제공하고, 보간되는 다른 모든 문자열에 대한 GitLab 관례와 일치하기 때문입니다. 카운트가 하나뿐이고 문자열에 다른 변수가 없다면 %d도 허용됩니다. 변수가 둘 이상인 문자열에는 항상 이름 있는 %{placeholder} 구문을 사용합니다.

Vue에서의 사용#

Vue에서는 런타임 카운트에 의존하는 복수형 문자열을 정적 상수로 정의하지 않습니다. 대신 count 인수를 받는 함수로 정의합니다.

// .../feature/constants.js
import { __, n__, sprintf } from '~/locale';

export const I18N = {
  // Static strings that are always singular do not need a function
  someDaysRemain: __('Some days remain'),
  daysRemaining(count) { return n__('%d day remaining', '%d days remaining', count); },
  // A named placeholder needs sprintf(), because n__() substitutes only %d
  itemsSelected(count) {
    return sprintf(n__('%{count} item selected', '%{count} items selected', count), { count });
  },
};

컴포넌트 템플릿에서 해당 함수를 사용합니다.

// .../feature/components/days_remaining.vue
import { I18N } from '../constants';

export default {
  props: {
    days: { type: Number, required: true },
  },
  i18n: I18N,
};
<template>
  <div>
    <span>{{ $options.i18n.someDaysRemain }}</span>
    <span>{{ $options.i18n.daysRemaining(days) }}</span>
  </div>

</template>

Ruby와 HAML에서의 사용#

n_()는 어떤 값도 치환하지 않습니다. n_() 호출 뒤에 형식을 적용합니다. 이름 있는 플레이스홀더에는 format()을, 결과가 HTML로 렌더링되는 곳에는 safe_format()을, %d에는 %를 사용합니다.

format(n_('There is a mouse.', 'There are %{count} mice.', size), count: size)
# => When size == 1: 'There is a mouse.'
# => When size == 2: 'There are 2 mice.'

n_('There is a mouse.', 'There are %d mice.', size) % size
# => When size == 2: 'There are 2 mice.'

단수 형태에서 %d 안티패턴#

숫자가 의미를 더하지 않는다면 단수 형태에 %d를 쓰지 않습니다. 예를 들어 Last 1 day보다 Last day가 더 자연스럽게 읽힙니다.

// Preferred: Singular form omits the number
n__('Last day', 'Last %d days', count)

// Avoid: "Last 1 day" is unnatural
n__('Last %d day', 'Last %d days', count)

단수 형태에서 %d를 사용할 때의 문제#

단수 형태에 %d를 포함하면 one 복수형 카테고리가 숫자 1과 동일하지 않은 언어에서 문제가 생깁니다.

one 카테고리는 문자 그대로의 카운트가 아니라 문법 범주입니다. Unicode CLDR Plural Rules 명세에 따르면 one 카테고리는 해당 언어에서 문법적으로 1처럼 동작하는 모든 숫자를 나타내며, 숫자 1에만 국한되지 않습니다.

예를 들면 다음과 같습니다.

  • 프랑스어에서는 0이 one 카테고리를 사용합니다.
  • 우크라이나어에서는 11을 제외하고 1로 끝나는 모든 숫자가 one 카테고리를 사용합니다. 1, 21, 31, 41, 51...

단수 형태에 %d가 없으면 우크라이나어를 담당하는 번역자가 하드코딩된 숫자를 번역문에 그대로 옮길 수 있습니다.

# Source string sent to translators (no placeholder in singular form)
one: You have 1 new message
other: You have %d new messages

# Ukrainian translation — translator mirrors the hardcoded 1
one: У вас є 1 нове повідомлення
other: У вас є %d нових повідомлень

우크라이나어에서 one 카테고리는 1, 21, 31, 41과 11을 제외하고 1로 끝나는 모든 숫자에 적용됩니다. 새 메시지가 21개인 사용자에게는 "You have 1 new message"가 표시됩니다. 이 번역은 숫자 1에는 맞지만 one 카테고리의 다른 모든 숫자에는 틀립니다.

이는 이론상의 위험이 아닙니다. GitLab 문자열을 작업하던 커뮤니티 번역자가 Crowdin에서 다음 코멘트로 이 문제를 정확히 지적했습니다. "Singular tag needs to be removed for the sentence to seem natural in ptBR". 이 번역자는 단수 형태에 하드코딩된 숫자 때문에 번역문이 부자연스럽게 읽힌다는 점을 정확히 파악한 것입니다.

Crowdin을 비롯한 번역 검사 도구는 검증할 플레이스홀더가 없기 때문에 이 오류를 잡지 못합니다. 문자열은 모든 검사를 통과하고 결함은 조용히 배포됩니다.

두 형태 모두에 플레이스홀더를 두는 편이 하드코딩된 숫자보다 낫습니다. 이름 있는 %{count} 플레이스홀더를 권장합니다. 번역자에게 읽기 쉬운 변수 이름을 제공하고 GitLab 관례와도 일치합니다.

// Avoid: Hardcoded number in singular form
n__('Timeago|1 second ago', 'Timeago|%s seconds ago', n)

// Preferred: Named placeholder, substituted by sprintf()
sprintf(n__('Timeago|%{count} second ago', 'Timeago|%{count} seconds ago', n), { count: n })

// Acceptable: %d, substituted by n__() itself
n__('Timeago|%d second ago', 'Timeago|%d seconds ago', n)

코드베이스의 Timeago| 문자열은 %s를 사용하며, n__()는 이를 그대로 둡니다. timeago.js 라이브러리는 n__()가 반환된 뒤 timeago_utility.js에서 %s를 치환합니다. 그 밖의 모든 곳에서는 %{count} 나 %d를 사용합니다.

숫자 없는 자연스러운 단수 형태가 필요하면 one 슬롯 안이 아니라 복수형 호출 바깥에서 별도 문자열로 처리합니다.

Ruby/HAML에서는 다음과 같습니다.

# Preferred: Separate handling for the exact count of 1
if count == 1
  s_('SecurityProfiles|Last scan successful')
else
  format(n_('SecurityProfiles|Last %{count} scan successful',
            'SecurityProfiles|Last %{count} scans successful', count), count: count)
end

JavaScript에서는 다음과 같습니다.

// Preferred: Separate handling for the exact count of 1
if (count === 1) {
  s__('SecurityProfiles|Last scan successful')
} else {
  sprintf(n__('SecurityProfiles|Last %{count} scan successful',
              'SecurityProfiles|Last %{count} scans successful', count), { count })
}

이렇게 하면 다른 모든 카운트에 대한 올바른 복수형 처리를 유지하면서 번역자와 사용자에게 자연스러운 단수 형태를 제공합니다.

하나의 문자열에 여러 독립 복수형#

Gettext는 하나의 문자열에서 서로 독립적인 여러 복수형을 처리하지 못합니다. ngettext 함수는 카운트를 하나만 받으므로 두 명사를 각각 독립적으로 복수형 처리할 수 없습니다.

GitLab 코드베이스의 다음 문자열을 예로 듭니다.

IncidentManagement|%{hours} hours, %{minutes} minutes remaining

hours와 minutes는 서로 다른 카운트에 따라 복수형이 결정됩니다. 복수 형태가 여섯 가지인 아랍어에서는 36가지 조합이 필요합니다. Gettext는 이를 표현할 수 없습니다.

분할 후 결합#

문자열을 복수형 처리되는 부분들로 나눈 뒤, 복수형 처리를 하지 않는 연결 문자열로 합칩니다.

const hoursText = sprintf(n__('%{count} hour', '%{count} hours', hours), { count: hours });
const minutesText = sprintf(n__('%{count} minute', '%{count} minutes', minutes), {
  count: minutes,
});

sprintf(s__('IncidentManagement|%{hours}, %{minutes} remaining'), {
  hours: hoursText,
  minutes: minutesText
});

결합하기 전에 각 부분에서 카운트를 치환합니다. sprintf()는 치환하는 값을 다시 검사하지 않으므로, hoursText 안에 남은 %{count}는 출력에 그대로 나타납니다.

각 n__() 호출은 복수형 하나를 독립적으로 처리합니다. 연결 문자열은 번역자가 어순을 제어할 수 있게 해 주는 일반적인 번역 대상 문자열입니다.

Note

ICU MessageFormat이나 Mozilla Fluent 같은 다른 국제화 프레임워크는 여러 인라인 복수형 선택자를 기본으로 지원합니다. GitLab은 gettext를 사용하므로 분할 후 결합 패턴이 올바른 접근 방식입니다.

번역된 문자열에 Rails pluralize 사용 금지#

Rails의 pluralize 헬퍼와 String#pluralize는 영어 규칙만으로 단어를 굴절시킵니다. 로캘을 인식하지 못합니다. 번역된 단어를 둘 중 하나에 전달하면 함수가 번역문에 영어 s를 덧붙이며, 영어가 아닌 모든 로캘에서 깨진 출력이 나옵니다.

# Avoid: Rails pluralize applied to a translated word
pluralize(count, _('day'))
# In Japanese, _('day') is "日", and pluralize appends "s":
# => "30 日s"

번역된 문자열에 String#pluralize를 호출할 때도 같은 문제가 생깁니다. 예를 들면 s_('ChatMessage|Failed job').pluralize(count) 입니다.

대신 n_로 문장 전체를 복수형 처리해 각 로캘의 복수형 규칙이 적용되도록 합니다.

# Preferred: whole-sentence n_ with a named %{count} placeholder
format(n_('Your group %{group_name} will be removed in %{count} day.',
          'Your group %{group_name} will be removed in %{count} days.',
          count), group_name: name, count: count)

n_() 방식은 플레이스홀더로 삽입되는 별도의 카운트 문구도 피할 수 있으며, 그런 문구는 문장 분할의 한 형태입니다.

CLDR 복수형 카테고리#

Unicode Common Locale Data Repository(CLDR)는 여섯 가지 복수형 카테고리를 정의합니다. 모든 언어가 이를 전부 사용하지는 않습니다.

카테고리 예시 언어
zero 아랍어, 웨일스어
one 영어, 프랑스어, 독일어, 우크라이나어
two 아랍어, 웨일스어, 슬로베니아어
few 폴란드어, 우크라이나어, 체코어, 아랍어
many 폴란드어, 우크라이나어, 아랍어
other 모든 언어(필수)

이 카테고리 이름은 문자 그대로의 설명이 아니라 기억을 돕는 이름입니다. one 카테고리는 숫자 1이 아닙니다. 문법적으로 1처럼 동작하는 모든 숫자입니다. 폴란드어의 few 카테고리는 2-4로 끝나는 숫자를 포함하지만 12-14는 제외합니다.

언어별 정확한 규칙은 Unicode CLDR Plural Rules 명세를 참고합니다.

언어별 복수형 수#

언어 형태 수 사용 카테고리
중국어 1 other
일본어 1 other
한국어 1 other
영어 2 one, other
프랑스어 2 one, other
독일어 2 one, other
체코어 3 one, few, other
폴란드어 4 one, few, many, other
우크라이나어 4 one, few, many, other
아랍어 6 zero, one, two, few, many, other

관련 주제#