블로그

2026년 8월 15일 · 10분 읽기

App Store Connect 다국어 리뷰 응답: 언어 불일치를 호출 전에 막기

App Store Connect API로 16개 언어 리뷰에 답하면서 언어 불일치를 외부 호출 전에 막았습니다. 한 나라가 두 언어에 속하고 간체와 번체가 점수로 갈리지 않는 문제를 지납니다.

  • App Store Connect
  • 다국어
  • AI 에이전트
  • MCP

2026년 8월 23일 업데이트

같은 자리에 프랑스어 초안을 넣으면 confidence: 0.733, match: true가 나옵니다. 한국어 본문을 넣으면 confidence: 0.929, match: false, detected: ko가 나옵니다.

두 번째 경우에 답변이 등록되면 고객은 답변을 받았는데 읽을 수 없습니다. 그리고 App Store Connect에 이미 흔적이 남았습니다. 지우려면 다시 호출해야 합니다.

답변 언어가 리뷰 국가와 다를 때 막히는 지점

한눈에 보기

문제 같은 자리에 프랑스어 초안을 넣으면 `confidence: 0.
결정 같은 파일에서 페이지네이션도 고쳤습니다.
결과 IDN이 fallback이라는 사실도 응답에 남겼습니다.
제약 ASC 응답이 한 페이지에 다 담기지 않았습니다.

ASC territory를 사람용 alpha-2에서 요청용 alpha-3로 정규화했습니다

국가 코드에는 두 표기가 함께 들어옵니다. 개발자는 FR, KR 같은 ISO alpha-2를 익숙하게 쓰지만, ASC의 filter[territory]에는 ISO alpha-3가 들어갑니다. FRA, KOR처럼 세 글자로 정규화하지 않으면 조회 기준과 로케일 결정 기준이 달라질 수 있습니다.

locale.ts는 alpha-2에서 alpha-3로 가는 ALPHA3_BY_ALPHA2 표를 만들고, 그 항목을 뒤집은 ALPHA2_BY_ALPHA3 표도 만듭니다. territoryMap에는 원래 territory와 alpha-3를 모두 넣습니다.

// care-mcp/src/locale.ts
ALPHA3_BY_ALPHA2      // FR → FRA
ALPHA2_BY_ALPHA3      // FRA → FR (앞의 표를 뒤집는다)
territoryMap          // 원래 territory와 alpha-3를 둘 다 키로 넣는다

FRFRA가 같은 fr-FR을 가리킵니다. 두 표기를 다 받되 ASC에 보내는 필터만 alpha-3로 통일합니다.

테스트에서 territory: 'KR' 요청이 URL의 filter%5Bterritory%5D=KOR로 바뀌는지 확인했습니다. KOR, TWN, BRA 같은 alpha-3 입력도 같은 함수가 받습니다.

입력 ASC 필터 로케일 예
FR FRA fr-FR
KR KOR ko
DE DEU de-DE
BRA BRA pt-BR
매핑되지 않은 IDN 로케일 fallback에 사용 en-US, fallback: true

하나의 언어가 여러 나라를 가지므로 마지막 보정 맵을 별도로 두었습니다

지원 로케일이 열여섯입니다. ko, ja, zh-Hans, zh-Hant, en-US, de-DE, fr-FR, es-ES, it, nl-NL, pt-BR, tr, vi, da, no, sv입니다. 그 열여섯이 territory 44개를 나눠 갖습니다.

초기 entries는 로케일마다 출시 territory 배열을 가집니다. 영어는 8개국, 스페인어는 7개 territory이며 그 안에 US도 포함됩니다. 독일어와 프랑스어도 스위스와 룩셈부르크처럼 여러 나라를 공유합니다.

로케일 territories 개수
en-US US, GB, AU, CA, IE, NZ, IN, ZA 8
de-DE DE, AT, CH, LI, LU 5
fr-FR FR, BE, MC, CH, LU 5
es-ES ES, MX, AR, CL, CO, PE, US 7
nl-NL NL, BE 2
pt-BR BR, PT 2

스위스 CHde-DEfr-FR에 모두 있고, 벨기에 BEfr-FRnl-NL에 모두 있습니다. 하나의 Map에 같은 키를 순서대로 넣으면 마지막 값이 남습니다. 이 충돌을 숨기지 않고 마지막 보정 맵으로 드러냈습니다.

보정 맵은 모든 나라의 언어를 판정하는 규칙이 아닙니다. 배열에서 생긴 중복을 마지막 쓰기로 정리하는 것입니다. 항목이 열한 개입니다.

// care-mcp/src/locale.ts:36-37
{ US: 'en-US', GB: 'en-US', DE: 'de-DE', AT: 'de-DE', CH: 'de-DE',
  FR: 'fr-FR', BE: 'fr-FR', ES: 'es-ES', MX: 'es-ES', AR: 'es-ES', PT: 'pt-BR' }
// 같은 표를 alpha-3 키로 한 번 더 넣는다: USA, GBR, DEU, AUT, CHE, FRA, BEL, ESP, MEX, ARG, PRT

같은 표를 두 번 넣습니다. 한 번은 alpha-2 키로, 한 번은 alpha-3 키로입니다. CHCHE가 둘 다 de-DE를 가리켜야 하고, 앞의 순회에서 alpha-3 키도 함께 넣었으니 보정도 양쪽에 해야 합니다.

열한 개 중 실제로 충돌을 푸는 것은 CH, BE, PT, US 넷입니다. 나머지 일곱은 이미 그 값이던 것을 다시 씁니다. DEde-DE 배열에만 있으니 보정이 없어도 de-DE입니다. 필요한 넷만 적는 대신 관련된 열한 개를 다 적어 두었습니다. 어느 것이 충돌 해소이고 어느 것이 확인용인지 코드만 봐서는 갈리지 않습니다.

스위스에 독일어를 쓰기로 한 것이 언어학적 판단은 아닙니다. 하나를 골라야 해서 골랐고, 어느 쪽을 골랐는지 코드에 남겼습니다. 프랑스어권 스위스 고객이 독일어 답변을 받는 것이 이 선택의 대가입니다.

언어 판별은 유니코드 특징과 점수를 직접 조합했습니다

localeCheck는 번역 품질을 평가하는 모델이 아닙니다. 본문이 기대한 로케일의 특징을 갖는지 빠르게 확인하는 검사입니다. 빈 본문이나 지원하지 않는 로케일은 처음부터 match: falseconfidence: 0으로 반환합니다.

점수는 네 가지로 쌓습니다.

신호 점수
한글 [가-힣] ko에 10
가나 [ぁ-ゖァ-ヺ] ja에 10
한자 범위 zh-Hans와 zh-Hant에 각각 2
간체 전용 문자 这来个为么说与应请谢汉 zh-Hans에 5
번체 전용 문자 這來個為麼說與應請謝漢 zh-Hant에 5
언어별 특징 낱말 그 언어에 2
다이어크리틱 그 언어에 3

한글과 가나는 다른 언어와 문자를 공유하지 않으니 10점을 줍니다. 한자는 공유하므로 범위 점수를 낮게 주고, 간체와 번체를 가르는 글자를 따로 봅니다.

다이어크리틱은 de-DEäöüß, fr-FRàâçéè…, trçğıİöşü처럼 언어마다 다릅니다. 낱말보다 짧은 본문에서도 잡히는 신호입니다.

// care-mcp/src/locale.ts:101-112 원문
  const sorted = [...scores.entries()].sort((a, b) => b[1] - a[1]);
  const [detected, top] = sorted[0]; const second = sorted[1]?.[1] ?? 0;
  const confidence = top <= 0 ? 0 : Math.min(1, Math.max(0, (top - second + 1) / (top + 2)));
  const match = detected === expect && top > 0 && (top - second >= 1 || ['zh-Hans', 'zh-Hant'].includes(expect));
  return { match, detected: top > 0 ? detected : null, confidence: Number(confidence.toFixed(3)), reason: match ? `${detected} 특징이 확인되었습니다.` : `감지된 언어(${detected ?? '없음'})가 요청 로케일과 다릅니다.` };
}

confidence 산식은 (top - second + 1) / (top + 2)입니다. 1등과 2등의 점수 차이가 크면 올라가고, 붙어 있으면 내려갑니다.

간체와 번체는 공통 한자 범위에서 같은 점수를 먼저 받습니다. 그래서 top과 second가 붙기 쉽습니다. 그 둘을 기대할 때만 점수 차이 1 이상 조건을 면제합니다.

이 판별이 틀리는 방향은 거절입니다

같은 함수를 그대로 옮겨 문장 몇 개를 넣어 봤습니다.

본문 기대 로케일 top·second confidence match
안녕하세요. 도와드리겠습니다. fr-FR 12·0 0.929 false
Merci pour votre message. fr-FR 4·0 0.833 true
Hallo, dank voor het bericht. nl-NL 6·0 0.875 true
Tak for din besked. Vi ser på det. da 9·7 0.273 true
Vi ser på det og sender en opdatering. da 11·11 0.077 false
Thanks. en-US 0·0 0 false

한국어 본문에 fr-FR을 기대하면 12점 대 0점으로 계산되어 거절됩니다.

아래 세 줄이 이 방식의 한계입니다.

덴마크어와 노르웨이어는 낱말 목록 일곱 개 중 여섯 개가 같습니다. og, det, en, med, for, ikke가 겹치고 taktakk만 다릅니다. 다이어크리틱도 둘 다 [æøå]입니다. 그래서 tak이 들어간 덴마크어 문장은 9점 대 7점으로 겨우 갈리고, 그 낱말이 없으면 11점 대 11점으로 동점이 됩니다.

동점이면 top - second >= 1이 깨져서 match가 false입니다. 올바른 덴마크어 답변이 거절됩니다. 마지막 줄도 같은 종류입니다. Thanks.는 목록의 thank와 낱말 경계가 맞지 않아 0점이고, 영어 답변이 영어로 인정되지 않습니다.

틀리는 방향이 한쪽입니다. 이 검사는 엉뚱한 언어를 통과시키기보다 맞는 언어를 막습니다. 막히면 사람이 다시 시도하고, 통과하면 고객의 화면에 남습니다. 되돌리는 비용이 다르니 이쪽으로 틀리는 편이 낫습니다.

덴마크어와 노르웨이어 답변이 실제로 몇 번 막히는지는 리뷰가 0건이라 알 수 없습니다. 지금 아는 것은 그 두 언어의 여유가 2점이라는 것까지입니다.

명시 locale이 있으면 리뷰 조회보다 먼저 불일치를 거절합니다

검사 함수보다 ASC 호출 순서가 언어 경계를 만듭니다. 입력에 locale: 'fr-FR'가 들어왔는데 본문이 한국어라면 리뷰 GET조차 하지 않아야 합니다.

// care-mcp/src/asc.ts — reply()의 앞부분
if (![...body].length || [...body].length > 5970) return err('BAD_INPUT', ...);
const explicitLocale = stringValue(input.locale);
if (explicitLocale !== undefined) {
  const preCheck = localeCheck(body, explicitLocale);        // 리뷰를 조회하기 전에
  if (!preCheck.match) return err('LOCALE_MISMATCH', ...);   // 여기서 끝난다
}
const dryRun = input.dry_run === true || !this.context.config.writeEnabled;
if (dryRun) return ok({ dry_run: true, ... });               // 쓰기가 닫혀 있으면 여기까지
const approval = await verifyApproval(...);                  // 승인 확인
const review = await this.getReview(reviewId);               // 이제야 GET
const locale = explicitLocale ?? localeForTerritory(review.territory).locale;
if (!localeCheck(body, locale).match) return err('LOCALE_MISMATCH', ...);  // 한 번 더

순서가 이 함수의 내용입니다. 입력 검사, 명시 locale 검사, dry-run, 승인, 조회, 재검사, 그다음이 POST나 PATCH입니다.

명시 locale이 없으면 territory를 얻으려고 리뷰를 먼저 조회합니다. 이 경우 GET 뒤에 locale을 정하고, ASC에 쓰기 직전에 다시 검사합니다.

답변을 보낸 뒤에는 care.review_reply에 기록합니다. ON CONFLICT(review_id) DO UPDATE이므로 같은 리뷰에 다시 답하면 행이 늘지 않고 갱신됩니다. 기록이 실패해도 답변은 유효하므로 logged: false를 응답에 담고 넘어갑니다.

답변 본문 한도는 5970자입니다. ASC가 받는 상한입니다.

입력 실행 순서 결과
locale=fr-FR, 한국어 body localeCheck → GET 없음 LOCALE_MISMATCH
locale 없음, territory FRA GET → fr-FR 결정 → 재검사 불일치면 ASC 호출 전 거절
locale과 본문 일치 승인 확인 → ASC 요청 성공 시 care.review_reply 기록
IDN territory en-US fallback fallback 여부 반환

테스트는 “호출하지 않았음”을 빈 배열로 고정합니다

언어 오류를 반환했다는 사실만 테스트하면 내부에서 GET이 먼저 실행되었는지 알 수 없습니다. 요청 메서드를 모은 methods 배열로 외부 호출이 없었음을 고정합니다.

// care-mcp/tests/asc.test.ts:18-36 원문
methods = [];
const explicit = await client.reply({ review_id: 'review-1', body: '안녕하세요. 도와드리겠습니다.', locale: 'fr-FR' });
expect(explicit).toMatchObject({ ok: false, error: { code: 'LOCALE_MISMATCH' } });
expect(methods).toEqual([]);

LOCALE_MISMATCH와 빈 배열이 함께 있어야 GET 전 차단이 보입니다.

같은 파일에서 페이지네이션도 고쳤습니다. links.next를 따라가며 합치고, filter[answered] 대신 exists[publishedResponse]를 씁니다. 합치지 않았을 때 로케일이 16개가 아니라 10개로 보였고 평점이 실제와 달랐습니다. filter[answered]는 400을 돌려줍니다.

인증이 전부 401이던 이유는 서명 32바이트였습니다

언어 이야기 앞에 인증이 있었습니다. 처음에는 모든 ASC 호출이 401이었습니다.

ASC는 ES256으로 서명한 JWT를 받습니다. Node의 sign은 DER 형식으로 서명을 돌려주고, JWT는 JOSE 형식을 요구합니다. 둘의 차이가 길이입니다.

// care-mcp/src/asc.ts:23-32
// DER 정수는 최상위 비트가 1이면 선행 0x00 이 붙어 33바이트가 되고, 값이 작으면 32바이트보다
// 짧아진다. 따라서 선행 0을 벗기고 32바이트 슬롯에 오른쪽 정렬해야 한다.
const toFixedWidth = (value: Buffer): Buffer => {
  let start = 0;
  while (start < value.length - 1 && value[start] === 0) start += 1;
  const trimmed = value.subarray(start);
  if (trimmed.length > 32) throw new Error('ES256 서명 정수가 32바이트를 초과합니다.');
  const slot = Buffer.alloc(32);
  trimmed.copy(slot, 32 - trimmed.length);   // 오른쪽 정렬
  return slot;
};

DER은 정수 두 개를 길이와 함께 담습니다. 그 정수의 최상위 비트가 1이면 부호를 구분하려고 앞에 0x00을 붙여 33바이트가 됩니다. 값이 작으면 31바이트일 수도 있습니다. JOSE는 각 정수를 정확히 32바이트로 요구합니다.

길이가 서명마다 달라지니 증상이 헷갈립니다. 키를 잘못 넣은 것도 아니고 kid가 틀린 것도 아닙니다. 서명 값의 바이트 수가 맞지 않아 검증에서 떨어집니다. 그리고 키는 매번 새 서명을 만들므로 어떤 서명은 우연히 64바이트가 되어 통과할 수도 있습니다. signatureDer.length === 64인 경우를 그대로 쓰는 분기가 그것입니다.

오른쪽 정렬이 이 함수의 요점입니다. 왼쪽에 붙이면 값이 256배씩 커집니다. 짧은 정수는 앞을 0으로 채워야 같은 수입니다.

로케일이 16개가 아니라 10개로 보였던 것도 같은 종류입니다

asc_version_locales가 10개를 돌려줬습니다. 출시 로케일은 16개입니다.

ASC 응답이 한 페이지에 다 담기지 않았습니다. links.next를 따라가지 않으면 첫 페이지만 봅니다. 평점 요약도 territory가 잘려서 실제와 다른 값이 나왔습니다.

// care-mcp/src/asc.ts:82 — 페이지를 이어 받는다
const links = inputObject(payload.links);
const candidate = links && typeof links.next === 'string' ? links.next : undefined;

숫자가 작게 나오는 버그는 눈에 잘 안 띕니다. 10개도 그럴듯한 숫자입니다. 16이라는 다른 근거가 있어서 잡혔습니다.

같은 파일에서 필터도 고쳤습니다. filter[answered]는 400을 돌려줍니다. ASC가 받는 것은 exists[publishedResponse]입니다. 이름이 비슷한 파라미터를 추측해서 쓰면 400이 오고, 400은 인증 오류처럼 보이지 않아 다행히 빨리 갈렸습니다.

본문 상한도 코드에 박아 뒀습니다. 5,970자입니다. 입력 스키마의 max(5970)과 함수 앞의 검사에 두 번 있습니다. 스키마를 통과해도 호출 경로에서 다시 셉니다.

언어와 호출과 원장을 각각 확인했습니다

라이브에서 확인한 것을 나열하면 이렇습니다.

확인 결과
출시 로케일 수 16
FRA fr-FR
JPN ja
DEU de-DE
BRA pt-BR
IDN en-US, fallback: true
asc_review_reply(locale=fr-FR) + 한국어 본문 LOCALE_MISMATCH, ASC 호출 0회
성공 경로 care.review_reply에 기록

IDN이 fallback이라는 사실도 응답에 남겼습니다. 인도네시아 고객에게 영어로 답한 것과 인도네시아어로 답한 것은 다른 사실입니다. fallback 여부를 숨기면 나중에 로케일을 추가할 근거가 사라집니다.