PHP 코드를 유지보수하던 중 경고 메시지를 본 적이 있나. 'Call to undefined function mysql_fetch_array()'라는 메시지가 나타나면서 코드가 작동하지 않는 상황을 말이다. 사실 이건 단순한 버그가 아니라 PHP 5.5.0부터 mysql 확장이 공식 폐기되고, PHP 7.0.0부터는 완전히 제거된 결과다. 다만 많은 개발자들은 이 사실을 모른 채 오래된 레거시 코드를 그대로 운영하다가 서버 업그레이드 시점에 낭패를 본다.
이번에는 mysql_fetch_array() 함수의 폐기 이유, 현대적인 대체 방법 두 가지(mysqli와 PDO), 그리고 기존 코드를 어떻게 마이그레이션하는지 완벽하게 정리해서 소개하겠다.
PHP의 mysql 확장은 1990년대 후반부터 2000년대 초반까지만 해도 가장 대중적인 MySQL 연결 방식이었다. 그러나 시간이 지나면서 보안 취약점과 구조적 한계가 드러났다. 가장 큰 문제는 SQL 인젝션 공격에 무방비라는 것이었다. mysql_query() 함수만 사용하면 사용자 입력을 직접 쿼리에 넣기 쉬워서, 악의적인 SQL 코드 삽입이 비교적 간단했다.
또 다른 문제는 확장 자체가 더 이상 업데이트되지 않았다는 점이다. MySQL 5.0 이상의 새로운 기능들(프리페어드 스테이트먼트, 바인딩, 트랜잭션 등)을 활용할 수 없었다. 이 때문에 PHP 개발팀은 더 안전하고 현대적인 두 가지 대안을 권장하게 됐다. 그것이 바로 mysqli와 PDO다.
| 항목 | mysqli | PDO |
|---|---|---|
| 설치 필수 여부 | php-mysqli 확장 필요 | php-pdo 확장 필요 |
| 지원 데이터베이스 | MySQL/MariaDB 전용 | 12가지 이상(MySQL, PostgreSQL, SQLite 등) |
| 프리페어드 스테이트먼트 | 지원(기본 O) | 지원(기본 O) |
| OOP 지원 | 지원 | 지원 |
| 학습곡선 | 낮음(MySQL 특화) | 높음(범용 설계) |
| 성능 | 약간 더 빠름 | 약간 더 느림 |
mysql_fetch_array()를 대체하는 주요 방법은 두 가지다. 먼저 mysqli는 MySQL 전용이지만 설정이 간단하고, MySQL 만 사용한다면 최선의 선택이다. 반면 PDO는 여러 데이터베이스를 지원해야 할 때 유리하다. 둘 다 SQL 인젝션으로부터 안전한 프리페어드 스테이트먼트를 지원한다.
✗ 문제가 있는 기존 코드(mysql 확장)
<?php
$conn = mysql_connect('localhost', 'user', 'password');
mysql_select_db('mydb', $conn);
$query = "SELECT * FROM users WHERE id = " . $_GET['id'];
$result = mysql_query($query);
while ($row = mysql_fetch_array($result)) {
echo $row['username'];
}
mysql_close($conn);
?>
위 코드는 여러 문제를 안고 있다. 첫째, 직접 사용자 입력($_GET['id'])을 쿼리에 삽입하므로 SQL 인젝션 취약점이 있다. 둘째, mysql_fetch_array()는 PHP 7.0 이상에서는 아예 함수 자체가 없다.
✓ MySQLi 객체 지향형으로 수정한 코드
<?php
$conn = new mysqli('localhost', 'user', 'password', 'mydb');
if ($conn->connect_error) {
die('연결 실패: ' . $conn->connect_error);
}
$id = $_GET['id'];
$stmt = $conn->prepare('SELECT * FROM users WHERE id = ?');
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
echo $row['username'];
}
$stmt->close();
$conn->close();
?>
핵심 차이점은 prepare()로 프리페어드 스테이트먼트를 준비하고, bind_param()으로 변수를 안전하게 바인딩한다는 점이다. 'i'는 정수형 파라미터를 의미한다('s'는 문자열, 'd'는 부동소수점). 이렇게 하면 사용자 입력이 데이터로 취급되지 코드로 해석되지 않는다.
fetch_assoc()는 mysql_fetch_array()의 직접적인 대체 함수다. 연관 배열(키-값 쌍) 형태로 각 행을 반환한다.
✓ MySQLi 절차 지향형(문법이 낡은 경우 사용)
<?php
$conn = mysqli_connect('localhost', 'user', 'password', 'mydb');
if (!$conn) {
die('연결 실패: ' . mysqli_connect_error());
}
$id = mysqli_real_escape_string($conn, $_GET['id']);
$query = "SELECT * FROM users WHERE id = " . $id;
$result = mysqli_query($conn, $query);
while ($row = mysqli_fetch_assoc($result)) {
echo $row['username'];
}
mysqli_close($conn);
?>
객체 지향형이 익숙하지 않다면 절차 지향형도 가능하다. 다만 프리페어드 스테이트먼트를 사용하는 것이 더 안전하므로, 가능하면 위의 객체 지향형 예제를 따르기를 권한다.
✓ PDO를 사용한 안전한 코드
<?php
try {
$pdo = new PDO(
'mysql:host=localhost;dbname=mydb;charset=utf8mb4',
'user',
'password'
);
} catch (PDOException $e) {
die('연결 실패: ' . $e->getMessage());
}
$id = $_GET['id'];
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id]);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
echo $row['username'];
}
?>
PDO는 try-catch로 예외 처리하는 것이 관례다. 연결 실패 시 PDOException이 던져진다. prepare()와 execute()로 프리페어드 스테이트먼트를 수행하는 방식은 mysqli와 유사하지만, 바인딩이 더 간결하다. ?는 위치 바인딩, :name은 명명 바인딩을 의미한다.
fetch(PDO::FETCH_ASSOC)는 mysql_fetch_array()와 동일하게 연관 배열을 반환한다. PDO::FETCH_NUM은 인덱스 배열, PDO::FETCH_BOTH는 둘 다를 반환한다.
✓ PDO 명명 바인딩 예제(더 가독성 좋음)
<?php
$pdo = new PDO('mysql:host=localhost;dbname=mydb', 'user', 'password');
$username = $_POST['username'];
$email = $_POST['email'];
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = :username AND email = :email');
$stmt->execute([':username' => $username, ':email' => $email]);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
echo $row['id'] . ' - ' . $row['username'];
}
?>
명명 바인딩은 :변수명 형태로, 어느 값이 어디에 바인딩되는지 훨씬 명확하다. 복잡한 쿼리일수록 이 방식이 유지보수에 유리하다.
✗ 실수 1: 여전히 직접 입력값을 쿼리에 넣기
<?php
$id = $_GET['id'];
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = $id");
$stmt->execute();
?>
prepare()를 썼더라도 이미 변수를 문자열에 포함시킨 순간 의미가 없다. SQL 인젝션 공격이 여전히 가능하다.
✓ 올바른 방법
<?php
$id = $_GET['id'];
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id]);
?>
반드시 ?나 :변수명으로 플레이스홀더를 사용하고, execute()에서 값을 전달해야 한다.
✗ 실수 2: fetch() 반환값 체크 안 하기
<?php
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([999]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
echo $row['username']; // Warning: Trying to access array offset on null
?>
쿼리 결과가 없으면 fetch()는 null을 반환한다. 이를 체크하지 않으면 배열 접근 오류가 발생한다.
✓ 올바른 방법
<?php
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([999]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
echo $row['username'];
} else {
echo '사용자를 찾을 수 없습니다';
}
?>
✗ 실수 3: 반복 쿼리 대신 fetchAll() 미사용
<?php
$stmt = $pdo->prepare('SELECT * FROM users');
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
echo $row['username'] . '<br />';
}
?>
수백만 행을 다룬다면 while 루프가 메모리 효율적이다. 그러나 몇십 행 정도라면 fetchAll()이 더 간결하다.
✓ 작은 결과셋일 때
<?php
$stmt = $pdo->prepare('SELECT * FROM users LIMIT 100');
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
echo $row['username'] . '<br />';
}
?>
만약 mysql_* 함수가 수백 곳에 흩어져 있다면 모두 한 번에 바꾸기는 힘들다. 이 경우 단계별 접근이 현실적이다.
첫째, 데이터베이스 연결 부분만 먼저 바꾼다. 보통 config.php나 db.php 같은 중앙 파일에 연결 로직이 집중되어 있다.
둘째, mysql_query() 호출 부분을 prepare()와 execute()로 한 번에 한 파일씩 치환한다. 정규표현식을 활용하면 많은 부분을 자동화할 수 있다.
셋째, mysql_fetch_array() 부분을 fetch_assoc()이나 fetch(PDO::FETCH_ASSOC)로 바꾼다.
넷째, 테스트를 충분히 거친 후 배포한다. 특히 관리자 화면이나 중요 기능부터 테스트하자.
mysql_fetch_array()는 2005년 이후 더 이상 권장되지 않는 함수고, PHP 7.0 이상에서는 완전히 제거됐다. 이제 남은 선택지는 두 가지다. MySQL만 사용한다면 MySQLi의 간결함이 매력이고, 여러 데이터베이스를 지원해야 한다면 PDO의 범용성이 빛난다. 어느 것이든 프리페어드 스테이트먼트로 SQL 인젝션을 원천 차단할 수 있다.
마이그레이션 과정에서 가장 중요한 것은 사용자 입력을 직접 쿼리에 넣지 않는 것이다. 이 한 가지 원칙만 지켜도 웹 애플리케이션의 보안 위협을 절반 이상 줄일 수 있다. 이 글에서 제시한 예제들을 참고해 기존 코드를 하나씩 개선하면, 레거시 시스템도 현대적이고 안전한 구조로 탈바꿈할 수 있을 것이다.