Devin.KR

작은 라우터와 미들웨어 - 프레임워크 속 흐름 이해

개발자KR 조회 0

이 장에서 배우는 것

앞 장까지 비밀번호 저장, 요청 위조 방어, 세션 같은 보안 기본기를 따로따로 다뤘다. 이 부품들이 요청 하나를 처리하는 동안 어떤 순서로 불리는지는 아직 정해 두지 않았다. 이번 장에서는 그 순서를 정하는 뼈대를 직접 만든다. 요청과 응답을 객체로 표현하고, 경로를 보고 처리 함수를 고르는 라우터(router)를 만들고, 인증과 기록 같은 공통 작업을 미들웨어(middleware)로 겹쳐 쌓는다. 웹 서버는 띄우지 않는다. 요청 배열을 흉내 내는 함수로 CLI 에서 모든 시나리오를 실행한다.

  • 요청 객체와 응답 객체를 불변 값으로 설계할 수 있다.
  • 경로 패턴 /courses/{id} 를 정규식으로 바꾸어 경로 매개변수를 꺼낼 수 있다.
  • 404, 405 와 같은 라우팅 실패를 구분해 응답으로 바꿀 수 있다.
  • 미들웨어 체인의 실행 순서와 중간 차단 원리를 설명할 수 있다.
  • 프레임워크의 라우터와 미들웨어가 이 장의 코드 중 어느 부분에 해당하는지 짚을 수 있다.

문제 상황

수강 신청 API 를 만들기 시작하면 index.php 하나에서 시작하는 경우가 많다. 처음에는 if ($path === '/courses') 한 줄이다. 강좌 상세, 수강 신청, 강좌 개설이 붙으면 분기가 열 줄을 넘고, 경로에서 강좌 번호를 자르는 코드가 곳곳에 흩어진다. 인증이 필요한 경로마다 토큰 검사 코드를 복사해 붙이다 보면 한 군데만 빠뜨려도 로그인 없이 신청이 들어온다. 요청 기록을 남기려면 모든 분기의 앞뒤에 같은 줄을 넣어야 한다.

확인도 불편하다. 동작을 보려면 서버를 띄우고 브라우저나 curl 로 요청을 보내야 한다. 전역 배열 $_SERVER 를 곳곳에서 직접 읽으면 함수 하나를 시험하는 데도 서버가 필요하다.

해결 방향은 세 가지다. 요청과 응답을 객체로 만들어 전역 배열에서 떼어 낸다. 경로와 처리 함수의 짝을 표로 등록하는 라우터를 둔다. 모든 요청에 공통인 일은 처리 함수 바깥의 미들웨어로 옮긴다. 이 세 가지가 프레임워크의 핵심 골격이고, 코드로 쓰면 200줄 안팎이다.

요청과 응답을 값으로 다루기

요청 객체

요청 객체는 메서드, 경로, 쿼리, 헤더, 본문을 담는다. 핵심은 불변(immutable)으로 만드는 것이다. 미들웨어가 요청에 사용자 정보를 덧붙이고 싶을 때 원본을 고치지 않고 속성이 하나 더 붙은 새 객체를 돌려준다. 앞 장에서 다룬 읽기 전용 프로퍼티가 그대로 쓰인다. 헤더 이름은 대소문자를 구분하지 않으므로 저장할 때 소문자로 통일한다.

$_SERVER 에서 요청 객체를 만드는 정적 메서드는 배열을 인자로 받는다. 전역 배열을 직접 읽지 않으니, 테스트에서는 같은 모양의 배열을 만들어 넘기면 된다. 이 장의 fakeRequest() 가 그 일을 한다. 웹 서버가 전달하는 헤더는 HTTP_AUTHORIZATION 처럼 접두어가 붙고 하이픈이 밑줄로 바뀐 이름으로 들어오므로, 그 변환을 되돌리는 코드도 여기에 모은다.

응답 객체

응답 객체는 상태 코드, 본문, 헤더를 담는다. 처리 함수가 echo 나 header() 를 직접 부르면 미들웨어가 응답을 가로채 고칠 수 없다. 응답을 값으로 돌려주기만 하고 실제 출력은 맨 끝의 send() 한 곳에서 하면, 응답 시간 헤더를 붙이거나 오류를 JSON 으로 바꾸는 일을 중간에서 할 수 있다.

경로 매개변수 라우팅

라우터는 등록된 (메서드, 경로 패턴, 처리 함수) 목록을 위에서부터 훑어 요청과 맞는 것을 고른다. 패턴에 {id} 같은 자리표시자가 있으면 정규식의 이름 붙은 캡처 그룹으로 바꾸고, 일치하면 캡처된 값을 경로 매개변수로 넘긴다. 변환은 패턴을 자리표시자와 나머지 글자로 쪼개서, 나머지 글자는 preg_quote() 로 이스케이프하고 자리표시자는 (?P<id>[^/]+) 로 바꾸는 식이다.

라우트 패턴은 정규식으로 컴파일되고, 요청 경로와 대조해 경로 매개변수 배열이 나온다

실패하는 경우는 둘로 나뉜다. 경로가 어떤 패턴과도 맞지 않으면 404 다. 경로는 맞는데 메서드가 다르면 405 다. 둘을 구분하려면 경로가 맞았던 경로들의 메서드를 모아 두었다가, 끝까지 일치하는 것이 없을 때 405 로 알려 주면 된다. 라우터는 응답을 만들지 않고 HttpException 을 던진다. 이를 응답으로 바꾸는 일은 뒤에서 만들 미들웨어가 맡는다.

이 장의 API 가 쓰는 상태 코드와 의미
코드의미발생 지점예
400요청 형식이 틀렸다핸들러숫자가 아닌 강좌 번호, 깨진 JSON
401누구인지 모른다인증 미들웨어토큰 없음
404대상이 없다라우터, 핸들러없는 경로, 없는 강좌
405경로는 있으나 메서드가 다르다라우터강좌 상세에 DELETE
409현재 상태와 충돌한다핸들러정원 초과

미들웨어 체인

미들웨어는 요청을 받아 다음 단계에 넘기고, 돌아온 응답을 받아 다시 위로 돌려주는 함수다. 요청이 들어가는 길과 응답이 나오는 길이 같아서 양파 껍질에 비유하곤 한다. 미들웨어는 $next 를 부르기 전에 요청을 검사하거나 고칠 수 있고, 부른 뒤에 응답을 고칠 수 있으며, 아예 부르지 않고 자기가 응답을 만들어 돌려줄 수도 있다. 마지막 경우가 인증 실패 같은 중간 차단이다.

요청은 미들웨어를 차례로 지나 핸들러에 닿고 응답은 거꾸로 돌아오며, 인증 실패 시 핸들러에 닿지 않는다

체인은 배열을 뒤에서부터 감싸서 만든다. 가장 안쪽에 핸들러를 두고, 배열의 마지막 미들웨어부터 하나씩 바깥을 두른다. 배열의 첫 번째 미들웨어가 가장 바깥이 되어 가장 먼저 요청을 만난다. 이 장에는 두 층의 체인이 있다. 모든 요청에 걸리는 전역 체인(로그, 오류 변환)은 라우팅 전체를 감싼다. 경로마다 달리 붙이는 경로 체인(인증)은 라우팅이 끝난 뒤 해당 핸들러만 감싼다. 인증은 경로 체인에 두었으므로 공개 경로인 강좌 목록에는 걸리지 않는다.

순서가 의미를 가진다. 로그가 오류 변환보다 바깥에 있어야 404 같은 오류 응답의 최종 상태 코드까지 기록된다. 둘을 바꾸면 예외가 로그 미들웨어를 뚫고 지나가 나감 기록이 남지 않는다.

프레임워크가 해 주는 일

Laravel, Symfony, Slim 같은 프레임워크의 라우터와 미들웨어도 같은 구조다. 이름과 부가 기능이 다를 뿐이다. 아래 표는 이 장의 코드가 프레임워크의 어느 기능에 해당하는지, 실무에서 더 필요한 것이 무엇인지 정리한다. 각 프레임워크의 정확한 규칙은 공식 문서에서 확인하자. PSR-7 과 PSR-15 같은 표준 인터페이스는 PHP-FIG 사이트에 정리되어 있다.

이 장의 코드와 프레임워크 기능의 대응
프레임워크 기능이 장의 코드실무에서 더 필요한 것
요청 객체Request업로드 파일, 쿠키, 본문 형식별 파싱
응답 객체Response스트리밍, 쿠키 설정, 캐시 헤더
라우트 정의Router::add그룹, 이름, 매개변수 제약, 캐시
미들웨어 파이프라인Pipeline::run컨테이너에서 미들웨어 생성
예외 처리기ErrorMiddleware운영 환경별 오류 화면, 보고

완성 코드

파일 하나로 구성했다. 강좌 데이터는 배열에 담아 두어 라우팅과 미들웨어에 집중하고, 데이터베이스 연결은 다음 단계에서 붙인다.

<?php
declare(strict_types=1);

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

final class Request
{
    /**
     * @param array<string, string> $query
     * @param array<string, string> $headers 이름은 소문자
     * @param array<string, mixed> $attributes 미들웨어가 덧붙인 값
     */
    public function __construct(
        public readonly string $method,
        public readonly string $path,
        public readonly array $query = [],
        public readonly array $headers = [],
        public readonly string $body = '',
        public readonly array $attributes = [],
    ) {
    }

    /** @param array<string, string> $server $_SERVER 와 같은 모양의 배열 */
    public static function fromServer(array $server, string $body = ''): self
    {
        $uri = $server['REQUEST_URI'] ?? '/';
        $path = parse_url($uri, PHP_URL_PATH);
        parse_str((string) parse_url($uri, PHP_URL_QUERY), $query);

        $headers = [];
        foreach ($server as $key => $value) {
            if (str_starts_with($key, 'HTTP_')) {
                $headers[strtolower(str_replace('_', '-', substr($key, 5)))] = $value;
            }
        }

        return new self(
            strtoupper($server['REQUEST_METHOD'] ?? 'GET'),
            is_string($path) && $path !== '' ? $path : '/',
            $query,
            $headers,
            $body,
        );
    }

    public function header(string $name): ?string
    {
        return $this->headers[strtolower($name)] ?? null;
    }

    public function withAttribute(string $name, mixed $value): self
    {
        return new self(
            $this->method,
            $this->path,
            $this->query,
            $this->headers,
            $this->body,
            [...$this->attributes, $name => $value],
        );
    }
}

final class Response
{
    /** @param array<string, string> $headers 이름은 소문자 */
    public function __construct(
        public readonly int $status,
        public readonly string $body,
        public readonly array $headers = [],
    ) {
    }

    /** @param array<mixed> $data */
    public static function json(array $data, int $status = 200): self
    {
        $body = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);

        return new self($status, $body, ['content-type' => 'application/json; charset=utf-8']);
    }

    public function withHeader(string $name, string $value): self
    {
        return new self($this->status, $this->body, [...$this->headers, strtolower($name) => $value]);
    }

    public function send(): void
    {
        http_response_code($this->status);
        foreach ($this->headers as $name => $value) {
            header("{$name}: {$value}");
        }
        echo $this->body;
    }
}

interface Middleware
{
    public function process(Request $request, Closure $next): Response;
}

final class Pipeline
{
    /** @param list<Middleware> $middleware 앞쪽일수록 바깥 */
    public static function run(array $middleware, Closure $core, Request $request): Response
    {
        $next = $core;
        foreach (array_reverse($middleware) as $mw) {
            $next = static fn (Request $r): Response => $mw->process($r, $next);
        }

        return $next($request);
    }
}

final class Router
{
    /** @var list<array{method: string, regex: string, handler: Closure, middleware: list<Middleware>}> */
    private array $routes = [];

    /** @param list<Middleware> $middleware */
    public function add(string $method, string $pattern, Closure $handler, array $middleware = []): void
    {
        $this->routes[] = [
            'method' => $method,
            'regex' => self::compile($pattern),
            'handler' => $handler,
            'middleware' => $middleware,
        ];
    }

    private static function compile(string $pattern): string
    {
        $parts = preg_split('#(\{\w+\})#', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
        $regex = '';
        foreach ($parts as $part) {
            $regex .= preg_match('#^\{(\w+)\}$#', $part, $m) === 1
                ? '(?P<' . $m[1] . '>[^/]+)'
                : preg_quote($part, '#');
        }

        return '#^' . $regex . '$#';
    }

    /** @return array{handler: Closure, middleware: list<Middleware>, params: array<string, string>} */
    public function resolve(Request $request): array
    {
        $allowed = [];
        foreach ($this->routes as $route) {
            if (preg_match($route['regex'], $request->path, $matches) !== 1) {
                continue;
            }
            if ($route['method'] !== $request->method) {
                $allowed[] = $route['method'];
                continue;
            }

            return [
                'handler' => $route['handler'],
                'middleware' => $route['middleware'],
                'params' => array_filter($matches, is_string(...), ARRAY_FILTER_USE_KEY),
            ];
        }

        if ($allowed !== []) {
            throw new HttpException(405, '허용되지 않는 메서드다: ' . implode(', ', array_unique($allowed)));
        }
        throw new HttpException(404, '경로를 찾을 수 없다');
    }
}

final class Application
{
    /** @param list<Middleware> $global */
    public function __construct(private readonly Router $router, private readonly array $global)
    {
    }

    public function handle(Request $request): Response
    {
        $core = function (Request $req): Response {
            $route = $this->router->resolve($req);
            $inner = fn (Request $r): Response => ($route['handler'])($r, $route['params']);

            return Pipeline::run($route['middleware'], $inner, $req);
        };

        return Pipeline::run($this->global, $core, $request);
    }
}

final class LogMiddleware implements Middleware
{
    /** @var list<string> */
    private array $lines = [];

    public function process(Request $request, Closure $next): Response
    {
        $this->lines[] = "  [log] 들어옴 {$request->method} {$request->path}";
        $response = $next($request);
        $this->lines[] = "  [log] 나감 {$response->status}";

        return $response;
    }

    /** @return list<string> */
    public function drain(): array
    {
        $lines = $this->lines;
        $this->lines = [];

        return $lines;
    }
}

final class ErrorMiddleware implements Middleware
{
    public function process(Request $request, Closure $next): Response
    {
        try {
            return $next($request);
        } catch (HttpException $e) {
            return Response::json(['error' => $e->getMessage()], $e->status);
        }
    }
}

final class AuthMiddleware implements Middleware
{
    /** @param array<string, string> $tokens 토큰 => 사용자 이름 */
    public function __construct(private readonly array $tokens)
    {
    }

    public function process(Request $request, Closure $next): Response
    {
        $header = $request->header('Authorization') ?? '';
        if (!str_starts_with($header, 'Bearer ')) {
            return Response::json(['error' => '인증이 필요하다'], 401)
                ->withHeader('WWW-Authenticate', 'Bearer');
        }

        $name = $this->tokens[substr($header, 7)] ?? null;
        if ($name === null) {
            return Response::json(['error' => '토큰이 올바르지 않다'], 401);
        }

        return $next($request->withAttribute('user', $name));
    }
}

enum EnrollResult
{
    case Enrolled;
    case Duplicate;
    case Full;
}

final class CourseStore
{
    /** @var array<int, array{id: int, title: string, capacity: int, students: list<string>}> */
    private array $courses = [];
    private int $nextId = 1;

    /** @return array{id: int, title: string, capacity: int, students: list<string>} */
    public function add(string $title, int $capacity): array
    {
        $course = ['id' => $this->nextId++, 'title' => $title, 'capacity' => $capacity, 'students' => []];
        $this->courses[$course['id']] = $course;

        return $course;
    }

    public function find(int $id): ?array
    {
        return $this->courses[$id] ?? null;
    }

    public function all(): array
    {
        return array_values($this->courses);
    }

    public function enroll(int $id, string $student): EnrollResult
    {
        $course = $this->courses[$id];
        if (in_array($student, $course['students'], true)) {
            return EnrollResult::Duplicate;
        }
        if (count($course['students']) >= $course['capacity']) {
            return EnrollResult::Full;
        }
        $this->courses[$id]['students'][] = $student;

        return EnrollResult::Enrolled;
    }
}

function loadCourse(CourseStore $store, string $rawId): array
{
    if (preg_match('/\A[0-9]+\z/', $rawId) !== 1) {
        throw new HttpException(400, '강좌 번호는 숫자여야 한다');
    }

    return $store->find((int) $rawId) ?? throw new HttpException(404, '강좌가 없다');
}

function buildApp(CourseStore $store, LogMiddleware $log): Application
{
    $auth = new AuthMiddleware(['token-kim' => '김하늘', 'token-lee' => '이도윤']);
    $router = new Router();

    $router->add('GET', '/courses', function (Request $r, array $p) use ($store): Response {
        $courses = $store->all();
        if (($r->query['open'] ?? '') === '1') {
            $courses = array_values(array_filter(
                $courses,
                fn (array $c): bool => count($c['students']) < $c['capacity'],
            ));
        }

        return Response::json(['courses' => $courses]);
    });

    $router->add('GET', '/courses/{id}', function (Request $r, array $p) use ($store): Response {
        return Response::json(loadCourse($store, $p['id']));
    });

    $router->add('POST', '/courses', function (Request $r, array $p) use ($store): Response {
        try {
            $data = json_decode($r->body, true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            throw new HttpException(400, '본문이 올바른 JSON이 아니다');
        }
        $title = is_array($data) ? ($data['title'] ?? null) : null;
        $capacity = is_array($data) ? ($data['capacity'] ?? null) : null;
        if (!is_string($title) || $title === '' || !is_int($capacity) || $capacity < 1) {
            throw new HttpException(422, 'title 은 문자열, capacity 는 1 이상의 정수여야 한다');
        }
        $course = $store->add($title, $capacity);

        return Response::json($course, 201)->withHeader('Location', "/courses/{$course['id']}");
    }, [$auth]);

    $router->add('POST', '/courses/{id}/enrollments', function (Request $r, array $p) use ($store): Response {
        $course = loadCourse($store, $p['id']);
        $student = (string) $r->attributes['user'];

        return match ($store->enroll($course['id'], $student)) {
            EnrollResult::Enrolled => Response::json(['message' => "{$student} 수강 신청 완료"], 201),
            EnrollResult::Duplicate => Response::json(['error' => '이미 신청한 강좌다'], 409),
            EnrollResult::Full => Response::json(['error' => '정원이 찼다'], 409),
        };
    }, [$auth]);

    return new Application($router, [$log, new ErrorMiddleware()]);
}

/** @param array<string, string> $headers */
function fakeRequest(string $method, string $uri, array $headers = [], string $body = ''): Request
{
    $server = ['REQUEST_METHOD' => $method, 'REQUEST_URI' => $uri];
    foreach ($headers as $name => $value) {
        $server['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value;
    }

    return Request::fromServer($server, $body);
}

$store = new CourseStore();
$store->add('초등 코딩반', 2);
$store->add('중등 수학반', 1);
$log = new LogMiddleware();
$app = buildApp($store, $log);

$kim = ['Authorization' => 'Bearer token-kim'];
$lee = ['Authorization' => 'Bearer token-lee'];

$scenarios = [
    '목록' => fakeRequest('GET', '/courses'),
    '상세' => fakeRequest('GET', '/courses/2'),
    '없는 강좌' => fakeRequest('GET', '/courses/9'),
    '숫자가 아닌 번호' => fakeRequest('GET', '/courses/abc'),
    '허용 안 된 메서드' => fakeRequest('DELETE', '/courses/2'),
    '토큰 없이 신청' => fakeRequest('POST', '/courses/2/enrollments'),
    '김하늘 신청' => fakeRequest('POST', '/courses/2/enrollments', $kim),
    '이도윤 신청, 정원 초과' => fakeRequest('POST', '/courses/2/enrollments', $lee),
    '이도윤 다른 강좌 신청' => fakeRequest('POST', '/courses/1/enrollments', $lee),
    '여석 있는 강좌만' => fakeRequest('GET', '/courses?open=1'),
    '강좌 개설' => fakeRequest('POST', '/courses', $lee, '{"title":"주말 드론반","capacity":3}'),
    '깨진 JSON' => fakeRequest('POST', '/courses', $lee, '{"title":'),
];

foreach ($scenarios as $label => $request) {
    $response = $app->handle($request);
    echo "== {$label} ({$request->method} {$request->path})\n";
    foreach ($log->drain() as $line) {
        echo $line, "\n";
    }
    echo "  {$response->status} {$response->body}\n";
    foreach ($response->headers as $name => $value) {
        if ($name !== 'content-type') {
            echo "    {$name}: {$value}\n";
        }
    }
}

줄별 해설

HttpException. 상태 코드를 읽기 전용 프로퍼티로 들고 있는 예외다. 라우터와 핸들러는 실패를 응답이 아닌 예외로 알리고, 응답으로 바꾸는 책임은 ErrorMiddleware 한 곳에 둔다.

Request::fromServer. parse_url() 로 경로와 쿼리를 나누고 parse_str() 로 쿼리를 배열로 만든다. HTTP_ 로 시작하는 키만 헤더로 간주해 접두어를 떼고 밑줄을 하이픈으로 바꾼 뒤 소문자로 저장한다. withAttribute() 는 기존 속성에 새 키를 펼쳐 붙인 새 객체를 반환한다. 원본은 바뀌지 않는다.

Response. json() 은 본문을 만들 때 JSON_THROW_ON_ERROR 를 써서 인코딩 실패를 조용히 넘기지 않는다. send() 는 CLI 실행에서는 쓰이지 않고, 뒤에서 설명할 php -S 진입점에서 호출한다.

Pipeline::run. 핵심은 반복문 네 줄이다. 가장 안쪽 $core 에서 출발해 배열을 뒤집은 순서로 하나씩 감싼다. 화살표 함수는 만들어지는 순간의 $mw 와 $next 값을 붙잡으므로, 각 층이 바로 안쪽 층만 가리키게 된다.

Router::compile. preg_split() 에 PREG_SPLIT_DELIM_CAPTURE 를 주면 {id} 같은 자리표시자도 결과 배열에 남는다. 각 조각이 자리표시자이면 이름 붙은 그룹으로, 아니면 preg_quote() 를 거쳐 이어 붙인다. 앞뒤를 ^ 와 $ 로 막아 경로 전체가 일치해야 한다.

Router::resolve. 경로가 맞으면 메서드를 비교한다. 다르면 $allowed 에 모아 두고 다음 경로를 본다. preg_match() 의 결과 배열에는 숫자 키와 이름 키가 섞여 있어서, array_filter() 에 키 기준 필터(ARRAY_FILTER_USE_KEY)를 주고 문자열 키만 남긴다.

Application::handle. 코어 함수가 두 단계를 한다. 라우터에서 핸들러를 고른 뒤, 그 경로에 붙은 미들웨어로 핸들러를 한 번 더 감싸 실행한다. 이 코어 전체를 전역 미들웨어가 감싼다. 라우팅 실패의 예외도 전역 체인 안에서 던져지므로 ErrorMiddleware 가 받는다.

AuthMiddleware. 헤더가 없거나 토큰이 틀리면 $next 를 부르지 않고 401 응답을 바로 반환한다. 통과하면 사용자 이름을 속성에 담은 새 요청을 다음 단계에 넘긴다. 핸들러는 $r->attributes['user'] 로 누가 요청했는지 안다.

loadCourse. 경로 매개변수는 항상 문자열이다. 숫자만으로 이루어졌는지 먼저 검사해 400 을 던지고, 정수로 바꾼 뒤 조회해 없으면 404 를 던진다. 상세 조회와 수강 신청이 이 검증을 함께 쓴다.

수강 신청 핸들러. match 가 결과 열거형의 세 경우를 응답으로 대응시킨다. 경우를 하나 빠뜨리면 실행 중 UnhandledMatchError 가 나므로 열거형에 값을 추가할 때 놓치기 어렵다.

마지막 반복문. 시나리오마다 요청 객체를 handle() 에 넣고, 로그 미들웨어가 모은 줄과 응답을 출력한다. 서버도 소켓도 없으니 출력은 매번 같다.

실행 결과

$ php main.php
== 목록 (GET /courses)
  [log] 들어옴 GET /courses
  [log] 나감 200
  200 {"courses":[{"id":1,"title":"초등 코딩반","capacity":2,"students":[]},{"id":2,"title":"중등 수학반","capacity":1,"students":[]}]}
== 상세 (GET /courses/2)
  [log] 들어옴 GET /courses/2
  [log] 나감 200
  200 {"id":2,"title":"중등 수학반","capacity":1,"students":[]}
== 없는 강좌 (GET /courses/9)
  [log] 들어옴 GET /courses/9
  [log] 나감 404
  404 {"error":"강좌가 없다"}
== 숫자가 아닌 번호 (GET /courses/abc)
  [log] 들어옴 GET /courses/abc
  [log] 나감 400
  400 {"error":"강좌 번호는 숫자여야 한다"}
== 허용 안 된 메서드 (DELETE /courses/2)
  [log] 들어옴 DELETE /courses/2
  [log] 나감 405
  405 {"error":"허용되지 않는 메서드다: GET"}
== 토큰 없이 신청 (POST /courses/2/enrollments)
  [log] 들어옴 POST /courses/2/enrollments
  [log] 나감 401
  401 {"error":"인증이 필요하다"}
    www-authenticate: Bearer
== 김하늘 신청 (POST /courses/2/enrollments)
  [log] 들어옴 POST /courses/2/enrollments
  [log] 나감 201
  201 {"message":"김하늘 수강 신청 완료"}
== 이도윤 신청, 정원 초과 (POST /courses/2/enrollments)
  [log] 들어옴 POST /courses/2/enrollments
  [log] 나감 409
  409 {"error":"정원이 찼다"}
== 이도윤 다른 강좌 신청 (POST /courses/1/enrollments)
  [log] 들어옴 POST /courses/1/enrollments
  [log] 나감 201
  201 {"message":"이도윤 수강 신청 완료"}
== 여석 있는 강좌만 (GET /courses)
  [log] 들어옴 GET /courses
  [log] 나감 200
  200 {"courses":[{"id":1,"title":"초등 코딩반","capacity":2,"students":["이도윤"]}]}
== 강좌 개설 (POST /courses)
  [log] 들어옴 POST /courses
  [log] 나감 201
  201 {"id":3,"title":"주말 드론반","capacity":3,"students":[]}
    location: /courses/3
== 깨진 JSON (POST /courses)
  [log] 들어옴 POST /courses
  [log] 나감 400
  400 {"error":"본문이 올바른 JSON이 아니다"}

토큰 없는 신청은 로그에 들어옴과 나감이 모두 찍혔지만 핸들러는 실행되지 않았다. 강좌 목록과 상세는 인증 없이 통과했다. 인증이 전역이 아닌 경로 체인에 달려 있기 때문이다.

php -S 로 브라우저에서 확인하기

같은 코드를 실제 서버에서 돌리려면 위 파일의 맨 아래 $store 부터 끝까지를 다음 네 줄로 바꾼다. 입력의 출처가 가짜 배열에서 PHP 가 채운 전역 배열로 바뀔 뿐, 나머지 코드는 그대로다.

$store = new CourseStore();
$store->add('초등 코딩반', 2);
$store->add('중등 수학반', 1);
$app = buildApp($store, new LogMiddleware());
$request = Request::fromServer($_SERVER, (string) file_get_contents('php://input'));
$app->handle($request)->send();

터미널에서 php -S 127.0.0.1:8000 index.php 로 서버를 띄우고, 다른 터미널에서 curl -i http://127.0.0.1:8000/courses 로 요청한다. 인증 경로는 curl -i -X POST -H 'Authorization: Bearer token-kim' http://127.0.0.1:8000/courses/1/enrollments 로 시험한다. 내장 서버는 요청마다 스크립트를 처음부터 실행하므로 배열에 담은 강좌 데이터는 요청 사이에 이어지지 않는다. 신청 직후에 목록을 다시 불러도 신청이 반영되지 않는 것은 정상이다. 상태를 유지하는 일은 데이터베이스의 몫이다. 내장 서버의 옵션은 PHP 매뉴얼의 내장 웹 서버 항목에서 확인할 수 있다.

실무에서 자주 틀리는 것

미들웨어에서 응답을 반환하지 않는다

응답을 받아 기록만 하고 반환을 빼먹으면 바깥 층은 응답을 받지 못한다. 반환 타입이 선언되어 있으므로 TypeError 로 바로 드러난다.

// 틀린 코드
public function process(Request $request, Closure $next): Response
{
    $response = $next($request);
    $this->lines[] = "나감 {$response->status}";
}

// 고친 코드
public function process(Request $request, Closure $next): Response
{
    $response = $next($request);
    $this->lines[] = "나감 {$response->status}";

    return $response;
}

미들웨어 순서를 거꾸로 둔다

오류 변환이 로그보다 바깥에 있으면, 로그 미들웨어 안에서 예외가 위로 지나가 버려 나감 기록이 남지 않는다. 오류 응답의 상태 코드를 기록하려면 로그가 바깥이어야 한다.

// 틀린 코드: 404 요청의 "나감" 줄이 사라진다
new Application($router, [new ErrorMiddleware(), $log]);

// 고친 코드: 로그가 오류 응답까지 본다
new Application($router, [$log, new ErrorMiddleware()]);

패턴을 이스케이프하지 않고 정규식으로 바꾼다

자리표시자만 치환하고 나머지 글자를 그대로 두면 점이나 괄호가 정규식 문법으로 해석된다. /files/{name}.json 의 점은 아무 글자와 일치해서 /files/ab-json 도 통과한다.

// 틀린 코드
$regex = '#^' . preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $pattern) . '$#';

// 고친 코드: 자리표시자가 아닌 조각은 preg_quote 를 거친다
foreach (preg_split('#(\{\w+\})#', $pattern, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY) as $part) {
    $regex .= preg_match('#^\{(\w+)\}$#', $part, $m) === 1
        ? '(?P<' . $m[1] . '>[^/]+)'
        : preg_quote($part, '#');
}

경로 매개변수를 검증 없이 정수로 바꾼다

경로 매개변수는 사용자가 쓴 문자열이다. (int) 'abc' 는 오류 없이 0 이 되어, 잘못된 입력이 존재하지 않는 강좌 조회로 조용히 둔갑한다. 형식이 틀렸다는 사실은 400 으로 따로 알려야 클라이언트가 원인을 안다.

// 틀린 코드
$course = $store->find((int) $p['id']);

// 고친 코드
$course = loadCourse($store, $p['id']);

한눈에 보기

이 장의 구성 요소와 역할
요소역할핵심 규칙
Request요청 정보를 값으로 담는다불변, 변경은 새 객체로
Response응답 정보를 값으로 담는다출력은 send() 한 곳에서
Router경로와 핸들러를 짝짓는다맞는 경로가 없으면 404, 메서드만 다르면 405
Middleware공통 작업을 겹쳐 쌓는다$next 를 부르거나 직접 응답을 반환한다
Pipeline미들웨어를 중첩 호출로 엮는다배열 앞쪽이 바깥
ErrorMiddleware예외를 응답으로 바꾼다로그보다 안쪽에 둔다

연습 문제

  1. 출결 조회용 경로 GET /courses/{id}/attendance/{date} 를 추가하라. 강좌가 없으면 404 를 내고, 성공하면 강좌 번호와 날짜를 JSON 으로 돌려준다.
  2. 모든 응답에 X-App: academy 헤더를 붙이는 미들웨어를 작성하고 전역 체인에 등록하라.
  3. 미들웨어 A, B, C 가 각각 $next 호출 앞뒤로 "A 들어옴", "A 나감" 처럼 기록한다. 체인이 [A, B, C] 일 때 기록 순서를 쓰라. B 가 $next 를 부르지 않고 응답을 반환하면 순서가 어떻게 바뀌는가.
  4. 405 응답에 허용 메서드를 알리는 Allow 헤더를 붙이려면 코드를 어떻게 바꿔야 하는가.

정답과 해설

1. 패턴은 /courses/, {id}, /attendance/, {date} 네 조각으로 나뉘어 컴파일된다. 날짜 형식은 핸들러에서 검증한다.

$router->add('GET', '/courses/{id}/attendance/{date}', function (Request $r, array $p) use ($store): Response {
    $course = loadCourse($store, $p['id']);
    if (preg_match('/\A\d{4}-\d{2}-\d{2}\z/', $p['date']) !== 1) {
        throw new HttpException(400, '날짜는 YYYY-MM-DD 형식이어야 한다');
    }

    return Response::json(['course' => $course['id'], 'date' => $p['date']]);
});

2. 응답은 $next 가 돌려준 뒤에 고친다. 등록은 [$log, new ErrorMiddleware(), new AppHeaderMiddleware()] 처럼 한다. 오류 응답에도 헤더를 붙이고 싶다면 오류 변환보다 바깥에 둔다.

final class AppHeaderMiddleware implements Middleware
{
    public function process(Request $request, Closure $next): Response
    {
        return $next($request)->withHeader('X-App', 'academy');
    }
}

3. 기록 순서는 A 들어옴, B 들어옴, C 들어옴, (핸들러), C 나감, B 나감, A 나감이다. B 가 $next 를 부르지 않으면 A 들어옴, B 들어옴, B 나감, A 나감이 된다. C 와 핸들러는 실행되지 않고, 바깥의 A 는 B 가 만든 응답을 그대로 받는다. 인증 미들웨어가 핸들러를 막는 원리와 같다.

4. HttpException 이 헤더를 들고 다니게 하고, ErrorMiddleware 가 응답에 옮겨 담는다. 라우터는 405 를 던질 때 ['allow' => implode(', ', array_unique($allowed))] 를 세 번째 인자로 넘긴다.

final class HttpException extends RuntimeException
{
    /** @param array<string, string> $headers */
    public function __construct(public readonly int $status, string $message, public readonly array $headers = [])
    {
        parent::__construct($message);
    }
}

// ErrorMiddleware 의 catch 블록
$response = Response::json(['error' => $e->getMessage()], $e->status);
foreach ($e->headers as $name => $value) {
    $response = $response->withHeader($name, $value);
}

return $response;

댓글 0

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

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