소개
일정 시간이 지나면 웹 게임/웹 앱의 성능이 저하된다는 이메일을 받습니다. 코드를 살펴보지만 눈에 띄는 문제가 없습니다. 그러다 Chrome의 메모리 성능 도구를 열어 다음과 같은 결과를 확인합니다.
동료 중 한 명이 메모리 관련 성능 문제가 있음을 알고 웃음을 터뜨립니다.
메모리 그래프 뷰에서 이 톱니 패턴은 잠재적으로 심각한 성능 문제를 나타냅니다. 메모리 사용량이 증가하면 타임라인 캡처에서 차트 영역도 증가합니다. 차트가 갑자기 감소하는 경우는 가비지 컬렉터가 실행되고 참조된 메모리 객체를 정리한 인스턴스입니다.
이와 같은 그래프에서 가비지 컬렉션 이벤트가 많이 발생하고 있음을 확인할 수 있습니다. 이는 웹 앱의 성능에 해로울 수 있습니다. 이 도움말에서는 메모리 사용량을 제어하여 성능에 미치는 영향을 줄이는 방법을 설명합니다.
가비지 컬렉션 및 성능 비용
JavaScript의 메모리 모델은 가비지 컬렉터라는 기술을 기반으로 합니다. 많은 언어에서 프로그래머는 시스템의 메모리 힙에서 메모리를 할당하고 해제하는 작업을 직접 담당합니다. 그러나 가비지 컬렉터 시스템은 프로그래머를 대신하여 이 작업을 관리합니다. 즉, 프로그래머가 참조 해제할 때 객체가 메모리에서 직접 해제되지 않고 나중에 GC의 휴리스틱이 그렇게 하는 것이 유리하다고 판단하는 시점에 해제됩니다. 이 결정 프로세스에는 GC가 활성 객체와 비활성 객체에 대해 일부 통계 분석을 실행해야 하며, 이 작업을 실행하는 데 시간이 걸립니다.
가비지 컬렉션은 프로그래머가 할당 해제하고 메모리 시스템에 반환할 객체를 지정해야 하는 수동 메모리 관리의 반대라고 종종 묘사됩니다.
GC가 메모리를 회수하는 프로세스는 무료가 아니며, 일반적으로 작업을 수행하는 데 시간 블록을 취하여 가용한 성능을 저하시킵니다. 이와 함께 시스템 자체에서 실행 시기를 결정합니다. 이 작업을 제어할 수 없습니다. GC 펄스는 코드 실행 중에 언제든지 발생할 수 있으며 완료될 때까지 코드 실행이 차단됩니다. 이 펄스의 지속 시간은 일반적으로 알 수 없습니다. 프로그램이 특정 시점에서 메모리를 활용하는 방식에 따라 실행하는 데 다소 시간이 걸립니다.
고성능 애플리케이션은 일관된 성능 경계를 사용하여 사용자에게 원활한 환경을 제공합니다. 가비지 컬렉터 시스템은 임의의 시간에 임의의 기간 동안 실행될 수 있으므로 이 목표를 단락시킬 수 있으며, 애플리케이션이 성능 목표를 달성하는 데 필요한 가용 시간을 줄입니다.
메모리 이탈 감소, 가비지 컬렉션 세금 감면
앞서 언급한 대로 일련의 휴리스틱이 펄스가 유용할 만큼 비활성 객체가 충분하다고 판단하면 GC 펄스가 발생합니다. 따라서 가비지 컬렉터가 애플리케이션에서 사용하는 시간을 줄이는 열쇠는 과도한 객체 생성 및 해제의 경우를 최대한 많이 제거하는 것입니다. 객체를 자주 생성/해제하는 이 프로세스를 '메모리 급변'이라고 합니다. 애플리케이션의 전체 기간 동안 메모리 급변을 줄이면 실행 시 GC에 걸리는 시간도 줄어듭니다. 즉, 생성되고 소멸된 객체의 수를 삭제/줄여야 하며, 실제로는 메모리 할당을 중지해야 합니다.
이 프로세스를 통해 메모리 그래프가 다음에서 이동합니다.
다음과 같이 변경합니다.
이 모델에서는 그래프에 더 이상 톱니 모양과 같은 패턴이 없지만 처음에는 크게 성장한 후 시간이 지남에 따라 천천히 증가하는 것을 확인할 수 있습니다. 메모리 변경으로 인해 성능 문제가 발생하는 경우 이 유형의 그래프를 만들어야 합니다.
정적 메모리 JavaScript로 전환
정적 메모리 JavaScript는 앱 시작 시 전체 기간에 필요한 모든 메모리를 사전 할당하고 객체가 더 이상 필요하지 않으므로 실행되는 동안 해당 메모리를 관리하는 기법입니다. 다음과 같은 간단한 단계로 이 목표에 도달할 수 있습니다.
- 애플리케이션을 계측하여 다양한 사용 시나리오에 필요한 최대 실시간 메모리 객체 수(유형별)를 확인합니다.
- 코드를 다시 구현하여 최대 개수를 사전 할당한 다음 기본 메모리로 이동하는 대신 수동으로 가져오거나 해제합니다.
실제로는 1번을 완료하려면 2번을 약간 해야 하므로 거기서부터 시작해 보겠습니다.
객체 풀
간단히 말해 객체 풀링은 유형을 공유하는 미사용 객체 집합을 유지하는 프로세스입니다. 코드에 새 객체가 필요한 경우 시스템 메모리 힙에서 새 객체를 할당하는 대신 풀에서 사용되지 않는 객체 중 하나를 재활용합니다. 외부 코드가 객체에서 완료되면 기본 메모리로 해제하지 않고 풀로 반환됩니다. 객체는 코드에서 역참조(삭제)되지 않으므로 가비지 컬렉션되지 않습니다. 객체 풀을 사용하면 메모리 제어를 프로그래머가 다시 할 수 있으므로 가비지 컬렉터가 성능에 미치는 영향을 줄일 수 있습니다.
애플리케이션이 유지관리하는 이기종 객체 유형이 있으므로 객체 풀을 올바르게 사용하려면 애플리케이션 런타임 중에 변동이 많은 유형당 하나의 풀이 있어야 합니다.
var newEntity = gEntityObjectPool.allocate();
newEntity.pos = {x: 215, y: 88};
//..... do some stuff with the object that we need to do
gEntityObjectPool.free(newEntity); //free the object when we're done
newEntity = null; //free this object reference
대부분의 애플리케이션에서는 새 객체를 할당해야 하는 필요성이 어느 정도 줄어듭니다. 애플리케이션을 여러 번 실행하면 이 상한이 어느 정도인지 파악할 수 있으며 애플리케이션 시작 시 해당 개수의 객체를 미리 할당할 수 있습니다.
객체 사전 할당
프로젝트에 객체 풀링을 구현하면 애플리케이션 런타임 중에 필요한 객체의 최대 개수를 이론적으로 알 수 있습니다. 다양한 테스트 시나리오를 통해 사이트를 실행하면 필요한 메모리 요구사항 유형을 파악하고 해당 데이터를 어딘가에 분류한 후 분석하여 애플리케이션의 메모리 요구사항 상한값을 파악할 수 있습니다.
그런 다음 앱의 출시 버전에서 모든 객체 풀을 지정된 수량으로 미리 채우도록 초기화 단계를 설정할 수 있습니다. 이렇게 하면 모든 객체 초기화가 앱 앞쪽으로 푸시되고 실행 중에 동적으로 발생하는 할당 수가 줄어듭니다.
function init() {
//preallocate all our pools.
//Note that we keep each pool homogeneous wrt object types
gEntityObjectPool.preAllocate(256);
gDomObjectPool.preAllocate(888);
}
선택하는 양은 애플리케이션의 동작에 큰 영향을 미칩니다. 이론적 최댓값이 최선의 옵션이 아닌 경우도 있습니다. 예를 들어 평균 최대값을 선택하면 핵심 사용자가 아닌 사용자의 메모리 사용량이 줄어들 수 있습니다.
만병통치약은 아닙니다.
정적 메모리 증가 패턴이 유용할 수 있는 앱의 전체 분류가 있습니다. 하지만 Chrome DevRel 동료인 레나토 만지니가 지적한 대로 몇 가지 단점이 있습니다.
결론
JavaScript가 웹에 적합한 이유 중 하나는 빠르고 재미있으며 시작하기 쉬운 언어라는 점입니다. 이는 주로 문법 제한에 대한 장벽이 낮고 메모리 문제를 대신 처리하기 때문입니다. 코딩을 계속하면서 지저분한 작업은 리액션 파이프라인이 처리하도록 할 수 있습니다. 하지만 HTML5 게임과 같은 고성능 웹 애플리케이션의 경우 GC가 매우 중요한 프레임 속도를 소모하여 최종 사용자 환경이 저하되는 경우가 많습니다. 신중하게 계측하고 객체 풀을 채택하면 프레임 속도에 미치는 이러한 부담을 줄이고 더 멋진 작업에 시간을 할애할 수 있습니다.
소스 코드
웹에는 객체 풀의 구현이 많이 있으므로 또 다른 구현을 소개해 지루하게 하지 않겠습니다. 대신 구체적인 구현 뉘앙스가 있으며, 애플리케이션 사용마다 구체적인 구현 요구가 있을 수 있다는 점을 감안하여 이러한 설명으로 안내해 드리겠습니다.