SSR Memory Leak 디버깅하는 방법

Next.js SSR 메모리 누수 디버깅하기

FlyingSquirrel
10 min readMay 1, 2022

TL; DR;

- 실제로 힙메모리가 부족한 걸 수도 있다. 그럴 땐 --max_old_space_size 옵션으로 메모리를 늘려준다.
- Node.js의 --inspect 옵션을 이용하면 dev tool에서 프로파일링 할 수 있다.
- Shallow Size에 비해 Retained Size가 큰 오브젝트가 누수를 일으키는 원인이라서 이 부분을 공략하면 된다.

SSR로 전환한 뒤 메모리 누수가 발생하고 있다는 것을 알았다

당시 모니터링했을 때 힙메모리 사용량이 시간이 지날 수록 상승하더니, 뚝 떨어지고, 다시 상승하는 그래프를 반복했다. 정상적인 그래프는 아니었다.
당시 모니터링했을 때 힙메모리 사용량이 시간이 지날 수록 상승하더니, 뚝 떨어지고, 다시 상승하는 산모양의 그래프를 반복했다. 정상적인 그래프는 아니었다.

사이드프로젝트에서는 프레임워크나 라이브러리의 ‘사용법’ 정도를 익힌다면, 회사에서 일할 때는 그걸 사용하면서 ‘문제’를 직면할 수 있다는 게 재밌는 것 같아요.

회사에서 맡고 있는 서비스를 근래에 SSR로 전환한 뒤부터 heap memory 사용량이 증가하더니 이내 인스턴스가 죽고 다시 시작되는 현상이 반복습니다.

힙메모리를 늘렸지만, 그래도 부족했다.

트래픽이 폭발적으로 늘어난 상황은 아니었고, 힙메모리를 늘려도 보았지만 산모양의 그래프는 계속 되었어요. Node.js 환경에서 메모리누수가 있다는 뜻이었죠.

힙메모리가 부족하다고 느껴질 때는 --max_old_space_size 로 최대 사용가능한 메모리를 늘릴 수도 있습니다. Node.js 문서에서는 2GB의 메모리인 환경에서는 힙메모리 사용량 1.5GB까지 사용하는 것을 고려할 수 있다고 말합니다. 2GB라고 2GB 다 쓰면 안되고, 좀 남겨둬야한데요.

node --max-old-space-size=1536 index.js

--inspect 옵션으로 디버깅을 시작해보자

// 일반적인 경우 package.json
"script" : {
"build": "next build",
"start": "cross-env NODE_OPTIONS='--inspect' next dev",
}
# yarn berry인 경우 package.json
"script" : {
"build": "next build",
"start": "cross-env NODE_OPTIONS='$NODE_OPTIONS --inspect' next dev",
}

$ yarn build && yarn start // http://localhost:포트 로 접속

dev 서버를 띄워서 확인해도 되긴 하지만, 나는 애플리케이션을 빌드(next build)한 뒤에 --inspect 옵션으로 디버깅하는 방법으로 디버깅했습니다. dev 서버로 디버깅하려고 했는데 memory profiling recording 속도가 너어어어어무 느렸거든요.

Next.js인 경우에는 cross-env 를 이용해서 NODE_OPTIONS 옵션을 설정해줄 수 있습니다. (👉 Next.js 문서)

yarn build && yarn start 로 실행 후 브라우저에서 http://localhost:포트 로 접속한 뒤, 크롬인 경우에는 주소창에 chrome://inspect 으로 접속하면 디버깅 가능한 장치들이 떠있는 걸 볼 수 있습니다.

크롬 브라우저 아니어도 vscode, JetBrain IDE에서도 인스펙트 모드를 지원하고 있어서 본인에게 편한 방법으로 하면 됩니다.

chrome://inspect 에서 현재 디버깅 가능한 장치목록이 있고, inspect를 클릭하면 디버거가 열린다.

디버깅 툴 살펴보기

Memory 탭에서 Recording을 한 상태에서 열려있는 localhost 페이지를 새고로침하면 이런 모습을 볼 수 있다.

DevTools에서 Memory 탭에는 프로파일링 타입을 선택할 수가 있는데, 저는 Allocation instrumentation on timeline으로 디버깅했습니다. Recording 버튼을 눌러서 이것 저것 하고 다시 버튼을 누르면 녹화를 멈출 수 있고 조금 기다리면 snapshot 결과가 나옵니다.

  • Heap snapshot: 현재 상태의 힙메모리를 기록할 때 씁니다. 누수의 원인을 찾을 때보단 퍼포먼스 개선했을 때 before/after 측정할 때 유용할 수 있습니다.
  • Allocation instrumentation on timeline: 위에 gif 화면처럼 시간별로 JS 메모리할당이 얼마나 이뤄졌는지를 볼 수 있습니다.
  • Allocation sampling: timeline 타입보다 훨씬 긴 시간을 프로파일링할 때 사용합니다. 긴 시간 동안 모든 오브젝트를 기록하는 것은 아니고 일부만 샘플링해서 힙메모리 사용을 기록합니다.
회색은 GC에 의해 해방된 메모리를 나타내고, 파란색은 사용된 메모리를 나타냅니다. 이게 메모리 누수의 범인이에요.
회색은 GC에 의해 해방된 메모리를 나타내고, 파란색은 사용된 메모리를 나타냅니다. 이게 메모리 누수의 범인이에요.

회색은 GC에 의해 해방된 메모리를 나타내고, 파랜색은 사용된 메모리인데 GC에 의해 해방되지 않은 메모리를 나타냅니다. 파란색이 치솟아 있는 게 메모리 누수의 범인입니다. 👀

Next.js로 SSR을 하는 것도 결국은 Nodejs로 서버를 띄우고, 들어오는 요청마다 적합한 응답(HTML, JS, CSS)을 반복하는 일입니다.

루트노드에 도달할 수 없는 노드들이 grabage입니다.

내가 작성한 코드가 Node.js에서 환경에서 실행될 때는 여러 노드들이 연결된 거대하고 여러 개의 그래프 관계가 그려지게 됩니다. 루트노드(GC Roots)는 여러 개 일 수 있습니다(Window, Global, DOM 등등). 각 관계마다 가장 루트 노드가 있을텐데, 루트 노트에 도달할 수 없는 노드들이 garbages(쓰레기)가 되고, garbage collector가 mark and sweep 알고리즘에 의해서 시스템에 메모리를 반환합니다.

gc로 해방되지 않은 메모리는 점점 쌓여서 메모리 누수가 됩니다.

들어오는 요청마다 Node.js가 적합한 응답값을 줄텐데, 응답을 주기 위해서 JavaScript (Node.js) 는 할당된 메모리 중 일부를 힙메모리로 사용합니다. 응답을 준 뒤에는 깔끔히 gc로 청소를 하고 시스템에 메모리를 반환하면 되는데, 그렇지 못하고 계속 메모리에 할당된 상태로 무언가(JS 오브젝트) 조금씩 남아있다면 그것이 메모리 누수가 됩니다. 자원(메모리)은 한정되어있는데, 계속 메모리의 일부를 차지하는 오브젝트가 늘어나니까, 이 블로그 글 가장 위에 그려놓은 산모양의 그래프가 그려지게 됩니다. 그러다 메모리가 가득차면 죽는거에요. 😇

본격 탐색, Shallow Size 대비 Retained Size가 큰 오브젝트를 찾아서..

가장 파란색 바가 튀는 범위를 지정하고 Shallow Size대비 Retained Size가 큰 것을 찾아야합니다.

이 부분부터는 쉽게 풀릴 수도 있고, 쉽지 않은 여정이 될 수 있습니다. 메모리 누수의 원인이 되는 것이 무엇인지 찾아내기 위해 뒤적뒤적하는 시간을 가져야하기 때문입니다.

Shallow Size, Retained Size를 잘 비교해보면서 하나씩 차분히 ☕️ 살펴봐야합니다.

  • Shallow Size: JS object 자기 자신의 크기입니다.
  • Retained Size: 참조하고 있는 다른 오브젝트가 있다면 그 오브젝트 크기까지 합친게 Retained Size가 됩니다. 다른 오브젝트가 또 참조하는 또 다른 오브젝트가 있다면 그 오브젝트의 크기도 합산됩니다. (자신+참조하고 있는 다른 오브젝트들)

메모리 누수의 원인을 찾을 때는 Shallow Size는 작은데, Retained Size가 엄청 큰 것부터 살펴보는 것이 효율적입니다.

출처: Chrome Developer 문서

shallow size에 비해서 retained size가 큰 요소를 클릭하면 하단에 Object라는 영역에서 굉장히 어렵고(?) 자세히 볼 수 있습니다.

자세히 보면 이 오브젝트가 위치한 경로를 볼 수가 있습니다. 이걸로 어떤 라이브러리가, 그리고 라이브러리에서 어떤 함수가 메모리누수를 일으키는지 알 수 있습니다.

의심되는 경로를 발견했다면, 코드를 다시 살펴서 메모리 누수가 일어날 만한 가설을 세우고 ➡️ 코드를 수정하고 ➡️ 다시 프로파일링해서 메모리 누수의 원인이 맞는지 검증하는 지루한 반복을 해야합니다.

이 과정에서 어떤 라이브러리의 문제가 있다면 그 라이브러리 GitHub issue 또는 discussion 페이지에서 검색해보면 누군가 남겨놓은 질문이 있을 수도 있습니다. 저의 경우에는 누가 남겨놓은 질문을 찾지 못해서(없는 거 같아요 😇) 해결하는 데 제법 시간이 걸렸어요.

메모리 누수로 추정되는 코드를 수정한 뒤 힙메모리 사용 그래프가 더이상 산모양의 그래프는 그려지지 않았어요.

SSR이 좋다 좋다하기도 하고, 좋은 점이 확실히 많지만, 서버를 하나 사용하는 것이다보니 조금 더 신경써줘야하는 부분이 있는 것 같습니다.

참고한 문서들

--

--