회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
AWS 이용 중이라면 최대 700만 원 지원받으세요
*FEConf2023에서 발표한 <SSR 환경(Node.js) 메모리 누수 디버깅 가이드>를 정리한 글입니다. 발표 내용을 2회로 나누어 발행합니다. 1회에서는 메모리 누수에 대해 알아보고, 메모리 누수를 모니터링 도구를 통해 확인해보겠습니다. 2회에서는 메모리 누수 현상을 직접 디버깅해보고 해결하는 방법을 알아봅니다. 본문에 삽입된 이미지의 출처는 모두 이 콘텐츠와 같은 제목의 발표 자료로, 따로 출처를 표기하지 않았습니다. 발표 자료는 FEConf2023 홈페이지에서 다운로드할 수 있습니다.
회원가입을 하면 원하는 문장을
저장할 수 있어요!
다음
회원가입을 하면
성장에 도움이 되는 콘텐츠를
스크랩할 수 있어요!
확인
*FEConf2023에서 발표한 <SSR 환경(Node.js) 메모리 누수 디버깅 가이드>를 정리한 글입니다. 발표 내용을 2회로 나누어 발행합니다. 1회에서는 메모리 누수에 대해 알아보고, 메모리 누수를 모니터링 도구를 통해 확인해보겠습니다. 2회에서는 메모리 누수 현상을 직접 디버깅해보고 해결하는 방법을 알아봅니다. 본문에 삽입된 이미지의 출처는 모두 이 콘텐츠와 같은 제목의 발표 자료로, 따로 출처를 표기하지 않았습니다. 발표 자료는 FEConf2023 홈페이지에서 다운로드할 수 있습니다.
▶ 2024년 FEConf 라이트닝 토크 발표자 모집 중
FEConf2023에서 발표된 ‘SSR 환경(Node.js) 메모리 누수 디버깅 가이드’/박지혜 토스 플레이스 프론트엔드 엔지니어
이번 글에서는 앞선 글에서의 메모리 누수 현상을 직접 디버깅해보고 해결해 보겠습니다.
우리는 앞선 엘리베이터 예제에서 메모리 누수를 해결하기 위한 방법 2가지를 알아봤습니다.
1. 힙메모리를 늘려주거나
2. 메모리 누수의 범인을 디버깅한다.
두 가지 방법에 대해 자세하게 알아보겠습니다.
먼저 메모리를 늘리는 방법에 대해 살펴보겠습니다. ‘메모리가 부족하니까 메모리를 늘린다.’라는 생각은 아주 당연한 생각의 흐름입니다. 그러나 과연 힙메모리를 늘리기만 하면 메모리 누수가 해결될까요?
그렇지 않습니다. 힙 메모리를 늘려줘도 이 코드는 계속해서 메모리 누수를 일으키고 서버가 죽게 됩니다. 왜일까요? Node.js의 V8 엔진이 메모리를 관리하는 방식을 알면 이해할 수 있습니다. V8 엔진은 메모리를 잘 관리하기 위해 ‘마크 앤 스윕’이라는 알고리즘을 사용합니다. 사용하는 것은 마크하고 사용하지 않는 것은 쓸어서 청소해버린다는 뜻입니다.
배열이나 오브젝트, 펑션과 같은 데이터 타입은 힙메모리로부터 메모리를 할당받아 동작합니다. 이러한 타입을 객체라고 부르겠습니다. 가비지 컬렉터는 이러한 객체가 시용되고 있는지 아닌지 루트로부터 시작해서 재귀적으로 계속 체크를 하고 있다가 더 이상 사용하지 않는 불필요한 객체를 수거하여 메모리 공간을 확보합니다. 이게 바로 ‘마크 앤 스윕' 알고리즘입니다.
그러나 어딘가에서 계속 참조를 하고 있는 객체가 있다면 그 객체들은 계속해서 힙메모리에 존재하게 됩니다. 이렇게 객체가 계속해서 존재하면 어떻게 될까요? 이를 이해하기 위해 힙 메모리에 대해 알 필요가 있습니다.
Node.js의 V8 엔진은 메모리를 잘 관리하기 위해 영역을 나누어 메모리를 관리합니다. 아래는 V8 엔진의 라이프 사이클을 설명하기 위해 표현한 간단한 가비지 콜렉터의 구조입니다.
기본적으로 영 제네레이션(Young Generation)과 올드 제네레이션(Old Generation)으로 나누어져 있고, 가비지콜렉터는 마이너 가비지 콜렉터와 메이저 가비지 콜렉터로 나누어져 있습니다. 만약에 처음 선언된 어떤 객체가 있다면 이 객체는 보통 nursery(유아기) 라는 영역에 메모리 할당이 됩니다. 가비지 콜렉터가 한번 작업을 수행했는데 해당 객체가 살아남았다면 이 객체는 intermediate(중간기) 라는 영역으로 넘어갑니다. 다시 가비지 콜렉터가 작업을 수행했는데 또 이 객체가 살아남았다면 이 객체는 결국 올드 제네레이션 영역으로 넘어갑니다. V8 문서에서는 이 영역까지 넘어가는 객체는 거의 없다고 설명을 합니다.
그렇다면 이 올드 제네레이션 영역에 살아남은 객체가 더 많아지면 어떻게 될까요? V8 엔진은 두 가지 영역을 조정하면서 어플리케이션을 동작시킵니다. 그러나 힙 메모리는 제한된 용량을 가지기 때문에 결국 꽉 차게 될 것이고 서버가 죽게 됩니다.
앞선 예제 코드와 함께 다시 볼까요?
listItems 배열은 전역 변수로 선언되었기 때문에 가비지 콜렉터가 수거하지 못하고 올드 제네레이션에 존재하게 됩니다. 처음에는 첫 번째 그림처럼 작은 영역만 차지하고 있을 겁니다. 그러고 나서 서버에 해당 요청이 오면 100만 번의 반복문을 수행하면서 listItems의 길이가 늘어나고 이게 반복되다 보면 두 번째 그림처럼 많은 용량을 차지하게 될 것입니다. 그러다가 결국 힙 메모리가 꽉 차는 순간이 오게 됩니다. 그러면 서버가 죽게 됩니다.
위 예제는 단순하지만 실제 코드를 작성할 때 이런 문제를 만나게 되었을 때 힙 메모리만 늘리면 문제가 해결될까요? 이런 문제를 만났을 때 구글링을 해보면 아래와 같이 max-old-space-size를 늘리라는 답변을 쉽게 만날 수 있습니다.
여기서 max-old-space-size는 Node.js의 올드 제네레이션의 용량을 조절하는 옵션입니다. 즉, 메모리 누수를 일으킨 대부분의 객체는 올드 제네레이션 영역에 존재하기 때문에 앞서 설명드린 가비지 콜렉터의 구조에서 올드 제네레이션의 용량을 늘리라는 답변을 많이 만나게 됩니다.
메모리 누수를 일으키는 원인은 이외에도 다양하게 존재합니다. 예시로 설명드린 전역 변수 뿐만 아니라, setTimeout, setInterval과 같은 함수를 사용 후 클리어를 하지 않은 경우와 클로저가 대표적입니다. 클로저의 경우는 실행 컨텍스트 안에서 객체나 변수의 선언과 참조, 이에 따른 또 다른 참조가 있는 경우 등 다양한 경우에서 힙 메모리를 많이 할당하는 경우도 발생합니다.
이러한 다양한 경우에서 힙 메모리가 굉장히 많이 필요한 상황이 발생하기 때문에 무작정 힙 메모리를 늘리는 것만이 해결책이 아닐 수도 있습니다.
이제 디버깅을 통해 해결 방법을 알아보겠습니다. node –inspect index.js와 같이 inspect라는 옵션을 사용해서 Node.js를 실행시키고 브라우저의 관리자 도구를 열면 초록색 Node.js 버튼을 볼 수 있습니다. 저는 주로 크롬의 인스펙트 메뉴를 활용합니다. 이 메뉴에는 현재 실행 중인 로컬 서버들의 목록을 볼 수 있고 원하는 서버를 선택하여 인스펙트 창을 열어 사용합니다.
디버깅을 위해 인스펙트 창을 열면 아래와 같은 창이 뜰 겁니다. 좌측 패널을 보면 동그라미 두개가 겹쳐진 모양의 프로파일링 녹화 버튼이 있습니다. 특정 구간의 메모리 사용량 등을 확인하기 위해 녹화를 시작하고 종료해야 하는데, 이 버튼으로 녹화의 시작과 종료를 할 수 있습니다.
그 옆에 금지 모양의 버튼과 아래에는 녹화가 끝난 프로파일링 결과 파일 목록이 있습니다. 금지 모양의 버튼은 프로파일링 녹화의 결과 파일 목록을 모두 삭제하는 버튼입니다.
마지막으로 쓰레기통 모양의 버튼이 있고 이는 수동으로 가비지 컬렉터를 작동시키는 버튼입니다. 보통 메모리 프로파일링을 하기 전에 가비지 컬렉터를 동작시켜 안정화를 시킨 다음에 프로파일링을 하는 방식으로 사용합니다.
다음으로 가장 중요한 영역을 살펴보겠습니다. 크롬에서는 세 가지 프로파일링 타입을 지원합니다. 첫 번째는 힙 스냅샷입니다. 지금 이 순간의 힙 메모리 사용량을 기록하는 타입입니다. 이 타입을 선택하면 아래에 take snapshot 버튼이 활성화됩니다. 버튼을 누르면 그 순간부터 힙 메모리를 기억하게 됩니다. 내가 정말 잘짠 코드가 있어서 메모리적으로나 성능적으로 개선한 부분이 있다면 그 전후를 기록하면 두 가지 경우를 비교할 수 있습니다. 즉, 어떤 부분에서 메모리 누수가 발생하는지 정확히 알고 있을 때 해당 부분을 디버깅할 수 있습니다.
두 번째 타입은 많이 사용하는 유용한 기능입니다. 주기적으로 힙 메모리를 기록하고 녹화하는 동안 힙 메모리를 얼마나 쓰고 있는지 그래프로 보여주는 타입입니다. 메모리 누수를 깨닫고 디버깅하는 경우 타임라인을 통해 시간이 지나면서 메모리 사용량이 어떻게 변하는지를 살펴볼 때 사용할 수 있습니다. 보통 메모리 누수가 어디서 발생하는지 알기 쉽지 않습니다. 이럴 때 사용하면 유용한 기능입니다.
마지막 타입은 샘플링 타입입니다. 두 번째 타입과 비슷하지만 훨씬 긴 시간을 녹화해야 하는 경우에 주로 사용합니다. 모든 순간을 다 기록하면 오버헤드가 발생할 수 있기 때문에 좀 더 긴 시간 동안 샘플링을 한 정보로 디버깅하는 방식입니다. 녹화 버튼을 누르면 변화가 없어 보이지만 기록 중인 상태가 되고, 녹화를 종료하면 샘플링된 정보를 보여줍니다.
세 가지 타입의 디버깅 방법에 대해 간단하게 알아봤습니다. 각자의 경우에 맞게 타입을 선택해서 사용하면 되지만, 보통 두 번째 타입을 많이 사용하게 됩니다. 메모리 누수 에러를 만났을 때 두 번째 타입으로 먼저 디버깅을 하면 문제점을 빠르게 확인할 수 있을 것입니다.
디버깅을 시작하면 앞선 2번째 디버깅 방법의 설명처럼 상단에 그래프가 나타납니다. 요청을 보내는 동안 Node.js에서 힙 메모리를 얼마나 사용하는지 나타내는 그래프입니다.
위 그래프의 높이는 그 순간에 힙메모리에 할당된 총량을 뜻합니다. 회색 부분은 가비지 콜렉터가 메모리를 수거한 부분이고, 파란색은 힙 메모리를 차지하고 있는 부분입니다. 즉, 파란색이 많으면 누수가 많다고 할 수 있습니다. 그 순간에 회색 부분이 많다면 메모리 누수를 걱정할 필요가 없다는 뜻입니다.
그래프 영역을 드래그하면 일부 구간만 선택해서 볼 수도 있습니다. 먼저 전체 영역을 확인하고 파란색 그래프가 있는 영역을 선택해서 보면 교집합처럼 공통으로 보이는 객체들이 있을 것입니다. 이런 부분들을 집중적으로 디버깅하면 시간을 단축시킬 수 있습니다.
그래프를 통해 어느 시점에 메모리 누수가 발생했는지를 쉽게 알 수 있는데, 누가 메모리 누수의 범인인지는 그래프가 알려주지 않습니다. 범인을 찾기 위해서 알아야 할 지식들이 많이 있지만, 아래의 내용은 꼭 알아두어야 합니다.
바로 쉘로우 사이즈와 리테인드 사이즈입니다. 쉘로우 사이즈는 객체 자신의 크기를 bytes로 표시한 것이고, 리테인드 사이즈는 객체 자신이 존재하기 위해 자신이 참조하고 있는 모든 객체들을 다 더한 크기입니다. 추가적으로 디스턴스라는 지표도 있는데, 이 지표는 가비지 콜렉터의 루트로부터 얼마나 떨어져 있는지 나타내는 값입니다. 이 값이 크면 메모리 누수가 일어날 가능성이 높다고 유추할 수 있습니다. 정확하게 디버깅하는 지표라기보다는 간단하게 참고 지표로 활용하시면 좋을 것 같습니다.
앞선 예제 코드에서 전역 변수에 선언한 listItems를 함수 안에서 참조하고 있었습니다. 가비지 콜렉터는 이 변수를 수거하지 못하고 힙 메모리를 계속 차지하게 됩니다. 함수 자체는 간단한 코드이지만 실제 실행 컨텍스트 안에서 이 변수는 아주 긴 길이를 가지기 때문에 힙 메모리가 굉장히 많이 필요한 상황이 발생합니다. 다시 말해 쉘로우 사이즈 크기에 비해 리테인드 사이즈가 굉장히 크다고 할 수 있습니다. 이처럼 쉘로우 사이즈보다 리테인드 사이즈가 굉장히 큰 객체나 변수를 집중적으로 디버깅하면 됩니다.
앞서 작성한 코드의 인스펙트 메뉴에서 리테인드 사이즈를 내림차순으로 정렬하고 살펴보면 제가 작성한 memoryLeakFunction을 볼 수 있습니다. 이 객체를 선택하고 아래쪽을 보면 Retainers 탭을 통해 이 객체가 어떤 참조 순서로 힙 메모리에 할당되었는지 알 수 있습니다. 파일명을 눌러 어떤 파일의 어떤 코드인지 확인할 수도 있습니다. 또 내가 사용한 특정 라이브러리의 경로도 알 수 있습니다.
아래로 쭉 넘어오면 listItems가 보입니다. 그림의 오른쪽 아래를 보면 listItems의 쉘로우 사이즈보다 리테인드 사이즈가 굉장히 크다는 걸 알 수 있습니다. 지루한 과정이 될 수도 있지만 이런 객체들을 하나씩 찾고 수정하면서 이 수치를 줄여가면 메모리 누수 범인을 찾고 이를 해결할 수 있게 됩니다.
아래 그림은 제가 실제로 경험한 인스펙트 화면입니다. 불필요한 부분을 제외하고 보니 아래와 같은 부분을 찾았습니다. 보통은 node_modules라는 경로로 보일 텐데 저의 경우 yarn berry로 노드 패키지 관리를 하기 때문에 “.yarn” 라는 경로를 볼 수 있습니다. 이 경로의 어떤 파일인지 확인할 수 있었고, 이 파일에서 메모리 누수가 발생했다는 것을 확인하고 수정하여 배포했더니 메모리 누수를 해결할 수 있었습니다.
배포 이전 그래프는 앞서 설명드린 산 모양의 그래프였는데, 배포 이후에는 아래처럼 잔잔한 그래프로 바뀐 것을 알 수 있습니다. 그 뒤에도 메모리 누수가 없는 상태를 계속 유지하고 있습니다.
마지막으로 소개하고 싶은 키워드가 있습니다. 바로 using이라는 키워드입니다. C#을 사용해 봤다면 익숙할 것 같습니다. 파이썬에서도 비슷한 개념이 있습니다. 최근 타입 스크립트 5.2 버전이 공지되면서 많은 분들이 봤겠지만 사실 새로운 개념은 아닙니다.
C#에서는 이미 있는 개념이고, 자바스크립트 TC39 (자바 스크립트 표준을 관리하는 그룹) 에서 이미 스테이지 3까지 진행된 상태이기 때문에 어쩌면 곧 만나볼 수도 있을 것 같습니다.
간단하게 설명하면 기존에 변수를 사용할 때 선언하던 var, let, const 대신에 using을 선언하면 이 변수의 스코프 끝에서 Symbol.dispose()를 만들어두고 이를 호출하여 클린업을 할 수 있습니다. 선언된 이벤트 리스너를 제거할 수도 있고, DB 연결을 했다면 이 연결을 해제할 수도 있습니다. 또, 스트림을 연결을 했다가 끊어야 하는 경우도 이를 활용해 라이프 사이클을 관리할 수 있습니다.
앞선 예제 코드를 using을 사용해서 바꿔 보면 아래와 같습니다. const 대신 using을 선언했고, using을 사용하기 위해 Disposable을 implements 했습니다. 함수의 내용은 동일하고 마지막 부분의 dispose를 통해 클린업하는 부분을 추가했습니다.
이 코드를 실행시키면 아까와는 다르게 힙 메모리가 일정하게 유지되는 것을 볼 수 있습니다. 이런 과정을 통해 using을 활용하면 메모리 누수가 없는 코드를 작성할 수 있을 것 같습니다.
지금까지 설명한 것들을 요약하면서 글을 마무리하겠습니다. 서버 환경과 클라이언트 환경을 나눠서 디버깅하는 방법, 서버 환경에서 인스펙트 옵션을 사용하고 타임라인으로 힙메모리 사용량을 프로파일링 하는 방법, 쉘로우 사이즈와 리테인드 사이즈의 크기를 통해 메모리 누수의 원인인 객체를 찾는 방법, 그리고 마지막으로 using을 사용하여 메모리 누수를 방지하는 방법 등 을 알아봤습니다.
현업에서 메모리 누수를 경험할 때 오늘 설명드린 방법을 통해 디버깅해보고, 원인을 찾아 성능을 개선해 볼 수 있는 기회가 됐으면 좋겠습니다.
감사합니다.
요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.