복수형(Pluralization)
GitLab v19.1복수형은 국제화(i18n) 결함 중 가장 흔한 원인 중 하나입니다. GitLab은 복수형 처리를 위해 GNU gettext를 사용합니다. Gettext는 단일 카운트를 평가하여 사용할 복수 형태를 결정합니다: 각 타깃 언어는 PO 파일 헤더(Plural-Forms)에 자체 복수형 규칙을 정의하며, 이 규칙이 카운트를 올바른 msgstr[] 슬롯으로 매핑합니다.
복수형은 국제화(i18n) 결함 중 가장 흔한 원인 중 하나입니다. 영어에는 두 가지 복수 형태(단수와 기타)가 있지만, 많은 언어에는 더 많은 형태가 있습니다. 폴란드어와 우크라이나어는 네 가지, 아랍어는 여섯 가지 형태를 사용합니다. 잘못된 복수형 처리는 수백만 명의 사용자에게 문법적으로 깨진 문장을 제공합니다.
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__()을 사용합니다.
기준: 카운트에 따라 명사 또는 동사가 다르게 변형되나요?
// 올바름: 카운트에 따라 "day"의 형태가 변함
n__('Last day', 'Last %d days', count)
// 올바름: "issue"의 형태가 변함
n__('%d issue', '%d issues', count)
n_()과 n__()은 동일한 문자열의 복수 형태 사이에서 선택하는 용도로만 사용합니다.
서로 다른 문자열 간의 로직 제어에는 사용하지 마세요.
문자열에 카운트 변수가 포함된 경우, 명사를 복수형으로 만들어야 하는지 확인하세요.
흔한 실수는 %{variable}을 통해 카운트를 전달하면서 n__()가 아닌 __() 또는 s__()를
사용하는 것인데, 이 경우 명사가 항상 단수 형태로 표시됩니다:
// 잘못됨: 카운트에 관계없이 "days"가 항상 단수
s__('TrialWidget|%{daysLeft} days left in trial')
// 올바름: 카운트에 따라 명사가 복수형이 됨
n__('TrialWidget|%{daysLeft} day left in trial',
'TrialWidget|%{daysLeft} days left in trial', daysLeft)
구조적으로 다른 문자열에는 별도의 문자열과 함께 if/else를 사용합니다:
# 권장: 다른 문자열은 조건부 로직으로 처리
if selected_projects.one?
selected_projects.first.name
else
n_("Project selected", "%d projects selected", selected_projects.count)
end
# 피할 것: 변수 이름과 카운트 기반 선택을 혼합
format(n_("%{project_name}", "%d projects selected", count), project_name: 'GitLab')
0 상태(zero state) 처리#
0 케이스를 처리하기 위해 독립적인 zero-state 문구를 one 슬롯에 배치하지 마세요:
# 피할 것 — 하나의 복수형 문자열에 개념적으로 다른 두 가지 아이디어가 포함됨
msgid "MlModelRegistry|· No other versions"
msgid_plural "MlModelRegistry|· %d versions"
여기서 단수 슬롯은 "버전 1개"를 표현하지 않습니다. 이것은 "버전 없음"을 표현합니다. 이 메시지들은 같은 개념의 두 가지 형태가 아닌 두 가지 다른 개념입니다.
중국어, 일본어, 한국어와 같은 언어는 하나의 복수 형태만 있고 other 카테고리만 사용합니다.
번역자는 단일 슬롯만 받으며 두 가지 아이디어를 모두 표현할 수 없습니다.
하나의 의미는 구조적으로 번역이 불가능합니다.
0 상태에는 별도의 문자열을 사용하고, 카운트가 있는 형태에는 n__()을 사용합니다:
// 권장: 0은 별도의 문자열로 처리
if (count === 0) {
s__('MlModelRegistry|No other versions')
} else {
n__('MlModelRegistry|%{count} version', 'MlModelRegistry|%{count} versions', count)
}
전체 문장 복수형 처리#
번역자에게 필요한 완전한 맥락을 제공하기 위해 전체 문장을 복수형으로 처리합니다:
// 권장: 전체 문장 복수형 처리
n__('Last day', 'Last %d days', days.length)
// 피할 것: 단어 하나를 추출하고 주변에 문장을 구성
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__()을 사용하지 않아야 하는 경우#
문자열의 숫자가 자동으로 복수형 문자열을 만들지는 않습니다. 문자열이 수량이 아닌 위치, 순서, 또는 식별자를 나타내는 경우 단수입니다.
// 이것들은 복수형이 아닙니다. 단일 단계의 위치를 나타냅니다.
__('Step %{currentStep}')
__('Step %{currentStep} of %{stepsTotal}')
// "Step"은 숫자에 관계없이 절대 형태가 변하지 않습니다.
// "Step 1", "Step 5", "Step 42"는 항상 단수입니다.
차이점은 카운트(복수형 처리 필요)와 라벨링 또는 순서 매기기(필요 없음)의 구분입니다.
항상 하나의 것을 지칭하고 숫자를 식별자로 삽입하는 경우,
n__()이 아닌 __()을 사용합니다.
복수형 문자열에서의 보간(Interpolation)#
위치 기반 %d 플레이스홀더 대신 이름이 있는 %{count} 보간을 사용합니다.
이름이 있는 플레이스홀더는 번역자에게 읽기 쉬운 변수 이름을 제공하며,
다른 모든 보간 문자열에 대한 GitLab 규칙과 일관성을 유지합니다.
단일 카운트 문자열의 경우 %d도 허용되지만, %{count}가 권장됩니다:
// 권장
n__('%{count} issue', '%{count} issues', count)
// 허용
n__('%d issue', '%d issues', count)
여러 변수가 있는 문자열에는 항상 이름이 있는 %{placeholder} 구문을 사용합니다.
Ruby와 HAML에서는 n_() 호출 이후에 %를 적용하여 값을 대체합니다:
n_("There is a mouse.", "There are %d mice.", size) % size
# => When size == 1: 'There is a mouse.'
# => When size == 2: 'There are 2 mice.'
Vue에서의 사용#
Vue에서는 런타임 카운트에 의존하는 복수형 문자열을 정적 상수로 정의하지 마세요.
대신 count 인수를 받는 함수로 정의합니다:
// .../feature/constants.js
import { n__ } from '~/locale';
export const I18N = {
// 항상 단수인 정적 문자열은 함수가 필요 없음
someDaysRemain: __('Some days remain'),
daysRemaining(count) { return n__('%d day remaining', '%d days remaining', 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>
단수 형태에서 %d 안티패턴#
숫자가 의미를 더하지 않는 경우 단수 형태에서 %d를 피합니다.
예를 들어, Last day는 Last 1 day보다 더 자연스럽습니다:
// 권장: 단수 형태에서 숫자 생략
n__('Last day', 'Last %d days', count)
// 피할 것: "Last 1 day"는 부자연스러움
n__('Last %d day', 'Last %d days', count)
단수 형태에서 %d를 사용할 때의 문제#
one 복수 카테고리가 숫자 1과 동일하지 않은 언어에서
단수 형태에 %d를 포함하면 문제가 발생합니다.
one 카테고리는 문법적 카테고리이며, 문자 그대로의 카운트가 아닙니다.
Unicode CLDR 복수형 규칙
사양에 따르면, one 카테고리는 주어진 언어에서 문법적으로 1처럼 동작하는 모든 숫자를 나타냅니다.
숫자 1만을 의미하지 않습니다.
예시:
-
프랑스어에서 0은
one카테고리를 사용합니다. -
우크라이나어에서 1로 끝나는 모든 숫자(11 제외)는
one카테고리를 사용합니다: 1, 21, 31, 41, 51…
단수 형태에 %d가 없으면, 우크라이나어로 작업하는 번역자가
번역에서 하드코딩된 숫자를 복사할 수 있습니다:
# 번역자에게 전송된 소스 문자열 (단수 형태에 플레이스홀더 없음)
one: You have 1 new message
other: You have %d new messages
# 우크라이나어 번역 — 번역자가 하드코딩된 1을 그대로 사용
one: У вас є 1 нове повідомлення
other: У вас є %d нових повідомлень
우크라이나어에서 one 카테고리는 1, 21, 31, 41 및 1로 끝나는 모든 숫자(11 제외)에 적용됩니다.
새 메시지가 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 및 다른 번역 검사 도구는 이 오류를 잡을 수 없습니다. 검증할 플레이스홀더가 없기 때문입니다. 문자열은 모든 검사를 통과하고 결함이 조용히 배포됩니다.
위치 기반 %s 플레이스홀더도 하드코딩된 숫자보다는 개선된 방법이지만,
이름이 있는 %{count} 플레이스홀더가 권장됩니다.
이는 번역자에게 읽기 쉬운 변수 이름을 제공하고 GitLab 규칙과 일관성을 유지합니다:
// 피할 것: 단수 형태에 하드코딩된 숫자
n__('Timeago|1 second ago', 'Timeago|%s seconds ago', n)
// 허용: 두 형태 모두에 위치 기반 플레이스홀더
n__('Timeago|%s second ago', 'Timeago|%s seconds ago', n)
// 권장: 이름이 있는 플레이스홀더
n__('Timeago|%{count} second ago', 'Timeago|%{count} seconds ago', n)
숫자 없이 자연스러운 단수 형태를 원한다면, one 슬롯 내부가 아닌
복수형 호출 외부의 별도 문자열로 처리합니다.
Ruby/HAML에서:
# 권장: 카운트가 정확히 1인 경우를 별도로 처리
if count == 1
s_('SecurityProfiles|Last scan successful')
else
n_('SecurityProfiles|Last %{count} scan successful',
'SecurityProfiles|Last %{count} scans successful', count)
end
JavaScript에서:
// 권장: 카운트가 정확히 1인 경우를 별도로 처리
if (count === 1) {
s__('SecurityProfiles|Last scan successful')
} else {
n__('SecurityProfiles|Last %{count} scan successful',
'SecurityProfiles|Last %{count} scans successful', count)
}
이렇게 하면 번역자와 사용자에게 자연스러운 단수 형태를 제공하면서 다른 모든 카운트에 대한 올바른 복수형 처리를 유지할 수 있습니다.
하나의 문자열에 여러 독립 복수형#
Gettext는 단일 문자열에서 여러 독립 복수형을 처리할 수 없습니다.
ngettext 함수는 하나의 카운트만 허용하므로 두 명사를 독립적으로 복수형으로 만들 수 없습니다.
GitLab 코드베이스의 다음 문자열을 고려해 보세요:
IncidentManagement|%{hours} hours, %{minutes} minutes remaining
hours와 minutes는 모두 서로 다른 카운트에 따라 복수형이 됩니다.
여섯 가지 복수 형태를 가진 아랍어의 경우 36가지 조합이 필요합니다.
Gettext는 이를 표현할 수 없습니다.
분할 후 결합(Split and combine)#
문자열을 별도의 복수형 부분으로 분할하고 복수형이 아닌 연결어로 결합합니다:
const hoursText = n__('%{count} hour', '%{count} hours', hours);
const minutesText = n__('%{count} minute', '%{count} minutes', minutes);
sprintf(s__('IncidentManagement|%{hours}, %{minutes} remaining'), {
hours: hoursText,
minutes: minutesText
});
각 n__() 호출이 하나의 복수형을 독립적으로 처리합니다.
연결 문자열은 번역자가 단어 순서를 제어할 수 있도록 하는 표준 번역 가능 문자열입니다.
ICU MessageFormat 및 Mozilla Fluent와 같은 다른 국제화 프레임워크는 여러 인라인 복수형 선택자를 기본적으로 지원합니다. GitLab은 gettext를 사용하므로 분할-결합(split-and-combine) 패턴이 올바른 접근 방식입니다.
CLDR 복수형 카테고리#
Unicode Common Locale Data Repository(CLDR)는 여섯 가지 복수형 카테고리를 정의합니다. 모든 언어가 이를 모두 사용하지는 않습니다.
| 카테고리 | 예시 언어 |
|---|---|
| zero | 아랍어, 웨일스어 |
| one | 영어, 프랑스어, 독일어, 우크라이나어 |
| two | 아랍어, 웨일스어, 슬로베니아어 |
| few | 폴란드어, 우크라이나어, 체코어, 아랍어 |
| many | 폴란드어, 우크라이나어, 아랍어 |
| other | 모든 언어 (필수) |
이 카테고리들은 니모닉(mnemonic)이지 문자 그대로의 설명이 아닙니다.
one 카테고리는 숫자 1이 아닙니다. 문법적으로 1처럼 동작하는 모든 숫자를 의미합니다.
폴란드어에서 few 카테고리는 2-4로 끝나는 숫자를 포함하지만, 12-14는 포함하지 않습니다.
언어별 정확한 규칙은 Unicode CLDR 복수형 규칙 사양을 참조하세요.
언어별 복수형 수#
| 언어 | 형태 수 | 사용 카테고리 |
|---|---|---|
| 중국어 | 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 |