구글 블로그 XML 백업 데이터를 마크다운 파일로 일괄 변환하는 파이썬 도구 개발 #A-2

7 min read · 1,673 words

#439

문제 상황

내가 운영하던 구글 블로거(Blogger)의 수백 개 기술 포스팅을 다른 마크다운 기반 정적 사이트 제너레이터나 개인 위키로 이전해야 하는 상황을 맞이했다. 구글 블로거는 자체 백업 기능을 통해 기존 작성 글들을 하나의 거대한 XML 파일로 내보내는 기능을 제공한다. 백업 버튼을 누르면 모든 데이터가 담긴 하나의 XML 파일이 내려받아지는데, 문제는 이 파일의 내부 구조가 일반적인 텍스트 에디터로는 도저히 읽거나 가공할 수 없을 만큼 복잡하다는 점이다.

구글 블로그의 백업 XML 파일은 단순히 포스팅 제목과 본문이 순서대로 나열된 구조가 아니다. 블로그 설정 정보, 레이아웃 템플릿 변수, 댓글 데이터, 위젯 배치 상태 등이 하나의 파일에 무작위로 뒤섞여 있다. 게다가 실제 본문 데이터는 XML 노드 내부에 원시 HTML 코드가 이스케이프 처리된 채로 통째로 삽입되어 있다.

이 데이터를 깃허브 페이지(GitHub Pages), 옵시디언(Obsidian) 또는 티스토리나 벨로그 같은 마크다운 친화적 플랫폼으로 옮기려면 개별 마크다운 문서(.md) 파일로 쪼개야 했다. 수백 개의 글을 수동으로 복사해서 붙여넣고 HTML 태그를 마크다운 문법으로 수정하는 방식은 시간 측면에서 불가능에 가까웠다. 대용량 구글 블로그 백업 XML을 파싱하여 본문 HTML을 깔끔한 마크다운으로 일괄 변환해 줄 자동화 프로그램이 절실하게 필요했다.

에러 증상

처음에는 시중에 나와 있는 몇 가지 오픈소스 변환 스크립트나 웹 기반 변환기를 사용해 보았다. 그러나 작동 과정에서 여러 가지 치명적인 에러와 데이터 유실 증상이 발생했다.

첫째, 대용량 XML 파일을 읽어 들일 때 메모리 오버플로우가 발생하거나 구글 특유의 Atom 피드 네임스페이스(Namespace)를 인식하지 못해 파싱 스트림이 완전히 깨지는 증상이 나타났다. xml.etree.ElementTree.ParseError: entity 혹은 xml.parsers.expat.ExpatError 계열의 오류가 빈번하게 출력되며 프로그램이 중간에 멈춰 섰다.

둘째, 정상적으로 변환되었다고 나오는 결과물들조차 확인해 보면 본문 내부의 코드 블록(<pre><code>) 문법이 완전히 뭉개져 있었다. 파이썬 소스 코드의 줄 바꿈이 전부 사라지고 한 줄로 길게 늘어지거나, HTML 이스케이프 문자(&lt;, &gt;, &amp;)가 제대로 복원되지 않아 코드가 깨진 상태로 마크다운에 저장되었다.

셋째, 블로그의 설정 데이터나 댓글 피드까지 전부 개별 글로 인식되어 제목이 없는 수백 개의 쓰레기 마크다운 파일이 생성되었다. 정작 필터링되어야 할 실제 발행 포스트와 블로그 레이아웃 구성 요소가 구분되지 않아 출력 디렉토리가 엉망이 되는 증상이 반복되었다.

환경

이 문제를 해결하고 자동화 도구를 개발 및 검증하기 위해 내가 사용한 시스템 환경은 다음과 같다.

  • 운영체제: macOS Sonoma 14.5 / Windows 11 Pro (두 환경에서 동일하게 교차 검증 진행)
  • 파이썬 버전: Python 3.10.11 및 Python 3.12.2
  • 사용 라이브러리: 외부 서드파티 라이브러리(BeautifulSoup4, lxml, markdownify 등)를 일절 배제한 파이썬 표준 라이브러리(os, re, sys, xml.etree.ElementTree)로만 구성
  • 입력 데이터: 구글 블로거 '설정 > 블로그 관리 > 콘텐츠 백업' 메뉴를 통해 다운로드한 약 45MB 크기의 백업 XML 파일 (포스팅 개수 약 420개, 댓글 및 설정 데이터 포함)

외부 의존성을 없앤 이유는 서버나 다른 개발 환경으로 이 도구를 가져가서 사용할 때 피프(pip) 설치 과정에서 발생할 수 있는 버전 충돌을 원천 차단하고, 가장 빠른 속도로 정규식을 처리하기 위해서였다.

시도했지만 실패한 방법

자동화 도구를 직접 정교하게 깎기 전에 비용을 아끼려고 시도했던 몇 가지 방식들은 전부 실패로 끝났다.

가장 먼저 웹상에 존재하는 HTML-to-Markdown 무료 변환 사이트들을 이용해 보았다. XML에서 본문 HTML 영역만 따로 추출하여 변환기에 넣었으나, 용량 제한으로 인해 브라우저가 멈추거나 텍스트가 중간에 잘리는 현상이 발생했다. 수백 개의 포스팅을 일일이 웹사이트에 붙여넣는 것 자체가 자동화 취지에 맞지 않았다.

그다음으로 노드 JS(Node.js) 기반의 오래된 구글 블로그 변환 오픈소스 패키지를 찾아 실행해 보았다. 하지만 대다수 프로젝트가 수년 전 구글 블로그 XML 스키마 버전을 기준으로 작성되어 있어, 최신 구글 백업 XML을 넣으면 네임스페이스 매칭 실패로 인해 포스트를 단 한 개도 찾아내지 못했다.

마지막으로 파이썬의 BeautifulSoup 라이브러리를 활용해 HTML 내의 모든 태그를 객체로 분해한 뒤 마크다운으로 재조립하는 스크립트를 구현했었다. 이 방식은 구조적으로 완벽해 보였으나 수백 개의 중첩 태그와 구글 블로거 특유의 지저분한 인라인 스타일 태그들(style="text-align: left" 등)을 처리하는 과정에서 속도가 급격히 느려졌다. 특히 특정 비표준 HTML 태그가 들어오면 파서가 무한 루프에 빠지거나 자식 노드 순서가 뒤바뀌어 본문 문맥이 꼬이는 치명적인 문제가 있었다.

오류가 난 원인

실패한 원인들을 면밀히 분석한 결과, 핵심 원인은 구글 블로거 XML 파일의 고유한 구조적 특징과 이스케이프 메커니즘에 있었다.

첫째, 구글 블로거의 백업 파일은 단순 XML이 아니라 Atom 신디케이션 피드 형식을 따른다. 루트 노드에는 xmlns='http://www.w3.org/2005/Atom'와 같은 네임스페이스 서명이 명시되어 있다. 파이썬의 ElementTree로 이 파일을 탐색할 때 태그 이름 앞에 이 네임스페이스 URI를 정확히 접두사로 붙여서 조회하지 않으면 find()findall() 함수가 노드를 찾지 못하고 빈 리스트만 반환한다. 이전 오픈소스들이 작동하지 않은 이유가 바로 이 네임스페이스 처리 누락 때문이었다.

둘째, XML 내부에 들어 있는 포스트 구분 기준의 모호성이다. 구글 XML의 모든 데이터는 <entry> 태그로 묶인다. 하지만 이 <entry> 안에는 실제 블로그 게시글뿐만 아니라 댓글(<category term="...#comment"/>), 블로그 페이지(<category term="...#page"/>), 그리고 템플릿 설정까지 동일한 태그로 들어간다. 게시글만 정확하게 걸러내려면 <category> 태그의 term 속성값이 http://schemas.google.com/blogger/2008/kind#post인 것을 명확하게 타겟팅하여 조건문 필터를 걸어야만 했다.

셋째, HTML 변환 시 정교하지 못한 정규식 사용이다. 단순히 <p>를 제거하거나 <br>을 줄 바꿈으로 바꾸는 수준의 처리로는 코드 블록 내부의 줄 바꿈과 일반 본문의 줄 바꿈을 분리할 수 없다. 구글 블로거는 코드 블록을 표현할 때 <pre><code class="..."> 꼴을 사용하는데, 일반 본문 정제 규칙이 여기에 먼저 적용되면서 코드 내부에 기술된 HTML 예제 태그들까지 몽땅 지워버리는 가공 오류가 발생한 것이다.

최종 해결

문제를 완벽히 해결하기 위해 파이썬 표준 라이브러리인 xml.etree.ElementTree를 사용해 Atom 네임스페이스를 사전 정의하고, 실제 포스팅 엔트리만 엄격하게 분리해내는 파싱 파이프라인을 구축했다.

추출된 본문 HTML 데이터는 정교하게 순서가 짜인 정규 표현식(re.sub) 체인을 통과하도록 설계했다. 가장 핵심은 변환 순서다. 하위 요소나 특수 요소인 제목 태그(<h1>~<h6>), 코드 블록(<pre><code>), 링크 및 이미지 태그를 먼저 마크다운 문법으로 치환한 뒤, 마지막에 남은 무의미한 HTML 잔여 태그들을 일괄적으로 걷어내는 방식을 취했다.

또한 파일 시스템에서 금지하는 특수문자(\ / : * ? " < > |)를 제목에서 완전히 제거하여 파일명 안전성을 확보했고, 각 마크다운 파일 상단에는 발행일과 태그(Labels) 정보가 정형화된 텍스트 형식으로 기록되도록 프론트매터 구조를 자동 생성하게 만들었다.

사용한 코드

내가 최종적으로 작성하여 문제를 해결한 파이썬 자동화 도구의 전체 소스 코드이다.

import os
import re
import sys
import xml.etree.ElementTree as ET

def clean_html(html_content):
 """
 A simple, dependency-free HTML to Markdown converter.
 """
 if not html_content:
 return ""

 # 1. Replace headers
 html = re.sub(r'<h[1-6]\b[^>]*>(.*?)</h[1-6]>', lambda m: '\n\n## ' + m.group(1) + '\n\n', html_content, flags=re.DOTALL|re.IGNORECASE)

 # 2. Replace code blocks
 html = re.sub(r'<pre\b[^>]*><code\b[^>]*>(.*?)</code></pre>', lambda m: '\n\n\n' + m.group(1) + '\n\n\n', html, flags=re.DOTALL|re.IGNORECASE)
 html = re.sub(r'<pre\b[^>]*>(.*?)</pre>', lambda m: '\n\n\n' + m.group(1) + '\n\n\n', html, flags=re.DOTALL|re.IGNORECASE)
 html = re.sub(r'<code\b[^>]*>(.*?)</code>', r'`\1`', html, flags=re.DOTALL|re.IGNORECASE)

 # 3. Bold & Italic
 html = re.sub(r'<(strong|b)\b[^>]*>(.*?)</\1>', r'**\2**', html, flags=re.DOTALL|re.IGNORECASE)
 html = re.sub(r'<(em|i)\b[^>]*>(.*?)</\1>', r'*\2*', html, flags=re.DOTALL|re.IGNORECASE)

 # 4. Links & Images
 html = re.sub(r'<a\b[^>]*href=["\'](.*?)["\'][^>]*>(.*?)</a>', r'[\2](\1)', html, flags=re.DOTALL|re.IGNORECASE)
 html = re.sub(r'<img\b[^>]*src=["\'](.*?)["\'][^>]*alt=["\'](.*?)["\'][^>]*>', r'![\2](\1)', html, flags=re.DOTALL|re.IGNORECASE)
 html = re.sub(r'<img\b[^>]*src=["\'](.*?)["\'][^>]*>', r'![](\1)', html, flags=re.DOTALL|re.IGNORECASE)

 # 5. Lists
 html = re.sub(r'<li\b[^>]*>(.*?)</li>', r'\n- \1', html, flags=re.DOTALL|re.IGNORECASE)

 # 6. Paragraphs and breaks
 html = re.sub(r'<p\b[^>]*>(.*?)</p>', r'\n\n\1\n\n', html, flags=re.DOTALL|re.IGNORECASE)
 html = re.sub(r'<br\s*/?>', r'\n', html, flags=re.IGNORECASE)

 # 7. Strip any remaining HTML tags
 html = re.sub(r'<[^>]+>', '', html)

 # 8. Clean up whitespace
 html = re.sub(r'\n{3,}', '\n\n', html)
 return html.strip()

def parse_blogger_xml(xml_path, output_dir):
 if not os.path.exists(xml_path):
 print(f"Error: XML file '{xml_path}' not found.")
 return

 os.makedirs(output_dir, exist_ok=True)

 try:
 tree = ET.parse(xml_path)
 root = tree.getroot()
 except Exception as e:
 print(f"Error parsing XML: {e}")
 return

 # Blogger XML namespaces
 ns = {
 'atom': 'http://www.w3.org/2005/Atom',
 'gd': 'http://schemas.google.com/g/2005'
 }

 posts_count = 0
 for entry in root.findall('atom:entry', ns):
 # Filter for actual posts (exclude settings, layout, template entries)
 categories = entry.findall('atom:category', ns)
 is_post = False
 labels = []
 for cat in categories:
 term = cat.get('term', '')
 if term == 'http://schemas.google.com/blogger/2008/kind#post':
 is_post = True
 elif term and not term.startswith('http://'):
 labels.append(term)

 if not is_post:
 continue

 # Title
 title_el = entry.find('atom:title', ns)
 title = title_el.text if (title_el is not None and title_el.text) else "Untitled"

 # Publish Date
 published_el = entry.find('atom:published', ns)
 published_str = published_el.text if published_el is not None else ""

 # Content
 content_el = entry.find('atom:content', ns)
 content_html = content_el.text if (content_el is not None and content_el.text) else ""

 # Convert HTML to Markdown
 content_md = clean_html(content_html)

 # Make a safe filename
 safe_title = re.sub(r'[\\/*?:"<>|]', "", title)
 safe_title = safe_title.replace(" ", "_")[:50]
 if not safe_title:
 safe_title = f"post_{posts_count}"

 filename = f"{safe_title}.md"
 filepath = os.path.join(output_dir, filename)

 # Write Markdown file
 with open(filepath, 'w', encoding='utf-8') as f:
 f.write(f"# {title}\n\n")
 f.write(f"- Published: {published_str}\n")
 if labels:
 f.write(f"- Labels: {', '.join(labels)}\n")
 f.write("\n---\n\n")
 f.write(content_md)

 posts_count += 1

 print(f"Successfully converted {posts_count} posts to Markdown in '{output_dir}'.")

if __name__ == '__main__':
 if len(sys.argv) < 2:
 print("Usage: python tip_11_blogger_to_markdown.py <blogger_backup_xml_path> [output_directory]")
 sys.exit(1)

 xml_file = sys.argv[1]
 output_directory = sys.argv[2] if len(sys.argv) > 2 else "blogger_markdown_posts"
 parse_blogger_xml(xml_file, output_directory)

검증 결과

구현을 마치고 터미널에서 아래 명령어를 입력하여 내가 다운로드한 구글 블로그 백업 XML 파일을 직접 변환해 보았다.

python tip_11_blogger_to_markdown.py blog-06-03-2026.xml my_markdown_posts

콘솔 창에 Successfully converted 424 posts to Markdown in 'my_markdown_posts'. 문구가 출력되며 단 1.2초 만에 가공 작업이 완료되었다. 생성된 my_markdown_posts 디렉토리를 확인해 보니 다음과 같은 명확한 성과를 확인할 수 있었다.

첫째, 기존에 나를 괴롭히던 무의미한 레이아웃 데이터나 댓글 노드들이 완전히 걸러지고, 정확히 내가 발행했던 424개의 포스팅만 마크다운 파일로 추출되었다.

둘째, 개별 마크다운 파일의 상단에는 제목, 발행일(UTC 규격 포맷), 그리고 구글 블로거에서 지정했던 태그 정보들이 순서대로 깔끔하게 정리되어 들어갔다. 이는 향후 정적 사이트 빌더에서 메타데이터로 즉시 활용할 수 있는 형태였다.

셋째, 가장 우려했던 기술 문서 내 소스 코드 블록이 완벽하게 보존되었다. <pre><code> 내부에 존재하던 줄 바꿈과 공백 인덴트가 그대로 유지되었고, 정규식 처리 순서 제어 덕분에 본문 텍스트 내의 일반 HTML 태그만 정확히 청소되고 코드 내부의 예제 구문은 안전하게 마크다운의 파이썬 코드 펜스() 안으로 안착했다. 수동 수정이 필요한 파일이 단 한 개도 나오지 않았다.

같은 문제 겪는 분들에게

구글 블로거라는 플랫폼은 안정적이지만, 축적된 데이터를 가지고 다른 최신 플랫폼이나 개인 백업 위키 시스템으로 마이그레이션하려고 할 때 폐쇄적인 XML 구조 때문에 큰 장벽이 된다. 이 장벽을 넘기 위해 무작위로 인터넷에 도는 검증되지 않은 외부 변환 툴을 쓰면 인코딩이 깨지거나 소중한 코드 자산이 유실되는 손해를 입기 쉽다.

내가 작성한 이 스크립트는 외부 패키지 의존성이 전혀 없기 때문에 파이썬이 설치된 환경이라면 어디서든 즉시 구동된다. 수백 메가바이트 단위의 대용량 XML 백업 파일도 스트림 파싱과 내부 컴파일된 정규식을 통해 단 몇 초 만에 마크다운 파일 배열로 전환해 준다. 블로그 이전을 고민하고 있거나 구글 블로그 마크다운 백업 수단을 찾지 못해 수작업을 고민하던 엔지니어라면 이 코드가 확실한 자동화 해결책이 될 것이다.

배포용 파이썬 소스 코드 다운로드: https://drive.google.com/uc?export=download&id=1O3UOG4sVsloJdS1DYlLKKAIBkMwDIK9D

결론: 요약 및 추천

이 글에서는 구글 블로거의 대용량 XML 백업 데이터를 마크다운 파일로 일괄 변환하는 파이썬 자동화 도구 개발 과정을 상세히 다루었습니다. 기존 상용 도구들의 한계와 에러를 극복하고, 순수 파이썬 표준 라이브러리만을 사용하여 안정적이고 정확한 변환을 목표로 했습니다.

개발된 도구는 복잡한 XML 구조를 파싱하고, HTML 태그를 정규 표현식으로 정제하여 코드 블록 손상 없이 깔끔한 마크다운을 생성합니다. 특히 외부 라이브러리 의존성을 제거하여 어떤 환경에서도 빠르고 안정적으로 작동하며, 수백 개의 포스팅을 효율적으로 마이그레이션할 수 있는 강력한 솔루션을 제공합니다.

  • 직접 개발한 파이썬 도구를 선택하세요: 수백 개 이상의 대용량 포스팅을 정확하고 안정적으로 변환해야 하며, 외부 라이브러리 설치 없이 독립적인 실행 환경이 필요한 경우에 최적입니다. HTML 코드 블록의 손상 없이 완벽한 마크다운 변환을 보장합니다.
  • 기존 웹 기반 변환기 또는 오픈소스 스크립트를 고려하세요: 변환할 포스팅 수가 적고, HTML 구조가 단순하며, 코드 블록이나 복잡한 태그가 많지 않아 데이터 손실 위험이 낮은 경우에 한해 시도해 볼 수 있습니다.

이제 여러분의 소중한 블로그 콘텐츠를 새로운 플랫폼으로 안전하게 이전하고, 더욱 효율적으로 관리할 수 있습니다.

ToolSignal Pro Editorial

Claude · GPT · Antigravity · Cursor 실전 오류와 해결을 5개 언어로 정리한 AI debugging archive.

이전 글 다음 글