
메모리 누수 찾기
앱을 오래 쓰면 느려지는 이유가 메모리 누수였다. Chrome DevTools Memory 탭으로 범인을 찾는 과정을 정리했다.

앱을 오래 쓰면 느려지는 이유가 메모리 누수였다. Chrome DevTools Memory 탭으로 범인을 찾는 과정을 정리했다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

안드로이드는 Xcode보다 낫다고요? Gradle 지옥에 빠져보면 그 말이 쏙 들어갈 겁니다. minSdkVersion 충돌, Multidex 에러, Namespace 변경(Gradle 8.0), JDK 버전 문제, 그리고 의존성 트리 분석까지 완벽하게 해결해 봅니다.

페이징이 기계적인 난도질이라면, 세그먼테이션은 의미 있는 정리정돈입니다. 코드, 데이터, 스택으로 나눠서 관리하기.

대시보드 페이지를 만들었다. 처음엔 쾌적하게 돌아갔다. 그런데 사용자가 "10분 정도 켜두면 엄청 느려져요"라는 피드백을 보냈다. 새로고침하면 다시 빨라진다. 뭔가 쌓이고 있다는 느낌이 확실했다.
처음엔 React 렌더링 문제인 줄 알았다. React DevTools Profiler를 열어서 확인했는데 렌더링 자체는 문제없었다. 그다음엔 API 호출이 너무 많나 싶어서 Network 탭을 봤는데 이것도 아니었다. 결국 Memory 탭을 열었는데, 거기서 충격적인 걸 봤다.
메모리 사용량이 계속 올라가고 있었다. 처음 로딩할 때 30MB였던 게 10분 후엔 150MB가 넘어가 있었다. Garbage Collector가 정리를 못 하고 있다는 뜻이었다. 내 코드 어딘가에 메모리 누수가 있었다.
메모리 누수를 이해하는 가장 쉬운 비유는 물탱크다. 정상적인 앱은 물을 넣었다가 빼는 과정이 균형을 이룬다. JavaScript의 Garbage Collector가 자동으로 안 쓰는 메모리를 비워준다.
그런데 구멍이 나면 문제가 된다. 물이 계속 새어나가는 게 아니라, 반대로 물이 빠져나가지 못하고 계속 쌓인다. 이게 메모리 누수다. 더 이상 필요 없는 데이터인데, 어딘가에서 참조를 붙잡고 있어서 Garbage Collector가 정리하지 못한다.
내 경우엔 대시보드가 실시간 데이터를 받는 구조였다. WebSocket으로 1초마다 업데이트가 들어왔다. 페이지를 이동해도 WebSocket 연결이 살아있었고, 이벤트 리스너가 계속 쌓이고 있었다. 양동이에 물이 계속 들어오는데 배수구가 막혀있는 상황이었다.
Memory 탭을 처음 열면 세 가지 옵션이 보인다.
나는 Heap snapshot으로 시작했다. 페이지를 열자마자 스냅샷을 하나 찍고, 5분 정도 사용한 뒤 두 번째 스냅샷을 찍었다. 그리고 비교했다.
// 메모리 누수를 일으키던 코드
function DashboardWidget() {
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/live');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
updateChart(data); // 이 함수가 계속 이벤트 리스너를 추가하고 있었다
};
// cleanup이 없다!
// return () => ws.close();
}, []);
return <Chart />;
}
Heap snapshot에서 "Comparison" 뷰로 보면 두 스냅샷 사이에 증가한 객체들이 보인다. 내 경우엔 EventListener 객체가 수백 개 쌓여있었다. Retained Size를 보니까 각각 몇 KB씩 차지하고 있었고, 합치면 수십 MB였다.
범인을 찾고 나니 패턴이 보였다. 내가 겪은 것 외에도 흔한 케이스들이 있었다.
// 나쁜 예
function BadComponent() {
useEffect(() => {
window.addEventListener('resize', handleResize);
// cleanup 없음
}, []);
}
// 좋은 예
function GoodComponent() {
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
}
이게 가장 흔하다. 이벤트 리스너는 등록한 순간부터 계속 메모리에 남는다. 컴포넌트가 unmount되어도 리스너는 남아있고, 그 리스너가 참조하는 모든 데이터도 같이 붙잡혀있다.
// 나쁜 예
function PollingComponent() {
useEffect(() => {
const interval = setInterval(() => {
fetchData();
}, 1000);
// cleanup 없음
}, []);
}
// 좋은 예
function PollingComponent() {
useEffect(() => {
const interval = setInterval(() => {
fetchData();
}, 1000);
return () => clearInterval(interval);
}, []);
}
setInterval이나 setTimeout도 마찬가지다. 타이머가 살아있으면 콜백 함수가 메모리에 남고, 그 함수가 참조하는 모든 것도 남는다.
// 나쁜 예
function SubscriptionComponent() {
useEffect(() => {
const subscription = dataStream.subscribe(data => {
setState(data);
});
// cleanup 없음
}, []);
}
// 좋은 예
function SubscriptionComponent() {
useEffect(() => {
const subscription = dataStream.subscribe(data => {
setState(data);
});
return () => subscription.unsubscribe();
}, []);
}
RxJS나 이벤트 스트림을 쓸 때 자주 나온다. 구독한 건 반드시 해제해야 한다.
// 나쁜 예
function SearchComponent() {
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data));
}, [query]);
}
// 좋은 예
function SearchComponent() {
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name === 'AbortError') return;
console.error(err);
});
return () => controller.abort();
}, [query]);
}
사용자가 빠르게 타이핑하면 fetch 요청이 여러 개 동시에 날아간다. 이전 요청들은 취소해야 하는데 안 하면 메모리에 계속 쌓인다.
이제 메모리 누수가 의심되면 이런 순서로 찾는다.
Retainers를 보면 참조 체인이 나온다. "이 객체를 A가 참조하고, A를 B가 참조하고..." 이런 식으로. 체인을 따라가다 보면 결국 내 코드에 도달한다.
내 경우엔 WebSocket → onmessage handler → updateChart function → Chart instance 이런 체인이었다. WebSocket을 닫지 않아서 핸들러가 살아있고, 핸들러가 Chart를 참조하고 있어서 Chart의 모든 데이터가 메모리에 남아있었다.
React를 쓰면서 배운 예방 패턴들이 있다.
function Component() {
useEffect(() => {
// setup
const resource = createResource();
// cleanup (꼭 넣기)
return () => {
resource.cleanup();
};
}, []);
}
모든 useEffect에 cleanup이 필요한 건 아니지만, 외부 리소스를 건드리면 꼭 필요하다. WebSocket, EventListener, Timer, Subscription 등.
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => controller.abort();
}, [url]);
return data;
}
fetch를 쓸 때마다 AbortController를 만드는 습관을 들였다. 컴포넌트가 unmount되면 요청이 취소되고, 응답이 와도 setState가 안 된다.
// 나쁜 예: 큰 객체를 클로저로 캡처
function Component({ bigData }) {
const handleClick = () => {
console.log('clicked');
// bigData를 안 쓰는데 클로저로 캡처됨
};
useEffect(() => {
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []);
}
// 좋은 예: 필요한 것만 추출
function Component({ bigData }) {
const id = bigData.id; // 필요한 것만
const handleClick = useCallback(() => {
console.log('clicked', id);
}, [id]);
useEffect(() => {
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [handleClick]);
}
클로저는 편하지만 위험하다. 함수를 만들 때 스코프에 있는 모든 변수를 캡처한다. 큰 객체를 통째로 캡처하면 그게 메모리에 계속 남는다.
메모리 누수는 눈에 안 보여서 찾기 어렵다. 하지만 Chrome DevTools Memory 탭을 쓰면 명확하게 보인다. Heap snapshot을 찍고 비교하면 무엇이 쌓이는지 알 수 있다.
대부분의 누수는 cleanup을 안 해서 생긴다. 이벤트 리스너, 타이머, 구독, fetch 요청 등 외부 리소스를 건드리면 꼭 정리해야 한다. React에서는 useEffect cleanup이 그 역할을 한다.
내 대시보드는 WebSocket cleanup을 추가하니까 해결됐다. 메모리 사용량이 30MB에서 안정적으로 유지됐다. 사용자 피드백도 "이제 안 느려져요"로 바뀌었다.
메모리 누수는 한 번 겪으면 패턴이 보인다. 그다음부턴 미리 예방할 수 있다. cleanup을 습관처럼 쓰고, 의심되면 Memory 탭을 열어보면 된다.