본문 바로가기
튜토리얼/백엔드

[PHP] 로그인/회원가입 시스템 만들기 — PDO SQLite, 세션, CSRF 방어

by 루까(Luka) 2026. 3. 4.
반응형

왜 만들었나

인증 시스템은 모든 웹 앱의 기본이다. 직접 구현해보면 세션이 어떻게 동작하는지, 비밀번호를 왜 해시로 저장해야 하는지, 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

반응형