데이터베이스와의 안전한 대화 - PDO 기초
데이터베이스를 다루는 SQL 문법을 익혔으니, 이제 PHP 프로그램에서 그 데이터베이스에 접속하고 명령을 내리는 방법을 배울 차례입니다.
1. PDO(PHP Data Objects)란?
과거에는 PHP에서 MySQL에 접속할 때 mysql_connect()라는 아주 직관적인 함수를 사용했습니다. 하지만 이 함수들은 보안에 너무 취약해서 최신 PHP(7.0 이상)에서는 완전히 흔적도 없이 삭제되었습니다.
그 자리를 완벽하게 대체한 것이 바로 PDO입니다. PDO는 MySQL뿐만 아니라 오라클, PostgreSQL, SQLite 등 어떤 데이터베이스를 쓰더라도 똑같은 문법으로 조종할 수 있게 해주는 아주 강력하고 안전한 만능 리모컨입니다.
2. 데이터베이스 접속하기
가장 먼저 데이터베이스에 접속하는 코드부터 작성해 보겠습니다.
<?php
declare(strict_types=1);
// DSN (Data Source Name): "어떤 DB의, 어느 주소의, 무슨 데이터베이스에 접속할지"를 적는 주소창입니다.
$dsn = 'mysql:host=localhost;dbname=mydb;charset=utf8mb4';
try {
$pdo = new PDO($dsn, '아이디', '비밀번호', [
// 1. 에러가 나면 화면에 엉뚱한 거 띄우지 말고 바로 예외(Exception)를 던져라
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
// 2. 데이터를 가져올 때는 무조건 다루기 편한 연관 배열(Associative Array)로 가져와라
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
// 3. PHP가 가짜로 방어하지 말고, DB의 진짜 Prepared Statement 기능을 사용해라
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
// 🚨 절대 에러 메시지($e->getMessage())를 화면에 그대로 echo로 출력하지 마세요! 해커에게 DB 구조를 바치는 꼴입니다.
error_log("DB 접속 실패: " . $e->getMessage());
exit('데이터베이스 접속에 일시적인 문제가 발생했습니다.');
}
3. SQL 명령 내리기 (가장 중요)
PDO에서 SQL 명령을 내리는 방식은 크게 두 가지로 나뉩니다. 이 두 가지를 완벽하게 구별해서 써야만 해킹을 막을 수 있습니다.
① 외부 입력값이 아예 없는 안전한 쿼리: query()
내가 직접 짠 고정된 쿼리만 날릴 때 사용합니다.
<?php
// 사용자 입력이 전혀 섞이지 않은 100% 안전한 쿼리
$stmt = $pdo->query("SELECT * FROM users ORDER BY id DESC LIMIT 5");
$rows = $stmt->fetchAll();
② 외부 입력값이 단 하나라도 섞인 쿼리: Prepared Statement (필수!)
사용자가 폼에서 입력한 글자나, 주소창($_GET)으로 넘어온 값이 SQL 문장에 단 하나라도 섞여 들어간다면 무조건, 예외 없이 Prepared Statement 방식을 써야 합니다. 이것이 이른바 'SQL 인젝션' 공격을 완벽하게 틀어막는 유일한 정석입니다.
<?php
$name = $_POST['name']; // 사용자가 입력한 값
// ❌ 최악의 코드: 입력값을 문자열 결합으로 직접 끼워 넣기 (절대 금지!)
$pdo->query("SELECT * FROM users WHERE name = '{$name}'");
// 만약 사용자가 name 란에 "' OR '1'='1" 이라고 적으면 모든 회원 정보가 다 뚫립니다.
// 🟢 올바른 코드: ? 나 :이름 형태의 '빈칸(Placeholder)'을 뚫어놓고 나중에 값을 안전하게 채워 넣습니다.
$stmt = $pdo->prepare("SELECT * FROM users WHERE name = :name");
$stmt->execute([':name' => $name]);
$rows = $stmt->fetchAll();
prepare()와 execute()를 거치면, 사용자가 아무리 악의적인 해킹 코드를 넣어도 PHP가 이를 "아, 이건 명령어가 아니라 단순한 문자열(글자)이구나" 하고 안전하게 캡슐화해 버립니다.
4. 데이터 꺼내오기 (SELECT 결과 받기)
조회(SELECT)를 한 뒤에는 결과물을 PHP 배열로 끄집어내야 합니다.
fetchAll(): 게시판 목록처럼 여러 개의 행(Row)을 한꺼번에 2차원 배열로 가져올 때 씁니다.fetch(): 게시글 상세 보기처럼 딱 하나의 행만 가져올 때 씁니다. 만약 찾는 데이터가 없으면false를 뱉습니다.fetchColumn(): "총 회원 수가 몇 명이지?" 처럼 딱 하나의 값(숫자나 글자)만 꺼낼 때 아주 유용합니다.
<?php
$count = (int) $pdo->query("SELECT COUNT(*) FROM users")->fetchColumn();
echo "총 회원 수: {$count}명";
5. 데이터 바꾸기 (INSERT, UPDATE, DELETE)
<?php
// [INSERT] 방금 넣은 글의 번호 알아내기
$stmt = $pdo->prepare("INSERT INTO users (name, age) VALUES (:name, :age)");
$stmt->execute([':name' => '홍길동', ':age' => 30]);
$newId = (int) $pdo->lastInsertId(); // 방금 추가된 행의 id(Auto Increment 값)를 바로 가져옵니다.
echo "새 회원 번호: {$newId}";
// [UPDATE / DELETE] 몇 개나 바뀌었는지 알아내기
$stmt = $pdo->prepare("UPDATE users SET age = :age WHERE name = :name");
$stmt->execute([':age' => 31, ':name' => '홍길동']);
$affected = $stmt->rowCount(); // 이 명령으로 인해 실제로 수정되거나 지워진 행의 개수를 알려줍니다.
echo "{$affected}명의 정보가 수정되었습니다.";
6. 묶어서 처리하기: 트랜잭션 (Transaction)
'계좌 이체'를 생각해 봅시다. A의 통장에서 돈을 빼고(1번 쿼리), B의 통장에 돈을 넣어야(2번 쿼리) 합니다. 그런데 A 통장에서 돈은 빠졌는데, 갑자기 서버가 멈춰서 B 통장에 입금이 안 되면 어떻게 될까요? 큰일 나겠죠.
이렇게 "여러 개의 쿼리가 모두 완벽하게 성공해야만 진짜로 반영하고, 중간에 하나라도 삑사리가 나면 처음 상태로 싹 다 되돌려라(Rollback)"라고 묶어주는 기능이 트랜잭션입니다.
<?php
// 트랜잭션 시작 (이제부터 내리는 명령은 임시 상태가 됩니다)
$pdo->beginTransaction();
try {
$pdo->prepare("UPDATE accounts SET balance = balance - 10000 WHERE id = 1")->execute();
$pdo->prepare("UPDATE accounts SET balance = balance + 10000 WHERE id = 2")->execute();
// 두 명령이 모두 에러 없이 무사히 통과했다면, DB에 진짜로 확정 지어라!
$pdo->commit();
} catch (PDOException $e) {
// 중간에 에러가 났다면, 여태까지 했던 작업을 전부 무효로 만들고 처음으로 되돌려라!
$pdo->rollBack();
error_log("계좌 이체 실패: " . $e->getMessage());
exit('이체 처리 중 오류가 발생했습니다.');
}
7. 실무 선배의 팁: db.php 파일 똑똑하게 만들기
게시판을 만들 때 require 'db.php'를 여기저기서 여러 번 부르다 보면, 자칫 똑같은 DB 접속을 두 번 세 번 반복해서 서버 메모리를 낭비할 수 있습니다. 이를 막기 위해 static 변수를 활용한 함수 하나를 만들어 두면 아주 깔끔해집니다.
<?php
// db.php
declare(strict_types=1);
function getPdo(): PDO {
// static 변수는 함수가 끝나도 값이 사라지지 않고 계속 살아있습니다.
static $pdo = null;
// 만약 최초 호출이라 $pdo가 비어있다면, 그때만 접속을 맺습니다.
if ($pdo === null) {
$dsn = 'mysql:host=localhost;dbname=mydb;charset=utf8mb4';
$pdo = new PDO($dsn, '아이디', '비번', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
// 두 번째 호출부터는 이미 접속해 둔 $pdo 객체를 재활용해서 돌려줍니다.
return $pdo;
}
이제 어떤 파일에서든 $pdo = getPdo(); 한 줄만 쓰면, 수백 번을 호출해도 DB 연결은 딱 한 번만 맺어지므로 서버가 아주 편안해합니다.