node.js memory leak 메모리 누수

원본글

http://apmblog.dynatrace.com/2015/11/04/understanding-garbage-collection-and-hunting-memory-leaks-in-node-js/

발 번역 죄송합니다. 저자에게 허락 구하고 옮긴 글입니다.

node.js의 bad press coverage가 있을 때마다 (일반적으로) 퍼포먼스 문제와 관련이 있었다. 이것이 node.js가 다른 기술들보다 더 문제를 일으키기 쉽다는 것을 뜻하는 것은 아니었다. — 사용자는 단순히 node.js를 어떻게 동작시킬 것인지에 대해서만 알고 있으면 된다. 이 기술이 꽤 평평하게 러닝 커브를 가져가고 있는 동안, node.js를 유지하고 있는 시스템은 꽤 복잡하며, 당신은 우선적으로 문제들을 피해갈 수 있는 방법을 이해해야 한다. 그리고 만약에 문제가 발생한다면 재빠르게 해결할 수 있는 방법을 알고 있어야 한다.

이 포스트에서는 node.js가 어떻게 메모리를 관리하는지와 메모리와 관련된 문제를 어떻게 추적할것인지에 대해서 다룰 것이다. php 플랫폼과 다르게, node.js 어플리케이션은 long-running 프로세스이다. 이점은 DB 커넥션을 한번 셋업하고나서 모든 request를 처리하기 위해계속 재사용한다는 것에서 큰 장점을 가지고 있지만, 이 또한 많은 문제를 동반한다. 먼저 node.js의 기본적인 부분에 해서 보도록 하자.

Node.js는 V8 자바스크립트 엔진으로 돌아가는 C++ 프로그램이다

Google V8 은 초기에 구글 크롬을 위해 만든 자바스크립트 엔진이다. 그러나 개별적으로도 사용이 가능하다. V8은 node.js에 딱 맞게 만들어 졌다. 그리고 자바스크립트 플랫폼의 한 부분이다. V8은 자바스크립트를 native code로 컴파일해서 실행한다. 실행되는 동안 메모리를 필요한 만큼 할당하고 회수한다. 이는 우리가 node.js의 메모리 관리에 대해서 논한다면 반드시 V8에 대해서 언급할 수 밖에 없다는 것을 뜻한다.

이곳을 통해 C++ 관점에서 V8을 어떻게 사용하는지에 대해 예제를 보도록 하자.

Getting Started

developers.google.com

V8의 메모리 정책

실행되고 있는 프로그램은 항상 메모리의 빈공간에 할당되어 나타난다. 이 공간은 Resident Set 이라고 불린다. V8 은 JVM과 비슷한 정책을 사용하며 메모리를 세그먼트로 나눈다.

Code: 실행될 실제 코드
Stack: 힙에 있는 오브젝트를 참조하는 포인터와 함께 모드 value 타입을 포함 ( integer, boolean과 같은 기본 요소 ) 
Heap: 오브젝트, 스트링, 클로저와 같은 레퍼런스 타입을 저장하기 위한 전용 메모리 세그먼트

Node.js내에서 현재 메모리 사용은 process.memoryUsage()를 이용해 쉽게 이용할 수 있다.

process Node.js v5.5.0 Manual & Documentation

nodejs.org

이 함수는 아래를 포함하고 있는 오브젝트를 리턴한다.

Resident Set Size
Total Size of the Heap
Heap actually Used

이 함수를 이용해서 실제 작업에서 메모리 사용에 따른 V8의 메모리 핸들링이 어떻게 이뤄지는지 보여주는 그래프를 그릴 것이다.

첨부된 힙 그래프의 변동성이 크지만 일정 범위안에서 일정치의 메모리 사용이 유지된다는 것을 볼 수 있다. 힙 메모리를 할당하고 풀어주는 메카니즘을 garbage collection 이라고 부른다.

Garbage Collection 살펴보기

메모리를 사용하는 모든 프로그램은 메모리에 대한 메카니즘을 필요로한다. C, C++에서는 아래 표에서 보여지는 것과 같이 malloc(), free()에 의해 이뤄진다. 우리는 프로그래머에게 더이상 필요하지 않은 힙 메모리를 거둬들여야 한다는 책임이 있다는 것을 알고 있다. 만약에 프로그램이 사용하지 않는 힙을 거둬들이지 않고 메모리가 고갈될 때까지 할당을 지속한다면 프로그램 충돌이 발생할 것이다. 우리는 이것을 memory leak이라고 부른다.
우리가 이미 배운것과 같이 node.js에서는 V8을 이용해서 자바스크립트를 native code로 컴파일한다. 
native 데이터의 구조는 원본이 수행했던 것과 완전히 다르게 V8에 의해 관리된다. 이 말은 즉. 실제로 자바스크립트로 메모리가 할당, 회수 되지 않는 다는 것이다. V8은 이 문제 접근하기 위해 garbage collection 메카니즘을 이용한다.
garbage collection의 이론은 굉장히 간단하다. 만약 메모리 세그먼트가 어디로부터 참조되지 않는다면 그것은 더 이상 사용되지 않을 것이라고 추측할 수 있다. 그러므로 그것은 회수 될 수 있다. 그러나 이 정보를 검색, 유지하는 것은 꽤 복잡하다. 체인형태로 참조되거나 복잡한 그래프 구조로 간접성을 갖고 있기 때문이다.

garbage collection 은 성능에 영향을 미치는 어플리케이션의 실행을 중단하기 때문에 꽤 비용이 따르는 프로세스 이다. 이러한 문제를 해결하기 위해 V8은 두가지 타입의 garbage collection 을 사용한다.

Scavenge, 이것은 빠르나 불완전하다.
Mark-Sweep, 이것은 느리지만 참조하지 않는 데이터를 회수한다.

아래 링크를 통해 V8의 garbage collection에 대한 심도있는 정보를 얻을 수 있다.

A tour of V8: Garbage Collection

jayconrod.com

process.memoryUsage()를 통해 수집된 데이터를 재탐색하므로써 우리는 쉽게 garbage collection 타입의 차이점에 대해서 식별할 수 있다 : 구강구조의 패턴은 Scavenge 실행으로 만들어 진것이고, downward jumps는 Mark-Sweep operations을 가리킨다.
native module node-gc-profiler 를 사용하는 것으로부터 garbage collection 실행에 대한 더 많은 정보를 모을 수 있다. 모듈은 V8에 의해 움직이는 garbage collection 이벤트에 가입한다. 그리고 모듈을 자바스크립트로 expose 한다.
오브젝트는 garbage collection과 지속기간을 되돌려 준다. 다시, 우리는 어떻게 작동하는지에 대해서 더 쉽게 이해하도록 그래프를 그릴수 있다.

우리는 Scavenge Compact가 Mark-Sweep보다 더 빈번하게 동작한다는 것을 볼 수 이다. 어플리케이션의 복잡도의 따라 기간은 변할 수 있다. 흥미롭게도 위의 차트도 빈번함을 보여주는데, 이는 이전에 내가 지정했던 방식의Mark-Sweep이 동작하는 것으로 매우 짧다.

문제가 발생할 때

그렇다면 garbage collection이 메모리를 청소할 때, 왜 당신은 그것들을 볼려고 하는 것인가? 
사실 당신의 logs에 갑자기 나타나는 메모리 누수를 나타나게 하는 것은 가능하다.(쉽다)

이전에 소개한 메모리 쌓는 것을 볼 수 있었던 방식을 이용해보자.

Garbage collection은 메모리를 회수하기 위한 최고의 방법을 시도이다. 그러나 모든 동작에서 garbage collection이 실행이 되고 난 뒤에도 메모리 소비량은 지속적으로 올라가는 것을 볼 수 있다. 
이 측정은 이상 관측을 위해 좋은 시작점이다. 이것을 추적하기 전에 누수가 어떻게 발생하는지 살펴보자.

메모리 누수 만들기

몇몇 메모리 누수는 명확하다( 프로세스 전역 변수를 저장한다든가), 예를 들면 array에 모든 방문자들의 ip를 저장한다든가.. 감지하기 힘든건 node.js 안에 미세한 놓친 statement 로부터 발생되는 월마트 메모리 누수이다. 그리고 이는 추적하는데 몇주가 걸린다.
여기서는 core code 에러를 다루지 않겠다. 대신에 Meteor’s blog 에서 사용한 간단한 자바스크립트에서 메모리 누수를 추적하는 방법을 보도록 하자.

An interesting kind of JavaScript memory leak

An interesting kind of JavaScript memory leak

info.meteor.com

이 소스를 처음 볼 때는 문제가 없는 것처럼 보인다. 그러나 우리는 theThing이 replaceThing()을 실행할 때마다 오버라이트 된다는 것을 알 수 있다. 이 문제는 someMethod 가 스스로 context 범위를 가진다는 것이다. unused()는 someMethod()의 범위안에 있다고 알려져 있다는 것이다. 심지어 unused()가 실행되지 않으면, Garbage collector가 originalThing을 회수하는 것을 방해할 것이다. 쉽게 이해하기에는 문제가 너무 많다. 이것은 버그는 아니지만 추적이 어려운 메모리 누수를 일으킨다.

그래서 만약에 힙 안에 무엇이 들었는지 볼 수 있다면 좋지 않겠는가? 이것은 가능한 일이다. V8 은 현재 힙에 무엇이 있는지 확인할 방법을 제공한다. 그리고 V8 프로파일러는 기능적으로 자바스크립트에게 노출시킨다.

만약 메모리 사용이 지속적으로 상승한다면 이 간단한 모듈은 힙 덤프 파일을 생성한다. 물론 변칙 감지에 더 복잡한 것도 있다. 그러나 (우리 목적에는) 이거면 충분하다. 만약 메모리 누수가 있다면 당신은 충분히 많게 그 파일들에 쌓여져 있을 것이다. 그래서 당신은 이것을 자세히 모니터 해야 한다. 그리고 몇몇의 alert을 모듈에 추가시켜야 한다. 이와 같은 힙 덤프 기능은 크롬 안에 제공된다. 그리고 V8 프로파일러 덤프를 분석하기위해 크롬 developer 툴을 사용할 수 있다.

단 한개의 힙 덤프는 시간이 지남에 따른 힙 상태를 보여주지 않기 때문에 도움이 되지 않는다. 
이것이 크롬 디벨로퍼 툴이 다른 메모리 프로파일러와 비교할 수 있게 해주는 이유이다. 두 덤프를 비요하므로써 아래 보는 바와 같이 두 덤프 사이에 구조의 증가를 나타내는 델타 밸류를 얻을 수 있다.

그리고 여기에 문제가 있다. longStr 이라고 불리는 변수는 *를 포함하고 있다. 그리고 originalThing로부터 참조되고 있고, 또 이는 someMethod로부터 참조되고 있다. 또 참조되고 있다…. 이 점을 포인트이다. 이것이 긴 path의 근원 레퍼런스이고 클로저이다. 그리고 이것들이 longStr을 회수되지 못하도록 막혀있다.
그러나 이 예제는 프로세스가 항상 같은 (명확한) 결과를 가져오게 했다.
1. 약간의 시간과 메모리가 할당되도록 힙 덤프를 만드세요.
2. 그리고 무엇이 증가하는지 비교하세요.

마무리

우리가 봐 온것 처럼, Garbage collection은 복잡한 프로세스이고 그리고 유효한 코드가 메모리를 야기시키게 할 수 있다. 크롬 디벨로퍼 툴에서 제공되는 기능들을 이용해서 메모리 누수의 근원을 찾을 수 있다. 그리고 당신의 어플리케이션안에 그러한 기능을 넣게 되면, 문제가 발생했을 때 해결할 수 있는 모든 것을 갖게 된다. 그러나 한가지 의문이 남는다.
우리는 어떻게 이 문제를 해결 할 수 있을까? 이것은 간단하다. 단순히 함수 맨 마지막에 theThing = null을 넣는 것이다. 그러면 당신의 시간을 아낄 수 있을 것이다.

Show your support

Clapping shows how much you appreciated Dongmin Jang’s story.