Devin.KR

PHP · 심화

설계와 보안으로 깊어지는 PHP

종합 실습 - 수강 신청 API

강좌·수강 신청 엔드포인트, 정원 초과 트랜잭션 처리, CSRF·입력 검증, 라우터·컨테이너 조합, 시나리오 실행

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장까지 타입, 값 객체, 의존성 주입, 예외 계층, 트랜잭션, 보안, 라우터를 각각 따로 만들었다. 이 장에서는 그 조각을 하나의 작은 수강 신청 API 로 묶는다. 새 문법은 거의 없다. 각 조각을 이어 붙일 때 어디에 무엇을 두어야 하는지가 이 장의 주제다.

  • 강좌 개설, 조회, 수강 신청 엔드포인트를 라우터 하나에 등록하고 컨테이너로 객체를 조립한다.
  • 정원 초과를 조건부 UPDATE 와 트랜잭션으로 막고, 실패 시 상태가 되돌아가는 것을 확인한다.
  • CSRF 토큰 검사와 입력 검증을 각각 미들웨어와 입력 클래스에 나누어 둔다.
  • 도메인 예외를 한곳에서 HTTP 상태 코드로 바꾼다.
  • 요청 배열을 흉내 내는 함수로 시나리오를 실행하고, php -S 로 같은 코드를 브라우저 쪽에서 호출하는 방법을 안다.

문제 상황

학원 운영자가 강좌를 열고, 수강생이 신청 버튼을 누른다. 정원이 2명인 강좌에 세 사람이 비슷한 시각에 신청하면 두 명만 받아야 한다. 같은 사람이 버튼을 두 번 누르면 한 번만 등록되어야 한다. 다른 사이트의 페이지가 로그인한 사용자의 브라우저를 이용해 몰래 신청 요청을 보내는 일도 막아야 한다. 잘못된 입력이 들어오면 서버가 죽지 않고 어떤 필드가 왜 틀렸는지 알려 주어야 한다.

이런 요구를 컨트롤러 한 함수에 모두 쓰면 금방 읽기 어려워진다. 정원 확인, 중복 확인, 토큰 비교, 오류 응답이 한 함수 안에서 뒤엉킨다. 그래서 책임을 나눈다. 토큰 검사는 미들웨어, 입력 검사는 입력 클래스, 정원과 중복은 리포지토리의 트랜잭션, 오류의 응답 변환은 가장 바깥 미들웨어가 맡는다. 컨트롤러는 이들이 던지는 결과를 이어 주기만 한다.

요청이 지나가는 길

이 API 에서 요청은 가장 바깥의 오류 변환 미들웨어로 들어가 CSRF 검사, 라우터, 컨트롤러, 리포지토리 순으로 안쪽을 향한다. 안쪽 어디에서든 예외가 던져지면 호출 스택을 거슬러 올라가 가장 바깥에서 잡힌다. 그래서 컨트롤러와 리포지토리에는 try/catch 로 HTTP 응답을 만드는 코드가 없다.

요청은 오류 변환, CSRF 검사, 라우터, 컨트롤러, 리포지토리 순으로 들어가고 응답과 예외는 같은 길을 거꾸로 나온다.

등록하는 경로는 다섯 개다. 상태를 바꾸는 경로는 POST 뿐이며, CSRF 검사는 GET 과 HEAD 를 제외한 모든 메서드에 적용한다.

API 경로와 각 경로의 검증, 성공 응답
메서드경로검증성공 응답
GET/csrf없음200, 세션 토큰
GET/courses없음200, 강좌 목록
POST/courses토큰, 제목 1~50자, 정원 1~100201, 새 강좌
GET/courses/{id}번호는 숫자200, 강좌와 수강생
POST/courses/{id}/enrollments토큰, 이름 1~20자201, 신청 내역

정원 초과를 한 문장으로 막기

정원 처리의 흔한 실수는 먼저 현재 인원을 읽고, 여유가 있으면 신청을 넣는 방식이다. 읽는 시점과 쓰는 시점 사이에 다른 요청이 끼어들 수 있기 때문이다. 이 장에서는 검사와 증가를 하나의 문장에 담는다.

UPDATE courses SET enrolled = enrolled + 1
WHERE id = :id AND enrolled < capacity

이 문장은 인원이 정원보다 작을 때만 행을 바꾼다. 영향받은 행이 0개라면 강좌가 없거나 가득 찬 것이다. 어느 쪽인지는 그다음에 강좌를 조회해서 가른다. 표에는 enrolled <= capacity 라는 CHECK 제약도 두었다. 코드가 틀려도 데이터베이스가 마지막 방어선이 된다.

신청 행을 넣는 것과 인원 증가는 한 트랜잭션에 묶는다. (course_id, student) 에 UNIQUE 제약이 있으므로 같은 사람이 두 번 신청하면 INSERT 가 실패한다. 이때 앞서 올린 인원을 롤백해야 숫자가 어긋나지 않는다. 시나리오에서 이 롤백을 눈으로 확인한다.

조건부 UPDATE 가 0행이면 롤백하고 409 를 돌려주며, 1행이면 신청을 넣고 커밋한다.

예외를 응답으로 바꾸기

모든 도메인 예외는 AppException 을 상속하고 상태 코드를 들고 있다. 오류 변환 미들웨어는 이 예외를 잡아 JSON 응답으로 바꾼다. 그 밖의 예외, 예를 들어 데이터베이스 드라이버가 던진 예외는 내부 정보가 담겨 있을 수 있으므로 메시지를 감추고 500 만 돌려준다.

예외 클래스와 HTTP 상태 코드의 대응
예외상태던지는 곳의미
CsrfException403CSRF 미들웨어토큰이 없거나 다르다
ValidationException422Input필드별 입력 오류
NotFoundException404라우터, 컨트롤러, 리포지토리경로나 강좌가 없다
CourseFullException409리포지토리정원이 찼다
AlreadyEnrolledException409리포지토리이미 신청했다
그 밖의 Throwable500어디서든예상하지 못한 오류

완성 코드

두 파일로 나눈다. app.php 는 출력 없이 클래스와 조립 함수만 담고, main.php 는 시나리오를 실행한다. 이렇게 나누어 두면 같은 app.php 를 나중에 php -S 용 진입 파일에서도 쓸 수 있다. 두 파일을 같은 디렉터리에 둔다.

app.php

<?php
declare(strict_types=1);

abstract class AppException extends RuntimeException
{
    public function __construct(string $message, public readonly int $status)
    {
        parent::__construct($message);
    }

    public function details(): array
    {
        return [];
    }
}

final class ValidationException extends AppException
{
    public function __construct(private array $fields)
    {
        parent::__construct('입력이 올바르지 않다', 422);
    }

    public function details(): array
    {
        return ['fields' => $this->fields];
    }
}

final class NotFoundException extends AppException
{
    public function __construct(string $message)
    {
        parent::__construct($message, 404);
    }
}

final class CsrfException extends AppException
{
    public function __construct()
    {
        parent::__construct('CSRF 토큰이 올바르지 않다', 403);
    }
}

abstract class ConflictException extends AppException
{
    public function __construct(string $message)
    {
        parent::__construct($message, 409);
    }
}

final class CourseFullException extends ConflictException
{
    public function __construct()
    {
        parent::__construct('정원이 가득 찼다');
    }
}

final class AlreadyEnrolledException extends ConflictException
{
    public function __construct()
    {
        parent::__construct('이미 신청한 수강생이다');
    }
}

final class Session
{
    private array $store;

    public function __construct(array &$store)
    {
        $this->store = &$store;
    }

    public function token(): string
    {
        $this->store['csrf'] ??= bin2hex(random_bytes(16));
        return $this->store['csrf'];
    }
}

final readonly class Request
{
    public function __construct(
        public string $method,
        public string $path,
        public array $body,
        public Session $session,
    ) {
    }
}

final readonly class Response
{
    public function __construct(public int $status, public array $body)
    {
    }
}

final class Input
{
    public static function id(string $raw): int
    {
        if (!ctype_digit($raw)) {
            throw new NotFoundException('잘못된 번호다');
        }
        return (int) $raw;
    }

    private static function toInt(mixed $value): ?int
    {
        if (is_int($value)) {
            return $value;
        }
        if (is_string($value) && ctype_digit($value) && strlen($value) < 10) {
            return (int) $value;
        }
        return null;
    }

    private static function text(mixed $value, int $min, int $max): ?string
    {
        if (!is_string($value)) {
            return null;
        }
        $trimmed = trim($value);
        $length = mb_strlen($trimmed);
        return $length >= $min && $length <= $max ? $trimmed : null;
    }

    public static function course(array $body): array
    {
        $title = self::text($body['title'] ?? null, 1, 50);
        $capacity = self::toInt($body['capacity'] ?? null);
        $errors = [];
        if ($title === null) {
            $errors['title'] = '제목은 1~50자여야 한다';
        }
        if ($capacity === null || $capacity < 1 || $capacity > 100) {
            $errors['capacity'] = '정원은 1~100 사이 정수여야 한다';
        }
        if ($errors !== []) {
            throw new ValidationException($errors);
        }
        return [$title, $capacity];
    }

    public static function student(array $body): string
    {
        $name = self::text($body['student'] ?? null, 1, 20);
        if ($name === null) {
            throw new ValidationException(['student' => '이름은 1~20자여야 한다']);
        }
        return $name;
    }
}

final class CourseRepository
{
    public function __construct(private PDO $pdo)
    {
    }

    public function create(string $title, int $capacity): int
    {
        $st = $this->pdo->prepare('INSERT INTO courses (title, capacity) VALUES (:t, :c)');
        $st->execute(['t' => $title, 'c' => $capacity]);
        return (int) $this->pdo->lastInsertId();
    }

    public function all(): array
    {
        return $this->pdo->query('SELECT id, title, capacity, enrolled FROM courses ORDER BY id')->fetchAll();
    }

    public function find(int $id): ?array
    {
        $st = $this->pdo->prepare('SELECT id, title, capacity, enrolled FROM courses WHERE id = :id');
        $st->execute(['id' => $id]);
        $row = $st->fetch();
        return $row === false ? null : $row;
    }

    public function students(int $id): array
    {
        $st = $this->pdo->prepare('SELECT student FROM enrollments WHERE course_id = :id ORDER BY id');
        $st->execute(['id' => $id]);
        return $st->fetchAll(PDO::FETCH_COLUMN);
    }

    public function enroll(int $courseId, string $student): int
    {
        $this->pdo->beginTransaction();
        try {
            $up = $this->pdo->prepare(
                'UPDATE courses SET enrolled = enrolled + 1 WHERE id = :id AND enrolled < capacity'
            );
            $up->execute(['id' => $courseId]);
            if ($up->rowCount() === 0) {
                throw $this->find($courseId) === null
                    ? new NotFoundException('강좌가 없다')
                    : new CourseFullException();
            }

            $ins = $this->pdo->prepare('INSERT INTO enrollments (course_id, student) VALUES (:c, :s)');
            try {
                $ins->execute(['c' => $courseId, 's' => $student]);
            } catch (PDOException $e) {
                if (str_starts_with((string) $e->getCode(), '23')) {
                    throw new AlreadyEnrolledException();
                }
                throw $e;
            }

            $enrollmentId = (int) $this->pdo->lastInsertId();
            $this->pdo->commit();
            return $enrollmentId;
        } catch (Throwable $e) {
            if ($this->pdo->inTransaction()) {
                $this->pdo->rollBack();
            }
            throw $e;
        }
    }
}

final class CourseController
{
    public function __construct(private CourseRepository $courses)
    {
    }

    public function index(Request $request, array $params): Response
    {
        return new Response(200, ['courses' => $this->courses->all()]);
    }

    public function create(Request $request, array $params): Response
    {
        [$title, $capacity] = Input::course($request->body);
        $id = $this->courses->create($title, $capacity);
        return new Response(201, ['id' => $id, 'title' => $title, 'capacity' => $capacity]);
    }

    public function show(Request $request, array $params): Response
    {
        $id = Input::id($params['id']);
        $course = $this->courses->find($id) ?? throw new NotFoundException('강좌가 없다');
        return new Response(200, $course + ['students' => $this->courses->students($id)]);
    }

    public function enroll(Request $request, array $params): Response
    {
        $id = Input::id($params['id']);
        $student = Input::student($request->body);
        $enrollmentId = $this->courses->enroll($id, $student);
        return new Response(201, [
            'enrollment_id' => $enrollmentId,
            'course_id' => $id,
            'student' => $student,
        ]);
    }
}

final class Router
{
    private array $routes = [];

    public function add(string $method, string $pattern, callable $handler): void
    {
        $regex = '#^' . preg_replace('#\{(\w+)\}#', '(?<$1>[^/]+)', $pattern) . '$#';
        $this->routes[] = ['method' => $method, 'regex' => $regex, 'handler' => $handler];
    }

    public function dispatch(Request $request): Response
    {
        foreach ($this->routes as $route) {
            if ($route['method'] !== $request->method) {
                continue;
            }
            if (preg_match($route['regex'], $request->path, $matches) === 1) {
                $params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
                return ($route['handler'])($request, $params);
            }
        }
        throw new NotFoundException('경로가 없다');
    }
}

final class ErrorMiddleware
{
    public function __invoke(Request $request, callable $next): Response
    {
        try {
            return $next($request);
        } catch (AppException $e) {
            return new Response($e->status, ['error' => $e->getMessage()] + $e->details());
        } catch (Throwable) {
            return new Response(500, ['error' => '서버 오류가 발생했다']);
        }
    }
}

final class CsrfMiddleware
{
    public function __invoke(Request $request, callable $next): Response
    {
        if (!in_array($request->method, ['GET', 'HEAD'], true)) {
            $sent = $request->body['_token'] ?? null;
            if (!is_string($sent) || !hash_equals($request->session->token(), $sent)) {
                throw new CsrfException();
            }
        }
        return $next($request);
    }
}

final class Kernel
{
    public function __construct(private Router $router, private array $middleware)
    {
    }

    public function handle(Request $request): Response
    {
        $core = fn(Request $r): Response => $this->router->dispatch($r);
        $chain = array_reduce(
            array_reverse($this->middleware),
            fn(Closure $next, callable $mw): Closure => fn(Request $r): Response => $mw($r, $next),
            $core
        );
        return $chain($request);
    }
}

final class Container
{
    private array $factories = [];
    private array $instances = [];

    public function set(string $id, Closure $factory): void
    {
        $this->factories[$id] = $factory;
    }

    public function get(string $id): object
    {
        if (!isset($this->instances[$id])) {
            $factory = $this->factories[$id] ?? throw new LogicException("등록되지 않은 서비스: $id");
            $this->instances[$id] = $factory($this);
        }
        return $this->instances[$id];
    }
}

function openDatabase(string $dsn): PDO
{
    $pdo = new PDO($dsn, null, null, [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    ]);
    $pdo->exec('PRAGMA foreign_keys = ON');
    $pdo->exec(
        'CREATE TABLE IF NOT EXISTS courses (
            id INTEGER PRIMARY KEY,
            title TEXT NOT NULL,
            capacity INTEGER NOT NULL CHECK (capacity > 0),
            enrolled INTEGER NOT NULL DEFAULT 0 CHECK (enrolled <= capacity)
        )'
    );
    $pdo->exec(
        'CREATE TABLE IF NOT EXISTS enrollments (
            id INTEGER PRIMARY KEY,
            course_id INTEGER NOT NULL REFERENCES courses(id),
            student TEXT NOT NULL,
            UNIQUE (course_id, student)
        )'
    );
    return $pdo;
}

function buildKernel(PDO $pdo): Kernel
{
    $container = new Container();
    $container->set(PDO::class, fn(): object => $pdo);
    $container->set(
        CourseRepository::class,
        fn(Container $k): object => new CourseRepository($k->get(PDO::class))
    );
    $container->set(
        CourseController::class,
        fn(Container $k): object => new CourseController($k->get(CourseRepository::class))
    );

    $courses = $container->get(CourseController::class);
    $router = new Router();
    $router->add('GET', '/csrf', fn(Request $r, array $p): Response => new Response(200, ['token' => $r->session->token()]));
    $router->add('GET', '/courses', $courses->index(...));
    $router->add('POST', '/courses', $courses->create(...));
    $router->add('GET', '/courses/{id}', $courses->show(...));
    $router->add('POST', '/courses/{id}/enrollments', $courses->enroll(...));

    return new Kernel($router, [new ErrorMiddleware(), new CsrfMiddleware()]);
}

main.php

<?php
declare(strict_types=1);

require __DIR__ . '/app.php';

function send(Kernel $kernel, Session $session, string $method, string $path, array $body = []): Response
{
    return $kernel->handle(new Request($method, $path, $body, $session));
}

function report(string $label, Response $response): void
{
    $json = json_encode($response->body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    echo '[', $label, '] ', $response->status, ' ', $json, "\n";
}

$kernel = buildKernel(openDatabase('sqlite::memory:'));
$browser = [];
$session = new Session($browser);

$token = send($kernel, $session, 'GET', '/csrf')->body['token'];
echo '[토큰 발급] 길이 ', strlen($token), "\n";

report('토큰 없이 강좌 개설', send($kernel, $session, 'POST', '/courses', [
    'title' => 'PHP 기초', 'capacity' => 2,
]));
report('잘못된 입력', send($kernel, $session, 'POST', '/courses', [
    '_token' => $token, 'title' => '  ', 'capacity' => 'abc',
]));
report('강좌 개설', send($kernel, $session, 'POST', '/courses', [
    '_token' => $token, 'title' => 'PHP 기초', 'capacity' => 2,
]));
report('민수 신청', send($kernel, $session, 'POST', '/courses/1/enrollments', [
    '_token' => $token, 'student' => '민수',
]));
report('민수 중복 신청', send($kernel, $session, 'POST', '/courses/1/enrollments', [
    '_token' => $token, 'student' => '민수',
]));
report('중복 뒤 강좌 상태', send($kernel, $session, 'GET', '/courses/1'));
report('지연 신청', send($kernel, $session, 'POST', '/courses/1/enrollments', [
    '_token' => $token, 'student' => '지연',
]));
report('서준 신청', send($kernel, $session, 'POST', '/courses/1/enrollments', [
    '_token' => $token, 'student' => '서준',
]));
report('최종 강좌 상태', send($kernel, $session, 'GET', '/courses/1'));
report('없는 강좌 조회', send($kernel, $session, 'GET', '/courses/99'));
report('없는 강좌 신청', send($kernel, $session, 'POST', '/courses/99/enrollments', [
    '_token' => $token, 'student' => '하나',
]));
report('강좌 목록', send($kernel, $session, 'GET', '/courses'));

줄별 해설

app.php: 예외와 값

AppException 은 상태 코드를 readonly 프로퍼티로 들고 있고, details() 로 응답에 덧붙일 정보를 내놓는다. 기본은 빈 배열이며 ValidationException 만 필드별 메시지를 돌려주도록 재정의한다. ConflictException 은 추상 클래스로 두어 409 라는 사실을 한곳에 모았다. 정원 초과와 중복 신청은 상태 코드가 같지만 호출하는 쪽이 구별할 수 있도록 별도 클래스로 둔다.

Session 은 배열에 대한 참조를 들고 있다. 이 장의 시나리오에서는 지역 배열을, 브라우저 쪽 진입 파일에서는 $_SESSION 을 넘긴다. 토큰은 random_bytes 로 만들고 한 번 만들어진 뒤에는 ??= 때문에 바뀌지 않는다. Request 와 Response 는 readonly 클래스라 만든 뒤 값이 바뀌지 않는다.

app.php: 입력 검증

Input::toInt 는 정수와 숫자만으로 이루어진 문자열만 받는다. "5.0", " 5", true 는 모두 null 이 되어 오류가 된다. 길이를 10 미만으로 제한한 것은 아주 큰 숫자 문자열이 정수 범위를 넘는 일을 피하기 위해서다. text 는 공백을 잘라낸 뒤 mb_strlen 으로 글자 수를 센다. 한글은 strlen 으로 세면 바이트 수가 나오므로 글자 수 제한에 쓸 수 없다. course 는 오류를 모아서 한 번에 던진다. 첫 오류에서 멈추면 사용자가 필드를 하나씩 고치며 여러 번 요청해야 한다.

app.php: 리포지토리

enroll 의 흐름은 그림 2 와 같다. 먼저 조건부 UPDATE 를 실행하고 rowCount() 가 0 이면 find 로 강좌의 존재 여부를 확인해 404 와 409 를 가른다. 그다음 신청 행을 INSERT 한다. UNIQUE 위반은 SQLSTATE 가 23 으로 시작하므로 그 경우만 AlreadyEnrolledException 으로 바꾸고, 다른 데이터베이스 오류는 그대로 다시 던져 오류 변환 미들웨어가 500 으로 처리하게 한다. 바깥 catch (Throwable) 는 어떤 예외든 롤백한 뒤 다시 던진다. 롤백 전에 inTransaction() 으로 확인하는 이유는, 이미 끝난 트랜잭션에 롤백을 호출하면 그 자체가 예외가 되기 때문이다. lastInsertId() 는 커밋 전에 읽어 둔다.

app.php: 컨트롤러와 라우터

컨트롤러 메서드는 입력을 검증하고, 리포지토리를 부르고, 응답 객체를 만든다. show 의 ?? throw 는 값이 없을 때 바로 예외를 던지는 표현이다. 라우터는 {id} 를 이름 있는 캡처 그룹으로 바꾸고, 일치한 결과에서 문자열 키만 남겨 경로 매개변수로 넘긴다. 숫자 여부는 라우터가 아니라 Input::id 가 판단한다. 라우터는 문자열 모양만 알고, 값의 의미는 입력 계층이 안다는 분담이다.

app.php: 미들웨어, 커널, 컨테이너

Kernel::handle 은 미들웨어 배열을 뒤집어서 array_reduce 로 양파 껍질처럼 감싼다. 배열의 첫 번째 미들웨어가 가장 바깥이 된다. 오류 변환이 CSRF 보다 바깥에 있으므로 CSRF 실패 예외도 403 응답으로 바뀐다. 순서를 바꿔 CSRF 를 바깥에 두면 그 예외는 아무도 잡지 못한다. 토큰 비교는 hash_equals 로 한다. 문자열 길이만큼 걸리는 시간이 달라지는 비교를 피하기 위해서다.

Container 는 식별자와 팩토리를 짝지어 두고, 처음 요청받을 때 한 번만 만든다. buildKernel 에서는 PDO, 리포지토리, 컨트롤러의 의존 관계를 팩토리 안에서 명시한다. 컨트롤러의 메서드는 ->index(...) 같은 첫 급 호출 문법으로 클로저가 되어 라우터에 등록된다.

main.php

send 는 요청 배열을 흉내 내는 함수다. 메서드, 경로, 본문, 세션으로 Request 를 만들어 커널에 넘긴다. report 는 상태 코드와 JSON 본문을 한 줄로 출력한다. 시나리오는 토큰을 받고, 토큰 없는 요청과 잘못된 입력을 거절당하게 한 뒤, 강좌를 열고 신청을 쌓는다. 토큰 값은 실행마다 달라지므로 출력하지 않고 길이만 찍어 결과가 늘 같게 한다.

실행 결과

$ php main.php
[토큰 발급] 길이 32
[토큰 없이 강좌 개설] 403 {"error":"CSRF 토큰이 올바르지 않다"}
[잘못된 입력] 422 {"error":"입력이 올바르지 않다","fields":{"title":"제목은 1~50자여야 한다","capacity":"정원은 1~100 사이 정수여야 한다"}}
[강좌 개설] 201 {"id":1,"title":"PHP 기초","capacity":2}
[민수 신청] 201 {"enrollment_id":1,"course_id":1,"student":"민수"}
[민수 중복 신청] 409 {"error":"이미 신청한 수강생이다"}
[중복 뒤 강좌 상태] 200 {"id":1,"title":"PHP 기초","capacity":2,"enrolled":1,"students":["민수"]}
[지연 신청] 201 {"enrollment_id":2,"course_id":1,"student":"지연"}
[서준 신청] 409 {"error":"정원이 가득 찼다"}
[최종 강좌 상태] 200 {"id":1,"title":"PHP 기초","capacity":2,"enrolled":2,"students":["민수","지연"]}
[없는 강좌 조회] 404 {"error":"강좌가 없다"}
[없는 강좌 신청] 404 {"error":"강좌가 없다"}
[강좌 목록] 200 {"courses":[{"id":1,"title":"PHP 기초","capacity":2,"enrolled":2}]}

중복 신청 직후의 강좌 상태에서 enrolled 가 1 인 점을 본다. 중복 신청 때 UPDATE 가 인원을 2 로 올렸다가 INSERT 실패로 롤백되어 1 로 돌아온 것이다. 롤백이 없었다면 신청자가 한 명뿐인데 정원이 찬 것으로 보였을 것이다.

브라우저 쪽에서 호출하기 (php -S)

같은 app.php 를 HTTP 서버 뒤에 붙이려면 진입 파일이 하나 더 필요하다. 이때는 세션을 $_SESSION 으로, 본문을 요청 바디의 JSON 으로 읽는다. 또 :memory: 데이터베이스는 요청마다 새로 만들어져 비워지므로 파일 데이터베이스를 쓴다.

index.php

<?php
declare(strict_types=1);

require __DIR__ . '/app.php';

session_start();
$pdo = openDatabase('sqlite:' . __DIR__ . '/academy.sqlite');

$decoded = json_decode(file_get_contents('php://input') ?: '[]', true);
$request = new Request(
    $_SERVER['REQUEST_METHOD'],
    (string) parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH),
    is_array($decoded) ? $decoded : [],
    new Session($_SESSION),
);

$response = buildKernel($pdo)->handle($request);
http_response_code($response->status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($response->body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

서버는 php -S 127.0.0.1:8080 index.php 로 띄운다. 마지막 인자가 라우터 스크립트라서 모든 경로가 index.php 로 들어온다. 터미널에서는 쿠키를 보관하는 파일을 지정해 확인한다. 토큰을 받은 뒤 그 값을 본문의 _token 에 넣어 POST 한다.

$ curl -c jar.txt -b jar.txt http://127.0.0.1:8080/csrf
$ curl -c jar.txt -b jar.txt -X POST http://127.0.0.1:8080/courses \
    -d '{"_token":"위에서 받은 값","title":"PHP 기초","capacity":2}'

이 출력은 토큰과 데이터베이스 파일 상태에 따라 달라지므로 위 CLI 시나리오처럼 고정된 결과가 아니다. php -S 는 개발용 서버이며 운영 환경에 쓰지 않는다. 옵션은 PHP 매뉴얼의 내장 웹 서버 항목에서 확인할 수 있다.

실무에서 자주 틀리는 것

읽고 나서 쓰는 정원 검사

틀린 코드는 인원을 읽은 뒤 PHP 에서 비교한다.

$count = (int) $pdo->query("SELECT enrolled FROM courses WHERE id = 1")->fetchColumn();
if ($count < $capacity) {
    $pdo->exec("INSERT INTO enrollments (course_id, student) VALUES (1, '서준')");
    $pdo->exec("UPDATE courses SET enrolled = enrolled + 1 WHERE id = 1");
}

읽은 뒤 쓰기 전에 다른 요청이 인원을 올리면 정원을 넘을 수 있다. 검사와 증가를 한 문장으로 합치고, 영향받은 행 수로 성공을 판단한다.

$up = $pdo->prepare('UPDATE courses SET enrolled = enrolled + 1 WHERE id = :id AND enrolled < capacity');
$up->execute(['id' => $courseId]);
if ($up->rowCount() === 0) {
    throw new CourseFullException();
}

트랜잭션 안에서 예외를 삼키기

틀린 코드는 실패를 잡아 로그만 남기고 넘어간다.

$pdo->beginTransaction();
try {
    // UPDATE, INSERT ...
    $pdo->commit();
} catch (PDOException $e) {
    error_log($e->getMessage());
}

롤백하지 않으므로 트랜잭션이 열린 채 남고, 호출한 쪽은 성공한 줄 안다. 롤백하고 예외를 다시 던져 호출한 쪽이 실패를 알게 한다.

} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    throw $e;
}

토큰을 느슨하게 비교하기, 상태를 바꾸는 GET

틀린 코드는 == 로 비교하고, 신청 취소를 GET 링크로 처리한다.

if ($request->body['_token'] == $request->session->token()) { /* 통과 */ }
$router->add('GET', '/courses/{id}/cancel', $courses->cancel(...));

토큰이 없을 때 null 과 빈 문자열이 같게 비교되는 식의 허점이 생기고, 이미지 태그 하나로도 GET 요청이 발사된다. 문자열 타입을 확인한 뒤 hash_equals 로 비교하고, 상태를 바꾸는 동작은 POST 나 DELETE 로 받아 CSRF 검사 대상에 넣는다.

if (!is_string($sent) || !hash_equals($session->token(), $sent)) {
    throw new CsrfException();
}

내부 오류 메시지를 응답에 싣기

틀린 코드는 모든 예외의 메시지를 그대로 내보낸다.

} catch (Throwable $e) {
    return new Response(500, ['error' => $e->getMessage()]);
}

데이터베이스 예외 메시지에는 SQL 문과 테이블 이름이 들어 있을 수 있다. 도메인 예외만 메시지를 그대로 보여 주고, 나머지는 일반 문구로 바꾼다. 원인은 서버 로그에만 남긴다.

} catch (AppException $e) {
    return new Response($e->status, ['error' => $e->getMessage()] + $e->details());
} catch (Throwable) {
    return new Response(500, ['error' => '서버 오류가 발생했다']);
}

한눈에 보기

이 장에서 조합한 요소와 각각의 역할
요소역할이 장의 코드주의할 점
라우터메서드와 경로로 핸들러 선택Router값의 의미 검증은 하지 않는다
미들웨어요청 앞뒤에 공통 처리ErrorMiddleware, CsrfMiddleware배열 순서가 바깥에서 안쪽 순서다
컨테이너의존 관계 조립, 한 번만 생성Container, buildKernel팩토리에 의존을 명시한다
입력 검증타입과 범위 확인, 오류 수집Input한글 글자 수는 mb_strlen
조건부 UPDATE정원 검사와 증가를 한 문장으로CourseRepository::enrollrowCount 로 성공을 판단한다
트랜잭션인원 증가와 신청 INSERT 를 묶음beginTransaction, rollBack예외를 삼키지 않고 다시 던진다
예외 변환도메인 예외를 상태 코드로AppException 계층내부 예외 메시지는 감춘다

연습 문제

  1. 수강 취소 기능을 추가한다. CourseRepository::cancel(int $courseId, string $student): void 를 만들되, 신청 내역이 없으면 404 가 되게 하고 인원은 한 번만 줄어야 한다.
  2. POST /courses 에 capacity 로 "10", "5.0", 0, true 를 각각 보냈을 때 결과를 예상하고 이유를 설명한다.
  3. 요청마다 메서드 경로 상태 한 줄을 모으는 로그 미들웨어를 만든다. 오류 응답의 상태 코드까지 기록하려면 미들웨어 배열의 어디에 넣어야 하는가.
  4. index.php 에서 openDatabase('sqlite::memory:') 를 쓰면 php -S 에서 어떤 일이 생기는가.

정답과 해설

1번. 삭제된 행이 있을 때만 인원을 줄이면 된다. 삭제와 감소를 한 트랜잭션에 묶는다.

public function cancel(int $courseId, string $student): void
{
    $this->pdo->beginTransaction();
    try {
        $del = $this->pdo->prepare('DELETE FROM enrollments WHERE course_id = :c AND student = :s');
        $del->execute(['c' => $courseId, 's' => $student]);
        if ($del->rowCount() === 0) {
            throw new NotFoundException('신청 내역이 없다');
        }
        $up = $this->pdo->prepare('UPDATE courses SET enrolled = enrolled - 1 WHERE id = :c');
        $up->execute(['c' => $courseId]);
        $this->pdo->commit();
    } catch (Throwable $e) {
        if ($this->pdo->inTransaction()) {
            $this->pdo->rollBack();
        }
        throw $e;
    }
}

삭제된 행 수가 0 이면 감소를 건너뛰므로 같은 취소를 두 번 보내도 인원이 이중으로 줄지 않는다. 라우트는 DELETE /courses/{id}/enrollments 로 등록하고 CSRF 검사 대상에 들어가는 것을 확인한다.

2번. "10" 은 숫자 문자열이라 10 으로 통과한다. "5.0" 은 점이 있어 ctype_digit 이 거짓이므로 422 다. 0 은 정수지만 1 미만이라 422 다. true 는 정수도 문자열도 아니므로 toInt 가 null 을 돌려주어 422 다. 암묵적 형 변환에 맡기지 않고 허용하는 형태를 명시한 결과다.

3번. 배열의 맨 앞에 넣는다.

final class LogMiddleware
{
    public array $lines = [];

    public function __invoke(Request $request, callable $next): Response
    {
        $response = $next($request);
        $this->lines[] = "{$request->method} {$request->path} {$response->status}";
        return $response;
    }
}

오류 변환 미들웨어가 예외를 응답으로 바꾼 뒤에야 상태 코드가 생긴다. 로그 미들웨어가 그보다 안쪽에 있으면 예외가 로그 미들웨어를 뚫고 지나가 기록이 남지 않는다. 그래서 [new LogMiddleware(), new ErrorMiddleware(), new CsrfMiddleware()] 순서로 둔다.

4번. php -S 는 요청마다 스크립트를 처음부터 실행하므로 메모리 데이터베이스가 매번 새로 만들어진다. 강좌를 열어도 다음 요청에서는 비어 있어 모든 강좌 조회가 404 가 된다. 파일 데이터베이스를 쓰거나 같은 프로세스 안에서 요청을 이어 처리해야 한다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

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

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