매일 쓰는데 설명 못 하는 것

User::create() 한 줄에 Observer가 어떻게 호출되는지 이전 글에서 따라가봤다. 그 글을 쓰고 나서 더 근본적인 질문이 떠올랐다.

Nginx가 /index.php로 보낸 , Laravel은 어떻게 원래 URL을 아는가?

브라우저가 /api/cars?page=2를 요청한다. Nginx의 try_files가 결국 /index.php로 넘긴다. 그러면 Laravel은 어떻게 /api/cars로 라우팅하는 걸까?

처음엔 이렇게 생각했다 — index.php가 URL을 파싱하는 거 아닌가?

아니었다.

잘못 알기 쉬운 모델

PHP 개발자가 흔히 갖는 모델:

브라우저 → Nginx → /index.php

    index.php가 URL 문자열을 파싱

            Router

이 모델대로면 index.php가 어딘가에서 /api/cars라는 문자열을 읽어와야 한다. 어디서?

public/index.php를 열어보면 핵심은 한 줄이다.

$app->handleRequest(Request::capture());

URL 문자열을 직접 다루는 코드는 없다. Request::capture()가 어딘가에서 가져온다.

진짜 흐름

브라우저 요청: GET /api/cars?page=2

Nginx
  - try_files: 실제 파일 없음 → /index.php 실행 결정
  - 단, REQUEST_URI는 /api/cars?page=2 그대로 보존

php-fpm
  - public/index.php 실행
  - PHP 전역 변수($_SERVER, $_GET 등)에 Nginx가 넘긴 값들이 채워짐

Laravel
  - Request::capture() — $_SERVER, $_GET, $_POST를 읽어 Request 객체 생성
  - $app->handleRequest($request) — Kernel → Router 매칭

Route::get('/api/cars', [CarController::class, 'index']);

Controller 실행

핵심은 Nginx가 PHP 전역 변수에 원래 URL 정보를 박아준다는 것. index.php는 그걸 읽기만 한다.

PHP 전역 변수의 두 얼굴

php-fpm이 index.php를 실행할 때 $_SERVER에는 대략 이런 값이 들어 있다.

$_SERVER['REQUEST_METHOD']    = 'GET';
$_SERVER['REQUEST_URI']       = '/api/cars?page=2';
$_SERVER['SCRIPT_FILENAME']   = '/var/www/laravel-bb/public/index.php';
$_SERVER['SCRIPT_NAME']       = '/index.php';
$_SERVER['QUERY_STRING']      = 'page=2';
$_GET['page']                 = '2';

여기서 헷갈리면 안 되는 두 변수:

변수의미
SCRIPT_FILENAME실행할 PHP 파일 (Nginx가 FastCGI param으로 명시)/var/www/laravel-bb/public/index.php
REQUEST_URI사용자가 실제로 요청한 URL/api/cars?page=2

Nginx 설정에서 fastcgi_param SCRIPT_FILENAME ...을 박는 이유가 이거다. 어떤 PHP 파일을 실행할지는 Nginx가 결정해서 명시적으로 알려준다. 반면 원래 URL은 자동으로 REQUEST_URI에 살아남는다.

이 분리가 핵심이다.

Request::capture() 한 줄을 따라가본다

Illuminate\Http\Request::capture()의 본질:

public static function capture()
{
    static::enableHttpMethodParameterOverride();

    return static::createFromBase(SymfonyRequest::createFromGlobals());
}

SymfonyRequest::createFromGlobals()가 진짜 핵심. 이름 그대로 PHP 전역 변수에서 Request 객체를 만든다.

내부에서:

$request = self::createRequestFromFactory(
    $_GET, $_POST, [], $_COOKIE, $_FILES, $_SERVER
);

즉 PHP 전역 6개 ($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER + 내부)를 읽어 Symfony Request 객체로 wrap한다.

이렇게 만들어진 Request는 다음과 같이 사용 가능:

$request->method();          // 'GET'
$request->getRequestUri();   // '/api/cars?page=2'
$request->path();            // 'api/cars'
$request->query('page');     // '2'

Router는 Request를 어떻게 보는가

$app->handleRequest($request)가 호출되면 내부적으로 HTTP Kernel을 거쳐 Router에 도달한다. Router는 등록된 라우트와 비교한다.

Route::get('/api/cars', [CarController::class, 'index']);

비교 기준은 본질적으로 두 개:

  1. HTTP method$request->method() === ‘GET’?
  2. path$request->path() === ‘api/cars’?

매칭되면 컨트롤러를 실행한다. 그게 다다.

복잡해 보이지만 문자열 매칭이다. index.php어떤 URL을 받았는지 신경 쓰지 않아도 되는 이유는, Symfony Request가 PHP 전역에서 다 읽어와서 객체로 만들어줬기 때문이다.

1줄 디버깅 — 직접 확인해보기

Laravel 컨트롤러 안에서 한 번만 박아봐도 모델이 잡힌다.

public function index(Request $request)
{
    dd([
        'method' => $request->method(),
        'uri'    => $request->getRequestUri(),
        'path'   => $request->path(),
        'script' => $_SERVER['SCRIPT_FILENAME'],
        'all_server' => $_SERVER,
    ]);
}

SCRIPT_FILENAMEindex.php 절대경로, uri원래 사용자 URL. 둘이 다른 게 보인다.

응용 — API prefix는 어디서 결정되나

같은 메커니즘 위에서 Laravel API prefix를 다루면 어디서 결정되는지 보인다.

bootstrap/app.php:

->withRouting(
    web: __DIR__.'/../routes/web.php',
    api: __DIR__.'/../routes/api.php',
    apiPrefix: '',  // 또는 'api'
    ...
)

routes/api.php:

Route::prefix('v1')->group(function () {
    Route::get('/cars', [CarController::class, 'index']);
});

이 두 가지가 합쳐져 최종 path가 만들어진다:

apiPrefixRoute::prefix최종 path
'api' (기본)'v1'/api/v1/cars
'''v1'/v1/cars
'api'없음/api/cars

기본값 'api'를 빈 문자열로 바꾸는 결정도 자주 본다. URL이 /api/v1/...으로 가는 것보다 /v1/...이 깔끔하다는 이유.

다만 Router 입장에선 단순 path 매칭이다. apiPrefix도 결국 prefix 추가일 뿐.

정리 — try_files와 Laravel은 분리되어 있다

이전 글에서 try_files / proxy_pass / fastcgi_pass의 차이를 정리했다. 그 글이 Nginx가 어디로 보내는가를 다뤘다면, 이 글은 보낸 후 Laravel 안에서 무슨 일이 일어나는가다.

핵심 분리:

Nginx 책임 (infrastructure 층)
- 어떤 PHP 파일을 실행할지 결정 (SCRIPT_FILENAME)
- 원래 URL은 REQUEST_URI에 보존
- php-fpm에 전달

Laravel 책임 (application 층)
- Request::capture()로 PHP 전역에서 Request 객체 생성
- Router가 method + path로 라우트 매칭
- 컨트롤러 실행

Nginx의 try_files가 보낸 결과index.php 실행이지만, 원래 URL 정보는 그대로 살아남는다. 이게 PHP의 30년 패턴이고, Laravel이 그 위에 얹혀 있는 이유다.

결론

매일 쓰면서 설명 못 했던 것 — Nginx → /index.php → Laravel Router 의 흐름. 한 줄로 정리하면:

Nginx가 PHP 전역 변수에 URL을 박아주고, Request::capture()가 그걸 객체로 wrap하고, Router가 그 객체의 path로 매칭한다.

index.php는 URL을 파싱하지 않는다. 읽어 객체로 만들 뿐이다. 그 분리를 알면 Nginx 설정이든 Laravel 라우팅이든 더 이상 마법이 아니다.

참고