오류를 읽는 능력이 실력이다
PHP 코드를 작성하다 보면 오류는 반드시 만난다. 초보자는 오류 메시지가 보이면 코드를 모두 지워야 할 것처럼 느끼지만, 오류 메시지는 사실 문제의 위치를 알려 주는 안내문에 가깝다. 파일명, 줄 번호, 오류 종류만 차분히 읽어도 원인을 좁힐 수 있는 경우가 많다.
좋은 개발자는 오류가 나지 않는 코드를 한 번에 쓰는 사람이 아니다. 오류가 났을 때 메시지를 읽고, 원인을 좁히고, 같은 문제가 다시 생기지 않도록 처리하는 사람이다.
이 장에서는 세 가지를 익힌다.
오류의 종류를 구분하는 법
개발 환경과 운영 환경에서 오류를 다르게 다루는 법
예외, 디버깅, 로그를 이용해 문제를 추적하는 법
앞에서 배운 데이터베이스, 객체지향, Composer 구조는 코드가 커질수록 더 유용해진다. 하지만 코드가 커질수록 문제도 여러 파일에 흩어진다. 그래서 이제부터는 "코드를 실행한다"에서 한 단계 더 나아가 "문제가 생겼을 때 추적한다"는 관점이 필요하다.
AI 생성 이미지
오류의 세 가지 종류
오류를 만나면 먼저 종류를 구분해야 한다. 그래야 어디부터 확인할지 정할 수 있다.
문법 오류
문법 오류는 PHP 문법 자체가 틀렸을 때 발생한다. 세미콜론을 빠뜨리거나, 괄호를 닫지 않거나, 따옴표 짝이 맞지 않는 경우가 대표적이다.
<?php
$name = 'PHP'
echo $name;위 코드는 첫 줄 끝에 세미콜론이 없다. 이런 경우 PHP는 코드를 실행하기 전에 멈춘다. 보통 Parse error, syntax error 같은 메시지가 나온다.
문법 오류를 볼 때는 오류 메시지에 표시된 파일명과 줄 번호를 먼저 확인한다. 단, 실제 원인은 표시된 줄 바로 위에 있을 때도 많다. 닫히지 않은 괄호나 따옴표 때문에 다음 줄에서야 오류가 드러날 수 있기 때문이다.
런타임 오류
런타임 오류는 문법은 맞지만 실행 중 문제가 생긴 경우다. 없는 파일을 읽거나, 존재하지 않는 함수를 호출하거나, 데이터베이스 연결에 실패하는 경우가 여기에 해당한다.
<?php
$content = file_get_contents(__DIR__ . '/missing.txt');파일이 없으면 경고가 발생하고, 함수는 원하는 파일 내용을 돌려주지 못한다. 데이터베이스 접속 정보가 틀렸다면 PDOException이 발생할 수 있다.
런타임 오류는 실행 조건에 따라 나타나기도 하고 나타나지 않기도 한다. 내 컴퓨터에서는 잘 되는데 서버에서는 실패하는 문제가 보통 여기에 속한다. 파일 경로, 권한, 환경 변수, 데이터베이스 계정, PHP 확장 설치 여부를 함께 확인해야 한다.
논리 오류
논리 오류는 가장 찾기 어렵다. PHP 입장에서는 문법도 맞고 실행도 되지만, 결과가 개발자의 의도와 다르다.
<?php
$price = 10000;
$discountRate = 10;
$finalPrice = $price * $discountRate; // 의도: 10% 할인
echo $finalPrice; // 100000위 코드는 실행된다. 하지만 10% 할인 가격을 구하려던 의도와 다르다. 올바른 코드는 다음에 가깝다.
<?php
$finalPrice = $price * (1 - $discountRate / 100);논리 오류는 오류 메시지가 나오지 않는다. 그래서 변수 값을 확인하고, 조건문이 어떤 경로로 실행되는지 따라가야 한다. 이때 디버깅 도구와 로그가 필요하다.
개발 환경의 오류 표시 설정
개발할 때는 오류를 자세히 보는 것이 좋다. 반대로 실제 서비스에서는 사용자에게 내부 오류를 그대로 보여 주면 안 된다. 파일 경로, SQL, 서버 구조 같은 민감한 정보가 노출될 수 있기 때문이다.
개발 환경에서는 보통 다음처럼 설정한다.
<?php
error_reporting(E_ALL);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');error_reporting(E_ALL)은 가능한 많은 오류를 보고하겠다는 뜻이다. display_errors는 오류를 화면에 보여 줄지 정한다. 개발 중에는 켜 두면 편하지만, 운영 서버에서는 꺼야 한다.
다만 이 설정은 PHP 코드가 실행된 뒤에 적용된다. 현재 파일 자체에 문법 오류가 있거나 PHP가 시작되는 단계에서 문제가 생기면 코드 안의 ini_set()까지 도달하지 못할 수 있다. 그래서 실무에서는 php.ini, 웹 서버 설정, 프레임워크 설정, 환경 변수 등으로 개발 환경과 운영 환경을 나누는 경우가 많다.
운영 환경에서는 화면 출력 대신 로그 기록을 사용한다.
<?php
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', __DIR__ . '/../storage/logs/php-error.log');여기서 중요한 점은 로그 파일 위치다. 가능하면 웹에서 직접 접근할 수 있는 public 폴더 아래에 로그를 두지 않는다. 사용자가 브라우저로 로그 파일을 열 수 있으면 안 된다.
이 책에서는 원리를 이해하기 위해 코드 예제로 보여 주지만, 핵심은 간단하다.
개발 환경: 오류를 화면에 자세히 표시한다.
운영 환경: 사용자에게는 일반 메시지를 보여 주고, 자세한 내용은 로그에 남긴다.
예외로 예상 가능한 문제 처리하기
예외는 "정상 흐름으로 처리하기 어려운 문제를 별도 흐름으로 넘기는 방법"이다. PHP에서는 try, catch, finally를 사용한다.
<?php
try {
$pdo = new PDO(
'mysql:host=localhost;dbname=app;charset=utf8mb4',
'app_user',
'secret',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
echo '데이터베이스 연결 성공';
} catch (PDOException $e) {
echo '데이터베이스 연결 중 문제가 발생했습니다.';
}try 안에는 실패할 수 있는 코드를 넣는다. catch는 문제가 생겼을 때 실행된다. 위 예제에서 실제 오류 메시지를 그대로 출력하지 않은 점이 중요하다. 사용자에게는 안전한 메시지를 보여 주고, 자세한 내용은 로그에 남기는 편이 좋다.
<?php
try {
$pdo = new PDO(
'mysql:host=localhost;dbname=app;charset=utf8mb4',
'app_user',
'secret',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
} catch (PDOException $e) {
error_log('[DB 연결 실패] ' . $e->getMessage());
echo '일시적인 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.';
}직접 예외를 던질 수도 있다. 입력값이 비어 있거나 규칙에 맞지 않을 때 유용하다.
<?php
function createPost(string $title, string $body): array
{
$title = trim($title);
$body = trim($body);
if ($title === '') {
throw new InvalidArgumentException('제목은 필수입니다.');
}
if (mb_strlen($title) > 100) {
throw new InvalidArgumentException('제목은 100자 이하로 입력해 주세요.');
}
return [
'title' => $title,
'body' => $body,
];
}이 함수는 잘못된 입력을 만나면 결과를 억지로 만들지 않고 예외를 던진다. 호출하는 쪽에서는 다음처럼 처리한다.
<?php
try {
$post = createPost($_POST['title'] ?? '', $_POST['body'] ?? '');
echo '게시글을 저장할 수 있습니다.';
} catch (InvalidArgumentException $e) {
echo htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8');
}입력 검증 메시지처럼 사용자가 알아야 하는 내용은 화면에 보여 줄 수 있다. 그래도 출력할 때는 htmlspecialchars()로 이스케이프하는 습관을 들이는 것이 좋다.
finally는 성공하든 실패하든 마지막에 실행되는 블록이다. 임시 파일 정리, 잠금 해제, 작업 종료 로그 등에 사용할 수 있다.
<?php
try {
echo '작업 시작';
// 실패할 수 있는 작업
} catch (Throwable $e) {
error_log($e->getMessage());
echo '작업 중 문제가 발생했습니다.';
} finally {
error_log('작업 종료');
}Throwable은 PHP에서 잡을 수 있는 오류와 예외의 공통 인터페이스다. 모든 문제를 무조건 Throwable로 잡는 습관은 좋지 않지만, 가장 바깥쪽에서 예상하지 못한 오류를 기록하고 안전한 화면을 보여 줄 때 사용할 수 있다.
변수와 실행 흐름 확인하기
오류 메시지가 없는데 결과가 이상하다면 값을 직접 확인해야 한다. 가장 기본적인 도구는 var_dump()와 print_r()이다.
<?php
$user = [
'id' => 1,
'name' => '민수',
'role' => 'admin',
];
var_dump($user);var_dump()는 값과 자료형을 함께 보여 준다. 문자열인지 숫자인지, null인지, 배열 구조가 어떻게 생겼는지 확인할 때 좋다.
print_r()은 배열을 조금 더 읽기 쉽게 보여 준다.
<?php
echo '<pre>';
print_r($user);
echo '</pre>';브라우저에서 확인할 때 <pre> 태그를 사용하면 줄바꿈과 들여쓰기가 유지되어 읽기 쉽다. 하지만 이런 디버깅 출력은 개발이 끝나면 반드시 제거해야 한다. 사용자 정보, 세션 값, 내부 경로가 노출될 수 있다.
조건문 흐름을 확인할 때는 중간에 표시를 남기는 방법도 있다.
<?php
if ($user['role'] === 'admin') {
var_dump('관리자 분기 실행');
} else {
var_dump('일반 사용자 분기 실행');
}다만 var_dump()를 여러 곳에 흩뿌리는 방식은 코드가 커질수록 지저분해진다. 규모가 커지면 Xdebug 같은 디버거를 사용하는 것이 좋다. 디버거는 코드의 특정 줄에서 실행을 멈추고, 변수 값과 호출 흐름을 한 단계씩 확인하게 해 주는 도구다.
처음부터 디버거 설정에 매달릴 필요는 없다. 하지만 다음 단계로 성장하려면 "출력해서 확인하기"에서 "멈춰서 관찰하기"로 넘어가야 한다.
로그로 운영 중 문제 추적하기
운영 중인 웹사이트에서는 사용자가 오류 화면을 캡처해서 보내 주지 않는다. 간혹 "안 돼요"라는 말만 남는다. 이때 서버에 로그가 없으면 문제를 재현하기 어렵다.
로그는 프로그램이 남기는 기록이다. 오류뿐 아니라 중요한 이벤트도 남길 수 있다.
로그인 성공과 실패
게시글 작성
결제 요청
파일 업로드 실패
데이터베이스 오류
외부 API 호출 실패
가장 간단한 방법은 error_log()를 사용하는 것이다.
<?php
error_log('게시글 저장 시도: user_id=3');파일을 직접 지정할 수도 있다.
<?php
$logFile = __DIR__ . '/../storage/logs/app.log';
$message = '[' . date('Y-m-d H:i:s') . '] 게시글 저장 실패' . PHP_EOL;
error_log($message, 3, $logFile);로그에는 시간을 남기는 것이 좋다. 가능하면 사용자 ID, 요청 URL, 처리하려던 작업도 함께 남긴다. 단, 비밀번호, 카드 번호, 인증 토큰 같은 민감한 값은 절대 그대로 기록하지 않는다.
조금 더 정리된 로그 함수를 만들 수 있다.
<?php
function app_log(string $level, string $message, array $context = []): void
{
$logFile = __DIR__ . '/../storage/logs/app.log';
$line = json_encode([
'time' => date('c'),
'level' => $level,
'message' => $message,
'context' => $context,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
error_log($line . PHP_EOL, 3, $logFile);
}사용 예는 다음과 같다.
<?php
try {
// 게시글 저장 코드
app_log('info', '게시글 저장 성공', ['user_id' => 3]);
} catch (Throwable $e) {
app_log('error', '게시글 저장 실패', [
'user_id' => 3,
'exception' => $e->getMessage(),
]);
echo '게시글 저장 중 문제가 발생했습니다.';
}로그를 JSON 형태로 남기면 나중에 검색하거나 분석하기 쉽다. 지금은 단순 파일 로그로 충분하지만, 실무에서는 Monolog 같은 라이브러리나 서버 로그 수집 도구를 사용하기도 한다.
작은 예제: 안전한 게시글 저장 흐름
지금까지 배운 내용을 합쳐 보자. 사용자가 게시글을 작성할 때는 입력 검증, 데이터베이스 처리, 예외 처리, 로그 기록이 함께 필요하다.
<?php
declare(strict_types=1);
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
try {
$title = trim($_POST['title'] ?? '');
$body = trim($_POST['body'] ?? '');
if ($title === '') {
throw new InvalidArgumentException('제목을 입력해 주세요.');
}
if (mb_strlen($title) > 100) {
throw new InvalidArgumentException('제목은 100자 이하로 입력해 주세요.');
}
$pdo = new PDO(
'mysql:host=localhost;dbname=app;charset=utf8mb4',
'app_user',
'secret',
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$stmt = $pdo->prepare(
'INSERT INTO posts (title, body, created_at) VALUES (:title, :body, NOW())'
);
$stmt->execute([
':title' => $title,
':body' => $body,
]);
echo '게시글이 저장되었습니다.';
} catch (InvalidArgumentException $e) {
echo e($e->getMessage());
} catch (PDOException $e) {
error_log('[DB 오류] ' . $e->getMessage());
echo '저장 중 문제가 발생했습니다.';
} catch (Throwable $e) {
error_log('[알 수 없는 오류] ' . $e->getMessage());
echo '일시적인 문제가 발생했습니다.';
}이 예제의 핵심은 오류를 숨기는 것이 아니다. 사용자에게 보여 줄 정보와 개발자가 확인할 정보를 분리하는 것이다.
사용자에게는 "저장 중 문제가 발생했습니다"처럼 안전하고 이해 가능한 메시지를 보여 준다. 개발자는 로그에서 실제 원인을 확인한다. 이것이 운영 가능한 코드의 기본 태도다.
실습 과제
일부러 문법 오류를 만들어 보고 오류 메시지의 파일명과 줄 번호를 확인해 보자. 세미콜론 누락, 괄호 누락, 따옴표 누락을 각각 실험한다.
개발용 설정 파일을 만들고
error_reporting(E_ALL),display_errors=1을 적용해 보자. 그다음 운영용 설정에서는display_errors=0,log_errors=1로 바꿔 본다.createPost()함수를 만들고 제목이 비어 있으면InvalidArgumentException을 던지게 하자. 호출하는 쪽에서는try/catch로 메시지를 출력한다.배열 데이터를
var_dump()와print_r()로 각각 출력해 보고 차이를 비교하자.storage/logs/app.log파일에 로그인 시도, 게시글 저장 성공, 게시글 저장 실패 로그를 남기는 함수를 작성해 보자.
자주 발생하는 오류 점검
화면이 하얗게만 보인다
오류 표시가 꺼져 있을 가능성이 있다. 개발 환경이라면 display_errors를 켜고, PHP 또는 웹 서버 오류 로그를 확인한다. 운영 환경에서는 화면 대신 로그를 먼저 확인한다.
오류 줄 번호를 봐도 원인을 모르겠다
표시된 줄만 보지 말고 바로 위쪽 코드도 확인한다. 닫히지 않은 괄호, 배열 대괄호, 따옴표, 세미콜론 누락은 다음 줄에서 오류로 드러나는 경우가 많다.
Undefined array key가 나온다
배열에 없는 키를 읽으려 했다는 뜻이다. 폼 입력이나 쿼리 문자열은 항상 존재한다고 가정하면 안 된다.
<?php
$name = $_POST['name'] ?? '';널 병합 연산자 ??를 사용해 기본값을 지정하자.
예외 메시지를 그대로 출력해도 될까?
입력 검증처럼 사용자가 이해해야 하는 메시지는 출력할 수 있다. 하지만 데이터베이스 오류, 파일 경로, SQL 내용, 서버 설정이 포함된 메시지는 그대로 보여 주면 안 된다. 이런 정보는 로그에 남기고, 화면에는 일반 메시지를 보여 준다.
로그 파일이 만들어지지 않는다
디렉터리가 없거나 PHP 프로세스에 쓰기 권한이 없을 수 있다. storage/logs 디렉터리가 실제로 있는지, 웹 서버가 해당 디렉터리에 파일을 쓸 수 있는지 확인한다. 로그 파일을 웹에서 직접 접근할 수 있는 위치에 두지 않았는지도 함께 점검하자.
오류 처리, 디버깅, 로깅은 화려한 기능은 아니다. 하지만 코드가 실제 사용자 앞에서 안정적으로 동작하게 만드는 핵심 기술이다. 다음 장에서는 여기서 한 걸음 더 나아가, 사용자 입력과 출력, 인증, 파일, 데이터베이스를 다룰 때 반드시 알아야 할 PHP 웹 보안의 기본을 정리한다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.