본문 바로가기
PHP

[PHP] 메모리 관리와 가비지 컬렉션 - unset, 순환 참조, gc_collect_cycles 등을 통한 메모리 누수 방지.

by teamnova 2025. 11. 30.
728x90

PHP는 기본적으로 참조 횟수 계산 방식을 사용하여 메모리를 관리합니다.

이 변수를 보고 있는 사람이 0명이면 메모리에서 지운다 라는 개념이며

치명적인 약점이 있는데, 바로 서로가 서로를 참조할 때(순환 참조)'입니다.

 

1. 참조 카운팅
변수를 unset() 하면 참조 카운트가 줄어들고, 0이 되면 즉시 메모리가 해제됩니다.

<?php
echo "시작 메모리: " . memory_get_usage() . " bytes\n";

$data = range(1, 100000); // 큰 배열 생성 (메모리 증가)
echo "배열 생성 후: " . memory_get_usage() . " bytes\n";

unset($data); // 변수 제거 -> 참조 카운트 0 -> 메모리 즉시 해제
echo "unset 후: " . memory_get_usage() . " bytes\n";

?>

 

2. 문제상황: 순환참조

두 객체가 서로를 속성으로 가지고 있으면, unset을 해도 참조 카운트가 0이 되지 않습니다.

<?php
class Family {
    public $member;
    public string $name;
   
    public function __construct($name) {
        $this->name = $name;
    }
}

echo "1. 시작: " . memory_get_usage() . "\n";

// 1. 객체 두 개 생성
$father = new Family('아빠');
$child = new Family('아이');

// 2. 순환 참조 생성 (서로를 가리킴)
$father->member = $child;
$child->member = $father;

// 3. 변수 해제 시도
unset($father);
unset($child);

// 4. 결과 확인
echo "2. unset 후 (순환 참조): " . memory_get_usage() . "\n";

/**
 * [결과 분석]
 * unset을 했음에도 메모리가 '시작' 시점보다 높게 나옵니다.
 * 이유는 $father와 $child라는 이름표는 떼어졌지만,
 * 힙 메모리 안에서 두 객체가 서로의 손을 잡고 있어서
 * 참조 카운트가 1로 남아있기 때문입니다.
 * PHP의 일반적인 메모리 해제 로직으로는 이것을 지울 수 없습니다.
 */
?>

 

 

3. 해결: gc_collect_cycles()

주기적으로 메모리를 스캔하여 외부에서 접근할 수 없는데 자기들끼리만 참조하는 고립된 섬(Cycle)을 찾아내 강제로 지웁니다.

<?php
class Family {
    public $member;
    public string $name;
   
    public function __construct($name) {
        $this->name = $name;
    }
}

echo "1. 시작: " . memory_get_usage() . "\n";

// 1. 객체 두 개 생성
$father = new Family('아빠');
$child = new Family('아이');

// 2. 순환 참조 생성 (서로를 가리킴)
$father->member = $child;
$child->member = $father;

// 3. 변수 해제 시도
unset($father);
unset($child);

// 4. 결과 확인
echo "2. unset 후 (순환 참조): " . memory_get_usage() . "\n";

/**
 * [결과 분석]
 * unset을 했음에도 메모리가 '시작' 시점보다 높게 나옵니다.
 * 이유는 $father와 $child라는 이름표는 떼어졌지만,
 * 힙 메모리 안에서 두 객체가 서로의 손을 잡고 있어서
 * 참조 카운트가 1로 남아있기 때문입니다.
 * PHP의 일반적인 메모리 해제 로직으로는 이것을 지울 수 없습니다.
 */

// 강제로 가비지 컬렉터 실행 (순환 참조 수거)
$cleaned = gc_collect_cycles();

echo "수거된 사이클 수: " . $cleaned . "\n";
echo "3. GC 실행 후: " . memory_get_usage() . "\n";

/**
 * [결과]
 * 수거된 사이클 수: 2 (아빠 객체, 아이 객체)
 * 메모리가 다시 1번(시작) 수준으로 떨어집니다.
 */
?>

 

 

 

4. 실무 예

웹 요청 하나가 끝나면 PHP 프로세스가 종료되면서 OS가 메모리를 알아서 회수해가므로, 짧은 스크립트에서는 크게 신경 쓸 필요 없습니다. 하지만 데몬 스크립트(Daemon), 배치 작업(Batch Processing), 수만 건의 엑셀 파싱 등을 할 때는 메모리 관리가 필수입니다.

 

<?php
class Node {
    public $next;
}

// 가비지 컬렉션 활성화 (기본적으로 켜져 있지만 명시)
gc_enable();

echo "작업 시작...\n";

for ($i = 0; $i < 10000; $i++) {
    $a = new Node();
    $b = new Node();
   
    // 순환 참조 발생 시킴
    $a->next = $b;
    $b->next = $a;
   
    // 로직 처리 (예: DB 저장, 로그 기록 등)
    // ...
   
    // 사용 완료 후 연결 끊기 (가장 좋은 방법)
    // $a->next = null;
    // $b->next = null;
   
    // 변수 해제
    unset($a, $b);
   
    // 1000번 돌 때마다 한 번씩 청소부를 부름
    if ($i % 1000 == 0) {
        $cycles = gc_collect_cycles();
        echo "[{$i}번째] 메모리 정리됨 (제거된 객체 수: {$cycles}) - 현재 메모리: " . (memory_get_usage() / 1024) . "KB\n";
    }
}

echo "작업 완료.\n";
?>

 

 

 

정리하면 언제 사용해야할까?

1. unset($var):
언제: 더 이상 필요 없는 큰 배열이나 객체를 다 썼을 때.
효과: 참조 카운트를 줄여 메모리 확보. (단, 순환 참조는 해결 못 함)
2. 순환 참조 피하기 (Best Practice):
객체 관계 설계 시 상호 참조를 최소화합니다.
필요 없다면 unset 하기 전에 $obj->friend = null; 처럼 연결 고리를 끊어주는 것이 가장 성능에 좋습니다.
3. gc_collect_cycles():
언제: 데몬, 워커, 긴 반복문 내에서 순환 참조가 발생할 수밖에 없는 구조일 때.
주의: GC가 돌 때 CPU를 사용하므로 매 반복마다 호출하기보다는, 주기적(예: 1000번마다)으로 호출하는 것이 좋습니다.