PHP 프로젝트를 운영하다 보면 갑자기 화면이 하얀 페이지로 변하고 에러 로그에 'Uncaught Exception' 메시지가 쌓이는 경험을 해봤을 것이다. 사용자는 아무것도 볼 수 없고, 개발자는 어디서 뭐가 잘못됐는지 알 수 없는 답답한 상황이다. 다만 대부분의 개발자들은 이 예외가 정확히 뭐고, 왜 발생하며, 어떻게 처리해야 하는지 제대로 모른 채로 구글링해서 가져온 try-catch 코드를 대충 감싼다. 이번에는 PHP 예외 처리의 원리부터 실전 디버깅까지 완벽하게 정리해서 소개하겠다.
먼저 헷갈리는 개념부터 정리하자. PHP에서 발생하는 문제는 크게 두 가지로 나뉜다. 에러는 PHP 엔진이 발생시키는 것(Warning, Notice, Fatal Error 등)이고, 예외는 개발자가 의도적으로 던지거나 라이브러리에서 발생시키는 것이다. 예외는 try-catch로 처리할 수 있지만, 전통적인 에러는 처리하기 까다롭다.
PHP 7 이후부터는 구조가 바뀌었다. Error 클래스가 생겼고, TypeError, DivisionByZeroError 같은 치명적인 에러도 예외처럼 catch할 수 있게 됐다. 하지만 여전히 많은 개발자들이 이 차이를 모르고 try-catch를 쓰면 모든 게 해결된다고 착각한다.
✗ 잘못된 코드
<?php
function processPayment($amount) {
if ($amount <= 0) {
throw new Exception('결제 금액은 0보다 커야 합니다.');
}
// 결제 로직
}
processPayment(-1000); // 예외 발생, 아무도 받지 않음
?>
이 코드를 실행하면 'Uncaught Exception' 에러가 나고 스크립트가 중단된다. 예외를 던졌지만 누구도 받아서 처리하지 않았기 때문이다.
✓ 올바른 코드
<?php
function processPayment($amount) {
if ($amount <= 0) {
throw new Exception('결제 금액은 0보다 커야 합니다.');
}
}
try {
processPayment(-1000);
} catch (Exception $e) {
echo '에러: ' . $e->getMessage();
// 또는 로그에 기록
error_log($e->getMessage());
}
?>
try-catch로 감싸면 예외를 안전하게 처리할 수 있다.
✗ 잘못된 코드
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$client = new Client();
$response = $client->request('GET', 'https://api.example.com/data'); // 네트워크 오류 시 예외 발생
$data = json_decode($response->getBody(), true);
?>
Guzzle이나 다른 라이브러리는 문제가 발생하면 바로 예외를 던진다. 네트워크 오류나 API 오류가 나면 즉시 크래시된다.
✓ 올바른 코드
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
$client = new Client();
try {
$response = $client->request('GET', 'https://api.example.com/data');
$data = json_decode($response->getBody(), true);
echo 'API 호출 성공';
} catch (RequestException $e) {
echo '네트워크 오류: ' . $e->getMessage();
error_log('API 호출 실패: ' . $e);
} catch (Exception $e) {
echo '알 수 없는 오류: ' . $e->getMessage();
}
?>
구체적인 예외 타입(RequestException)을 먼저 catch하고, 일반 Exception을 마지막에 catch하는 것이 최선이다.
✗ 잘못된 코드
<?php
$pdo = new PDO('mysql:host=localhost;dbname=mydb', 'user', 'pass');
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([999]); // 존재하지 않는 ID
$user = $stmt->fetch();
echo $user['name']; // null에서 배열 접근
?>
PDO가 잘못된 쿼리나 연결 오류를 만나면 예외를 던진다. 이를 catch하지 않으면 크래시된다.
✓ 올바른 코드
<?php
try {
$pdo = new PDO('mysql:host=localhost;dbname=mydb', 'user', 'pass');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); // 예외 모드 활성화
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([999]);
$user = $stmt->fetch();
if ($user) {
echo $user['name'];
} else {
echo '사용자를 찾을 수 없습니다.';
}
} catch (PDOException $e) {
echo '데이터베이스 오류: ' . $e->getMessage();
error_log('DB 쿼리 실패: ' . $e->getTraceAsString());
}
?>
PDOException을 catch하고, ERRMODE_EXCEPTION을 설정해서 모든 DB 오류를 예외로 받는 것이 안전하다.
모든 함수마다 try-catch를 달기는 번거롭다. 대신 PHP의 set_exception_handler() 함수로 전역 예외 처리기를 등록하면, catch하지 못한 모든 예외를 한곳에서 처리할 수 있다.
✗ 문제 있는 상황
<?php
function apiCall($endpoint) {
throw new Exception('API 호출 실패');
}
function processData() {
apiCall('/users'); // 예외 발생, 여기서 처리 안 함
}
processData(); // Uncaught Exception
?>
✓ 전역 예외 처리기 등록
<?php
set_exception_handler(function(Throwable $e) {
http_response_code(500);
echo json_encode([
'error' => 'Internal Server Error',
'message' => $e->getMessage()
]);
error_log('Uncaught Exception: ' . $e);
exit(1);
});
function apiCall($endpoint) {
throw new Exception('API 호출 실패');
}
function processData() {
apiCall('/users');
}
processData(); // 전역 예외 처리기가 받아서 처리
?>
이제 깊은 함수 체인 어디서 예외가 나든 전역 처리기가 받아서 사용자에게 깔끔한 에러 메시지를 보낸다. 동시에 개발자는 error_log에서 전체 스택 트레이스를 확인할 수 있다.
예외가 발생해도 반드시 실행돼야 할 코드(파일 닫기, DB 연결 해제 등)가 있다. 이때는 finally를 사용한다.
✗ 자원이 제대로 정리되지 않는 코드
<?php
$file = fopen('data.txt', 'r');
try {
$content = fread($file, 1000);
if (strpos($content, 'error') !== false) {
throw new Exception('파일에 오류 데이터가 있습니다.');
}
} catch (Exception $e) {
echo '오류: ' . $e->getMessage();
}
// 예외가 발생하면 이 줄이 실행되지 않음
fclose($file);
?>
✓ finally로 정리 보장
<?php
$file = fopen('data.txt', 'r');
try {
$content = fread($file, 1000);
if (strpos($content, 'error') !== false) {
throw new Exception('파일에 오류 데이터가 있습니다.');
}
} catch (Exception $e) {
echo '오류: ' . $e->getMessage();
} finally {
// 예외 발생 여부와 상관없이 항상 실행됨
fclose($file);
}
?>
finally 블록은 try나 catch에서 return/exit이 있어도 무조건 실행된다. 데이터베이스 트랜잭션 롤백이나 외부 리소스 정리에 필수다.
✗ 문제 1: 일반적인 Exception을 남발하기
<?php
function validateEmail($email) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new Exception('잘못된 이메일입니다.'); // 너무 일반적
}
}
function transfer($amount) {
if ($amount > 1000000) {
throw new Exception('금액이 너무 많습니다.'); // 또 일반적인 Exception
}
}
?>
✓ 문제 2: 구체적인 예외 클래스 사용
<?php
class InvalidEmailException extends Exception {}
class TransferLimitExceededException extends Exception {}
function validateEmail($email) {
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidEmailException('잘못된 이메일입니다.');
}
}
function transfer($amount) {
if ($amount > 1000000) {
throw new TransferLimitExceededException('금액이 너무 많습니다.');
}
}
try {
transfer(2000000);
} catch (TransferLimitExceededException $e) {
echo '한도 초과: ' . $e->getMessage();
} catch (InvalidEmailException $e) {
echo '이메일 오류: ' . $e->getMessage();
} catch (Exception $e) {
echo '알 수 없는 오류: ' . $e->getMessage();
}
?>
구체적인 예외 클래스를 만들면, catch에서 정확히 뭐가 잘못됐는지 알 수 있고, 경우에 따라 다르게 대응할 수 있다.
• 외부 API 호출(Guzzle, cURL)은 항상 try-catch로 감싸기
• PDO 쿼리는 ERRMODE_EXCEPTION으로 설정하고 catch하기
• 자원 정리(파일, DB, HTTP 연결)는 finally에 넣기
• 구체적인 예외 클래스를 상황별로 만들어서 사용하기
• set_exception_handler()로 전역 처리기 등록해서 누락 방지하기
• 본번 환경에서는 예외 메시지를 사용자에게 직접 노출하지 않기
• 모든 예외는 error_log에 기록해서 나중에 분석할 수 있게 하기
PHP의 'Uncaught Exception' 에러는 결국 예외 처리를 무시한 결과다. 작은 습관 하나, 함수마다 적절한 try-catch 몇 줄이 모여서 안정적인 애플리케이션을 만든다는 점을 잊지 말자. 이 글의 전역 예외 처리기와 구체적인 예외 클래스 부분을 참고해서 프로젝트에 적용하면, 예상 밖의 크래시를 훨씬 줄일 수 있을 것이다.