I Added One Key to a PHP Array. It Cost 25 MB of Memory
PHP 배열에 키 하나를 추가했더니 메모리 25MB — packed 배열이 해시 테이블로 바뀌는 순간
PHP 배열이 정수 순서대로 채워진 packed list로 유지되는 동안에는 원소당 16바이트로 매우 효율적이지만, 문자열 키 하나만 추가해도 해시 테이블로 전환되며 백만 개 원소 기준 25MB가 추가로 소모됩니다. 저자는 PHP 8.4 기준으로 zend_array 내부 구조를 뜯어보며 전환 조건과 비용을 계측했고, 연관 배열 대신 타입이 선언된 클래스로 행을 담으면 메모리가 거의 절반이 된다는 결론을 제시합니다.
- 주제
본문
레거시 PHP 코드를 리팩터링하던 저자는 같은 데이터 배열이 전혀 다른 방식으로 쓰이는 것을 보고 PHP 배열의 메모리 구조를 직접 계측했습니다. PHP 8.4.21의 공식 php:8.4-cli Docker 이미지에서 memory_get_usage()의 델타로 측정했고, 8.2에서 내부 구현이 크게 바뀌었기 때문에 8.1.34도 함께 돌려 전후를 비교했습니다.
packed list와 해시 테이블의 격차
0부터 백만까지 순서대로 채운 배열은 16,781,392바이트, 원소당 16.78바이트였습니다. 그런데 같은 키와 값을 역순으로 채우면 41,943,120바이트로 2.5배가 됩니다. 순서대로 채워진 배열은 엔진이 packed로 인식해 키를 저장하지 않고 원소당 16바이트 zval 하나만 쓰지만, 순서가 깨지면 키·해시·인덱스를 갖춘 진짜 해시 테이블이 되어 슬롯당 약 40바이트가 듭니다. packed 백만 개 배열에 $a['x'] = 0 하나를 추가하자 25,161,728바이트가 추가됐고, 음수 키 -1도 같은 결과였습니다. 더 놀라운 것은 unset으로 그 키를 지워도 메모리가 돌아오지 않는다는 점입니다. unset은 값만 해제하고 레이아웃은 그대로 두며, sort()나 array_values()만이 packed로 되돌립니다.
zend_array 내부 구조
모든 PHP 배열은 64비트에서 56바이트인 zend_array이며, 데이터를 가리키는 포인터가 union { uint32_t *arHash; Bucket *arData; zval *arPacked; }로 선언되어 있습니다. 해시 모드에서는 버킷당 8바이트의 uint32 인덱스와 32바이트 Bucket(zval 16바이트 + 정수 키 또는 문자열 해시 8바이트 + zend_string* 키 8바이트)으로 슬롯당 40바이트가 되고, packed 모드에서는 인덱스 없이 16바이트 zval만 나열됩니다. 이 packed 최적화는 Dmitry Stogov의 PR #7491로 PHP 8.2에서 도입된 것으로, 8.1에서는 packed도 32바이트 버킷을 써서 백만 개 리스트가 33.56바이트/원소였습니다. Tideways도 8.2 출시 당시 10만 개 리스트에서 4.3MB에서 2.3MB로 줄었다고 측정한 바 있습니다.
전환 규칙과 함정
테이블 크기는 2의 거듭제곱으로 커지며 가득 차면 두 배로 늘어납니다. 1,048,576개까지는 원소당 16바이트인데 하나만 더 넣어도 32바이트가 되고, 백만 개 중 999,000개를 unset해도 테이블은 줄어들지 않습니다. 배열은 역대 최대 크기를 기억하며, 이는 큐 워커에서 메모리가 계속 증가하는 이유 중 하나입니다. _zend_hash_index_add_or_update_i의 전환 로직에 따르면, 구멍 난 슬롯에 쓰거나 삽입 순서 보장이 깨지는 모든 경우와 문자열 키·음수 키는 해시로 전환됩니다. 소스의 주석 /* we have to keep the order :( */처럼 foreach가 삽입 순서를 보장해야 하기 때문입니다. 실제로 문제가 되는 것은 array_filter()입니다. 키를 보존하기 때문에 짝수만 걸러낸 결과는 다섯 번째 원소에서 해시로 전환되어, 절반 크기인데도 원본 packed 백만 개보다 메모리를 더 씁니다. array_values()를 한 번 호출해 복사 비용을 치르면 다시 16.79바이트/원소로 돌아갑니다. array_is_list()는 레이아웃이 아니라 키만 검사하므로 해시 배열인데 true를 반환할 수 있고, PHP 8.4부터 debug_zval_dump()가 packed 여부를 출력합니다. 또한 $b = $a는 refcount만 올리고 실제 쓰기 시점에 테이블을 복사하는 copy-on-write가 동작합니다.
행을 담는 컨테이너 비교
백만 개의 행을 다섯 필드로 만들어 비교한 결과, 선언된 타입 프로퍼티를 가진 클래스는 행당 233바이트(프로세스를 분리해 재측정하면 241바이트), packed list(FETCH_NUM 형태)는 321바이트, SplFixedArray(5)는 323바이트, 연관 배열(FETCH_ASSOC 형태)은 481바이트, stdClass(FETCH_OBJ 형태)는 529바이트였습니다. 연관 배열 한 행은 56바이트 헤더와 최소 8버킷 해시 블록을 합쳐 376바이트이고, 객체는 zend_object 헤더 40바이트에 프로퍼티가 인라인 zval로 붙어 128바이트에 불과합니다. 프로퍼티 이름은 클래스의 오프셋 테이블에 한 번만 저장되기 때문입니다. readonly 여부는 바이트에 영향이 없으므로 정확성을 위해 쓰는 readonly DTO가 가장 저렴한 행 컨테이너이기도 합니다. stdClass는 객체 헤더와 자체 프로퍼티 해시 테이블을 둘 다 갖는 최악의 조합입니다. SplFixedArray는 백만 정수에 정확히 16.00바이트/원소로 packed의 16.78보다 5% 적지만, 행에는 클래스보다 비쌉니다. ext-ds 1.6.0의 Ds\Vector는 16.09바이트/원소, Ds\Map은 해시 배열보다 약 10% 효율적이나 행마다 Map을 쓰면 오히려 512바이트로 나빠집니다.
실천 지침
리스트는 packed로 유지하는 것이 핵심입니다. 순서대로 append하고, 역순 인덱스 채우기를 피하고, 리스트에 문자열 키를 넣지 말며, array_filter() 결과가 오래 살아남는다면 array_values()로 재배치하는 것이 좋습니다. 메모리에 담는 행은 promoted typed property를 가진 작은 final 클래스로 바꾸면 연관 배열의 절반 메모리에 필드 이름과 타입까지 얻습니다. 가장 큰 절약은 백만 개를 들고 있지 않는 것입니다. generator로 스트리밍하면 같은 루프의 피크가 241MB에서 37KB로 떨어집니다. 다만 MySQL은 기본이 버퍼드 쿼리라 결과 전체를 PHP 메모리에 담으므로, 큰 쿼리에는 Pdo\Mysql::ATTR_USE_BUFFERED_QUERY를 false로 끄거나 기본 키로 페이징해야 하며, Laravel의 cursor()가 이 패턴입니다.
원문: dev.to / 번역·요약: Trawling