계층형 게시판 만들기
계층형 게시판이란?
일반 게시판은 글이 시간 순으로 나열됩니다.
계층형 게시판은 특정 글에 답변을 달면 원글 바로 아래에 들여쓰기로 답변이 표시됩니다.
1번 글 — 질문합니다
└ 2번 글 — Re: 질문합니다 (답변)
└ 4번 글 — Re: Re: 질문합니다 (답변의 답변)
3번 글 — 다른 질문
└ 5번 글 — Re: 다른 질문 (답변)
이 구조를 구현하기 위해 thread와 depth 두 컬럼을 씁니다.
핵심 개념: thread와 depth
thread — 같은 글타래(원글 + 모든 답글)임을 나타내는 값입니다.
depth — 들여쓰기 깊이입니다. 원글은 0, 첫 번째 답글은 1, 답글의 답글은 2...
목록은 thread DESC, thread_order ASC 순으로 정렬합니다.
같은 thread 안에서는 최신 답변이 원글 바로 아래에 오도록 정렬합니다.
thread 값 배정 방법
새 글을 쓸 때: 현재 가장 큰 thread 값보다 1000 큰 값을 부여합니다.
(1000씩 간격을 두는 이유: 나중에 답글이 그 사이에 끼어들 자리를 미리 확보하기 위해)
답글을 달 때: 원글의 thread보다 1 작은 값을 부여합니다.
그리고 원글보다 작고 원글 이전 글보다 큰 thread 값을 가진 기존 답글들을 모두 1씩 낮춥니다.
(새 답글이 기존 답글들보다 원글에 가깝게, 즉 목록에서 위에 오도록)
데이터베이스 설계
CREATE TABLE threadboard (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
thread INT UNSIGNED NOT NULL,
depth TINYINT NOT NULL DEFAULT 0,
name VARCHAR(20) NOT NULL,
email VARCHAR(100) NULL,
pass VARCHAR(255) NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
wdate DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ip VARCHAR(45) NOT NULL,
view INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (id),
INDEX idx_thread (thread DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
thread에 인덱스를 추가하면 정렬 성능이 좋아집니다.
DB 연결 (db.php)
<?php
declare(strict_types=1);
$dsn = 'mysql:host=localhost;dbname=mydb;charset=utf8mb4';
try {
$pdo = new PDO($dsn, '아이디', '비밀번호', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
} catch (PDOException $e) {
error_log($e->getMessage());
exit('데이터베이스 연결에 실패했습니다.');
}
글 목록 (list.php)
depth 값에 따라 들여쓰기를 표현합니다.
<?php
declare(strict_types=1);
require 'db.php';
$pageSize = 10;
$page = max(1, (int) ($_GET['page'] ?? 1));
$offset = ($page - 1) * $pageSize;
$total = (int) $pdo->query("SELECT COUNT(*) FROM threadboard")->fetchColumn();
$totalPage = (int) ceil($total / $pageSize);
$stmt = $pdo->prepare(
"SELECT * FROM threadboard ORDER BY thread DESC LIMIT :limit OFFSET :offset"
);
$stmt->bindValue(':limit', $pageSize, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
?>
<!DOCTYPE html>
<html lang="ko">
<head><meta charset="UTF-8"><title>계층형 게시판</title></head>
<body>
<table border="1" width="580">
<tr>
<th width="40">번호</th>
<th>제목</th>
<th width="80">글쓴이</th>
<th width="80">날짜</th>
<th width="50">조회</th>
</tr>
<?php foreach ($rows as $row): ?>
<tr>
<td align="center"><?= $row['id'] ?></td>
<td>
<?php
// depth만큼 들여쓰기 (└ 기호로 답글 표시)
if ($row['depth'] > 0) {
echo str_repeat(' ', $row['depth']) . '└ ';
}
?>
<a href="read.php?id=<?= $row['id'] ?>&page=<?= $page ?>">
<?= htmlspecialchars($row['title'], ENT_QUOTES, 'UTF-8') ?>
</a>
</td>
<td align="center"><?= htmlspecialchars($row['name'], ENT_QUOTES, 'UTF-8') ?></td>
<td align="center"><?= substr($row['wdate'], 0, 10) ?></td>
<td align="center"><?= $row['view'] ?></td>
</tr>
<?php endforeach; ?>
</table>
<!-- 페이지 네비게이션 -->
<div>
<?php
for ($i = 1; $i <= $totalPage; $i++) {
if ($i === $page) {
echo " <strong>{$i}</strong> ";
} else {
echo " <a href='?page={$i}'>{$i}</a> ";
}
}
?>
</div>
<a href="write.php">글쓰기</a>
</body>
</html>
새 글 저장 (insert.php)
<?php
declare(strict_types=1);
require 'db.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: list.php');
exit;
}
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$pass = trim($_POST['pass'] ?? '');
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
if ($name === '' || $pass === '' || $title === '' || $content === '') {
exit('필수 항목을 모두 입력해주세요.');
}
// 새 thread 값: 현재 최댓값을 1000 단위로 올림한 뒤 1000을 더합니다
$maxThread = (int) $pdo->query("SELECT MAX(thread) FROM threadboard")->fetchColumn();
$newThread = (int) (ceil($maxThread / 1000) * 1000 + 1000);
$pdo->prepare(
"INSERT INTO threadboard (thread, depth, name, email, pass, title, content, ip)
VALUES (:thread, 0, :name, :email, :pass, :title, :content, :ip)"
)->execute([
':thread' => $newThread,
':name' => $name,
':email' => $email,
':pass' => password_hash($pass, PASSWORD_DEFAULT),
':title' => $title,
':content' => $content,
':ip' => $_SERVER['REMOTE_ADDR'],
]);
header('Location: list.php');
exit;
답글 저장 (insert_reply.php)
답글을 저장하는 핵심 로직입니다.
<?php
declare(strict_types=1);
require 'db.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: list.php');
exit;
}
$parentId = (int) ($_POST['parent_id'] ?? 0);
$parentThread = (int) ($_POST['parent_thread'] ?? 0);
$parentDepth = (int) ($_POST['parent_depth'] ?? 0);
$name = trim($_POST['name'] ?? '');
$email = trim($_POST['email'] ?? '');
$pass = trim($_POST['pass'] ?? '');
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
if ($name === '' || $pass === '' || $title === '' || $content === '') {
exit('필수 항목을 모두 입력해주세요.');
}
// 트랜잭션으로 묶어야 합니다 — thread 값 업데이트와 INSERT가 원자적으로 처리돼야 합니다
$pdo->beginTransaction();
try {
// 원글과 이전 글 사이의 기존 답글들의 thread를 1씩 낮춥니다
$prevGroupThread = (int) (ceil($parentThread / 1000) * 1000 - 1000);
$pdo->prepare(
"UPDATE threadboard
SET thread = thread - 1
WHERE thread > :prev AND thread < :parent"
)->execute([
':prev' => $prevGroupThread,
':parent' => $parentThread,
]);
// 답글을 원글 바로 아래(thread - 1)에 삽입합니다
$pdo->prepare(
"INSERT INTO threadboard (thread, depth, name, email, pass, title, content, ip)
VALUES (:thread, :depth, :name, :email, :pass, :title, :content, :ip)"
)->execute([
':thread' => $parentThread - 1,
':depth' => $parentDepth + 1,
':name' => $name,
':email' => $email,
':pass' => password_hash($pass, PASSWORD_DEFAULT),
':title' => $title,
':content' => $content,
':ip' => $_SERVER['REMOTE_ADDR'],
]);
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
error_log($e->getMessage());
exit('답글 등록에 실패했습니다.');
}
header('Location: list.php');
exit;
beginTransaction() / commit() / rollBack()을 쓰는 이유는
thread 값 업데이트와 INSERT가 항상 함께 성공하거나 함께 실패해야 하기 때문입니다.
중간에 에러가 나면 thread 값만 바뀌고 글이 안 들어가는 불일치가 생길 수 있습니다.
글 읽기 (read.php)
<?php
declare(strict_types=1);
require 'db.php';
$id = (int) ($_GET['id'] ?? 0);
$page = (int) ($_GET['page'] ?? 1);
if ($id === 0) {
header('Location: list.php');
exit;
}
$pdo->prepare("UPDATE threadboard SET view = view + 1 WHERE id = :id")
->execute([':id' => $id]);
$stmt = $pdo->prepare("SELECT * FROM threadboard WHERE id = :id");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
if (!$row) exit('존재하지 않는 글입니다.');
function h(string $s): string {
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
?>
<!DOCTYPE html>
<html lang="ko">
<head><meta charset="UTF-8"><title><?= h($row['title']) ?></title></head>
<body>
<table border="1" width="580">
<tr><th colspan="4"><?= h($row['title']) ?></th></tr>
<tr>
<td>글쓴이</td><td><?= h($row['name']) ?></td>
<td>날짜</td><td><?= $row['wdate'] ?></td>
</tr>
<tr>
<td colspan="4"><?= nl2br(h($row['content'])) ?></td>
</tr>
<tr>
<td colspan="4">
<a href="list.php?page=<?= $page ?>">[목록]</a>
<a href="reply.php?id=<?= $id ?>&page=<?= $page ?>">[답글]</a>
<a href="edit.php?id=<?= $id ?>&page=<?= $page ?>">[수정]</a>
<a href="delete.php?id=<?= $id ?>&page=<?= $page ?>">[삭제]</a>
</td>
</tr>
</table>
</body>
</html>
답글 폼 (reply.php)
원글 내용을 인용(>)해서 미리 채워줍니다.
<?php
declare(strict_types=1);
require 'db.php';
$id = (int) ($_GET['id'] ?? 0);
$page = (int) ($_GET['page'] ?? 1);
$stmt = $pdo->prepare("SELECT * FROM threadboard WHERE id = :id");
$stmt->execute([':id' => $id]);
$parent = $stmt->fetch();
if (!$parent) exit('원글을 찾을 수 없습니다.');
$replyTitle = 'RE: ' . $parent['title'];
$replyContent = "\n>" . str_replace("\n", "\n>", $parent['content']);
function h(string $s): string {
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
?>
<!DOCTYPE html>
<html lang="ko">
<head><meta charset="UTF-8"><title>답글 달기</title></head>
<body>
<form action="insert_reply.php" method="post">
<input type="hidden" name="parent_id" value="<?= $parent['id'] ?>">
<input type="hidden" name="parent_thread" value="<?= $parent['thread'] ?>">
<input type="hidden" name="parent_depth" value="<?= $parent['depth'] ?>">
<input type="hidden" name="page" value="<?= $page ?>">
<table border="1" width="580">
<tr><td colspan="2" align="center"><b>답글 달기</b></td></tr>
<tr>
<td width="80">이름</td>
<td><input type="text" name="name" maxlength="20" required></td>
</tr>
<tr>
<td>이메일</td>
<td><input type="text" name="email" maxlength="100"></td>
</tr>
<tr>
<td>비밀번호</td>
<td><input type="password" name="pass" required></td>
</tr>
<tr>
<td>제목</td>
<td><input type="text" name="title" size="60" value="<?= h($replyTitle) ?>" required></td>
</tr>
<tr>
<td>내용</td>
<td><textarea name="content" cols="65" rows="15" required><?= h($replyContent) ?></textarea></td>
</tr>
<tr>
<td colspan="2" align="center">
<input type="submit" value="저장">
<input type="button" value="취소" onclick="history.back()">
</td>
</tr>
</table>
</form>
</body>
</html>
파일 구조 정리
threadboard/
├── db.php — DB 연결
├── list.php — 글 목록 (들여쓰기 표현)
├── write.php — 새 글쓰기 폼
├── insert.php — 새 글 저장
├── read.php — 글 읽기
├── reply.php — 답글 폼
├── insert_reply.php — 답글 저장 (thread 재배치 로직)
├── edit.php — 수정 폼
├── update.php — 수정 처리
└── delete.php — 삭제
수정/삭제 코드는 일반 게시판과 동일하게 비밀번호 검증 후 처리합니다.
게시판 만들기의 edit.php, update.php, delete.php를 참고하세요.
계층형 게시판의 핵심은 thread 값을 이용한 정렬입니다.
개념이 낯설게 느껴질 수 있지만, "원글과 그 아래 답글들이 하나의 그룹"이라는 것,
그리고 "새 답글은 원글 바로 아래에 끼어들어야 한다"는 것만 이해하면 됩니다.
요즘은 이런 구조를 직접 만들기보다 프레임워크의 Nested Set이나 Closure Table 패턴을 쓰기도 합니다.