PHP 동작원리와 Node.js 비교 — 요청은 어떻게 처리되는가
들어가며
이전 회사에서 약 5년간 PHP로 결제 시스템, 주문 관리, 정기결제 등을 개발했습니다. 이후 NestJS(Node.js)로 전환하면서, 같은 "웹 서버"라도 요청을 처리하는 방식이 근본적으로 다르다는 걸 체감했습니다.
Node.js 시리즈 1~5편에서 런타임, V8, libuv, 이벤트 루프를 다뤘는데, 정작 PHP 쪽은 어떻게 동작하는지 정리한 적이 없었습니다. 면접 준비를 하다가 "PHP에서 Node.js로 왜 전환했나요?"라는 예상 질문을 마주했을 때, PHP의 동작원리를 구조적으로 설명하지 못하면 설득력이 떨어진다는 생각이 들었습니다.
그래서 이번 글에서는 PHP와 Node.js가 HTTP 요청 하나를 어떻게 받아서 처리하고 응답하는지, 그 내부 동작원리를 비교해봤습니다.
PHP의 동작원리
Zend Engine — PHP의 실행 엔진
Node.js에 V8이 있다면, PHP에는 Zend Engine이 있습니다. PHP 소스코드를 실행 가능한 형태로 변환하고 실행하는 인터프리터 엔진입니다.
PHP 소스코드 → [Lexer/Parser] → AST → [Compiler] → OPcode → [Zend VM] → 실행
1편에서 V8이 JavaScript를 기계어로 JIT 컴파일한다고 정리했었는데, Zend Engine은 접근이 다릅니다. 기본적으로 바이트코드(OPcode) 인터프리터입니다. PHP 8.0에서 JIT이 추가됐지만, 일반적인 웹 요청에서는 효과가 크지 않습니다. 그 이유는 뒤에서 설명하겠습니다.
PHP-FPM — 요청을 처리하는 프로세스 관리자
PHP 코드가 웹에서 실행되려면 PHP-FPM(FastCGI Process Manager)이 필요합니다. 웹서버(Nginx)와 PHP 사이에서 요청을 중계하는 프로세스 관리자입니다.
클라이언트 → Nginx → PHP-FPM master → worker process → PHP 스크립트 실행
↓
클라이언트 ← Nginx ← PHP-FPM master ← worker process ← 응답 반환
PHP-FPM은 master process와 worker process로 구성됩니다.
- Master process: 워커 프로세스를 생성하고, 종료하고, 감시합니다. 직접 PHP 코드를 실행하지는 않습니다.
- Worker process: 실제로 PHP 코드를 실행하는 프로세스입니다. 워커 하나가 요청 하나를 처리합니다.
pm.max_children 설정이 동시에 처리할 수 있는 최대 요청 수를 결정합니다. 워커가 10개면 동시 요청은 최대 10개입니다.
요청 라이프사이클 — Shared-Nothing의 핵심
PHP의 가장 중요한 특성은 요청마다 완전히 새로 시작하고, 끝나면 전부 정리한다는 것입니다.
- Nginx가 HTTP 요청을 수신합니다.
- FastCGI 프로토콜로 PHP-FPM에 전달합니다.
- PHP-FPM master가 유휴 워커에 요청을 할당합니다.
- 워커가 PHP 스크립트를 로드하고 실행합니다.
- OPcode 컴파일 (또는 OPcache에서 로드)
- 변수, 객체, DB 연결 생성
- 비즈니스 로직 실행
- 응답 생성
- 응답을 Nginx로 반환합니다.
- 워커의 메모리를 전부 해제합니다. 변수, 객체, DB 연결 모두 사라집니다.
- 워커가 다음 요청을 대기합니다.
6번이 핵심입니다. 이것이 Shared-Nothing 아키텍처입니다. 요청 간에 메모리를 공유하지 않습니다. 모든 상태는 요청과 함께 태어나고, 요청과 함께 사라집니다.
5년간 PHP로 개발하면서 메모리 누수를 걱정해본 적이 거의 없었는데, 돌이켜 생각해보면 이 Shared-Nothing 구조 덕분이었습니다. 당시에는 그게 당연한 줄 알았습니다.
OPcache — 컴파일 결과 재사용
Shared-Nothing이지만, 매 요청마다 소스코드를 파싱하고 컴파일하는 건 낭비입니다. OPcache는 컴파일된 OPcode를 공유 메모리에 캐싱합니다.
OPcache 없이: 소스코드 → 파싱 → AST → 컴파일 → OPcode → 실행 (매 요청)
OPcache 있음: 소스코드 → [캐시 히트] → OPcode → 실행 (파싱/컴파일 생략)
Shared-Nothing의 유일한 예외입니다. OPcode는 읽기 전용이므로 공유해도 안전합니다. 프로덕션에서는 사실상 필수 설정입니다.
Node.js의 동작원리 (요약)
시리즈 1~5편에서 상세히 다뤘으므로 핵심만 정리하겠습니다.
- V8 엔진: JavaScript를 기계어로 JIT 컴파일합니다. Zend Engine보다 실행 속도가 빠릅니다.
- libuv: 비동기 I/O를 처리하는 C 라이브러리입니다. 스레드 풀(기본 4개)과 OS 비동기 API를 관리합니다.
- 이벤트 루프: libuv의
uv_run()함수입니다. 6개 페이즈(Timers → Pending → Idle/Prepare → Poll → Check → Close)를 순환하며 콜백을 실행합니다. - 싱글스레드: JavaScript 실행은 메인스레드 하나에서 이루어집니다. I/O 작업은 libuv에 위임하고, 완료되면 콜백으로 결과를 받습니다.
구조적 차이 비교
동시성 모델 — 가장 핵심적인 차이
PHP-FPM:
요청 1 → [워커 1] DB쿼리... 대기... 응답 → 반환
요청 2 → [워커 2] DB쿼리... 대기... 응답 → 반환
요청 3 → [워커 3] DB쿼리... 대기... 응답 → 반환
↑ 각 워커가 독립적으로 블로킹 대기
Node.js:
요청 1 → [메인스레드] DB쿼리 위임 → 요청 2 처리
요청 2 → [메인스레드] DB쿼리 위임 → 요청 3 처리
요청 3 → [메인스레드] DB쿼리 위임 → 대기
↑ DB 응답 도착하면 Poll에서 콜백 실행
PHP는 워커 수 = 동시 처리 수입니다. 동시 접속 1,000개를 처리하려면 워커가 1,000개 필요하고, 워커당 10~50MB면 메모리만 수십 GB가 필요합니다.
Node.js는 메인스레드 하나로 수천 개의 동시 연결을 처리할 수 있습니다. I/O 대기 시간에 다른 요청을 처리하기 때문입니다. 대신, CPU를 오래 점유하는 작업이 있으면 이벤트루프 전체가 멈춥니다.
비유로 이해하기
PHP-FPM은 은행 창구에 비유할 수 있습니다.
- 창구 10개가 있으면 동시에 고객 10명을 처리할 수 있습니다.
- 한 고객이 서류를 기다리면 그 창구 직원도 같이 기다립니다.
- 다른 창구에는 영향이 없습니다.
Node.js는 식당 홀서버에 비유할 수 있습니다.
- 서버 1명이 테이블 20개를 담당합니다.
- 주문을 받으면 주방에 넘기고 다음 테이블로 갑니다.
- 요리가 나오면 해당 테이블에 서빙합니다.
- 서버가 쓰러지면 홀 전체가 마비됩니다.
메모리 모델
| 항목 | PHP (Shared-Nothing) | Node.js |
|---|---|---|
| 메모리 수명 | 요청 시작~종료 | 프로세스 시작~종료 |
| 메모리 누수 | 사실상 없음 (매번 정리) | 주의 필요 (누적됨) |
| 요청 간 캐시 | 불가 (Redis 필요) | 메모리에 직접 캐시 가능 |
| 전역 변수 | 요청 끝나면 사라짐 | 프로세스 살아있는 한 유지 |
에러 영향 범위
| 상황 | PHP | Node.js |
|---|---|---|
| Fatal Error | 해당 워커만 죽음 | 프로세스 전체 종료 가능 |
| 무한 루프 | 해당 워커만 타임아웃 | 이벤트루프 전체 블로킹 |
| 메모리 초과 | 해당 요청만 실패 | OOM으로 프로세스 종료 |
PHP의 Shared-Nothing은 장애 격리에서 압도적으로 유리합니다. 한 요청의 문제가 다른 요청에 절대 전파되지 않습니다. Node.js로 전환한 이후에야 이 장점이 얼마나 큰 것이었는지 느꼈습니다.
I/O 처리 방식
// PHP — 동기 블로킹
$result = $pdo->query("SELECT * FROM users");
// ↑ DB 응답이 올 때까지 워커가 멈춥니다. 아무것도 못 합니다.
echo json_encode($result->fetchAll());
// Node.js — 비동기 논블로킹
const users = await User.find();
// ↑ DB 쿼리를 OS에 위임하고, 이벤트루프는 다른 요청을 처리합니다.
// DB 응답이 오면 Poll 페이즈에서 콜백 → Promise resolve → await 재개.
res.json(users);
PHP 워커가 DB 응답을 기다리는 1~5초 동안, Node.js의 이벤트루프는 수십~수백 개의 다른 요청을 처리할 수 있습니다.
JIT 컴파일: V8 vs Zend Engine
앞에서 PHP 8.0의 JIT이 웹 요청에서 효과가 크지 않다고 했는데, 그 이유를 정리하겠습니다.
| 항목 | V8 (Node.js) | Zend Engine (PHP) |
|---|---|---|
| 프로세스 수명 | 서버 시작~종료 (수시간~수개월) | 요청 시작~종료 (수십ms~수초) |
| JIT 워밍업 | 충분한 시간 확보 | 요청 끝나면 최적화 결과 소멸 |
| Hot path 최적화 | 반복 실행되는 코드를 기계어로 변환 | 요청 수명이 짧아 hot path 식별 어려움 |
Node.js 서버는 한번 시작하면 app.listen() 상태로 오래 살아있습니다. V8이 자주 실행되는 코드를 기계어로 최적화할 시간이 충분합니다. PHP는 요청이 끝나면 모든 것이 사라지므로, JIT이 워밍업할 시간이 부족합니다.
결국 Shared-Nothing이라는 PHP의 최대 강점이, JIT에서는 오히려 약점으로 작용하는 셈입니다. 흥미로운 트레이드오프라는 생각이 들었습니다.
각각은 언제 강한가
PHP가 유리한 경우
- 안정성이 최우선인 환경: 한 요청의 에러가 다른 요청에 영향을 줘서는 안 되는 서비스
- CPU 집약 작업이 섞인 웹 서비스: 무거운 연산을 해도 다른 요청이 멈추지 않습니다
- 레거시 시스템과의 통합: WordPress, Laravel 기반의 거대한 에코시스템
- 단순한 운영: 상태가 없으니 graceful shutdown이나 메모리 모니터링 부담이 적습니다
Node.js가 유리한 경우
- I/O-bound 작업 위주: DB 쿼리, 외부 API 호출이 대부분인 API 서버
- 대량 동시 연결: 채팅, 실시간 알림, WebSocket
- 메모리 효율: 적은 리소스로 많은 동시 요청을 처리해야 할 때
- 타입 안전성: TypeScript로 대규모 코드베이스를 관리할 때
- 풀스택 언어 통일: 프론트엔드와 백엔드가 같은 언어를 쓸 수 있습니다
실무에서 느낀 차이
이전 회사에서 정기결제 배치를 PHP CLI로 돌렸을 때, 워커 하나가 수천 건의 결제를 순차 처리하면서 CPU를 오래 점유했습니다. 하지만 웹서버의 다른 워커들은 정상적으로 요청을 처리했습니다. PHP-FPM의 프로세스 격리 덕분이었습니다. 당시에는 "원래 그런 거 아닌가?" 싶었는데, Node.js로 넘어오고 나서야 그게 얼마나 편한 구조였는지 알게 됐습니다.
반면 NestJS로 대량 주문 취소/승인 시스템을 개발할 때는, Bull Queue에 concurrency: 4를 설정해서 4개의 Job이 I/O 대기 시간을 중첩 활용할 수 있었습니다. 하나의 Job이 PG API 응답을 1~5초 기다리는 동안 다른 Job이 실행됩니다. PHP에서 같은 효과를 내려면 워커를 4개 띄워야 했을 겁니다. Node.js의 논블로킹 I/O가 빛나는 순간이었습니다.
결국 "어느 쪽이 더 좋다"가 아니라, 워크로드의 특성에 따라 적합한 모델이 다르다는 것이 PHP를 오래 쓰고 Node.js로 전환하면서 얻은 결론입니다. PHP의 Shared-Nothing이 주는 안정감도, Node.js의 이벤트루프가 주는 효율성도, 각각의 맥락에서 분명한 강점이었습니다.
정리
| 항목 | PHP (PHP-FPM) | Node.js |
|---|---|---|
| 실행 엔진 | Zend Engine (바이트코드) | V8 (JIT 컴파일) |
| 동시성 | 멀티프로세스 (워커 N개) | 싱글스레드 + 이벤트루프 |
| I/O | 동기 블로킹 | 비동기 논블로킹 |
| 메모리 | Shared-Nothing (요청마다 초기화) | 프로세스 수명 동안 유지 |
| 메모리 누수 | 사실상 없음 | 주의 필요 |
| 에러 격리 | 완벽 (워커 단위) | 프로세스 전체 영향 |
| CPU 작업 | 워커가 독립 처리 | 이벤트루프 블로킹 위험 |
| 동시 접속 효율 | 워커 수에 비례 | 적은 리소스로 대량 처리 |
| 실시간 통신 | 부적합 | WebSocket, SSE 강점 |
돌이켜 생각해보면, PHP에서 당연하게 누렸던 것들(메모리 누수 걱정 없음, 에러 격리, 배치 작업의 독립성)이 Node.js에서는 의식적으로 신경 써야 하는 부분이었고, PHP에서 불편했던 것들(동기 블로킹, 요청 간 상태 공유 불가)이 Node.js에서는 자연스럽게 해결되었습니다. 두 런타임을 모두 실무에서 써본 경험이, 각각의 설계 철학을 이해하는 데 큰 도움이 됐다는 생각이 듭니다.
관련 글
프로세스, 스레드, 메모리 — Node.js를 이해하기 위한 OS 기초 (2편)
Node.js의 libuv 스레드 풀을 이해하려면 OS 기초가 필요합니다. 프로세스와 스레드의 차이, CPU 코어와 소프트웨어 스레드의 관계, 메모리 동적 할당까지 — Node.js 동시성의 전제 지식을 정리합니다.
Node.js는 싱글 스레드인데 어떻게 동시에 처리할까 — 콜 스택과 이벤트 루프 (3편)
1편에서 Node.js의 내부 구조를, 2편에서 프로세스와 스레드의 기본 개념을 확인했습니다. 이번에는 "싱글 스레드인데 어떻게 동시 처리가 가능한가"라는 질문에 답하기 위해, 콜 스택과 이벤트 루프의 관계, libuv가 작업을 처리하는 두 가지 방식, 그리고 이벤트 루프 6개 페이즈의 실체를 소스코드로 확인해봤습니다.
Node.js 소스코드를 직접 열어봤습니다 — 런타임, V8, libuv의 실체 (1편)
면접 준비를 하다가 "Node.js가 뭔가요?"라는 질문에 제대로 답할 수 없다는 걸 깨달았습니다. 런타임이 뭔지, V8과 libuv가 각각 무슨 역할인지, 실제 Node.js GitHub 소스코드를 열어서 os.hostname() 한 줄이 OS까지 도달하는 과정을 추적해봤습니다.