블로그로 돌아가기
A Contentful-style content entry card showing a filled English locale tab and an empty French locale tab with a warning icon.

왜 당신의 Contentful 콘텐츠가 아직 실제로 다국어가 아닌가요

By Robert

왜 당신의 Contentful 콘텐츠는 아직 진정한 다국어가 아닌가

Contentful은 로케일을 잘 처리합니다. 필요한 만큼 정의할 수 있고, 폴백 체인을 설정하며, 에디터에서 로케일 간 전환이 가능하고, 경로별로 올바른 언어를 제공하는 프론트엔드를 구축할 수 있습니다. 다국어 콘텐츠를 위한 인프라는 정말 훌륭합니다.

Contentful이 하지 않는 것은 당신의 콘텐츠를 번역하는 것입니다.

이것은 직접 말하면 당연해 보이지만, "로케일 필드를 설정했다"는 것과 "우리는 다국어다"를 혼동하기 쉽습니다. 두 가지는 동일하지 않습니다. 로케일 필드는 컨테이너입니다. 다국어란 그 컨테이너 안에 콘텐츠가 있다는 뜻입니다.

만약 당신의 Contentful 공간에 프랑스어와 독일어 로케일이 설정되어 있지만, 그 로케일의 필드가 비어 있거나 영어 복사본이 자리 표시자로 채워져 있거나, 사이트가 출시된 이후 아무도 검토하지 않은 대략적인 초안으로 채워져 있다면 — 당신은 다국어가 아닙니다. 다국어를 위한 뼈대만 있는 것입니다.

이 글은 그 간극을 메우는 것에 관한 내용입니다.


Contentful이 실제로 제공하는 것

Contentful의 로케일 시스템은 잘 설계되어 있습니다. 각 콘텐츠 항목은 로케일별로 필드 값을 가질 수 있습니다. 기본 로케일을 설정하고, 폴백을 구성하며, 배달 API는 요청 시 올바른 로케일을 반환합니다. 콘텐츠 모델링은 다양한 콘텐츠 유형에 걸친 복잡한 다국어 요구 사항을 처리할 만큼 유연합니다.

하지만 시스템은 번역된 콘텐츠가 어떻게 입력되는지에 대해 전혀 중립적입니다. Contentful은 프랑스어 로케일 필드에 전문 번역이 들어 있는지, 기계 번역된 복사본인지, 실수로 붙여넣은 영어 텍스트인지, 아니면 전혀 없는지 알지 못합니다. 단지 당신이 입력한 것을 저장하고 전달할 뿐입니다.

번역 문제는 전적으로 당신이 해결해야 할 문제입니다.


팀들이 보통 어떻게 처리하는지 (그리고 어디서 문제가 발생하는지)

Contentful 번역을 처리하는 대부분의 팀은 몇 가지 패턴 중 하나에 속합니다.

수동 내보내기 패턴. 개발자가 Contentful에서 콘텐츠를 내보내 번역 에이전시나 프리랜서에게 보내고, 돌아올 때까지 기다렸다가 다시 포맷하고 가져옵니다. 이는 일회성 출시에는 작동하지만 콘텐츠가 변경되면 유지할 수 없습니다. 소스 로케일이 업데이트될 때마다 전체 사이클을 다시 거쳐야 합니다. 실제로 번역된 콘텐츠는 빠르게 소스에 뒤처지고 아무도 따라잡을 시간이 없습니다.

에디터 내 번역 패턴. 편집자가 각 항목을 열고 대상 로케일로 전환한 후 필드별로 수동으로 또는 번역 도구에 내용을 붙여넣어 번역합니다. 정확하지만 느립니다. 또한 확장성이 없습니다 — 수십 개의 콘텐츠 유형에 걸쳐 수백 개의 항목이 있다면 수작업 양이 상당합니다.

"그냥 이 정도면 됐어" 패턴. 번역된 콘텐츠는 존재하지만 처음 생성된 이후로 검토되지 않았습니다. 그 사이 소스 로케일은 여러 번 업데이트되었습니다. 프랑스어 가격 페이지는 8개월 전에 폐지한 요금제를 여전히 참조하고 있습니다. 독일어 홈페이지는 여전히 오래된 태그라인을 사용하고 있습니다. 아무도 문제를 지적하지 않았는데, 아무도 확인하지 않기 때문입니다.

세 가지 패턴 모두 근본적인 문제를 공유합니다: 번역이 일회성 작업으로 취급되고 콘텐츠 워크플로우의 지속적인 일부로 다뤄지지 않는다는 점입니다.


다국어가 실제로 요구하는 것

Contentful에서 진정한 다국어 콘텐츠는 세 가지가 함께 작동해야 합니다.

정확한 초기 번역. 모든 대상 로케일의 모든 필드에는 정확하고 적절하게 현지화되었으며, 단순한 번역 도구를 거쳐 검토 없이 남겨진 것이 아니라 해당 시장을 위해 작성된 것처럼 실제로 읽히는 번역된 콘텐츠가 필요합니다.

번역을 최신 상태로 유지하는 프로세스. 원본 콘텐츠가 변경되면 번역된 콘텐츠도 변경되어야 합니다. 이것은 대부분의 팀이 과소평가하는 부분입니다. 주당 여러 번 업데이트를 게시하고 네 개의 로케일과 50개의 콘텐츠 유형이 있는 Contentful 공간을 운영하는 콘텐츠 팀은 수동으로 처리할 경우 상당한 지속적인 번역 작업량을 감당해야 합니다.

번역이 오래되었는지 알 수 있는 방법. Contentful의 로케일 시스템은 오래됨을 표시하지 않습니다. 항목의 영어 복사본을 업데이트하고 프랑스어 복사본 업데이트를 잊으면 프랑스어 버전은 조용히 이전 콘텐츠를 계속 제공합니다. 이를 감지할 수 있는 프로세스나 도구가 필요합니다.


PolyLingo의 위치

PolyLingo의 API는 구조화된 콘텐츠를 구조를 유지하면서 번역합니다. Contentful에서 관련 형식은 JSON입니다 — Contentful 항목의 필드 값들을 추출하여 API에 보내면 동일한 구조를 유지한 채 번역된 결과가 반환됩니다.

Node.js에서의 기본 흐름:

import PolyLingo from 'polylingo'
import { createClient } from 'contentful-management'

const poly = new PolyLingo({ apiKey: process.env.POLYLINGO_API_KEY })
const contentful = createClient({
  accessToken: process.env.CONTENTFUL_MANAGEMENT_TOKEN,
})

const space = await contentful.getSpace(process.env.CONTENTFUL_SPACE_ID)
const environment = await space.getEnvironment('master')

// 번역할 항목을 가져옵니다
const entry = await environment.getEntry('your-entry-id')

// PolyLingo는 지역 없이 BCP-47 코드를 사용합니다 (fr, de).
// Contentful 로케일 ID가 다르면 매핑하세요 (예: fr-FR, de-DE).
const localeMap = { fr: 'fr', de: 'de' } // 공간의 로케일 ID에 맞게 조정

// 영어 문자열 필드만 추출 — 참조, 링크, 불리언, 숫자는 건너뜁니다
const sourceFields = Object.fromEntries(
  Object.entries(entry.fields)
    .filter(([, value]) => typeof value['en-US'] === 'string')
    .map(([key, value]) => [key, value['en-US']])
)

// 프랑스어와 독일어로 한 번에 번역 요청
const result = await poly.translate({
  content: JSON.stringify(sourceFields),
  format: 'json',
  targets: Object.keys(localeMap),
})

// 번역된 값을 Contentful 로케일 ID를 사용해 항목에 다시 씁니다
for (const [polyLocale, translated] of Object.entries(result.translations)) {
  const contentfulLocale = localeMap[polyLocale]
  const parsed = JSON.parse(translated)
  for (const [field, value] of Object.entries(parsed)) {
    if (!entry.fields[field]) entry.fields[field] = {}
    entry.fields[field][contentfulLocale] = value
  }
}

await entry.update()
await entry.publish()

console.log('항목이 번역되어 게시되었습니다.')

한 번의 API 호출로 프랑스어와 독일어가 모두 반환됩니다. 항목은 같은 스크립트에서 업데이트되고 게시됩니다.

로케일 ID에 대한 참고: PolyLingo는 지역 접미사 없는 BCP-47 코드를 사용합니다 (fr, de). Contentful 공간은 종종 fr-FR 또는 de-DE 같은 지역이 포함된 ID를 사용합니다. 위의 localeMap 객체에서 이를 맞추세요 — Contentful 공간에서 사용하는 로케일 ID에 맞게 업데이트하세요.

같은 패턴이 Python에서도 작동합니다:

import os, json, requests
from contentful_management import Client

poly_key = os.environ['POLYLINGO_API_KEY']
contentful = Client(os.environ['CONTENTFUL_MANAGEMENT_TOKEN'])

space = contentful.spaces().find(os.environ['CONTENTFUL_SPACE_ID'])
environment = space.environments().find('master')
entry = environment.entries().find('your-entry-id')

# 영어 필드 값 추출
source_fields = {
    key: value.get('en-US')
    for key, value in entry.fields().items()
    if value.get('en-US')
}

# 프랑스어와 독일어로 번역
r = requests.post(
    'https://api.usepolylingo.com/v1/translate',
    headers={'Authorization': f"Bearer {poly_key}"},
    json={
        'content': json.dumps(source_fields),
        'format': 'json',
        'targets': ['fr', 'de'],
    },
    timeout=120,
)
r.raise_for_status()
translations = r.json()['translations']

# 항목에 다시 씀
for locale, translated in translations.items():
    parsed = json.loads(translated)
    for field, value in parsed.items():
        if field in entry.fields():
            entry.fields()[field][locale] = value

entry.save()
entry.publish()
print('항목이 번역되어 게시되었습니다.')

대규모 번역

위의 예제는 단일 항목을 처리합니다. 여러 콘텐츠 유형에 걸쳐 많은 항목이 있는 Contentful 공간의 경우, 배치 엔드포인트는 요청당 최대 100개의 항목을 처리합니다:

// 여러 항목을 가져와 한 번의 배치 호출로 번역
const entries = await environment.getEntries({
  content_type: 'blogPost',
  limit: 50,
})
 
// PolyLingo 로케일 코드를 Contentful 로케일 ID에 매핑
const localeMap = { fr: 'fr', de: 'de', es: 'es' } // 공간에 맞게 조정하세요
 
const items = entries.items.map(entry => ({
  id: entry.sys.id,
  content: JSON.stringify(
    Object.fromEntries(
      Object.entries(entry.fields)
        .filter(([, value]) => typeof value['en-US'] === 'string')
        .map(([k, v]) => [k, v['en-US']])
    )
  ),
  format: 'json',
}))
 
const batch = await poly.batch({
  items,
  targets: Object.keys(localeMap),
})
 
for (const result of batch.results) {
  const entry = entries.items.find(e => e.sys.id === result.id)
  for (const [polyLocale, translated] of Object.entries(result.translations)) {
    const contentfulLocale = localeMap[polyLocale]
    const parsed = JSON.parse(translated)
    for (const [field, value] of Object.entries(parsed)) {
      if (!entry.fields[field]) entry.fields[field] = {}
      entry.fields[field][contentfulLocale] = value
    }
  }
  await entry.update()
  await entry.publish()
}

50개의 항목, 3개 언어, 1회 배치 요청.


번역을 최신 상태로 유지하기

위 스크립트는 필요할 때 실행하거나 게시 워크플로에 연결할 수 있습니다. 일반적인 패턴은 소스 콘텐츠가 변경될 때 트리거되는 CI 작업의 일부로 번역을 실행하거나, 지난 24시간 내에 업데이트된 항목을 포착하는 일정에 따라 실행하는 것입니다.

Contentful은 웹후크도 지원합니다 — 기본 로케일에서 항목이 게시될 때 작동하는 웹후크를 구성할 수 있으며, 이는 자동으로 번역 작업을 트리거할 수 있습니다. PolyLingo를 해당 웹후크에 연결하면 편집자가 업데이트된 영어 콘텐츠를 게시할 때마다 번역된 로케일이 수동 단계 없이 업데이트됩니다. 이는 전용 PolyLingo 통합을 위한 로드맵에 포함되어 있으며, 그동안 API는 해당 흐름을 직접 구축하는 데 필요한 모든 것을 제공합니다.


필드 유형에 대한 참고 사항

위의 접근 방식은 짧은 텍스트, 긴 텍스트 및 문자열로 저장된 리치 텍스트에 잘 작동합니다. Contentful의 몇 가지 필드 유형은 별도로 처리해야 합니다:

리치 텍스트 필드는 Contentful의 문서 형식(일반 문자열이 아님)으로 저장되므로 PolyLingo에 보내기 전에 일반 텍스트 또는 HTML로 직렬화한 후 다시 역직렬화해야 합니다. @contentful/rich-text-html-renderer 패키지가 직렬화 단계를 처리하며, PolyLingo 측의 format: "html"이 마크업을 올바르게 보존합니다.

참조 필드(다른 항목 또는 자산에 대한 링크)는 번역해서는 안 됩니다 — 이들은 텍스트가 아닌 ID입니다. API에 보내는 필드에서 제외하세요.

숫자, 불리언 및 날짜 필드는 본질적으로 번역할 수 없습니다. 소스 객체를 만들기 전에 필터링하세요.


시작하기

PolyLingo의 무료 요금제에는 월 50,000 토큰이 포함되어 있습니다. 적당한 양의 콘텐츠가 있는 Contentful 공간의 경우, 여러 로케일에 걸친 여러 항목에 대한 초기 번역 작업을 포함하며 지속적인 업데이트를 위한 여유 공간도 있습니다.

전체 API 문서는 usepolylingo.com/docs에서 확인할 수 있습니다. SDK 패키지는 Node.js, Python, Ruby, PHP, Java, Go용으로 제공됩니다.

npm install polylingo

API 키 받기