메모리 누수에 대하여

웹맨은 상주 메모리 프레임워크이므로 메모리 누수에 대해 어느 정도 주의할 필요가 있습니다. 그러나 개발자는 지나치게 걱정할 필요가 없습니다. 메모리 누수는 매우 극단적인 조건에서만 발생하며, 쉽게 피할 수 있습니다. 웹맨 개발은 기존 프레임워크 개발과 거의 동일하여 메모리 관리에 대한 추가 작업이 필요하지 않습니다.


웹맨에 내장된 모니터 프로세스가 모든 프로세스의 메모리 사용량을 모니터링합니다. 프로세스의 메모리 사용량이 php.ini의 memory_limit에 설정된 값에 도달하면 해당 프로세스를 자동으로 안전하게 재시작하여 메모리를 해제합니다. 이 과정에서 비즈니스에는 영향이 없습니다.

메모리 누수의 정의

요청이 증가함에 따라 웹맨의 메모리 사용량이 늘어나는 것은 정상입니다. 일반적으로 프로세스가 일정한 요청량(보통 수백만 단위)에 도달하면 메모리 사용량은 더 이상 증가하지 않거나 가끔 소폭 증가합니다.

대부분의 비즈니스에서 단일 프로세스의 메모리 사용량은 최종적으로 10M~100M 정도에서 안정됩니다. 단일 프로세스 메모리가 100M를 넘지 않으면 걱정할 필요가 없습니다.

또한 대용량 파일 처리, 대용량 요청 처리, 데이터베이스에서 대량의 데이터를 읽는 등의 작업 시 PHP는 상당한 메모리를 할당합니다. PHP는 사용한 메모리 일부를 재사용하기 위해 보관하고, 전부를 운영체제에 반환하지 않을 수 있습니다. 이로 인해 메모리 사용량이 높아지는 현상이 발생할 수 있지만, 메모리가 재사용되므로 걱정하지 않으셔도 됩니다.


phar 패키지나 바이너리 패키지 프로젝트의 경우, 패키지 크기가 크면 패키지된 프로젝트의 메모리 사용량이 100M를 초과하는 것도 정상입니다.

메모리 누수 확인 방법

프로세스가 수백만 건 이상의 요청을 처리했고, 메모리가 100M를 초과하며, 매 요청 후에도 메모리가 계속 증가한다면 메모리 누수가 발생했을 수 있습니다.

메모리 누수 찾는 방법

간단한 방법은 각 API에 부하 테스트를 수행하여 수백만 건의 요청 후에도 메모리가 계속 증가하는 API를 찾는 것입니다.

문제 API를 발견하면 이분 탐색법을 사용하여 비즈니스 코드의 절반을 매번 주석 처리하고, 문제가 되는 코드를 특정할 때까지 반복합니다.

메모리 누수가 발생하는 원인

메모리 누수는 다음 두 조건을 동시에 만족할 때 발생합니다:

  1. 긴 생명 주기의 배열이 존재해야 합니다 (일반 배열은 문제없음)
  2. 긴 생명 주기의 배열이 무한히 확장되어야 합니다 (비즈니스가 끊임없이 데이터를 삽입하고 정리하지 않음)

1번과 2번 조건이 동시에 충족되면 메모리 누수가 발생합니다. 위 조건을 충족하지 않거나 조건 중 하나만 충족되면 메모리 누수가 아닙니다.

긴 생명 주기의 배열

웹맨에서 긴 생명 주기의 배열은 다음과 같습니다:

  1. static 키워드로 정의된 배열
  2. 싱글톤의 배열 속성
  3. global 키워드로 정의된 배열

참고
웹맨에서는 긴 생명 주기 데이터 사용이 허용되지만, 데이터 내 요소가 유한하고 요소 수가 무한히 확장되지 않도록 해야 합니다.

다음은 각각의 예시입니다.

무한히 확장되는 static 배열

class Foo
{
    public static $data = [];
    public function index(Request $request)
    {
        self::$data[] = time();
        return response('hello');
    }
}

static 키워드로 정의된 $data 배열은 긴 생명 주기의 배열입니다. 예시에서는 요청마다 $data 배열이 계속 확장되어 메모리 누수를 유발합니다.

무한히 확장되는 싱글톤 배열 속성

class Cache
{
    protected static $instance;
    public $data = [];

    public function instance()
    {
        if (!self::$instance) {
            self::$instance = new self;
        }
        return self::$instance;
    }

    public function set($key, $value)
    {
        $this->data[$key] = $value;
    }
}

호출 코드

class Foo
{
    public function index(Request $request)
    {
        Cache::instance()->set(time(), time());
        return response('hello');
    }
}

Cache::instance()는 Cache 싱글톤을 반환하며, 이는 긴 생명 주기의 클래스 인스턴스입니다. $data 속성은 static 키워드를 사용하지 않지만, 클래스 자체가 긴 생명 주기를 가지므로 $data도 긴 생명 주기의 배열입니다. $data 배열에 서로 다른 키의 데이터를 계속 추가하면 프로그램의 메모리 사용량이 늘어나 메모리 누수가 발생합니다.

참고
Cache::instance()->set(key, value)로 추가하는 키가 유한한 수라면 메모리 누수가 발생하지 않습니다. $data 배열이 무한히 확장되지 않기 때문입니다.

무한히 확장되는 global 배열

class Index
{
    public function index(Request $request)
    {
        global $data;
        $data[] = time();
        return response($foo->sayHello());
    }
}

global 키워드로 정의된 배열은 함수나 클래스 메서드 실행이 끝난 후에도 회수되지 않으므로 긴 생명 주기의 배열입니다. 위 코드는 요청이 계속 증가하면 메모리 누수를 유발합니다. 마찬가지로 함수나 메서드 내에서 static 키워드로 정의된 배열도 긴 생명 주기의 배열이며, 배열이 무한히 확장되면 메모리 누수가 발생합니다. 예를 들어:

class Index
{
    public function index(Request $request)
    {
        static $data = [];
        $data[] = time();
        return response($foo->sayHello());
    }
}

권장사항

개발자들이 메모리 누수에 특별히 주의할 필요는 없습니다. 메모리 누수는 매우 드물게 발생하며, 발생하더라도 부하 테스트를 통해 누수를 유발하는 코드를 찾아 문제를 파악할 수 있습니다. 개발자가 누수 지점을 찾지 못하더라도 웹맨에 내장된 모니터 서비스가 메모리 누수가 발생한 프로세스를 적절한 시점에 안전하게 재시작하여 메모리를 해제합니다.

가능한 한 메모리 누수를 피하고 싶다면 다음 권장사항을 참고하세요.

  1. global, static 키워드 배열을 가급적 사용하지 말고, 사용할 경우 무한히 확장되지 않도록 보장하세요.
  2. 익숙하지 않은 클래스의 경우 싱글톤 대신 new 키워드로 초기화하세요. 싱글톤이 필요하다면 무한히 확장되는 배열 속성이 있는지 확인하세요.