저는 최근 참여하고 있는 프로젝트에서 Next.js를 사용하고 있어요. 그런데, 개발을 하던 중 Node.js 서버가 종료되면서 터미널에 아래와 같은 메세지가 나타났습니다.
에러 메세지를 확인해 보니 OOM(Out Of Memory)이 발생한 것을 알 수 있었어요. OOM은 무엇이며 어떻게 해결할 수 있을까요?
이번 포스트에서는 메모리 누수에 대해 알아보고, 이를 디버깅하는 방법에 대해 이야기해 볼게요!
다른 이야기지만, 혹시 사용하지 않는 물건이 방을 차지하고 있다면 신경 쓰이지 않나요? 저는 (아주 조금) 신경이 쓰여서 정리를 자주 하는 편이에요. 이처럼, 메모리 누수는 사용하지 않는 값(물건)이 메모리(공간)을 차지하는 것을 의미해요.
메모리 누수에 대해 자세히 이야기하기 전에, 자바스크립트에서 값이 어떻게 메모리에 할당되고 해제되는지 간단하게 알아볼까요?
자바스크립트의 원시 값은 7개의 종류가 있어요.
원시 값은 실행 컨텍스트가 생성되는 시점에서 스택이라는 메모리 공간에 생성돼요.
원시 값은 실행 컨텍스트가 종료되면 자동으로 메모리가 해제되기 때문에 가비지 컬렉션이 필요하지 않아요.
자바스크립트의 참조 값은 원시 값 이외의 모든 객체를 의미해요.
참조 값은 힙이라는 메모리 공간에 생성된 후 메모리 주소를 참조해요.
여러 실행 컨텍스트에서 참조 값이 공유될 수 있기 때문에, 가비지 컬렉션은 참조 값의 메모리를 관리해 주는 역할을 합니다.
메모리 누수가 발생했다는 것은, 가비지 컬렉션이 (더 이상 사용하지 않는) 참조 값의 메모리를 제대로 관리하지 못했다는 것을 의미해요.
가비지 컬렉션은 아래와 같은 상황에서 메모리를 해제하지 못한다는 특징이 있어요.
이 글에서는 위와 같은 상황에서 왜 메모리를 해제하지 못하는지 설명하지 않습니다. 자세한 내용은 이 블로그를 참고해 주세요!
참조 값을 저장하는 힙 내부는 아래와 같이 구성되어 있어요. 현실 세계의 힙은 조금 더 복잡하지만, 여기서는 Young Generation과 Old Generation 이렇게 두 가지만 살펴보도록 할게요.
객체가 최초로 생성되면 Young Generation의 Nursery에 저장됩니다. 대부분의 객체는 생성된 후 금방 사라지기 때문에, Young Generation의 공간은 Old Generation에 비해 더 작고 내부에서 가비지 컬렉션도 더 자주 이루어져요.
가비지 컬렉션 이후 Nursery에서 살아남은 객체는 Intermediate로 복사됩니다. 이때, Young Generation의 공간은 항상 절반이 비워져 있어야 한다는 특징 때문에 Nursery 공간은 비워지게 돼요. 이후 Nursery와 Intermediate 공간은 교체되어 다음 가비지 컬렉션 주기에 같은 과정이 반복됩니다.
이렇게 Young Generation에서 가비지 컬렉션을 할 때 Scavenge 알고리즘을 사용해요. Scavenge 알고리즘은 속도가 비교적 빠르고 작은 크기의 메모리를 수집하는데 효과적입니다.
Young Generation에서 살아남은 객체는 Old Generation으로 복사됩니다. Old Generation은 Young Generation보다 큰 공간을 가지고 있으며, 가비지 컬렉션을 할 때 Mark and Sweep 알고리즘을 사용해요.
Old Generation에서 많은 양의 메모리 누수가 발생하면 Node.js 서버가 다운됩니다.
Old Generation의 공간을 더 크게 늘려서 임시로 해결할 수 있어요. 하지만 이 방법은 메모리 누수가 누적될수록 한계가 있어 보이네요.
메모리 누수가 발생하지 않도록 코드를 개선하는 것이 근본적인 해결 방법인 것 같습니다. 그렇다면 메모리 누수는 어떻게 디버깅을 해야 할까요?
FEConf 2023에서 배운 메모리 누수 디버깅 방법을 사용해 볼 거예요. 크롬 개발자 도구의 Memory 탭을 이용하면 메모리 누수를 쉽게 디버깅할 수 있어요.
메모리 프로파일링 옵션은 세 가지가 있어요.
1. Heap snapshot
현재 페이지의 힙 상태를 기록하고 분석할 수 있어요. 성능 개선 전후로 스냅샷을 비교할 때 유용해요.
2. Allocation instrumentation on timeline
메모리 누수가 의심되는 시나리오를 수행하여 메모리 상태를 기록할 수 있어요. 기록을 진행하는 동안 타임라인에 메모리 할당과 해제가 표시돼요. 이를 분석하여 메모리 누수를 디버깅할 수 있어요.
3. Allocation sampling
Allocation instrumentation on timeline과 비슷한 방식으로 기록하지만, Allocation sampling은 메모리 할당을 함수 단위로 간단하게 기록할 수 있어요.
저는 Allocation instrumentation on timeline과 Allocation sampling을 이용하여 메모리 누수를 디버깅해 보았습니다.
가장 큰 문제는 간헐적으로 Node.js 서버가 다운되는 현상이었어요. 정확히 어느 시점에서 OOM이 발생하는지 확인하기 어려워 삽질을 하던 중, 페이지를 이동할 때 서버가 다운되었다는 파트원의 제보를 받아 페이지 이동 시 메모리 상태를 확인하기로 했어요.
함수 단위로 메모리 할당을 확인하기 위해 Allocation sampling을 이용해서 디버깅을 해주었어요. 기록 버튼을 클릭한 후, 페이지에서 특정 메뉴로 이동하여 메모리 상태를 기록했어요.
Total Size를 내림차순으로 정렬 후 메모리 상태를 확인해 보니 (anonymous)
가 메모리의 가장 큰 부분을 차지하고 있었어요. (anonymous)
만으로는 어디가 문제인지 알 수 없으니 트리를 타고 쭉 내려가주었습니다.
트리를 타고 내려갔더니 익숙한 컴포넌트들이 보이기 시작했어요. 그중에서 현재 페이지에서 사용하지 않는 파일들도 불러오는 것을 확인할 수 있었어요.
지금 당장 필요한 파일들만 불러오기 위해 Next.js에서 제공하는 dynamic import를 적용해 주었습니다.
개선 전
import UnusedComponent01 from './UnusedComponent01';
import UnusedComponent02 from './UnusedComponent02';
import UnusedComponent03 from './UnusedComponent03';
import UsedComponent from './UsedComponent';
개선 후
const UnusedComponent01 = dynamic(() => import('./UnusedComponent01'));
const UnusedComponent02 = dynamic(() => import('./UnusedComponent02'));
const UnusedComponent03 = dynamic(() => import('./UnusedComponent03'));
const UsedComponent = dynamic(() => import('./UsedComponent'));
다시 동일한 시나리오로 프로파일링을 해보니, (anonymous)
의 Total Size가 크게 줄어들었고 불필요한 파일들을 불러오지 않는 것을 확인할 수 있었어요. 또한 페이지 이동 속도도 개선된 것을 체감할 수 있었습니다.
모처럼 메모리 프로파일링 하는 방법을 배웠으니 다른 기능도 사용해 보았어요.
Allocation instrumentation on timeline 기능을 사용해서 메모리 할당/해제 시점을 확인해 보기로 했습니다.
form에서 데이터를 입력하고 POST 요청이 성공하면 토스트 메세지를 띄우는 동작을 기록해 주었어요. (아래 이미지는 예시입니다.)
(출처: Simple HTML Page with Toast Notifications using Toastr)
Allocation instrumentation on timeline으로 기록하면 막대들이 위아래로 움직이는 것을 볼 수 있어요. 객체에 메모리가 할당되는 순간 파란색 막대가 생기고, 가비지 컬렉션에 의해 메모리가 해제되면 회색 막대로 변경됩니다. 만약 가비지 컬렉션 이후에도 파란색 막대가 남아있다면 메모리 누수가 발생했다는 의미예요.
기록을 마치면 아래 이미지처럼 결과를 확인할 수 있어요.
여기에서 중요한 개념 중 하나인 Shallow Size와 Retained Size를 확인할 수 있어요.
즉, Reatined Size는 (가비지 컬렉션에 의해) 객체가 삭제될 때 확보할 수 있는 메모리의 크기를 의미해요. Retained Size가 큰 객체를 우선적으로 디버깅하는 게 효율적이겠죠? Retained Size를 내림차순으로 정렬해서 메모리를 어떻게 사용하고 있는지 확인해 주었어요.
트리를 타고 내려가면서 확인하던 중, (closure)
에서 익숙한 함수 이름이 보였어요.
openToast
는 토스트 메세지를 노출하는 공통 함수인데요, 이 함수로 인해 클로저가 발생하면서 가비지 컬렉션이 정상적으로 이루어지지 않고 있었어요.
export default function useCustomToast() {
const toast = useToast({
// 커스텀 옵션 설정
});
const openToast = (options) => {
// 커스텀 옵션이 설정된 토스트 노출
return toast(options);
};
return { openToast };
};
(확실하지 않지만) useCustomToast
hook이 openToast
함수를 리턴하면서 종료될 때, 리턴된 openToast
함수가 hook 내부의 toast
객체를 참조하고 있기 때문에 가비지 컬렉션이 되지 않는 것으로 가정했어요.
클로저를 제거하기 위해 useCustomToast
hook을 개선해 주었습니다.
export default function useCustomToast() {
const toast = useToast();
const openToast(options) => {
toast({
// 커스텀 옵션 설정
});
};
return Object.assign(openToast, toast);
}
동일한 시나리오로 기록한 후 결과를 확인해 보았습니다. (closure)
에서 openToast
함수가 제거되었고, 아주 미미하게 Retained Size가 줄어든 것을 확인할 수 있었어요.
언제부턴가 메모리 누수 디버깅은 제 마음속의 숙제였는데요, 이번 기회로 메모리 누수가 발생하는 패턴과 디버깅하는 방법을 배우게 되었습니다.
메모리 누수 디버깅을 하면서 또 알게 된 사실은, 제로-메모리 누수
는 현실적으로 쉽지 않다는 것이었어요. 라이브러리에서 꽤 많은 메모리 누수가 발생하고 있더라구요! 🤔 타겟 유저와 서비스를 사용하는 환경에 따라 라이브러리의 성능도 잘 고려해야 할 것 같아요.
이번에 시도한 성능 개선은 정말 미미하지만, 앞으로 메모리 누수 패턴을 고려해서 개발하면 더 좋은 서비스를 만들 수 있을 것 같습니다. 😊