PHP · 심층 가이드
PHP 성능 완전 정리
hrtime으로 실제 비용을 재고 OPcache와 preload로 부팅 비용을 줄이며 generator로 메모리를 아끼는, 9가지 PHP 성능 측정·개선 패턴을 다룹니다.
PHP 성능 이야기는 추측으로 시작하면 거의 틀립니다. 요청마다 스크립트를 다시 컴파일하는 구조 때문에 실행 시간의 상당 부분이 코드 본문이 아니라 부팅과 I/O에 쓰이고, 그래서 반복문 안쪽을 아무리 다듬어도 체감이 없는 경우가 흔합니다. 반대로 옵코드 캐시가 꺼져 있거나 대용량 배열을 통째로 메모리에 올리는 코드 한 줄이 전체를 좌우하기도 합니다. 측정 지점을 먼저 정하고 그다음에 손을 대는 순서가 이 가이드의 뼈대입니다.
앞쪽 예제는 진단, 뒤쪽 예제는 처방에 가깝습니다. hrtime 벤치마크로 나노초 단위의 기준선을 만들고 OPcache 상태 점검으로 운영 환경이 애초에 캐시를 쓰고 있는지 확인한 다음, 병목이 메모리 쪽이면 generator 로 메모리 절약과 PDO fetch 제너레이터로 적재를 지연 순회로 바꿉니다. 파일 입출력이 문제라면 대용량 파일 스트리밍이 같은 아이디어를 디스크에 적용한 형태이고, preload 스크립트 형태는 자주 쓰는 클래스를 시작 시점에 올려 두는 마지막 손질입니다.
마이크로 벤치마크에서 흔히 어긋나는 부분이 있습니다. hrtime은 단조 증가 시계라 시스템 시간이 조정돼도 영향을 받지 않지만, microtime은 그렇지 않아 짧은 구간 측정에서 음수 차이가 나오기도 합니다. generator는 한 번 소진하면 되감을 수 없으므로 같은 결과를 두 번 순회해야 한다면 그 자리에서 배열로 물질화해야 하고, 이때 메모리 이득은 사라집니다. preload는 프로세스를 재시작해야 갱신되므로 배포 절차에 재시작을 넣지 않으면 옛 코드가 계속 살아 있습니다.
01hrtime 벤치마크
hrtime(true)로 나노초 정수를 두 번 찍어 그 사이 구간이 얼마나 걸렸는지 잽니다. 최적화 전후를 같은 조건에서 비교할 때 쓰는 최소 측정 틀입니다. microtime과 달리 단조 증가 시계라 시스템 시간이 조정돼도 값이 뒤로 가지 않고 부동소수 오차도 없습니다.
PHP code
<?php
$start = hrtime(true);
array_sum(range(1, 10000));
$elapsedNs = hrtime(true) - $start;
echo $elapsedNs;인자 없이 부르면 초와 나노초로 나뉜 배열을 돌려줍니다. 나노초를 백만으로 나누면 밀리초가 되고, 짧은 구간은 여러 번 반복해 평균을 내야 의미 있는 값이 나옵니다.
OPcache가 켜진 상태와 꺼진 상태, 첫 실행과 이후 실행의 값이 크게 다릅니다. 한 번 잰 숫자를 근거로 최적화를 결정하면 엉뚱한 곳을 고치게 됩니다.
02OPcache 상태 점검
opcache_get_status가 존재하는지 먼저 확인한 뒤 호출해, 확장이 꺼진 환경에서 오류로 죽지 않게 했습니다. 배포한 서버에 캐시가 실제로 켜졌는지 볼 때 쓰는 점검 코드입니다. 인자 false는 스크립트별 목록을 빼고 요약만 받겠다는 뜻이라, 파일이 많은 서버에서 결과가 비대해지는 것을 막습니다.
PHP code
<?php
if (function_exists('opcache_get_status')) {
$status = opcache_get_status(false);
var_dump($status !== false);
}반환값의 메모리 사용량과 통계에서 캐시가 가득 차 재시작이 잦은지 볼 수 있습니다. CLI는 웹과 설정 파일이 달라 같은 코드라도 결과가 다르게 나옵니다.
이 파일을 공개 경로에 두면 서버 내부 상태가 그대로 노출됩니다. 배포 후 캐시를 비우려면 별도의 절차가 필요하고, 파일을 덮어쓰는 것만으로는 부족한 설정도 있습니다.
03generator 로 메모리 절약
yield로 값을 하나씩 내보내기 때문에 1000개를 세는 동안에도 배열이 통째로 만들어지지 않습니다. range()로 다 올리기 부담스러운 크기를 다룰 때 대안이 됩니다. 제너레이터는 한 번만 순회할 수 있어서, 같은 결과를 두 번 돌리려면 함수를 다시 호출해야 합니다.
PHP code
<?php
function huge(int $count): Generator {
for ($i = 0; $i < $count; $i++) {
yield $i;
}
}
echo iterator_count(huge(1000));iterator_count()는 값을 전부 흘려보내며 세기 때문에 제너레이터를 소진합니다. 개수와 값이 모두 필요하면 순회하면서 카운터를 함께 올리는 편이 낫습니다.
메모리를 아끼려고 제너레이터로 바꿨는데 결과를 다시 배열에 담으면 이득이 사라집니다. 소비하는 쪽까지 스트리밍으로 유지해야 의미가 있습니다.
04정적 캐시 메모이제이션
함수 안의 static $cache는 첫 호출에서만 초기화되고 다음 호출에도 같은 배열이 남아 있습니다. 한 요청 안에서 반복되는 조회 결과를 재사용할 때의 형태입니다. 이 상태는 요청이 끝나면 사라져 요청 간 캐시가 되지 못하고, 테스트에서 값을 되돌릴 방법이 없다는 점도 감수해야 합니다.
PHP code
<?php
function config(string $key): string {
static $cache = ['env' => 'local', 'app' => 'pullh'];
return $cache[$key];
}
echo config('app');이 캐시는 함수 호출 비용만 줄일 뿐 첫 조회 비용은 그대로입니다. 여러 함수가 공유해야 한다면 클래스 정적 프로퍼티나 주입된 캐시 객체로 옮기는 편이 낫습니다.
없는 키를 넘기면 경고와 함께 null이 나오는데 반환 타입이 string이라 그 자리에서 TypeError로 이어집니다. 기본값이나 예외 처리를 함께 두어야 합니다.
05foreach 와 array_map 비교용 골격
같은 변환을 foreach 누적과 array_map으로 각각 돌린 뒤 두 구간의 나노초를 나란히 출력합니다. 어느 쪽이 빠른지 직접 재 보라는 골격입니다. 1000개를 한 번 재는 정도로는 편차가 커서 실행 순서만 바꿔도 결과가 뒤집히니, 여러 번 돌려 중앙값을 봐야 의미가 생깁니다.
PHP code
<?php
$numbers = range(1, 1000);
$start = hrtime(true);
$result = [];
foreach ($numbers as $number) {
$result[] = $number * 2;
}
$foreachNs = hrtime(true) - $start;
$start = hrtime(true);
$mapped = array_map(fn (int $n): int => $n * 2, $numbers);
$mapNs = hrtime(true) - $start;
echo $foreachNs . ' / ' . $mapNs;제대로 비교하려면 실행 순서를 바꿔 가며 여러 번 돌리고 중앙값을 봐야 합니다. 실제 병목은 대개 이런 루프가 아니라 DB 왕복과 외부 API 호출에 있습니다.
이 정도 규모의 측정 결과로 코드 스타일을 결정하면 가독성만 잃습니다. 어디에 시간이 쓰이는지 프로파일러로 먼저 확인하는 편이 낫습니다.
06대용량 파일 스트리밍
php://temp에 몇 줄을 쓰고 되감은 다음 fgets로 한 줄씩 읽어 나갑니다. 파일 전체를 메모리에 올리지 않고 처리하는 흐름을 그대로 보여 주는 코드입니다. php://temp는 일정 크기까지만 메모리에 두고 넘어가면 임시 파일로 옮기므로, 실제 파일 없이 테스트하기에 좋습니다.
PHP code
<?php
$stream = fopen('php://temp', 'r+');
for ($i = 0; $i < 3; $i++) {
fwrite($stream, "row-{$i}
");
}
rewind($stream);
while (($line = fgets($stream)) !== false) {
echo trim($line) . PHP_EOL;
}php://temp는 기본 2MB까지 메모리에 두고 그 이상은 임시 파일로 넘어가며, 경로에 maxmemory 값을 붙여 기준을 바꿀 수 있습니다. 파일로 넘어가지 않게 하려면 php://memory입니다.
스트리밍으로 읽어도 각 줄을 배열에 계속 쌓으면 결국 전체가 메모리에 올라갑니다. 읽는 즉시 처리해 흘려보내야 메모리 이점이 유지됩니다.
07PDO fetch 제너레이터
쿼리 결과를 fetch로 한 행씩 꺼내며 yield해서 전체 결과셋을 배열로 쌓지 않습니다. 수십만 행을 훑어야 할 때 fetchAll 대신 고르는 방식입니다. 순회가 끝날 때까지 statement가 열린 채 남으므로, 같은 연결로 중간에 다른 쿼리를 섞으면 드라이버에 따라 문제가 됩니다.
PHP code
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->exec('CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)');
$pdo->exec("INSERT INTO items (name) VALUES ('php'), ('go')");
function rows(PDO $pdo): Generator {
$stmt = $pdo->query('SELECT name FROM items');
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
yield $row;
}
}
foreach (rows($pdo) as $row) {
echo $row['name'] . PHP_EOL;
}PDO는 기본적으로 결과를 클라이언트에 모두 받아 두므로 MySQL에서 진짜 스트리밍을 하려면 버퍼드 쿼리 옵션을 꺼야 합니다. 그때는 순회 중 다른 쿼리를 쓸 수 없습니다.
다 소비하지 않고 중간에 break하면 statement가 열린 채 남아 다음 쿼리가 막힐 수 있습니다. closeCursor()로 정리하고 빠져나오는 편이 안전합니다.
08preload 스크립트 형태
오토로더를 불러 두고 class_exists로 커널 클래스를 미리 적재하는 프리로드 스크립트의 뼈대입니다. 요청마다 반복되는 클래스 로딩을 줄이려 할 때 씁니다. 이 파일은 php.ini의 opcache.preload로 지정해 마스터 시작 시 한 번 실행되므로, 코드를 고치면 PHP-FPM 재시작이 필요합니다.
PHP code
<?php
// preload.php
require __DIR__ . '/vendor/autoload.php';
class_exists(App\Kernel::class);프리로드된 클래스는 모든 워커가 공유해 요청마다 다시 링크하지 않아도 됩니다. 부모나 인터페이스가 함께 적재되지 않은 클래스는 프리로드에서 조용히 제외됩니다.
프리로드된 파일은 소스를 고쳐도 반영되지 않아 재시작을 빠뜨리면 옛 코드가 계속 돕니다. 개발 환경에서 켜 두면 수정 사항이 사라진 것처럼 보입니다.
09문자열 버퍼링
조각을 배열에 모아 두었다가 마지막에 implode로 한 번에 합치고, 길이와 걸린 시간을 같이 찍습니다. 반복문에서 문자열을 이어 붙이는 코드를 정리할 때 쓰는 형태입니다. PHP의 .=도 내부 버퍼를 늘려 재사용하므로 늘 더 빠르다기보다, 구분자를 한 곳에서 정하는 이점이 큽니다.
PHP code
<?php
$start = hrtime(true);
$buffer = [];
for ($i = 0; $i < 100; $i++) {
$buffer[] = "row-{$i}";
}
$text = implode("
", $buffer);
$elapsed = hrtime(true) - $start;
echo strlen($text) . ' / ' . $elapsed;구분자를 마지막에 한 번만 정하므로 조각마다 붙였다가 끝을 잘라 내는 코드가 사라집니다. 출력이 아주 크다면 모으지 말고 곧바로 스트림에 쓰는 편이 낫습니다.
조각 배열과 합쳐진 문자열이 잠시 동시에 메모리에 남습니다. 수십만 줄을 다루면 implode 시점에 사용량이 두 배로 뛰어 한도에 걸릴 수 있습니다.
정리하며
- 최적화 전에 OPcache 활성 여부부터 확인하세요. 설정 하나가 코드 수십 줄보다 큰 차이를 냅니다.
- 짧은 구간 측정에는 microtime 대신 hrtime을 쓰고, 반복 횟수를 충분히 늘려 노이즈를 눌러야 합니다.
- generator는 한 번만 순회할 수 있습니다. 재사용이 필요하면 메모리 이득을 포기하고 배열로 담으세요.
- 대용량 결과 집합은 fetchAll 대신 커서 순회로 바꾸면 피크 메모리가 데이터 크기와 무관해집니다.
더 깊이 들어가고 싶다면 PHP 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.