Home Tistory에서 GitHub Pages로 블로그 마이그레이션 완전 가이드
Post
Cancel

Tistory에서 GitHub Pages로 블로그 마이그레이션 완전 가이드

블로그를 Tistory에서 GitHub Pages(Jekyll)로 마이그레이션하는 과정은 생각보다 복잡합니다. 이 글에서는 112개의 포스트, 788개의 이미지를 성공적으로 마이그레이션한 전체 과정과 발생했던 에러들, 그리고 해결 방법을 공유하고자 합니다.

왜 GitHub Pages로 이전했나?

Tistory는 사용하기 쉽지만, 다음과 같은 한계가 있습니다:

  • 플랫폼 의존성: Tistory 서비스 종료 시 데이터 손실 위험
  • 완전한 커스터마이징 불가: 제한된 HTML/CSS 수정만 가능
  • 버전 관리 불가: 포스트 변경 이력 관리 어려움
  • 데이터 포팅 어려움: 다른 플랫폼으로 이전 시 복잡한 과정

GitHub Pages는 이러한 문제를 해결합니다:

  • 완전한 소유권: 모든 데이터를 git 저장소로 관리
  • 버전 제어: git을 통한 완전한 변경 이력 관리
  • 무제한 커스터마이징: 전체 코드 제어 가능
  • 무료 호스팅: 백업 및 배포 자동화
  • 마크다운 기반: 포맷 독립적인 콘텐츠 관리

전체 마이그레이션 프로세스

1단계: 포스트 추출 및 마이그레이션

1.1 Tistory 스크래핑

먼저 Tistory 블로그에서 모든 포스트를 추출해야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import requests
from bs4 import BeautifulSoup
from pathlib import Path
import time

BASE_URL = "https://songseungwon.tistory.com"
PAGES = 12  # 페이지 수

def scrape_tistory_posts():
    """Tistory에서 모든 포스트 URL 추출"""
    posts = []
    for page in range(1, PAGES + 1):
        url = f"{BASE_URL}?page={page}"
        response = requests.get(url)
        soup = BeautifulSoup(response.content, 'html.parser')

        # 포스트 링크 추출
        for link in soup.find_all('a', {'class': 'post-link'}):
            post_url = link.get('href')
            if post_url:
                posts.append(post_url)

        time.sleep(0.5)  # 서버 부하 분산

    return posts

1.2 포스트 내용 추출

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def extract_post_content(url):
    """개별 포스트에서 제목, 날짜, 내용 추출"""
    response = requests.get(url)
    soup = BeautifulSoup(response.content, 'html.parser')

    # 제목 추출
    title = soup.find('h1', {'class': 'title-article'})
    title_text = title.get_text(strip=True) if title else "Untitled"

    # 날짜 추출 (형식: 2025. 6. 10. 18:54)
    date_elem = soup.find('span', {'class': 'date'})
    date_text = date_elem.get_text(strip=True) if date_elem else ""
    date_formatted = convert_date_format(date_text)  # 2025-06-10

    # 본문 추출
    article = soup.find('div', {'class': 'article-view'})

    return {
        'title': title_text,
        'date': date_formatted,
        'content': article,
        'url': url
    }

def convert_date_format(date_str):
    """Tistory 날짜 형식을 Jekyll 형식으로 변환
    2025. 6. 10. 18:54 -> 2025-06-10
    """
    parts = date_str.split('.')
    year = parts[0].strip()
    month = parts[1].strip().zfill(2)
    day = parts[2].strip().zfill(2)
    return f"{year}-{month}-{day}"

2단계: 이미지 추출 및 최적화

2.1 이미지 다운로드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def download_images(article, post_id):
    """포스트에서 모든 이미지를 다운로드하고 URL 업데이트"""
    images_dir = Path("assets/images/posts")
    images_dir.mkdir(parents=True, exist_ok=True)

    img_counter = 0
    for img in article.find_all('img'):
        src = img.get('src') or img.get('data-src')
        if not src:
            continue

        try:
            # 이미지 URL 정규화
            img_url = resolve_image_url(src, BASE_URL)
            response = requests.get(img_url, timeout=5)
            response.raise_for_status()

            # 파일 형식 감지
            ext = get_image_extension(img_url, response)
            img_name = f"{post_id}-{img_counter}{ext}"
            img_dest = images_dir / img_name

            # 이미지 저장
            with open(img_dest, 'wb') as f:
                f.write(response.content)

            # 로컬 경로로 업데이트 (중요!)
            img['src'] = f"/assets/images/posts/{img_name}"
            img_counter += 1

        except Exception as e:
            print(f"이미지 다운로드 실패: {img_url} - {e}")
            img.decompose()  # 실패한 이미지 제거

def resolve_image_url(src, base_url):
    """상대경로, CDN URL, 프로토콜 없는 URL 등 처리"""
    if src.startswith('http'):
        return src
    elif src.startswith('//'):
        return 'https:' + src
    elif src.startswith('/'):
        return base_url + src
    else:
        return base_url + '/' + src

def get_image_extension(url, response):
    """Content-Type으로부터 파일 확장자 감지"""
    content_type = response.headers.get('content-type', '').lower()

    type_to_ext = {
        'image/jpeg': '.jpg',
        'image/png': '.png',
        'image/gif': '.gif',
        'image/webp': '.webp'
    }

    for mime_type, ext in type_to_ext.items():
        if mime_type in content_type:
            return ext

    # URL에서 확장자 추출
    path = url.split('?')[0]
    return Path(path).suffix or '.jpg'

2.2 WebP 압축 (75% 크기 감소)

원본 이미지: 167.51 MB → 압축 후: 41.08 MB (126.43 MB 절약)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from PIL import Image

def compress_to_webp(image_path, quality=85):
    """이미지를 WebP 형식으로 압축"""
    try:
        img = Image.open(image_path)

        # RGBA를 RGB로 변환 (압축률 향상)
        if img.mode in ('RGBA', 'LA', 'P'):
            background = Image.new('RGB', img.size, (255, 255, 255))
            if img.mode == 'P':
                img = img.convert('RGBA')
            background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
            img = background
        elif img.mode not in ('RGB', 'L'):
            img = img.convert('RGB')

        # WebP로 저장
        webp_path = image_path.with_suffix('.webp')
        img.save(
            webp_path,
            'WEBP',
            quality=quality,
            method=6  # 최대 압축
        )

        # 원본 이미지 삭제
        image_path.unlink()

        return webp_path

    except Exception as e:
        print(f"WebP 압축 실패: {image_path} - {e}")
        return None

2.3 마크다운으로 변환

HTML을 마크다운으로 변환하면서 이미지 링크 보존이 중요합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from markdownify import markdownify

def convert_to_markdown(article):
    """HTML을 마크다운으로 변환
    이미지 링크 보존이 핵심!
    """
    # 먼저 이미지를 다운로드하고 src를 업데이트
    download_images(article, post_id)

    # 그 후에 마크다운으로 변환
    markdown_content = markdownify(
        str(article),
        heading_style="underlined"
    )

    # 과도한 줄바꿈 정리
    markdown_content = re.sub(r'\n\s*\n\s*\n', '\n\n', markdown_content).strip()

    return markdown_content

3단계: Jekyll 포스트 생성

3.1 YAML Front Matter 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def create_jekyll_post(title, date, content, categories, post_id):
    """Jekyll 형식의 포스트 파일 생성"""

    # 파일명 생성 (YYYY-MM-DD-title.md)
    safe_title = re.sub(r'[^\w\s-]', '', title)
    safe_title = re.sub(r'[-\s]+', '-', safe_title)
    filename = f"_posts/{date}-{safe_title}.md"

    # YAML Front Matter
    front_matter = f"""---
layout: post
title: {title}
date: {date}
categories: {categories}
---

"""

    # 포스트 저장
    with open(filename, 'w', encoding='utf-8') as f:
        f.write(front_matter + content)

    return filename

4단계: 카테고리 추출 및 매핑

4.1 Tistory에서 카테고리 추출

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def extract_tistory_categories():
    """Tistory의 모든 카테고리 추출"""
    url = "https://songseungwon.tistory.com/category"
    response = requests.get(url)
    soup = BeautifulSoup(response.content, 'html.parser')

    categories = {}
    for link in soup.find_all('a', href=True):
        href = link.get('href', '')
        if '/category/' in href:
            text = link.get_text(strip=True)
            if text:
                categories[text] = href

    return categories

4.2 포스트에 카테고리 매핑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
def infer_category_from_title(title):
    """포스트 제목에서 카테고리 추론"""
    title_lower = title.lower()

    # 키워드 기반 카테고리 분류
    category_keywords = {
        "Web/JavaScript": ['javascript', 'html', 'css', 'nodejs', 'npm'],
        "ML/DL": ['machine', 'deep', 'learning', 'neural', 'gan', 'xgboost'],
        "Finance": ['주식', 'stock', 'etf', 'bitcoin', 'crypto', '금융', 'finance'],
        "Server/Data/Cloud": ['docker', 'kubernetes', 'elastic', 'gcp', 'cloud'],
        "Quantitative Investment": ['정량', 'quantitative', '매크로', 'macro'],
    }

    for category, keywords in category_keywords.items():
        if any(kw in title_lower for kw in keywords):
            return category

    return "Technology"  # 기본값

def add_category_to_post(post_file, category):
    """포스트의 YAML에 카테고리 추가"""
    with open(post_file, 'r', encoding='utf-8') as f:
        content = f.read()

    # YAML 파싱
    parts = content.split('---', 2)
    front_matter = parts[1]
    body = parts[2]

    # 카테고리 추가
    front_matter = re.sub(
        r'(date: [^\n]+\n)',
        r'\1categories: ["' + category + '"]\n',
        front_matter
    )

    # 저장
    with open(post_file, 'w', encoding='utf-8') as f:
        f.write(f"---{front_matter}---{body}")

5단계: 에러 처리 및 검증

5.1 발생한 주요 에러들

에러 1: 제어 문자 (Control Characters)

1
2
3
Error: YAML Exception reading post file:
control characters are not allowed at line 1 column 1
ERROR: Input contains prohibited control code point U+0008

원인: Tistory에서 추출한 일부 포스트에 백스페이스(U+0008) 등의 제어 문자 포함

해결:

1
2
3
4
5
6
7
def remove_control_characters(text):
    """제어 문자 제거"""
    cleaned = ''.join(
        ch for ch in text
        if ord(ch) >= 32 or ch in '\n\t\r'
    )
    return cleaned.replace('\x00', '')

에러 2: 파일명의 과도한 대시

예: 2019-09-23---NodeJS---NPM--PM2.md (3개 이상의 대시)

해결:

1
2
3
4
def clean_filename(filename):
    """연속된 대시 제거"""
    clean = re.sub(r'-{2,}', '-', filename)
    return clean.strip('- ')

에러 3: 이미지 링크 누락

1
2
원인: HTML을 `.get_text()`로 변환하면 모든 HTML 태그가 제거되어
     이미지 태그도 함께 삭제됨

해결:

1
2
3
4
5
6
7
8
9
10
11
# 잘못된 방법 ❌
content = article.get_text()  # 이미지 링크 손실

# 올바른 방법 ✅
# 1단계: 이미지 다운로드 및 src 업데이트
for img in article.find_all('img'):
    # ... 이미지 다운로드
    img['src'] = f"/assets/images/posts/{img_name}"

# 2단계: 마크다운으로 변환 (이미지 링크 보존)
content = markdownify(str(article))

에러 4: 잘못된 HTML 태그로 인한 빌드 실패

1
ERROR: Invalid first code point of tag name U+D0DC

원인: 마크다운 코드블록의 HTML 예제가 실제 HTML 태그로 렌더링됨

예: <태그>, < 46, < 0.05가 HTML로 해석됨

해결:

1
2
3
# 잘못된 방법 ❌
```html
<script src="bg.js"></script>

올바른 방법 ✅

1
&lt;script src="bg.js"&gt;&lt;/script&gt;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
**에러 5: Tistory 아티팩트 텍스트**

포스트 하단에 다음 텍스트가 자동으로 추가됨:
- 공유하기
- 게시글 관리
- 관성을 이기는 데이터
- 저작자표시 (새창열림)

**해결**:
```python
def remove_tistory_artifacts(content):
    """Tistory 아티팩트 제거"""
    artifacts = [
        r"^\s*공유하기\s*$",
        r"^\s*게시글 관리\s*$",
        r"^\s*관성을 이기는 데이터\s*$",
        r"^\s*저작자표시\s*\(새창열림\)\s*$",
    ]

    for pattern in artifacts:
        content = re.sub(pattern, '', content, flags=re.MULTILINE)

    return content

5.2 검증 스크립트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import yaml

def validate_posts():
    """모든 포스트 검증"""
    posts_dir = Path('_posts')
    valid_count = 0
    error_count = 0

    for post_file in posts_dir.glob('*.md'):
        try:
            with open(post_file, 'r', encoding='utf-8') as f:
                content = f.read()

            # YAML 파싱 (문법 체크)
            if content.startswith('---'):
                parts = content.split('---', 2)
                yaml.safe_load(parts[1])

            # 제어 문자 확인
            if any(ord(ch) < 32 and ch not in '\n\t\r' for ch in content):
                print(f"제어 문자 발견: {post_file.name}")
                error_count += 1
                continue

            valid_count += 1

        except Exception as e:
            print(f"오류: {post_file.name} - {e}")
            error_count += 1

    print(f"\n✅ 유효한 포스트: {valid_count}")
    print(f"❌ 오류: {error_count}")

    return valid_count, error_count

최종 결과

마이그레이션 통계

항목수치
총 포스트112개
총 이미지788개
원본 이미지 크기167.51 MB
압축 후 이미지 크기41.08 MB
크기 감소126.43 MB (75.5% 절약)
포스트 검증 성공률100% (112/112)
카테고리 매핑100% (112/112)

카테고리 분포

카테고리포스트 수비율
Finance (금융)3228.6%
Technology (기술)2320.5%
Web/JavaScript1715.2%
ML/DL (머신러닝)1614.3%
Server/Data/Cloud1311.6%
Quantitative Investment87.1%
Business Analytics32.7%

Git 커밋 히스토리

마이그레이션 전체 과정은 다음과 같은 커밋으로 기록되었습니다:

1
2
3
4
5
6
002797c migrated 112 posts from Tistory
7092b7f Fix HTML code blocks in Web-HTML post
14cf009 Fix all remaining HTML code blocks
06010c6 Fix HTML code blocks - escape all tags
7f974a4 Remove Tistory blog artifacts from all posts
aac9db5 Add categories to all posts

배운 점 및 권장사항

1. 사전 계획의 중요성

마이그레이션 전에 다음을 확인하세요:

  • 포스트 수 및 이미지 수
  • 파일 명명 규칙
  • 카테고리 구조
  • 예상 소요 시간

2. 자동화 스크립트 작성

수동으로 하나씩 처리하는 것은 비현실적입니다. Python으로 자동화 스크립트를 작성하세요.

3. 단계별 검증

각 단계 후 검증을 수행하세요:

  • 포스트 추출 후: 포스트 수 확인
  • 이미지 다운로드 후: 이미지 개수 및 링크 확인
  • 마크다운 변환 후: YAML 문법 검증
  • 최종: 모든 포스트 빌드 테스트

4. 버전 관리 활용

Git을 활용하여 각 단계를 기록하세요:

1
2
3
4
git add -A
git commit -m "Step 1: Extract posts from Tistory"
git commit -m "Step 2: Download and compress images"
git commit -m "Step 3: Convert to Jekyll format"

5. 에러 로깅

모든 오류를 기록하고 분류하세요:

1
2
3
4
5
6
7
8
9
10
11
12
13
import logging

logging.basicConfig(
    filename='migration.log',
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)

try:
    # 작업 수행
    pass
except Exception as e:
    logging.error(f"Error: {e}", exc_info=True)

마이그레이션 스크립트 제공

전체 마이그레이션 프로세스를 위한 통합 스크립트는 다음 저장소에서 확인할 수 있습니다:

1
https://github.com/sw-song/sw-song.github.io

포함된 스크립트:

  • migrate.py: 메인 마이그레이션 스크립트
  • compress_images.py: WebP 압축
  • remove_tistory_artifacts.py: 아티팩트 제거
  • map_post_categories.py: 카테고리 매핑
  • validate_posts.py: 포스트 검증

결론

Tistory에서 GitHub Pages로의 마이그레이션은 초기에는 복잡해 보이지만, 체계적으로 접근하면 성공할 수 있습니다. 주요 포인트는:

  1. 자동화: 수백 개의 포스트는 수동으로 처리 불가능
  2. 검증: 각 단계에서 결과를 확인하고 오류 처리
  3. 문서화: 각 단계와 오류를 기록
  4. 버전 관리: Git으로 진행 상황 추적

이 가이드가 여러분의 블로그 마이그레이션에 도움이 되길 바랍니다!


참고 자료

This post is licensed under CC BY 4.0 by the author.

Trending Tags