종합 실습 - 수강 신청 API
이 장에서 배우는 것
앞 장까지 타입, 값 객체, 의존성 주입, 예외 계층, 트랜잭션, 보안, 라우터를 각각 따로 만들었다. 이 장에서는 그 조각을 하나의 작은 수강 신청 API 로 묶는다. 새 문법은 거의 없다. 각 조각을 이어 붙일 때 어디에 무엇을 두어야 하는지가 이 장의 주제다.
- 강좌 개설, 조회, 수강 신청 엔드포인트를 라우터 하나에 등록하고 컨테이너로 객체를 조립한다.
- 정원 초과를 조건부 UPDATE 와 트랜잭션으로 막고, 실패 시 상태가 되돌아가는 것을 확인한다.
- CSRF 토큰 검사와 입력 검증을 각각 미들웨어와 입력 클래스에 나누어 둔다.
- 도메인 예외를 한곳에서 HTTP 상태 코드로 바꾼다.
- 요청 배열을 흉내 내는 함수로 시나리오를 실행하고,
php -S로 같은 코드를 브라우저 쪽에서 호출하는 방법을 안다.
문제 상황
학원 운영자가 강좌를 열고, 수강생이 신청 버튼을 누른다. 정원이 2명인 강좌에 세 사람이 비슷한 시각에 신청하면 두 명만 받아야 한다. 같은 사람이 버튼을 두 번 누르면 한 번만 등록되어야 한다. 다른 사이트의 페이지가 로그인한 사용자의 브라우저를 이용해 몰래 신청 요청을 보내는 일도 막아야 한다. 잘못된 입력이 들어오면 서버가 죽지 않고 어떤 필드가 왜 틀렸는지 알려 주어야 한다.
이런 요구를 컨트롤러 한 함수에 모두 쓰면 금방 읽기 어려워진다. 정원 확인, 중복 확인, 토큰 비교, 오류 응답이 한 함수 안에서 뒤엉킨다. 그래서 책임을 나눈다. 토큰 검사는 미들웨어, 입력 검사는 입력 클래스, 정원과 중복은 리포지토리의 트랜잭션, 오류의 응답 변환은 가장 바깥 미들웨어가 맡는다. 컨트롤러는 이들이 던지는 결과를 이어 주기만 한다.
요청이 지나가는 길
이 API 에서 요청은 가장 바깥의 오류 변환 미들웨어로 들어가 CSRF 검사, 라우터, 컨트롤러, 리포지토리 순으로 안쪽을 향한다. 안쪽 어디에서든 예외가 던져지면 호출 스택을 거슬러 올라가 가장 바깥에서 잡힌다. 그래서 컨트롤러와 리포지토리에는 try/catch 로 HTTP 응답을 만드는 코드가 없다.
등록하는 경로는 다섯 개다. 상태를 바꾸는 경로는 POST 뿐이며, CSRF 검사는 GET 과 HEAD 를 제외한 모든 메서드에 적용한다.
| 메서드 | 경로 | 검증 | 성공 응답 |
|---|---|---|---|
| GET | /csrf | 없음 | 200, 세션 토큰 |
| GET | /courses | 없음 | 200, 강좌 목록 |
| POST | /courses | 토큰, 제목 1~50자, 정원 1~100 | 201, 새 강좌 |
| 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 가 실패한다. 이때 앞서 올린 인원을 롤백해야 숫자가 어긋나지 않는다. 시나리오에서 이 롤백을 눈으로 확인한다.
예외를 응답으로 바꾸기
모든 도메인 예외는 AppException 을 상속하고 상태 코드를 들고 있다. 오류 변환 미들웨어는 이 예외를 잡아 JSON 응답으로 바꾼다. 그 밖의 예외, 예를 들어 데이터베이스 드라이버가 던진 예외는 내부 정보가 담겨 있을 수 있으므로 메시지를 감추고 500 만 돌려준다.
| 예외 | 상태 | 던지는 곳 | 의미 |
|---|---|---|---|
| CsrfException | 403 | CSRF 미들웨어 | 토큰이 없거나 다르다 |
| ValidationException | 422 | Input | 필드별 입력 오류 |
| NotFoundException | 404 | 라우터, 컨트롤러, 리포지토리 | 경로나 강좌가 없다 |
| CourseFullException | 409 | 리포지토리 | 정원이 찼다 |
| AlreadyEnrolledException | 409 | 리포지토리 | 이미 신청했다 |
| 그 밖의 Throwable | 500 | 어디서든 | 예상하지 못한 오류 |
완성 코드
두 파일로 나눈다. 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::enroll | rowCount 로 성공을 판단한다 |
| 트랜잭션 | 인원 증가와 신청 INSERT 를 묶음 | beginTransaction, rollBack | 예외를 삼키지 않고 다시 던진다 |
| 예외 변환 | 도메인 예외를 상태 코드로 | AppException 계층 | 내부 예외 메시지는 감춘다 |
연습 문제
- 수강 취소 기능을 추가한다.
CourseRepository::cancel(int $courseId, string $student): void를 만들되, 신청 내역이 없으면 404 가 되게 하고 인원은 한 번만 줄어야 한다. POST /courses에capacity로"10","5.0",0,true를 각각 보냈을 때 결과를 예상하고 이유를 설명한다.- 요청마다
메서드 경로 상태한 줄을 모으는 로그 미들웨어를 만든다. 오류 응답의 상태 코드까지 기록하려면 미들웨어 배열의 어디에 넣어야 하는가. 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 가 된다. 파일 데이터베이스를 쓰거나 같은 프로세스 안에서 요청을 이어 처리해야 한다.