InfoGrab DocsInfoGrab Docs

GitLab 국제화(i18n)

요약

국제화(i18n) 작업에는 GNU gettext를 사용합니다. 이 페이지에서 설명하는 모든 rake 명령어는 GitLab 인스턴스에서 실행해야 합니다. GitLab Community Edition 프로젝트에서 작업하려면 GDK를 통해 다운로드하고 설정해야 합니다.

국제화(i18n) 작업에는 GNU gettext를 사용합니다. 이는 이 작업에 가장 널리 쓰이는 도구이며, 이를 활용해 작업을 돕는 다양한 애플리케이션이 있기 때문입니다.

Note

이 페이지에서 설명하는 모든 rake 명령어는 GitLab 인스턴스에서 실행해야 합니다. 이 인스턴스는 일반적으로 GitLab Development Kit(GDK)입니다.

GitLab Development Kit(GDK) 설정#

GitLab Community Edition 프로젝트에서 작업하려면 GDK를 통해 다운로드하고 설정해야 합니다.

GitLab 프로젝트가 준비되면 번역 작업을 시작할 수 있습니다.

도구#

다음 도구를 사용합니다:

  • 번역 관련 일상적인 개발 작업을 돕기 위해 직접 작성한 도구:

    • tooling/bin/gettext_extractor locale/gitlab.pot: 모든 소스 파일을 스캔하여 번역할 새 콘텐츠를 찾습니다
    • rake gettext:compile: PO 파일의 내용을 읽어 프론트엔드에서 사용 가능한 모든 번역이 담긴 JS 파일을 생성합니다.
    • rake gettext:lint: PO 파일 유효성 검사
  • gettext_i18n_rails: 이 gem을 사용하면 모델, 뷰, 컨트롤러의 콘텐츠를 번역할 수 있습니다. 내부적으로 fast_gettext를 사용합니다.

    또한 일상적으로는 거의 필요하지 않은 다음 Rake 태스크에 대한 접근도 제공합니다:

    • rake gettext:add_language[language]: 새 언어 추가
    • rake gettext:find: Rails 애플리케이션의 거의 모든 파일을 파싱해 번역 대상으로 표시된 콘텐츠를 찾습니다. 그런 다음 이 콘텐츠로 PO 파일을 업데이트합니다.
    • rake gettext:pack: PO 파일을 처리하여 애플리케이션이 사용하는 바이너리 MO 파일을 생성합니다.
  • PO 편집기: PO 파일 작업에 도움이 되는 다양한 애플리케이션이 있습니다. 좋은 선택으로는 macOS, GNU/Linux, Windows에서 사용할 수 있는 Poedit이 있습니다.

번역을 위한 페이지 준비#

다음에 제공되는 헬퍼를 사용해 문자열을 번역 가능으로 표시합니다. 문자열은 사용 맥락이 명확하지 않은 도구에서 번역된다는 점에 유의합니다. 번역자에게 더 많은 맥락을 제공하려면 도메인별 문자열에 네임스페이스 적용을 고려합니다.

파일 유형은 네 가지입니다:

  • Ruby 파일: 모델과 컨트롤러.
  • HAML 파일: 뷰 파일.
  • ERB 파일: 이메일 템플릿에 사용합니다.
  • JavaScript 파일: 주로 Vue 템플릿으로 작업합니다.

Ruby 파일#

원시 문자열을 다루는 메서드나 변수가 있는 경우, 예를 들어:

def hello
  "Hello world!"
end

또는:

hello = "Hello world!"

다음과 같이 해당 콘텐츠를 번역 대상으로 표시할 수 있습니다:

def hello
  _("Hello world!")
end

또는:

hello = _("Hello world!")

클래스나 모듈 수준에서 문자열을 번역할 때는 주의합니다. 이는 클래스 로드 시 한 번만 평가되기 때문입니다. 예를 들어:

validates :group_id, uniqueness: { scope: [:project_id], message: _("already shared with this group") }

이 코드는 클래스가 로드될 때 번역되어, 오류 메시지가 항상 기본 로케일로 표시되는 결과를 낳습니다. Active Record의 :message 옵션은 Proc을 허용하므로 대신 다음과 같이 작성합니다:

validates :group_id, uniqueness: { scope: [:project_id], message: -> (object, data) { _("already shared with this group") } }

API(lib/api/ 또는 app/graphql)의 메시지는 외부화할 필요가 없습니다.

Rails 모델 오류 메시지#

Rails 모델에 오류 메시지를 추가할 때, 오류 메시지가 완전한 문장이라면 특정 속성에 오류를 추가하지 않습니다. Rails는 속성 이름을 자동으로 사람이 읽기 좋은 형태로 바꿔 오류 메시지 앞에 붙이는데, 이로 인해 번역하기 어려운 분리된 문장이 생깁니다.

예를 들어, 다음은 피합니다:

# Bad: Rails prepends "Squash option" to the message
errors.add(:squash_option, _('cannot be used with wildcard branch rules. Use an exact branch name.'))

대신, 완전한 문장으로 :base에 오류를 추가합니다:

# Good: Complete sentence that translators can work with
errors.add(:base, _('Squash option cannot be used with wildcard branch rules. Use an exact branch name.'))

HAML 파일#

HAML에 다음 콘텐츠가 있다면:

%h1 Hello world!

다음과 같이 번역 대상으로 표시할 수 있습니다:

%h1= _("Hello world!")

ERB 파일#

ERB에 다음 콘텐츠가 있다면:

<h1>Hello world!</h1>

다음과 같이 번역 대상으로 표시할 수 있습니다:

<h1><%= _("Hello world!") %></h1>

JavaScript 파일#

~/locale 모듈은 외부화를 위한 다음 핵심 함수를 내보냅니다:

  • __() 번역할 콘텐츠를 표시합니다(이중 언더스코어 괄호).
  • s__() 네임스페이스가 있는 번역할 콘텐츠를 표시합니다(s 이중 언더스코어 괄호).
  • n__() 복수형 번역할 콘텐츠를 표시합니다(n 이중 언더스코어 괄호).
import { __, s__, n__ } from '~/locale';

const defaultErrorMessage = s__('Branches|Create branch failed.');
const label = __('Subscribe');
const message =  n__('Apple', 'Apples', 3)

JavaScript 번역을 테스트하려면 UI에서 번역을 수동으로 테스트하기를 참고합니다.

Vue 파일#

Vue 파일에서는 translate 믹스인을 사용하여 Vue 템플릿에서 다음 함수를 사용할 수 있습니다:

  • __()
  • s__()
  • n__()
  • sprintf

즉, ~/locale 파일에서 이 함수들을 가져오지 않고도 Vue 템플릿에서 문자열을 외부화할 수 있습니다:

<template>
  <h1>{{ s__('Branches|Create a new branch') }}</h1>
  <gl-button>{{ __('Create branch') }}</gl-button>
</template>

Vue 컴포넌트의 JavaScript에서 문자열을 번역해야 한다면, JavaScript 파일 섹션에서 설명한 대로 ~/locale 파일에서 필요한 외부화 함수를 가져올 수 있습니다.

Vue 번역을 테스트하려면 UI에서 번역을 수동으로 테스트하기를 참고합니다.

테스트 파일(RSpec)#

RSpec 테스트에서 외부화된 콘텐츠에 대한 기대값은 하드코딩하지 않습니다. 기본이 아닌 로케일로 테스트를 실행해야 할 수 있으며, 콘텐츠가 하드코딩된 테스트는 실패하기 때문입니다.

즉, 외부화된 콘텐츠에 대한 기대값은 번역과 일치시키기 위해 동일한 외부화 메서드를 호출해야 합니다.

나쁜 예:

click_button 'Submit review'

expect(rendered).to have_content('Thank you for your feedback!')

좋은 예:

click_button _('Submit review')

expect(rendered).to have_content(_('Thank you for your feedback!'))

테스트 파일(Jest)#

프론트엔드 Jest 테스트에서는 기대값이 외부화 메서드를 참조할 필요가 없습니다. 외부화는 프론트엔드 테스트 환경에서 모킹되므로 기대값은 로케일과 무관하게 결정적입니다 (관련 MR 참고).

예시:

// Bad. Not necessary in Frontend environment.
expect(findText()).toBe(__('Lorem ipsum dolor sit'));
// Good.
expect(findText()).toBe('Lorem ipsum dolor sit');

권장 사항#

번역은 사용되는 위치에 최대한 가깝게 배치합니다. 가능하면 번역을 담은 변수보다 인라인 번역을 사용합니다. 번역에 대한 가장 좋은 설명은 그 키 자체입니다. 이는 코드 가독성을 높이고 코드 맥락을 유지하는 인지적 부담을 줄여줍니다. 또한 번역 외에 변수를 따로 유지할 필요가 없어 리팩토링이 쉬워집니다.

// Bad. A variable is defined far from where it is used
const TITLE = __('Organisations');

function transform() {
  return TITLE;
}

// Good.
function transform() {
  return __('Organisations');
}
공유 번역#

번역이 파일이나 모듈 내 여러 곳에서 사용되는 경우가 있습니다. 이때 번역을 공유하는 변수를 사용할 수 있지만, 다음 사항을 고려합니다:

  • 인라인 번역은 코드 명확성이 더 높습니다. 번역을 변수에 넣는 유일한 이유로 DRY 원칙을 내세우지 않습니다.
  • 번역을 삽입하거나 결합할 때는 주의합니다. 자세한 내용은 변수를 사용한 동적 텍스트 삽입을 참고합니다.
  • 두 번역이 같은 영문 키를 공유한다고 해서 다른 언어에서도 동일한 번역이 되는 것은 아닙니다. 적절한 경우 네임스페이스 사용을 고려합니다.

특정 경우에 번역이 담긴 변수를 사용하는 것이 더 나으면, 변수를 선언하고 배치하는 방법에 대한 다음 지침을 따릅니다.

JavaScript 파일에서는 번역이 담긴 상수를 선언합니다:

const ORGANISATIONS_TITLE = __('Organisations');

Vue 컴포넌트는 Vue 단일 파일 컴포넌트를 참고합니다.

모듈에서 여러 파일에 걸쳐 동일한 번역을 재사용하는 경우, constants.js나 i18n.js 파일에 추가하고 모듈 전체에서 가져올 수 있습니다. 다만 이는 코드베이스에 또 다른 복잡성을 더하므로 신중하게 사용합니다.

문자열을 내보낼 때 피해야 할 또 다른 방식은 스펙에서 그 문자열을 가져오는 것입니다. 더 효율적인 테스트처럼 보일 수 있지만(문구를 바꿔도 테스트가 계속 통과합니다), 다음과 같은 추가 문제가 생깁니다:

  • 가져오는 값이 undefined일 위험이 있어 테스트에서 false-positive가 발생할 수 있습니다(i18n 객체를 가져오는 경우 더욱 그렇습니다. 상수를 기본형으로 내보내기 참고).
  • 무엇을 테스트하는지 파악하기 어렵습니다(어떤 문구를 기대해야 하는지).
  • 어서션을 다시 작성하지 않고 상수 값이 올바르다고 가정하기 때문에 오타를 놓칠 위험이 더 높습니다.
  • 이 접근 방식의 이점은 미미합니다. 컴포넌트의 문구를 바꿔도 스펙을 바꾸지 않아도 된다는 점은 잠재적 문제를 상쇄할 만큼 크지 않습니다.

예를 들면:

import { MSG_ALERT_SETTINGS_FORM_ERROR } from 'path/to/constants.js';

// Bad. What is the actual text for `MSG_ALERT_SETTINGS_FORM_ERROR`? If `wrapper.text()` returns undefined, the test may still pass with the wrong values!
expect(wrapper.text()).toBe(MSG_ALERT_SETTINGS_FORM_ERROR);
// Very bad. Same problem as above and we are going through the vm property!
expect(wrapper.text()).toBe(MyComponent.vm.i18n.buttonLabel);
// Good. What we are expecting is very clear and there can be no surprises.
expect(wrapper.text()).toBe('There was an error: Please refresh and hope for the best!');
Vue 단일 파일 컴포넌트#

Vue 단일 파일 컴포넌트에서는 번역 호출을 <template>에 직접 넣습니다. 이 섹션에 나열된 이유 중 하나가 적용되지 않는 한 컴포넌트의 $options.i18n 객체로 옮기지 않습니다.

// Bad. A single-use string moved away from where it is rendered.
<script>
  export default {
    i18n: {
      buttonLabel: s__('Plan|Button label'),
    },
  };
</script>

<template>
  <gl-button>{{ $options.i18n.buttonLabel }}</gl-button>
</template>

// Good.
<template>
  <gl-button>{{ s__('Plan|Button label') }}</gl-button>
</template>

인라인 문자열은 렌더링되는 위치에서 바로 읽을 수 있어 따라갈 간접 참조가 없고, 마크업을 삭제하는 리팩토링이 고아 상태의 i18n 항목을 남기지 않습니다. 일반적인 원칙은 권장 사항을 참고합니다.

번역 헬퍼는 컴포넌트 전체에서 동작하므로, 인라인화가 <template>에만 한정되지는 않습니다:

  • <template>에서는 __(), s__(), n__(), sprintf()를 직접 호출합니다. translate 믹스인이 이를 제공하므로 별도로 가져올 필요가 없습니다. 자세한 내용은 Vue 파일을 참고합니다.
  • 메서드, 라이프사이클 훅, computed 속성, Apollo error() 핸들러 같은 컴포넌트 JavaScript에서는 this.__(), this.s__()로 호출하거나 ~/locale에서 가져옵니다.
  • prop의 default나 validator에서는 아직 컴포넌트 인스턴스가 존재하지 않으므로 this나 $options.i18n에 접근할 수 없습니다. 헬퍼를 가져와 직접 호출합니다.

gettext 추출기는 <template> 블록을 파싱하므로 인라인 문자열도 pot 파일에 반영됩니다. sprintf, n__, GlSprintf도 템플릿에서 모두 동작합니다.

다음의 경우에는 $options.i18n이나 상수를 사용합니다:

  • 같은 문자열이 재사용되고 단일 소스로 관리할 가치가 있는 경우입니다. 템플릿에 표시되면서 컴포넌트 JavaScript에서도 쓰이는 문자열, 또는 여러 사용처에서 반드시 동일하게 유지되어야 하는 문자열이 그렇습니다. 중복 자체는 이유가 되지 않습니다. 인라인 호출 두 개는 괜찮으며, 권장 사항에서 언급했듯 DRY가 유일한 근거는 아닙니다. 사용처를 동기화 상태로 유지하는 것이 중요하거나 개수가 두어 개를 넘어설 때 추출합니다.
  • 문자열에 ~/lib/dompurify의 sanitize()처럼 템플릿에 두기 적절하지 않은 처리가 필요한 경우입니다.
  • 문자열이 상태나 열거값으로 키가 지정된 조회 맵의 값인 경우입니다. 키는 런타임에만 알 수 있기 때문입니다.
  • 문자열이 같은 모듈 내 여러 컴포넌트에서 공유되는 경우입니다. 자세한 내용은 공유 번역을 참고합니다.

다음 예시는 템플릿과 메서드 양쪽에서 사용되는 문자열을 보여줍니다. 항목을 하나로 두면 둘을 동기화된 상태로 유지할 수 있습니다:

<script>
  import { createAlert } from '~/alert';

  export default {
    i18n: {
      saveError: s__('Plan|Could not save the plan'),
    },
    methods: {
      onError() {
        createAlert({ message: this.$options.i18n.saveError });
      },
    },
  };
</script>

<template>
  <gl-alert v-if="failed">{{ $options.i18n.saveError }}</gl-alert>
</template>

개수에 따라 달라지는 문자열은 Vue에서의 복수형 처리를 참고합니다.

이 지침은 새 코드와 변경된 코드에 적용합니다. 기존의 $options.i18n 사용을 굳이 마이그레이션할 필요는 없습니다.

동적 번역#

자세한 내용은 번역을 동적으로 유지하기를 참고합니다.

번역된 문자열 변경하기#

GitLab에서 소스 문자열을 변경하는 경우, 변경 사항을 푸시하기 전에 pot 파일을 업데이트합니다. pot 파일이 최신 상태가 아니면 pre-push 검사와 gettext용 파이프라인 job이 실패합니다.

특수 콘텐츠 다루기#

보간(Interpolation)#

번역된 텍스트의 플레이스홀더는 해당 소스 파일의 코드 스타일과 일치시킵니다. 예를 들어 Ruby에서는 %{created_at}을, JavaScript에서는 %{createdAt}을 사용합니다. 링크 추가 시 문장을 분리하지 않도록 주의합니다.

  • Ruby/HAML에서:

    format(_("Hello %{name}"), name: 'Joe') => 'Hello Joe'
    
  • Vue에서:

    다음 경우에는 GlSprintf 컴포넌트를 사용합니다:

    • 번역 문자열에 자식 컴포넌트를 포함하는 경우입니다.
    • 번역 문자열에 HTML을 포함하는 경우입니다.
    • sprintf를 사용하면서 플레이스홀더 값의 이스케이프를 막기 위해 세 번째 인수로 false를 전달하는 경우입니다.

    예를 들면:

    <gl-sprintf :message="s__('ClusterIntegration|Learn more about %{linkStart}zones%{linkEnd}')">
      <template #link="{ content }">
        <gl-link :href="somePath">{{ content }}</gl-link>
      </template>
    </gl-sprintf>
    

    그 외의 경우에는 계산된 속성 등에서 sprintf를 사용하는 것이 더 간단할 수 있습니다. 예를 들면:

    <script>
    import { __, sprintf } from '~/locale';
    
    export default {
      ...
      computed: {
        userWelcome() {
          return sprintf(__('Hello %{username}'), { username: this.user.name });
        }
      }
      ...
    }
    </script>
    
    <template>
      <span>{{ userWelcome }}</span>
    </template>
    
  • JavaScript에서(Vue를 사용할 수 없는 경우):

    import { __, sprintf } from '~/locale';
    
    sprintf(__('Hello %{username}'), { username: 'Joe' }); // => 'Hello Joe'
    

    번역 내에 마크업을 사용해야 한다면 sprintf를 사용하고, 세 번째 인수로 false를 전달하여 플레이스홀더 값의 이스케이프를 막습니다. 보간되는 동적 값은 직접 이스케이프해야 하며, 예를 들어 lodash-es의 escape를 사용합니다.

    import { escape } from 'lodash-es';
    import { __, sprintf } from '~/locale';
    
    let someDynamicValue = '<script>alert("evil")</script>';
    
    // Dangerous:
    sprintf(__('This is %{value}'), { value: `<strong>${someDynamicValue}</strong>`, false);
    // => 'This is <strong><script>alert('evil')</script></strong>'
    
    // Incorrect:
    sprintf(__('This is %{value}'), { value: `<strong>${someDynamicValue}</strong>` });
    // => 'This is &lt;strong&gt;&lt;script&gt;alert(&#x27;evil&#x27;)&lt;/script&gt;&lt;/strong&gt;'
    
    // OK:
    sprintf(__('This is %{value}'), { value: `<strong>${escape(someDynamicValue)}</strong>` }, false);
    // => 'This is <strong>&lt;script&gt;alert(&#x27;evil&#x27;)&lt;/script&gt;</strong>'
    

복수형#

GitLab은 복수형 처리에 GNU gettext를 사용합니다. n_()과 n__() 구문, CLDR 복수형 카테고리, 흔한 안티패턴에 대한 지침은 복수형을 참고합니다.

네임스페이스#

네임스페이스는 함께 속하는 번역을 그룹화하는 방법입니다. 접두사를 붙이고 그 뒤에 바 기호(|)를 추가하여 번역자에게 맥락을 제공합니다. 예를 들어:

'Namespace|Translated string'

네임스페이스는:

  • 단어의 모호성을 해소합니다. 예: Promotions|Promote 대 Epic|Promote.
  • 번역자가 임의의 문자열이 아니라 같은 제품 영역에 속한 외부화 문자열 번역에 집중할 수 있게 합니다.
  • 번역자에게 언어적 맥락을 제공합니다.

일부 언어는 영어보다 맥락 의존적입니다. 예를 들어 cancel은 사용 방식에 따라 다르게 번역될 수 있습니다. 사용 맥락을 정의하기 위해 영어 UI 텍스트에는 항상 네임스페이스를 추가합니다.

네임스페이스는 PascalCase로 작성합니다.

네임스페이스를 선택할 때는 더 나은 맥락을 제공하기 위해 광범위한 카테고리보다 세밀한 하위 카테고리를 우선합니다. Feature| 같은 범용 네임스페이스 대신, 기능 영역이나 액션 맥락을 설명하는 더 구체적인 하위 카테고리를 고려합니다. 적절한 맥락이 없으면 모호해질 수 있는, 일반적으로 보이는 문자열에서는 이 점이 특히 중요합니다.

예를 들어:

  • WorkItems|Add to 대신 WorkItemsStatusConfigure|Add to를 사용합니다.
  • MergeRequest|Approve 대신 MergeRequestReviewActions|Approve를 사용합니다.
  • Project|Delete 대신 ProjectSettingsGeneral|Delete를 사용합니다.

이 접근 방식은 번역자가 다음을 이해하는 데 도움이 됩니다:

  • 문자열이 나타나는 구체적인 UI 맥락.

  • 의도된 액션이나 목적.

  • 같은 기능 영역의 다른 유사 문자열과 문자열이 어떻게 관련되는지.

  • Ruby/HAML에서:

    s_('OpenedNDaysAgo|Opened')
    

    번역을 찾을 수 없으면 Opened가 반환됩니다.

  • JavaScript에서:

    s__('OpenedNDaysAgo|Opened')
    

네임스페이스는 번역에서 제거해야 합니다. 자세한 내용은 번역 가이드라인을 참고합니다.

HTML#

이제 번역을 위해 제출하는 문자열에는 HTML을 직접 포함하지 않습니다. 이유는 다음과 같습니다:

  1. 번역된 문자열에 잘못된 HTML이 실수로 포함될 수 있습니다.
  2. Open Web Application Security Project(OWASP)에서 지적했듯, 번역된 문자열이 XSS의 공격 벡터가 될 수 있습니다.

번역된 문자열에 서식을 포함하려면 다음과 같이 할 수 있습니다:

  • Ruby/HAML에서:

    safe_format(_('Some %{strongOpen}bold%{strongClose} text.'), tag_pair(tag.strong, :strongOpen, :strongClose))
    # => 'Some <strong>bold</strong> text.'
    
  • JavaScript에서:

      sprintf(__('Some %{strongOpen}bold%{strongClose} text.'), { strongOpen: '<strong>', strongClose: '</strong>'}, false);
    
      // => 'Some <strong>bold</strong> text.'
    
  • Vue에서:

    보간 섹션을 참고합니다.

이 번역 헬퍼 이슈가 완료되면 번역된 문자열에 서식을 포함하는 절차를 업데이트할 예정입니다.

꺾쇠 괄호 포함#

HTML에 사용되지 않는 꺾쇠 괄호(</>)가 문자열에 포함되어 있으면 rake gettext:lint 린터가 여전히 이를 플래그로 표시합니다. 이 오류를 피하려면 해당하는 HTML 엔티티 코드 (&lt; 또는 &gt;)를 대신 사용합니다:

  • Ruby/HAML에서:

    safe_format(_('In &lt; 1 hour'))
    
    # => 'In < 1 hour'
    
  • JavaScript에서:

    import { sanitize } from '~/lib/dompurify';
    
    const i18n = { LESS_THAN_ONE_HOUR: sanitize(__('In &lt; 1 hour'), { ALLOWED_TAGS: [] }) };
    
    // ... using the string
    element.innerHTML = i18n.LESS_THAN_ONE_HOUR;
    
    // => 'In < 1 hour'
    
  • Vue에서:

    <gl-sprintf :message="s__('In &lt; 1 hours')"/>
    
    // => 'In < 1 hour'
    

숫자#

로케일마다 숫자 형식이 다를 수 있습니다. 숫자의 현지화를 지원하기 위해 toLocaleString()을 활용하는 formatNumber를 사용합니다.

기본적으로 formatNumber는 현재 사용자 로케일을 사용하여 숫자를 문자열로 형식화합니다.

  • JavaScript에서:
import { formatNumber } from '~/locale';

// Assuming "User Preferences > Language" is set to "English":

const tenThousand = formatNumber(10000); // "10,000" (uses comma as decimal symbol in English locale)
const fiftyPercent = formatNumber(0.5, { style: 'percent' }) // "50%" (other options are passed to toLocaleString)
  • Vue 템플릿에서:
<script>
import { formatNumber } from '~/locale';

export default {
  //...
  methods: {
    // ...
    formatNumber,
  },
}
</script>
<template>
<div class="my-number">
  {{ formatNumber(10000) }} <!-- 10,000 -->
</div>

<div class="my-percent">
  {{ formatNumber(0.5,  { style: 'percent' }) }} <!-- 50% -->
</div>

</template>

날짜/시간#

  • JavaScript에서:
import { createDateTimeFormat } from '~/locale';

const dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' });
console.log(dateFormat.format(new Date('2063-04-05'))) // April 5, 2063

이는 Intl.DateTimeFormat을 활용합니다.

  • Ruby/HAML에서는 날짜와 시간에 서식을 지정하는 두 가지 방법이 있습니다:

    • l 헬퍼 사용: 예를 들어 l(active_session.created_at, format: :short). 날짜와 시간에는 미리 정의된 형식이 있습니다. 코드의 다른 부분에서도 활용할 수 있어 새 형식을 추가해야 한다면 en.yml 파일에 추가합니다.
    • strftime 사용: 예를 들어 milestone.start_date.strftime('%b %-d'). en.yml에 정의된 형식 중 필요한 날짜/시간 사양과 일치하는 것이 없고, 매우 특수한 경우(예: 단일 뷰에서만 사용되는 경우)라 새 형식으로 추가할 필요가 없을 때 strftime을 사용합니다.

모범 사례#

번역 업데이트 최소화#

업데이트는 해당 문자열의 번역 손실로 이어질 수 있습니다. 위험을 최소화하려면 다음에 해당하지 않는 한 문자열 변경을 피합니다:

  • 사용자에게 가치를 더하는 경우.
  • 번역자를 위한 추가 맥락을 담는 경우.

예를 들어 다음과 같은 변경은 피합니다:

- _('Number of things: %{count}') % { count: 10 }
+ n_('Number of things: %d', 10)

번역을 동적으로 유지하기#

배열이나 해시 안에 번역을 함께 두는 것이 합리적인 경우가 있습니다.

예시:

  • 드롭다운 목록을 위한 매핑
  • 오류 메시지

이런 종류의 데이터를 저장할 때 상수를 사용하는 것이 최선의 선택처럼 보입니다. 하지만 번역에는 이 방법이 통하지 않습니다.

예를 들어 다음은 피합니다:

class MyPresenter
  MY_LIST = {
    key_1: _('item 1'),
    key_2: _('item 2'),
    key_3: _('item 3')
  }
end

번역 메서드(_)는 클래스가 처음 로드될 때 호출되어 텍스트를 기본 로케일로 번역합니다. 사용자의 로케일과 무관하게 이 값들은 두 번째로 번역되지 않습니다.

메모이제이션을 사용하는 클래스 메서드에서도 비슷한 일이 발생합니다.

예를 들어 다음은 피합니다:

class MyModel
  def self.list
    @list ||= {
      key_1: _('item 1'),
      key_2: _('item 2'),
      key_3: _('item 3')
    }
  end
end

이 메서드는 이 메서드를 처음 호출한 사용자의 로케일을 사용해 번역을 메모이제이션합니다.

이런 문제를 피하려면 번역을 동적으로 유지합니다.

좋은 예:

class MyPresenter
  def self.my_list
    {
      key_1: _('item 1'),
      key_2: _('item 2'),
      key_3: _('item 3')
    }.freeze
  end
end

bin/rake gettext:find를 실행할 때 파서가 찾지 못하는 동적 번역이 있는 경우가 있습니다. 이런 상황에는 N_ 메서드를 사용할 수 있습니다. 유효성 검사 오류 메시지를 번역하는 대안적인 방법도 있습니다.

문장 분리#

문장을 절대 분리하지 않습니다. 문장을 분리하면 문장의 문법과 구조가 모든 언어에서 동일하다고 가정하게 되기 때문입니다.

예를 들어 다음은:

{{ s__("mrWidget|Set by") }}
{{ author.name }}
{{ s__("mrWidget|to be merged automatically when the pipeline succeeds") }}

다음과 같이 외부화해야 합니다:

{{ sprintf(s__("mrWidget|Set by %{author} to be merged automatically when the pipeline succeeds"), { author: author.name }) }}

번역 가능한 문장을 여러 GlSprintf 인스턴스로 나누지 않습니다. Created %{date} by %{author}처럼 전체 문장은 하나의 GlSprintf :message에 플레이스홀더와 함께 유지하여, 번역자가 자기 언어의 문법에 맞게 단어 순서를 재배열할 수 있게 합니다.

링크 추가 시 문장 분리 피하기#

이는 번역된 문장 사이에 링크를 사용할 때도 적용됩니다. 그렇지 않으면 특정 언어에서 이 텍스트를 번역할 수 없게 됩니다.

  • Ruby/HAML에서, 다음 대신:

    - zones_link = link_to(s_('ClusterIntegration|zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer')
    = s_('ClusterIntegration|Learn more about %{zones_link}').html_safe % { zones_link: zones_link }
    

    링크의 시작과 끝 HTML 조각을 변수로 설정합니다:

    - zones_link_url = 'https://cloud.google.com/compute/docs/regions-zones/regions-zones'
    - zones_link = link_to('', zones_link_url, target: '_blank', rel: 'noopener noreferrer')
    = safe_format(s_('ClusterIntegration|Learn more about %{zones_link_start}zones%{zones_link_end}'), tag_pair(zones_link, :zones_link_start, :zones_link_end))
    
  • Vue에서, 다음 대신:

    <template>
      <div>
        <gl-sprintf :message="s__('ClusterIntegration|Learn more about %{link}')">
          <template #link>
            <gl-link
              href="https://cloud.google.com/compute/docs/regions-zones/regions-zones"
              target="_blank"
            >zones</gl-link>
          </template>
        </gl-sprintf>
      </div>
    
    </template>
    

    링크의 시작과 끝 HTML 조각을 플레이스홀더로 설정합니다:

    <template>
      <div>
        <gl-sprintf :message="s__('ClusterIntegration|Learn more about %{linkStart}zones%{linkEnd}')">
          <template #link="{ content }">
            <gl-link
              href="https://cloud.google.com/compute/docs/regions-zones/regions-zones"
              target="_blank"
            >{{ content }}</gl-link>
          </template>
        </gl-sprintf>
      </div>
    
    </template>
    
  • JavaScript에서(Vue를 사용할 수 없는 경우), 다음 대신:

    {{
        sprintf(s__("ClusterIntegration|Learn more about %{link}"), {
            link: '<a href="https://cloud.google.com/compute/docs/regions-zones/regions-zones" target="_blank" rel="noopener noreferrer">zones</a>'
        }, false)
    }}
    

    링크의 시작과 끝 HTML 조각을 플레이스홀더로 설정합니다:

    {{
        sprintf(s__("ClusterIntegration|Learn more about %{linkStart}zones%{linkEnd}"), {
            linkStart: '<a href="https://cloud.google.com/compute/docs/regions-zones/regions-zones" target="_blank" rel="noopener noreferrer">',
            linkEnd: '</a>',
        }, false)
    }}
    

이렇게 하는 이유는 일부 언어에서 단어가 맥락에 따라 바뀌기 때문입니다. 예를 들어 일본어에서는 は가 문장의 주어에, を가 목적어에 붙습니다. 문장에서 개별 단어를 추출하면 올바르게 번역할 수 없습니다.

확신이 서지 않을 때는 이 Mozilla Developer 문서에 설명된 모범 사례를 따릅니다.

번역 헬퍼에는 항상 문자열 리터럴을 전달하기#

tooling/bin/gettext_extractor locale/gitlab.pot 스크립트는 코드베이스를 파싱하여 번역 준비가 된 모든 문자열을 번역 헬퍼에서 추출합니다.

문자열이 변수, 함수 호출, 문자열 보간으로 전달되면 스크립트가 이를 확인할 수 없습니다. 따라서 헬퍼에는 항상 문자열 리터럴을 전달합니다.

  • Ruby/HAML에서:

    # Good
    _('Some label');
    _("Some label");
    _('Hi %{name}') % { name: 'Luki' }
    s_('Namespace|Label');
    n_('%d apple', '%d apples', appleCount);
    
    # Bad
    _(LABEL);
    _('Hi %{name}' % { name: 'Luki' })
    _("Step: #{count}");
    s_(get_label());
    n_(label_singular, label_plural, appleCount);
    
  • JavaScript에서:

    // Good
    __('Some label');
    s__('Namespace', 'Label');
    s__('Namespace|Label');
    n__('%d apple', '%d apples', appleCount);
    
    // Bad
    __(LABEL);
    __(`Step: ${count}`);
    s__(getLabel());
    s__(NAMESPACE, LABEL);
    n__(LABEL_SINGULAR, LABEL_PLURAL, appleCount);
    

변수를 사용한 동적 텍스트 삽입#

번역 가능한 문자열에서 텍스트 값을 변수로 사용할 때는 여러 언어에서 문법적 정확성을 보장하기 위해 특별한 주의가 필요합니다.

위험 요소와 과제#

번역 가능한 문자열에 텍스트를 추가하기 위해 변수를 사용하면 여러 현지화 과제가 발생할 수 있습니다:

  • 성 일치: 문법적 성이 있는 언어에서는 삽입되는 명사의 성에 따라 관사, 형용사, 대명사의 형태가 달라질 수 있습니다. 예를 들어 프랑스어에서는 관사, 형용사, 일부 과거분사가 명사의 성 및 문장 내 위치와 일치해야 합니다.
  • 격과 어형 변화: 격이 있는 언어(독일어 등)에서는 삽입되는 텍스트가 문장에서의 문법적 역할에 따라 다른 형태가 필요할 수 있습니다.
  • 어순: 언어마다 어순 요구 사항이 다르며, 자연스러운 번역을 위해 삽입되는 텍스트가 문장의 다른 위치에 나타나야 할 수 있습니다.

모범 사례#

  1. 가능하면 텍스트를 변수로 추가하지 않습니다:
    • 변수가 있는 문자열 하나 대신, 각 경우에 대해 고유한 문자열을 만듭니다. 예를 들어:
    # Instead of:
    s_('WorkItem|Adds this %{workItemType} as related to %{relatedWorkItemType}')

    # Create separate strings:
    s_('WorkItem|Adds this task as related to incident')
    s_('WorkItem|Adds this incident as related to task')
  1. 문장형 배열보다 주제-설명 구조를 사용합니다: 변수 사용을 피할 수 없다면, 완전한 문장 대신 주제-설명 구조를 사용하도록 메시지를 재구성하는 것을 고려합니다:
   # Instead of a sentence with inserted variables:
   s_('WorkItem|Adds this %{workItemType} as related to %{relatedWorkItemType}')

   # Use topic-comment structure:
   s_('WorkItem|Related items: %{workItemType} → %{relatedWorkItemType}')

패턴 결합#

네임스페이싱, 복수형 처리, 파라미터 치환 같은 여러 기능이 필요한 복잡한 시나리오를 처리하기 위해 번역 헬퍼 함수를 결합할 수 있습니다.

예를 들어:

   ns_('BulkImport|%{count} placeholder user has been created.', 'BulkImport|%{count} placeholder users have been created.', @success_count)

번역 가능한 문자열의 대소문자 변환#

언어마다 영어와 다른 대문자 표기 규칙을 가질 수 있습니다. 예를 들어 독일어에서는 문장 내 위치와 관계없이 모든 명사를 대문자로 표기합니다. 번역 가능한 문자열에 downcase나 toLocaleLowerCase()를 사용하지 않습니다. 텍스트는 번역자가 제어하도록 둡니다.

  • 맥락에 의존하는 경우:

toLocaleLowerCase() 메서드는 로케일을 인식하지만, 맥락에 따른 대소문자 표기 요구 사항은 처리할 수 없습니다. 예를 들어:

    # This forces lowercase, but it may not work for many languages:
    job_type = "CI/CD Pipeline".toLocaleLowerCase()
    s_("Jobs|Starting a new %{job_type}") % { job_type: job_type }

    # In German this would incorrectly show:
    # "Starting a new ci/cd pipeline"
    # When it should be:
    # "Starting a new CI/CD Pipeline"  (Pipeline is a noun and must be capitalized)

    # In French it might show:
    # "Starting a new ci/cd pipeline"
    # When it should be:
    # "Démarrer un nouveau pipeline CI/CD"  (technical terms might keep original case)

새 콘텐츠로 PO 파일 업데이트하기#

새 콘텐츠가 번역 대상으로 표시되었으므로, 다음 명령을 실행해 locale/gitlab.pot 파일을 업데이트합니다.

tooling/bin/gettext_extractor locale/gitlab.pot

이 명령은 새로 외부화된 문자열로 locale/gitlab.pot 파일을 업데이트하고 사용하지 않는 문자열을 제거합니다. 변경 사항이 기본 브랜치에 반영되면 Crowdin이 이를 가져와 번역 대상으로 표시합니다.

locale/[language]/gitlab.po 파일의 변경 사항은 커밋할 필요가 없습니다. Crowdin의 번역이 머지되면 자동으로 업데이트됩니다.

gitlab.pot 파일에 머지 충돌이 있으면 파일을 삭제하고 같은 명령으로 다시 생성할 수 있습니다.

PO 파일 검증하기#

번역 파일을 최신 상태로 유지하기 위해, static-analysis job의 일부로 CI에서 실행되는 린터가 있습니다. 로컬에서 PO 파일의 수정 사항을 린트하려면 rake gettext:lint를 실행하면 됩니다.

이 린터는 다음 사항을 확인합니다.

  • 올바른 PO 파일 문법
  • 변수 사용
    • 언어에 따라 변수 순서가 바뀔 수 있으므로 이름 없는(%d) 변수는 하나만 사용합니다.
    • 메시지 ID에서 사용한 모든 변수가 번역에서도 사용되어야 합니다.
    • 메시지 ID에 없는 변수를 번역에서 사용하면 안 됩니다.
  • 번역 중 오류
  • 꺾쇠괄호(< 또는 >)의 존재 여부

오류는 파일별, 메시지 ID별로 그룹화됩니다.

Errors in `locale/zh_HK/gitlab.po`:
  PO-syntax errors
    SimplePoParser::ParserErrorSyntax error in lines
    Syntax error in msgctxt
    Syntax error in msgid
    Syntax error in msgstr
    Syntax error in message_line
    There should be only whitespace until the end of line after the double quote character of a message text.
    Parsing result before error: '{:msgid=>["", "You are going to delete %{project_name_with_namespace}.\\n", "Deleted projects CANNOT be restored!\\n", "Are you ABSOLUTELY sure?"]}'
    SimplePoParser filtered backtrace: SimplePoParser::ParserError
Errors in `locale/zh_TW/gitlab.po`:
  1 pipeline
    <%d 條流水線> is using unknown variables: [%d]
    Failure translating to zh_TW with []: too few arguments

이 출력에서 locale/zh_HK/gitlab.po에는 문법 오류가 있습니다. locale/zh_TW/gitlab.po 파일에는 메시지 ID 1 pipeline에 없는 변수가 번역에 사용되었습니다.

새 언어 추가하기#

새 언어는 문자열의 최소 10%가 번역되고 승인된 후에만 사용자 환경설정의 옵션으로 추가해야 합니다. 더 많은 문자열이 번역되었더라도, GitLab UI에는 승인된 번역만 표시됩니다.

Note

번역률이 2% 미만인 언어는 UI에서 사용할 수 없습니다.

예를 들어 프랑스어처럼 새 언어에 대한 번역을 추가하려 한다고 가정합니다.

  1. lib/gitlab/i18n.rb에 새 언어를 등록합니다.

    ...
    AVAILABLE_LANGUAGES = {
      ...,
      'fr' => 'Français'
    }.freeze
    ...
    
  2. 언어를 추가합니다.

    bin/rake gettext:add_language[fr]
    

    특정 지역용 새 언어를 추가하려면 명령이 비슷합니다. 지역은 밑줄(_)로 구분하고 대문자로 지정해야 합니다. 예를 들면 다음과 같습니다.

    bin/rake gettext:add_language[en_GB]
    
  3. 언어를 추가하면 locale/fr/ 경로에 새 디렉터리도 생성됩니다. 이제 PO 편집기를 사용해 locale/fr/gitlab.edit.po에 있는 PO 파일을 편집할 수 있습니다.

  4. 번역을 업데이트한 후에는 PO 파일을 처리하여 바이너리 MO 파일을 생성하고, 번역이 담긴 JSON 파일을 업데이트해야 합니다.

    bin/rake gettext:compile
    
  5. 번역된 콘텐츠를 확인하려면 선호 언어를 변경해야 합니다. 이는 사용자의 Settings(/profile)에서 확인할 수 있습니다.

  6. 변경 사항에 문제가 없는지 확인한 후 새 파일을 커밋합니다. 예를 들면 다음과 같습니다.

    git add locale/fr/ app/assets/javascripts/locale/fr/
    git commit -m "Add French translations for Value Stream Analytics page"
    

UI에서 번역 수동으로 테스트하기#

Vue 번역을 수동으로 테스트하려면 다음과 같이 합니다.

  1. GitLab 현지화를 영어가 아닌 다른 언어로 변경합니다.
  2. bin/rake gettext:compile를 사용해 JSON 파일을 생성합니다.

GitLab 국제화(i18n)

GitLab v19.4
원문 보기

요약

국제화(i18n) 작업에는 GNU gettext를 사용합니다. 이 페이지에서 설명하는 모든 rake 명령어는 GitLab 인스턴스에서 실행해야 합니다. GitLab Community Edition 프로젝트에서 작업하려면 GDK를 통해 다운로드하고 설정해야 합니다.

국제화(i18n) 작업에는 GNU gettext를 사용합니다. 이는 이 작업에 가장 널리 쓰이는 도구이며, 이를 활용해 작업을 돕는 다양한 애플리케이션이 있기 때문입니다.

Note

이 페이지에서 설명하는 모든 rake 명령어는 GitLab 인스턴스에서 실행해야 합니다. 이 인스턴스는 일반적으로 GitLab Development Kit(GDK)입니다.

GitLab Development Kit(GDK) 설정#

GitLab Community Edition 프로젝트에서 작업하려면 GDK를 통해 다운로드하고 설정해야 합니다.

GitLab 프로젝트가 준비되면 번역 작업을 시작할 수 있습니다.

도구#

다음 도구를 사용합니다:

  • 번역 관련 일상적인 개발 작업을 돕기 위해 직접 작성한 도구:

    • tooling/bin/gettext_extractor locale/gitlab.pot: 모든 소스 파일을 스캔하여 번역할 새 콘텐츠를 찾습니다
    • rake gettext:compile: PO 파일의 내용을 읽어 프론트엔드에서 사용 가능한 모든 번역이 담긴 JS 파일을 생성합니다.
    • rake gettext:lint: PO 파일 유효성 검사
  • gettext_i18n_rails: 이 gem을 사용하면 모델, 뷰, 컨트롤러의 콘텐츠를 번역할 수 있습니다. 내부적으로 fast_gettext를 사용합니다.

    또한 일상적으로는 거의 필요하지 않은 다음 Rake 태스크에 대한 접근도 제공합니다:

    • rake gettext:add_language[language]: 새 언어 추가
    • rake gettext:find: Rails 애플리케이션의 거의 모든 파일을 파싱해 번역 대상으로 표시된 콘텐츠를 찾습니다. 그런 다음 이 콘텐츠로 PO 파일을 업데이트합니다.
    • rake gettext:pack: PO 파일을 처리하여 애플리케이션이 사용하는 바이너리 MO 파일을 생성합니다.
  • PO 편집기: PO 파일 작업에 도움이 되는 다양한 애플리케이션이 있습니다. 좋은 선택으로는 macOS, GNU/Linux, Windows에서 사용할 수 있는 Poedit이 있습니다.

번역을 위한 페이지 준비#

다음에 제공되는 헬퍼를 사용해 문자열을 번역 가능으로 표시합니다. 문자열은 사용 맥락이 명확하지 않은 도구에서 번역된다는 점에 유의합니다. 번역자에게 더 많은 맥락을 제공하려면 도메인별 문자열에 네임스페이스 적용을 고려합니다.

파일 유형은 네 가지입니다:

  • Ruby 파일: 모델과 컨트롤러.
  • HAML 파일: 뷰 파일.
  • ERB 파일: 이메일 템플릿에 사용합니다.
  • JavaScript 파일: 주로 Vue 템플릿으로 작업합니다.

Ruby 파일#

원시 문자열을 다루는 메서드나 변수가 있는 경우, 예를 들어:

def hello
  "Hello world!"
end

또는:

hello = "Hello world!"

다음과 같이 해당 콘텐츠를 번역 대상으로 표시할 수 있습니다:

def hello
  _("Hello world!")
end

또는:

hello = _("Hello world!")

클래스나 모듈 수준에서 문자열을 번역할 때는 주의합니다. 이는 클래스 로드 시 한 번만 평가되기 때문입니다. 예를 들어:

validates :group_id, uniqueness: { scope: [:project_id], message: _("already shared with this group") }

이 코드는 클래스가 로드될 때 번역되어, 오류 메시지가 항상 기본 로케일로 표시되는 결과를 낳습니다. Active Record의 :message 옵션은 Proc을 허용하므로 대신 다음과 같이 작성합니다:

validates :group_id, uniqueness: { scope: [:project_id], message: -> (object, data) { _("already shared with this group") } }

API(lib/api/ 또는 app/graphql)의 메시지는 외부화할 필요가 없습니다.

Rails 모델 오류 메시지#

Rails 모델에 오류 메시지를 추가할 때, 오류 메시지가 완전한 문장이라면 특정 속성에 오류를 추가하지 않습니다. Rails는 속성 이름을 자동으로 사람이 읽기 좋은 형태로 바꿔 오류 메시지 앞에 붙이는데, 이로 인해 번역하기 어려운 분리된 문장이 생깁니다.

예를 들어, 다음은 피합니다:

# Bad: Rails prepends "Squash option" to the message
errors.add(:squash_option, _('cannot be used with wildcard branch rules. Use an exact branch name.'))

대신, 완전한 문장으로 :base에 오류를 추가합니다:

# Good: Complete sentence that translators can work with
errors.add(:base, _('Squash option cannot be used with wildcard branch rules. Use an exact branch name.'))

HAML 파일#

HAML에 다음 콘텐츠가 있다면:

%h1 Hello world!

다음과 같이 번역 대상으로 표시할 수 있습니다:

%h1= _("Hello world!")

ERB 파일#

ERB에 다음 콘텐츠가 있다면:

<h1>Hello world!</h1>

다음과 같이 번역 대상으로 표시할 수 있습니다:

<h1><%= _("Hello world!") %></h1>

JavaScript 파일#

~/locale 모듈은 외부화를 위한 다음 핵심 함수를 내보냅니다:

  • __() 번역할 콘텐츠를 표시합니다(이중 언더스코어 괄호).
  • s__() 네임스페이스가 있는 번역할 콘텐츠를 표시합니다(s 이중 언더스코어 괄호).
  • n__() 복수형 번역할 콘텐츠를 표시합니다(n 이중 언더스코어 괄호).
import { __, s__, n__ } from '~/locale';

const defaultErrorMessage = s__('Branches|Create branch failed.');
const label = __('Subscribe');
const message =  n__('Apple', 'Apples', 3)

JavaScript 번역을 테스트하려면 UI에서 번역을 수동으로 테스트하기를 참고합니다.

Vue 파일#

Vue 파일에서는 translate 믹스인을 사용하여 Vue 템플릿에서 다음 함수를 사용할 수 있습니다:

  • __()
  • s__()
  • n__()
  • sprintf

즉, ~/locale 파일에서 이 함수들을 가져오지 않고도 Vue 템플릿에서 문자열을 외부화할 수 있습니다:

<template>
  <h1>{{ s__('Branches|Create a new branch') }}</h1>
  <gl-button>{{ __('Create branch') }}</gl-button>
</template>

Vue 컴포넌트의 JavaScript에서 문자열을 번역해야 한다면, JavaScript 파일 섹션에서 설명한 대로 ~/locale 파일에서 필요한 외부화 함수를 가져올 수 있습니다.

Vue 번역을 테스트하려면 UI에서 번역을 수동으로 테스트하기를 참고합니다.

테스트 파일(RSpec)#

RSpec 테스트에서 외부화된 콘텐츠에 대한 기대값은 하드코딩하지 않습니다. 기본이 아닌 로케일로 테스트를 실행해야 할 수 있으며, 콘텐츠가 하드코딩된 테스트는 실패하기 때문입니다.

즉, 외부화된 콘텐츠에 대한 기대값은 번역과 일치시키기 위해 동일한 외부화 메서드를 호출해야 합니다.

나쁜 예:

click_button 'Submit review'

expect(rendered).to have_content('Thank you for your feedback!')

좋은 예:

click_button _('Submit review')

expect(rendered).to have_content(_('Thank you for your feedback!'))

테스트 파일(Jest)#

프론트엔드 Jest 테스트에서는 기대값이 외부화 메서드를 참조할 필요가 없습니다. 외부화는 프론트엔드 테스트 환경에서 모킹되므로 기대값은 로케일과 무관하게 결정적입니다 (관련 MR 참고).

예시:

// Bad. Not necessary in Frontend environment.
expect(findText()).toBe(__('Lorem ipsum dolor sit'));
// Good.
expect(findText()).toBe('Lorem ipsum dolor sit');

권장 사항#

번역은 사용되는 위치에 최대한 가깝게 배치합니다. 가능하면 번역을 담은 변수보다 인라인 번역을 사용합니다. 번역에 대한 가장 좋은 설명은 그 키 자체입니다. 이는 코드 가독성을 높이고 코드 맥락을 유지하는 인지적 부담을 줄여줍니다. 또한 번역 외에 변수를 따로 유지할 필요가 없어 리팩토링이 쉬워집니다.

// Bad. A variable is defined far from where it is used
const TITLE = __('Organisations');

function transform() {
  return TITLE;
}

// Good.
function transform() {
  return __('Organisations');
}
공유 번역#

번역이 파일이나 모듈 내 여러 곳에서 사용되는 경우가 있습니다. 이때 번역을 공유하는 변수를 사용할 수 있지만, 다음 사항을 고려합니다:

  • 인라인 번역은 코드 명확성이 더 높습니다. 번역을 변수에 넣는 유일한 이유로 DRY 원칙을 내세우지 않습니다.
  • 번역을 삽입하거나 결합할 때는 주의합니다. 자세한 내용은 변수를 사용한 동적 텍스트 삽입을 참고합니다.
  • 두 번역이 같은 영문 키를 공유한다고 해서 다른 언어에서도 동일한 번역이 되는 것은 아닙니다. 적절한 경우 네임스페이스 사용을 고려합니다.

특정 경우에 번역이 담긴 변수를 사용하는 것이 더 나으면, 변수를 선언하고 배치하는 방법에 대한 다음 지침을 따릅니다.

JavaScript 파일에서는 번역이 담긴 상수를 선언합니다:

const ORGANISATIONS_TITLE = __('Organisations');

Vue 컴포넌트는 Vue 단일 파일 컴포넌트를 참고합니다.

모듈에서 여러 파일에 걸쳐 동일한 번역을 재사용하는 경우, constants.js나 i18n.js 파일에 추가하고 모듈 전체에서 가져올 수 있습니다. 다만 이는 코드베이스에 또 다른 복잡성을 더하므로 신중하게 사용합니다.

문자열을 내보낼 때 피해야 할 또 다른 방식은 스펙에서 그 문자열을 가져오는 것입니다. 더 효율적인 테스트처럼 보일 수 있지만(문구를 바꿔도 테스트가 계속 통과합니다), 다음과 같은 추가 문제가 생깁니다:

  • 가져오는 값이 undefined일 위험이 있어 테스트에서 false-positive가 발생할 수 있습니다(i18n 객체를 가져오는 경우 더욱 그렇습니다. 상수를 기본형으로 내보내기 참고).
  • 무엇을 테스트하는지 파악하기 어렵습니다(어떤 문구를 기대해야 하는지).
  • 어서션을 다시 작성하지 않고 상수 값이 올바르다고 가정하기 때문에 오타를 놓칠 위험이 더 높습니다.
  • 이 접근 방식의 이점은 미미합니다. 컴포넌트의 문구를 바꿔도 스펙을 바꾸지 않아도 된다는 점은 잠재적 문제를 상쇄할 만큼 크지 않습니다.

예를 들면:

import { MSG_ALERT_SETTINGS_FORM_ERROR } from 'path/to/constants.js';

// Bad. What is the actual text for `MSG_ALERT_SETTINGS_FORM_ERROR`? If `wrapper.text()` returns undefined, the test may still pass with the wrong values!
expect(wrapper.text()).toBe(MSG_ALERT_SETTINGS_FORM_ERROR);
// Very bad. Same problem as above and we are going through the vm property!
expect(wrapper.text()).toBe(MyComponent.vm.i18n.buttonLabel);
// Good. What we are expecting is very clear and there can be no surprises.
expect(wrapper.text()).toBe('There was an error: Please refresh and hope for the best!');
Vue 단일 파일 컴포넌트#

Vue 단일 파일 컴포넌트에서는 번역 호출을 <template>에 직접 넣습니다. 이 섹션에 나열된 이유 중 하나가 적용되지 않는 한 컴포넌트의 $options.i18n 객체로 옮기지 않습니다.

// Bad. A single-use string moved away from where it is rendered.
<script>
  export default {
    i18n: {
      buttonLabel: s__('Plan|Button label'),
    },
  };
</script>

<template>
  <gl-button>{{ $options.i18n.buttonLabel }}</gl-button>
</template>

// Good.
<template>
  <gl-button>{{ s__('Plan|Button label') }}</gl-button>
</template>

인라인 문자열은 렌더링되는 위치에서 바로 읽을 수 있어 따라갈 간접 참조가 없고, 마크업을 삭제하는 리팩토링이 고아 상태의 i18n 항목을 남기지 않습니다. 일반적인 원칙은 권장 사항을 참고합니다.

번역 헬퍼는 컴포넌트 전체에서 동작하므로, 인라인화가 <template>에만 한정되지는 않습니다:

  • <template>에서는 __(), s__(), n__(), sprintf()를 직접 호출합니다. translate 믹스인이 이를 제공하므로 별도로 가져올 필요가 없습니다. 자세한 내용은 Vue 파일을 참고합니다.
  • 메서드, 라이프사이클 훅, computed 속성, Apollo error() 핸들러 같은 컴포넌트 JavaScript에서는 this.__(), this.s__()로 호출하거나 ~/locale에서 가져옵니다.
  • prop의 default나 validator에서는 아직 컴포넌트 인스턴스가 존재하지 않으므로 this나 $options.i18n에 접근할 수 없습니다. 헬퍼를 가져와 직접 호출합니다.

gettext 추출기는 <template> 블록을 파싱하므로 인라인 문자열도 pot 파일에 반영됩니다. sprintf, n__, GlSprintf도 템플릿에서 모두 동작합니다.

다음의 경우에는 $options.i18n이나 상수를 사용합니다:

  • 같은 문자열이 재사용되고 단일 소스로 관리할 가치가 있는 경우입니다. 템플릿에 표시되면서 컴포넌트 JavaScript에서도 쓰이는 문자열, 또는 여러 사용처에서 반드시 동일하게 유지되어야 하는 문자열이 그렇습니다. 중복 자체는 이유가 되지 않습니다. 인라인 호출 두 개는 괜찮으며, 권장 사항에서 언급했듯 DRY가 유일한 근거는 아닙니다. 사용처를 동기화 상태로 유지하는 것이 중요하거나 개수가 두어 개를 넘어설 때 추출합니다.
  • 문자열에 ~/lib/dompurify의 sanitize()처럼 템플릿에 두기 적절하지 않은 처리가 필요한 경우입니다.
  • 문자열이 상태나 열거값으로 키가 지정된 조회 맵의 값인 경우입니다. 키는 런타임에만 알 수 있기 때문입니다.
  • 문자열이 같은 모듈 내 여러 컴포넌트에서 공유되는 경우입니다. 자세한 내용은 공유 번역을 참고합니다.

다음 예시는 템플릿과 메서드 양쪽에서 사용되는 문자열을 보여줍니다. 항목을 하나로 두면 둘을 동기화된 상태로 유지할 수 있습니다:

<script>
  import { createAlert } from '~/alert';

  export default {
    i18n: {
      saveError: s__('Plan|Could not save the plan'),
    },
    methods: {
      onError() {
        createAlert({ message: this.$options.i18n.saveError });
      },
    },
  };
</script>

<template>
  <gl-alert v-if="failed">{{ $options.i18n.saveError }}</gl-alert>
</template>

개수에 따라 달라지는 문자열은 Vue에서의 복수형 처리를 참고합니다.

이 지침은 새 코드와 변경된 코드에 적용합니다. 기존의 $options.i18n 사용을 굳이 마이그레이션할 필요는 없습니다.

동적 번역#

자세한 내용은 번역을 동적으로 유지하기를 참고합니다.

번역된 문자열 변경하기#

GitLab에서 소스 문자열을 변경하는 경우, 변경 사항을 푸시하기 전에 pot 파일을 업데이트합니다. pot 파일이 최신 상태가 아니면 pre-push 검사와 gettext용 파이프라인 job이 실패합니다.

특수 콘텐츠 다루기#

보간(Interpolation)#

번역된 텍스트의 플레이스홀더는 해당 소스 파일의 코드 스타일과 일치시킵니다. 예를 들어 Ruby에서는 %{created_at}을, JavaScript에서는 %{createdAt}을 사용합니다. 링크 추가 시 문장을 분리하지 않도록 주의합니다.

  • Ruby/HAML에서:

    format(_("Hello %{name}"), name: 'Joe') => 'Hello Joe'
    
  • Vue에서:

    다음 경우에는 GlSprintf 컴포넌트를 사용합니다:

    • 번역 문자열에 자식 컴포넌트를 포함하는 경우입니다.
    • 번역 문자열에 HTML을 포함하는 경우입니다.
    • sprintf를 사용하면서 플레이스홀더 값의 이스케이프를 막기 위해 세 번째 인수로 false를 전달하는 경우입니다.

    예를 들면:

    <gl-sprintf :message="s__('ClusterIntegration|Learn more about %{linkStart}zones%{linkEnd}')">
      <template #link="{ content }">
        <gl-link :href="somePath">{{ content }}</gl-link>
      </template>
    </gl-sprintf>
    

    그 외의 경우에는 계산된 속성 등에서 sprintf를 사용하는 것이 더 간단할 수 있습니다. 예를 들면:

    <script>
    import { __, sprintf } from '~/locale';
    
    export default {
      ...
      computed: {
        userWelcome() {
          return sprintf(__('Hello %{username}'), { username: this.user.name });
        }
      }
      ...
    }
    </script>
    
    <template>
      <span>{{ userWelcome }}</span>
    </template>
    
  • JavaScript에서(Vue를 사용할 수 없는 경우):

    import { __, sprintf } from '~/locale';
    
    sprintf(__('Hello %{username}'), { username: 'Joe' }); // => 'Hello Joe'
    

    번역 내에 마크업을 사용해야 한다면 sprintf를 사용하고, 세 번째 인수로 false를 전달하여 플레이스홀더 값의 이스케이프를 막습니다. 보간되는 동적 값은 직접 이스케이프해야 하며, 예를 들어 lodash-es의 escape를 사용합니다.

    import { escape } from 'lodash-es';
    import { __, sprintf } from '~/locale';
    
    let someDynamicValue = '<script>alert("evil")</script>';
    
    // Dangerous:
    sprintf(__('This is %{value}'), { value: `<strong>${someDynamicValue}</strong>`, false);
    // => 'This is <strong><script>alert('evil')</script></strong>'
    
    // Incorrect:
    sprintf(__('This is %{value}'), { value: `<strong>${someDynamicValue}</strong>` });
    // => 'This is &lt;strong&gt;&lt;script&gt;alert(&#x27;evil&#x27;)&lt;/script&gt;&lt;/strong&gt;'
    
    // OK:
    sprintf(__('This is %{value}'), { value: `<strong>${escape(someDynamicValue)}</strong>` }, false);
    // => 'This is <strong>&lt;script&gt;alert(&#x27;evil&#x27;)&lt;/script&gt;</strong>'
    

복수형#

GitLab은 복수형 처리에 GNU gettext를 사용합니다. n_()과 n__() 구문, CLDR 복수형 카테고리, 흔한 안티패턴에 대한 지침은 복수형을 참고합니다.

네임스페이스#

네임스페이스는 함께 속하는 번역을 그룹화하는 방법입니다. 접두사를 붙이고 그 뒤에 바 기호(|)를 추가하여 번역자에게 맥락을 제공합니다. 예를 들어:

'Namespace|Translated string'

네임스페이스는:

  • 단어의 모호성을 해소합니다. 예: Promotions|Promote 대 Epic|Promote.
  • 번역자가 임의의 문자열이 아니라 같은 제품 영역에 속한 외부화 문자열 번역에 집중할 수 있게 합니다.
  • 번역자에게 언어적 맥락을 제공합니다.

일부 언어는 영어보다 맥락 의존적입니다. 예를 들어 cancel은 사용 방식에 따라 다르게 번역될 수 있습니다. 사용 맥락을 정의하기 위해 영어 UI 텍스트에는 항상 네임스페이스를 추가합니다.

네임스페이스는 PascalCase로 작성합니다.

네임스페이스를 선택할 때는 더 나은 맥락을 제공하기 위해 광범위한 카테고리보다 세밀한 하위 카테고리를 우선합니다. Feature| 같은 범용 네임스페이스 대신, 기능 영역이나 액션 맥락을 설명하는 더 구체적인 하위 카테고리를 고려합니다. 적절한 맥락이 없으면 모호해질 수 있는, 일반적으로 보이는 문자열에서는 이 점이 특히 중요합니다.

예를 들어:

  • WorkItems|Add to 대신 WorkItemsStatusConfigure|Add to를 사용합니다.
  • MergeRequest|Approve 대신 MergeRequestReviewActions|Approve를 사용합니다.
  • Project|Delete 대신 ProjectSettingsGeneral|Delete를 사용합니다.

이 접근 방식은 번역자가 다음을 이해하는 데 도움이 됩니다:

  • 문자열이 나타나는 구체적인 UI 맥락.

  • 의도된 액션이나 목적.

  • 같은 기능 영역의 다른 유사 문자열과 문자열이 어떻게 관련되는지.

  • Ruby/HAML에서:

    s_('OpenedNDaysAgo|Opened')
    

    번역을 찾을 수 없으면 Opened가 반환됩니다.

  • JavaScript에서:

    s__('OpenedNDaysAgo|Opened')
    

네임스페이스는 번역에서 제거해야 합니다. 자세한 내용은 번역 가이드라인을 참고합니다.

HTML#

이제 번역을 위해 제출하는 문자열에는 HTML을 직접 포함하지 않습니다. 이유는 다음과 같습니다:

  1. 번역된 문자열에 잘못된 HTML이 실수로 포함될 수 있습니다.
  2. Open Web Application Security Project(OWASP)에서 지적했듯, 번역된 문자열이 XSS의 공격 벡터가 될 수 있습니다.

번역된 문자열에 서식을 포함하려면 다음과 같이 할 수 있습니다:

  • Ruby/HAML에서:

    safe_format(_('Some %{strongOpen}bold%{strongClose} text.'), tag_pair(tag.strong, :strongOpen, :strongClose))
    # => 'Some <strong>bold</strong> text.'
    
  • JavaScript에서:

      sprintf(__('Some %{strongOpen}bold%{strongClose} text.'), { strongOpen: '<strong>', strongClose: '</strong>'}, false);
    
      // => 'Some <strong>bold</strong> text.'
    
  • Vue에서:

    보간 섹션을 참고합니다.

이 번역 헬퍼 이슈가 완료되면 번역된 문자열에 서식을 포함하는 절차를 업데이트할 예정입니다.

꺾쇠 괄호 포함#

HTML에 사용되지 않는 꺾쇠 괄호(</>)가 문자열에 포함되어 있으면 rake gettext:lint 린터가 여전히 이를 플래그로 표시합니다. 이 오류를 피하려면 해당하는 HTML 엔티티 코드 (&lt; 또는 &gt;)를 대신 사용합니다:

  • Ruby/HAML에서:

    safe_format(_('In &lt; 1 hour'))
    
    # => 'In < 1 hour'
    
  • JavaScript에서:

    import { sanitize } from '~/lib/dompurify';
    
    const i18n = { LESS_THAN_ONE_HOUR: sanitize(__('In &lt; 1 hour'), { ALLOWED_TAGS: [] }) };
    
    // ... using the string
    element.innerHTML = i18n.LESS_THAN_ONE_HOUR;
    
    // => 'In < 1 hour'
    
  • Vue에서:

    <gl-sprintf :message="s__('In &lt; 1 hours')"/>
    
    // => 'In < 1 hour'
    

숫자#

로케일마다 숫자 형식이 다를 수 있습니다. 숫자의 현지화를 지원하기 위해 toLocaleString()을 활용하는 formatNumber를 사용합니다.

기본적으로 formatNumber는 현재 사용자 로케일을 사용하여 숫자를 문자열로 형식화합니다.

  • JavaScript에서:
import { formatNumber } from '~/locale';

// Assuming "User Preferences > Language" is set to "English":

const tenThousand = formatNumber(10000); // "10,000" (uses comma as decimal symbol in English locale)
const fiftyPercent = formatNumber(0.5, { style: 'percent' }) // "50%" (other options are passed to toLocaleString)
  • Vue 템플릿에서:
<script>
import { formatNumber } from '~/locale';

export default {
  //...
  methods: {
    // ...
    formatNumber,
  },
}
</script>
<template>
<div class="my-number">
  {{ formatNumber(10000) }} <!-- 10,000 -->
</div>

<div class="my-percent">
  {{ formatNumber(0.5,  { style: 'percent' }) }} <!-- 50% -->
</div>

</template>

날짜/시간#

  • JavaScript에서:
import { createDateTimeFormat } from '~/locale';

const dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' });
console.log(dateFormat.format(new Date('2063-04-05'))) // April 5, 2063

이는 Intl.DateTimeFormat을 활용합니다.

  • Ruby/HAML에서는 날짜와 시간에 서식을 지정하는 두 가지 방법이 있습니다:

    • l 헬퍼 사용: 예를 들어 l(active_session.created_at, format: :short). 날짜와 시간에는 미리 정의된 형식이 있습니다. 코드의 다른 부분에서도 활용할 수 있어 새 형식을 추가해야 한다면 en.yml 파일에 추가합니다.
    • strftime 사용: 예를 들어 milestone.start_date.strftime('%b %-d'). en.yml에 정의된 형식 중 필요한 날짜/시간 사양과 일치하는 것이 없고, 매우 특수한 경우(예: 단일 뷰에서만 사용되는 경우)라 새 형식으로 추가할 필요가 없을 때 strftime을 사용합니다.

모범 사례#

번역 업데이트 최소화#

업데이트는 해당 문자열의 번역 손실로 이어질 수 있습니다. 위험을 최소화하려면 다음에 해당하지 않는 한 문자열 변경을 피합니다:

  • 사용자에게 가치를 더하는 경우.
  • 번역자를 위한 추가 맥락을 담는 경우.

예를 들어 다음과 같은 변경은 피합니다:

- _('Number of things: %{count}') % { count: 10 }
+ n_('Number of things: %d', 10)

번역을 동적으로 유지하기#

배열이나 해시 안에 번역을 함께 두는 것이 합리적인 경우가 있습니다.

예시:

  • 드롭다운 목록을 위한 매핑
  • 오류 메시지

이런 종류의 데이터를 저장할 때 상수를 사용하는 것이 최선의 선택처럼 보입니다. 하지만 번역에는 이 방법이 통하지 않습니다.

예를 들어 다음은 피합니다:

class MyPresenter
  MY_LIST = {
    key_1: _('item 1'),
    key_2: _('item 2'),
    key_3: _('item 3')
  }
end

번역 메서드(_)는 클래스가 처음 로드될 때 호출되어 텍스트를 기본 로케일로 번역합니다. 사용자의 로케일과 무관하게 이 값들은 두 번째로 번역되지 않습니다.

메모이제이션을 사용하는 클래스 메서드에서도 비슷한 일이 발생합니다.

예를 들어 다음은 피합니다:

class MyModel
  def self.list
    @list ||= {
      key_1: _('item 1'),
      key_2: _('item 2'),
      key_3: _('item 3')
    }
  end
end

이 메서드는 이 메서드를 처음 호출한 사용자의 로케일을 사용해 번역을 메모이제이션합니다.

이런 문제를 피하려면 번역을 동적으로 유지합니다.

좋은 예:

class MyPresenter
  def self.my_list
    {
      key_1: _('item 1'),
      key_2: _('item 2'),
      key_3: _('item 3')
    }.freeze
  end
end

bin/rake gettext:find를 실행할 때 파서가 찾지 못하는 동적 번역이 있는 경우가 있습니다. 이런 상황에는 N_ 메서드를 사용할 수 있습니다. 유효성 검사 오류 메시지를 번역하는 대안적인 방법도 있습니다.

문장 분리#

문장을 절대 분리하지 않습니다. 문장을 분리하면 문장의 문법과 구조가 모든 언어에서 동일하다고 가정하게 되기 때문입니다.

예를 들어 다음은:

{{ s__("mrWidget|Set by") }}
{{ author.name }}
{{ s__("mrWidget|to be merged automatically when the pipeline succeeds") }}

다음과 같이 외부화해야 합니다:

{{ sprintf(s__("mrWidget|Set by %{author} to be merged automatically when the pipeline succeeds"), { author: author.name }) }}

번역 가능한 문장을 여러 GlSprintf 인스턴스로 나누지 않습니다. Created %{date} by %{author}처럼 전체 문장은 하나의 GlSprintf :message에 플레이스홀더와 함께 유지하여, 번역자가 자기 언어의 문법에 맞게 단어 순서를 재배열할 수 있게 합니다.

링크 추가 시 문장 분리 피하기#

이는 번역된 문장 사이에 링크를 사용할 때도 적용됩니다. 그렇지 않으면 특정 언어에서 이 텍스트를 번역할 수 없게 됩니다.

  • Ruby/HAML에서, 다음 대신:

    - zones_link = link_to(s_('ClusterIntegration|zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer')
    = s_('ClusterIntegration|Learn more about %{zones_link}').html_safe % { zones_link: zones_link }
    

    링크의 시작과 끝 HTML 조각을 변수로 설정합니다:

    - zones_link_url = 'https://cloud.google.com/compute/docs/regions-zones/regions-zones'
    - zones_link = link_to('', zones_link_url, target: '_blank', rel: 'noopener noreferrer')
    = safe_format(s_('ClusterIntegration|Learn more about %{zones_link_start}zones%{zones_link_end}'), tag_pair(zones_link, :zones_link_start, :zones_link_end))
    
  • Vue에서, 다음 대신:

    <template>
      <div>
        <gl-sprintf :message="s__('ClusterIntegration|Learn more about %{link}')">
          <template #link>
            <gl-link
              href="https://cloud.google.com/compute/docs/regions-zones/regions-zones"
              target="_blank"
            >zones</gl-link>
          </template>
        </gl-sprintf>
      </div>
    
    </template>
    

    링크의 시작과 끝 HTML 조각을 플레이스홀더로 설정합니다:

    <template>
      <div>
        <gl-sprintf :message="s__('ClusterIntegration|Learn more about %{linkStart}zones%{linkEnd}')">
          <template #link="{ content }">
            <gl-link
              href="https://cloud.google.com/compute/docs/regions-zones/regions-zones"
              target="_blank"
            >{{ content }}</gl-link>
          </template>
        </gl-sprintf>
      </div>
    
    </template>
    
  • JavaScript에서(Vue를 사용할 수 없는 경우), 다음 대신:

    {{
        sprintf(s__("ClusterIntegration|Learn more about %{link}"), {
            link: '<a href="https://cloud.google.com/compute/docs/regions-zones/regions-zones" target="_blank" rel="noopener noreferrer">zones</a>'
        }, false)
    }}
    

    링크의 시작과 끝 HTML 조각을 플레이스홀더로 설정합니다:

    {{
        sprintf(s__("ClusterIntegration|Learn more about %{linkStart}zones%{linkEnd}"), {
            linkStart: '<a href="https://cloud.google.com/compute/docs/regions-zones/regions-zones" target="_blank" rel="noopener noreferrer">',
            linkEnd: '</a>',
        }, false)
    }}
    

이렇게 하는 이유는 일부 언어에서 단어가 맥락에 따라 바뀌기 때문입니다. 예를 들어 일본어에서는 は가 문장의 주어에, を가 목적어에 붙습니다. 문장에서 개별 단어를 추출하면 올바르게 번역할 수 없습니다.

확신이 서지 않을 때는 이 Mozilla Developer 문서에 설명된 모범 사례를 따릅니다.

번역 헬퍼에는 항상 문자열 리터럴을 전달하기#

tooling/bin/gettext_extractor locale/gitlab.pot 스크립트는 코드베이스를 파싱하여 번역 준비가 된 모든 문자열을 번역 헬퍼에서 추출합니다.

문자열이 변수, 함수 호출, 문자열 보간으로 전달되면 스크립트가 이를 확인할 수 없습니다. 따라서 헬퍼에는 항상 문자열 리터럴을 전달합니다.

  • Ruby/HAML에서:

    # Good
    _('Some label');
    _("Some label");
    _('Hi %{name}') % { name: 'Luki' }
    s_('Namespace|Label');
    n_('%d apple', '%d apples', appleCount);
    
    # Bad
    _(LABEL);
    _('Hi %{name}' % { name: 'Luki' })
    _("Step: #{count}");
    s_(get_label());
    n_(label_singular, label_plural, appleCount);
    
  • JavaScript에서:

    // Good
    __('Some label');
    s__('Namespace', 'Label');
    s__('Namespace|Label');
    n__('%d apple', '%d apples', appleCount);
    
    // Bad
    __(LABEL);
    __(`Step: ${count}`);
    s__(getLabel());
    s__(NAMESPACE, LABEL);
    n__(LABEL_SINGULAR, LABEL_PLURAL, appleCount);
    

변수를 사용한 동적 텍스트 삽입#

번역 가능한 문자열에서 텍스트 값을 변수로 사용할 때는 여러 언어에서 문법적 정확성을 보장하기 위해 특별한 주의가 필요합니다.

위험 요소와 과제#

번역 가능한 문자열에 텍스트를 추가하기 위해 변수를 사용하면 여러 현지화 과제가 발생할 수 있습니다:

  • 성 일치: 문법적 성이 있는 언어에서는 삽입되는 명사의 성에 따라 관사, 형용사, 대명사의 형태가 달라질 수 있습니다. 예를 들어 프랑스어에서는 관사, 형용사, 일부 과거분사가 명사의 성 및 문장 내 위치와 일치해야 합니다.
  • 격과 어형 변화: 격이 있는 언어(독일어 등)에서는 삽입되는 텍스트가 문장에서의 문법적 역할에 따라 다른 형태가 필요할 수 있습니다.
  • 어순: 언어마다 어순 요구 사항이 다르며, 자연스러운 번역을 위해 삽입되는 텍스트가 문장의 다른 위치에 나타나야 할 수 있습니다.

모범 사례#

  1. 가능하면 텍스트를 변수로 추가하지 않습니다:
    • 변수가 있는 문자열 하나 대신, 각 경우에 대해 고유한 문자열을 만듭니다. 예를 들어:
    # Instead of:
    s_('WorkItem|Adds this %{workItemType} as related to %{relatedWorkItemType}')

    # Create separate strings:
    s_('WorkItem|Adds this task as related to incident')
    s_('WorkItem|Adds this incident as related to task')
  1. 문장형 배열보다 주제-설명 구조를 사용합니다: 변수 사용을 피할 수 없다면, 완전한 문장 대신 주제-설명 구조를 사용하도록 메시지를 재구성하는 것을 고려합니다:
   # Instead of a sentence with inserted variables:
   s_('WorkItem|Adds this %{workItemType} as related to %{relatedWorkItemType}')

   # Use topic-comment structure:
   s_('WorkItem|Related items: %{workItemType} → %{relatedWorkItemType}')

패턴 결합#

네임스페이싱, 복수형 처리, 파라미터 치환 같은 여러 기능이 필요한 복잡한 시나리오를 처리하기 위해 번역 헬퍼 함수를 결합할 수 있습니다.

예를 들어:

   ns_('BulkImport|%{count} placeholder user has been created.', 'BulkImport|%{count} placeholder users have been created.', @success_count)

번역 가능한 문자열의 대소문자 변환#

언어마다 영어와 다른 대문자 표기 규칙을 가질 수 있습니다. 예를 들어 독일어에서는 문장 내 위치와 관계없이 모든 명사를 대문자로 표기합니다. 번역 가능한 문자열에 downcase나 toLocaleLowerCase()를 사용하지 않습니다. 텍스트는 번역자가 제어하도록 둡니다.

  • 맥락에 의존하는 경우:

toLocaleLowerCase() 메서드는 로케일을 인식하지만, 맥락에 따른 대소문자 표기 요구 사항은 처리할 수 없습니다. 예를 들어:

    # This forces lowercase, but it may not work for many languages:
    job_type = "CI/CD Pipeline".toLocaleLowerCase()
    s_("Jobs|Starting a new %{job_type}") % { job_type: job_type }

    # In German this would incorrectly show:
    # "Starting a new ci/cd pipeline"
    # When it should be:
    # "Starting a new CI/CD Pipeline"  (Pipeline is a noun and must be capitalized)

    # In French it might show:
    # "Starting a new ci/cd pipeline"
    # When it should be:
    # "Démarrer un nouveau pipeline CI/CD"  (technical terms might keep original case)

새 콘텐츠로 PO 파일 업데이트하기#

새 콘텐츠가 번역 대상으로 표시되었으므로, 다음 명령을 실행해 locale/gitlab.pot 파일을 업데이트합니다.

tooling/bin/gettext_extractor locale/gitlab.pot

이 명령은 새로 외부화된 문자열로 locale/gitlab.pot 파일을 업데이트하고 사용하지 않는 문자열을 제거합니다. 변경 사항이 기본 브랜치에 반영되면 Crowdin이 이를 가져와 번역 대상으로 표시합니다.

locale/[language]/gitlab.po 파일의 변경 사항은 커밋할 필요가 없습니다. Crowdin의 번역이 머지되면 자동으로 업데이트됩니다.

gitlab.pot 파일에 머지 충돌이 있으면 파일을 삭제하고 같은 명령으로 다시 생성할 수 있습니다.

PO 파일 검증하기#

번역 파일을 최신 상태로 유지하기 위해, static-analysis job의 일부로 CI에서 실행되는 린터가 있습니다. 로컬에서 PO 파일의 수정 사항을 린트하려면 rake gettext:lint를 실행하면 됩니다.

이 린터는 다음 사항을 확인합니다.

  • 올바른 PO 파일 문법
  • 변수 사용
    • 언어에 따라 변수 순서가 바뀔 수 있으므로 이름 없는(%d) 변수는 하나만 사용합니다.
    • 메시지 ID에서 사용한 모든 변수가 번역에서도 사용되어야 합니다.
    • 메시지 ID에 없는 변수를 번역에서 사용하면 안 됩니다.
  • 번역 중 오류
  • 꺾쇠괄호(< 또는 >)의 존재 여부

오류는 파일별, 메시지 ID별로 그룹화됩니다.

Errors in `locale/zh_HK/gitlab.po`:
  PO-syntax errors
    SimplePoParser::ParserErrorSyntax error in lines
    Syntax error in msgctxt
    Syntax error in msgid
    Syntax error in msgstr
    Syntax error in message_line
    There should be only whitespace until the end of line after the double quote character of a message text.
    Parsing result before error: '{:msgid=>["", "You are going to delete %{project_name_with_namespace}.\\n", "Deleted projects CANNOT be restored!\\n", "Are you ABSOLUTELY sure?"]}'
    SimplePoParser filtered backtrace: SimplePoParser::ParserError
Errors in `locale/zh_TW/gitlab.po`:
  1 pipeline
    <%d 條流水線> is using unknown variables: [%d]
    Failure translating to zh_TW with []: too few arguments

이 출력에서 locale/zh_HK/gitlab.po에는 문법 오류가 있습니다. locale/zh_TW/gitlab.po 파일에는 메시지 ID 1 pipeline에 없는 변수가 번역에 사용되었습니다.

새 언어 추가하기#

새 언어는 문자열의 최소 10%가 번역되고 승인된 후에만 사용자 환경설정의 옵션으로 추가해야 합니다. 더 많은 문자열이 번역되었더라도, GitLab UI에는 승인된 번역만 표시됩니다.

Note

번역률이 2% 미만인 언어는 UI에서 사용할 수 없습니다.

예를 들어 프랑스어처럼 새 언어에 대한 번역을 추가하려 한다고 가정합니다.

  1. lib/gitlab/i18n.rb에 새 언어를 등록합니다.

    ...
    AVAILABLE_LANGUAGES = {
      ...,
      'fr' => 'Français'
    }.freeze
    ...
    
  2. 언어를 추가합니다.

    bin/rake gettext:add_language[fr]
    

    특정 지역용 새 언어를 추가하려면 명령이 비슷합니다. 지역은 밑줄(_)로 구분하고 대문자로 지정해야 합니다. 예를 들면 다음과 같습니다.

    bin/rake gettext:add_language[en_GB]
    
  3. 언어를 추가하면 locale/fr/ 경로에 새 디렉터리도 생성됩니다. 이제 PO 편집기를 사용해 locale/fr/gitlab.edit.po에 있는 PO 파일을 편집할 수 있습니다.

  4. 번역을 업데이트한 후에는 PO 파일을 처리하여 바이너리 MO 파일을 생성하고, 번역이 담긴 JSON 파일을 업데이트해야 합니다.

    bin/rake gettext:compile
    
  5. 번역된 콘텐츠를 확인하려면 선호 언어를 변경해야 합니다. 이는 사용자의 Settings(/profile)에서 확인할 수 있습니다.

  6. 변경 사항에 문제가 없는지 확인한 후 새 파일을 커밋합니다. 예를 들면 다음과 같습니다.

    git add locale/fr/ app/assets/javascripts/locale/fr/
    git commit -m "Add French translations for Value Stream Analytics page"
    

UI에서 번역 수동으로 테스트하기#

Vue 번역을 수동으로 테스트하려면 다음과 같이 합니다.

  1. GitLab 현지화를 영어가 아닌 다른 언어로 변경합니다.
  2. bin/rake gettext:compile를 사용해 JSON 파일을 생성합니다.