PHP · 심층 가이드
PHP 비동기/동시성 완전 정리
이벤트 루프가 없는 PHP에서 curl_multi, Fiber, stream_select, 자식 프로세스를 조합해 여러 작업을 겹쳐 실행하는 실전 구조를 단계별로 만들어 봅니다.
PHP의 실행 모델은 요청 하나가 시작해서 끝나면 메모리를 통째로 버리는 구조입니다. 그래서 언어에 내장된 이벤트 루프가 없고, 표준 함수 대부분은 블로킹으로 동작합니다. Fiber가 들어오면서 상황이 달라졌지만 오해하기 쉬운 부분이 있습니다. Fiber는 중단하고 재개하는 원시 기능만 제공할 뿐 스케줄러도, 논블로킹 I/O도 함께 주지 않습니다. 실제 동시성은 결국 curl_multi나 stream_select 같은 다중화 API, 아니면 별도 프로세스에서 나옵니다.
그래서 순서를 실용적인 쪽부터 잡았습니다. curl_multi 로 병렬 요청은 코드를 거의 바꾸지 않고 대기 시간을 겹치는 가장 빠른 수단이고, curl_multi 배치 API 요청에서 동시 실행 개수를 제한하는 패턴까지 확장합니다. 다음으로 Fiber 기본 사용과 간단한 스케줄러 루프를 붙여 보면 협력형 멀티태스킹이 어떻게 굴러가는지 손에 잡히고, 소켓 레벨로 내려가고 싶을 때 stream_select 로 이벤트 대기가 이어집니다. 외부 명령이 필요한 작업은 proc_open 으로 자식 프로세스 실행이 담당합니다.
직접 만든 동시성 코드에서 가장 흔한 정지 원인은 두 가지입니다. 첫째, Fiber 안에서 file_get_contents나 PDO 질의처럼 블로킹 호출을 하면 스케줄러 전체가 멈춥니다. Fiber는 어디까지나 협력형이라 스스로 suspend 하지 않는 코드는 양보하지 않습니다. 둘째, proc_open으로 띄운 자식의 표준 출력 파이프를 읽지 않으면 OS 파이프 버퍼가 가득 찬 순간 자식이 쓰기에서 멈추고, 부모는 종료를 기다리며 서로 교착합니다. 자식을 기다리기 전에 파이프를 먼저 비우거나 논블로킹으로 설정해 두십시오.
01curl_multi 로 병렬 요청
여러 URL의 cURL 핸들을 하나의 멀티 핸들에 등록한 뒤 curl_multi_exec를 반복 호출해 전송을 겹쳐 실행합니다. 외부 API 두세 곳을 모아 와야 하는 화면에서 응답 시간이 합산되지 않고 가장 느린 요청 하나로 수렴합니다. 겹쳐 도는 것은 소켓 대기뿐이라 받아온 뒤의 파싱은 여전히 한 줄로 처리됩니다.
PHP code
<?php
$urls = ['https://example.com', 'https://example.org'];
$multi = curl_multi_init();
$handles = [];
foreach ($urls as $url) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 3,
]);
curl_multi_add_handle($multi, $ch);
$handles[] = $ch;
}
do {
curl_multi_exec($multi, $running);
curl_multi_select($multi);
} while ($running > 0);
echo count($handles);루프 안에 curl_multi_select()를 넣어야 CPU를 태우지 않고 소켓 이벤트를 기다립니다. 전송이 끝나면 핸들마다 제거와 curl_close()를 짝지어 정리해야 합니다.
curl 확장이 설치되어 있지 않으면 예제 자체가 동작하지 않습니다.
02Fiber 기본 사용
Fiber는 함수 실행을 중간에서 멈췄다 이어갈 수 있는 실행 단위를 만듭니다. start()가 Fiber::suspend('paused')에 넘긴 값을 돌려주고, resume()이 멈춘 지점부터 다시 진행시킵니다. 스레드가 아니므로 두 코드가 동시에 도는 것은 아니고, 제어권을 넘기는 지점을 직접 정한다는 점만 다릅니다.
PHP code
<?php
$fiber = new Fiber(function (): void {
echo "fiber start
";
Fiber::suspend('paused');
echo "fiber resume
";
});
echo $fiber->start() . PHP_EOL;
$fiber->resume();Fiber::suspend()는 파이버 안에서만 부를 수 있고 바깥에서 호출하면 FiberError가 납니다. resume('x')로 넘긴 값은 그대로 suspend() 호출의 반환값이 됩니다.
start()를 부르지 않은 채 resume()을 호출하면 아직 시작되지 않은 파이버라며 FiberError가 던져집니다. 이미 끝난 파이버를 다시 resume()해도 같은 방식으로 실패합니다.
03Generator 코루틴 흉내
yield가 있는 함수는 호출해도 본문이 시작되지 않고 Generator 객체만 돌려주며, current()가 첫 yield까지만 몸통을 실행합니다. 이어서 next()가 다음 구간을 진행시킵니다. Fiber와 달리 깊은 호출 스택 안쪽에서는 양보할 수 없고 함수 자신이 직접 yield해야 합니다.
PHP code
<?php
function task(): Generator {
echo "before
";
yield 'step-1';
echo "after
";
}
$gen = task();
echo $gen->current() . PHP_EOL;
$gen->next();Generator는 send()로 값을 밀어 넣을 수 있고, 본문의 마지막 return 값은 getReturn()으로 따로 꺼냅니다. 단 getReturn()은 순회가 끝난 뒤에만 호출할 수 있습니다.
current()보다 next()를 먼저 부르면 첫 구간이 소리 없이 지나가 첫 yield 값을 놓칩니다. 제너레이터는 되감을 수 없어 그 값은 다시 얻을 방법이 없습니다.
04stream_select 로 이벤트 대기
STDIN을 읽기 감시 목록에 올리고 최대 0.2초를 기다리다가, 읽을 것이 생기면 준비된 스트림 개수를 반환합니다. 소켓 여러 개를 돌아가며 지켜보는 루프의 대기 구간에 씁니다. 넘긴 배열은 참조로 전달돼 호출이 끝나면 준비된 스트림만 남도록 덮어써지므로, 루프를 돌 때마다 배열을 새로 채워야 합니다.
PHP code
<?php
$read = [STDIN];
$write = [];
$except = [];
if (stream_select($read, $write, $except, 0, 200000) > 0) {
echo "stdin ready
";
} else {
echo "no input
";
}대기 인자에 0과 0을 주면 기다리지 않고 즉시 확인하고, null을 주면 준비될 때까지 무한히 막힙니다. 반환값이 false면 시그널 등으로 실패한 경우라 0과 구분해야 합니다.
$read를 루프 밖에서 한 번만 만들면 호출마다 배열이 준비된 스트림만 남게 덮여, 두 번째 반복부터 감시 대상이 사라집니다. 매 반복마다 배열을 새로 채워야 합니다.
05논블로킹 스트림 읽기
메모리 스트림에 문자열을 쓰고 되감은 다음 stream_set_blocking으로 논블로킹 모드를 켠 뒤 읽어 냅니다. 네트워크 소켓이라면 이 설정 덕분에 데이터가 없을 때 기다리지 않고 빈 문자열이 즉시 돌아오지만, php://temp 같은 일반 파일 계열 스트림에는 사실상 영향이 없습니다.
PHP code
<?php
$stream = fopen('php://temp', 'r+');
fwrite($stream, "hello");
rewind($stream);
stream_set_blocking($stream, false);
echo stream_get_contents($stream);이 설정이 실제로 의미를 갖는 대상은 소켓과 파이프처럼 데이터가 늦게 도착하는 스트림입니다. 논블로킹 소켓에서는 빈 문자열이 돌아온 것과 EOF를 feof()로 구분해야 합니다.
논블로킹 소켓에 stream_get_contents()를 쓰면 아직 도착하지 않은 데이터를 기다리지 않고 그때까지 온 조각만 돌려줍니다. 이를 전체 응답으로 착각하면 잘린 본문을 파싱하게 됩니다.
06proc_open 으로 자식 프로세스 실행
자식 프로세스를 띄우면서 표준출력을 파이프로 연결하고, 그 파이프를 stream_get_contents로 끝까지 읽습니다. 이미지 변환기 같은 외부 명령을 감쌀 때 쓰며 shell_exec과 달리 입출력을 따로 잡습니다. 파이프를 다 읽기 전에 proc_close를 부르면 서로 기다리다 멈출 수 있습니다.
PHP code
<?php
$descriptor = [
1 => ['pipe', 'w'],
];
$process = proc_open('php -r "echo 123;"', $descriptor, $pipes);
echo stream_get_contents($pipes[1]);
proc_close($process);descriptor에 2 => ['pipe', 'w']를 추가해 표준에러도 따로 받아야 외부 명령이 왜 실패했는지 알 수 있습니다. 종료 코드는 proc_close()의 반환값으로 돌아옵니다.
파이프를 다 읽지 않은 채 proc_close()를 부르면 자식은 버퍼가 비기를 기다리고 부모는 종료를 기다려 서로 멈춥니다. 읽기를 끝내고 fclose() 한 뒤 닫아야 합니다.
07간단한 스케줄러 루프
클로저 배열을 큐처럼 두고 array_shift로 앞에서 하나씩 꺼내 즉시 호출하는 루프입니다. 작업 목록을 코드 흐름에서 떼어 놓는 최소 형태이며, 앞 작업이 끝나야 다음 작업이 시작됩니다. array_shift는 남은 원소의 인덱스를 매번 다시 매기므로 목록이 길어지면 SplQueue가 낫습니다.
PHP code
<?php
$tasks = [
fn () => print("job-1
"),
fn () => print("job-2
"),
];
while ($task = array_shift($tasks)) {
$task();
}작업이 실행 중에 새 작업을 $tasks에 덧붙이면 같은 루프가 이어서 처리합니다. 큐를 참조로 넘기거나 프로퍼티로 두면 재귀 없이 작업 그래프를 펼칠 수 있습니다.
while ($task = array_shift($tasks))는 대입 결과를 조건으로 삼기 때문에 큐에 null이나 빈 문자열이 섞이면 남은 작업이 있어도 루프가 그대로 끝납니다.
08지수 백오프 폴링
재시도 횟수만큼 돌면서 대기 값을 두 배씩 키워 1, 2, 4로 늘어나는 지연을 계산합니다. 실제 sleep 없이 값 변화만 보여 주는 코드이며, 외부 API가 429나 503을 돌려줄 때 이 배수를 그대로 대기 시간에 씁니다. 모든 클라이언트가 같은 배수를 쓰면 재시도가 한 순간에 겹치므로 약간의 난수를 섞습니다.
PHP code
<?php
$delay = 1;
for ($attempt = 1; $attempt <= 3; $attempt++) {
echo "attempt {$attempt}
";
$delay *= 2;
}
echo "next delay: {$delay}
";지연에 상한을 두지 않으면 몇 번 만에 대기가 분 단위로 커집니다. min($delay, 30) 같은 천장과 전체 시도 제한 시간을 함께 두는 편이 실무에 맞습니다.
이 코드에는 실제 대기가 없어 루프가 즉시 세 번 돕니다. sleep()을 빠뜨린 채 그대로 옮기면 백오프 없이 순간적으로 세 번 요청해 오히려 차단을 앞당깁니다.
09parallel 확장 사용 여부 체크
extension_loaded로 parallel 확장이 올라와 있는지 확인하고 없으면 곧바로 반환합니다. 진짜 스레드가 필요한 스크립트 맨 앞에 가드로 두면 실행 도중 알 수 없는 클래스 오류로 죽는 대신 이유를 남깁니다. 이 확장은 ZTS로 빌드된 PHP에서만 동작해서 흔한 배포판에서는 설치 자체가 막힙니다.
PHP code
<?php
if (!extension_loaded('parallel')) {
echo "parallel extension required
";
return;
}
echo "parallel ready
";넘기는 이름은 php -m 출력과 같아야 하며 대소문자는 가리지 않습니다. CLI와 FPM은 ini 파일이 달라 한쪽에만 확장이 올라간 경우가 흔합니다.
이 가드를 parallel 클래스를 쓰는 코드 뒤에 두면 가드에 닿기도 전에 클래스를 찾지 못해 Error로 죽습니다. 확인은 반드시 사용보다 앞서야 합니다.
10curl_multi 배치 API 요청
ID 목록으로 URL을 만들어 핸들을 생성하고 멀티 핸들에 등록하는 준비 단계까지만 담고 있습니다. 전송을 돌리는 curl_multi_exec 루프와 curl_multi_getcontent 수거가 빠져 있어 이 상태로는 요청이 나가지 않고 개수만 찍힙니다. 핸들마다 제거와 curl_close도 짝지어 정리해야 합니다.
PHP code
<?php
$ids = [101, 102, 103];
$multi = curl_multi_init();
foreach ($ids as $id) {
$ch = curl_init("https://example.com/users/{$id}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_multi_add_handle($multi, $ch);
}
echo count($ids);curl_multi_getcontent()는 CURLOPT_RETURNTRANSFER가 켜진 핸들에서만 본문을 돌려줍니다. 어떤 핸들의 응답인지 구분하려면 핸들을 키로 쓸 수 있는 SplObjectStorage에 ID를 매핑해 둡니다.
전송 루프가 없어 요청이 한 건도 나가지 않는데 화면에는 3이 찍혀 성공한 것처럼 보입니다. 개수만 확인하고 넘어가면 API 호출이 통째로 누락된 채 배포됩니다.
11Fiber 취소 플래그
참조로 캡처한 $cancelled를 루프 조건으로 삼아, 플래그가 켜지면 파이버가 스스로 빠져나와 끝납니다. 바깥에서 값을 바꾼 뒤 resume()을 불러야 파이버가 조건을 다시 확인한다는 점이 핵심입니다. 파이버를 강제로 죽이는 API는 없어 취소는 이렇게 협조적으로만 만들 수 있습니다.
PHP code
<?php
$cancelled = false;
$fiber = new Fiber(function () use (&$cancelled): void {
while (!$cancelled) {
Fiber::suspend();
}
});
$fiber->start();
$cancelled = true;
$fiber->resume();use (&$cancelled)의 &가 없으면 값이 복사돼 바깥에서 아무리 바꿔도 파이버 안 조건은 영원히 false입니다. 취소 신호는 참조나 공유 객체로 전달해야 합니다.
플래그만 켜고 resume()을 부르지 않으면 파이버는 멈춘 자리에 그대로 남아 종료되지 않습니다. 이 상태로 스크립트가 끝나면 정리 코드도 실행되지 않습니다.
12작업 큐 소비 루프
큐에 작업 이름 두 건을 넣고 비어질 때까지 앞에서 꺼내 처리합니다. 넣은 순서 그대로 나오는 선입선출이라 우선순위나 재시도 같은 개념은 들어 있지 않습니다. 이 큐는 실행 중인 프로세스 메모리에만 존재해 요청이 끝나면 사라지므로, 실제 작업 위임에는 Redis나 DB에 얹은 큐가 필요합니다.
PHP code
<?php
$queue = new SplQueue();
$queue->enqueue('email');
$queue->enqueue('thumbnail');
while (!$queue->isEmpty()) {
echo 'run: ' . $queue->dequeue() . PHP_EOL;
}SplQueue는 이중 연결 리스트라 앞에서 꺼내도 인덱스를 다시 매기지 않아 array_shift보다 유리합니다. 순회하며 꺼내려면 setIteratorMode()로 삭제 모드를 켭니다.
이 큐는 프로세스 메모리에만 있어 스크립트가 끝나면 남은 작업이 사라집니다. 웹 요청에서 쌓아 두고 나중에 처리하려 했다면 그 작업은 영영 실행되지 않습니다.
13프로세스 상태 폴링
자식 프로세스를 띄운 뒤 proc_get_status의 running이 꺼질 때까지 루프를 돌며 기다립니다. 사이에 usleep이 없어 CPU를 계속 태우는 바쁜 대기이고, 자식이 출력을 많이 쏟으면 파이프 버퍼가 차서 양쪽이 멈춥니다. 상태 확인보다 파이프 읽기를 먼저 두는 편이 안전합니다.
PHP code
<?php
$process = proc_open('php -r "usleep(100000); echo 1;"', [1 => ['pipe', 'w']], $pipes);
do {
$status = proc_get_status($process);
} while ($status['running']);
echo stream_get_contents($pipes[1]);
proc_close($process);proc_get_status()의 exitcode는 프로세스가 끝난 뒤 처음 조회할 때만 실제 값이고 이후 호출에서는 -1이 됩니다. 종료 코드가 필요하면 첫 결과를 변수에 담아 둬야 합니다.
usleep()이 없는 이 do-while은 자식이 끝날 때까지 CPU 한 코어를 계속 태웁니다. 게다가 출력이 파이프 버퍼를 넘기면 자식이 쓰기에서 멈춰 루프가 영영 끝나지 않습니다.
14비동기 라이브러리 진입점 형태
Composer 오토로더를 불러오는 것 말고는 아무 일도 하지 않는, 비동기 런타임 진입 스크립트의 뼈대입니다. ReactPHP나 Amp 같은 라이브러리라면 이 자리에서 이벤트 루프를 만들고 루프를 돌린 뒤 끝냅니다. 요청마다 프로세스가 정리되는 php-fpm과 달리 이런 스크립트는 CLI에서 계속 살아 있어야 합니다.
PHP code
<?php
require __DIR__ . '/vendor/autoload.php';
echo "bootstrap async runtime";오래 사는 프로세스는 요청마다 초기화되지 않으므로 정적 프로퍼티와 컨테이너에 쌓인 객체가 그대로 남습니다. 주기적으로 재시작하거나 요청 단위 상태를 명시적으로 비워야 합니다.
이 스크립트를 php-fpm 뒤에 두고 웹으로 열면 실행 시간 제한과 요청 종료에 걸려 이벤트 루프가 곧 끊깁니다. 상주 프로세스는 CLI로 띄우고 감시자에 맡겨야 합니다.
정리하며
- 요청 지연이 병목이면 먼저 curl_multi로 대기 시간만 겹쳐도 큰 효과를 얻습니다.
- Fiber는 스케줄러를 제공하지 않으므로 루프와 재개 조건을 직접 설계해야 합니다.
- 협력형 코드 안에서는 블로킹 함수 한 번이 전체를 멈춘다는 점을 전제로 짭니다.
- proc_open 사용 시 자식 파이프를 먼저 읽어 버퍼 포화로 인한 교착을 막습니다.
더 깊이 들어가고 싶다면 PHP 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.