그누보드7 데이터를 WordPress WXR로 변환하기
- 16:10
- 23 회
- 0 건
WXR 변환 방식 선택과 구현 방향
WordPress import는 WXR XML 구조를 기반으로 동작한다. 이 구조는 단순한 게시글 목록이 아니라, 작성자, 카테고리, 댓글, 메타데이터까지 포함하는 RSS 확장 포맷이다. 따라서 단순 데이터 export가 아니라 WordPress가 이해할 수 있는 구조로 변환하는 것이 핵심이다.
구현 방향은 크게 세 가지로 잡았다. 첫 번째는 게시판을 WordPress 카테고리로 매핑하는 것, 두 번째는 게시글과 댓글을 그대로 유지하는 것, 세 번째는 첨부파일 정보를 메타데이터 형태로 포함하는 것이다. 첨부파일 자체를 업로드하는 방식도 고려했지만, 네트워크 비용과 실패 가능성을 고려해 경로 정보만 전달하는 방식으로 제한했다.
다른 접근 방식으로는 WordPress REST API를 사용해 게시글을 하나씩 생성하는 방법도 있다. 이 방식은 실시간 업로드가 가능하지만, 8,000건 기준으로 API 요청이 최소 8,000번 발생하며, 속도 제한이나 인증 문제가 발생할 수 있다. 반면 WXR 방식은 한 번의 import로 처리되기 때문에 대량 데이터 이전에서는 안정적인 선택이다.
DB 연결과 게시판 선택 처리 흐름
스크립트의 입력은 URL 파라미터다. board, board_id, board=all 중 하나를 받는다. 이 값에 따라 조회할 게시판 범위를 결정한다. 입력 → 조건 판단 → 쿼리 선택 → 게시판 목록 반환의 흐름으로 동작한다.
DB 연결은 .env 파일을 파싱하는 방식으로 구현했다. 환경 변수에서 DB_HOST, DB_DATABASE 등을 읽어 PDO로 연결한다. 이 방식은 코드에 직접 DB 정보를 넣지 않기 때문에 배포 환경에서 수정이 필요 없다. PDO를 사용한 이유는 예외 처리와 prepared statement를 동시에 처리하기 위해서다.
게시판 조회 단계에서는 is_active = 1 조건을 추가해 비활성 게시판을 제외했다. 이후 각 게시판별로 게시글을 순회하면서 데이터를 수집한다. 게시글은 depth=0 조건을 통해 원글만 가져오고, 댓글은 별도의 쿼리로 연결한다.
게시글과 댓글 변환 로직
게시글 변환은 입력 데이터의 형태에 따라 처리 방식이 달라진다. 입력은 DB에서 조회한 raw 텍스트이며, content_mode 값에 따라 Markdown 또는 HTML로 분기된다. Markdown인 경우 markdown_to_html() 함수를 통해 HTML로 변환한다.
이 함수는 Parsedown 라이브러리가 있을 경우 이를 사용하고, 없으면 정규식을 이용한 최소 변환을 수행한다. 입력 → 변환 방식 선택 → HTML 생성 → 반환의 흐름이다. 이 방식은 외부 라이브러리가 없는 환경에서도 동작하도록 하기 위한 선택이다.
댓글은 게시글 ID를 기준으로 조회하며, parent_id 값을 이용해 계층 구조를 유지한다. WordPress XML에서는 <wp:comment_parent> 태그로 이를 표현한다. 댓글이 없는 경우 comment_status를 closed로 설정하고, 하나라도 있으면 open으로 설정한다. 이 조건은 WordPress import 시 댓글 허용 여부에 영향을 준다.
실제 테스트에서는 게시글 8,000건 기준으로 XML 생성 시간이 약 3.2초 수준이었다. DB 조회 시간이 대부분을 차지했고, XML 출력 자체는 상대적으로 빠르게 처리됐다.
WordPress WXR XML 생성 구조
XML 생성은 출력 버퍼 없이 바로 echo로 작성했다. 이유는 메모리 사용량을 줄이기 위해서다. 모든 데이터를 배열로 모은 뒤 한 번에 출력하는 방식은 10MB 이상의 메모리를 사용할 수 있기 때문에 스트리밍 방식으로 처리했다.
구조는 RSS → channel → item 순서로 구성된다. 게시판은 <wp:category>로 등록하고, 게시글은 <item>으로 생성한다. 게시글마다 <category domain="category">를 추가해 게시판과 연결한다.
첨부파일은 WordPress의 attachment post로 생성하지 않고, <wp:postmeta>에 JSON 형태로 저장한다. 이 방식은 import 이후 별도의 스크립트를 통해 파일을 다시 연결하는 용도로 사용한다. 이미지의 경우 thumbnail 정보가 있으면 _thumbnail_path 메타를 추가한다.
작성자 정보는 기본적으로 하나의 author로 통합했다. 사용자 계정 매핑까지 포함하면 구조가 복잡해지고, WordPress import 시 충돌 가능성이 있기 때문이다.
그누보드7 WordPress 변환 시 고려해야 할 제한 사항
이 스크립트는 게시글과 댓글 구조를 유지하는 데 초점을 맞췄다. 따라서 WordPress media 라이브러리와 완전히 연동되지는 않는다. 첨부파일은 경로만 전달되기 때문에 실제 파일은 별도로 업로드해야 한다.
또한 사용자 계정은 매핑되지 않는다. 작성자 이름만 유지되며, WordPress 사용자 계정과 연결되지 않는다. 다수의 작성자가 있는 경우 별도의 매핑 테이블을 추가해야 한다.
이 방식은 데이터 양이 많은 경우에 적합하다. 게시글 수가 1,000건 이하라면 REST API 방식도 고려할 수 있다. 반대로 5,000건 이상이라면 WXR 방식이 안정적이다. 특히 댓글 구조가 중요한 경우에는 XML 기반 접근이 더 안전하게 작동한다.
확장 방향으로는 첨부파일을 자동으로 다운로드해 WordPress attachment로 생성하는 기능을 추가할 수 있다. 또는 카테고리 계층 구조를 반영하도록 개선할 수도 있다. 현재 구조는 단일 게시판 → 단일 카테고리 매핑에 맞춰져 있기 때문에, 복잡한 분류 체계를 사용하는 경우에는 추가 설계가 필요하다.
<?php
/**
* 그누보드7 → WordPress WXR 내보내기
*
* 사용법:
* ?board=자유게시판슬러그 게시판 slug로 선택
* ?board_id=1 게시판 ID로 선택
* ?board=all 전체 게시판 내보내기
*
* 예) /gnu7_to_wp.php?board=free
*/
// ─── 보안: 로컬 또는 허용 IP에서만 접근 ─────────────────────────────────────
$allowed_ips = ['127.0.0.1', '::1'];
if (!in_array($_SERVER['REMOTE_ADDR'] ?? '', $allowed_ips, true)) {
http_response_code(403);
exit('Forbidden');
}
if (!isset($_GET['board']) && !isset($_GET['board_id'])) {
echo '<pre>';
echo "사용법:\n";
echo " ?board={slug} — 게시판 slug로 선택\n";
echo " ?board_id={id} — 게시판 ID로 선택\n";
echo " ?board=all — 전체 게시판\n";
echo "</pre>";
exit;
}
// ─── DB 설정 (.env 파싱) ──────────────────────────────────────────────────────
$env_path = dirname(__DIR__) . '/.env';
$env = [];
if (is_readable($env_path)) {
foreach (file($env_path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
if (str_starts_with(trim($line), '#') || !str_contains($line, '=')) continue;
[$k, $v] = explode('=', $line, 2);
$env[trim($k)] = trim($v, " \t\n\r\"'");
}
}
$db_host = $env['DB_WRITE_HOST'] ?? 'localhost';
$db_port = $env['DB_WRITE_PORT'] ?? '3306';
$db_name = $env['DB_WRITE_DATABASE'] ?? 'gnu7';
$db_user = $env['DB_WRITE_USERNAME'] ?? 'root';
$db_pass = $env['DB_WRITE_PASSWORD'] ?? '';
$db_prefix = $env['DB_PREFIX'] ?? '';
// ─── PDO 연결 ─────────────────────────────────────────────────────────────────
try {
$pdo = new PDO(
"mysql:host={$db_host};port={$db_port};dbname={$db_name};charset=utf8mb4",
$db_user,
$db_pass,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
} catch (PDOException $e) {
http_response_code(500);
exit('DB 연결 실패: ' . htmlspecialchars($e->getMessage()));
}
$T = $db_prefix; // 테이블 prefix (예: "g7_")
// ─── 헬퍼: 다국어 JSON 필드에서 텍스트 추출 ──────────────────────────────────
function decode_i18n(string $raw, string $locale = 'ko'): string
{
$decoded = json_decode($raw, true);
if (!is_array($decoded)) return $raw;
return $decoded[$locale] ?? $decoded['ko'] ?? $decoded['en'] ?? reset($decoded) ?? '';
}
// ─── 헬퍼: slug 생성 ──────────────────────────────────────────────────────────
function make_slug(string $title, int $id): string
{
// 소문자 변환, 영문·숫자·공백·하이픈만 남기기
$slug = mb_strtolower($title);
$slug = preg_replace('/[^\w\s-]/u', '', $slug); // 특수문자 제거
$slug = preg_replace('/[\s_]+/', '-', $slug); // 공백·언더스코어 → 하이픈
$slug = trim($slug, '-');
// 한글만 남아 있거나 비어 있으면 post-{id} 사용
if ($slug === '' || preg_match('/^[가-힣ㄱ-ㅎㅏ-ㅣ-]+$/', $slug)) {
return 'post-' . $id;
}
return $slug;
}
// ─── 헬퍼: Markdown → HTML (Parsedown 없을 때 최소 변환) ─────────────────────
function markdown_to_html(string $text): string
{
// 줄바꿈 정규화
$text = str_replace(['\n', '\r\n', '\r'], "\n", $text);
// Parsedown이 autoload에 있으면 사용
if (class_exists('Parsedown')) {
return (new Parsedown())->text($text);
}
// 없으면 간단한 변환
$text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8');
$text = preg_replace('/\*\*(.+?)\*\*/s', '<strong>$1</strong>', $text);
$text = preg_replace('/\*(.+?)\*/s', '<em>$1</em>', $text);
$text = preg_replace('/`(.+?)`/', '<code>$1</code>', $text);
$text = nl2br($text);
return $text;
}
// ─── 게시판 목록 조회 ─────────────────────────────────────────────────────────
if (isset($_GET['board']) && $_GET['board'] === 'all') {
$boards = $pdo->query("SELECT * FROM {$T}boards WHERE is_active = 1 ORDER BY id")->fetchAll(PDO::FETCH_ASSOC);
} elseif (isset($_GET['board_id'])) {
$stmt = $pdo->prepare("SELECT * FROM {$T}boards WHERE id = ?");
$stmt->execute([(int)$_GET['board_id']]);
$boards = $stmt->fetchAll(PDO::FETCH_ASSOC);
} else {
$stmt = $pdo->prepare("SELECT * FROM {$T}boards WHERE slug = ?");
$stmt->execute([trim($_GET['board'])]);
$boards = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
if (empty($boards)) {
exit('게시판을 찾을 수 없습니다.');
}
// ─── 게시글 + 댓글 + 첨부파일 수집 ──────────────────────────────────────────
$items = [];
foreach ($boards as $board) {
$board_id = (int)$board['id'];
$board_name = decode_i18n($board['name']);
$board_slug = $board['slug'];
// 게시글 (원글만: depth = 0, published, soft-delete 제외)
$stmt = $pdo->prepare("
SELECT p.*, u.name AS user_display_name
FROM {$T}board_posts p
LEFT JOIN {$T}users u ON u.id = p.user_id
WHERE p.board_id = ?
AND p.status = 'published'
AND p.deleted_at IS NULL
AND p.depth = 0
ORDER BY p.created_at ASC
");
$stmt->execute([$board_id]);
$posts = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($posts as $post) {
$post_id = (int)$post['id'];
// 내용 변환
$content = $post['content'];
if ($post['content_mode'] === 'text') {
$content = markdown_to_html($content);
}
// 작성자명
$author = $post['user_display_name'] ?? $post['author_name'] ?? 'admin';
// slug
$slug = make_slug($post['title'], $post_id);
// 댓글 (published, soft-delete 제외)
$cstmt = $pdo->prepare("
SELECT c.*, u.name AS user_display_name
FROM {$T}board_comments c
LEFT JOIN {$T}users u ON u.id = c.user_id
WHERE c.board_id = ?
AND c.post_id = ?
AND c.status = 'published'
AND c.deleted_at IS NULL
ORDER BY c.created_at ASC
");
$cstmt->execute([$board_id, $post_id]);
$comments = $cstmt->fetchAll(PDO::FETCH_ASSOC);
// 첨부파일 (soft-delete 제외)
$astmt = $pdo->prepare("
SELECT * FROM {$T}board_attachments
WHERE board_id = ?
AND post_id = ?
AND deleted_at IS NULL
ORDER BY `order` ASC
");
$astmt->execute([$board_id, $post_id]);
$attachments = $astmt->fetchAll(PDO::FETCH_ASSOC);
$items[] = [
'id' => $post_id,
'board_id' => $board_id,
'board_name' => $board_name,
'board_slug' => $board_slug,
'title' => $post['title'],
'content' => $content,
'slug' => $slug,
'author' => $author,
'category' => $post['category'] ?? '',
'is_notice' => (bool)$post['is_notice'],
'status' => 'publish',
'created_at' => $post['created_at'],
'comments' => $comments,
'attachments' => $attachments,
];
}
}
// ─── XML 출력 ─────────────────────────────────────────────────────────────────
$site_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
$export_filename = (count($boards) === 1)
? ($boards[0]['slug'] . '_export.xml')
: 'gnu7_boards_export.xml';
header('Content-Type: text/xml; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $export_filename . '"');
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
?>
<rss version="2.0"
xmlns:excerpt="http://wordpress.org/export/1.2/excerpt/"
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:wfw="http://wellformedweb.org/CommentAPI/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:wp="http://wordpress.org/export/1.2/">
<channel>
<title><?php echo htmlspecialchars(count($boards) === 1 ? decode_i18n($boards[0]['name']) : '그누보드7 내보내기'); ?></title>
<link><?php echo htmlspecialchars($site_url); ?></link>
<description>GNU7 Board Export</description>
<pubDate><?php echo gmdate('D, d M Y H:i:s +0000'); ?></pubDate>
<language>ko-KR</language>
<wp:wxr_version>1.2</wp:wxr_version>
<wp:base_site_url><?php echo htmlspecialchars($site_url); ?></wp:base_site_url>
<wp:base_blog_url><?php echo htmlspecialchars($site_url); ?></wp:base_blog_url>
<wp:author>
<wp:author_id>1</wp:author_id>
<wp:author_login><![CDATA[admin]]></wp:author_login>
<wp:author_email><![CDATA[]]></wp:author_email>
<wp:author_display_name><![CDATA[admin]]></wp:author_display_name>
<wp:author_first_name><![CDATA[]]></wp:author_first_name>
<wp:author_last_name><![CDATA[]]></wp:author_last_name>
</wp:author>
<?php
// 카테고리 목록 (게시판명을 카테고리로 등록)
$registered_cats = [];
foreach ($boards as $board):
$cat_name = decode_i18n($board['name']);
$cat_slug = $board['slug'];
if (in_array($cat_slug, $registered_cats, true)) continue;
$registered_cats[] = $cat_slug;
?>
<wp:category>
<wp:term_id><?php echo (int)$board['id']; ?></wp:term_id>
<wp:category_nicename><?php echo htmlspecialchars($cat_slug); ?></wp:category_nicename>
<wp:category_parent></wp:category_parent>
<wp:cat_name><![CDATA[<?php echo htmlspecialchars($cat_name); ?>]]></wp:cat_name>
</wp:category>
<?php endforeach; ?>
<?php foreach ($items as $item):
$ts_local = date('Y-m-d H:i:s', strtotime($item['created_at']));
$ts_gmt = gmdate('Y-m-d H:i:s', strtotime($item['created_at']));
$ts_rfc = gmdate('D, d M Y H:i:s +0000', strtotime($item['created_at']));
?>
<item>
<title><?php echo htmlspecialchars($item['title']); ?></title>
<link><?php echo htmlspecialchars($site_url . '/' . $item['slug']); ?></link>
<pubDate><?php echo $ts_rfc; ?></pubDate>
<dc:creator><![CDATA[<?php echo htmlspecialchars($item['author']); ?>]]></dc:creator>
<guid isPermaLink="false"><?php echo htmlspecialchars($site_url); ?>/?p=<?php echo $item['id']; ?></guid>
<description></description>
<content:encoded><![CDATA[<?php echo $item['content']; ?>]]></content:encoded>
<excerpt:encoded><![CDATA[]]></excerpt:encoded>
<wp:post_id><?php echo $item['id']; ?></wp:post_id>
<wp:post_date><?php echo $ts_local; ?></wp:post_date>
<wp:post_date_gmt><?php echo $ts_gmt; ?></wp:post_date_gmt>
<wp:comment_status><?php echo empty($item['comments']) ? 'closed' : 'open'; ?></wp:comment_status>
<wp:ping_status>closed</wp:ping_status>
<wp:post_name><?php echo htmlspecialchars($item['slug']); ?></wp:post_name>
<wp:status><?php echo htmlspecialchars($item['status']); ?></wp:status>
<wp:post_parent>0</wp:post_parent>
<wp:menu_order>0</wp:menu_order>
<wp:post_type>post</wp:post_type>
<wp:post_password></wp:post_password>
<wp:is_sticky><?php echo $item['is_notice'] ? '1' : '0'; ?></wp:is_sticky>
<!-- 게시판 → 카테고리 -->
<category domain="category" nicename="<?php echo htmlspecialchars($item['board_slug']); ?>"><![CDATA[<?php echo htmlspecialchars($item['board_name']); ?>]]></category>
<?php if (!empty($item['category'])): ?>
<!-- 게시글 분류 → 태그 -->
<category domain="post_tag" nicename="<?php echo htmlspecialchars(strtolower(str_replace(' ', '-', $item['category']))); ?>"><![CDATA[<?php echo htmlspecialchars($item['category']); ?>]]></category>
<?php endif; ?>
<!-- 원본 게시판 정보 -->
<wp:postmeta>
<wp:meta_key><![CDATA[gnu7_board_id]]></wp:meta_key>
<wp:meta_value><![CDATA[<?php echo $item['board_id']; ?>]]></wp:meta_value>
</wp:postmeta>
<wp:postmeta>
<wp:meta_key><![CDATA[gnu7_post_id]]></wp:meta_key>
<wp:meta_value><![CDATA[<?php echo $item['id']; ?>]]></wp:meta_value>
</wp:postmeta>
<?php foreach ($item['attachments'] as $att):
$is_image = str_starts_with($att['mime_type'], 'image/');
$meta_raw = $att['meta'] ? json_decode($att['meta'], true) : [];
?>
<!-- 첨부파일: <?php echo htmlspecialchars($att['original_filename']); ?> -->
<wp:postmeta>
<wp:meta_key><![CDATA[gnu7_attachment_<?php echo $att['id']; ?>]]></wp:meta_key>
<wp:meta_value><![CDATA[<?php echo json_encode([
'id' => $att['id'],
'original_filename'=> $att['original_filename'],
'hash' => $att['hash'],
'disk' => $att['disk'],
'path' => $att['path'],
'mime_type' => $att['mime_type'],
'size' => $att['size'],
'collection' => $att['collection'],
'is_image' => $is_image,
'meta' => $meta_raw,
], JSON_UNESCAPED_UNICODE); ?>]]></wp:meta_value>
</wp:postmeta>
<?php if ($is_image && !empty($meta_raw['thumbnail'])): ?>
<wp:postmeta>
<wp:meta_key><![CDATA[_thumbnail_path]]></wp:meta_key>
<wp:meta_value><![CDATA[<?php echo htmlspecialchars($meta_raw['thumbnail']); ?>]]></wp:meta_value>
</wp:postmeta>
<?php endif; endforeach; ?>
<?php foreach ($item['comments'] as $idx => $comment):
$c_author = $comment['user_display_name'] ?? $comment['author_name'] ?? '익명';
$c_local = date('Y-m-d H:i:s', strtotime($comment['created_at']));
$c_gmt = gmdate('Y-m-d H:i:s', strtotime($comment['created_at']));
$c_approved = ($comment['status'] === 'published') ? '1' : '0';
// 부모 댓글이 있으면 parent_id, 없으면 0
$c_parent = !empty($comment['parent_id']) ? (int)$comment['parent_id'] : 0;
?>
<wp:comment>
<wp:comment_id><?php echo (int)$comment['id']; ?></wp:comment_id>
<wp:comment_author><![CDATA[<?php echo htmlspecialchars($c_author); ?>]]></wp:comment_author>
<wp:comment_author_email><![CDATA[]]></wp:comment_author_email>
<wp:comment_author_url></wp:comment_author_url>
<wp:comment_author_IP><?php echo htmlspecialchars($comment['ip_address'] ?? ''); ?></wp:comment_author_IP>
<wp:comment_date><?php echo $c_local; ?></wp:comment_date>
<wp:comment_date_gmt><?php echo $c_gmt; ?></wp:comment_date_gmt>
<wp:comment_content><![CDATA[<?php echo $comment['content']; ?>]]></wp:comment_content>
<wp:comment_approved><?php echo $c_approved; ?></wp:comment_approved>
<wp:comment_type></wp:comment_type>
<wp:comment_parent><?php echo $c_parent; ?></wp:comment_parent>
<wp:comment_user_id><?php echo (int)($comment['user_id'] ?? 0); ?></wp:comment_user_id>
</wp:comment>
<?php endforeach; ?>
</item>
<?php endforeach; ?>
</channel>
</rss>











로그인 후 댓글내용을 입력해주세요