왜 만들었나
인증 시스템은 모든 웹 앱의 기본이다. 직접 구현해보면 세션이 어떻게 동작하는지, 비밀번호를 왜 해시로 저장해야 하는지, CSRF 공격이 무엇인지 체감할 수 있다.
PHP는 세션 관리, DB 연동, 폼 처리를 기본으로 제공한다. 이번 튜토리얼은 MySQL 서버 없이 SQLite + PDO로 구현한다. 파일 하나로 DB가 되기 때문에 서버 설정 없이 php -S로 바로 실행할 수 있다.
앱 구조
25-php-login/
├── index.php # 진입점 (로그인 여부에 따라 리다이렉트)
├── register.php # 회원가입 (GET: 폼, POST: 처리)
├── login.php # 로그인 (GET: 폼, POST: 처리)
├── dashboard.php # 보호된 페이지 (세션 없으면 차단)
├── logout.php # 세션 파기 후 리다이렉트
├── db.php # PDO SQLite 연결 + 테이블 생성
├── auth.php # 인증 헬퍼 함수 모음
├── style.css.php # 공유 CSS (PHP include용)
└── data/
└── users.db # SQLite DB (자동 생성)
핵심 구현
PDO SQLite 연결
define('DB_PATH', __DIR__ . '/data/users.db');
function get_db(): PDO
{
static $pdo = null;
if ($pdo !== null) return $pdo;
$pdo = new PDO('sqlite:' . DB_PATH);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
");
return $pdo;
}
static $pdo = null은 함수 호출이 여러 번 되어도 PDO 객체를 한 번만 생성하는 간단한 싱글톤 패턴이다. CREATE TABLE IF NOT EXISTS로 DB 파일이 없으면 자동 생성한다.
비밀번호 해시 저장
// 저장 (회원가입)
$hash = password_hash($password, PASSWORD_DEFAULT);
$stmt = get_db()->prepare('INSERT INTO users (name, email, password) VALUES (?, ?, ?)');
$stmt->execute([$name, $email, $hash]);
// 검증 (로그인)
if (!password_verify($password, $user['password'])) {
$errors[] = '이메일 또는 비밀번호가 올바르지 않습니다.';
}
password_hash()는 bcrypt 알고리즘으로 솔트를 자동 포함해 해시한다. 평문 비밀번호를 DB에 저장하면 절대 안 된다. password_verify()는 해시가 바뀌어도 비교가 가능하다.
세션 기반 인증
// 로그인 성공 시
session_regenerate_id(true); // 세션 고정 공격 방지
$_SESSION['user_id'] = $user['id'];
header('Location: dashboard.php');
exit;
// 보호된 페이지 진입 시
function require_auth(): void
{
if (empty($_SESSION['user_id'])) {
header('Location: login.php');
exit;
}
}
// 로그아웃
$_SESSION = [];
session_destroy();
session_regenerate_id(true)는 로그인 성공 직후 세션 ID를 새로 발급한다. 로그인 전 세션 ID를 탈취해도 로그인 후엔 무효가 된다.
CSRF 방어
// 토큰 생성 (폼에 hidden field로 삽입)
function csrf_token(): string
{
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
// 검증
function verify_csrf(string $token): bool
{
return isset($_SESSION['csrf_token'])
&& hash_equals($_SESSION['csrf_token'], $token);
}
<!-- 폼에 포함 -->
<input type="hidden" name="csrf_token" value="<?= csrf_token() ?>">
CSRF는 다른 사이트에서 사용자 대신 폼을 제출하는 공격이다. 세션에 저장한 토큰과 폼에서 받은 토큰을 비교해 차단한다. hash_equals()는 타이밍 공격을 방지하는 상수 시간 비교 함수다.
XSS 방어
// 사용자 입력을 출력할 때 항상 htmlspecialchars() 적용
<p><?= htmlspecialchars($user['name']) ?></p>
htmlspecialchars()는 <, >, ", & 등을 HTML 엔티티로 변환해서 스크립트 삽입을 차단한다. DB에서 가져온 데이터를 출력할 때도 반드시 적용한다.
실행 방법
cd 25-php-login
php -S localhost:8080
브라우저에서 http://localhost:8080 접속. data/users.db는 최초 접속 시 자동 생성된다.
비교 테이블
| 항목 | 이번 구현 | 실무 권장 |
|---|---|---|
| DB | SQLite (파일) | MySQL / PostgreSQL |
| 비밀번호 | password_hash (bcrypt) | 동일 |
| 세션 저장 | 서버 파일시스템 | Redis / DB |
| CSRF | 수동 구현 | Laravel CSRF 미들웨어 등 |
| 이메일 인증 | 없음 | PHPMailer + 인증 링크 |
| Remember me | 없음 | 쿠키 + DB 토큰 |
튜토리얼 목적으로는 SQLite로 충분하다. 실서비스로 가면 MySQL로 연결만 바꾸면 된다 ('sqlite:...' → 'mysql:host=...;dbname=...').
삽질 기록
headers already sent 에러
header('Location: ...')를 호출하기 전에 echo나 HTML이 출력되면 Cannot modify header information - headers already sent 에러가 난다. PHP 파일 최상단에 불필요한 공백이나 BOM이 있어도 발생한다. <?php 앞에 아무것도 없어야 한다.
PDO prepare + execute vs query
SQL 쿼리에 사용자 입력을 직접 이어 붙이면 SQL 인젝션이 발생한다. prepare() + ? 플레이스홀더를 쓰면 PDO가 자동으로 이스케이프 처리한다. $db->query("SELECT * WHERE email='$email'")은 절대 쓰면 안 된다.
session_regenerate_id()를 빠뜨리면
로그인 전 공격자가 세션 ID를 심어놓고(session fixation), 사용자가 그 세션으로 로그인하면 공격자도 같은 세션으로 접근할 수 있다. 로그인 성공 직후 session_regenerate_id(true)로 반드시 세션 ID를 재발급해야 한다.
마무리
PHP 인증의 핵심은 세 가지다. password_hash()로 비밀번호를 해시 저장하고, session_regenerate_id()로 세션 고정 공격을 막고, CSRF 토큰으로 위조 요청을 차단한다. 여기에 htmlspecialchars()로 XSS까지 막으면 기본 보안은 갖춰진다.
기술 스택: PHP 8.x · PDO SQLite · 세션 · password_hash (bcrypt) · CSRF 토큰
소스 코드: GitHub
'튜토리얼 > 백엔드' 카테고리의 다른 글
| [PHP] 파일 업로드 시스템 만들기 — 다중 업로드, 파일 교체, 다운로드 스트리밍 (0) | 2026.03.05 |
|---|---|
| [PHP] REST API 서버 만들기 — 프레임워크 없이 순수 PHP로 CRUD API 구현 (0) | 2026.03.04 |
| [PHP] 게시판 CRUD 만들기 — SQLite, CSRF 방어, 소유권 검사, 페이지네이션 (0) | 2026.03.04 |
| [Python] FastAPI로 REST API 서버 만들기 — Pydantic, CRUD, 자동 문서화 (0) | 2026.03.04 |