byminseok.com 한/영 전환 구현 플랜
byminseok.com 한/영 전환 구현 플랜
방향 요약
GitHub Pages 블로그에 한영 전환 기능을 추가한다. GitHub Actions 빌드타임 번역으로 SEO가 걸리는 정적 영문 페이지를 생성하고, 토글 UI로 언어를 전환한다.
- 번역 엔진. DeepL API Free (500K chars/month, 한영 품질 최상)
- 키 보호. GitHub Actions secret (
DEEPL_API_KEY) — 별도 프록시 불필요 - SEO. 빌드타임에
/en/경로로 영문 HTML 생성 → 검색엔진 인덱싱 가능 - 포스트 원어 표시. front matter
lang: ko/en+ 번역 배지
1. 번역 API 선택 및 근거
비교표
| 항목 | DeepL Free | Google Cloud v3 | MS Translator |
|---|---|---|---|
| 무료 한도 | 50만 자/월 | 50만 자/월 | 200만 자/월 |
| 한↔영 품질 | 상 (honorific 지원) | 중상 | 중상 |
| HTML 보존 | tag_handling=html | 지원 | 지원 |
| 빌드타임 호출 | API 키만으로 OK | 서비스 계정 필요 | 서비스 키 필요 |
선택. DeepL API Free
- 한↔영 번역 품질이 가장 높고,
tag_handling=html로 HTML 구조 보존 - 월 50만 자 무료면 현재 블로그 규모(~110 포스트, 월 ~500 방문)에서 충분
- GitHub Actions secret으로 API 키 관리 → Cloudflare Workers 프록시 불필요
- 트래픽 증가 시 MS Translator(200만 자/월 무료)로 전환 가능
비용 예측
| 시나리오 | API 호출 문자 | 비용 |
|---|---|---|
| 초기 빌드 (110편 전체, 1회성) | ~300K | 무료 |
| 일반 운영 (4-8편/월) | ~20-40K/월 | 무료 |
| 과거글 수정 | ~5K/월 | 무료 |
2. 아키텍처
[작성] md (lang: ko) + title_en
│
▼
[GitHub Actions] master push 시 빌드 파이프라인
│
├─ Step 1. title_en 없는 포스트 → DeepL로 영문 제목 자동 생성 + 커밋
├─ Step 2. 포스트 본문 → DeepL API로 영문 번역 (hash 비교, 변경분만)
├─ Step 3. 번역 결과를 _translated/en/ 에 마크다운으로 저장
├─ Step 4. Jekyll 빌드 (원문 + 번역 동시 빌드)
└─ Step 5. GitHub Pages 배포
[결과물]
byminseok.com/2026-03-04/personal-memo-fragments (한국어 원문)
byminseok.com/en/2026-03-04/personal-memo-fragments (영문 번역, SEO 인덱싱 가능)
URL 구조
| 언어 | URL 패턴 | 예시 |
|---|---|---|
| 한국어 (기본) | /:year-:month-:day/:title |
/2026-03-04/personal-memo-fragments |
| English | /en/:year-:month-:day/:title |
/en/2026-03-04/personal-memo-fragments |
<link rel="alternate" hreflang="en"> 태그로 상호 참조 → 검색엔진이 다국어 페이지로 인식
3. 포스트 작성 워크플로우
front matter 확장
---
layout: post
title: "새 글 제목"
title_en: "New Post Title" # 없으면 빌드 시 DeepL로 자동 생성
lang: ko # 필수. 원문 언어 (ko 또는 en). 미지정 시 ko 기본
date: 2026-03-08
tags: 에세이
---
title_en을 직접 쓰면 자연스러운 영문 제목 가능, 안 쓰면 GitHub Actions가 자동 생성+커밋- 영어로 쓴 포스트는
lang: en지정 → 한글 번역이 생성됨 - 전체 110개 포스트에
title_en추가 완료 (Claude로 번역)
4. 구현 단계
Phase 1. GitHub Actions 번역 파이프라인 (핵심)
4-1. 워크플로우 파일 .github/workflows/translate-and-deploy.yml
name: Translate & Deploy
on:
push:
branches: [master]
paths: ['_posts/**']
jobs:
translate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
token: $
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install dependencies
run: pip install requests pyyaml
- name: Generate missing title_en
env:
DEEPL_API_KEY: $
run: python scripts/generate_title_en.py
- name: Translate posts
env:
DEEPL_API_KEY: $
run: python scripts/translate_posts.py
- name: Commit translations
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add _posts/ _translated/ _translation_cache.json
git diff --staged --quiet || git commit -m "Auto-translate posts"
git push
- name: Build Jekyll
uses: actions/jekyll-build-pages@v1
- name: Deploy
uses: actions/deploy-pages@v4
4-2. 번역 스크립트 scripts/translate_posts.py
핵심 로직
_posts/순회하며 각 포스트의 lang 확인- lang: ko → 영문 번역 생성, lang: en → 한글 번역 생성
- 번역 결과를
_translated/en/(또는_translated/ko/)에 마크다운으로 저장 - 코드 블록(
```)과 front matter(---)는 번역하지 않음 - 이미지, 링크 경로는 보존
- 캐싱.
_translation_cache.json에 포스트 content hash 저장 → 변경분만 재번역
4-3. title_en 자동 생성 scripts/generate_title_en.py
_posts/순회하며title_en없는 포스트 감지- DeepL API로 title 번역
- front matter에
title_en삽입 - 이후 translate-and-deploy 워크플로우가 커밋
4-4. 번역 캐시
_translation_cache.json에{slug: {content_hash, translated_at}}저장- 포스트 내용이 변경되지 않으면 기존 번역 재사용
- 캐시 파일은 git에 포함 (빌드 간 지속)
Phase 2. UI 문자열 i18n + 토글 UI
4-5. _data/i18n.yml (UI 문자열 ~50개)
site:
description:
ko: "글을 쓰고, 기술을 사유하고, 코드를 기록합니다"
en: "Writing, thinking about technology, and documenting code"
author:
ko: "김민석"
en: "Minseok Kim"
post:
back_to_list:
ko: "← 목록"
en: "← List"
date_format:
ko: "%Y년 %m월 %d일"
en: "%B %d, %Y"
archive:
tag_all:
ko: "전체"
en: "All"
select_post:
ko: "글을 선택하세요"
en: "Select a post"
toc:
title:
ko: "목차"
en: "Table of Contents"
collapse:
ko: "접기"
en: "Collapse"
expand:
ko: "펼치기"
en: "Expand"
spread:
loading:
ko: "천천히 천천히"
en: "Loading..."
error:
ko: "글을 불러올 수 없습니다."
en: "Failed to load the post."
home:
recent_posts:
ko: "최근 글"
en: "Recent posts"
4-6. Liquid 템플릿 변환 (양언어 동시 렌더링 + CSS 토글)
<span class="i18n" data-lang="ko">← 목록</span>
<span class="i18n" data-lang="en">← List</span>
CSS 토글 규칙 (_sass/_i18n.scss):
// Default: Korean visible
.i18n[data-lang="en"] { display: none; }
.i18n[data-lang="ko"] { display: inline; }
// English mode
html[data-lang="en"] .i18n[data-lang="ko"] { display: none; }
html[data-lang="en"] .i18n[data-lang="en"] { display: inline; }
// Block-level variant
.i18n-block[data-lang="en"] { display: none; }
.i18n-block[data-lang="ko"] { display: block; }
html[data-lang="en"] .i18n-block[data-lang="ko"] { display: none; }
html[data-lang="en"] .i18n-block[data-lang="en"] { display: block; }
주요 변환 대상
post.html→ “← 목록”, 날짜 포맷archive.html→ “전체”, “글을 선택하세요”, 태그 필터 aria-labelhome.html→ 자기소개 텍스트, “최근 글”default.html→<html lang="ko" data-lang="ko">
4-7. 토글 버튼 (theme-toggle 옆에 배치)
<header class="header">
<h1><a href="/">byminseok.com</a></h1>
<div class="header__controls">
<button class="lang-toggle" id="lang-toggle" aria-label="Translate to English">
<span class="lang-toggle__ko">한</span>
<span class="lang-toggle__en">EN</span>
</button>
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark mode">
<!-- existing SVG icons -->
</button>
</div>
</header>
동작 방식 (빌드타임 번역이므로 페이지 이동 방식)
- 한국어 페이지에서 EN 클릭 →
/en/경로의 같은 포스트로 이동 - 영문 페이지에서 KO 클릭 → 원문 경로로 이동
- localStorage에 선호 언어 저장 (
reader-lang) - 첫 방문 시
navigator.language기반 감지
_includes/head.html에 언어 초기화 추가:
<script>
(function() {
var s = localStorage.getItem('reader-lang');
var l = s || (navigator.language.startsWith('en') ? 'en' : 'ko');
document.documentElement.setAttribute('data-lang', l);
document.documentElement.setAttribute('lang', l);
})();
</script>
assets/js/lang-toggle.js:
(function() {
var toggle = document.getElementById('lang-toggle');
if (!toggle) return;
function getLang() {
return document.documentElement.getAttribute('data-lang') || 'ko';
}
function setLang(lang) {
localStorage.setItem('reader-lang', lang);
// Navigate to translated version
var path = window.location.pathname;
if (lang === 'en' && !path.startsWith('/en/')) {
window.location.href = '/en' + path;
} else if (lang === 'ko' && path.startsWith('/en/')) {
window.location.href = path.replace(/^\/en/, '');
}
}
toggle.addEventListener('click', function() {
setLang(getLang() === 'ko' ? 'en' : 'ko');
});
})();
토글 SCSS (_sass/_layout.scss에 추가):
.header__controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.lang-toggle {
background: none;
border: none;
color: var(--color-muted);
cursor: pointer;
padding: 0.3rem;
font-size: 0.85rem;
font-family: var(--font-sans);
transition: color 0.2s ease;
display: flex;
gap: 0.15rem;
&:hover { color: var(--color-text); }
}
.lang-toggle__en { opacity: 0.4; }
html[data-lang="en"] .lang-toggle__ko { opacity: 0.4; }
html[data-lang="en"] .lang-toggle__en { opacity: 1; }
html[data-lang="ko"] .lang-toggle__ko { opacity: 1; }
Phase 3. 번역 배지 + SEO
4-8. 번역 배지 (영문 페이지 상단에 표시)
| 페이지 | 원문 언어 | 배지 |
|---|---|---|
/en/... |
ko | “Translated from Korean” + [View original] |
/en/... |
en | (없음, 영어 원문) |
| 원문 페이지 | ko | (없음) |
| 원문 페이지 | en | (없음) |
영문 레이아웃 (_layouts/post-en.html)에서 배지를 정적으로 렌더링.
배지 SCSS (_sass/_post.scss에 추가):
.translate-badge {
font-size: 0.85rem;
color: var(--color-muted);
margin-top: var(--space-xs);
padding: var(--space-xs) 0;
&--translated {
border-left: 2px solid var(--color-accent);
padding-left: var(--space-xs);
}
&__btn {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 2px;
&:hover { color: var(--color-accent-hover); }
}
}
4-9. SEO 태그 (_includes/head.html에 추가)
<link rel="alternate" hreflang="ko" href="https://byminseok.com/i18n_%EA%B5%AC%ED%98%84%ED%94%8C%EB%9E%9C" />
<link rel="alternate" hreflang="en" href="https://byminseok.com/en/i18n_%EA%B5%AC%ED%98%84%ED%94%8C%EB%9E%9C" />
<link rel="alternate" hreflang="x-default" href="https://byminseok.com/i18n_%EA%B5%AC%ED%98%84%ED%94%8C%EB%9E%9C" />
jekyll-sitemap이/en/경로 포스트도 자동 포함- 영문 페이지에는
title_en+ 영문 description을 meta 태그에 사용
4-10. archive 목록 제목 처리
front matter의 title_en을 활용하여 양언어 표시:
<a href="" class="post-list__link">
<span class="i18n" data-lang="ko"></span>
<span class="i18n" data-lang="en"></span>
</a>
Phase 4. 스프레드 모드 + JS 문자열 처리 (Polish)
4-11. 스프레드 모드 영문 경로 지원
spread.js가 AJAX로 포스트를 로드할 때, 현재 언어에 따라 /en/ 경로에서 로드:
var url = getLang() === 'en' ? '/en' + post.url : post.url;
4-12. JS 파일 내 문자열 처리
Liquid가 빌드 타임에 window.__i18n 객체를 주입. JS는 __t() 헬퍼로 현재 언어 문자열 사용.
<script>
window.__i18n = {};
window.__getLang = function() {
return document.documentElement.getAttribute('data-lang') || 'ko';
};
window.__t = function(obj) {
if (!obj) return '';
return obj[window.__getLang()] || obj.ko || '';
};
</script>
toc.js, spread.js, heartbeat.js 내 하드코딩 문자열을 __t() 헬퍼로 교체.
5. 파일별 수정 명세
신규 생성
| 파일 | 용도 |
|---|---|
.github/workflows/translate-and-deploy.yml |
번역 + 빌드 + 배포 파이프라인 |
scripts/translate_posts.py |
포스트 본문 번역 스크립트 (DeepL API) |
scripts/generate_title_en.py |
title_en 자동 생성 스크립트 |
_data/i18n.yml |
UI 문자열 ko/en 쌍 (~50개) |
_layouts/post-en.html |
영문 포스트 레이아웃 (배지 포함) |
assets/js/lang-toggle.js |
언어 전환 (페이지 이동 방식) |
_sass/_i18n.scss |
i18n CSS 토글 규칙 + 배지 스타일 |
수정
| 파일 | 변경 내용 |
|---|---|
_layouts/default.html |
<html lang="ko" data-lang="ko">, 헤더에 lang-toggle + header__controls div |
_includes/head.html |
언어 초기화 스크립트, hreflang 태그, lang-toggle.js 로드 |
_layouts/post.html |
i18n span 패턴 적용, data-lang 속성 |
_layouts/archive.html |
i18n span 적용, 목록 제목 양언어 처리 (title_en) |
_layouts/home.html |
자기소개 양언어, “최근 글” 양언어 |
_includes/footer.html |
i18n span 적용 |
_includes/archive-nav.html |
i18n span 적용 |
_config.yml |
defaults에 lang: ko 추가 |
assets/js/spread.js |
영문 경로 지원 |
assets/js/toc.js |
__t() 헬퍼로 문자열 교체 |
_sass/_layout.scss |
.header__controls, .lang-toggle 스타일 |
_sass/_post.scss |
.translate-badge 스타일 |
assets/css/main.scss |
@import "i18n" 추가 |
6. 구현 우선순위
- GitHub Actions 워크플로우 + 번역 스크립트 (핵심, Phase 1)
- title_en 자동 생성 (새 포스트용, Phase 1)
- 언어 토글 UI + lang-toggle.js (Phase 2)
- _data/i18n.yml + Liquid 템플릿 변환 (Phase 2)
- hreflang + SEO 태그 (Phase 3)
- 번역 배지 (Phase 3)
- Spread 모드 영문 경로 지원 (Phase 4)
- JS 문자열 i18n (Phase 4)
7. 리스크 및 대응
| 리스크 | 대응 |
|---|---|
| DeepL Free 한도 초과 | 캐시(hash 비교)로 재번역 방지, 초기 빌드는 나눠서 실행 |
| 번역 품질 | 코드 블록 보호, front matter 제외, “Translated” 배지로 명시, 수동 교정 가능 |
| 빌드 시간 증가 | 변경된 포스트만 번역, 병렬 API 호출 |
| GitHub Actions 분 제한 | Free 2,000분/월, 번역+빌드 ~3분 = 충분 |
| 스프레드 모드 | 영문 경로 지원 추가로 AJAX 로드 포스트에도 적용 |
| 포스트 수정 시 번역 갱신 | content hash 비교로 자동 감지, 변경된 포스트만 재번역 |
| SEO 중복 콘텐츠 | hreflang 태그로 검색엔진에 다국어 관계 명시 |