Static Memory Javascript with Object Pools

HTML5 Rocks

소개

고객이 이메일로 당신에게 웹 게임/웹앱의 성능이 일정 시간이 지나면 심하게 저하되는 이유를 묻는다면, 아마 여러분은 코드 분석을 시작할 겁니다. Chrome의 메모리 성능 측정 도구를 열어서 아래와 같은 내용을 확인하겠죠.

이런 톱니 모양이 다 뭐죠?

당신의 동료 중 한 명은 메모리 관련 성능 문제가 발생했다는 것을 알아채고는 키득키득 거리며 웃습니다.

메모리 그래프 화면에 이러한 톱니 모양의 패턴이 나타난다는 것은 치명적인 성능 문제 발생할 가능성을 말해줍니다. 메모리 사용량이 증가하면, 캡처한 타임라인의 차트의 그래프 영역 역시 증가합니다. 차트의 그래프가 갑자기 떨어지는 경우는, 가비지 콜렉터를 실행해서 메모리 상에 있는 객체를 청소한 상황입니다.

Look at all those GC Events!

위의 그래프에서, 많은 가비지 콜렉션 이벤트가 일어나는 것을 볼 수 있습니다. 이 이벤트는 웹앱의 성능을 떨어뜨립니다. 이 글에서 메모리 사용을 제어함으로써 성능에 끼치는 영향을 줄이는 방법에 대해서 이야기를 해보겠습니다.

가비지 콜렉션(Garbage collection)과 성능 비용

JavaScript의 메모리 모델가비지 콜렉터(Garbage Collector)라고 알려진 기술을 이용합니다. 많은 언어가 시스템의 메모리 힙(Memory Heap)에 메모리를 할당하고 제어할 수 있는 책임을 프로그래머에게 부여합니다. 그런데, 프로그래머를 대신하여 이 작업을 관리한다는 것은 프로그래머가 객체를 역참조하고 싶을 때 직접 메모리를 해제할 수 없다는 것을 의미합니다. 오히려 GC가 경험적으로 판단하는 것이 더 나은 방법이라고 여기는 거죠. 이러한 판단을 위해서 GC는 어떤 통계적인 방법으로 활성 및 비활성 객체를 분석하는데, 이 때 애플리케이션의 실행이 잠시 중단됩니다.

컴퓨터 과학에서, 가비지 콜렉터 (GC)는 자동 메모리 관리의 한 형태입니다. 가비지 콜렉터는 가비지, 또는 프로그램이 사용하다가 더이상 필요가 없어진 객체가 점유하고 있는 메모리를 수거합니다.

종종 가비지 콜렉션을 메모리에 할당한 객체를 프로그래머가 직접 해제하여 시스템에 메모리를 반환하는 수동적 메모리 관리에 반대되는 개념으로 설명합니다.

GC가 메모리를 수거하는 과정은 비용이 들어갑니다. 보통 이 과정을 처리하는 동안 애플리케이션 실행 시간을 차지해서 성능을 떨어뜨리죠. 이와 동시에 시스템이 스스로 실행 시점을 결정합니다. 개발자는 이 동작을 제어할 수 없습니다. GC 펄스(GC pulse)-메모리 점유 그래프가 GC 수행 후 떨어지는 현상-는 코드 실행 중 언제라도 일어날 수 있고, 이 작업을 처리하는 동안에는 코드 실행이 중단됩니다. GC 펄스의 지속 시간은 일반적으로 알 수 없는데, 프로그램이 해당 시점에 메모리를 얼마나 사용하고 있는지에 따라서 달라집니다.

고성능(High performance) 애플리케이션이 되기 위해서는 부드러운 사용자 경험을 보장하는 일관성 있는 성능을 유지해야 합니다. 임의의 시간에, 임의의 시간 동안 동작할 수 있는 가비지 콜렉터 시스템은 애플리케이션이 성능 목표를 달성하기 위해 필요한 시간을 차지해버림으로써 일관성 있는 성능 유지를 방해합니다.

메모리 변동을 줄여서 가비지 콜렉션 비용 줄이기(Reduce Memory Churn, Reduce Garbage Collection taxes)

앞에서 언급한 바와 같이, 사용하지 않는 객체가 많아서 GC를 하는 것이 유리하다고 경험적으로 판단되면 GC를 수행합니다. 따라서, 지나친 객체 생성/제거를 할 수 있는 한 막는 것이 가비지 콜렉터가 애플리케이션을 차지하는 시간을 줄이는 핵심입니다. 이러한 객체 생성/제거가 빈번하게 일어나는 현상을 "메모리 변동(memory churn)"이라고 합니다. 애플리케이션이 동작하는 동안에 메모리 변동(memory churn) 발생 빈도를 줄일 수 있다면, 전체 실행 시간 중 GC가 차지하는 비율을 줄일 수 있습니다. 생성하고 파괴하는 객체의 수를 줄이면 효과적으로 메모리 할당을 중단할 수 있겠죠. 이렇게 하면 메모리 그래프가 다음과 같은 모습에서,

I wonder what all those saw-tooths are?

이렇게 바뀔 겁니다.

Ahhhh, that's better.

이 모델의 그래프에서 더 이상 톱니와 같은 패턴이 나타나지 않는 것을 볼 수 있습니다. 오히려 초기에 그래프가 급증하고 시간이 지남에 따라 천천히 증가하고 있죠. 메모리 변동(memory churn) 때문에 성능 문제를 겪고 있다면, 이런 형태의 그래프를 만들고 싶어집니다.

정적 메모리 JavaScript를 향해서

정적 메모리 JavaScript는 애플리케이션의 시작 시점에 실행하는 동안에 필요한 모든 메모리를 사전할당(pre-allocating)하고 객체가 더 이상 필요없어지면 실행 중에 메모리를 관리하는 테크닉입니다. 이를 위한 몇 가지 간단한 단계가 있습니다.

  1. 애플리케이션에 사용 시나리오에 따라 메모리에 존재해야 하는 객체(타입별)의 최대수가 얼마인지 확인하기 위한 장치를 합니다.
  2. 최대량만큼 사전에 할당하는 코드를 다시 구현한 다음에, 메인 메모리로 가지 않고 직접 객체를 획득/해제합니다.
사실 #1을 달성하기 위해서는 #2를 먼저 약간 구현해야 합니다. 그러니 #2에서부터 시작하겠습니다.

객체 풀(Object Pools)

간단히 말해서, 객체 풀링(object pooling)은 사용하지 않는 객체 집함을 유지하는 과정입니다. 이 집합의 객체는 동일한 타입을 갖습니다. 코드 작성을 하다 새로운 객체가 필요하면, 메모리 힙(Memory Heap)에 할당하는 대신에, 풀에 있는 사용하지 않는 객체 중 하나를 재사용합니다. 객체를 사용하는 외부 코드의 수행이 끝나면, 메인 메모리에서 객체를 해제하지 않고 객체 풀로 반환합니다. 객체를 코드에서 역참조(dereferenced)(일명 삭제)하지 않기 때문에 객체는 가비지 콜렉팅이 대상이 아닙니다. 객체 풀을 활용하면 프로그래머가 메모리를 제어할 수 있어, 가비지 콜렉터가 성능에 미치는 영향을 줄일 수 있습니다.

객체 풀은 시스템에 발생하는 메모리 변동(memory churn)을 줄이기 위해서 많은 고성능 애플리케이션이 일반적으로 사용하는 방법입니다. 객체 풀 자체는 두가지 주요한 특징을 가지고 있습니다.
  1. 살아있는 객체가 증가하는 만큼 풀이 차지하는 메모리 공간도 계속 증가합니다.
  2. 프레임당 생성/해제하는 객체의 수는 애플리케이션에 필요한 최소치까지 감소합니다.

애플리케이션이 관리하는 객체의 타입이 다르기 때문에, 객체 풀을 적절하게 사용하려면 애플리케이션 실행 중 잦은 변동(churn)을 겪는 타입별로 객체 풀이 하나씩 필요합니다.

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

대부분의 경우에, 결국은 새로운 객체 할당을 요구하는 측면의 수준을 기록할 것입니다.(For the large majority of applications, you’ll eventually hit some level-off in terms of needing to allocate new objects.) 애플리케이션을 여러번 실행하면서 이 상한선이 얼마인지 감을 잡을 수 있으면 애플리케이션 시작 시점에 그 만큼의 객체를 사전에 할당할 수 있습니다.

객체 사전할당(Pre-allocating objects)

프로젝트에 사용할 객체 풀을 구현하면 애플리케이션 실행중 필요한 객체수의 이론상 최대치를 알 수 있습니다. 일단 다양한 테스트 시나리오를 사이트에 적용해서 돌려보면, 필요한 메모리 요구 형태를 이해할 수 있습니다. 그리고 어딘가에 그 데이터 목록을 만들고 분석해서 애플리케이션에 필요한 메모리 요구 상한이 얼마인지 알 수 있습니다.

그런 다음, 애플리케이션의 릴리즈 버전에 초기화 단계를 설정해서 모든 객체 풀을 지정한 만큼 미리 채울 수 있습니다. 이렇게 하면 모든 객체 초기화를 애플리케이션 실행 앞부분에 둠으로써 실행중에 동적으로 발생하는 객체 할당의 양을 줄일 수 있습니다.

function init() {
  //preallocate all our pools.
  //Note that we keep each pool homogeneous wrt object types
  gEntityObjectPool.preAllocate(256);
  gDomObjectPool.preAllocate(888);
}

선택한 값은 애플리케이션과 아주 밀접한 관계를 갖습니다. 가끔은 이론적으로 정한 최대치가 최선의 선택이 아닐 수도 있습니다. 예를 들어, 파워 유저가 아닌 경우에는 평균 최대치를 선택해서 메모리 사용량을 줄일 수 있겠죠.

만능은 아닙니다(Far from a silver bullet)

정적 메모리 증가 패턴은 모든 분류의 애플리케이션에 적용할 수 있습니다. 하지만 Chrome DevRel 팀의 Renato Mangini는 몇가지 단점을 지적합니다.

객체 풀을 모든 애플리케이션에 적용할 수는 없습니다. 심지어 고성능 애플리케이션인 경우도 그렇습니다. 객체 풀과 정적 메모리 방법을 채택하기 전에 다음의 손실을 고려하세요. 초기화 시점의 메모리 할당 주기 때문에 시작 시간이 더 길어집니다. 애플리케이션이 탐욕스럽게 메모리를 차지하고 있기 때문에 적은 메모리가 필요한 경우에도 메모리 사용량이 줄지 않습니다. 풀에 객체를 반환할 때 가끔씩 객체를 정리할 필요가 있는데, 메모리 변동이 아주 높은(high-memory-churn) 영역에서 이런 일이 발생하면 적지 않은 오버헤드가 발생할 수 있습니다.

결론(Conclusion)

JavaScript가 웹에 이상적인 이유중 하나는 빠르고, 즐겁고, 쉽게 시작할 수 있는 언어이기 때문입니다. 문법 제약의 장벽이 낮고 개발자를 대신하여 메모리 이슈를 처리해주죠. 코드만 작성하면 지저분한 일은 알아서 해줍니다. 하지만 HTML5 게임과 같은 고성능 웹 애플리케이션의 경우, 중요한 프레임률(frame rate)을 GC가 종종 떨어뜨려서 사용자 경험을 헤칩니다. 주의 깊게 설계해서 객체 풀을 사용한다면, 프레임률과 관련한 이런 문제에서 벗어나 더 멋진 일에 집중할 수 있겠죠.

소스 코드(Source Code)

웹에 돌아다니는 많은 객체 풀 구현 코드가 있기 때문에 여기에서 또 다른 객체 풀을 구현하는 걸로 여러분을 지루하게 만들고 싶지는 않습니다. 대신에, 아래 링크를 참조하세요. 각각의 구현 방식에는 미묘한 차이가 있습니다. 애플리케이션의 용도에 따라 요구사항이 달랐을 것이기 때문에 이는 중요한 차이입니다.

참고 문헌(References)

Comments

0