PHP · 심층 가이드
PHP 에러 처리 완전 정리
Throwable 계층과 전역 핸들러, 경고의 예외 승격, 재시도와 JSON 오류 응답까지 PHP 애플리케이션이 실패했을 때 무엇을 남기고 무엇을 돌려줄지 설계합니다.
PHP에는 실패를 알리는 경로가 하나가 아닙니다. 예외를 던지는 함수가 있고, false를 반환하며 경고만 찍는 오래된 표준 함수가 있고, 타입 위반처럼 Error로 올라오는 엔진 레벨 실패가 있습니다. PHP 7에서 Throwable 인터페이스가 도입되면서 Exception과 Error를 한 자리에서 잡을 수 있게 됐지만, 경고와 통지는 여전히 예외가 아니라서 try 블록으로는 걸리지 않습니다. 이 세 갈래를 하나의 흐름으로 모으는 것이 에러 처리 설계의 실질입니다.
try catch 기본과 finally 로 정리 작업으로 골격을 잡은 뒤, 커스텀 예외 클래스와 다중 catch로 실패를 종류별로 구분하는 습관을 들이면 됩니다. 그다음이 핵심인데, 에러를 예외로 승격에서 set_error_handler로 경고를 ErrorException으로 바꾸고 전역 예외 핸들러로 마지막 안전망을 두면 모든 실패가 한 통로로 모입니다. 여기까지 오면 도메인 예외 매핑과 JSON 에러 응답 포맷으로 내부 예외를 사용자에게 보여 줄 형태로 번역하는 작업이 자연스럽게 이어집니다.
안전망에는 구멍이 있습니다. set_exception_handler는 메모리 한도나 실행 시간 초과 같은 치명적 오류를 잡지 못하고, 이때는 register_shutdown_function 안에서 error_get_last()를 확인하는 수밖에 없습니다. 또 finally 블록에서 return하거나 새 예외를 던지면 원래 예외가 조용히 사라집니다. 정리 코드는 값을 돌려주지 않게 유지하십시오. PDO 예외 모드를 켜지 않으면 질의 실패가 예외 없이 false로만 돌아와 다음 줄에서 엉뚱한 오류로 터집니다.
01try catch 기본
throw로 던진 RuntimeException을 같은 타입의 catch가 받아 메시지를 꺼냅니다. 잡을 타입을 좁게 적으면 예상한 실패만 처리하고 나머지는 위로 흘려보낼 수 있습니다. 내장 예외 대부분은 Exception을 상속하지만 TypeError 같은 Error 계열은 이 catch에 걸리지 않습니다.
PHP code
<?php
try {
throw new RuntimeException('boom');
} catch (RuntimeException $e) {
echo $e->getMessage();
}getCode()와 getPrevious()로 원인 사슬까지 볼 수 있고, 변수를 생략한 catch (RuntimeException) 형태도 PHP 8부터 허용됩니다.
메시지만 찍고 넘어가면 호출부는 작업이 성공한 줄 압니다. 복구할 수 없는 실패라면 다시 던지거나 실패를 나타내는 값을 분명히 돌려줘야 합니다.
02finally 로 정리 작업
try 블록이 정상적으로 끝나든 예외로 빠져나가든 finally가 실행되어 스트림을 닫습니다. 파일 핸들이나 락처럼 반드시 되돌려야 하는 자원 정리에 씁니다. return으로 나갈 때도 finally가 먼저 도는데, 여기서 다시 return하면 원래 반환값을 덮어쓰니 주의해야 합니다.
PHP code
<?php
$resource = fopen('php://temp', 'r+');
try {
fwrite($resource, 'php');
} finally {
fclose($resource);
echo "closed";
}finally는 예외가 전파되는 중에도 실행되고, 그 안에서 새 예외를 던지면 원래 예외가 previous로 이어집니다. 자원 정리는 여기에 모아 두는 편이 안전합니다.
finally 안에서 return하면 try의 반환값이나 전파 중인 예외까지 덮어써 실패가 통째로 사라집니다. 정리 코드에는 return을 두지 않는 편이 낫습니다.
03커스텀 예외 클래스
RuntimeException을 상속한 예외 타입을 만들어 도메인에서 생긴 실패에 이름을 붙입니다. 호출부가 타입만 보고 처리 방식을 나눌 수 있게 됩니다. 다만 DomainException은 PHP가 이미 SPL로 제공하는 이름이라, 네임스페이스 없이 전역에 이렇게 선언하면 충돌이 납니다.
PHP code
<?php
class DomainException extends RuntimeException {}
throw new DomainException('invalid state');도메인 정보를 담을 프로퍼티나 정적 팩토리 메서드를 예외에 두면 메시지 문자열을 여기저기서 조립하지 않아도 됩니다. 상속 계층으로 처리 범위를 조절할 수 있습니다.
전역에 DomainException을 다시 선언하면 이미 있는 SPL 클래스와 부딪혀 치명적 오류로 스크립트가 멈춥니다. 이름을 바꾸거나 네임스페이스 안에 두어야 합니다.
04다중 catch
파이프로 여러 예외 타입을 묶어 한 블록에서 받고, $e::class로 실제 잡힌 타입을 출력합니다. 처리 방법이 같은 예외들을 같은 코드로 두 번 적지 않아도 됩니다. 참고로 InvalidArgumentException은 LogicException 계열이라 RuntimeException과 상속 관계가 없고, 그래서 둘을 나란히 적어야 합니다.
PHP code
<?php
try {
throw new InvalidArgumentException('bad input');
} catch (InvalidArgumentException | RuntimeException $e) {
echo $e::class;
}catch는 위에서부터 처음으로 맞는 블록만 실행되므로 좁은 타입을 먼저, 넓은 타입을 뒤에 적어야 합니다. 파이프로 묶은 타입끼리는 순서가 의미 없습니다.
두 예외를 상속 관계로 착각해 하나만 잡으면 다른 쪽이 그대로 빠져나가 최상위까지 올라갑니다. 계층은 짐작하지 말고 문서로 확인해야 합니다.
05Throwable 전체 처리
배열을 strlen에 넘겨 발생한 TypeError를 Throwable로 받아 냅니다. Throwable은 Exception과 Error 양쪽이 구현하는 인터페이스라 프로그래밍 오류까지 전부 포함합니다. 이렇게 넓게 잡는 것은 요청 최상위 경계에서만 하고, 안쪽에서는 타입을 좁혀 잡아야 버그가 묻히지 않습니다.
PHP code
<?php
try {
strlen([]);
} catch (Throwable $e) {
echo $e::class;
}Throwable로 잡아도 catch 안에서 다시 던지면 상위 핸들러가 그대로 이어받습니다. 로그를 남기고 되던지는 형태가 최상위 경계 밖에서는 더 안전합니다.
Throwable을 잡고 아무 기록 없이 지나가면 오타나 타입 오류 같은 버그까지 묻혀 원인 없는 빈 화면만 남습니다. 넓게 잡을수록 로깅은 반드시 붙어야 합니다.
06전역 예외 핸들러
잡히지 않은 예외가 스크립트를 끝내기 직전에 불릴 핸들러를 등록합니다. 스택 트레이스를 그대로 노출하는 대신 정해진 오류 화면이나 JSON 응답을 내보내는 자리로 씁니다. 핸들러가 끝나면 스크립트는 그대로 종료되고, 그 안에서 다시 예외를 던져도 받아 줄 catch는 없습니다.
PHP code
<?php
set_exception_handler(function (Throwable $e): void {
echo '[handled] ' . $e->getMessage();
});
throw new RuntimeException('global');핸들러 안에서는 앞선 출력이 이미 나갔을 수 있으므로 출력 버퍼를 정리하고 응답을 새로 쓰는 편이 깔끔합니다. restore_exception_handler()로 이전 핸들러로 되돌릴 수도 있습니다.
이 핸들러는 파스 오류나 메모리 초과 같은 치명적 오류에는 불리지 않습니다. 그것까지 잡으려면 register_shutdown_function과 error_get_last를 따로 붙여야 합니다.
07에러를 예외로 승격
set_error_handler가 경고나 알림 수준의 오류를 가로채 ErrorException으로 다시 던져, 처리 경로를 예외 하나로 통일합니다. 조용히 지나가는 경고를 놓치지 않으려는 프레임워크가 쓰는 방식입니다. 이 핸들러는 치명적 오류에는 불리지 않고, 인자를 빼먹은 strpos()는 ArgumentCountError로 곧장 던져집니다.
PHP code
<?php
set_error_handler(function (int $severity, string $message, string $file, int $line): void {
throw new ErrorException($message, 0, $severity, $file, $line);
});
try {
strpos();
} catch (Throwable $e) {
echo $e::class;
}핸들러가 false를 돌려주면 PHP 기본 오류 처리가 이어서 동작합니다. error_reporting에서 제외한 수준도 핸들러에는 전달되므로 안에서 직접 걸러야 합니다.
모든 에러를 예외로 바꾸면 라이브러리 호환성에 영향을 줄 수 있으니 범위를 신중히 정해야 합니다.
08ValueError 다루기
빈 배열에 array_rand를 부르면 인자 타입은 맞지만 값이 성립하지 않아 ValueError가 던져집니다. PHP 8부터 이런 상황이 false 반환이나 경고가 아닌 예외로 바뀌어 실패를 넘겨 버리기 어려워졌습니다. ValueError는 Error 계열이라 Exception만 잡는 코드로는 걸러지지 않습니다.
PHP code
<?php
try {
array_rand([], 1);
} catch (ValueError $e) {
echo $e->getMessage();
}ValueError는 값이 허용 범위를 벗어났을 때, TypeError는 타입이 맞지 않을 때로 역할이 나뉩니다. 메시지에 몇 번째 인자가 문제인지 담겨 원인을 빨리 좁힐 수 있습니다.
PHP 7 시절 코드를 옮기면서 반환값이 false인지만 확인하도록 두면 이제는 그 지점에서 예외가 던져져 검사 코드에 닿지도 못합니다. 배열이 비었는지 먼저 봐야 합니다.
09assert 사용
assert_options(ASSERT_EXCEPTION, 1)을 켜 단언 실패가 AssertionError로 던져지게 하고 그 메시지를 확인합니다. 개발 중 코드의 불변 조건을 못박아 두는 용도입니다. 운영에서는 zend.assertions를 -1로 두어 assert 호출이 아예 컴파일되지 않으므로, 사용자 입력 검증을 여기에 맡기면 안 됩니다.
PHP code
<?php
assert_options(ASSERT_EXCEPTION, 1);
try {
assert(1 === 2, 'unexpected');
} catch (AssertionError $e) {
echo $e->getMessage();
}zend.assertions가 1인 환경에서만 assert가 실행되며 표현식도 그때만 평가됩니다. 실패 메시지를 다듬으려면 두 번째 인자에 문자열이나 Throwable을 줍니다.
assert 안에 값을 바꾸는 코드를 넣으면 운영에서 그 줄이 통째로 사라져 동작이 달라집니다. 단언은 검사만 하고 부작용이 없어야 합니다.
10PDO 예외 모드
ATTR_ERRMODE를 예외 모드로 바꿔 없는 테이블 조회가 PDOException으로 올라오게 합니다. 예전 기본값에서는 query가 false를 돌려줘 확인을 빠뜨리기 쉬웠습니다. PHP 8부터는 예외 모드가 기본이라 이 줄은 의도를 드러내는 쪽에 가깝고, 예외 메시지에 SQL이 담기니 화면에 그대로 내보내면 안 됩니다.
PHP code
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
try {
$pdo->query('SELECT * FROM missing_table');
} catch (PDOException $e) {
echo 'db error';
}예외 모드와 함께 기본 페치 모드나 프리페어 에뮬레이션 설정을 생성자 옵션 배열로 한 번에 넘기면 연결마다 설정이 어긋나는 일이 없습니다.
PDOException 메시지를 그대로 응답에 담으면 테이블명과 쿼리 조각이 외부에 노출됩니다. 사용자에게는 일반화된 문구만 보내고 자세한 내용은 로그로 남겨야 합니다.
11도메인 예외 매핑
이메일 형식 검사에 실패하면 InvalidArgumentException을 던지고, 바깥에서 그것을 잡아 previous로 감싼 RuntimeException으로 바꿔 다시 던집니다. 계층 경계에서 안쪽 예외를 바깥 어휘로 번역할 때 쓰는 형태입니다. 원본을 previous에 넣어야 로그의 caused by 사슬에 진짜 원인이 남습니다.
PHP code
<?php
function createEmail(string $value): string {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('invalid email');
}
return $value;
}
try {
createEmail('oops');
} catch (InvalidArgumentException $e) {
throw new RuntimeException('user input error', previous: $e);
}예외를 번역하면 상위 계층이 하위의 세부 타입을 알 필요가 없어져 계층 경계가 지켜집니다. 어떤 예외를 어떤 예외로 바꿀지 규칙을 정해 두면 처리 코드가 단순해집니다.
previous를 넘기지 않고 새 예외만 던지면 스택 트레이스가 이 지점부터 다시 시작해 실제 원인 줄이 사라집니다. 로그만 보고는 어디서 시작된 실패인지 알 수 없습니다.
12재시도 래퍼
작업이 예외를 던지면 남은 횟수만큼 goto로 try 앞으로 돌아가 다시 실행하고, 횟수를 다 쓰면 마지막 예외를 그대로 올려보냅니다. 일시적인 네트워크 실패에 대응하는 패턴입니다. 대기 없이 곧바로 반복하므로 실제로는 사이에 지연이 필요하고, 결제처럼 중복 실행이 위험한 작업에는 그대로 쓰면 안 됩니다.
PHP code
<?php
function retry(int $times, callable $task): mixed {
beginning:
try {
return $task();
} catch (Throwable $e) {
if (--$times > 0) {
goto beginning;
}
throw $e;
}
}
echo retry(2, fn () => 'ok');재시도는 같은 요청을 다시 보내도 결과가 같은 작업에만 안전합니다. 멱등하지 않은 호출이라면 요청마다 고유 키를 붙여 서버가 중복을 걸러 내게 해야 합니다.
retry(1, ...)로 부르면 --$times가 0이 되어 재시도 없이 한 번만 실행됩니다. 횟수가 총 시도인지 추가 시도인지 헷갈리면 호출부마다 동작이 달라집니다.
13스택 트레이스 로깅
예외 메시지와 getTraceAsString() 결과를 error_log로 남깁니다. 응답은 정상적으로 돌려주면서도 원인을 나중에 되짚어야 하는 경우에 필요합니다. 트레이스 문자열에는 호출 당시의 인자가 함께 찍힐 수 있어, 비밀번호나 토큰이 로그 파일로 새어 나가지 않는지 확인해야 합니다.
PHP code
<?php
try {
throw new RuntimeException('boom');
} catch (Throwable $e) {
error_log($e->getMessage());
error_log($e->getTraceAsString());
}getTraceAsString()은 긴 트레이스를 잘라 내므로 자세한 기록이 필요하면 getTrace() 배열이나 예외 객체를 문자열로 변환한 전체를 쓰는 편이 낫습니다.
error_log()의 기본 목적지는 php.ini 설정을 따라가므로, 설정을 확인하지 않으면 로그가 어디에도 남지 않거나 웹서버 오류 로그에 뒤섞입니다.
14JSON 에러 응답 포맷
HTTP 상태 코드를 지정하고 ok 플래그와 메시지를 담은 JSON 문자열을 만들어 돌려줍니다. API의 오류 형식을 한 함수에 모아 두는 방식입니다. http_response_code는 헤더가 이미 나간 뒤에는 아무 효과가 없고, JSON_UNESCAPED_UNICODE는 한글을 \uXXXX로 바꾸지 않고 그대로 내보냅니다.
PHP code
<?php
function errorResponse(string $message, int $status = 400): string {
http_response_code($status);
return json_encode(['ok' => false, 'message' => $message], JSON_UNESCAPED_UNICODE);
}
echo errorResponse('invalid payload');오류 응답도 성공과 같은 최상위 구조를 유지하면 클라이언트가 분기를 한 번만 짜면 됩니다. 상태 코드와 본문의 의미가 어긋나지 않도록 한 함수에 묶어 두는 것이 핵심입니다.
이미 echo나 파일 앞의 공백이 출력됐다면 상태 코드 지정이 무시되어 실패에도 200이 나갑니다. 클라이언트가 성공으로 처리해 오류를 통째로 놓치게 됩니다.
정리하며
- set_error_handler로 경고를 ErrorException으로 승격해 실패 경로를 하나로 모읍니다.
- 치명적 오류는 register_shutdown_function과 error_get_last로만 기록할 수 있습니다.
- finally에서 return하거나 예외를 던지면 원래 예외가 사라지니 정리만 수행합니다.
- PDO는 연결 직후 ERRMODE_EXCEPTION을 지정해 질의 실패를 즉시 드러냅니다.
더 깊이 들어가고 싶다면 PHP 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.