PHP 프로젝트를 운영하다 보면 갑자기 화면이 하얀 페이지로 변하고 에러 로그에 'Uncaught Exception' 메시지가 쌓이는 경험을 해봤을 것이다. 사용자는 아무것도 볼 수 없고, 개발자는 어디서 뭐가 잘못됐는지 알 수 없는 답답한 상황이다. 다만 대부분의 개발자들은 이 예외가 정확히 뭐고, 왜 발생하며, 어떻게 처리해야 하는지 제대로 모른 채로 구글링해서 가져온 try-catch 코드를 대충 감싼다. 이번에는 PHP 예외 처리의 원리부터 실전 디버깅까지 완벽하게 정리해서 소개하겠다.

 

예외(Exception)와 에러(Error)의 치명적 차이

먼저 헷갈리는 개념부터 정리하자. PHP에서 발생하는 문제는 크게 두 가지로 나뉜다. 에러는 PHP 엔진이 발생시키는 것(Warning, Notice, Fatal Error 등)이고, 예외는 개발자가 의도적으로 던지거나 라이브러리에서 발생시키는 것이다. 예외는 try-catch로 처리할 수 있지만, 전통적인 에러는 처리하기 까다롭다.

PHP 7 이후부터는 구조가 바뀌었다. Error 클래스가 생겼고, TypeError, DivisionByZeroError 같은 치명적인 에러도 예외처럼 catch할 수 있게 됐다. 하지만 여전히 많은 개발자들이 이 차이를 모르고 try-catch를 쓰면 모든 게 해결된다고 착각한다.

 

Uncaught Exception이 발생하는 3가지 상황

 

1. throw로 예외를 던졌는데 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로 감싸면 예외를 안전하게 처리할 수 있다.

 

2. 외부 라이브러리(Composer)에서 예외를 던진 경우

✗ 잘못된 코드

<?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하는 것이 최선이다.

 

3. 데이터베이스나 파일 시스템 오류

✗ 잘못된 코드

<?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에서 전체 스택 트레이스를 확인할 수 있다.

 

finally로 정리 작업 보장하기

예외가 발생해도 반드시 실행돼야 할 코드(파일 닫기, 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이 있어도 무조건 실행된다. 데이터베이스 트랜잭션 롤백이나 외부 리소스 정리에 필수다.

 

예외를 throw할 때 피해야 할 실수

✗ 문제 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 몇 줄이 모여서 안정적인 애플리케이션을 만든다는 점을 잊지 말자. 이 글의 전역 예외 처리기와 구체적인 예외 클래스 부분을 참고해서 프로젝트에 적용하면, 예상 밖의 크래시를 훨씬 줄일 수 있을 것이다.