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-label
  • home.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. 구현 우선순위

  1. GitHub Actions 워크플로우 + 번역 스크립트 (핵심, Phase 1)
  2. title_en 자동 생성 (새 포스트용, Phase 1)
  3. 언어 토글 UI + lang-toggle.js (Phase 2)
  4. _data/i18n.yml + Liquid 템플릿 변환 (Phase 2)
  5. hreflang + SEO 태그 (Phase 3)
  6. 번역 배지 (Phase 3)
  7. Spread 모드 영문 경로 지원 (Phase 4)
  8. JS 문자열 i18n (Phase 4)

7. 리스크 및 대응

리스크 대응
DeepL Free 한도 초과 캐시(hash 비교)로 재번역 방지, 초기 빌드는 나눠서 실행
번역 품질 코드 블록 보호, front matter 제외, “Translated” 배지로 명시, 수동 교정 가능
빌드 시간 증가 변경된 포스트만 번역, 병렬 API 호출
GitHub Actions 분 제한 Free 2,000분/월, 번역+빌드 ~3분 = 충분
스프레드 모드 영문 경로 지원 추가로 AJAX 로드 포스트에도 적용
포스트 수정 시 번역 갱신 content hash 비교로 자동 감지, 변경된 포스트만 재번역
SEO 중복 콘텐츠 hreflang 태그로 검색엔진에 다국어 관계 명시