앱 서버
d138a78 기준.
FrankenPHP는 Go의 HTTP 서버 안에 PHP 인터프리터를 넣은 서버입니다. 일반적인 Nginx + PHP-FPM 구성에서는 웹 서버가 FastCGI로 별도 PHP 프로세스에 일을 넘깁니다. 여기서는 Go와 C의 연결 기능인 CGO를 통해 같은 프로세스 안의 PHP 실행 스레드에 요청을 전달합니다.
PHP를 Go 코드로 번역하는 도구는 아닙니다. PHP 코드는 PHP 엔진이 실행하고, Go는 요청 접수와 스레드 배치를 맡습니다. 이 저장소의 핵심은 언어를 바꾸는 것이 아니라, 요청과 PHP 실행기의 수명을 다르게 묶는 데 있습니다.
| 실행 방식 | 요청마다 하는 일 | 다음 요청까지 남는 것 |
|---|---|---|
| 일반 모드 | PHP 요청을 시작하고 스크립트를 실행한 뒤 요청을 정리 | 서버·PHP 스레드·OPcache 등. 앱의 일반 요청 상태는 종료 절차를 거침 |
| 워커 모드 | 이미 실행 중인 PHP 스크립트의 콜백으로 요청 처리 | 앱 객체·전역 변수·정적 변수·메모리 캐시 등이 워커 수명 동안 지속 |
일반 모드에서도 요청마다 운영체제 스레드를 새로 만드는 것은 아닙니다.
threadregular.go의 regularThread는 살아 있는 PHP 스레드를 재사용하면서 스크립트를 실행합니다.
워커 모드는 여기서 더 나아가 스크립트 자체를 끝내지 않고 여러 요청을 처리합니다.
Caddy는 홀의 접수대, Go의 스레드 관리 코드는 주문 배치 담당자, PHP 엔진은 조리 공간입니다. 일반 모드는 주문마다 앱이 필요한 준비를 하고 정리합니다. 워커 모드는 레시피와 조리 도구를 미리 펼쳐 두고 주문 처리만 반복합니다.
준비 시간을 줄이는 대신 이전 손님의 재료가 다음 접시에 섞이지 않게 해야 합니다. 워커를 배울 때 가장 중요한 질문도 같습니다. 무엇을 재사용하고, 무엇을 요청마다 지울 것인가?
다음은 구조를 설명하는 학습용 예제입니다. 실제 프레임워크의 초기화·종료 코드는 공식 통합을 따릅니다.
<?php
// 워커 스크립트 시작 시 한 번 준비한다.
$dictionary = ['hello' => '안녕하세요'];
$handler = static function () use ($dictionary): void {
// 요청마다 달라지는 값은 콜백 안에서 읽는다.
$word = $_GET['word'] ?? 'hello';
header('Content-Type: text/plain; charset=utf-8');
echo $dictionary[$word] ?? '등록되지 않은 단어';
};
while (frankenphp_handle_request($handler)) {
// 실제 앱에서는 요청별 서비스 정리도 이 경계에서 수행한다.
}
frankenphp.c의 PHP_FUNCTION(frankenphp_handle_request)가 이 PHP 함수를 구현합니다.
워커 모드 밖에서 호출하면 예외를 던지므로, 단순히 기존 PHP 파일에 반복문만 붙여 실행할 수는 없습니다.
2026-10-23 GitHub Trending Go Weekly #11에 오른 시점의 스타는 11,454개, 포크는 500개입니다.
최신 릴리스 v1.13.2는 이틀 전인 2026-10-21에 공개됐습니다.
다만 순위와 릴리스 날짜만으로 유행의 원인을 확정할 수는 없습니다. 아래는 소스와 공식 문서에서 읽을 수 있는 주목할 만한 기술적 이유입니다.
caddy/frankenphp/main.go는 Caddy 표준 모듈과 FrankenPHP 모듈을 가져온 뒤 caddycmd.Main()을 호출합니다.
별도 웹 서버를 처음부터 구현하지 않고 Caddy의 설정·인증서·HTTP 기능을 확장하는 방식입니다.
docs/migrate.md는 Nginx/Apache와 PHP-FPM을 하나의 FrankenPHP 실행 파일로 대체하는 구성을 설명합니다.
| 비교 항목 | Nginx + PHP-FPM | FrankenPHP 일반 모드 | FrankenPHP 워커 모드 |
|---|---|---|---|
| PHP 전달 경로 | FastCGI로 별도 프로세스에 전달 | 프로세스 내부의 CGO·SAPI 경계 | 같은 내부 경로 + 장기 실행 콜백 |
| 앱 초기화 | 일반적으로 요청마다 수행 | 일반적으로 요청마다 수행 | 워커 시작 시 수행하고 재사용 |
| 설정 중심 | 웹 서버 설정 + FPM 풀 설정 | Caddyfile의 사이트·frankenphp 설정 | 일반 설정 + 워커 수·상태 정리 정책 |
| HTTPS | 인증서 발급·갱신 구성을 별도로 선택 | Caddy 자동 HTTPS 활용 | 동일 |
| 장애 격리 | PHP 자식 프로세스별 경계 | PHP 실행 스레드들이 한 프로세스 공유 | 동일 + 앱 상태 장기 보존에 주의 |
| 이전 작업 | 기존 운영 방식 유지 | 경로·확장·프록시 정책 호환성 확인 | 일반 모드 검증 + 워커 호환성 확인 |
이 표는 실행 모델 비교입니다. 처리량 순위표가 아닙니다. 워커 모드의 장점은 앱 초기화의 반복을 줄이는 것이며, 실제 개선 폭은 초기화 비용이 전체 요청에서 차지하는 비중에 달려 있습니다.
PHP의 echo가 Go의 응답 쓰기로 이어지고, Go의 채널 대기가 PHP 함수의 요청 대기로 이어집니다.
익숙한 웹 요청을 출발점으로 다른 언어의 동시성 모델을 배울 수 있습니다.
server.go는 서버별 설정과 워커를 묶고, internal/state/state.go는 스레드의 안전한 전환을 관리합니다.
README는 Symfony와 Laravel의 공식 워커 통합을 소개합니다.
이는 기존 생태계와 연결되는 장점이지만, 모든 앱이 수정 없이 워커에 적합하다는 뜻은 아닙니다.
공식 docs/worker.md도 앱을 워커 수명에 맞게 조정하고 메모리 누수를 관리해야 한다고 설명합니다.
README는 Early Hints, HTTP/2·HTTP/3, 자동 HTTPS, 실시간 기능, 핫 리로드를 소개합니다.
소스에서는 caddy/caddy.go의 모듈 등록, metrics.go의 Prometheus 계측,
hotreload.go의 Mercure 업데이트 발행처럼 각 기능의 연결점을 확인할 수 있습니다.
인증서 관리는 Caddy, PHP 실행은 PHP 엔진, 요청 배치는 Go 코드가 담당합니다. 좋은 통합 서버는 모든 것을 직접 구현하는 대신, 각 부품의 수명과 실패 처리를 맞춥니다. 이 저장소에서 익혀 다른 시스템에도 적용할 수 있는 설계 원칙입니다.
루트 go.mod와 caddy/go.mod는 모두 go 1.27.0을 선언합니다.
저장소 주소는 php/frankenphp지만 모듈 경로는 여전히 github.com/dunglas/frankenphp입니다.
예제의 import 경로를 저장소 주소에 맞춰 임의로 바꾸면 안 됩니다.
| 구성 요소 | 실제 선언 버전 | 파일과 역할 |
|---|---|---|
| Go | 1.27.0 | 두 go.mod의 최소 Go 버전 선언 |
| Caddy | v2.11.7 | caddy/go.mod · HTTP 서버와 모듈 실행 기반 |
| FrankenPHP 모듈 | v1.13.2 | caddy/go.mod · 다만 로컬 replace가 루트 코드를 선택 |
| CertMagic | v0.25.6 | caddy/go.mod · 인증서 자동화 관련 의존성 |
| Mercure | v1.1.4 | 두 go.mod · 실시간 업데이트와 핫 리로드 기반 |
| Prometheus 클라이언트 | v1.25.0 | 두 go.mod · 스레드·워커·대기 요청 계측 |
| Cobra | v1.10.2 | caddy/go.mod · php-server 등 명령 옵션 구성 |
| Otter | v2.4.0 | go.mod · Go 캐시 라이브러리 의존성 |
| Watcher | v0.0.0-20260223030516-06f84a1314be | go.mod · 파일 변경 감지 모듈의 의사 버전 |
| Testify | v1.12.1 | 두 go.mod · assert·require 기반 테스트 |
| PHP | 정적 빌드 기본 8.5 | craft.yml · 패치 버전까지 고정한 선언은 아님 |
// caddy/go.mod의 핵심 관계
module github.com/dunglas/frankenphp/caddy
go 1.27.0
replace github.com/dunglas/frankenphp => ../
require (
github.com/caddyserver/caddy/v2 v2.11.7
github.com/dunglas/frankenphp v1.13.2
)
위 예제는 원본에서 관련 선언만 발췌한 것입니다.
replace 때문에 Caddy 모듈을 이 소스 트리에서 빌드하면 릴리스의 코어 대신 상위 디렉터리의 코어를 씁니다.
의존성 표의 버전과 실제 빌드에 들어가는 소스가 언제나 같지는 않으므로, require와 replace를 함께 읽어야 합니다.
cgo.go에는 Unix의 -lphp -lm -lutil, Linux의 -ldl -lresolv 같은 링크 옵션이 있습니다.
따라서 이 코어는 순수 Go 프로그램처럼 CGO_ENABLED=0만으로 완성할 수 있는 구조가 아닙니다.
PHP 라이브러리·헤더와 플랫폼별 네이티브 의존성을 함께 맞춰야 합니다.
추적 파일에는 package.json, composer.json, Cargo.toml, pyproject.toml이 없습니다.
React나 Vue 앱을 빌드하는 저장소가 아니라 PHP 앱을 실행하는 기반 서버입니다.
프론트엔드 프레임워크와 Composer 의존성은 그 위에 올리는 개별 PHP 애플리케이션의 선택입니다.
JavaScript 파일 7개는 testdata/load-test.js와 testdata/performance/의 부하 테스트입니다.
예를 들어 testdata/performance/hello-world.js는 k6/http를 가져옵니다.
JavaScript 파일이 있다는 사실만으로 Node.js 프론트엔드 스택을 추정하면 안 됩니다.
브라우저와 연결되는 핫 리로드 설정은 caddy/hotreload.go에서 확인할 수 있습니다.
configureHotReload()는 Mercure 허브가 없으면 오류를 반환하고,
FRANKENPHP_HOT_RELOAD 환경 변수에 구독 경로를 전달합니다. 기본으로 모든 페이지가 자동 갱신되는 것은 아닙니다.
| 파일 | 직접 확인할 설정 | 배울 점 |
|---|---|---|
| Dockerfile | common 기반의 builder·runner 단계, 빌드 산출물 복사 | 컴파일 도구와 실행 환경을 분리 |
| docker-bake.hcl | Go 1.27, PHP 8.2·8.3·8.4·8.5, 기본 PHP 8.5 | 버전·운영체제 조합으로 이미지 행렬 생성 |
| craft.yml | php-version: "8.5", enable-zts: true | 정적 빌드의 확장·라이브러리·SAPI 선언 |
| build-static.sh | static-php-cli v3 사용, LIBC=musl 또는 gnu | 완전 정적 빌드와 glibc 연동 빌드 구분 |
| .github/workflows/tests.yaml | Go -race, FuzzRequest, PHP 8.6은 experimental | 기본 검증과 실험적 호환성 검증 구분 |
| .github/workflows/sanitizers.yaml | asan·msan 행렬, USE_ZEND_ALLOC=0 | Go 경합 검사와 C 메모리 검사는 별도 축 |
Dockerfile은 FROM php-base와 golang-base라는 외부 빌드 문맥을 사용합니다.
실제 버전·운영체제 조합은 docker-bake.hcl까지 읽어야 알 수 있습니다.
또한 Watcher C 라이브러리는 Dockerfile에서 GitHub의 최신 릴리스를 조회하므로, Go 모듈 고정만으로 모든 네이티브 입력이 고정되지는 않습니다.
컨테이너 기본 명령은 --config /etc/frankenphp/Caddyfile --adapter caddyfile이고,
Dockerfile이 PHP 엔트리포인트를 frankenphp run 호출로 바꿉니다.
상태 검사는 http://localhost:2019/metrics를 대상으로 합니다.
이 내부 상태 검사 주소를 외부 서비스용 API로 해석하지 않는 것이 중요합니다.
caddy/caddy.go의 init()은 php와 php_server 지시어를 등록합니다.
caddy/module.go의 parsePhpServer()는 PHP 처리기와 정적 파일 처리기를 함께 구성합니다.
기본 실행 확장자는 .php, 기본 인덱스 파일은 index.php입니다.
FrankenPHPModule.ServeHTTP()가 문서 루트·환경 변수·원래 요청 정보를 준비합니다.Server.ServeHTTP()가 등록 상태를 검사하고 newContextFromRequest()로 요청 문맥을 만듭니다.context.go와 cgi.go에서 서버 범위의 워커 매처와 파일 경로를 바탕으로 실행 대상을 결정합니다.worker.handleRequest(), 없으면 handleRequestWithRegularPHPThreads()로 갑니다.
연결의 실물은 frankenphp.c의 frankenphp_sapi_module입니다.
여기에 frankenphp_ub_write, frankenphp_send_headers,
frankenphp_read_post 같은 함수가 등록됩니다.
frankenphp.go의 go_ub_write() 등은 Go 쪽 요청 문맥과 응답 작성자를 사용합니다.
어댑터는 같은 일을 서로 다른 양식으로 접수하는 두 부서 사이의 번역 창구입니다.
Go는 http.Request를 이해하고 PHP 엔진은 SAPI와 슈퍼글로벌을 이해합니다.
번역 창구가 경로·헤더·입출력을 맞춰 주므로 양쪽이 상대의 내부 구현을 전부 알 필요가 없습니다.
phpthread.go의 boot()는 C.frankenphp_new_php_thread()를 호출합니다.
실제 구현은 frankenphp.c에서 pthread_create()로 PHP 실행 스레드를 만듭니다.
PHP 요청마다 goroutine 하나를 PHP 실행기로 삼는 모델이 아니라, Go가 네이티브 PHP 스레드들을 조율하는 모델입니다.
threadregular.go는 먼저 비어 있는 스레드의 requestChan으로 전달을 시도합니다.
바로 받을 스레드가 없으면 공유 regularRequestChan에서 전달 기회를 기다립니다.
이 채널은 버퍼 없이 만들어집니다. “요청 큐”는 거대한 채널 버퍼가 아니라 전달을 기다리는 요청들과 카운터로 표현됩니다.
// threadregular.go에서 분기 구조만 추린 의사 코드
select {
case regularRequestChan <- fc:
// PHP 쪽 응답 완료 통지를 기다린다.
<-fc.done
case scaleChan <- fc:
// 증설을 요청한 뒤 원래 요청은 계속 대기한다.
case <-fc.ctx.Done():
// 아직 배정되지 않은 취소 요청을 큐에서 뺀다.
case <-timeoutChan(maxWaitTime):
// 대기 한도를 넘긴 요청을 거절한다.
}
이 의사 코드에서 생략된 실제 구현은 카운터와 메트릭도 모든 종료 경로에서 정리합니다.
max_wait_time의 기본값은 비활성화이므로 설정 없이 대기 시간이 자동으로 제한된다고 생각하면 안 됩니다.
다만 증설 신호를 보내 루프를 반복하면 타이머가 다시 만들어지므로, 이 값이 전체 대기 시간의 엄격한 상한은 아닙니다.
또한 fc.done은 응답 완료 통지입니다. 조기 응답 종료 함수가 쓰이면 PHP 후처리와 스레드의 다음 요청 가능 시점은 그보다 뒤일 수 있습니다.
scaling.go는 최소 대기 5ms, CPU 관측 120ms,
CPU 사용률 기준 0.8을 두고 증설을 판단합니다.
증설은 max_threads가 시작 스레드 수보다 클 때 활성화됩니다.
요청이 많다는 이유만으로 무조건 스레드를 늘리지 않는 설계입니다.
internal/state/state.go는 Reserved, Booting, Inactive,
Ready, ShuttingDown, RebootReady 등의 상태를 정의합니다.
현재 상태뿐 아니라 원하는 상태를 기다리는 구독 채널도 관리합니다.
phpThread.setHandler()는 기존 처리기가 안전한 전환 지점에 도달하기를 기다린 뒤 새 처리기를 설치합니다.
ThreadState.WaitFor()는 상태 확인과 대기 구독 등록을 같은 잠금 안에서 수행합니다.
확인 직후 알림이 먼저 지나가 영원히 기다리는 경쟁 상황을 피하는 방식입니다.
phpMainThread.rebootAllThreads()의 긴 주석도 좋은 교재입니다.
PHP의 TSRM 전역 자원을 정리하기 전에 모든 PHP 스레드가 종료되어야 하므로, 살아 있는 스레드를 남겨 두고 재부팅을 진행하지 않습니다.
이 지점에서 “빨리 복구하기”보다 메모리 수명 보장이 먼저라는 판단을 읽을 수 있습니다.
일반 모드의 frankenphp.c 내부 php_thread()는
php_request_startup() → 스크립트 실행 → php_request_shutdown()을 반복합니다.
워커는 그 스크립트 실행 안에서 frankenphp_handle_request() 호출을 반복합니다.
따라서 워커 스크립트의 종료와 개별 HTTP 요청의 종료가 서로 다른 사건이 됩니다.
threadworker.go의 waitForWorkerRequest()는 새 요청을 기다리고,
go_frankenphp_finish_worker_request()는 요청 문맥을 닫습니다.
phpThread에 포함된 runtime.Pinner는 C가 참조하는 Go 메모리의 수명을 유지하며,
Unpin()은 다음 워커 요청 대기 등 정리 경계에서 수행됩니다.
요청이 끝났다는 사실만으로 워커의 객체와 외부 참조까지 모두 사라지는 것은 아니므로, 각 자원의 수명 경계를 따로 확인해야 합니다.
특히 docs/worker.md는 대부분의 슈퍼글로벌과 달리 $_ENV는 요청 사이에 초기화되지 않는다고 명시합니다.
앱 전역 변수와 정적 속성도 별도 정리가 필요합니다.
docs/worker.md의 실패 절은 비정상 종료에 지수 백오프를 설명합니다.
하지만 threadworker.go의 tearDownWorkerScript()는
이미 요청 대기에 도달한 워커의 종료에는 즉시 재시작 경로를 사용하고, 연속 시작 실패에만 대기를 적용합니다.
대기식도 failureCount * failureCount * 100ms에 상한 1s를 둡니다.
식의 값은 0·100·400·900·1000ms 순서이며 지수식이 아닙니다.
시작 실패 횟수 제한과 파일 감시 여부도 결과에 관여합니다. 커밋 d138a78의 동작을 설명할 때는 실제 분기와 식을 기준으로 읽어야 합니다.
| 읽을 영역 | 역할 | 초보자가 먼저 던질 질문 |
|---|---|---|
| 루트 Go·C 파일 | 서버의 실제 실행 엔진 | 누가 요청을 받고, 누가 PHP 스레드를 깨우는가? |
| caddy/ | 코어를 웹 서버 제품으로 연결 | 설정 문자열이 어떤 Go 구조체로 바뀌는가? |
| internal/state/ | 동시 실행 사이의 전환 조율 | 종료 중인 스레드에 새 역할을 주지 않으려면? |
| internal/extgen/ | Go 선언으로 PHP 확장 연결 코드 생성 | 같은 선언을 여러 언어로 일관되게 옮기려면? |
| testdata/·테스트 | 오류·취소·입출력 경계의 사례 | 성공 요청 외에 어떤 실패 경로를 보장하는가? |
| 배포·CI 파일 | 운영체제·PHP·네이티브 조합 검증 | 한 환경의 빌드 성공을 다른 환경에 일반화할 수 있는가? |
internal/extgen/generator.go의 Generator.Generate()는 선언을 분석하고,
스텁 → 인수 정보 → 헤더 → C → Go → 문서를 차례로 생성합니다.
internal/extgen/funcparser.go는 parser.ParseFile(..., parser.ParseComments)로 Go 구문 트리와 주석을 함께 읽습니다.
함수 선언을 구문 트리로 분석해 연결 코드를 생성하는 작은 컴파일러 사례입니다.
이 기능의 API에는 EXPERIMENTAL 표시가 있습니다.
서버 요청 경로를 이해한 다음에 읽을 확장 주제이며, 처음부터 전체 생성기를 이해할 필요는 없습니다.
우선 caddy/frankenphp/main.go → caddy/module.go → server.go → 두 스레드 처리기를 읽으면 핵심 연결이 보입니다.
| 기술 | 이 저장소에서 배울 것 | 작은 실습 아이디어 |
|---|---|---|
| Go 채널·select | 즉시 전달, 공유 대기, 취소, 시간 제한을 하나의 흐름으로 구성 | 일꾼 두 개짜리 별도 Go 예제로 취소 시 대기 수가 줄어드는지 확인 |
| 잠금·상태 머신 | 상태 확인과 구독 등록을 원자적인 절차로 묶기 | WaitFor의 잠금 범위를 종이에 표시하고 알림 누락 가능성을 설명 |
| CGO·메모리 | C에 전달한 Go 포인터의 보존 기간과 해제 책임 | pinString·pinCString·Unpin 호출 관계를 수명 표로 정리 |
| PHP 워커 | 요청 상태와 앱 상태를 구분하고 재사용 가능한 경계를 설계 | 워커 한 개에서 정적 카운터와 요청별 변수를 비교 |
| Caddy 모듈 | 등록·설정 해석·준비·시작·요청 처리를 분리 | php_server 지시어가 처리기와 파일 서버를 만드는 경로 추적 |
| HTTP·시간 제한 | 큐 대기·본문 읽기·PHP 실행·응답 쓰기의 시간을 구분 | 느린 업로드와 빈 스레드 대기가 서로 다른 제한임을 도식화 |
| 관측·성능 | 처리량 외에 대기열·사용 중 스레드·재시작을 함께 읽기 | 느린 DB 가정에서 CPU가 낮아도 지연이 커지는 이유 설명 |
| 테스트·빌드 | Go 경합과 C 메모리 오류를 다른 도구로 찾기 | -race·ASan·MSan이 각각 놓칠 수 있는 문제를 표로 비교 |
context.go의 frankenPHPContext에는 요청 문맥, 응답 작성자,
done 채널, isDone과 클라이언트 연결 상태가 들어 있습니다.
HTTP 핸들러 반환, 응답 전송 완료, PHP 후처리 완료는 언제나 같은 순간이 아닙니다.
frankenphp_finish_request()가 있는 시스템에서 이 구분은 특히 중요합니다.
동시성 코드를 배울 때는 정상 경로보다 취소·종료·재시작에서 누가 마지막 정리를 맡는지 먼저 확인하는 습관이 유용합니다.
threadregular_test.go의 TestCancelledRequestLeavesRegularQueue()는
취소된 대기 요청이 큐 카운터에 남지 않는지 확인하는 좋은 출발점입니다.
TestRegularRequestChanIsCreatedBeforeInitRuns()는 초기화 전후 틈에 요청이 도착해도
nil 채널에서 영원히 기다리지 않도록 채널이 미리 존재하는지 확인합니다.
TestRequestsQueuedBeforeThreadsAreReadyAreHandedOver()는 준비 전 대기 요청이 실제로 전달되고 완료되는지 확인합니다.
함수 이름, 사전 상태, 마지막 단언을 차례로 읽으면 테스트가 막는 사고를 설명할 수 있습니다.
scaling_test.go는 증설 후 Ready 상태와 처리기 타입을 검사하고,
축소 후에는 inactiveThread로 바뀌는지 확인합니다.
축소를 “스레드가 즉시 완전히 사라진다”로 해석하지 않는 근거입니다.
worker_internal_test.go와 requestbodytimeout_test.go는 재시작·느린 입력의 경계 사례를 확장해 줍니다.
파일 이름을 많이 외우는 대신 세 장을 만들어 보세요. 요청 흐름도 한 장, 스레드 상태 전이도 한 장, 앱·요청·메모리 수명 표 한 장입니다. 각 화살표 옆에 실제 함수 이름을 붙이면 추상적인 동시성 개념이 구체적인 실행 경로가 됩니다.
| 항목 | 확인된 조건 | 학습·운영 시 해석 |
|---|---|---|
| 운영체제 | README는 Linux·macOS Apple Silicon·Windows 바이너리 안내 | 플랫폼마다 네이티브 의존성·확장·타임아웃 동작을 확인 |
| PHP 버전 | checkPHPConfig()는 PHP 8.2 미만을 거절 | 기본 배포의 PHP 8.5와 최소 허용 버전을 구분 |
| PHP 빌드 | compile 문서: embed·ZTS 활성화, Zend signals 비활성화 | 일반 CLI PHP 설치만으로 libphp·개발 헤더가 충족되지는 않음 |
| Go 빌드 | go.mod는 1.27.0, CGO와 플랫폼별 C 도구 체인 필요 | 완성 바이너리 실행에 Go 컴파일러를 설치할 필요는 없음 |
| 추가 라이브러리 | Watcher·Brotli 등은 선택 기능과 연결 | nowatcher·nobrotli·nomercure 빌드 태그도 함께 확인 |
| 네트워크 | Dockerfile은 80·443/TCP, 443/UDP, 2019 선언 | 443/UDP는 HTTP/3 관련. 포트 선언 자체가 외부 공개 설정은 아님 |
| CPU·RAM | 설정 파일에 모든 앱에 공통인 최소 코어·RAM 수치는 없음 | 앱의 메모리와 동시 실행 수를 바탕으로 예산 결정 |
문서가 요구하는 정상적인 병렬 실행 환경은 ZTS 빌드입니다.
소스의 Init()에는 ZTS가 없으면 시작 스레드 수를 1로 낮추고 경고하는 경로도 있습니다.
이는 문서의 권장·필수 빌드 조건과 구현의 방어적 처리 사이의 차이이며, NTS 환경의 모든 조합이 검증됐다는 보장은 아닙니다.
checkPHPConfig()는 ZTS와 Zend signals가 함께 활성화된 조합을 거절합니다.
Linux에서 Zend Max Execution Timers가 없으면 실행 시간 제한이 동작하지 않는다는 경고도 있습니다.
“PHP 8.x라서 된다”보다 빌드 기능까지 확인하는 것이 중요합니다.
calculateMaxThreads()는 2 × runtime.GOMAXPROCS(0)를 기본 예산 계산에 사용합니다.
아래는 ZTS 환경에서 GOMAXPROCS=4인 예입니다.
운영체제가 표시하는 전체 코어 수와 Go가 사용하는 동시 실행 기준이 반드시 같지는 않습니다.
| 설정 사례 | 일반 처리 + 워커 | 시작 PHP 처리 스레드 합계 |
|---|---|---|
| 스레드·워커 지정 없음 | 일반 8 + 워커 0 | 8 |
| 워커 한 종류, num 생략 | 일반 최소 1 + 기본 워커 8 | 9 |
| num_threads 2, 워커 num 3 | 일반 2 + 워커 3 | 5 |
| 위 설정 + max_threads 8 | 시작 5, 남은 증설 여지 3 | 5에서 최대 8까지 |
별도 주 PHP 스레드 등 관리용 실행 단위는 이 처리 슬롯 계산과 구분합니다. num_threads를 명시하면 일반 요청용 스레드에 워커 수가 더해지고, max_threads는 두 종류를 합친 처리 스레드 한도입니다. 따라서 “워커도 기본 두 배”라는 짧은 설명만으로 전체 메모리 사용량을 계산하면 틀리기 쉽습니다.
# 메모리 예산을 잡는 개념식이며 실제 RSS 보장식은 아니다.
필요 예산 ≈ PHP 동시 실행 메모리
+ 장기 보존 앱 상태·공유 캐시
+ Go·Caddy·네이티브 확장·연결 버퍼·운영체제 여유
예: 처리 슬롯 5개 × memory_limit 128MiB = 640MiB
여기에 위의 추가 메모리와 여유 공간을 별도로 잡는다.
max_threads auto는 phpmainthread.go에서 시스템 메모리를 PHP의
memory_limit로 나눠 한도를 추정하며, 추정 불가 시 시작 스레드 수의 두 배를 사용합니다.
Linux의 internal/memory/memory_linux.go는 syscall.Sysinfo()를 사용하므로
이 경로 자체가 컨테이너의 cgroup 한도를 직접 읽는 구현은 아닙니다.
PHP의 메모리 제한, Go의 GOMEMLIMIT, 컨테이너 메모리 제한은 서로 다른 범위를 제어합니다.
auto도 Go·네이티브 메모리와 운영 여유를 모두 예약해 주는 식이 아닙니다.
실제 프로세스 메모리와 대기열을 관측하면서 상한을 정해야 합니다.
docs/known-issues.md는 특정 PHP 버전의 하드 메모리 한도 초과가 전체 프로세스를 종료할 수 있다고 설명합니다.
단일 프로세스에 여러 요청이 함께 있으므로 감독 프로세스의 재시작 정책도 필요합니다.
서버 종료의 유예 시간이 지나도 플랫폼·블로킹 호출에 따라 스레드 종료가 지연될 수 있다는 점도 phpthread.go의 주석에 나옵니다.
앞부분은 소스 읽기만으로 따라갈 수 있습니다. 아래 실행 실습은 PHP를 포함한 FrankenPHP 실행 파일이 준비된 환경을 전제로 합니다.
예제 파일은 별도의 demo/ 프로젝트에 두고, 명령은 그 프로젝트의 루트에서 실행합니다.
외부 데이터베이스나 실제 사용자 데이터 없이도 수명과 대기 동작을 관찰할 수 있습니다.
public/index.php에 아래 코드를 저장합니다.
응답의 PHP_SAPI 값으로 PHP가 어떤 실행 환경에서 돌아가는지 확인합니다.
<?php
header('Content-Type: text/plain; charset=utf-8');
echo '실행 환경: ' . PHP_SAPI . "\n";
echo '요청 경로: ' . ($_SERVER['REQUEST_URI'] ?? '') . "\n";
# 첫 번째 터미널
frankenphp php-server --root public --listen 127.0.0.1:8080
# 두 번째 터미널
curl 'http://127.0.0.1:8080/index.php?hello=1'
완료 기준: 실행 환경에 frankenphp가 나오고 쿼리 문자열을 포함한 요청 경로를 확인합니다.
public/note.txt도 만들어 요청한 뒤, PHP 실행과 정적 파일 응답이 갈라지는 지점을 구조도에 표시합니다.
근거 파일은 caddy/module.go의 parsePhpServer()입니다.
앞의 public/index.php를 아래 워커 코드로 교체합니다.
이전 서버를 종료하고, 워커를 한 개만 지정해 다시 시작합니다. 여러 워커를 쓰면 카운터가 각 워커에 따로 생깁니다.
<?php
$handler = static function (): void {
static $count = 0;
++$count;
$name = $_GET['name'] ?? '익명';
header('Content-Type: text/plain; charset=utf-8');
echo "이 워커의 요청 수: {$count}\n";
echo "이번 요청의 이름: {$name}\n";
};
while (frankenphp_handle_request($handler)) {
gc_collect_cycles();
}
frankenphp php-server --root public --listen 127.0.0.1:8080 \
--worker "$PWD/public/index.php,1"
# 다른 터미널에서 순서대로 요청한다.
curl 'http://127.0.0.1:8080/index.php?name=A'
curl 'http://127.0.0.1:8080/index.php?name=B'
완료 기준: 같은 워커에서 카운터가 1·2로 증가하고 이름은 A·B로 바뀝니다.
카운터는 보존되지만 $_GET은 새 요청 값으로 바뀌는 이유를 설명합니다.
정적 카운터가 전체 서비스의 전역 요청 수는 아니라는 점과, 재시작하면 초기화된다는 점도 적습니다.
추가 질문: gc_collect_cycles()만 호출하면 정적 배열에 계속 추가한 값도 사라질까요?
계속 참조되는 객체와 도달할 수 없는 순환 참조는 다릅니다. 수집기 호출이 상태 정리 코드를 대신하지는 않습니다.
워커 콜백 안에 usleep(200_000);을 넣어 느린 작업을 흉내 냅니다.
CLI로 실행하던 서버를 종료하고 아래 Caddyfile로 실행합니다.
일반 처리 1개와 워커 1개의 합계가 2이며, 최대치도 2여서 자동 증설 여지가 없습니다.
{
debug
frankenphp {
num_threads 1
max_threads 2
max_wait_time 100ms
php_ini memory_limit 128M
worker ./public/index.php 1
}
}
http://127.0.0.1:8080 {
root * public
php_server
}
frankenphp run --config Caddyfile --adapter caddyfile
# 다른 터미널에서 요청 세 개를 겹쳐 보낸다.
for n in 1 2 3; do
curl -s -o /dev/null -w '%{http_code}\n' \
'http://127.0.0.1:8080/index.php' &
done
wait
완료 기준: 요청 시점에 따라 일부 요청에서 503을 관찰하고,
worker.go의 대기 분기와 ErrMaxWaitTimeExceeded의 HTTP 상태 값을 연결합니다.
max_wait_time은 처리 슬롯을 기다리는 시간이며 PHP 실행 시간 제한이 아니라는 점을 설명합니다.
이어서 max_threads 4로 바꾸고 요청 수를 조금씩 늘려 증설 로그가 나타나는 조건을 관찰합니다.
CPU 관측과 지연 기준 때문에 증설이 즉시 보장되지는 않습니다.
워커가 늘면 과제 2의 카운터도 여러 개로 나뉜다는 사실을 함께 확인합니다.
워커에 “현재 사용자”를 보관하는 간단한 서비스 객체를 만들고, 콜백 종료 시 그 값을 비우는 reset()을 구현합니다.
프레임워크 없이 한다면 요청 처리의 try/finally 경계에서 정리하도록 설계합니다.
테스트 식별자는 A·B 같은 가짜 값만 사용합니다.
frankenphp 전역 설정에 실험적 옵션 max_requests 10을 넣어 워커 재기동을 관찰합니다.tearDownWorkerScript() 분기로 설명합니다.
완료 기준: 사용자 정보가 다음 요청으로 새지 않고, 오류 뒤에도 정상 요청을 처리합니다.
max_requests에 의한 재시작을 누수 원인의 수정과 혼동하지 않는 짧은 검증 보고서를 남깁니다.
재시작 뒤에도 반드시 유지할 데이터라면 워커 메모리 밖에 보관해야 한다는 결론까지 연결합니다.
실습의 성공은 빠른 응답 숫자 하나가 아니라, 요청 사이에 남겨도 되는 상태와 반드시 지워야 할 상태를 구분해 설명하는 것입니다. 성능을 비교할 때는 같은 PHP 버전·확장·앱·부하를 사용하고 초기화 구간과 정상 상태를 따로 기록합니다.
PHP의 함수·배열·클로저, Go의 구조체·인터페이스를 먼저 익히면 좋습니다. C 문법을 완벽하게 알 필요는 없지만 포인터·메모리 할당·콜백의 기본 뜻은 4주차 전에 정리합니다. 매주 실행 결과와 함께 “왜 이 결과가 나오는가”를 설명하는 산출물을 남깁니다.
| 주차 | 집중 주제와 읽을 코드 | 완료 결과물 |
|---|---|---|
| 1주차 | HTTP 요청·응답, Caddy 라우팅 · caddy/frankenphp/main.go, caddy/caddy.go, caddy/module.go | 정적 파일과 PHP 요청의 두 경로를 그린 구조도, 과제 1 |
| 2주차 | 채널·select·취소·잠금 · threadregular.go, internal/state/state.go | 대기 요청이 완료·취소·시간 초과로 빠지는 경로 표 |
| 3주차 | PHP 앱 부팅·워커 수명·상태 정리 · worker.go, threadworker.go | 과제 2와 사용자 상태 분리 실험, 일반 모드와 워커 비교 노트 |
| 4주차 | CGO·SAPI·포인터 수명 · cgo.go, phpthread.go, frankenphp.c | echo의 출력 경로와 Pin·Unpin의 책임자를 표시한 수명 표 |
| 5주차 | 관측·용량·입출력 시간 제한 · scaling.go, metrics.go, requestbodytimeout_test.go | 과제 3의 한도별 응답·대기·증설 기록 |
| 6주차 | 이식성·빌드·회복 · Dockerfile, craft.yml, CI, internal/extgen/ | 플랫폼별 의존성 표와 과제 4의 실패·복구 검증 보고서 |
파일을 끝까지 읽는 것보다 하나의 요청·하나의 실패·하나의 자원이 어디서 시작하고 끝나는지 설명할 수 있는 것이 더 좋은 학습 기준입니다. 스레드 수나 성능 수치를 외우기보다 그 값을 바꿨을 때 무엇이 함께 바뀌는지 예측해 보세요.
| 용어 | 쉬운 설명 |
|---|---|
| 애플리케이션 서버 | HTTP 요청을 받아 앱 코드를 실행하고 응답을 돌려주는 프로그램 |
| Caddy | HTTPS와 라우팅을 담당하며 모듈로 기능을 확장하는 Go 웹 서버 |
| PHP-FPM | 여러 PHP 자식 프로세스를 관리하고 FastCGI 요청을 처리하는 실행 관리자 |
| FastCGI | 웹 서버와 별도 앱 실행기 사이에서 요청·응답 정보를 주고받는 프로토콜 |
| SAPI | PHP 엔진과 웹 서버·CLI 같은 바깥 실행 환경을 연결하는 인터페이스 |
| CGO | Go 프로그램이 C 함수·자료형·네이티브 라이브러리와 연결되는 기능 |
| ZTS·TSRM | ZTS는 PHP의 스레드 안전 빌드 방식이며, TSRM은 스레드별 런타임 자원 관리 체계 |
| goroutine | Go 런타임이 운영체제 스레드 위에서 배치하는 가벼운 실행 단위 |
| 스레드 풀 | 준비된 여러 실행 스레드에 요청을 배정해 재사용하는 구조 |
| 채널·select | Go 실행 단위 사이의 전달 통로와, 여러 전달·종료 조건을 기다리는 구문 |
| 역압 | 처리 용량이 부족할 때 대기·제한·거절로 상류의 과도한 일을 제어하는 것 |
| 상태 머신 | 준비·실행·종료 같은 상태와 허용되는 전환을 명시적으로 관리하는 방식 |
| 워커 모드 | PHP 앱을 부팅한 상태로 유지하며 콜백을 통해 여러 요청을 처리하는 모드 |
| 슈퍼글로벌 | $_GET·$_SERVER처럼 PHP 여러 범위에서 접근하는 요청·환경 정보 변수 |
| OPcache | PHP의 컴파일된 명령을 저장해 반복 컴파일을 줄이는 기능. 앱 객체 보존과는 별개 |
| Pin·Unpin | C에서 참조할 Go 메모리를 살아 있게 고정하고, 그 고정을 해제하는 동작 |
| 백오프 | 실패를 반복할 때 재시도 사이에 기다리는 정책. 증가식과 상한을 함께 확인 |
| Mercure·SSE | Mercure는 실시간 업데이트를 전달하는 기술이며, SSE는 서버가 브라우저에 이벤트를 보내는 HTTP 방식 |
| Early Hints | 최종 응답 전 103 상태로 필요한 자원 정보를 먼저 알리는 HTTP 기능 |
| Prometheus 메트릭 | 스레드 수·대기 요청 수 같은 수치를 이름 붙여 관측 시스템에 제공하는 데이터 |
| ASan·MSan·-race | 각각 메모리 접근 오류, 초기화되지 않은 메모리 사용, Go의 데이터 경합을 찾는 도구 |
| AST·스텁·arginfo | AST는 코드의 구문 트리, 스텁은 API 선언, arginfo는 PHP가 쓰는 함수 인수 정보 |
| 정적 링크·동적 링크 | 라이브러리를 실행 파일에 묶는 방식과 실행 시 외부 라이브러리에 연결하는 방식 |
커밋을 포함한 링크는 이 글의 코드 기준을 고정합니다.
일반 공식 문서와 라이브러리 API 문서는 이후 바뀔 수 있으므로, 동작 차이를 발견하면
d138a78 시점의 구현과 설정 파일을 함께 대조합니다.
| 항목 | 기준 정보 |
|---|---|
| 선정 | GitHub Trending Go Weekly #11 · 2026-10-23 분석 |
| 스타 / 포크 | 11,454 / 500 · 2026-10-23 GitHub 기준 |
| 저장소 생성일 | 2022-03-25 |
| 주 언어 / 설명 | Go / 🧟 The modern PHP app server |
| 최신 릴리스 | v1.13.2 · 2026-10-21 |
| 최근 push | 2026-10-23 |
| 최근 커밋 | d138a78 · 2026-10-23 · chore: bump Go dependencies (#2751) |
| 추적 파일 규모 | 606개 · 커밋 d138a78 기준 |
| Go | 160개 파일 / 32,756줄 · 이 중 _test.go 69개 / 20,521줄 |
| 비테스트 Go | 91개 파일 / 12,235줄 · 테스트 보조 도구가 일부 포함되므로 모두 서버 구현 코드는 아님 |
| C / C 헤더 | C 4개 / 2,452줄 · 헤더 6개 / 666줄 |
| PHP / JavaScript | PHP 115개 / 2,982줄 · JavaScript 7개 / 263줄 |
| 셸 / YAML | 셸 24개 / 1,403줄 · YAML 24개 / 2,336줄 |
| 문서 | Markdown 212개 / 39,063줄 · 여러 언어 번역 포함 |
| 라이선스 | 루트 LICENSE는 MIT · 포함되는 외부 구성 요소는 별도 확인 |
줄 수는 Git 추적 파일을 확장자로 묶어 센 물리적 줄 수의 대략치입니다.
주석·빈 줄·테스트·픽스처·생성 헤더를 포함하며, 외부 의존성의 소스 줄 수를 더한 수치가 아닙니다.
Go 줄 수의 약 63%가 _test.go에 있지만 이 비율은 테스트 커버리지나 품질 점수가 아닙니다.
제한된 커밋 스냅샷만으로 전체 커밋 수나 기여자 기여 비중을 추정하지 않습니다.
README의 폭넓은 PHP 앱 지원 설명에는 조건이 있습니다.
기준 커밋의 알려진 문제 문서는 imap·newrelic·pcov 등 비호환 확장과 imagick 등의 주의점을 명시합니다.
워커는 전역·정적 상태와 $_ENV를 다음 요청으로 유지할 수 있어 앱별 정리가 필요합니다.
이 글에는 실측 처리량·지연·메모리 벤치마크 결과가 없습니다. 자동 HTTPS·HTTP/3·단일 실행 파일이라는 기능도 인증서 접근성, 네트워크, 빌드 형태와 설정에 따라 조건이 달라집니다. 단일 프로세스의 네이티브 치명 오류는 여러 진행 중 요청에 영향을 줄 수 있습니다.
코어는 MIT지만 docs/compile.md는 선택 구성인 Mercure Go 라이브러리를 AGPL로 표시합니다.
코어의 라이선스 표기만으로 선택 모듈·PHP 확장·최종 배포물의 조건까지 같다고 일반화하지 마세요.
설정 기본값, 실험적 API, PHP 버전별 동작은 이후 커밋에서 달라질 수 있습니다.