서버에서 갑자기 PHP 스크립트가 죽어버렸고, 로그에 'Fatal error: Allowed memory size of X bytes exhausted'라는 메시지가 남아있다면 메모리 고갈 문제를 겪고 있는 것이다. 다만 대부분의 개발자들은 memory_limit을 무작정 늘리기만 하고, 정작 왜 메모리가 낭비되는지, 어디서부터 막아야 하는지 모른 채 응급처치를 반복한다. 이번에는 메모리 누수의 정확한 원인이 뭔지, 어떻게 진단하는지, 어떻게 근본적으로 해결하는지 완벽하게 정리해서 소개하겠다.
PHP는 기본적으로 메모리 사용량을 제한한다. php.ini의 memory_limit 설정으로 스크립트 하나가 사용할 수 있는 최대 메모리를 정하는데, 기본값은 보통 128MB다. 이 한계를 넘으면 PHP는 즉시 실행을 멈추고 Fatal error를 던진다.
메모리는 어디에 쓰일까. 변수 저장, 함수 호출 스택, 배열 요소, 객체 인스턴스, 데이터베이스 쿼리 결과 등이 모두 메모리를 먹는다. 문제는 코드가 실행되는 동안 이 메모리들이 정리되지 않고 계속 쌓여간다는 것이다. 특히 루프 안에서 대용량 데이터를 처리하거나, 캐시 배열이 무한정 커지거나, 데이터베이스에서 결과를 한 번에 다 읽어올 때 이런 일이 발생한다.
memory_limit을 그냥 늘리는 건 응급처치일 뿐 근본 해결이 아니다. 메모리 누수의 원인을 찾아서 코드 차원에서 막아야 한다.
먼저 현재 상황을 파악해야 한다. 어느 부분에서 메모리를 얼마나 쓰고 있는지 확인하는 3가지 방법을 소개한다.
가장 간단한 방법은 memory_get_usage() 함수를 사용하는 것이다. 코드 곳곳에 이 함수를 심어서 메모리 사용량을 추적할 수 있다.
<?php
// ✗ 잘못된 코드: 메모리 사용량을 모르고 진행
$large_array = range(1, 1000000);
foreach ($large_array as $item) {
// 대량의 작업 처리
process_data($item);
}
echo "처리 완료";
?>
<?php
// ✓ 올바른 코드: 메모리 사용량 모니터링
echo "시작: " . (memory_get_usage() / 1024 / 1024) . "MB\n";
$large_array = range(1, 1000000);
echo "배열 생성 후: " . (memory_get_usage() / 1024 / 1024) . "MB\n";
foreach ($large_array as $item) {
process_data($item);
}
echo "처리 완료: " . (memory_get_usage() / 1024 / 1024) . "MB\n";
echo "피크: " . (memory_get_peak_usage() / 1024 / 1024) . "MB\n";
?>
결과: 배열 생성만으로도 50MB를 먹는 걸 확인할 수 있다. memory_get_peak_usage()는 지금까지의 최대 메모리 사용량을 반환한다.
더 깊은 분석이 필요하면 xdebug 확장을 설치해서 메모리 누수 지점을 정확히 파악할 수 있다.
<?php
// php.ini에 다음 추가
// xdebug.mode = profile
// xdebug.output_dir = /tmp
// xdebug.profiler_enable = 1
// 코드 실행 후 생성된 cachegrind 파일을 분석
// 명령어: cachegrind_annotate /tmp/cachegrind.* | grep -E "(Mem|TOTAL)"
?>
긴급하게 memory_limit을 잠깐 늘려서 스크립트가 어디서 터지는지 확인할 수 있다.
<?php
// 스크립트 시작 부분에서만 효과 있음
ini_set('memory_limit', '256M');
// 나머지 코드...
?>
✗ 잘못된 코드: 100만 개 요소를 한 번에 메모리에 로드
<?php
$result = $pdo->query("SELECT * FROM large_table WHERE status = 'active'");
$all_records = $result->fetchAll(PDO::FETCH_ASSOC); // 메모리 폭탄
foreach ($all_records as $record) {
process_record($record);
}
?>
✓ 올바른 코드: Generator나 페이징으로 한 번에 조금씩 처리
<?php
// 방법 1: PDO 스트리밍 결과
$stmt = $pdo->query("SELECT * FROM large_table WHERE status = 'active'");
while ($record = $stmt->fetch(PDO::FETCH_ASSOC)) {
process_record($record);
}
// 방법 2: Generator 함수로 메모리 효율화
function stream_records($pdo) {
$offset = 0;
$limit = 1000;
while (true) {
$stmt = $pdo->prepare("SELECT * FROM large_table LIMIT ? OFFSET ?");
$stmt->execute([$limit, $offset]);
$records = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($records)) break;
foreach ($records as $record) {
yield $record;
}
$offset += $limit;
}
}
foreach (stream_records($pdo) as $record) {
process_record($record);
}
?>
Generator를 사용하면 백만 개 레코드도 상수 메모리로 처리할 수 있다.
✗ 잘못된 코드: 캐시에 데이터를 계속 추가만 함
<?php
class DataCache {
private $cache = [];
public function store($key, $value) {
$this->cache[$key] = $value; // 계속 쌓임
}
public function get($key) {
return $this->cache[$key] ?? null;
}
}
$cache = new DataCache();
for ($i = 0; $i < 1000000; $i++) {
$cache->store("key_$i", "huge_data_$i");
}
?>
✓ 올바른 코드: TTL(시간 제한) 또는 크기 제한 추가
<?php
class DataCache {
private $cache = [];
private $max_size = 10000;
private $ttl = 3600; // 1시간
public function store($key, $value) {
// 캐시 크기 제한
if (count($this->cache) >= $this->max_size) {
// 가장 오래된 항목 제거 (FIFO)
array_shift($this->cache);
}
$this->cache[$key] = [
'value' => $value,
'expire' => time() + $this->ttl
];
}
public function get($key) {
if (!isset($this->cache[$key])) {
return null;
}
if ($this->cache[$key]['expire'] < time()) {
unset($this->cache[$key]);
return null;
}
return $this->cache[$key]['value'];
}
}
?>
✗ 잘못된 코드: 객체가 서로를 참조해서 가비지 컬렉션이 안 됨
<?php
class Node {
public $parent;
public $children = [];
}
$parent = new Node();
$child = new Node();
$child->parent = $parent; // 자식이 부모를 참조
$parent->children[] = $child; // 부모가 자식을 참조
unset($parent);
unset($child);
// 순환 참조 때문에 메모리가 해제되지 않음
?>
✓ 올바른 코드: 명시적으로 참조 끊기 또는 WeakReference 사용
<?php
// 방법 1: 명시적으로 참조 제거
class Node {
public $parent;
public $children = [];
public function cleanup() {
$this->parent = null;
foreach ($this->children as $child) {
$child->cleanup();
}
$this->children = [];
}
}
$parent = new Node();
$child = new Node();
$child->parent = $parent;
$parent->children[] = $child;
$parent->cleanup(); // 메모리 해제
unset($parent);
unset($child);
// 방법 2: WeakReference 사용 (PHP 7.4+)
use WeakReference;
class Node {
private $parent;
public function setParent($parent) {
$this->parent = WeakReference::create($parent);
}
public function getParent() {
return $this->parent->get();
}
}
?>
✗ 잘못된 코드: 큰 변수를 클로저로 캡처해서 메모리 고정
<?php
function process_files() {
$large_buffer = str_repeat('x', 50 * 1024 * 1024); // 50MB
$callback = function($file) use ($large_buffer) {
// $large_buffer 전체가 클로저에 캡처됨
echo "처리: $file\n";
};
register_shutdown_function($callback); // 프로세스 종료까지 메모리 유지
}
?>
✓ 올바른 코드: 꼭 필요한 부분만 캡처
<?php
function process_files() {
$large_buffer = str_repeat('x', 50 * 1024 * 1024);
$size = strlen($large_buffer);
unset($large_buffer); // 사용 후 즉시 제거
// 필요한 정보만 캡처
$callback = function($file) use ($size) {
echo "처리: $file (크기: $size)\n";
};
register_shutdown_function($callback);
}
?>
✗ 잘못된 코드: 데이터베이스 연결을 닫지 않음
<?php
for ($i = 0; $i < 100; $i++) {
$pdo = new PDO('mysql:host=localhost;dbname=mydb', 'user', 'pass');
// 처리...
// 연결을 닫지 않음
}
// 메모리에 100개의 DB 연결이 남음
?>
✓ 올바른 코드: 명시적으로 연결 종료
<?php
for ($i = 0; $i < 100; $i++) {
$pdo = new PDO('mysql:host=localhost;dbname=mydb', 'user', 'pass');
try {
// 처리...
} finally {
$pdo = null; // 연결 해제
}
}
?>
근본 원인을 해결하면서 동시에, 응급 상황에 대비해 memory_limit을 상황에 맞게 설정해야 한다. 다만 무작정 늘리는 것은 위험하다. 실행 시간이 긴 스크립트와 짧은 스크립트에 다른 값을 줄 필요가 있다.
<?php
// 웹 요청 핸들러: 보통 128MB로 충분
ini_set('memory_limit', '128M');
ini_set('max_execution_time', '30');
// CLI 스크립트: 데이터 처리가 많으면 256MB
if (php_sapi_name() === 'cli') {
ini_set('memory_limit', '256M');
ini_set('max_execution_time', '0'); // 제한 없음
}
// 대용량 파일 처리: 512MB까지 허용
if (strpos($_SERVER['REQUEST_URI'], '/import') !== false) {
ini_set('memory_limit', '512M');
}
echo "현재 메모리 제한: " . ini_get('memory_limit') . "\n";
?>
| 항목 | 확인사항 | 해결책 |
|---|---|---|
| 대용량 쿼리 | fetchAll()로 모든 결과를 한 번에 로드하는가 | fetch() 또는 Generator로 변경 |
| 캐시 배열 | 크기나 TTL 제한이 있는가 | Redis 사용 또는 크기 제한 추가 |
| 순환 참조 | 객체가 서로를 참조하는가 | cleanup() 메서드 구현 또는 WeakReference 사용 |
| 클로저 캡처 | 큰 변수를 use()로 캡처하는가 | 필요한 부분만 캡처, 사용 후 unset() |
| 리소스 해제 | DB 연결, 파일 핸들, 스트림을 닫는가 | finally 블록에서 명시적 해제 |
프로덕션 환경에서 메모리 누수를 자동으로 감지하는 미들웨어를 만들면 문제를 조기에 발견할 수 있다.
<?php
class MemoryMonitor {
private static $threshold = 100 * 1024 * 1024; // 100MB
public static function check() {
$usage = memory_get_usage(true);
$peak = memory_get_peak_usage(true);
$limit = (int) str_replace('M', '', ini_get('memory_limit')) * 1024 * 1024;
if ($peak > self::$threshold) {
error_log(sprintf(
"메모리 경고: 현재 %sMB, 피크 %sMB, 제한 %sMB",
$usage / 1024 / 1024,
$peak / 1024 / 1024,
$limit / 1024 / 1024
));
}
if ($usage > $limit * 0.9) {
throw new Exception("메모리 거의 가득 참");
}
}
}
// 요청 종료 시 자동 확인
register_shutdown_function([MemoryMonitor::class, 'check']);
?>
PHP 메모리 관리는 코드 수준의 효율성과 런타임 설정의 균형을 맞추는 일이다. memory_limit을 늘리는 것은 증상 완화일 뿐이고, fetchAll() 대신 fetch() 쓰기, Generator로 스트리밍하기, 캐시 크기 제한 같은 작은 최적화가 모여서 안정적인 서버를 만든다. 이 글의 5가지 누수 원인과 체크리스트를 참고해서, 지금 바로 자신의 코드를 점검해보자. 한두 곳만 수정해도 메모리 사용량이 급격히 줄어드는 경험을 할 수 있을 것이다.