Devin.KR

예외와 파일 - JSON 으로 저장하기

개발자KR 조회 0

이 장에서 배우는 것

앞 장까지는 프로그램이 정상적으로 흘러가는 경우만 다뤘다. 실제 사이트에서는 손님이 없는 빵을 주문하거나, 저장 파일이 반쯤만 써져 있거나, 두 사람이 동시에 주문하는 일이 생긴다. 이 장에서는 그런 상황을 코드로 구분해 처리하는 예외(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 을 했든 실행된다. 파일을 닫는 것처럼 반드시 해야 하는 뒷정리를 둔다.
예외가 없으면 try 에서 finally 로, 예외가 있으면 catch 를 거쳐 finally 로 가며 finally 는 두 경우 모두 실행된다.

사용자 정의 예외

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 하는 쪽
InvalidOrderExceptionInvalidArgumentException이름, 빵, 수량 검사 실패주문 받는 코드
StorageExceptionRuntimeException파일 열기·쓰기·JSON 해석 실패저장소를 쓰는 코드
JsonExceptionExceptionjson_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아니오
flocktruefalse아니오

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 설명에서 확인할 수 있다.

JSON 함수의 오류 처리 방식 비교
방식실패 신호오류 원인 확인문제점
옵션 없음false 또는 nulljson_last_error()null 문서와 구분이 어렵다
JSON_THROW_ON_ERRORJsonExceptiongetCode()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_ERRORJSON 실패를 예외로 받기getCode() 로 원인을 구분한다

연습 문제

  1. 빵의 재고가 모자랄 때 던질 SoldOutException 을 만들어라. RuntimeException 을 상속하고, 빵 이름(bread)과 남은 수량(remaining)을 읽기 전용 속성으로 갖게 하며, 메시지에도 두 값을 넣어라.
  2. 다음 코드를 실행했을 때 출력되는 문자열을 예측하라.
    function f(): string
    {
        try {
            echo 'A';
            throw new RuntimeException('x');
        } catch (RuntimeException $e) {
            echo 'B';
            return 'C';
        } finally {
            echo 'D';
        }
    }
    echo f(), "\n";
  3. JSON_THROW_ON_ERROR 를 쓰지 않고 json_decode 를 호출했을 때, 결과가 null 인 것이 실패 때문인지 JSON 문서가 null 이었기 때문인지 구분하는 방법을 써라.
  4. 주문을 이름으로 지우는 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 이 객체가 아닌 배열로 저장된다. 인코딩을 파일을 비우기 전에 끝내 두는 점도 같다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.