PHP · 심화
설계와 보안으로 깊어지는 PHP
예외 계층 설계 - 도메인 예외와 연결
Exception·Error 계층, 도메인 예외 클래스 설계, previous 로 원인 연결, finally 와 자원 정리, 전역 예외 처리기
개발자KR · 원고 갱신
이 장에서 배우는 것
기본서에서는 예외를 던지고 잡는 문법과 파일을 다룰 때의 기본 처리를 익혔다. 이 장은 한 단계 나아가 예외를 설계 대상으로 본다. 수강 신청 코드에서 "정원이 찼다"와 "데이터베이스가 응답하지 않는다"는 원인도 대응도 다른 사건이다. 둘을 같은 Exception 하나로 던지면 호출하는 쪽은 메시지 문자열을 읽어 구분하는 수밖에 없다. 이 장에서는 PHP 가 제공하는 예외 계층을 확인하고, 학원 시스템에 맞는 도메인 예외 계층을 만들고, 원인을 잃지 않고 연결하고, 마지막 방어선으로 전역 처리기를 둔다.
Throwable,Exception,Error의 관계를 설명하고 어떤 것을 잡아야 하는지 판단한다.- 업무 규칙 위반과 인프라 오류를 나누는 도메인 예외 계층을 설계한다.
previous인자로 원인 예외를 연결하고 사슬을 따라 읽는다.finally로 트랜잭션 같은 자원을 정리하고,finally안의return이 부르는 문제를 안다.- 전역 예외 처리기와 오류 처리기로 잡히지 않은 실패를 한곳에서 다룬다.
문제 상황
수강 신청 기능을 처음 만들 때는 보통 이렇게 쓴다. 정원이 차면 throw new Exception('정원 초과'), 중복 신청이면 throw new Exception('이미 신청함'), 저장에 실패하면 PDO 가 던진 예외를 그대로 둔다. 화면 쪽 코드는 다음과 같아진다.
try {
$service->enroll($code, $student);
} catch (Exception $e) {
if (str_contains($e->getMessage(), '정원')) {
// 대기 신청 안내
} else {
// 그 밖의 오류
}
}
이 코드는 세 가지 이유로 깨지기 쉽다. 메시지 문구를 고치면 분기가 조용히 틀어진다. 데이터베이스 오류의 SQL 문구가 수강생 화면에 그대로 나갈 수 있다. 그리고 "이미 신청함"이 실제로는 UNIQUE 제약 위반에서 나왔다는 사실이 예외를 다시 만드는 순간 사라진다. 장애가 났을 때 로그에는 "이미 신청함"만 남고 원인 쿼리는 찾을 수 없다.
해결의 방향은 두 가지다. 예외의 종류를 클래스로 나눠 catch 가 타입으로 분기하게 하고, 예외를 감쌀 때 원래 예외를 previous 로 붙들어 둔다.
Exception 과 Error: 던져지는 것의 계층
Throwable 아래의 두 갈래
PHP 에서 throw 할 수 있는 것은 Throwable 인터페이스(interface)를 구현한 객체뿐이다. 이 인터페이스를 직접 구현할 수는 없고, 구현체는 Exception 계열과 Error 계열 둘이다. 엔진이 던지는 오류는 대부분 Error 계열이다. 타입 선언 위반은 TypeError, 0 으로 나누기는 DivisionByZeroError 가 된다. 반면 Exception 계열은 프로그램이 정상적으로 만날 수 있는 실패, 곧 입력이 잘못되었거나 자원이 없는 경우를 나타낸다.
| 클래스 | 계열 | 던지는 상황 | 다루는 위치 |
|---|---|---|---|
| JsonException | Exception | JSON_THROW_ON_ERROR 로 해석 실패 | 입력을 받는 경계 |
| PDOException | RuntimeException | 예외 모드에서 SQL 실패 | 리포지토리 |
| TypeError | Error | 타입 선언 위반 | 호출 코드를 고친다 |
| DivisionByZeroError | ArithmeticError | 나누는 수가 0 | 입력 검증으로 예방한다 |
무엇을 잡을 것인가
catch (Exception $e) 는 Error 계열을 잡지 못한다. 이는 결함이 아니라 의도다. TypeError 는 코드가 잘못되었다는 신호이므로 업무 흐름 중간에서 삼킬 이유가 없다. 원칙은 단순하다. 업무 흐름 안에서는 구체적인 도메인 예외만 잡는다. Throwable 을 잡는 곳은 요청 하나를 감싸는 가장 바깥 경계와 전역 처리기로 한정한다. 이 장의 예제도 그렇게 쓴다.
도메인 예외 설계와 원인 연결
계층을 나누는 기준
예외 클래스를 많이 만든다고 설계가 좋아지는 것은 아니다. 기준은 "호출하는 쪽이 서로 다르게 대응하는가"이다. 수강 신청에서는 대응이 크게 둘로 갈린다. 정원 초과, 중복 신청, 없는 강좌는 수강생에게 이유를 알려 주면 되는 업무상 거절이다. 저장소 장애는 수강생에게 일반 안내만 보내고 운영자에게 알려야 한다. 그래서 뿌리 AcademyException 아래에 거절 계열의 EnrollmentRejected 를 두고, 저장소 오류는 그 옆에 둔다. 뿌리는 SPL 의 RuntimeException 에서 이어 받는다. 내장 DomainException 은 이름이 같아 혼동되므로 쓰지 않는다. 아래 표는 이 장의 예외와 대응을 정리한 것이다.
| 예외 | 부모 | 코드 | 대응 |
|---|---|---|---|
| CourseFullException | EnrollmentRejected | 1001 | 사유를 안내한다 |
| DuplicateEnrollmentException | EnrollmentRejected | 1002 | 사유를 안내한다 |
| CourseNotFoundException | EnrollmentRejected | 1003 | 사유를 안내한다 |
| StorageException | AcademyException | 2001 | 일반 안내, 운영자 기록 |
예외 객체에는 문장만이 아니라 값도 담는다. CourseFullException 은 강좌 코드와 정원을 readonly 속성으로 들고 있어서, 호출하는 쪽이 메시지를 파싱하지 않고도 값을 꺼낼 수 있다. 메시지는 생성자 안에서 만든다. 던지는 곳마다 문구가 달라지는 일을 막기 위해서다.
previous 로 원인 잇기
예외를 다른 예외로 바꿔 던질 때는 세 번째 인자인 previous 에 원래 예외를 넘긴다. 리포지토리는 SQLite 의 UNIQUE 위반을 PDOException 으로 받고, 이를 DuplicateEnrollmentException 으로 감싸 던진다. 위쪽 계층은 SQL 을 몰라도 되고, 로그에는 getPrevious() 로 원래 예외가 남는다.
이 감싸기는 계층 경계에서만 한다. 리포지토리가 저장소 언어를 도메인 언어로 번역하는 자리가 그 경계다. 모든 함수가 매번 감싸면 사슬만 길어지고 읽기 어려워진다.
finally 와 자원 정리
finally 블록은 try 가 정상으로 끝나든, catch 가 처리하든, 예외가 그대로 지나가든 실행된다. 열린 파일, 잠긴 자원, 시작한 트랜잭션처럼 "반드시 되돌려야 하는 것"을 정리하는 자리다. 이 장의 transactional() 은 catch 없이 try 와 finally 만 쓴다. 예외를 다룰 책임은 호출한 쪽에 남기고, 정리만 책임지는 구조다. 성공 여부를 $done 플래그로 기록해 두었다가, 끝까지 가지 못했을 때만 롤백한다.
전역 예외 처리기
아무도 잡지 않은 예외는 스크립트를 종료시키고 PHP 의 기본 오류 출력으로 이어진다. 운영 환경에서 이 출력에는 파일 경로와 메시지가 섞일 수 있다. set_exception_handler() 로 처리기를 등록하면 잡히지 않은 예외가 이 함수로 온다. 처리기 안에서는 거절 계열이면 사유를 보이고, 그 밖이면 내부 기록을 남기고 사용자에게는 일반 안내만 보낸다. 처리기가 끝나면 스크립트도 끝난다는 점에 유의한다. 실제 서비스의 처리기는 마지막에 exit 로 종료 코드를 정하는 것이 보통이다.
경고와 알림은 예외가 아니라 별도 경로로 나온다. set_error_handler() 에서 ErrorException 으로 바꿔 던지면 경고도 같은 방식으로 다룰 수 있다. 처리기가 다루지 못하는 치명적 종료까지 점검하려면 register_shutdown_function() 이 쓰이지만, 이 장에서는 다루지 않는다. 자세한 규칙은 PHP 매뉴얼의 예외 항목과 set_exception_handler 항목에서 확인한다.
완성 코드
파일 하나로 실행한다. 아래를 main.php 로 저장하고 php main.php 로 돌린다.
main.php
<?php
declare(strict_types=1);
abstract class AcademyException extends RuntimeException
{
}
abstract class EnrollmentRejected extends AcademyException
{
}
final class CourseFullException extends EnrollmentRejected
{
public function __construct(
public readonly string $courseCode,
public readonly int $capacity,
) {
parent::__construct("강좌 {$courseCode}: 정원 {$capacity}명이 모두 찼다", 1001);
}
}
final class DuplicateEnrollmentException extends EnrollmentRejected
{
public function __construct(
public readonly string $courseCode,
public readonly string $student,
?Throwable $previous = null,
) {
parent::__construct("강좌 {$courseCode}: 이미 신청한 수강생 {$student}", 1002, $previous);
}
}
final class CourseNotFoundException extends EnrollmentRejected
{
public function __construct(public readonly string $courseCode)
{
parent::__construct("강좌 {$courseCode}: 존재하지 않는다", 1003);
}
}
final class StorageException extends AcademyException
{
public function __construct(string $operation, ?Throwable $previous = null)
{
parent::__construct("저장소 작업 실패: {$operation}", 2001, $previous);
}
}
final class EnrollmentRepository
{
public function __construct(private PDO $pdo)
{
}
public function install(): void
{
$this->pdo->exec('CREATE TABLE courses (code TEXT PRIMARY KEY, title TEXT NOT NULL, capacity INTEGER NOT NULL)');
$this->pdo->exec('CREATE TABLE enrollments (course_code TEXT NOT NULL, student TEXT NOT NULL, UNIQUE (course_code, student))');
}
public function addCourse(string $code, string $title, int $capacity): void
{
$stmt = $this->pdo->prepare('INSERT INTO courses (code, title, capacity) VALUES (:code, :title, :capacity)');
$stmt->execute(['code' => $code, 'title' => $title, 'capacity' => $capacity]);
}
public function capacityOf(string $code): ?int
{
$stmt = $this->pdo->prepare('SELECT capacity FROM courses WHERE code = :code');
$stmt->execute(['code' => $code]);
$value = $stmt->fetchColumn();
return $value === false ? null : (int) $value;
}
public function countEnrolled(string $code): int
{
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM enrollments WHERE course_code = :code');
$stmt->execute(['code' => $code]);
return (int) $stmt->fetchColumn();
}
public function insertEnrollment(string $code, string $student): void
{
try {
$stmt = $this->pdo->prepare('INSERT INTO enrollments (course_code, student) VALUES (:code, :student)');
$stmt->execute(['code' => $code, 'student' => $student]);
} catch (PDOException $e) {
if ($e->getCode() === '23000') {
throw new DuplicateEnrollmentException($code, $student, $e);
}
throw new StorageException('수강 신청 저장', $e);
}
}
public function attendanceCount(string $code): int
{
try {
$stmt = $this->pdo->prepare('SELECT COUNT(*) FROM attendance WHERE course_code = :code');
$stmt->execute(['code' => $code]);
return (int) $stmt->fetchColumn();
} catch (PDOException $e) {
throw new StorageException('출결 집계', $e);
}
}
public function transactional(callable $work): void
{
$this->pdo->beginTransaction();
$done = false;
try {
$work();
$this->pdo->commit();
$done = true;
} finally {
if ($done === false && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
echo " [정리] 롤백했다\n";
}
}
}
}
final class EnrollmentService
{
public function __construct(private EnrollmentRepository $repo)
{
}
public function enroll(string $code, string $student): void
{
$this->repo->transactional(function () use ($code, $student): void {
$capacity = $this->repo->capacityOf($code)
?? throw new CourseNotFoundException($code);
if ($this->repo->countEnrolled($code) >= $capacity) {
throw new CourseFullException($code, $capacity);
}
$this->repo->insertEnrollment($code, $student);
});
}
}
function seatsLeft(int $capacity, int $taken): int
{
return $capacity - $taken;
}
function classify(callable $job): string
{
try {
$job();
return '예외 없음';
} catch (Exception $e) {
return 'Exception 계열 (' . $e::class . ')';
} catch (Error $e) {
return 'Error 계열 (' . $e::class . ')';
}
}
function chain(Throwable $e): string
{
$names = [];
for ($c = $e; $c instanceof Throwable; $c = $c->getPrevious()) {
$names[] = $c::class;
}
return implode(' <- ', $names);
}
function attempt(EnrollmentService $service, string $code, string $student): void
{
try {
$service->enroll($code, $student);
echo "신청 성공: {$code} {$student}\n";
} catch (EnrollmentRejected $e) {
echo "거절({$e->getCode()}): {$e->getMessage()}\n";
if ($e->getPrevious() instanceof Throwable) {
echo ' 원인 사슬: ' . chain($e) . "\n";
}
}
}
$raw = '30';
$zero = 0;
echo "== 1. Exception 과 Error ==\n";
$jobs = [
'JSON 해석' => fn() => json_decode('{', flags: JSON_THROW_ON_ERROR),
'0으로 나누기' => fn() => intdiv(10, $zero),
'타입 불일치' => fn() => seatsLeft($raw, 5),
'도메인 규칙' => fn() => throw new CourseNotFoundException('GO999'),
];
foreach ($jobs as $label => $job) {
echo "{$label}: " . classify($job) . "\n";
}
echo "\n== 2. 도메인 예외 ==\n";
$pdo = new PDO('sqlite::memory:', options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$repo = new EnrollmentRepository($pdo);
$repo->install();
$repo->addCourse('PHP101', 'PHP 입문', 2);
$service = new EnrollmentService($repo);
attempt($service, 'PHP101', '하늘');
attempt($service, 'PHP101', '하늘');
attempt($service, 'PHP101', '보람');
attempt($service, 'PHP101', '새봄');
attempt($service, 'GO999', '민수');
echo "\n== 3. 원인 연결 ==\n";
try {
$repo->attendanceCount('PHP101');
} catch (AcademyException $e) {
echo "저장소 오류({$e->getCode()}): {$e->getMessage()}\n";
echo '원인 사슬: ' . chain($e) . "\n";
}
echo "\n== 4. 경고를 예외로 ==\n";
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
throw new ErrorException($message, 0, $severity, $file, $line);
});
try {
$row = [];
echo $row['x'];
} catch (ErrorException $e) {
echo '경고가 예외로: ' . $e::class . ' / ' . $e->getMessage() . "\n";
}
echo "\n== 5. 전역 처리기 ==\n";
set_exception_handler(static function (Throwable $e): void {
if ($e instanceof EnrollmentRejected) {
echo "[처리기] 요청 거절: {$e->getMessage()}\n";
return;
}
echo '[처리기] 내부 오류 기록: ' . $e::class . "\n";
echo '[처리기] 원인 사슬: ' . chain($e) . "\n";
echo "[처리기] 사용자에게는 일반 안내만 보낸다\n";
});
$repo->attendanceCount('PHP101');
echo "이 줄은 실행되지 않는다\n";
줄별 해설
예외 클래스 선언. AcademyException 과 EnrollmentRejected 는 abstract 로 선언해 직접 new 하지 못하게 했다. 던질 수 있는 것은 구체적인 사건을 나타내는 final 클래스뿐이다. 구체 클래스는 생성자 승격(promotion)으로 readonly 속성을 만들고, parent::__construct() 에 메시지와 코드, 필요하면 previous 를 넘긴다. DuplicateEnrollmentException 만 ?Throwable $previous = null 을 받는 것은, 원인이 있을 때만 사슬이 생기기 때문이다.
insertEnrollment(). SQLite 의 제약 위반은 SQLSTATE 23000 으로 보고되고, PDOException::getCode() 가 이 문자열을 돌려준다. 이 값이면 중복 신청으로 번역하고, 그렇지 않으면 StorageException 으로 감싼다. 두 경우 모두 $e 를 previous 로 넘긴다. attendanceCount() 는 아직 만들지 않은 attendance 테이블을 조회해 저장소 오류를 일부러 일으킨다.
transactional(). $done 이 true 가 되는 것은 commit() 까지 끝난 뒤다. 도중에 예외가 나면 finally 에서 $done 이 false 인 것을 보고 롤백한다. inTransaction() 검사는 커밋 자체가 실패해 이미 트랜잭션이 끝난 경우에 rollBack() 이 또 예외를 던져 원래 예외를 덮는 일을 막는다. catch 가 없으므로 예외는 그대로 위로 올라간다.
EnrollmentService::enroll(). ?? throw 는 값이 null 일 때 예외를 던지는 식이다. 강좌가 없으면 CourseNotFoundException, 정원이 찼으면 CourseFullException, 아니면 삽입한다. 서비스에는 try 가 없다. 번역은 리포지토리가, 처리는 호출한 쪽이 맡는다.
classify(). catch 를 Exception, Error 순으로 두어 어느 계열인지 보여 준다. $e::class 는 객체의 클래스 이름 문자열이다. 타입 불일치는 파일 첫머리의 declare(strict_types=1) 때문에 '30' 을 int 로 바꾸지 않고 TypeError 를 던진다.
chain(). getPrevious() 가 Throwable 이 아닐 때까지 거슬러 올라가며 클래스 이름을 모은다. 반복 조건을 instanceof 로 쓰면 null 에서 자연히 멈춘다.
attempt(). EnrollmentRejected 만 잡는다. 저장소 오류는 여기서 잡히지 않고 위로 올라간다. 업무 거절만 이 자리에서 처리한다는 결정이 코드에 그대로 드러난다.
섹션 4. 오류 처리기가 경고를 ErrorException 으로 바꾼다. 존재하지 않는 배열 키를 읽으면 경고가 나고, 처리기가 이를 던지므로 catch 가 받는다. 이 처리기는 이후 코드에도 계속 적용된다.
섹션 5. 마지막 호출은 잡는 곳이 없다. 예외가 set_exception_handler() 의 함수로 가고, StorageException 은 거절 계열이 아니므로 내부 오류 경로로 간다. 처리기가 끝나면 스크립트가 끝나므로 다음 echo 는 실행되지 않는다.
실행 결과
$ php main.php
== 1. Exception 과 Error ==
JSON 해석: Exception 계열 (JsonException)
0으로 나누기: Error 계열 (DivisionByZeroError)
타입 불일치: Error 계열 (TypeError)
도메인 규칙: Exception 계열 (CourseNotFoundException)
== 2. 도메인 예외 ==
신청 성공: PHP101 하늘
[정리] 롤백했다
거절(1002): 강좌 PHP101: 이미 신청한 수강생 하늘
원인 사슬: DuplicateEnrollmentException <- PDOException
신청 성공: PHP101 보람
[정리] 롤백했다
거절(1001): 강좌 PHP101: 정원 2명이 모두 찼다
[정리] 롤백했다
거절(1003): 강좌 GO999: 존재하지 않는다
== 3. 원인 연결 ==
저장소 오류(2001): 저장소 작업 실패: 출결 집계
원인 사슬: StorageException <- PDOException
== 4. 경고를 예외로 ==
경고가 예외로: ErrorException / Undefined array key "x"
== 5. 전역 처리기 ==
[처리기] 내부 오류 기록: StorageException
[처리기] 원인 사슬: StorageException <- PDOException
[처리기] 사용자에게는 일반 안내만 보낸다
두 번째 신청에서 롤백 줄이 거절 줄보다 먼저 나오는 것을 눈여겨본다. finally 는 예외가 호출한 쪽의 catch 에 닿기 전에 실행된다. 브라우저에서 확인하고 싶다면 이 서비스를 호출하는 index.php 를 만들어 php -S 127.0.0.1:8000 으로 띄우면 된다. 그 경우 전역 처리기 안에서 응답 코드를 설정하고 본문을 보내면 된다.
실무에서 자주 틀리는 것
1. catch (Exception) 으로 모든 실패를 잡았다고 믿는다
틀린 코드:
try {
$left = seatsLeft($raw, 5);
} catch (Exception $e) {
echo '처리했다';
}
$raw 가 문자열이면 TypeError 가 던져지는데 이는 Error 계열이라 이 catch 를 지나친다. 고친 코드는 호출 쪽에서 타입을 맞추고, 업무 흐름에서는 도메인 예외만 잡는다. 모든 것을 받아야 하는 곳은 최외곽 경계 한 곳이다.
$left = seatsLeft((int) $raw, 5);
// 최외곽 경계에서만
try {
run();
} catch (Throwable $e) {
// 기록하고 일반 안내
}
2. 감싸면서 원인을 버린다
틀린 코드:
} catch (PDOException $e) {
throw new StorageException('수강 신청 저장');
}
새 예외에는 원래 SQL 오류가 남지 않는다. 장애 분석에서 가장 필요한 정보가 사라진다. 고친 코드는 $e 를 세 번째 인자로 넘긴다.
} catch (PDOException $e) {
throw new StorageException('수강 신청 저장', $e);
}
3. finally 안에서 return 한다
틀린 코드:
function save(): string
{
try {
throw new StorageException('저장');
} finally {
return '완료';
}
}
이 함수는 예외 없이 '완료' 를 돌려준다. finally 의 return 이 대기 중이던 예외를 버리기 때문이다. 실패가 성공처럼 보이는 가장 찾기 어려운 형태다. finally 에는 정리만 두고, 반환은 밖에서 한다.
function save(): string
{
try {
throw new StorageException('저장');
} finally {
echo "정리\n";
}
}
4. 내부 메시지를 사용자 응답에 그대로 쓴다
틀린 코드:
} catch (Throwable $e) {
echo $e->getMessage();
}
PDOException 의 메시지에는 테이블 이름과 SQL 조각이 들어 있다. 고친 코드는 거절 계열만 메시지를 보이고, 나머지는 기록한 뒤 고정 문구를 보낸다. 앞의 전역 처리기가 같은 구조다.
} catch (EnrollmentRejected $e) {
echo $e->getMessage();
} catch (Throwable $e) {
error_log(chain($e));
echo '잠시 후 다시 시도해 달라';
}
한눈에 보기
| 도구 | 역할 | 쓰는 위치 | 주의 |
|---|---|---|---|
| Exception 계열 | 예상할 수 있는 실패 | 업무 흐름 | 구체 타입으로 잡는다 |
| Error 계열 | 코드의 결함 신호 | 고치는 대상 | 중간에서 삼키지 않는다 |
| 도메인 예외 계층 | 대응이 다른 실패를 타입으로 구분 | 서비스와 리포지토리 | 대응이 같으면 합친다 |
| previous | 원인 보존 | 계층 경계에서 감쌀 때 | 빠뜨리면 원인이 사라진다 |
| finally | 성공과 실패 모두의 정리 | 트랜잭션, 자원 해제 | return 을 두지 않는다 |
| 전역 처리기 | 잡히지 않은 예외의 마지막 처리 | 진입 지점 | 끝나면 스크립트도 끝난다 |
연습 문제
- 수강 신청 마감 후의 신청을 거절하는
EnrollmentClosedException을 추가한다. 코드는 1004 로 하고, 어느 클래스를 상속해야attempt()를 고치지 않고도 거절로 처리되는지 쓰라. - 다음 코드에서 두 번째
catch가 실행되지 않는 이유를 설명하고 고쳐라.try { $service->enroll('PHP101', '하늘'); } catch (AcademyException $e) { echo '일반 처리'; } catch (CourseFullException $e) { echo '정원 안내'; } - 다음 코드의 출력을 쓰라.
function probe(): string { try { echo 'A'; throw new RuntimeException('x'); } catch (RuntimeException) { echo 'B'; return 'C'; } finally { echo 'D'; } } echo probe(), "\n"; - 다음 코드가 원인을 잃는 부분을 찾아 고쳐라.
try { $repo->insertEnrollment($code, $student); } catch (StorageException $e) { throw new RuntimeException('신청 처리 중 오류'); }
정답과 해설
EnrollmentRejected를 상속한다.attempt()는EnrollmentRejected를 잡으므로 새 클래스는 자동으로 거절 경로를 탄다.
계층에 맞게 두었기 때문에 호출하는 쪽은 수정이 필요 없다. 이것이 부모를 거절 계열로 나눈 이유다.final class EnrollmentClosedException extends EnrollmentRejected { public function __construct(public readonly string $courseCode) { parent::__construct("강좌 {$courseCode}: 신청이 마감되었다", 1004); } }- 더 넓은
AcademyException이 먼저 있으면CourseFullException도 그쪽에서 잡힌다.catch는 위에서부터 처음 맞는 것 하나만 실행한다. 좁은 타입을 위에 둔다.} catch (CourseFullException $e) { echo '정원 안내'; } catch (AcademyException $e) { echo '일반 처리'; } - 출력은
ABDC다음에 줄바꿈이다. 순서는 이렇다.A를 출력하고 예외가 던져져catch가B를 출력한다.return 'C'의 값은 보관된 채로finally가 먼저 실행되어D를 출력하고, 그 뒤에 함수가'C'를 돌려주어 바깥의echo가C를 출력한다. - 새로 던지는
RuntimeException에$e를 넘기지 않아StorageException과 그 밑의PDOException이 모두 사라진다. 세 번째 인자로 넘긴다. 코드 인자 자리에 0 을 둔다.
이미 도메인 예외가 있다면 새로 감싸지 않고 그대로 두는 편이 낫다. 계층이 바뀌는 경계에서만 번역한다.throw new RuntimeException('신청 처리 중 오류', 0, $e);
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.