3개 언어 블로그를 411줄짜리 Python 정적 사이트 생성기로 만들기

Hugo 대신 직접 만든 이유와, hreflang·noindex 옛 글·광고 자리·JSON-LD·안전한 증분 FTP 배포를 어떻게 처리하는지.

이 블로그는 직접 만든 정적 사이트 생성기로 돌아갑니다. 411줄짜리 Python 파일 하나와 Jinja2 템플릿, Markdown 파일들입니다. 3개 언어 167페이지를 약 0.8초에 빌드합니다. 이 글에서는 기존 생성기를 쓰지 않은 이유, 가장 고민한 부분(hreflang, 옛 글, 광고), 그리고 서버에 무엇이든 올라가기 전에 도는 검사를 정리합니다.

Hugo를 쓰지 않은 이유

Markdown을 HTML로 바꾸는 건 쉬운 부분이고, 어떤 생성기든 잘합니다. 저희의 어려운 요구 사항은 모두 이 사이트만의 것이었습니다.

  • 앱스토어 URL. 스토어 등록 24건이 옛 개인정보 URL과 몇몇 지원 URL을 가리킵니다. 이 페이지들은 앱 데이터로 만들고, 옛 주소 그대로 서비스합니다.
  • 데이터로 만드는 개인정보 페이지. 앱마다 데이터 항목 목록(ads, push, camera 등)이 있고, 거기서 지원 페이지·개인정보 페이지·전체 앱 표를 3개 언어로 만듭니다.
  • 옛 글. 2021년 강좌 67개는 공개해 두되 보강이 끝날 때까지 noindex로 둡니다. 사이트맵과 hreflang에서는 빠져야 하고, 읽을 수는 있어야 합니다.
  • 같이 쓰는 서버. 같은 문서 루트에서 앱 API와 광고 설정 엔드포인트도 돌아갑니다. 배포는 그것들을 절대 건드리면 안 됩니다.

어느 것도 Hugo로 불가능한 건 아닙니다. 다만 테마를 덮어쓰거나 플러그인을 찾는 대신 각각 Python 몇 줄로 해결됐고, 생성기 전체를 한 번에 읽을 수 있습니다.

콘텐츠 구조

content/en/*.md   content/ja/*.md   content/ko/*.md   content/ko/_legacy/*.md
---
title: "Dart http 클라이언트는 POST의 301을 따라가지 않는다: 실측과 해결"
description: "…"
slug: dart-http-post-redirect-not-followed   # 모든 언어에서 같은 슬러그
date: 2026-11-09
category: flutter                            # 표시 이름이 아니라 키
translation_key: dart-http-post-redirect     # 3개 언어판을 잇는다
faq: [{q: "…", a: "…"}]
---

나중에 효과를 보는 선택이 두 가지 있습니다. 카테고리는 키(flutter)로 적고, 언어별 표시 이름은 site.yaml에서 가져옵니다. 그래서 "같은 카테고리인가"를 기계가 확인할 수 있습니다. 그리고 언어판을 잇는 건 translation_key 하나뿐입니다. 파일 이름이나 제목은 마음대로 달라도 됩니다.

파서는 실패를 분명하게 알립니다. 필수 항목 누락, 알 수 없는 카테고리, ASCII가 아닌 슬러그, 슬러그 중복이 있으면 파일 이름을 보여 주며 빌드를 멈춥니다.

hreflang은 "색인 가능한 형제"끼리만

hreflang은 검색엔진에 "이 페이지에는 다른 언어판이 있다"고 알려 줍니다. noindex 페이지를 가리키거나 번역이 없는 페이지에 붙이면 서로 어긋나는 신호가 됩니다. 생성기의 규칙은 이렇습니다.

siblings = by_key.get(p.get("translation_key"), {})
hreflangs = []
if not p["noindex"] and len(siblings) > 1:
    idx = {l: s for l, s in siblings.items() if not s["noindex"]}
    if len(idx) > 1:
        hreflangs = [{"hreflang": l, "url": s["url"]} for l, s in idx.items()]
        xd = idx.get(site["default_lang"]) or next(iter(idx.values()))
        hreflangs.append({"hreflang": "x-default", "url": xd["url"]})

한국어 옛 글에는 일본어·영어판을 갖춘 보강본이 나오기 전까지 hreflang이 붙지 않습니다. 보강본이 나오면 세 언어가 자동으로 서로를 가리킵니다. hreflang은 사람 눈에 보이지 않으니, 같은 데이터로 독자용 "다른 언어로 읽기" 링크도 만듭니다.

광고 자리를 위한 구역 나누기

글 안에서 애드센스를 흔히 두는 자리는 "본문 위", "목차 아래", "소제목 앞", "글 끝"입니다. 생성기는 렌더링한 HTML을 <h2>마다 나눕니다.

def split_sections(body):
    return [p for p in re.split(r"(?=<h2[ >])", body) if p.strip()]

def ad_positions(n):
    return {i for i in (2, 5) if i < n}   # 본문 중간 광고는 최대 2개

템플릿은 구역과 광고 자리를 번갈아 놓습니다. 광고 자리는 자리 ID가 설정돼 있고 동시에 페이지가 색인 가능할 때만 출력됩니다. 옛 글과 미리보기 페이지에는 광고 코드가 아예 들어가지 않습니다. 사이트 승인 전에는 자리 ID가 모두 비어 있어서 본문 중간 광고 마크업은 나오지 않습니다.

같은 프런트매터로 구조화 데이터

글마다 TechArticle, BreadcrumbList, 그리고 프런트매터에 faq가 있으면 FAQPage까지 담은 JSON-LD @graph 하나를 붙입니다. FAQPage의 답변은 페이지에 보이는 문장과 같습니다. JSON은 </script>에 대비해 이스케이프하고, 검사기가 모든 블록을 json.loads로 다시 읽어 보므로, 따옴표 하나 때문에 구조화 데이터가 소리 없이 깨지는 일은 없습니다.

진짜 제품은 검사기다

check_content.py(186줄)는 빌드 결과물을 검사하고, 하나라도 보고가 있으면 배포 스크립트가 움직이지 않습니다.

검사 실제로 잡은 것
보호 경로: 출력 파일이 서버 쪽 소유 경로(tlabo-ad/, api/, app/*/index.php, ads.txt 등)와 겹치지 않는가 일부러 세 개를 넣어 확인: 오류 3건
스토어에 등록된 URL의 대상 페이지가 모두 있는가 등록 24건 보호
내부 링크와 #앵커가 해석되는가 아직 없음. 빌드할 때마다 실행
사이트맵의 URL이 모두 있고 noindex가 아닌가 noindex 38페이지를 사이트맵에 넣은 미리보기 빌드
<title> 길이, description, canonical, lang 70자를 넘는 영어 제목(여러 번)

미리보기 빌드(--drafts)는 모든 페이지를 noindex로 만들고 사이트맵을 쓰지 않습니다. 실수로 미리보기를 올려도 색인될 일이 없습니다.

공유 호스팅에 안전하게 배포하기

사이트는 235줄짜리 스크립트로 FTPS를 통해 올립니다.

  • 바뀐 것만. 올린 파일마다 MD5를 매니페스트에 기록합니다. 최근 소개 페이지 수정 때는 294개 중 11개, 글을 추가할 때는 보통 30~40개를 올립니다.
  • 허용 목록. 올릴 수 있는 건 생성기가 소유한 경로(en/, ja/, ko/, static/, 고정 페이지, app/<id>/support|privacy/)뿐입니다. 다른 것이 하나라도 있으면 배포 전체를 중단합니다.
  • 남의 파일. 서버에는 있는데 매니페스트에 없는 파일이 있으면 멈춥니다. 명시적인 플래그가 있을 때만 원본을 먼저 내려받은 뒤 계속합니다. 이 덕분에 검색엔진 사이트 인증 줄이 든 robots.txt를 지켰습니다.
  • 기본은 지우지 않기. 지우는 건 예전에 직접 올렸고 지금 빌드에서는 더 이상 만들지 않는 파일뿐입니다.

숫자

항목 값
생성기 411줄(build.py) + 186줄(check_content.py) + 235줄(deploy.py)
템플릿·CSS·JS 약 900줄
빌드 0.8초 (HTML 167페이지, 3개 언어, 사이트맵 4개, 피드 3개)
결과물 4.8MB, 그중 2.2MB가 정적 파일 (대부분 옛 글 이미지)
페이지당 CSS / JS 13.4KB / 3.6KB. 72KB짜리 코드 하이라이터는 코드가 있는 페이지에서만 불러옴
첫 바이트까지 (운영 서버) 약 0.13~0.14초

직접 만드는 게 맞는 경우

상황 선택
일반적인 블로그나 문서 사이트 Hugo, Eleventy, Astro. 테마와 플러그인의 가치가 세밀한 제어보다 크다
같은 서버에서 다른 시스템과 함께 살아야 한다 작은 자체 생성기 + 엄격한 배포 허용 목록
업무 데이터(앱, 정책, 리다이렉트)로 페이지를 만든다 직접 만들기. 데이터 모델이 핵심이다
코드를 유지보수할 생각이 없다 기존 생성기. 무조건

생성기 자체는 흥미로운 부분이 아닙니다. 하는 일이 적어서 짧을 뿐입니다. 사이트를 안전하게 운영하게 해 준 건 배포 앞에 선 검사기와, "배포는 생성기가 소유한 것만 건드린다"는 규칙입니다.

자주 묻는 질문

왜 Hugo 같은 기존 생성기를 쓰지 않았나요?

본문 변환은 쉬운 부분이었습니다. 어려운 요구 사항은 모두 이 사이트만의 것이었습니다. 앱스토어에 등록된 URL로 서비스하기, 앱 개인정보 페이지를 데이터로 만들기, 옛 글 67개를 보강 전까지 noindex로 두기, 다른 시스템이 가진 파일 위에는 배포하지 않기. 작은 생성기에서는 각각이 몇 줄이면 끝났습니다.

일부만 번역된 글의 hreflang은 어떻게 제대로 넣나요?

프런트매터의 translation_key로 글을 묶고, 모두 색인 가능한 형제 글 사이에서만 hreflang을 넣습니다. noindex인 페이지나 번역이 없는 페이지에는 hreflang을 붙이지 않습니다. 영어판이 있으면 x-default는 영어판을 가리킵니다.

속도는 어떤가요?

노트북에서 3개 언어 HTML 167페이지와 사이트맵·피드까지 약 0.8초입니다. 배포는 MD5가 바뀐 파일만 올리며, 글을 추가하면 보통 294개 중 10~40개 정도입니다.

  • #Python
  • #정적사이트
  • #Jinja2
  • #hreflang
  • #SEO
  • #다국어

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑