dev.to

Laravel Is Not As Heavy As You Think

Laravel은 생각만큼 무겁지 않습니다

Laravel 13의 요청 메모리를 PHP-FPM에서 측정한 결과, OPcache를 켠 기본 경로의 피크는 0.66MB였고 프레임워크 부팅분은 316KB였습니다. 반면 Eloquent 모델 1만 건을 한꺼번에 읽으면 약 18MB가 들었습니다. 메모리를 줄이는 데는 프레임워크 설정 조정보다 조회 결과를 나눠 읽는 일이 훨씬 큰 효과를 냅니다.

AI 요약

Laravel이 메모리를 많이 쓴다는 통념을 확인하기 위해 작성자는 Laravel 13의 실제 요청을 PHP-FPM에서 측정했습니다. 환경은 PHP 8.4.22, Laravel 13.31.0, MySQL 8.4.11이며, 요청은 단일 워커를 둔 PHP-FPM을 거칩니다. 모든 측정은 워커가 준비되고 OPcache가 코드를 컴파일한 뒤 들어온 세 번째 요청을 기준으로 합니다. 글에서 MB는 1,048,576바이트를 뜻합니다.

PHP 메모리 수치 읽기

memory_get_usage()는 PHP 할당자가 현재 스크립트에 내준 메모리를 보여줍니다. memory_get_usage(true)는 할당자가 운영체제에서 받아 둔 메모리를 계산합니다. 프로세스의 실제 상주 메모리인 RSS는 또 다른 수치입니다. Zend Memory Manager는 운영체제에서 2MiB 단위로 메모리를 받아 4KiB 페이지를 PHP에 나눠 줍니다. 따라서 ‘사용 중인 메모리’, ‘할당자가 보유한 메모리’, ‘프로세스 RSS’를 구분해야 합니다.

PHP-FPM 워커는 요청이 끝나도 할당받은 청크를 바로 운영체제에 돌려주지 않습니다. Zend 메모리 관리자는 최근 요청의 사용량을 바탕으로 청크를 캐시에 남겨 다음 요청에 대비합니다. 무거운 요청을 처리한 워커는 이후 가벼운 요청에서도 memory_get_usage(true)와 RSS가 높게 보일 수 있습니다. 이는 곧바로 메모리 누수를 뜻하지 않습니다. 장기 실행 워커에서 memory_get_peak_usage(true)를 요청 비용으로 기록하면 앞선 요청의 영향까지 섞입니다. 요청별 피크를 보려면 일반 memory_get_peak_usage()를 쓰고, PHP 8.2 이상에서는 memory_reset_peak_usage()로 피크를 초기화할 수 있습니다.

OPcache가 바꾸는 요청 비용

새 Laravel 앱의 기본 상태처럼 OPcache와 캐시를 끄면 hello-world 경로의 피크 메모리는 약 17.8MB, 컨트롤러 진입 시점은 약 15.8MB였습니다. OPcache와 Laravel 최적화 캐시를 켜면 피크는 691KB, 컨트롤러 진입 시점은 663KB로 내려갑니다. 글에서 프레임워크 부팅에 해당하는 메모리는 설정에 따라 14.7MB와 316KB로, 약 47배 차이가 납니다. 두 경우 모두 비슷한 수의 파일과 서비스 프로바이더를 사용합니다.

OPcache를 끄면 요청마다 파일을 읽고 토큰화한 뒤 컴파일한 코드를 요청별 힙에 둡니다. 켜면 컴파일 결과가 공유 메모리 세그먼트에 저장되고 여러 FPM 워커가 함께 사용합니다. 따라서 요청별 PHP 메모리에서 컴파일 코드 비용이 사라집니다. 다만 워커 RSS는 OPcache를 켰을 때 27.7MB에서 30.7MB로 오히려 늘었습니다. 이 설정의 이점은 프로세스 자체를 작게 만드는 데 있지 않습니다. 요청마다 프레임워크 코드를 다시 컴파일해 15MB가량 쌓는 일을 막고, 측정한 응답 시간을 28.3ms에서 0.8ms로 줄입니다.

php artisan optimize는 OPcache가 꺼진 환경에서 요청당 약 0.7MB, 켜진 환경에서 약 143KB를 줄였습니다. 설정·라우트 캐시는 여러 파일을 읽는 대신 캐시 파일을 읽지만, 결과로 만들어지는 배열은 대체로 비슷합니다. Composer 최적화 클래스 맵은 PSR-4 규칙을 6,861개 클래스와 파일의 배열로 만듭니다. OPcache가 꺼졌을 때 이 배열을 요청마다 만들면서 메모리가 약 1.2MB 늘었습니다. OPcache가 켜지면 배열이 공유 메모리에 저장돼 요청별 비용은 나타나지 않았습니다. 두 최적화는 메모리보다 파일 탐색과 디스크 작업, 실행 시간을 줄이는 설정입니다.

서비스 프로바이더 지연 로딩

일반 프로바이더가 싱글턴 세 개만 등록할 때 요청당 비용은 OPcache를 끄면 8,168바이트, 켜면 2,456바이트였습니다. 이를 지연 로딩하면 각각 384바이트와 측정 가능한 추가 비용 없음으로 줄었습니다. 하지만 프로바이더 수십 개를 지연 로딩해도 절약량은 수백 KB 수준입니다.

차이는 등록 과정에서 실제 작업을 하느냐에 따라 커집니다. 예를 들어 5,000개 항목을 담은 527KB PHP 파일을 읽어 배열을 만드는 무거운 프로바이더는 OPcache가 꺼진 상태에서 요청당 약 3.4MB를 썼습니다. 서비스가 요청될 때 배열을 만들도록 지연하면 비용이 거의 사라집니다. OPcache를 켠 상태에서는 상수 배열이 공유 메모리에 저장돼 즉시 로드하더라도 요청 힙 비용은 184바이트였습니다. 네트워크 요청이나 JSON 파싱처럼 매번 계산하는 작업은 OPcache만으로 없어지지 않으므로 지연 로딩 대상입니다.

메모리를 좌우하는 데이터 조회

OPcache와 캐시를 켠 상태에서 1만 행을 Eloquent의 get()으로 읽으면 피크가 18.44MB, 실행 시간은 40ms였습니다. Query Builder의 일반 객체로 읽으면 14.15MB와 9ms가 들었습니다. Eloquent 모델은 행마다 약 1,799바이트, 일반 객체는 약 1,172바이트를 썼습니다. 행 데이터와 객체 자체가 비용의 약 3분의 2이며, 나머지는 Eloquent의 속성 배열과 변경 감지용 원본 복사본, 모델 객체에서 나옵니다. 1만 행 조회 하나가 프레임워크 부팅 전체보다 메모리를 55배 많이 씁니다.

조회 방법별 피크는 chunk(1000)가 2.58MB, lazy(1000)가 4.25MB, cursor()가 3.17MB였습니다. chunk()는 페이지를 처리한 뒤 결과를 해제하므로 10만 행에서도 약 2.65MB에 머뭅니다. 대신 10만 행이면 쿼리 100번을 실행합니다. lazy()는 순회하기 편한 반복자를 제공하며 10만 행에서도 약 4.32MB였습니다. 두 방식 모두 한꺼번에 모든 모델을 쌓지 않습니다.

MySQL의 기본 PDO 드라이버는 조회 결과를 버퍼링합니다. cursor()는 모델을 한 번에 하나씩 만들지만, 원시 행 전체가 드라이버 메모리에 먼저 쌓일 수 있습니다. 그래서 10만 행에서 피크가 24.28MB까지 늘었습니다. PDO::MYSQL_ATTR_USE_BUFFERED_QUERY를 끄면 같은 cursor() 조회가 약 921KB에서 끝납니다. 단, 결과를 다 읽을 때까지 연결을 다른 쿼리에 쓸 수 없습니다. 조회 중 같은 연결로 DB 작업을 해야 한다면 lazy()가 더 적합합니다. SQLite는 행을 요청할 때마다 읽으므로 10만 행 cursor() 피크가 약 847KB였습니다. 버퍼에 따른 차이는 PHP 전체의 특성이 아니라 드라이버 동작에 달렸습니다.

운영 환경에서의 측정과 적용

Eloquent로 10만 행을 한꺼번에 읽으면 피크가 약 175MB, 워커 RSS는 208MB였습니다. 기본 memory_limit인 128M에서는 쿼리가 실패하고, 예외 처리기 로딩마저 메모리 부족으로 실패해 응답 본문이나 Laravel 로그에 오류가 남지 않을 수 있습니다. 운영 중 경로별 피크를 미들웨어로 기록하고, 상위 경로의 무제한 get()부터 페이지네이션이나 lazy()로 바꾸라고 작성자는 권합니다. 순수 읽기·스트리밍 작업에는 버퍼를 끈 cursor()도 선택지입니다.

FPM 워커 RSS는 hello 경로 처리 중 30.7MB, 1만 모델 처리 중 52.1MB, 무거운 요청이 끝난 뒤 36.3MB였습니다. OPcache 공유 메모리 페이지와 할당자 캐시가 RSS에 반영되기 때문입니다. 따라서 pm.max_children을 memory_limit으로 나눠 정하기보다, 실제 워커 RSS와 가장 무거운 경로를 측정해 계산해야 합니다. 예시 환경에서 PHP에 2GB를 배정하고 워커당 52MB를 기준으로 잡으면 약 39개가 됩니다. 10만 행 경로처럼 워커당 208MB가 필요한 경우에는 워커 수를 줄이는 데 앞서 조회 방식을 고쳐야 합니다.

Octane은 애플리케이션을 워커마다 한 번 부팅하므로 프레임워크 부팅 비용이 요청마다 반복되지 않습니다. 대신 워커가 계속 살아 있어 참조나 정적 배열이 쌓이면 메모리도 계속 늘 수 있습니다. 글은 워커 재활용과 memory_reset_peak_usage()를 함께 언급합니다. 측정의 요지는 프레임워크 부팅보다 요청이 실제로 읽어 들이는 데이터가 메모리 사용량을 훨씬 크게 좌우한다는 점입니다.

원문: dev.to / 번역·요약: Trawling