처음에는 단순한 i18n 작업이라고 생각했다. 한국어로 잘 만들어진 매장 랜딩을 영어, 일본어, 중국어 간체로 번역해서 /en/, /ja/, /zh-Hans/에 붙이면 된다고 봤다. 막상 구현해보니 어려운 부분은 번역 호출이 아니라 그 결과를 실제 public page라고 말할 수 있을 만큼 검증하는 일이었다.
요약
- 문제: AI가 만든 HTML을 다른 언어 URL로 내보낼 때 visible text만 번역하면 public page로는 부족했다.
- 판단: body, metadata, 이미지 alt, JSON-LD, canonical,
hreflang, language switcher를 하나의 public surface로 봤다. - 구현:
landing_localizations를 published landing version에 묶고, HTML 전체가 아니라 번역 가능한 segment만 모델에게 넘겼다. - 검증: locale별로 한글 잔여, 영어 nav/CTA 잔여,
zh-Hans의 번체 혼입을 따로 막았다. - 배운 점: AI 랜딩페이지 현지화에서 진짜 어려운 부분은 "번역"보다 "이 페이지를 공개해도 되는지 판단하는 QA"였다.
이 글은 앞서 쓴 AI 마케팅 문구를 덜 AI처럼 만들기와 이어진다. 그 글이 원문 생성 단계에서 데이터에 근거한 문장을 만들자는 기록이었다면, 이번 글은 그 HTML을 다른 언어의 공개 URL로 내보낼 때 같은 원칙을 어떻게 지켰는지에 가깝다.
Place2Page의 다른 운영 기록은 Place2Page Engineering Notes에 모아두었다. 이 글처럼 문제, 확인한 증거, 바뀐 판단 기준이 분명한 기록을 우선해서 이어갈 생각이다.
확인한 산출물
이번 작업의 결과로 한 매장 랜딩은 아래와 같은 locale별 public URL 구조를 갖게 됐다.
https://example.place2page.com/https://example.place2page.com/en/https://example.place2page.com/ja/https://example.place2page.com/zh-Hans/
여기서 확인한 것은 "검색엔진에 색인됐다"가 아니다. 확인 범위는 public URL이 200으로 응답하고, locale별 html lang, language switcher, hreflang alternate가 붙어 있다는 점이다. 색인과 노출은 검색엔진이 결정하는 영역이라 보장처럼 쓰지 않았다.
번역보다 public surface가 문제였다
처음 생각한 흐름은 단순했다.
한국어 랜딩 HTML
-> 번역 모델 호출
-> 영어/일본어/중국어 페이지 저장
-> /en, /ja, /zh-Hans 로 서빙이 구조는 너무 낙관적이었다. Place2Page의 랜딩은 사람이 직접 작성한 정적 HTML이 아니라 장소 데이터, 사진, 리뷰, 메뉴, 영업 정보, 프롬프트 결과가 합쳐진 AI 생성 HTML이다.
그래서 곧 질문이 늘어났다.
<body>에 보이는 텍스트만 번역하면 되는가<title>과 meta description은 본문과 같은 언어여야 하지 않나- Open Graph, Twitter card, JSON-LD 안의
name,description,address는 어디까지 번역해야 하나 - 이미지
alt는 그대로 두면 되는가 - URL, script, style, tracking snippet은 절대 바뀌면 안 되는데 HTML 전체를 모델에게 맡겨도 괜찮나
/zh-Hans/가 중국어처럼 보이는 것과 간체로 충분히 정리된 것은 같은 말인가
이 질문을 풀기 전까지는 다국어 기능이 아니라 "HTML을 번역 모델에게 통째로 맡기는 기능"에 가까웠다. public page에 그렇게 내보내기에는 불안했다.
URL보다 저장 경계를 먼저 정했다
첫 번째로 정한 것은 URL 모양이 아니라 저장 경계였다. Place2Page는 공개 페이지를 published landing version 기준으로 서비스한다. 그래서 localization도 project 전체에 붙이지 않고 특정 landing version에 붙였다.
project
-> published landing version
-> landing_localizations
-> en
-> ja
-> zh-Hanslanding_localizations row는 project_id, landing_id, locale, rendered_html, metadata_json을 가진다. 여기서 중요한 값은 landing_id였다.
프로젝트가 새 landing version을 publish하면 예전 localization을 자동으로 재사용하지 않는다. 같은 매장이라도 HTML 구조와 문구가 달라졌을 수 있기 때문이다. 오래된 번역을 새 페이지에 붙이는 것보다 해당 locale이 없으면 기본 페이지로 돌리는 쪽이 더 안전했다.
public route도 같은 원칙을 따르게 했다.
Request
-> slug와 locale 검증
-> published project와 active landing 조회
-> 해당 landing_id에 locale row가 있으면 localized HTML 응답
-> 없거나 지원하지 않는 locale이면 default public page로 redirectlocale URL은 단순한 path alias가 아니었다. 특정 published landing version에 실제 localization row가 있고, 그 row가 public response에 필요한 metadata까지 갖췄을 때만 공개 surface가 된다.
HTML 전체를 번역하지 않았다
두 번째 결정은 번역 모델에게 HTML 문서 전체를 맡기지 않는 것이었다.
HTML 전체를 "영어로 번역해줘"라고 넘기면 모델은 그럴듯한 결과를 만든다. 하지만 public page에서는 script가 바뀌거나, URL이 바뀌거나, attribute quote가 깨지거나, JSON-LD가 invalid JSON이 될 수 있다. AI 번역 실패가 copy 품질 문제에서 끝나지 않고 페이지 자체를 깨뜨릴 수 있다.
그래서 HTML을 parsing해서 번역 가능한 segment만 뽑았다.
번역 대상으로 본 것:
- visible text
<title>- description, OG, Twitter metadata
- 이미지
alt,title,aria-label,placeholder - JSON-LD 안의
name,description,headline,caption,address같은 text field
번역하지 않은 것:
- script
- style
- URL
- image
src - structural HTML
- tracking code
- map/provider integration에 필요한 값
모델에게는 segment id, kind, context, 원문 text만 보냈다. 응답도 HTML이 아니라 JSON mapping으로 받았다.
{
"translations": {
"text:0": "A quiet pasta bar near the station",
"attr:3": "Photo of the signature pasta set",
"jsonld:5": "Italian restaurant in Seoul"
}
}모델에게 HTML을 다시 쓰게 하지 않고, 모델이 바꿔도 되는 조각만 명시했다.
Metadata와 normalizer도 page의 일부였다
처음에는 본문 텍스트에 더 신경이 갔다. 그런데 public page에서는 metadata도 페이지다. 사용자는 <title>이나 Open Graph tag를 직접 보지 않지만, 검색 결과, 공유 카드, crawler, 구조화 데이터는 이 값을 본다.
그래서 metadata와 JSON-LD text field도 번역 대상에 포함했다. 단, url, @context, @type 같은 구조 값은 보존했다. 이름, 설명, 주소처럼 자연어인 field만 segment로 뽑았다.
저장된 localized HTML도 그대로 응답하지 않았다. public response 직전에 HTML normalizer를 한 번 더 태웠다.
- canonical URL
hreflangalternate linksx-defaulthtml lang- favicon
- site name metadata
- visible language switcher
- public page responsive guard
예를 들어 default page와 localized page는 canonical이 달라야 한다.
<link rel="canonical" href="https://example.place2page.com/" />
<link rel="canonical" href="https://example.place2page.com/en/" />그리고 locale row가 실제로 존재하는 언어만 alternate로 노출했다. 아직 일본어 row가 없는데 /ja/를 hreflang에 넣으면 crawler에게 없는 페이지를 알려주는 꼴이 된다.
QA는 언어별로 달라야 했다
번역 모델이 성공 응답을 줬다고 localization이 성공한 것은 아니다. 가장 위험한 실패는 "대충 보면 괜찮아 보이는 실패"였다. 몇 군데에 원문이나 다른 언어가 남아 있는데도 전체적으로는 현지화된 것처럼 보이는 경우다.
그래서 locale별 품질 게이트를 나눴다.
en
-> translated segment에 Hangul이 남아 있으면 실패
ja
-> kana가 너무 적으면 실패
-> 일본어/한자가 없는 영어 nav, CTA, hero copy가 남으면 실패
zh-Hans
-> Han script가 너무 적으면 실패
-> 영어 nav, CTA, hero copy가 남으면 실패
-> 간체 페이지에 자주 보이는 번체 문자가 남으면 실패특히 zh-Hans는 "중국어처럼 보이는가"만으로 부족했다. 간체 URL에 번체가 섞이면 사용자는 바로 어색함을 느낀다.
鷺梁津 -> 번체가 섞인 표기
鹭梁津 -> 간체 페이지에서 기대한 표기이 검사는 완벽한 언어 품질 평가가 아니다. 대신 production으로 내보내면 안 되는 명백한 실패를 막는 guardrail이다.
예외도 필요했다. 브랜드명, 인스타그램 handle, reviewer handle, 통화 단위, URL 주변 text는 라틴 문자열로 남는 것이 자연스러울 수 있다. 반대로 Menu, Story, Location, Book Now, Get Directions처럼 nav나 CTA로 보이는 단어는 남기지 않도록 했다.
품질 게이트는 언어 감정가가 아니라 production 차단기여야 했다.
Language switcher와 sitemap도 같은 기준을 따랐다
언어 전환은 작은 UI처럼 보이지만 public page에서는 crawler surface이기도 하다. 그래서 client-side script가 아니라 <nav> 안의 실제 <a> 링크로 넣었다.
<nav data-p2p-language-switcher="true" aria-label="Language selector">
<a href="https://example.place2page.com/" hreflang="ko-KR" lang="ko">
한국어
</a>
<a href="https://example.place2page.com/en/" hreflang="en" lang="en">
English
</a>
</nav>사용자에게는 전환 버튼이고, crawler에게는 locale URL을 발견할 수 있는 내부 링크다. 현재 언어에는 aria-current="page"를 붙였다.
sitemap도 같은 기준을 따랐다. 지원 가능한 locale 목록이 아니라, 현재 published landing에 실제 row가 있는 locale만 넣었다.
published landing
-> existing localization rows
-> sitemap URL entries
-> xhtml:link locale alternatesrobots.txt, sitemap.xml, canonical, hreflang, IndexNow 요청은 발견과 갱신 신호다. 즉시 색인이나 검색 노출을 보장하지는 않는다.
검증한 범위
검증은 로컬, 배포, 운영 확인을 분리했다.
로컬에서는 ruff check와 tests/test_landing_localization_service.py를 먼저 통과시켰다. 목적은 번역 모델 품질을 점수화하는 것이 아니라, HTML segment 추출과 locale별 guardrail이 깨지지 않았는지 확인하는 것이었다.
그 다음 CI/CD가 성공하고 production deploy가 끝난 뒤 public URL을 확인했다.
https://example.place2page.com/en/
-> 200
-> <html lang="en">
-> language switcher present
-> hreflang alternates present
-> Hangul residual 0
https://example.place2page.com/ja/
-> 200
-> <html lang="ja">
-> language switcher present
-> hreflang alternates present
-> Hangul residual 0
https://example.place2page.com/zh-Hans/
-> 200
-> <html lang="zh-Hans">
-> language switcher present
-> hreflang alternates present
-> Hangul residual 0
-> English nav/CTA residual blocked
-> Traditional Chinese probe blocked
https://example.place2page.com/fr/
-> root fallback다시 말하지만 이것은 "번역이 문학적으로 훌륭하다"는 검증이 아니다. "public page로 내보내면 안 되는 형태의 실패는 막았다"는 검증이다.
남은 한계
이번 작업은 다국어 public page를 운영 가능한 수준으로 만드는 것이었다. 하지만 아직 제품적으로 끝난 기능은 아니다.
- 관리자 UI에서 locale generation을 실행하는 화면
- locale별 generation progress와 실패 사유 표시
- 사람이 최종 copy를 고칠 수 있는 editor
- 언어별 screenshot/visual regression QA
- native speaker review가 필요한 페이지를 표시하는 workflow
자동 품질 게이트는 native review를 대체하지 않는다. 다만 "한글이 남아 있는 영어 페이지", "영어 CTA가 남아 있는 중국어 페이지", "간체 URL에 번체가 섞인 페이지" 같은 실패를 production에 그대로 내보내지 않게 해준다.
다음에는 이렇게 판단한다
- AI 현지화는 번역 호출보다 public surface 검증 문제로 먼저 본다.
- body text, metadata, JSON-LD, canonical,
hreflang, language switcher가 같은 locale을 가리키는지 확인한다. - HTML 전체를 모델에게 맡기지 않고, 바꿔도 되는 segment만 넘긴다.
- 없는 locale은 URL, switcher, sitemap,
hreflang어디에도 노출하지 않는다. - 검색엔진 신호를 보냈다는 사실과 실제 indexing/ranking 결과를 섞어 말하지 않는다.
- 자동 QA는 native review가 아니라 production 차단기다. 명백히 공개하면 안 되는 실패부터 막는다.
