Skip to main content

국제화

Shopify는 175개 이상의 국가에서 커머스를 지원하며 20개 이상의 언어를 네이티브로 지원합니다. 앱 개발자로서 국제화(i18n)는 있으면 좋은 것이 아닙니다 -- 글로벌 판매자 기반에 도달하기 위해 필수적입니다. 이 모듈에서는 Shopify Markets 구성, Translation API, 다중 통화 가격 책정, 세금 및 관세 계산, 로케일 인식 테마 확장 기능 구축을 다룹니다.

Shopify Markets 설정

Shopify Markets는 국제 판매를 관리하기 위한 중앙 집중식 시스템입니다. 앱이 국제 판매를 하는 판매자에게 올바르게 작동하려면 Markets를 인식해야 합니다.

Markets 아키텍처 이해

하나의 Shopify 스토어에는 여러 Markets가 있을 수 있으며, 각각 지리적 지역을 나타내며 고유한 설정을 갖습니다:

  • 통화 -- 가격 표시에 사용되는 통화
  • 언어 -- 스토어프론트 언어
  • 도메인 -- 해당 지역의 서브도메인 또는 최상위 도메인
  • 가격 -- 조정된 가격 (백분율 또는 고정 조정)
  • 관세 및 세금 -- 결제 시 관세를 징수하는지 여부

GraphQL을 통한 Markets 조회

query GetMarkets {
markets(first: 20) {
edges {
node {
id
name
enabled
primary
regions(first: 50) {
edges {
node {
... on MarketRegionCountry {
code
name
currency {
currencyCode
currencyName
}
}
}
}
}
webPresence {
domain {
host
}
defaultLocale
alternateLocales
rootUrls {
locale
url
}
}
}
}
}
}

Markets 인식 앱 로직

앱이 주문을 처리하거나 상품 정보를 표시할 때는 항상 마켓 컨텍스트를 고려하세요:

// services/markets-service.js
export async function getMarketForRequest(client, request) {
const countryCode = request.headers['x-shopify-country'] || 'US';
const locale = request.headers['accept-language']?.split(',')[0] || 'en';

const response = await client.query({
data: {
query: `query MarketByGeography($countryCode: CountryCode!) {
marketByGeography(countryCode: $countryCode) {
id
name
currencySettings {
baseCurrency { currencyCode }
localCurrencies
}
}
}`,
variables: { countryCode },
},
});

return response.body.data.marketByGeography;
}
tip

Storefront API 쿼리를 할 때는 항상 @inContext 디렉티브를 사용하여 가격, 가용성, 번역이 올바른 마켓 컨텍스트를 반영하도록 하세요. 이 디렉티브가 없으면 API는 기본 마켓의 데이터만 반환합니다.

query ProductInContext @inContext(country: CA, language: FR) {
product(handle: "winter-jacket") {
title
description
priceRange {
minVariantPrice {
amount
currencyCode
}
}
}
}

Translation API

Shopify의 Translation API(TranslatableResource GraphQL 타입을 통해)를 사용하면 스토어의 모든 번역 가능한 리소스에 대한 번역을 읽고 쓸 수 있습니다.

번역 읽기

// services/translation-service.js
export async function getProductTranslations(client, productId, locale) {
const response = await client.query({
data: {
query: `query GetTranslations($resourceId: ID!, $locale: String!) {
translatableResource(resourceId: $resourceId) {
resourceId
translatableContent {
key
value
digest
locale
}
translations(locale: $locale) {
key
value
outdated
}
}
}`,
variables: {
resourceId: productId,
locale,
},
},
});

return response.body.data.translatableResource;
}

번역 작성

export async function createTranslation(client, resourceId, translations) {
const response = await client.query({
data: {
query: `mutation CreateTranslation($id: ID!, $translations: [TranslationInput!]!) {
translationsRegister(resourceId: $id, translations: $translations) {
userErrors {
field
message
}
translations {
key
value
locale
}
}
}`,
variables: {
id: resourceId,
translations: translations.map((t) => ({
key: t.key,
value: t.value,
locale: t.locale,
translatableContentDigest: t.digest,
})),
},
},
});

const result = response.body.data.translationsRegister;

if (result.userErrors.length > 0) {
throw new Error(
`Translation errors: ${result.userErrors.map((e) => e.message).join(', ')}`
);
}

return result.translations;
}

대량 번역 워크플로우

번역 서비스를 제공하는 앱의 경우, 효율성을 위해 벌크 작업을 사용하세요:

// services/bulk-translator.js
export async function bulkTranslateProducts(client, locale, translations) {
// Group translations by resource to minimize API calls
const byResource = translations.reduce((acc, t) => {
if (!acc[t.resourceId]) acc[t.resourceId] = [];
acc[t.resourceId].push(t);
return acc;
}, {});

const results = [];

// Process in batches of 10 to stay within rate limits
const resourceIds = Object.keys(byResource);
for (let i = 0; i < resourceIds.length; i += 10) {
const batch = resourceIds.slice(i, i + 10);

const batchResults = await Promise.all(
batch.map((resourceId) =>
createTranslation(client, resourceId, byResource[resourceId])
)
);

results.push(...batchResults);

// Respect rate limits
if (i + 10 < resourceIds.length) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
}

return results;
}
warning

API를 통해 등록된 번역은 판매자가 Shopify 관리자에서 수동으로 편집하면 덮어쓰여집니다. 앱은 기존 번역의 outdated 필드를 확인하고 판매자의 변경 사항을 덮어쓰기 전에 사용자에게 경고해야 합니다.

다중 통화 지원

여러 통화를 올바르게 처리하는 것은 매우 중요합니다. Shopify는 Storefront API를 통해 표시 가격을, Markets를 통해 관리자 가격 규칙을 제공합니다.

올바른 가격 포맷팅

// utils/currency-formatter.js
export function formatPrice(amount, currencyCode, locale = 'en-US') {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}

// Examples:
// formatPrice(29.99, 'USD', 'en-US') → "$29.99"
// formatPrice(29.99, 'EUR', 'de-DE') → "29,99 €"
// formatPrice(3299, 'JPY', 'ja-JP') → "¥3,299"
info

JPY(일본 엔)와 KRW(한국 원)과 같은 일부 통화는 소수점을 사용하지 않습니다. 통화 서식 규칙을 하드코딩하지 말고 항상 Intl.NumberFormat을 사용하세요. Shopify의 @shopify/react-i18n 패키지는 관리자 임베디드 앱에 대해 이를 자동으로 처리합니다.

앱에서의 시장별 가격 책정

// services/pricing-service.js
export async function getContextualPricing(client, productId, market) {
const response = await client.query({
data: {
query: `query ContextualPricing($productId: ID!, $marketId: ID!) {
product(id: $productId) {
variants(first: 100) {
edges {
node {
id
title
contextualPricing(context: { marketId: $marketId }) {
price {
amount
currencyCode
}
compareAtPrice {
amount
currencyCode
}
}
}
}
}
}
}`,
variables: { productId, marketId: market.id },
},
});

return response.body.data.product.variants.edges.map((edge) => ({
variantId: edge.node.id,
title: edge.node.title,
price: edge.node.contextualPricing.price,
compareAtPrice: edge.node.contextualPricing.compareAtPrice,
}));
}

세금 및 관세 계산

국제 판매에는 정확한 세금 및 관세 계산이 필요합니다. Shopify는 관세 및 수입세 기능을 통해 이를 처리하지만, 앱은 이것이 주문 합계와 상품 가격에 어떤 영향을 미치는지 알아야 합니다.

관세 정보 조회

query OrderDuties($orderId: ID!) {
order(id: $orderId) {
totalDutiesSet {
shopMoney {
amount
currencyCode
}
presentmentMoney {
amount
currencyCode
}
}
lineItems(first: 50) {
edges {
node {
title
duties {
id
price {
shopMoney { amount currencyCode }
presentmentMoney { amount currencyCode }
}
harmonizedSystemCode
countryCodeOfOrigin
}
}
}
}
}
}

HS Code 관리

앱이 상품을 관리하는 경우, 정확한 관세 계산을 위해 판매자가 HS(통일 상품 분류 제도) 코드를 설정하도록 도와주세요:

export async function updateHSCodes(client, variantId, hsCode, countryOfOrigin) {
const response = await client.query({
data: {
query: `mutation UpdateHSCode($input: InventoryItemUpdateInput!) {
inventoryItemUpdate(id: $input.id, input: $input) {
inventoryItem {
harmonizedSystemCode
countryCodeOfOrigin
}
userErrors {
field
message
}
}
}`,
variables: {
input: {
harmonizedSystemCode: hsCode,
countryCodeOfOrigin: countryOfOrigin,
},
},
},
});

return response.body.data.inventoryItemUpdate;
}
tip

판매자가 Markets 설정에서 "관세 및 수입세"를 활성화하면 Shopify가 결제 시 자동으로 관세를 계산합니다. 앱이 수동으로 관세를 계산할 필요는 없지만, 주문 합계를 표시하거나 보고서를 생성할 때 관세 금액을 반영해야 합니다.

로케일 인식 Liquid 템플릿

앱에 테마 앱 확장 기능이 포함되어 있거나 Liquid 스니펫을 주입하는 경우, 스토어에 구성된 언어를 지원해야 합니다.

Shopify의 번역 필터 사용

{% comment %}
Theme app extension: Product recommendation widget
This template automatically uses the store's active locale.
{% endcomment %}

<div class="app-recommendations" data-section-id="{{ section.id }}">
<h2>{{ 'apps.recommendations.heading' | t }}</h2>

{% for product in recommendations %}
<div class="recommendation-card">
<img
src="{{ product.featured_image | image_url: width: 300 }}"
alt="{{ product.featured_image.alt | escape }}"
loading="lazy"
width="300"
height="300"
/>
<h3>{{ product.title }}</h3>
<p class="price">{{ product.price | money_with_currency }}</p>
<a href="{{ product.url }}" class="btn">
{{ 'apps.recommendations.view_product' | t }}
</a>
</div>
{% endfor %}

{% if recommendations.size == 0 %}
<p>{{ 'apps.recommendations.empty' | t }}</p>
{% endif %}
</div>

번역 파일 제공

테마 앱 확장 기능에 로케일 파일을 포함하세요:

// extensions/theme-block/locales/en.default.json
{
"apps": {
"recommendations": {
"heading": "You might also like",
"view_product": "View product",
"empty": "No recommendations available",
"add_to_cart": "Add to cart",
"out_of_stock": "Out of stock"
}
}
}
// extensions/theme-block/locales/fr.json
{
"apps": {
"recommendations": {
"heading": "Vous aimerez peut-etre aussi",
"view_product": "Voir le produit",
"empty": "Aucune recommandation disponible",
"add_to_cart": "Ajouter au panier",
"out_of_stock": "Rupture de stock"
}
}
}
// extensions/theme-block/locales/de.json
{
"apps": {
"recommendations": {
"heading": "Das konnte Ihnen auch gefallen",
"view_product": "Produkt ansehen",
"empty": "Keine Empfehlungen verfugbar",
"add_to_cart": "In den Warenkorb",
"out_of_stock": "Nicht auf Lager"
}
}
}

RTL 언어 지원

아랍어와 히브리어와 같은 언어의 경우, 테마 확장 기능이 오른쪽에서 왼쪽(RTL) 레이아웃을 지원하도록 하세요:

{% assign is_rtl = false %}
{% if request.locale.iso_code == 'ar' or request.locale.iso_code == 'he' %}
{% assign is_rtl = true %}
{% endif %}

<div class="app-widget" dir="{{ is_rtl | if: 'rtl', 'ltr' }}">
{% comment %} Widget content {% endcomment %}
</div>
/* Use logical properties for automatic RTL support */
.recommendation-card {
margin-inline-start: 1rem;
padding-inline-end: 0.5rem;
text-align: start;
border-inline-start: 3px solid var(--color-primary);
}
warning

테마 확장 기능에서는 항상 CSS 논리적 속성(margin-left 대신 margin-inline-start, padding-bottom 대신 padding-block-end)을 사용하세요. 이렇게 하면 별도의 스타일시트 없이 LTR과 RTL 언어 모두에서 올바른 레이아웃이 보장됩니다.

국제화 체크리스트

영역요구 사항비고
MarketsQuery market context for all pricingUse @inContext directive
CurrencyUse Intl.NumberFormat for formattingNever hardcode currency symbols
TranslationsProvide locale files for theme extensionsAt minimum: en, fr, de, es, pt, ja
DutiesAccount for duty amounts in order totalsDo not calculate manually
RTLUse CSS logical propertiesTest with Arabic locale
Date/TimeUse Intl.DateTimeFormatRespect merchant timezone
NumbersUse Intl.NumberFormatDecimal separators vary by locale

진정으로 국제화된 Shopify 앱을 구축하려면 모든 사용자 대면 화면에서 세부 사항에 주의를 기울여야 합니다. 이 투자는 큰 보상을 가져다 줍니다 -- 국제 판매를 하는 판매자는 Shopify 에코시스템에서 가장 빠르게 성장하는 세그먼트이며, 그들의 요구를 지원하는 앱은 큰 경쟁 우위를 가집니다.