홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

PHP 동작원리와 Node.js 비교 — 요청은 어떻게 처리되는가

정기창·2026년 3월 15일

들어가며

이전 회사에서 약 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의 가장 중요한 특성은 요청마다 완전히 새로 시작하고, 끝나면 전부 정리한다는 것입니다.

  1. Nginx가 HTTP 요청을 수신합니다.
  2. FastCGI 프로토콜로 PHP-FPM에 전달합니다.
  3. PHP-FPM master가 유휴 워커에 요청을 할당합니다.
  4. 워커가 PHP 스크립트를 로드하고 실행합니다.
    • OPcode 컴파일 (또는 OPcache에서 로드)
    • 변수, 객체, DB 연결 생성
    • 비즈니스 로직 실행
    • 응답 생성
  5. 응답을 Nginx로 반환합니다.
  6. 워커의 메모리를 전부 해제합니다. 변수, 객체, DB 연결 모두 사라집니다.
  7. 워커가 다음 요청을 대기합니다.

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 필요)메모리에 직접 캐시 가능
전역 변수요청 끝나면 사라짐프로세스 살아있는 한 유지

에러 영향 범위

상황PHPNode.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에서는 자연스럽게 해결되었습니다. 두 런타임을 모두 실무에서 써본 경험이, 각각의 설계 철학을 이해하는 데 큰 도움이 됐다는 생각이 듭니다.

PHPNode.jsPHP-FPM이벤트 루프백엔드

관련 글

프로세스, 스레드, 메모리 — Node.js를 이해하기 위한 OS 기초 (2편)

Node.js의 libuv 스레드 풀을 이해하려면 OS 기초가 필요합니다. 프로세스와 스레드의 차이, CPU 코어와 소프트웨어 스레드의 관계, 메모리 동적 할당까지 — Node.js 동시성의 전제 지식을 정리합니다.

관련도 91%

Node.js는 싱글 스레드인데 어떻게 동시에 처리할까 — 콜 스택과 이벤트 루프 (3편)

1편에서 Node.js의 내부 구조를, 2편에서 프로세스와 스레드의 기본 개념을 확인했습니다. 이번에는 "싱글 스레드인데 어떻게 동시 처리가 가능한가"라는 질문에 답하기 위해, 콜 스택과 이벤트 루프의 관계, libuv가 작업을 처리하는 두 가지 방식, 그리고 이벤트 루프 6개 페이즈의 실체를 소스코드로 확인해봤습니다.

관련도 90%

Node.js 소스코드를 직접 열어봤습니다 — 런타임, V8, libuv의 실체 (1편)

면접 준비를 하다가 "Node.js가 뭔가요?"라는 질문에 제대로 답할 수 없다는 걸 깨달았습니다. 런타임이 뭔지, V8과 libuv가 각각 무슨 역할인지, 실제 Node.js GitHub 소스코드를 열어서 os.hostname() 한 줄이 OS까지 도달하는 과정을 추적해봤습니다.

관련도 90%