PHP 8.5 OPcache·JIT·프로파일링 실측: 무엇이 진짜 빨라지나
CLI 에선 OPcache 가 오히려 느렸고 서버 요청은 35 배 빨라졌다. JIT 는 계산 4.5 배, 문자열 작업 8%. 900 배 느린 문자열 연결과 제너레이터 메모리까지 실측.
시리즈: PHP 강좌 6/6 (고급). PHP 코드는 잘 돌아가고, 이제 시간과 메모리가 어디로 가는지 알고 싶은 분을 위한 글입니다. 다 읽으면 OPcache 와 JIT 를 효과 있는 곳에만 켜고(효과 없는 곳도 알고), 프로파일러를 설치하지 않고 hrtime 으로 재고, PHP 가 실제로 실행하는 opcode 를 읽고, 필요 이상으로 100 배 비싼 루프 패턴을 알아볼 수 있습니다.
"PHP 페이지가 느리다"는 말에 가장 먼저 "JIT 켜 봐"라는 답이 돌아오는 팀이라면 이 글이 필요합니다. 우리 측정에서 JIT 는 순수 계산에서 4.5 배, 흔한 웹 작업에서 8% 였고, 문자열 만드는 한 줄을 고친 것은 거의 900 배였습니다. 공유 호스팅처럼 php.ini 를 마음대로 못 바꾸는 환경일수록 코드 쪽 개선이 더 중요합니다. 숫자는 모두 아래 환경에서 잰 것이고, 측정이 우리를 속인 경우(파일이 너무 새것이라 OPcache 없이 돈 벤치마크)도 그대로 남겼습니다.
실행 환경: PHP 8.5.6 CLI (NTS) + Zend OPcache, Apple Silicon Mac (M1 Pro), macOS, memory_limit=128M. xdebug·SPX·xhprof·Blackfire 는 설치돼 있지 않고 이 글에서도 아무것도 설치하지 않습니다. 시간은 모두 hrtime() 으로 쟀고, 중앙값에는 실행 횟수를 적었습니다. 출력의 경로는 ~/demo/ 로 줄였습니다.
확장 없이 프로파일링: hrtime 과 '무엇을 재는지' 확인하기
hrtime(true) 는 단조 증가하는 나노초 카운터를 정수로 돌려줍니다. microtime(true) 와 달리 시스템 시계를 맞춰도 튀지 않고 부동소수 반올림도 없습니다. 이 글의 프로세스 내 벤치마크는 전부 이 함수로 돌렸습니다.
<?php
// one warm-up call, then the median of 5 rounds
function bench(string $name, callable $fn, int $rounds = 5): void {
$fn(); // warm-up round, discarded
$t = [];
for ($r = 0; $r < $rounds; $r++) { $s = hrtime(true); $res = $fn(); $t[] = (hrtime(true) - $s) / 1e6; }
sort($t);
printf("%-34s %8.2f ms (%s)\n", $name, $t[intdiv($rounds, 2)], is_scalar($res) ? $res : count($res));
}
결과값도 함께 찍습니다. 두 방식의 결과가 다르면 다른 일을 비교하고 있는 것이니까요. 진짜 프로파일러(xdebug 프로파일러, SPX, Blackfire)는 어느 함수가 느린지 추측 없이 보여 줍니다. 여기에는 없으므로 방법은 이렇습니다. 요청 전체를 재고, 의심 가는 곳 주변을 hrtime 으로 감싸 반씩 좁혀 갑니다.
PHP 에는 실제로 무엇을 실행하는지 보는 방법도 들어 있습니다. opcache.opt_debug_level 은 최적화 전(0x10000)과 후(0x20000)의 opcode 를 출력합니다.
function f(array $a): int {
$n = 0;
for ($i = 0; $i < count($a); $i++) $n += strlen("hello") * 2;
return $n;
}
$ php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x20000 ops.php
f:
; (after optimizer)
0004 CV1($n) = ADD CV1($n) int(10)
0005 PRE_INC CV2($i)
0006 T4 = COUNT CV0($a)
0007 T3 = IS_SMALLER CV2($i) T4
0008 JMPNZ T3 0004
strlen("hello") * 2 는 최적화 전 출력에서 이미 상수 int(10) 이었습니다. 컴파일러가 접어 버린 것입니다. 이런 식으로 "strlen 이 얼마나 빠른가"를 재면 덧셈을 재게 됩니다. 그리고 count() 는 함수 호출이 아니라 COUNT opcode 하나입니다. 이건 뒤에서 다시 나옵니다.
OPcache: CLI 와 서버는 정반대
OPcache 는 컴파일된 opcode 를 공유 메모리에 저장해 다음 요청에서 파싱과 컴파일을 건너뛰게 합니다. CLI 에서는 기본으로 꺼져 있고(opcache.enable_cli => Off), 그럴 만한 이유가 있습니다. 작은 클래스 2,000 개짜리 437KB 파일을 만들고, 설정마다 새 php 프로세스를 20 번(첫 1 회는 버림) 띄워 쟀습니다.
empty script median 56.1 ms
opcache.enable_cli=0 median 64.7 ms last line: M1999-05998 loaded 2000 classes in 7.78 ms
opcache.enable_cli=1 median 75.7 ms last line: M1999-05998 loaded 2000 classes in 17.49 ms
enable_cli=1 + file_cache median 58.3 ms last line: M1999-05998 loaded 2000 classes in 1.74 ms
20 회짜리 측정을 두 번 더 했을 때는 63.9/75.3/58.2, 64.3/74.5/58.2ms 였습니다. enable_cli=1 에서 CLI 는 오히려 느려졌습니다. 프로세스마다 최적화를 돌리고 캐시를 채운 뒤 종료하니 재사용될 일이 없습니다. opcache.file_cache=<디렉터리> 와 opcache.file_cache_only=1 을 주면 컴파일 결과가 디스크에 남아, 다음 프로세스는 2,000 클래스를 7.78ms 대신 1.74ms 에 읽었습니다. 56ms 라는 바닥도 눈여겨보세요. 확장 67 개를 읽는 이 PHP 빌드는 시작만으로 스크립트 전체보다 오래 걸립니다.
서버는 반대입니다. 내장 서버는 오래 사는 프로세스 하나이고 opcache.enable 이 적용됩니다. ext/curl 로 순차 요청 300 회(첫 1 회 제외)입니다.
| 내장 서버 | 중앙값 | p95 | 스크립트 안의 로드 시간 |
|---|---|---|---|
opcache.enable=0 |
6.79ms (2 회째 6.95) | 7.43ms | 6.31ms |
opcache.enable=1 |
0.18ms (0.20) | 0.26ms | 0.01ms |
측정 후 opcache_get_status() 는 hits=1198 misses=3 cached_scripts=3 이었습니다. 요청당 약 35 배이고, 거의 전부가 컴파일 시간입니다. PHP-FPM 과 Apache mod_php 는 두 번째 줄처럼 동작하므로 운영에서는 OPcache 를 그냥 켜 두면 됩니다.
JIT: CPU 만 쓰는 벤치마크
PHP 8.0 부터 OPcache 에는 자주 실행되는 opcode 를 기계어로 바꾸는 JIT 컴파일러가 들어 있습니다. 8.5 에서는 기본값이 꺼짐입니다(opcache.jit => disable, opcache.jit_buffer_size => 64M). 테스트는 600×600, 픽셀당 최대 200 반복의 망델브로 집합입니다. PHP 루프 안의 순수한 부동소수 계산입니다.
<?php
// mandel.php — CPU-bound: 600x600 Mandelbrot, 200 iterations max. Prints a checksum.
declare(strict_types=1);
function mandel(int $w, int $h, int $maxIter): int {
$inside = 0;
for ($y = 0; $y < $h; $y++) {
$ci = 2.0 * $y / $h - 1.0;
for ($x = 0; $x < $w; $x++) {
$cr = 3.0 * $x / $w - 2.0;
$zr = 0.0; $zi = 0.0; $i = 0;
while ($i < $maxIter && $zr * $zr + $zi * $zi < 4.0) {
$t = $zr * $zr - $zi * $zi + $cr;
$zi = 2.0 * $zr * $zi + $ci;
$zr = $t;
$i++;
}
if ($i === $maxIter) $inside++;
}
}
return $inside;
}
$t = hrtime(true);
$r = mandel(600, 600, 200);
printf("mandel %d %.1f ms\n", $r, (hrtime(true) - $t) / 1e6);
설정마다 새 프로세스 5 회, 스크립트 안의 타이머 기준이고, 모든 실행이 같은 체크섬 91677 을 출력했습니다.
| 플래그 | 중앙값 | 최소~최대 |
|---|---|---|
opcache.enable_cli=0 |
524.7ms | 524.4~525.5 |
enable_cli=1, jit=disable |
334.4ms | 333.6~338.7 |
jit=tracing, jit_buffer_size=0 |
333.2ms | 332.8~333.8 |
jit=tracing, 버퍼 64M |
75.0ms | 74.6~76.4 |
jit=function, 버퍼 64M |
74.1ms | 73.9~74.9 |
세 가지를 알 수 있습니다. OPcache 만으로도 루프가 1.6 배 빨라졌습니다. 최적화기가 타입을 추론해 특화된 opcode 를 쓰기 때문입니다. JIT 는 거기에 4.5 배를 더했습니다. 그리고 버퍼가 0 인 jit=tracing 은 아무 말 없이 아무것도 하지 않았습니다. 진실은 opcache_get_status(false)['jit'] 가 알려 줍니다.
-d opcache.jit=tracing → opcache_get_status: false
-d opcache.enable_cli=1 -d opcache.jit=tracing → jit enabled=true on=true kind=5 buffer=67108848
... -d opcache.jit_buffer_size=0 → jit enabled=false on=false kind=5 buffer=0
-d opcache.enable_cli=1 -d opcache.jit=bogus
PHP Warning: Invalid "opcache.jit" setting. Should be "disable", "on", "off", "tracing", "function" or 4-digit number in Unknown on line 0
enable_cli=1 이 없으면 CLI 에서 JIT 설정은 아무 효과가 없고, 경고는 오타일 때만 나옵니다. 이 벤치마크처럼 뜨거운 함수가 하나뿐이면 tracing 과 function 이 비슷합니다. 뜨거운 경로가 여러 함수에 걸칠 때 보통 tracing 이 유리합니다.
문자열·배열 작업에서의 JIT
PHP 시간의 대부분은 배열 만들기, json_encode, 정렬, 문자열 함수에 쓰이고, 이것들은 JIT 와 상관없이 PHP 안의 C 코드로 돕니다.
<?php
// strwork.php — "web-ish" work: build rows, json_encode, explode, str_replace, sort.
declare(strict_types=1);
mt_srand(42);
$t = hrtime(true);
$total = 0;
for ($round = 0; $round < 40; $round++) {
$rows = [];
for ($i = 0; $i < 5000; $i++) {
$rows[] = ['id' => $i, 'name' => 'user_' . mt_rand(1, 99999), 'tags' => 'a,b,c,' . ($i % 7)];
}
$json = json_encode($rows);
$back = json_decode($json, true);
usort($back, fn($a, $b) => strcmp($a['name'], $b['name']));
foreach ($back as $r) {
$total += count(explode(',', $r['tags'])) + strlen(str_replace('user_', 'u:', $r['name']));
}
}
printf("strwork %d %.1f ms\n", $total, (hrtime(true) - $t) / 1e6);
| 플래그 | 5 회 중앙값 | 최소~최대 |
|---|---|---|
| OPcache 없음 | 261.5ms | 254.1~267.6 |
| OPcache, JIT 끔 | 261.2ms | 260.2~267.5 |
| JIT tracing | 240.2ms | 234.9~245.0 |
| JIT function | 244.7ms | 241.0~258.0 |
약 8% 이고 최소~최대 범위가 거의 맞닿습니다. DB 를 조회하고 템플릿을 그리는 보통의 페이지라면 효과는 더 작습니다. I/O 대기는 전혀 빨라지지 않으니까요. 이미지 처리, 수치 계산, PHP 로 짠 파서라면 JIT 를 켤 가치가 있고, CRUD 앱이라면 OPcache 가 답이고 JIT 는 잡음 수준입니다.
메모리: 100 만 개를 배열로, 제너레이터로
정수 100 만 개를 더한 뒤의 memory_get_peak_usage() 를, 모드마다 새 프로세스에서 루프 전 사용량 대비로 쟀습니다.
function numbers(int $n): Generator { for ($i = 0; $i < $n; $i++) yield $i; }
// 'array': $a = range(0, $n - 1); foreach ($a as $v) $sum += $v;
// 'gen': foreach (numbers($n) as $v) $sum += $v;
| 100 만 개 | 최대 증가량 | 시간 |
|---|---|---|
range() 정수 배열 |
16.0MB | 7ms |
제너레이터(yield) |
0.0MB | 22ms |
["k$i" => $i] 문자열 키 |
76.0MB | 66ms |
["row-$i", …] 문자열 |
54.2MB | 45ms |
[['id' => $i, 'v' => …], …] 행 배열 |
128M 에서 Fatal, 1G 로 374.6MB | 185ms |
정수만 든 packed 배열은 원소당 16 바이트입니다. 제너레이터는 값을 하나씩만 들고 있지만 여기서는 3 배 느렸습니다. yield 마다 함수를 재개하기 때문입니다. 문자열 키는 해시 테이블과 키 문자열이 더해집니다. 행마다 배열인 마지막 경우는 "모든 행 가져오기" 쿼리가 만드는 모양이고, 기본 한도에서는 죽었습니다.
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes) in ~/demo/mem.php on line 13
제너레이터는 큰 입력(CSV, PDOStatement::fetch() 커서)을 흘려보낼 때 쓰는 도구로, "메모리 부족"을 "일정한 메모리"로 바꿔 줍니다. 이미 메모리에 들어가는 작은 배열이라면 배열이 더 빠릅니다.
흔한 느린 패턴, 실측
slow.php 로 각 패턴을 bench() 로 돌렸습니다. 워밍업 1 회 후 5 회 중앙값이고, 먼저 OPcache 없이(CLI 기본값), 다음에 OPcache + tracing JIT 로 쟀습니다.
| 패턴 | OPcache 없음 | OPcache + JIT |
|---|---|---|
조건에 count($data) 가 있는 for, 100 만 |
7.55ms | 1.13ms |
count() 를 변수로 뺌 |
6.54ms | 1.02ms |
foreach |
5.24ms | 1.89ms |
$s .= $piece, 20 만 조각 |
8.96ms | 8.33ms |
$s = $s . $piece, 20 만 조각 |
7,961ms | 7,619ms |
$s = $piece . $s, 2 만 조각 |
66.70ms | 95.60ms |
배열을 implode(), 20 만 |
9.85ms | 9.96ms |
in_array 로 1 만 개를 1 만 개 문자열에서 |
232.51ms | 224.93ms |
array_flip 1 회 + isset 1 만 회 |
0.37ms | 0.26ms |
in_array, 한 번만 찾기 |
0.04ms | 0.04ms |
array_flip + isset, 한 번만 찾기 |
0.08ms | 0.09ms |
$all = array_merge($all, $chunk), 2 천 × 50 |
227.13ms | 224.18ms |
array_push($all, ...$chunk) |
1.06ms | 1.10ms |
조각을 모아 array_merge(...$parts) 한 번 |
0.46ms | 0.46ms |
조건 안의 count() 는 가장 많이 반복되는 성능 팁이지만 여기서는 가장 덜 중요했습니다. 100 만 번에 1ms 차이입니다. count() 는 O(1) opcode 이기 때문입니다(위 덤프). 가독성 때문에 빼는 건 좋습니다. JIT 에서는 오히려 foreach 가 셋 중 가장 느렸습니다. PHP 5 시절의 속설은 측정 앞에서 살아남지 못합니다.
진짜 함정은 문자열 연결에 있었습니다.
자주 하는 실수: 루프 안의 $s = $s . $x
실수. 덧붙이기를 긴 형태로 쓰는 것. 같은 줄에서 다른 일도 할 때 자주 나옵니다. $html = $html . render($row); 같은 식입니다.
실제로 일어나는 일. 매번 새 문자열을 만들고 지금까지 만든 내용을 전부 복사합니다. 20 만 조각이 8.96ms 대신 7,961ms, 거의 900 배였고, 크기의 제곱으로 늘어납니다.
n= 25000 s=s.x 91.6 ms s.=x 1.00 ms
n= 50000 s=s.x 296.6 ms s.=x 2.06 ms
n=100000 s=s.x 1141.0 ms s.=x 4.10 ms
n 을 두 배로 하면 3.2~3.8 배 느려졌고, .= 는 선형으로 늘었습니다. 참조가 하나뿐이면 PHP 가 문자열을 그 자리에서 늘릴 수 있기 때문입니다. 앞에 붙이기($s = $piece . $s)도 제곱으로 늘어납니다. JIT 도 소용없었습니다(7,619ms).
수정. .= 를 쓰거나, 조각을 배열에 모아 implode() 한 번. 둘 다 약 9ms 였습니다.
in_array 와 뒤집은 배열의 isset. in_array 는 배열을 처음부터 훑으므로 1 만 개 중에서 1 만 번 찾으면 최악의 경우 1 억 번 비교, 232ms 입니다. 한 번 뒤집으면 값이 해시 테이블의 키가 되고 isset 은 해시 조회 한 번이라 0.37ms, 628 배 빨랐습니다. 하지만 한 번만 찾을 때는 뒤집는 비용이 더 큽니다(0.08 대 0.04ms). 같은 배열에서 여러 번 찾을 때 뒤집으세요.
루프 안의 array_merge 는 커지는 배열을 매번 복사하는 같은 제곱 모양입니다. 조각을 모아 한 번에 합치면 227ms 가 0.46ms 가 됐습니다.
속이는 마이크로벤치마크 (우리도 속았다)
JIT 워밍업을 보여 주려고 mandel(150, 150, 200) 을 6 번 부르는 작은 파일을 만들고, 만들자마자 opcache.jit=tracing 으로 돌렸습니다.
call 1: 33.38 ms
call 2: 32.96 ms
...
call 6: 33.82 ms
JIT 를 끈 것과 같았습니다. JIT 버퍼는 1KB 쯤 늘었을 뿐입니다. 원인은 opcache.file_update_protection => 2 였습니다. OPcache 는 반쯤 쓰인 파일을 캐시하지 않으려고 최근 2 초 안에 수정된 파일을 캐시하지 않고, 캐시가 없으면 JIT 도 없습니다. 2 초 뒤 같은 명령입니다.
call 1: 5.16 ms
call 2: 4.72 ms
call 6: 4.70 ms
만들자마자 돌린 벤치마크, 고치고 2 초 안에 다시 돌린 벤치마크는 OPcache 없는 PHP 를 재고 있는 것입니다. 이 글에서 본 다른 함정들입니다.
- CLI 기본값은 운영 환경이 아니다. OPcache 없이
count()루프는 7.55ms, 있으면 1.13ms. 서버와 같은 OPcache 설정으로 재세요. - 상수 접기. 루프 속
strlen("hello")는10으로 컴파일됩니다. 컴파일러가 알 수 없는 값을 넣어야 합니다. - 너무 작아서 의미 없는 차이. 한 번 찾기의 0.04 대 0.08ms 는 사실이지만 중요하지 않습니다. 중요한 건 232 대 0.37ms 쪽입니다.
- 프로세스 시작이 지배한다. 빈 스크립트가 56ms. CLI 도구에서 5ms 최적화는 그 옆에서 보이지 않습니다.
- 잡음.
strwork는 같은 실행끼리도 254~268ms 로 흔들렸습니다. 5% 미만의 차이는 훨씬 많이 돌려야 말할 수 있습니다.
판단표
| 상황 | 할 일 | 근거 |
|---|---|---|
| 웹 서버(FPM, mod_php, LiteSpeed) | opcache.enable=1 |
요청당 6.79 → 0.18ms |
| 한 번 돌고 끝나는 CLI, cron | enable_cli=0 유지 또는 opcache.file_cache |
64.7 / 75.7 / 58.3ms |
| PHP 루프 속 순수 계산 | opcache.jit=tracing(버퍼 > 0) |
334 → 75ms |
| JSON, 문자열, 배열, DB | JIT 보다 알고리즘 | 261 → 240ms |
| 문자열 만들기 | .= 또는 implode |
8.96 대 7,961ms |
| 여러 번의 포함 여부 검사 | array_flip 한 번 후 isset |
232 → 0.37ms |
| 큰 입력 | 제너레이터 | 0MB 대 Fatal |
| JIT 가 켜져 있나? | opcache_get_status()['jit']['on'] 확인 |
버퍼 0 이면 조용히 꺼짐 |
솔직한 한계와 버전 메모
- PHP 8.5.6, Apple Silicon 한 대에서만 쟀습니다. x86-64 Linux 서버에서의 JIT 효과는 다를 것입니다. 옛 버전은 돌려 보지 않았습니다. PHP 변경 기록에 따르면 JIT 는 8.0 에 추가됐고, 8.4 에서 기본값이
opcache.jit=disable,jit_buffer_size=64M으로 바뀌었습니다(그 전에는jit=tracing에 버퍼 0, 역시 꺼진 상태). - 프로파일러 확장은 쓰지 않았습니다. xdebug·SPX·xhprof·Blackfire 는 함수별 비용을 보여 주지만
hrtime은 감싼 곳만 잴 수 있습니다. 공유 호스팅에서는 이런 확장을 설치할 수 없는 경우가 많아서 이 방법을 알아 둘 가치가 있습니다. - 서버 테스트는 PHP 의 단일 스레드 내장 서버에 순차 요청을 보낸 것이지 동시 부하가 아닙니다. OPcache 가 없애는 컴파일 비용을 보여 줄 뿐 처리량은 아닙니다.
memory_get_peak_usage()는 PHP 자체 할당자만 셉니다. 라이브러리가 잡는 메모리(5 강의 argon2, 이미지 라이브러리, SQLite 엔진)는 나오지 않습니다.
연습문제
1. 활성 사용자의 ID 를 돌려주는 함수입니다. 사용자 3 만 명, 활성 주소 1 만 개로 1,213ms 걸렸습니다. 결과를 바꾸지 않고 빠르게 만들고, 결과가 같다는 것을 어떻게 증명할지 답해 보세요.
function activeIds(array $users, array $activeEmails): array {
$out = [];
foreach ($users as $u) {
if (in_array($u['email'], $activeEmails, true)) $out = array_merge($out, [$u['id']]);
}
return $out;
}
정답
제곱 패턴이 둘입니다. 루프 안의 in_array, 그리고 찾을 때마다 $out 을 복사하는 array_merge.
function activeIds(array $users, array $activeEmails): array {
$active = array_flip($activeEmails); // once: values become keys
$out = [];
foreach ($users as $u) {
if (isset($active[$u['email']])) $out[] = $u['id'];
}
return $out;
}
확인할 차이 하나. in_array(..., true) 는 엄격 비교지만 배열 키는 "123" 같은 숫자 문자열을 정수로 바꿉니다. 이메일 주소에서는 일어날 수 없는 일입니다. 같은 시드(mt_srand(42))로 만든 입력에 두 버전을 돌리고 === 로 비교하면 증명이 됩니다. 우리 실행 결과는 slow 1212.8 ms, fast 2.49 ms, 4625 ids, identical: true, 약 490 배였습니다.
PHP 강좌는 여기까지입니다. 같은 코드의 보안 쪽은 5 강에서 다뤘습니다.
이전: 5 강: XSS·CSRF·세션 · 처음으로: 1 강: 타입·문자열·배열
관련 글: Python 6 강: 프로파일링과 최적화 · Java 6 강: JIT·GC·메모리 · prepared statement 가 JSON 숫자 타입을 바꾼 이유
자주 묻는 질문
PHP CLI 에서 OPcache 를 켜면 스크립트가 빨라지나요?
한 번 실행하고 끝나는 스크립트라면 아닙니다. 클래스 2,000 개를 읽는 스크립트가 opcache.enable_cli=1 에서 63.9~64.7ms 대신 74.5~75.7ms 걸렸습니다. 프로세스마다 캐시를 만들고 버리기 때문입니다. opcache.file_cache 를 더하면 58.2ms 였습니다. 하나의 프로세스가 계속 도는 내장 서버에서는 요청 1 회가 6.79ms 에서 0.18ms 로 줄었습니다.
PHP 8.5 에서 JIT 를 켜면 얼마나 빨라지나요?
작업에 따라 다릅니다. CPU 만 쓰는 망델브로 계산은 334.4ms(OPcache, JIT 끔)에서 opcache.jit=tracing 으로 75.0ms, 약 4.5 배였습니다. json_encode·usort·explode·str_replace 위주의 작업은 261.2ms 에서 240.2ms, 약 8% 였습니다. 시간이 C 로 짠 내장 함수 안에서 쓰이기 때문입니다.
$s = $s . $x 가 $s .= $x 보다 느린가요?
루프 안이라면 극단적으로 느립니다. 조각 20 만 개로 2.3MB 문자열을 만들 때 .= 는 8.96ms, $s = $s . $x 는 7,961ms 였습니다(PHP 8.5). 뒤쪽은 매번 문자열 전체를 복사합니다. JIT 로도 고쳐지지 않았습니다.