예외와 파일 - JSON 으로 저장하기
이 장에서 배우는 것
앞 장까지는 프로그램이 정상적으로 흘러가는 경우만 다뤘다. 실제 사이트에서는 손님이 없는 빵을 주문하거나, 저장 파일이 반쯤만 써져 있거나, 두 사람이 동시에 주문하는 일이 생긴다. 이 장에서는 그런 상황을 코드로 구분해 처리하는 예외(exception)의 구조를 배운다. 그다음 빵집 주문을 파일에 JSON 형식으로 저장하고 다시 읽는 프로그램을 만든다.
- try, catch, finally 가 실행되는 순서를 설명할 수 있다.
- 내장 예외 클래스를 상속해 사용자 정의 예외를 만들고, 필요한 값을 담아 던질 수 있다.
- file_get_contents, file_put_contents, flock 으로 파일을 읽고 잠그며 쓸 수 있다.
- json_encode 와 json_decode 의 실패를 JSON_THROW_ON_ERROR 로 예외로 받아 처리할 수 있다.
문제 상황
빵집 사이트에 예약 주문이 들어오기 시작했다. 아직 데이터베이스는 배우지 않았으므로 주문을 orders.json 파일 하나에 목록으로 쌓기로 했다. 그런데 다음 일들이 곧바로 문제가 된다.
- 손님이 메뉴에 없는 빵을 적거나 수량에 문자열을 넣는다. 이런 입력은 저장하지 않고 이유를 알려 줘야 한다.
- 서버가 저장 중에 멈춰 파일이 중간에서 끊긴다. 다음에 읽을 때 프로그램이 이유 없이 빈 목록으로 시작하면 기존 주문이 조용히 사라진다.
- 두 손님이 거의 동시에 주문한다. 각자 파일을 읽고 한 건씩 더해 쓰면 나중에 쓴 쪽이 앞의 주문을 덮어쓴다.
세 문제의 공통점은 실패했을 때 프로그램이 어떻게 행동할지 미리 정해 두지 않으면 데이터가 말없이 망가진다는 점이다. 예외와 잠금은 이 실패를 눈에 보이게 만드는 도구다.
예외: try, catch, finally
예외는 함수 안에서 "여기서는 계속할 수 없다"고 알리는 신호다. throw 로 예외 객체를 던지면 그 함수의 나머지 코드는 실행되지 않고, 호출한 쪽으로 거슬러 올라가며 그 예외를 받아 줄 catch 를 찾는다. 끝까지 받는 곳이 없으면 프로그램이 오류 메시지와 함께 멈춘다.
try {
// 실패할 수 있는 작업
} catch (InvalidOrderException $e) {
// 이 종류의 예외가 던져졌을 때만 실행
} finally {
// 성공하든 실패하든 마지막에 실행
}
세 블록의 역할은 다음과 같다.
- try: 실패할 수 있는 코드를 둔다. 예외가 던져지면 그 줄에서 바로 빠져나간다.
- catch: 괄호 안에 적은 클래스(와 그 자식 클래스)의 예외만 받는다. 여러 개를 이어 쓸 수 있고 위에서부터 차례로 맞는지 본다.
- finally: 예외가 있었든 없었든, try 안에서 return 을 했든 실행된다. 파일을 닫는 것처럼 반드시 해야 하는 뒷정리를 둔다.
사용자 정의 예외
PHP 에는 RuntimeException, InvalidArgumentException, JsonException 같은 내장 예외 클래스가 있다. 모두 Throwable 인터페이스를 구현한 Exception 계열이다. 우리 프로그램의 실패에 이름을 붙이려면 이 중 알맞은 것을 상속해 새 클래스를 만든다. 앞 장에서 배운 상속과 생성자 승격이 그대로 쓰인다.
final class InvalidOrderException extends InvalidArgumentException
{
public function __construct(public readonly string $field, string $message)
{
parent::__construct($message);
}
}
이렇게 하면 catch 하는 쪽에서 "주문 입력이 잘못됐다"와 "저장소에 문제가 생겼다"를 클래스로 구분할 수 있고, 어느 입력 칸이 문제인지도 $e->field 로 꺼낼 수 있다. 예외 객체에는 이미 file, line 이라는 속성이 있으므로 같은 이름으로 속성을 만들면 안 된다.
| 클래스 | 부모 | 던지는 상황 | catch 하는 쪽 |
|---|---|---|---|
| InvalidOrderException | InvalidArgumentException | 이름, 빵, 수량 검사 실패 | 주문 받는 코드 |
| StorageException | RuntimeException | 파일 열기·쓰기·JSON 해석 실패 | 저장소를 쓰는 코드 |
| JsonException | Exception | json_encode·json_decode 실패 | 저장소 함수 안쪽 |
예외를 감싸서 다시 던지기
저장소를 쓰는 쪽은 JSON 라이브러리의 세부 사항을 알 필요가 없다. 그래서 JsonException 을 잡아 우리 예외로 바꿔 던지되, 원래 예외를 세 번째 인수(previous)로 넘겨 원인을 보존한다. 나중에 getPrevious() 로 꺼내 볼 수 있다.
파일 읽기와 쓰기, 그리고 잠금
file_get_contents($path) 는 파일 전체를 문자열로 돌려주고, 실패하면 false 를 돌려준다. file_put_contents($path, $text) 는 문자열을 파일에 쓰고 쓴 바이트 수를 돌려주며, 실패하면 역시 false 다. 이 두 함수는 예외를 던지지 않는다. 반환값이 false 인지 직접 확인해서 우리 예외로 바꿔야 한다. 없는 파일을 읽으면 경고 메시지도 함께 출력되므로, 읽기 전에 is_file() 로 확인하는 편이 깔끔하다.
세 번째 인수에 LOCK_EX 를 주면 쓰는 동안 독점 잠금(exclusive lock)을 건다. 다른 프로세스가 같은 파일에 잠금을 걸고 쓰려 하면 앞의 쓰기가 끝날 때까지 기다린다. 다만 이 잠금은 그 함수 호출 하나 동안만 유지된다.
읽고, 고치고, 쓰는 사이의 틈
주문 한 건을 추가하려면 "읽기, 목록에 더하기, 쓰기" 세 단계가 필요하다. 각 단계를 따로 잠그면 읽은 뒤 쓰기 전에 다른 프로세스가 끼어들 수 있다. 아래 그림의 왼쪽이 그런 경우다. 두 프로세스가 모두 1건짜리 파일을 읽고 각각 2건으로 써서, 나중에 쓴 쪽이 앞선 주문을 지운다.
해결은 파일을 fopen 으로 열어 두고 flock($handle, LOCK_EX) 로 잠근 채 읽기부터 쓰기까지 모두 한 번에 하는 것이다. 여기서 두 가지를 조심한다.
- 열기 모드는
'c+'를 쓴다. 읽고 쓸 수 있고, 파일이 없으면 만들며, 열 때 내용을 지우지 않는다.'w+'는 잠금을 얻기 전에 파일을 비워 버린다. - 잠금은 서로 협조하는 방식(advisory lock)이다. 같은 방식으로 잠그는 코드끼리만 서로를 기다린다. 이 파일을 다루는 모든 코드가 같은 함수를 거치게 만들어야 한다.
| 함수 | 성공 시 | 실패 시 | 예외를 던지는가 |
|---|---|---|---|
| file_get_contents | 파일 내용 문자열 | false | 아니오 |
| file_put_contents | 쓴 바이트 수 | false | 아니오 |
| fopen | 파일 핸들 | false | 아니오 |
| flock | true | false | 아니오 |
JSON 인코딩과 디코딩의 오류 처리
JSON 은 값을 문자열로 적는 텍스트 형식이다. json_encode 는 배열을 JSON 문자열로, json_decode 는 JSON 문자열을 배열이나 객체로 바꾼다. 두 번째 인수에 true 를 주면 JSON 객체를 연관 배열로 돌려준다.
기본 상태에서 이 함수들은 실패해도 예외를 던지지 않는다. json_encode 는 false, json_decode 는 null 을 돌려줄 뿐이다. 그런데 JSON 문서 null 을 디코딩해도 결과가 null 이라서 반환값만으로는 실패와 성공을 구분할 수 없다. 옵션에 JSON_THROW_ON_ERROR 를 넣으면 실패할 때 JsonException 이 던져지고, 오류 종류는 $e->getCode() 가 JSON_ERROR_SYNTAX, JSON_ERROR_UTF8 같은 상수와 같은지로 판별한다.
저장할 때는 JSON_UNESCAPED_UNICODE 를 함께 주면 한글이 \uAC00 같은 코드 대신 그대로 적혀 파일을 열어 읽기 쉽다. JSON_PRETTY_PRINT 는 들여쓰기를 넣어 준다. 공식 문서는 JSON 확장 설명과 flock 설명에서 확인할 수 있다.
| 방식 | 실패 신호 | 오류 원인 확인 | 문제점 |
|---|---|---|---|
| 옵션 없음 | false 또는 null | json_last_error() | null 문서와 구분이 어렵다 |
| JSON_THROW_ON_ERROR | JsonException | getCode() | catch 를 써야 한다 |
완성 코드
아래 파일 하나가 전체 프로그램이다. 저장 파일은 tempnam 으로 임시 위치에 만들고 끝날 때 지우므로, 몇 번을 실행해도 같은 결과가 나온다.
main.php
<?php
declare(strict_types=1);
const BREADS = ['소금빵', '식빵', '바게트'];
final class StorageException extends RuntimeException
{
}
final class InvalidOrderException extends InvalidArgumentException
{
public function __construct(public readonly string $field, string $message)
{
parent::__construct($message);
}
}
function validateOrder(array $input): array
{
$name = $input['name'] ?? '';
if (is_string($name) === false || trim($name) === '') {
throw new InvalidOrderException('name', '이름이 비어 있다');
}
$bread = $input['bread'] ?? '';
if (is_string($bread) === false || in_array($bread, BREADS, true) === false) {
throw new InvalidOrderException('bread', '메뉴에 없는 빵이다');
}
$quantity = $input['quantity'] ?? 0;
if (is_int($quantity) === false || $quantity < 1 || $quantity > 20) {
throw new InvalidOrderException('quantity', '수량은 1 이상 20 이하의 정수여야 한다');
}
return ['name' => trim($name), 'bread' => $bread, 'quantity' => $quantity];
}
function decodeOrders(string $text): array
{
if (trim($text) === '') {
return [];
}
try {
$data = json_decode($text, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new StorageException('주문 파일의 JSON 형식이 깨졌다', 0, $e);
}
if (is_array($data) === false || array_is_list($data) === false) {
throw new StorageException('주문 파일의 최상위 값이 목록이 아니다');
}
return $data;
}
function encodeOrders(array $orders): string
{
try {
$flags = JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT;
return json_encode($orders, $flags) . "\n";
} catch (JsonException $e) {
throw new StorageException('주문을 JSON 으로 바꾸지 못했다', 0, $e);
}
}
function loadOrders(string $path): array
{
if (is_file($path) === false) {
return [];
}
$text = file_get_contents($path);
if ($text === false) {
throw new StorageException('주문 파일을 읽지 못했다');
}
return decodeOrders($text);
}
function writeWhole(string $path, string $text): void
{
if (file_put_contents($path, $text, LOCK_EX) === false) {
throw new StorageException('주문 파일을 쓰지 못했다');
}
}
function addOrder(string $path, array $order): int
{
$handle = fopen($path, 'c+');
if ($handle === false) {
throw new StorageException('주문 파일을 열지 못했다');
}
try {
if (flock($handle, LOCK_EX) === false) {
throw new StorageException('주문 파일의 잠금을 얻지 못했다');
}
$text = stream_get_contents($handle);
if ($text === false) {
throw new StorageException('주문 파일을 읽지 못했다');
}
$orders = decodeOrders($text);
$orders[] = $order;
$json = encodeOrders($orders);
ftruncate($handle, 0);
rewind($handle);
if (fwrite($handle, $json) === false) {
throw new StorageException('주문 파일을 쓰지 못했다');
}
fflush($handle);
return count($orders);
} finally {
fclose($handle);
}
}
function runDemo(string $path): void
{
$inputs = [
['name' => '김하늘', 'bread' => '소금빵', 'quantity' => 3],
['name' => ' ', 'bread' => '식빵', 'quantity' => 1],
['name' => '이서준', 'bread' => '크루아상', 'quantity' => 2],
['name' => '박민지', 'bread' => '바게트', 'quantity' => 2],
['name' => '최도윤', 'bread' => '식빵', 'quantity' => '5'],
];
echo "== 1. 주문 받기 ==\n";
foreach ($inputs as $input) {
try {
$order = validateOrder($input);
$count = addOrder($path, $order);
echo "저장: {$order['name']} {$order['bread']} {$order['quantity']}개 (총 {$count}건)\n";
} catch (InvalidOrderException $e) {
echo "거절: [{$e->field}] {$e->getMessage()}\n";
}
}
echo "== 2. 저장된 파일 ==\n";
echo file_get_contents($path);
echo "== 3. 깨진 파일 ==\n";
writeWhole($path, '[{"name": "김하늘",');
try {
loadOrders($path);
} catch (StorageException $e) {
echo "읽기 실패: {$e->getMessage()}\n";
$cause = $e->getPrevious();
echo '원인이 JsonException 인가: ' . ($cause instanceof JsonException ? '예' : '아니오') . "\n";
echo '문법 오류 코드인가: ' . ($cause?->getCode() === JSON_ERROR_SYNTAX ? '예' : '아니오') . "\n";
}
writeWhole($path, encodeOrders([]));
echo '복구 뒤 건수: ' . count(loadOrders($path)) . "\n";
echo "== 4. 인코딩 오류 ==\n";
$bad = ['name' => "\xB1\x31"];
echo '플래그 없이: ' . var_export(json_encode($bad), true) . "\n";
try {
json_encode($bad, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
echo 'JsonException, UTF-8 오류 코드인가: ' . ($e->getCode() === JSON_ERROR_UTF8 ? '예' : '아니오') . "\n";
}
echo "== 5. 없는 파일 ==\n";
echo '없는 파일은 빈 목록: ' . count(loadOrders($path . '.none')) . "건\n";
}
$path = tempnam(sys_get_temp_dir(), 'bakery');
if ($path === false) {
fwrite(STDERR, "임시 파일을 만들지 못했다\n");
exit(1);
}
try {
runDemo($path);
} finally {
unlink($path);
echo "임시 파일을 지웠다\n";
}
줄별 해설
선언부
declare(strict_types=1)은 인수 타입을 엄격하게 검사한다. 수량'5'는 문자열이므로is_int검사에서 걸린다.BREADS상수는 메뉴 목록이다.in_array의 세 번째 인수true는 앞서 배운 엄격 비교를 쓰겠다는 뜻이다.- 두 예외 클래스는
final이다.InvalidOrderException은 생성자 승격으로field를 읽기 전용 속성으로 갖고, 메시지는 부모 생성자에 넘긴다.
validateOrder
입력 배열에서 값을 꺼낼 때 ?? '' 로 없는 키를 기본값으로 바꾼다. 이름, 빵, 수량을 차례로 검사하다가 처음 틀린 항목에서 바로 예외를 던진다. 통과하면 정리된 배열을 돌려준다. 이 함수는 파일을 전혀 건드리지 않으므로 저장 실패와 입력 실패가 섞이지 않는다.
decodeOrders, encodeOrders
decodeOrders 는 빈 문자열이면 빈 목록을 돌려준다. 막 만든 빈 파일을 처음 열 때를 위한 처리다. 그 외에는 JSON_THROW_ON_ERROR 로 디코딩하고, 실패하면 StorageException 으로 감싸 던진다. 세 번째 인수 0 은 오류 코드 자리이고, 네 번째로 원래 예외 $e 를 넘겨 원인을 남긴다. 성공해도 최상위 값이 목록이 아니면(array_is_list) 형식이 어긋난 것으로 보고 거절한다. encodeOrders 는 같은 방식으로 인코딩 실패를 감싼다.
loadOrders, writeWhole
loadOrders 는 파일이 없으면 빈 목록을 돌려주고, 있으면 읽어서 decodeOrders 에 맡긴다. writeWhole 은 LOCK_EX 로 파일 전체를 한 번에 쓰고 false 를 예외로 바꾼다. 이 프로그램에서는 깨진 파일을 흉내 내고 빈 목록으로 되돌릴 때 쓴다.
addOrder
fopen($path, 'c+')로 열고 실패를 확인한다. 이 시점에는 아직 try 안이 아니다. 열지 못했다면 닫을 것도 없기 때문이다.- try 안에서
flock으로 독점 잠금을 얻는다. 이후 읽기, 목록에 더하기, 인코딩, 쓰기가 모두 잠금 안에서 이뤄진다. - 인코딩(
encodeOrders)을ftruncate앞에서 한다. 인코딩이 실패해도 파일이 이미 비워진 뒤는 아니어야 하기 때문이다. ftruncate로 내용을 비우고rewind로 쓰기 위치를 처음으로 돌린 뒤 쓴다.- finally 의
fclose는 return 이 실행되든 예외가 던져지든 실행된다. 핸들을 닫으면 잠금도 풀린다.
runDemo
다섯 개의 입력 중 셋은 검증에서 걸린다. 반복문 안의 try 는 InvalidOrderException 만 받는다. 그래서 잘못된 입력은 거절하고 다음 손님으로 넘어가지만, 저장소의 StorageException 은 잡히지 않고 위로 올라간다. 저장소 문제는 입력 하나를 건너뛰어 해결할 수 없기 때문이다.
3번 단계는 끊긴 JSON 을 파일에 써 놓고 읽어 본다. 잡은 StorageException 의 getPrevious() 로 원래 JsonException 과 오류 코드를 확인한다. ?-> 는 앞의 값이 null 이면 null 을 돌려주는 연산자다. 4번 단계의 "\xB1\x31" 은 올바른 UTF-8 이 아닌 바이트 열이다. 옵션 없이 인코딩하면 false 가 나오고, 옵션을 주면 예외가 나온다.
마지막 블록
tempnam 은 임시 폴더에 겹치지 않는 이름의 빈 파일을 만들어 그 경로를 돌려준다. runDemo 에서 무슨 일이 있어도 finally 가 파일을 지운다. 출력에 경로를 넣지 않았으므로 결과가 실행마다 같다.
실행 결과
$ php main.php
== 1. 주문 받기 ==
저장: 김하늘 소금빵 3개 (총 1건)
거절: [name] 이름이 비어 있다
거절: [bread] 메뉴에 없는 빵이다
저장: 박민지 바게트 2개 (총 2건)
거절: [quantity] 수량은 1 이상 20 이하의 정수여야 한다
== 2. 저장된 파일 ==
[
{
"name": "김하늘",
"bread": "소금빵",
"quantity": 3
},
{
"name": "박민지",
"bread": "바게트",
"quantity": 2
}
]
== 3. 깨진 파일 ==
읽기 실패: 주문 파일의 JSON 형식이 깨졌다
원인이 JsonException 인가: 예
문법 오류 코드인가: 예
복구 뒤 건수: 0
== 4. 인코딩 오류 ==
플래그 없이: false
JsonException, UTF-8 오류 코드인가: 예
== 5. 없는 파일 ==
없는 파일은 빈 목록: 0건
임시 파일을 지웠다
실무에서 자주 틀리는 것
빈 catch 로 예외를 삼키기
예외가 귀찮아서 받기만 하고 아무것도 하지 않으면, 주문이 저장되지 않았는데도 손님에게는 성공한 것처럼 보인다.
try {
addOrder($path, $order);
} catch (Exception $e) {
}
echo "주문이 접수되었다\n";
고친 코드는 처리할 수 있는 종류만 받고, 결과를 손님에게 알린다.
try {
addOrder($path, $order);
echo "주문이 접수되었다\n";
} catch (StorageException $e) {
echo "지금은 주문을 받을 수 없다. 잠시 뒤 다시 시도해 달라\n";
}
json_decode 결과를 null 로만 검사하기
$data = json_decode($text, true);
if ($data === null) {
$data = [];
}
파일이 깨져 있어도 빈 목록으로 시작하므로, 다음 저장 때 기존 주문이 전부 덮어써진다. 옵션을 주고 실패를 예외로 받아 저장을 멈춰야 한다.
try {
$data = json_decode($text, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new StorageException('주문 파일의 JSON 형식이 깨졌다', 0, $e);
}
LOCK_EX 만 믿고 읽고 고쳐 쓰기
$orders = loadOrders($path);
$orders[] = $order;
file_put_contents($path, encodeOrders($orders), LOCK_EX);
잠금은 쓰기 한 번에만 걸리므로 읽은 뒤 쓰기 전의 틈이 남는다. 앞의 addOrder 처럼 핸들을 열고 flock 으로 잠근 채 읽기부터 쓰기까지 마친다.
부모 예외를 먼저 catch 하기
try {
validateOrder($input);
} catch (Exception $e) {
echo "일반 오류\n";
} catch (InvalidOrderException $e) {
echo "입력 오류\n";
}
catch 는 위에서부터 맞는 것을 찾는다. InvalidOrderException 도 Exception 의 자식이므로 첫 번째 블록이 모두 받고 두 번째는 실행되지 않는다. 좁은(자식) 클래스를 위에 둔다.
try {
validateOrder($input);
} catch (InvalidOrderException $e) {
echo "입력 오류\n";
} catch (Exception $e) {
echo "일반 오류\n";
}
한눈에 보기
| 도구 | 용도 | 주의할 점 |
|---|---|---|
| try / catch | 예외를 받아 대응 | 좁은 클래스를 위에 둔다 |
| finally | 뒷정리(핸들 닫기, 파일 삭제) | return 이 있어도 실행된다 |
| 사용자 정의 예외 | 실패에 이름과 정보를 붙임 | file, line 속성명을 피한다 |
| previous 인수 | 원인 예외를 보존 | getPrevious() 로 꺼낸다 |
| file_put_contents + LOCK_EX | 파일 전체를 잠그고 쓰기 | 읽고 고쳐 쓰기에는 부족하다 |
| fopen c+ + flock | 읽기부터 쓰기까지 한 번에 잠금 | 모든 코드가 같은 방식을 써야 한다 |
| JSON_THROW_ON_ERROR | JSON 실패를 예외로 받기 | getCode() 로 원인을 구분한다 |
연습 문제
- 빵의 재고가 모자랄 때 던질
SoldOutException을 만들어라.RuntimeException을 상속하고, 빵 이름(bread)과 남은 수량(remaining)을 읽기 전용 속성으로 갖게 하며, 메시지에도 두 값을 넣어라. - 다음 코드를 실행했을 때 출력되는 문자열을 예측하라.
function f(): string { try { echo 'A'; throw new RuntimeException('x'); } catch (RuntimeException $e) { echo 'B'; return 'C'; } finally { echo 'D'; } } echo f(), "\n"; - JSON_THROW_ON_ERROR 를 쓰지 않고
json_decode를 호출했을 때, 결과가null인 것이 실패 때문인지 JSON 문서가null이었기 때문인지 구분하는 방법을 써라. - 주문을 이름으로 지우는
removeOrder(string $path, string $name): int의 뼈대를 써라. 지운 건수를 돌려주고, 읽기부터 쓰기까지 하나의 잠금 안에서 처리해야 한다.
정답과 해설
1번
final class SoldOutException extends RuntimeException
{
public function __construct(public readonly string $bread, public readonly int $remaining)
{
parent::__construct("{$bread} 재고가 {$remaining}개뿐이다");
}
}
생성자 승격으로 두 속성을 선언하고, 부모 생성자에는 메시지만 넘긴다. 던지는 쪽은 throw new SoldOutException('식빵', 2) 처럼 쓴다.
2번
출력은 ABDC 다. try 에서 A 를 출력하고 예외가 던져져 catch 에서 B 를 출력한 뒤 return 'C' 를 준비한다. 함수를 빠져나가기 전에 finally 가 D 를 출력한다. 그다음 함수가 돌려준 C 를 echo 가 출력한다. 즉 ABD 는 함수 실행 중에, C 는 함수가 끝난 뒤에 나온다.
3번
호출 직후 json_last_error() === JSON_ERROR_NONE 인지 본다. 같으면 오류가 없었으므로 null 은 문서 자체의 값이고, 다르면 실패다. 가장 좋은 방법은 처음부터 JSON_THROW_ON_ERROR 를 쓰는 것이다.
4번
function removeOrder(string $path, string $name): int
{
$handle = fopen($path, 'c+');
if ($handle === false) {
throw new StorageException('주문 파일을 열지 못했다');
}
try {
if (flock($handle, LOCK_EX) === false) {
throw new StorageException('주문 파일의 잠금을 얻지 못했다');
}
$text = stream_get_contents($handle);
if ($text === false) {
throw new StorageException('주문 파일을 읽지 못했다');
}
$orders = decodeOrders($text);
$kept = array_values(array_filter(
$orders,
fn (array $order): bool => $order['name'] !== $name
));
$json = encodeOrders($kept);
ftruncate($handle, 0);
rewind($handle);
fwrite($handle, $json);
fflush($handle);
return count($orders) - count($kept);
} finally {
fclose($handle);
}
}
구조는 addOrder 와 같고 목록을 바꾸는 부분만 다르다. array_filter 는 키를 유지하므로 array_values 로 번호를 다시 매겨야 JSON 이 객체가 아닌 배열로 저장된다. 인코딩을 파일을 비우기 전에 끝내 두는 점도 같다.