블로그에 한/영 전환 붙이기 Adding Korean/English Toggle to My Blog
이 블로그를 영어로도 읽을 수 있게 만들고 싶었다. 포트폴리오 용도도 있지만, 솔직히 더 큰 이유는 내가 쓰는 글의 맥락이 한국어권 밖에서도 통할 수 있는지 궁금했기 때문이다. 기술철학 에세이, 서천 시골집 이야기, 개발 기록 같은 것들. 번역하면 어떻게 읽힐까.
문제는 Jekyll 블로그에서 다국어를 어떻게 구현하느냐였다. 방법이 여러 가지인데, 각각 트레이드오프가 다르다. 혼자 고민하는 대신 Claude Code의 에이전트 팀을 써보기로 했다.
Phase 0. 플랜 에이전트
바로 구현부터 시작하지 않았다. 먼저 플랜 에이전트⟨blog-i18n-planner⟩를 돌렸다. “Jekyll 블로그에 한/영 전환을 붙이고 싶다”는 요구사항만 주고, 구현 방식을 설계하게 했다.
플랜 에이전트는 4개의 서브 에이전트를 병렬로 띄웠다. 번역 API + 빌드 파이프라인 설계, UI 문자열 및 Liquid 템플릿 분석, SEO + 배지 설계, 유사 사례 조사. 각자 독립적으로 조사한 뒤 결과를 종합해서 구현 플랜 문서⟨i18n_구현플랜.md⟩를 산출했다.
이 단계에서 핵심 결정들이 내려졌다.
런타임 번역 vs 빌드타임 번역. Google Translate 위젯 같은 런타임 방식은 품질 통제가 안 되고 SEO도 안 된다. 110개 포스트를 수동 번역할 시간도 없다. 결론은 빌드타임 자동 번역. GitHub Actions에서 DeepL API로 번역하고, 정적 HTML로 빌드해서 /en/ 경로에 배포한다.
UI 문자열 전환 방식. 포스트 본문은 별도 HTML 파일로 분리하되, 네비게이션이나 페이지 제목 같은 UI 문자열은 CSS data-lang 속성으로 즉시 전환한다. 다크모드 토글⟨data-theme⟩과 같은 패턴이다.
4개 Phase 구조. Phase 1은 번역 파이프라인, Phase 2는 UI i18n + 토글, Phase 3은 배지 + SEO, Phase 4는 스프레드 모드 JS 문자열 처리. Phase 내 독립 태스크는 병렬, Phase 간에는 순차.
플랜 문서가 충분히 구체적이어서 바로 구현으로 넘어갈 수 있었다.
Phase 1. 번역 파이프라인
구현 에이전트⟨blog-i18n-implement⟩를 돌렸다. 이 에이전트는 플랜 문서를 읽고 Phase별로 서브 에이전트 팀을 구성해서 실행한다.
Phase 1에서는 3개 에이전트가 병렬로 동작했다. workflow-builder⟨GitHub Actions yml⟩, translate-script⟨번역 스크립트⟩, title-script⟨영문 제목 생성⟩. 서로 다른 파일을 만드니까 충돌 없이 동시에 진행된다.
번역 스크립트의 핵심 구조는 이렇다.
def translate_post(filepath, api_key):
fm, body, raw = parse_post(filepath)
# 코드 블록 보호 (번역 대상에서 제외)
protected_body, code_blocks = protect_code_blocks(body)
translated_body = translate_text(protected_body, api_key)
translated_body = restore_code_blocks(translated_body, code_blocks)
# 영문 포스트 front matter 생성
new_fm = {
"layout": "post-en",
"title": title_en,
"permalink": f"/en/{date_str}/{slug}/",
"original_url": f"/{date_str}/{slug}/",
"lang": "en",
"translated_from": "ko"
}
코드 블록은 placeholder로 치환한 뒤 번역하고, 끝나면 복원한다. front matter에서 title_en이 이미 있으면 그걸 쓰고, 없으면 제목도 번역한다. 콘텐츠 해시 기반 캐시로 변경된 포스트만 재번역한다.
_posts/ ← 한국어 원문
_translated/en/ ← DeepL로 번역된 영문 포스트
_translation_cache.json ← MD5 해시 캐시
Phase 2. CSS 기반 i18n 토글
Phase 2에서도 3개 에이전트가 병렬이었다. i18n-data⟨_data/i18n.yml 생성⟩, liquid-convert⟨Liquid 템플릿에 i18n span 적용⟩, toggle-ui⟨토글 JS + SCSS⟩.
i18n span 패턴은 다크모드와 정확히 같다.
<span class="i18n" data-lang="ko">글 목록</span>
<span class="i18n" data-lang="en">Post List</span>
.i18n { display: none; }
[data-lang="ko"] .i18n[data-lang="ko"] { display: inline; }
[data-lang="en"] .i18n[data-lang="en"] { display: inline; }
<html> 태그에 data-lang 하나만 바꾸면 페이지 전체의 UI 문자열이 즉시 전환된다. 번역 데이터는 _data/i18n.yml에 모았다.
pages:
writings:
title:
ko: "✍️ 이것저것 쓰고싶은 글을 씁니다"
en: "✍️ Writing about this and that"
tags:
공연:
en: "Performance"
기술철학:
en: "Philosophy of Technology"
Phase 3~4. 배지, SEO, JS 문자열
Phase 3은 영문 포스트 레이아웃⟨post-en.html⟩에 “Translated from Korean” 배지를 달고, hreflang 태그와 <html lang> 동적 설정을 추가하는 작업이었다. Phase 4는 스프레드 모드의 JS 문자열⟨로딩 메시지, 목차 제목 등⟩을 __t() 헬퍼로 교체하는 작업.
여기까지가 에이전트가 자동으로 만든 범위다. 빌드 통과까지 확인하고 끝.
수동 QA에서 터진 것들
에이전트가 만든 코드는 빌드는 통과했지만, 직접 써보니 문제가 속속 나왔다. 여기서부터는 내가 하나씩 잡아야 했다.
DeepL API 인증 실패. 에이전트가 생성한 스크립트는 auth_key를 POST body에 넣었는데, DeepL Free 플랜은 Authorization: DeepL-Auth-Key 헤더를 요구한다. 403 에러가 나서 직접 수정했다.
68개 포스트 404. 가장 골치 아팠던 버그. 번역 후 영문 페이지에 접속하면 110개 중 68개가 404였다. 원인은 타임존이었다. Jekyll은 front matter의 date를 KST⟨+9⟩로 변환해서 URL을 생성한다. date: 2026-02-10T22:52:00+00:00이면 KST로 2월 11일이 되는데, 번역 스크립트는 파일명 날짜⟨2026-02-10⟩를 그대로 permalink에 썼다.
def get_jekyll_date(fm, filename):
date = fm.get("date")
if date:
if hasattr(date, 'tzinfo') and date.tzinfo is not None:
# UTC → KST 변환
local_date = date + timedelta(hours=9)
return local_date.strftime("%Y-%m-%d")
return date.strftime("%Y-%m-%d")
# fallback: 파일명에서 추출
name = Path(filename).stem
match = re.match(r"(\d{4}-\d{2}-\d{2})", name)
return match.group(0) if match else None
스크립트를 수정하고 68개 파일의 permalink를 일괄 수정했다. DeepL API 재호출 없이 front matter만 고쳐서 해결.
스프레드 모드 토글 이탈. 데스크탑 스프레드 모드에서 한/영 토글을 누르면 스프레드에서 벗어나는 버그. history.replaceState로 URL이 포스트 경로로 바뀌어 있어서, 토글 JS가 “포스트 페이지구나” 하고 /en/ URL로 이동해버렸다. lang-changed 커스텀 이벤트 패턴으로 해결했다.
// lang-toggle.js
if (isInSpreadMode()) {
document.dispatchEvent(new CustomEvent('lang-changed', { detail: { lang: next } }));
return;
}
// spread.js
document.addEventListener('lang-changed', function() {
if (!currentUrl) return;
cache = {};
var reloadUrl = currentUrl;
currentUrl = null;
loadPost(reloadUrl);
});
hreflang 이중 prefix. /en/ 페이지에서 hreflang이 /en/en/...으로 생성되는 버그. page.lang으로 조건 분기.
{% if page.lang == 'en' %}
<link rel="alternate" hreflang="ko" href="{{ page.original_url | absolute_url }}" />
<link rel="alternate" hreflang="en" href="{{ page.url | absolute_url }}" />
{% else %}
<link rel="alternate" hreflang="ko" href="{{ page.url | absolute_url }}" />
<link rel="alternate" hreflang="en" href="{{ '/en' | append: page.url | absolute_url }}" />
{% endif %}
portfolio/newsletter 영어 안 나옴. EN 모드에서 스프레드가 /en/portfolio/를 fetch하려고 하는데 그런 페이지는 없다. 포스트 URL 패턴⟨/YYYY-MM-DD/slug/⟩일 때만 /en/ prefix를 붙이도록 수정하고, portfolio와 newsletter 페이지에 직접 i18n span을 넣었다.
QA 에이전트
수동으로 이것저것 눌러보다가 버그가 계속 나오니까 답이 없었다. 이쯤에서 QA 에이전트를 돌렸다. 9개 테스트 카테고리⟨토글 동작, 스프레드 모드, URL 구조, SEO 태그, 레이아웃 일관성 등⟩로 나눠서 전체 동작을 체계적으로 점검하게 했다.
QA 에이전트가 찾아낸 이슈가 5개였다. 스프레드 모드 토글 이탈⟨Critical⟩, hreflang 이중 prefix⟨High⟩, <html lang="ko"> 하드코딩⟨High⟩, 영문 포스트 태그 미번역⟨Medium⟩, 영문 포스트 이전/다음 네비게이션 미작동⟨Low⟩. 우선순위까지 매겨서 돌아왔다.
수동 QA로는 “이거 왜 안 되지?” 하면서 하나씩 발견하던 걸, 에이전트가 한번에 정리해줬다. 내가 이미 발견한 것도 있었고, 미처 못 본 것도 있었다. <html lang="ko"> 하드코딩 같은 건 눈으로는 확인이 안 되는 SEO 이슈라 에이전트 없었으면 놓쳤을 거다.
이 리포트를 받고 5개를 한번에 수정했다. 스프레드 토글은 lang-changed 이벤트 패턴, hreflang은 page.lang 조건 분기, <html lang>은 Liquid 변수화, 태그 번역은 i18n.yml 매핑 참조, prev/next는 번역 포스트 구조상 불가능해서 아예 제거.
DeepL API Free 플랜
DeepL Free 플랜은 월 500,000자 제한이다. 110개 포스트 전체를 번역하는 데 약 357,000자를 썼다. 한번 전체 번역을 돌리면 나머지 달은 새 글이나 수정된 글만 증분 번역하면 되니까 충분하다.
번역 품질은 생각보다 괜찮았다. 특히 기술 용어가 섞인 한국어를 잘 처리했다. 코드 블록도 placeholder 치환 덕에 깨지지 않았다. 다만 에세이적인 문장에서 뉘앙스가 날아가는 경우가 있어서, 중요한 글은 title_en을 직접 써두는 게 낫다.
배포 삽질
코드를 다 고치고 커밋을 주제별로 5개로 나눠서 push했다. 그런데 GitHub Actions가 실패했다. 에이전트가 만든 workflow에서 secrets.DEEPL_API_KEY를 step의 if 조건에 직접 참조했는데, GitHub Actions 문법상 그렇게 하면 안 된다. job-level env로 빼서 env.HAS_DEEPL_KEY == 'true'로 체크해야 한다.
고쳐서 다시 push했더니 이번엔 배포 자체가 안 됐다. 원래 이 블로그는 GitHub의 기본 pages-build-deployment workflow로 배포되고 있었는데, 에이전트가 커스텀 workflow에 deploy-pages@v4를 넣어버려서 배포 경로가 꼬인 거였다. 커스텀 workflow에서 빌드+배포를 빼고 번역 전용으로 분리한 뒤, 별도 deploy.yml을 추가해서 해결했다.
결국 최종 workflow 구조는 이렇게 됐다. translate-and-deploy.yml⟨이름은 나중에 바꿔야겠다⟩은 _posts/ 변경 시에만 번역을 돌리고 커밋한다. deploy.yml은 모든 master push에서 Jekyll 빌드 + Pages 배포를 한다. 역할이 깔끔하게 분리됐다.
전체 흐름을 정리하면 이렇다. 플랜 에이전트가 아키텍처를 설계하고, 구현 에이전트가 20개 파일의 코드를 만들고, 내가 수동 QA를 하다가 한계를 느끼면 QA 에이전트를 돌리고, 발견된 이슈를 수정한다. 에이전트 → 사람 → 에이전트 → 사람, 이 루프가 반복됐다.
솔직히 에이전트가 만든 코드가 한번에 완벽하게 돌아가진 않았다. DeepL 인증 방식을 틀리고, 타임존 변환을 빠뜨리고, 스프레드 모드에서 토글이 깨졌다. 하지만 에이전트가 설계와 보일러플레이트를 만들어주니까, 내가 집중할 범위가 “전체 설계 + 전체 구현”에서 “QA + 엣지 케이스 수정”으로 좁혀졌다. 다크모드 토글과 같은 패턴⟨data-theme → data-lang⟩이 반복된다는 걸 알아챈 것도 플랜 단계에서였다.
번역 품질은 완벽하지 않다. 하지만 “영어 버전이 아예 없는 것”과 “기계 번역이라도 있는 것” 사이의 간극은 크다. 중요한 글은 나중에 직접 다듬으면 된다. 일단 110개 포스트 전부에 영어 버전이 생겼다는 것 자체가 의미 있다.