Home > AI > AI-Pairing > AI 에이전트 72개로 메모리 누수를 감사하고, 그 결과를 다시 검증했다

AI 에이전트 72개로 메모리 누수를 감사하고, 그 결과를 다시 검증했다
AI Claude Code React 메모리누수

문제

오래 켜두는 SPA에서 메모리 누수는 재현이 어렵다. 특정 화면을 열고 닫기를 수십 번 반복해야 드러나고, 그때는 이미 어느 컴포넌트 탓인지 특정하기 어렵다. 그렇다고 수백 개 컴포넌트를 사람이 하나씩 훑는 건 현실적이지 않다.

그래서 접근을 바꿨다. 병렬 에이전트 72개를 띄워 코드베이스를 영역별로 나눠 감사시켰다. 각 에이전트에게 “이 디렉토리에서 cleanup이 누락된 지점을 찾아라”라는 과제를 준 것이다.

결과와, 그 결과를 믿지 않은 이유

감사 결과 누수 후보 51건이 나왔다. 여기서 바로 고치기 시작하지 않았다.

AI 감사는 재현율은 높지만 정밀도는 보장되지 않는다. “cleanup이 없다”는 지적이 실제로는 해당 리소스가 컴포넌트 수명과 무관하거나, 이미 상위에서 정리되고 있거나, 애초에 오탐인 경우가 섞여 있다. 그래서 51건 전부를 독립 검증 단계에 태웠다. 지적한 에이전트와 다른 컨텍스트에서 실제 코드 경로를 따라가며 “이게 정말 누수인가”를 확인하는 게이트다.

검증을 통과한 49건을 적용했고, 1건은 보류했다. 보류한 건 redux-persist whitelist 관련 항목이었는데, 고치면 메모리는 줄지만 사용자 데이터가 유실될 위험이 있었다. 나머지는 검증 과정에서 걸러졌다.

발견된 누수의 유형

심각도를 H(High)/M(Medium)/L(Low)로 라벨링해 정리했다.

등급 유형 대표 사례
H 차트 인스턴스 미해제 ECharts dispose() 누락
H D3 tooltip orphan 빈 데이터 early-return 경로에 tooltip.remove() 누락
M blob URL revoke 누락 파일 다운로드·이미지 프리뷰
M RTK Query 구독 누수 subscribe: false 미적용
M WebSocket 구독 해제 불가 STOMP 구독 핸들 미보관
L 타이머·rAF 핸들 방치 setTimeout / requestAnimationFrame
L 무한 증가 배열 알림 목록에 cap 없음
L 불필요한 깊은 복사 Immer reducer 내 cloneDeep

몇 가지는 조금 더 볼 만하다.

D3 tooltip orphan

D3로 그린 차트에서 tooltip은 보통 body 밑에 append된다. 컴포넌트가 언마운트돼도 이 DOM 노드는 남는다. 정상 렌더 경로에는 cleanup이 있었는데, 빈 데이터일 때 early-return하는 분기에는 없었다. 데이터가 비었다가 채워지기를 반복하는 화면에서 tooltip 노드가 계속 쌓인다.

WebSocket 구독을 해제할 수가 없었다

STOMP 구독 코드가 이렇게 돼 있었다.

if (stompClient?.connected) {
  stompClient.subscribe(destination, (msg) => entry.callback(msg));
}

subscribe()가 반환하는 핸들을 버리고 있었다. 즉 구독을 해제할 방법이 애초에 존재하지 않았다. 핸들을 보관하고 unsubscribeWs 인프라를 신설했다.

entry.handle = stompClient.subscribe(destination, (msg) => entry.callback(msg));

export const unsubscribeWs = (destination: string) => {
  const index = activeSubscriptions.findIndex((s) => s.destination === destination);
  if (index === -1) return;
  const entry = activeSubscriptions[index];
  entry.handle?.unsubscribe();
  activeSubscriptions.splice(index, 1);
};

같이 나온 게 onDisconnect 가드다. 기존에는 stompClient === client이면 전역 참조를 null로 밀었는데, 자동 재연결 중에도 onDisconnect가 불린다. 재연결로 살아날 클라이언트의 참조를 지워버리면 그 뒤 구독이 전부 허공으로 간다. && !client.active 조건을 붙여 “진짜 끝난 경우”에만 정리하도록 했다.

알림 배열에 cap이 없었다

WebSocket으로 들어오는 알림을 배열 앞에 계속 unshift하고 있었다. 상한이 없으니 세션이 길어질수록 단조 증가한다. 200개 cap을 걸었다.

남는 교훈

가장 중요한 결정은 51건을 찾은 것보다 51건을 그대로 믿지 않은 것이었다.

에이전트를 72개 띄우면 후보는 많이 나온다. 문제는 그중 무엇이 진짜인지 판단하는 비용이 남는다는 점이다. 검증 없이 49건을 한 번에 적용했다면, 오탐을 고치느라 멀쩡한 코드에 불필요한 cleanup을 넣거나 — 보류한 1건처럼 — 데이터 유실을 만들었을 수도 있다.

AI 감사의 산출물은 “고칠 목록”이 아니라 “확인할 목록”이다. 그 사이에 게이트를 두는 것이 이 작업 전체를 실용적으로 만든 부분이었다.