프로젝트의 목표와 완성 기준
지금까지 배운 PHP 문법, 폼 처리, 세션, 파일 업로드, MySQL, PDO, 객체지향, MVC, 보안, 배포 지식을 하나로 묶어 작은 게시판을 완성한다. 목표는 단순히 코드가 실행되는 게시판이 아니다. 사용자가 실제로 가입하고, 로그인하고, 글을 쓰고, 검색하고, 첨부파일을 올릴 수 있는 구조를 만드는 것이다.
완성 기준은 다음과 같다.
누구나 게시글 목록과 상세 페이지를 볼 수 있다.
회원은 가입, 로그인, 로그아웃을 할 수 있다.
로그인한 회원은 게시글을 작성할 수 있다.
작성자만 자신의 게시글을 수정하고 삭제할 수 있다.
제목과 본문으로 게시글을 검색할 수 있다.
목록은 페이지 단위로 나누어 보여 준다.
게시글에 파일을 첨부할 수 있다.
모든 입력, 출력, 데이터베이스 처리에 보안 원칙을 적용한다.
이 프로젝트에서 중요한 것은 기능의 개수가 아니라 연결이다. $_POST로 받은 값이 검증을 거쳐 모델로 전달되고, PDO Prepared Statement로 저장되며, 뷰에서 안전하게 출력되는 흐름을 몸에 익혀야 한다.
AI 생성 이미지
프로젝트 폴더 구조 정리
게시판 프로젝트는 MVC 구조를 기준으로 정리한다. 브라우저가 직접 접근하는 파일은 public/ 안에만 둔다. 설정 파일, 컨트롤러, 모델, 뷰 파일은 웹에서 직접 열리지 않게 두는 것이 기본이다.
board/
public/
index.php
config/
app.php
database.php
src/
Core/
Database.php
View.php
Auth.php
Controllers/
AuthController.php
PostController.php
Models/
User.php
Post.php
Attachment.php
Support/
helpers.php
views/
layout.php
auth/
register.php
login.php
posts/
index.php
show.php
create.php
edit.php
storage/
logs/
uploads/
vendor/
composer.json
README.mdpublic/index.php는 모든 요청이 처음 들어오는 입구다. 컨트롤러는 요청을 해석하고, 모델은 데이터베이스 작업을 처리하고, 뷰는 HTML 출력만 담당한다. 이 구조를 지키면 코드가 길어져도 어디를 수정해야 하는지 찾기 쉽다.
첨부파일은 예제에 따라 public/uploads/에 둘 수도 있지만, 실제 운영을 생각한다면 storage/uploads/처럼 웹 루트 밖에 저장하고 다운로드 컨트롤러를 통해 제공하는 편이 더 안전하다. 이 장에서는 보안 원칙을 분명히 하기 위해 업로드 파일을 storage/uploads/에 저장하는 흐름을 기준으로 설명한다.
데이터베이스 설계
게시판의 핵심 테이블은 users, posts, attachments 세 개다.
CREATE TABLE users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE posts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_posts_created_at (created_at),
INDEX idx_posts_title (title)
);
CREATE TABLE attachments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
post_id BIGINT UNSIGNED NOT NULL,
original_name VARCHAR(255) NOT NULL,
stored_name VARCHAR(255) NOT NULL,
mime_type VARCHAR(100) NOT NULL,
size INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
UNIQUE KEY uq_attachments_stored_name (stored_name)
);비밀번호는 원문이 아니라 해시 값을 저장한다. 첨부파일도 파일 자체를 데이터베이스에 넣기보다, 서버에 저장한 파일명과 원래 파일명 같은 메타데이터만 기록한다. stored_name에는 사용자가 올린 원래 파일명을 그대로 쓰지 않고, 충돌하기 어려운 랜덤 파일명을 저장한다.
검색 기능은 이 장에서 LIKE 검색으로 구현한다. 작은 게시판에서는 충분하지만, 데이터가 많아지면 일반 인덱스만으로는 본문 검색 성능이 부족할 수 있다. 그때는 MySQL의 전문 검색 인덱스나 별도의 검색 엔진을 검토하면 된다.
공통 기반 코드 만들기
프로젝트 전체에서 반복되는 코드는 헬퍼 함수로 분리한다. 가장 먼저 필요한 것은 안전한 출력과 CSRF 토큰이다. 이 함수들을 사용하려면 요청 초기에 session_start()가 실행되어 있어야 한다.
<?php
function e(?string $value): string
{
return htmlspecialchars($value ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf(): void
{
$token = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
http_response_code(419);
exit('잘못된 요청입니다.');
}
}e()는 HTML 출력 직전에 사용한다. 데이터베이스에 저장할 때가 아니라 화면에 보여 줄 때 이스케이프하는 것이 원칙이다. 그래야 같은 데이터를 HTML, JSON, 이메일 등 다른 형식으로 사용할 때도 상황에 맞게 처리할 수 있다.
CSRF 검증은 글 작성, 수정, 삭제, 로그아웃처럼 서버 상태를 바꾸는 모든 POST 요청에 적용한다. 단순 조회처럼 상태를 바꾸지 않는 GET 요청에는 보통 CSRF 토큰을 요구하지 않는다.
회원가입, 로그인, 로그아웃
회원가입에서는 이름, 이메일, 비밀번호를 검증한다. 이메일은 중복되면 안 되고, 비밀번호는 너무 짧지 않아야 한다. 저장할 때는 반드시 password_hash()를 사용한다.
<?php
$passwordHash = password_hash($password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare(
'INSERT INTO users (name, email, password_hash) VALUES (:name, :email, :password_hash)'
);
$stmt->execute([
'name' => $name,
'email' => $email,
'password_hash' => $passwordHash,
]);로그인에서는 이메일로 사용자를 찾고 password_verify()로 비밀번호를 확인한다. 로그인에 성공하면 세션 ID를 새로 발급해야 한다. 이는 세션 고정 공격을 줄이는 기본 습관이다.
<?php
if (!$user || !password_verify($password, $user['password_hash'])) {
$errors[] = '이메일 또는 비밀번호가 올바르지 않습니다.';
} else {
session_regenerate_id(true);
$_SESSION['user_id'] = (int) $user['id'];
header('Location: /posts');
exit;
}중요한 점은 로그인에 실패했을 때 세션에 사용자 ID를 넣으면 안 된다는 것이다. 따라서 성공 처리와 실패 처리를 분명히 나누어야 한다.
로그아웃은 세션 값을 지우고 세션 쿠키도 만료시키는 방식으로 처리한다. 단순히 $_SESSION['user_id']만 삭제하는 것보다 세션 전체를 정리하는 편이 안전하다. 로그아웃도 상태를 바꾸는 동작이므로 POST 요청과 CSRF 검증을 사용하는 편이 좋다.
<?php
verify_csrf();
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000, [
'path' => $params['path'],
'domain' => $params['domain'],
'secure' => $params['secure'],
'httponly' => $params['httponly'],
'samesite' => $params['samesite'] ?? 'Lax',
]);
}
session_destroy();
header('Location: /login');
exit;게시글 CRUD 완성하기
CRUD는 Create, Read, Update, Delete의 줄임말이다. 게시판에서는 글 작성, 목록, 상세 보기, 수정, 삭제를 뜻한다.
목록과 상세 보기는 누구나 접근할 수 있다. 하지만 작성, 수정, 삭제는 로그인한 사용자만 가능해야 한다. 수정과 삭제는 한 단계 더 나아가 작성자 본인인지 확인해야 한다.
<?php
function require_login(): void
{
if (empty($_SESSION['user_id'])) {
header('Location: /login');
exit;
}
}
function current_user_id(): ?int
{
return isset($_SESSION['user_id']) ? (int) $_SESSION['user_id'] : null;
}게시글 저장은 검증 후 처리한다. 제목은 비어 있으면 안 되고, 길이 제한을 둔다. 본문도 비어 있는지 확인한다.
<?php
$title = trim($_POST['title'] ?? '');
$body = trim($_POST['body'] ?? '');
$errors = [];
if ($title === '') {
$errors[] = '제목을 입력하세요.';
}
if (mb_strlen($title) > 200) {
$errors[] = '제목은 200자 이하여야 합니다.';
}
if ($body === '') {
$errors[] = '본문을 입력하세요.';
}검증을 통과한 뒤에만 데이터베이스에 저장한다. 작성자 ID는 폼에서 받지 말고 세션의 현재 사용자 ID를 사용해야 한다.
<?php
if (empty($errors)) {
$stmt = $pdo->prepare(
'INSERT INTO posts (user_id, title, body) VALUES (:user_id, :title, :body)'
);
$stmt->execute([
'user_id' => current_user_id(),
'title' => $title,
'body' => $body,
]);
header('Location: /posts');
exit;
}삭제할 때는 반드시 작성자 확인을 먼저 한다.
<?php
if ((int) $post['user_id'] !== current_user_id()) {
http_response_code(403);
exit('권한이 없습니다.');
}
$stmt = $pdo->prepare('DELETE FROM posts WHERE id = :id AND user_id = :user_id');
$stmt->execute([
'id' => $postId,
'user_id' => current_user_id(),
]);SQL의 WHERE user_id = :user_id 조건은 실수를 줄이는 안전장치다. PHP 코드에서 권한을 확인했더라도 데이터베이스 쿼리에서도 한 번 더 제한하면 잘못된 수정이나 삭제를 막기 쉽다.
검색과 페이지네이션
검색은 사용자가 입력한 키워드로 제목과 본문을 찾는 기능이다. 페이지네이션은 목록을 한 번에 모두 보여 주지 않고 10개, 20개처럼 나누어 보여 주는 방식이다.
<?php
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = 10;
$offset = ($page - 1) * $perPage;
$keyword = trim($_GET['q'] ?? '');
$where = '';
$params = [];
if ($keyword !== '') {
$where = 'WHERE p.title LIKE :q_title OR p.body LIKE :q_body';
$params['q_title'] = '%' . $keyword . '%';
$params['q_body'] = '%' . $keyword . '%';
}
$countSql = 'SELECT COUNT(*)
FROM posts p
' . $where;
$countStmt = $pdo->prepare($countSql);
foreach ($params as $key => $value) {
$countStmt->bindValue(':' . $key, $value);
}
$countStmt->execute();
$totalCount = (int) $countStmt->fetchColumn();
$totalPages = max(1, (int) ceil($totalCount / $perPage));
if ($page > $totalPages) {
$page = $totalPages;
$offset = ($page - 1) * $perPage;
}
$sql = 'SELECT p.*, u.name AS author_name
FROM posts p
JOIN users u ON u.id = p.user_id
' . $where . '
ORDER BY p.id DESC
LIMIT :limit OFFSET :offset';
$stmt = $pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue(':' . $key, $value);
}
$stmt->bindValue(':limit', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$posts = $stmt->fetchAll();검색어도 사용자 입력이다. SQL 문자열에 직접 붙이지 않고 플레이스홀더로 바인딩한다. 같은 검색어를 제목과 본문에 모두 사용할 때도 :q_title, :q_body처럼 플레이스홀더 이름을 나누면 PDO 설정에 따른 호환성 문제를 줄일 수 있다.
화면에 다시 검색어를 출력할 때는 e($keyword)를 사용한다. 페이지 번호가 1보다 작거나 마지막 페이지보다 크면 위 예제처럼 보정할 수도 있고, 정책에 따라 404로 처리할 수도 있다.
파일 첨부 추가하기
파일 업로드는 편리하지만 위험하다. 사용자가 올린 파일을 그대로 믿으면 안 된다. 파일명, 확장자, MIME 타입, 크기를 모두 검사한다.
처리 순서는 다음과 같다.
폼에
enctype="multipart/form-data"를 지정한다.$_FILES의 업로드 오류 코드를 확인한다.허용할 최대 크기를 검사한다.
finfo로 실제 MIME 타입을 확인한다.허용 목록에 있는 파일만 받는다.
원래 파일명을 저장 파일명으로 사용하지 않는다.
랜덤한 새 파일명으로 저장한다.
파일 메타데이터를
attachments테이블에 저장한다.
<?php
$allowedTypes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'application/pdf' => 'pdf',
];
$file = $_FILES['attachment'] ?? null;
$attachment = null;
if ($file && $file['error'] !== UPLOAD_ERR_NO_FILE) {
if ($file['error'] !== UPLOAD_ERR_OK) {
$errors[] = '파일 업로드 중 오류가 발생했습니다.';
} elseif ($file['size'] > 5 * 1024 * 1024) {
$errors[] = '첨부파일은 5MB 이하여야 합니다.';
} else {
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
$extension = $allowedTypes[$mimeType] ?? null;
if ($extension === null) {
$errors[] = '허용되지 않는 파일 형식입니다.';
} else {
$attachment = [
'tmp_name' => $file['tmp_name'],
'original_name' => basename($file['name']),
'stored_name' => bin2hex(random_bytes(16)) . '.' . $extension,
'mime_type' => $mimeType,
'size' => (int) $file['size'],
];
}
}
}위 코드는 파일을 바로 옮기지 않고, 먼저 검증 결과를 $attachment에 담아 둔다. 게시글 제목과 본문 검증까지 모두 통과한 뒤 게시글을 저장하고, 그다음 파일을 옮기고, 마지막으로 첨부파일 메타데이터를 저장하는 흐름이 좋다.
<?php
if (empty($errors)) {
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'INSERT INTO posts (user_id, title, body) VALUES (:user_id, :title, :body)'
);
$stmt->execute([
'user_id' => current_user_id(),
'title' => $title,
'body' => $body,
]);
$postId = (int) $pdo->lastInsertId();
if ($attachment !== null) {
$uploadDir = dirname(__DIR__, 2) . '/storage/uploads';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$targetPath = $uploadDir . '/' . $attachment['stored_name'];
if (!move_uploaded_file($attachment['tmp_name'], $targetPath)) {
throw new RuntimeException('첨부파일 저장에 실패했습니다.');
}
$stmt = $pdo->prepare(
'INSERT INTO attachments
(post_id, original_name, stored_name, mime_type, size)
VALUES (:post_id, :original_name, :stored_name, :mime_type, :size)'
);
$stmt->execute([
'post_id' => $postId,
'original_name' => $attachment['original_name'],
'stored_name' => $attachment['stored_name'],
'mime_type' => $attachment['mime_type'],
'size' => $attachment['size'],
]);
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}데이터베이스 트랜잭션은 데이터베이스 작업을 묶어 주지만, 파일 시스템 작업까지 자동으로 되돌려 주지는 않는다. 그래서 실제 서비스에서는 파일 이동에 성공한 뒤 데이터베이스 저장이 실패했을 때 남은 파일을 삭제하는 보정 코드까지 넣으면 더 좋다.
업로드 파일은 실행 가능한 PHP 파일로 취급되지 않도록 해야 한다. 운영 환경에서는 업로드 디렉터리에서 PHP 실행을 막고, 가능하면 웹 루트 밖에 원본 파일을 저장한 뒤 다운로드 컨트롤러를 통해 제공하는 방식이 더 안전하다.
보안 원칙을 프로젝트 전체에 적용하기
게시판이 완성되었다고 말하려면 보안 처리가 기능마다 일관되게 들어가야 한다.
| 위험 | 적용 위치 | 방어 방법 |
|---|---|---|
| XSS | 게시글 제목, 본문, 작성자명, 검색어 출력 | e()로 HTML 이스케이프 |
| SQL 인젝션 | 로그인, 검색, CRUD 쿼리 | PDO Prepared Statement 사용 |
| CSRF | 글 작성, 수정, 삭제, 로그아웃 | CSRF 토큰 검증 |
| 비밀번호 유출 | 회원가입, 로그인 | password_hash(), password_verify() |
| 권한 우회 | 수정, 삭제 | 작성자 ID와 현재 로그인 ID 비교 |
| 파일 업로드 공격 | 첨부파일 저장 | MIME 검사, 크기 제한, 랜덤 파일명 |
| 정보 노출 | 오류 화면, 설정 파일 | 운영에서 오류 화면 출력 금지 |
특히 수정과 삭제는 버튼을 숨기는 것만으로 보호되지 않는다. 사용자는 개발자 도구나 직접 요청으로 URL을 호출할 수 있다. 따라서 컨트롤러와 SQL 조건에서 반드시 권한을 검사해야 한다.
또 하나 기억할 점은 보안 처리가 한 곳에만 있으면 쉽게 빠뜨린다는 것이다. 예를 들어 PostController의 update()에서 권한을 검사했다면 delete()에서도 같은 기준으로 검사해야 한다. 폼마다 CSRF 토큰을 넣고, POST 처리마다 verify_csrf()를 호출하는 식으로 규칙을 반복해서 적용한다.
README 작성과 배포 전 점검
프로젝트 루트에 README.md를 작성한다. README는 다른 사람뿐 아니라 미래의 자신을 위한 문서다.
README에는 다음 내용을 포함한다.
# PHP Board
## 요구 사항
- PHP 8.x
- MySQL 8.x
- Composer
## 설치
1. composer install
2. config/database.php 설정
3. SQL 마이그레이션 실행
4. php -S localhost:8000 -t public 실행
## 주요 기능
- 회원가입, 로그인, 로그아웃
- 게시글 CRUD
- 검색과 페이지네이션
- 파일 첨부
- CSRF, XSS, SQL 인젝션 방어배포 전에는 다음 항목을 확인한다.
문서 루트가
public/으로 설정되어 있는가?config/,src/,views/,.env가 웹에서 직접 열리지 않는가?운영 환경에서
display_errors가 꺼져 있는가?오류는 화면이 아니라 로그 파일에 기록되는가?
데이터베이스 비밀번호가 코드 저장소에 올라가지 않았는가?
세션 쿠키에
Secure,HttpOnly,SameSite옵션을 적용했는가?HTTPS 환경에서 동작하도록 웹 서버와 쿠키 설정을 맞추었는가?
첨부파일 크기 제한과 MIME 타입 제한이 동작하는가?
업로드 디렉터리에서 PHP 파일이 실행되지 않는가?
작성자만 수정과 삭제를 할 수 있는가?
테스트 계정과 불필요한 디버그 코드가 제거되었는가?
데이터베이스 백업 방법을 정했는가?
실습 과제
게시글 상세 페이지에 이전 글, 다음 글 링크를 추가한다.
게시글 목록에서 작성자명으로도 검색할 수 있게 만든다.
첨부파일 삭제 기능을 추가하되 작성자만 삭제할 수 있게 한다.
로그인 실패 횟수를 세어 너무 많은 시도를 잠시 제한한다.
PostValidator클래스를 만들고 제목과 본문 검증을 테스트 코드로 작성한다.관리자 계정을 추가하고 모든 게시글을 관리할 수 있는 페이지를 만든다.
자주 발생하는 오류 점검
로그인이 유지되지 않으면
session_start()가 요청 초기에 실행되는지 확인한다.CSRF 오류가 계속 나면 폼의 토큰 이름과 검증 코드의 토큰 이름이 같은지 확인한다.
검색 페이지에서 SQL 오류가 나면 검색어 플레이스홀더와
LIMIT,OFFSET값이 올바르게 바인딩되는지 확인한다.한글 제목 길이 검사가 이상하면
strlen()이 아니라mb_strlen()을 사용한다.첨부파일이 저장되지 않으면 업로드 디렉터리 권한과
upload_max_filesize,post_max_size설정을 확인한다.첨부파일 MIME 타입이 계속 거부되면 브라우저가 보낸 값이 아니라
finfo로 확인한 실제 MIME 타입을 기준으로 검사하는지 확인한다.삭제 후 파일만 남아 있으면 데이터베이스 삭제와 실제 파일 삭제를 같은 흐름에서 처리했는지 확인한다.
이 프로젝트를 완성하면 PHP의 핵심 조각들이 하나의 웹 애플리케이션으로 연결된다. 문법은 목적지가 아니라 도구다. 이제 중요한 것은 작은 기능을 안전하게 만들고, 역할별로 나누고, 문제가 생겼을 때 추적할 수 있는 구조로 유지하는 것이다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.