Laravel 프로젝트를 배포할 때 보통 이 구조를 쓴다.
Nginx → PHP-FPM → Laravel
그런데 Laravel 공식 문서에는 다른 선택지가 있다.
FrankenPHP (Worker Mode) → Laravel
벤치마크를 보면 Worker 모드가 2~10배 빠르다. 같은 Laravel 코드인데 왜 이런 차이가 나는가?
PHP-FPM: 요청마다 프레임워크를 부팅한다
PHP-FPM의 실행 흐름을 보면 이유가 보인다.
요청 1: Nginx → PHP-FPM → [PHP 초기화 → Composer 오토로더 → Laravel 부트스트랩 → 라우터 → 컨트롤러] → 응답 → 메모리 해제
요청 2: Nginx → PHP-FPM → [PHP 초기화 → Composer 오토로더 → Laravel 부트스트랩 → 라우터 → 컨트롤러] → 응답 → 메모리 해제
요청 3: ...동일
매 요청마다 똑같은 초기화를 반복한다. PHP 엔진 시작, Composer 오토로드, 설정 파일 파싱, 서비스 컨테이너 구성, 미들웨어 등록. 이 “부트스트랩” 과정이 요청당 10~50ms를 잡아먹는다. 실제 컨트롤러 로직은 5ms인데, 준비하는 데 30ms가 드는 구조.
왜 이렇게 설계했을까? PHP가 태어난 맥락을 보면 이해된다.
PHP는 1995년에 “HTML에 동적 값을 끼워넣는 템플릿 언어”로 시작했다. 웹 서버가 .php 파일을 만나면 PHP 인터프리터를 불러서 실행하고, 결과 HTML을 돌려주고, 인터프리터를 종료하는 방식이었다. 요청이 끝나면 모든 메모리를 해제하는 것이 기본 설계였다.
이건 1995년에는 합리적이었다. 스크립트가 10줄이었으니까. 하지만 2026년의 Laravel은 부트스트랩에만 수백 개의 클래스를 로드한다. 30년 전 설계가 현대 프레임워크의 병목이 된 것이다.
FrankenPHP Worker 모드: 한 번 부팅하고 메모리에 올려둔다
Worker 모드의 실행 흐름:
서버 시작: [PHP 초기화 → Composer 오토로더 → Laravel 부트스트랩] → 메모리에 상주
요청 1: → [라우터 → 컨트롤러] → 응답 (부트스트랩 생략)
요청 2: → [라우터 → 컨트롤러] → 응답 (부트스트랩 생략)
요청 3: → [라우터 → 컨트롤러] → 응답 (부트스트랩 생략)
부트스트랩을 한 번만 실행하고, 그 상태를 메모리에 유지한다. 이후 요청은 준비된 환경 위에서 컨트롤러 로직만 실행한다. 요청당 30ms의 오버헤드가 사라진다.
Node.js나 Go 서버가 원래 이 방식이다. node server.js를 실행하면 프로세스가 떠서 계속 요청을 받는다. 매 요청마다 Node.js를 재시작하지 않는다. FrankenPHP는 이 “당연한” 모델을 PHP에 가져온 것이다.
그런데 이게 어떻게 가능한가?
PHP는 “요청 끝나면 메모리 해제”가 기본 설계다. 이걸 어떻게 바꿨을까? 여기서 FrankenPHP의 아키텍처가 흥미로워진다.
1단계: PHP는 C로 만들어졌다
PHP 인터프리터 자체가 C 프로그램이다. C로 만든 프로그램은 다른 언어에서 라이브러리처럼 가져다 쓸 수 있는 API를 제공한다. PHP도 php_embed_init(), zend_eval_string(), php_embed_shutdown() 같은 C 함수 API가 있다.
2단계: Go에는 CGo가 있다
FrankenPHP는 Go로 만들어졌다. Go에는 CGo라는 기능이 있어서 C 라이브러리를 직접 호출할 수 있다.
Go (FrankenPHP) → CGo → PHP C API → PHP 엔진
Go 프로그램이 PHP 인터프리터를 외부 프로그램으로 실행하는 게 아니라, 자기 프로세스 안에 라이브러리로 포함시킨다.
3단계: PHP의 생명주기를 제어한다
이 조합으로 FrankenPHP가 하는 일:
서버 시작 시:
- CGo를 통해
php_embed_init()호출 → PHP 엔진 초기화 - Laravel 부트스트랩 스크립트 실행
php_embed_shutdown()을 호출하지 않는다 → PHP가 메모리에 살아있는 상태로 유지- 이게 “워커”의 탄생
요청마다:
- Go의 웹 서버(Caddy)가 HTTP 요청을 받는다
- 요청 데이터를 CGo를 통해 PHP 워커에 전달
zend_eval_string()으로 컨트롤러 로직만 실행 — 부트스트랩 없이- 응답을 Go로 돌려보낸다
- 워커는 종료되지 않고 다음 요청 대기
┌──────────────────────────────────────────────┐
│ FrankenPHP (Go 프로세스) │
│ │
│ ┌─────────┐ CGo ┌─────────────────┐ │
│ │ Caddy │ ────────→ │ PHP 워커 │ │
│ │ (HTTP) │ ←──────── │ (메모리 상주) │ │
│ └─────────┘ │ Laravel 부팅 완료 │ │
│ └─────────────────┘ │
└──────────────────────────────────────────────┘
핵심: Go가 PHP의 “태어나고 죽는” 생명주기를 직접 제어해서, “태어나서 안 죽게” 만든 것이다.
PHP-FPM과 FrankenPHP 비교
| PHP-FPM | FrankenPHP Worker | |
|---|---|---|
| 실행 모델 | 요청마다 부트스트랩 | 서버 시작 시 한 번만 |
| 부트스트랩 비용 | 요청당 10~50ms | 서버 시작 시 1회 |
| 메모리 | 요청 끝나면 해제 | 워커가 상주 |
| 웹 서버 | Nginx + PHP-FPM (별도 프로세스) | Caddy + PHP (같은 프로세스) |
| PHP 제어 방식 | FastCGI 프로토콜 (프로세스 간 통신) | CGo (같은 프로세스 내 함수 호출) |
| 상태 관리 | 무상태 (자동 정리) | 유상태 (메모리 누수 주의) |
| 설정 복잡도 | Nginx + PHP-FPM 설정 별도 | 단일 바이너리 |
Worker 모드의 트레이드오프
공짜 점심은 없다.
메모리 누수
PHP-FPM은 요청 끝나면 메모리를 전부 해제한다. 코드에 메모리 누수가 있어도 요청이 끝나면 사라진다. Worker 모드는 프로세스가 안 죽으니까 메모리 누수가 누적된다. Node.js에서 메모리 관리를 신경 써야 하는 것과 같은 이유다.
전역 상태 오염
요청 A에서 설정한 전역 변수가 요청 B에서 남아있을 수 있다. PHP-FPM에서는 불가능한 버그가 Worker 모드에서는 가능하다. Laravel Octane이 요청 간 상태를 초기화하는 로직을 제공하지만, 서드파티 패키지가 전역 상태를 쓰면 문제가 될 수 있다.
디버깅 난이도
“요청 100번째에서만 발생하는 버그”가 가능해진다. PHP-FPM에서는 매 요청이 독립적이라 재현이 쉽지만, Worker 모드에서는 메모리 상태에 따라 동작이 달라질 수 있다.
언제 뭘 쓸 것인가
PHP-FPM을 유지해야 하는 경우:
- 서드파티 패키지가 많고, Worker 모드 호환성을 검증할 시간이 없다
- 레거시 코드에 전역 상태(
global, 정적 변수)가 많다 - 요청 단위 격리가 중요한 멀티테넌트 환경
FrankenPHP Worker 모드로 전환할 만한 경우:
- API 서버처럼 응답 속도가 중요하다
- 프레임워크 부트스트랩 오버헤드가 전체 응답 시간의 50% 이상이다
- 새 프로젝트라서 Worker 모드 기준으로 코드를 작성할 수 있다
- Docker 기반 배포 환경이라 단일 바이너리가 유리하다
정리
PHP가 느린 건 언어가 느려서가 아니다. 1995년의 “요청마다 새로 시작” 모델이 2026년의 프레임워크와 맞지 않기 때문이다.
FrankenPHP는 Go의 CGo를 통해 PHP 인터프리터를 프로세스 안에 라이브러리로 포함시키고, PHP의 생명주기를 직접 제어해서 “한 번 부팅, 계속 실행” 모델을 만들었다. Node.js나 Go 서버에서는 당연한 것을 PHP에서도 가능하게 한 것이다.
같은 Laravel 코드가 PHP-FPM에서 50ms, FrankenPHP에서 5ms로 응답하는 이유는 코드가 빨라진 게 아니라, 매 요청마다 반복하던 30년짜리 의식(ritual)을 건너뛰기 때문이다.