AI

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

문제

  • 오래 켜두는 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의 append 위치 — 보통 body
  • 그 결과 컴포넌트가 언마운트돼도 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 감사의 산출물은 “고칠 목록”이 아니라 “확인할 목록”이다. 그 사이에 게이트를 두는 것이 이 작업 전체를 실용적으로 만든 부분이었다.