게시일: 2026년 10월 29일
Google Chat은 대규모 단일 페이지 애플리케이션입니다. 사용자는 정기적으로 브라우저 탭에서 웹 앱을 한 번에 며칠 또는 몇 주 동안 열어 둡니다. 수명이 긴 애플리케이션에서는 클라이언트 측 메모리 상태가 주요 관심사입니다. 수집되지 않은 클로저와 분리된 DOM 트리가 천천히 쌓여 결국 UI 성능, 가비지 컬렉션 일시중지 또는 제한된 기기의 탭 비정상 종료에 영향을 미칩니다.
이 문제를 해결하기 위해 Google Chat팀은 메모리 문제를 발견하고 해결하기 위한 이니셔티브를 시작했습니다. 이들은 DevTools MCP 서버가 포함된 에이전트용 Chrome DevTools를 사용하여 까다로운 수동 프로세스를 자동화된 워크플로로 전환했습니다.
과제: 500MB의 모래사장에서 바늘 찾기
유출된 객체가 메모리에 유지되는 이유를 정확하게 파악하려면 고도로 축소된 코드를 통해 유지 경로를 추적해야 합니다. 최근 Chrome DevTools 메모리 패널이 개선되어(예: 고급 에지 필터링 및 객체 쿼리) 이 작업이 더 쉬워졌지만, 500MB 스냅샷에서 수동으로 이 작업을 수행하는 것은 여전히 느리고 정신적으로 지칠 수 있습니다.
누수를 해결하려면 두 가지 지식이 필요합니다.
- V8 JavaScript 엔진이 컴파일된 바이트코드를 처리하는 방식을 이해해야 합니다.
- 또한 애플리케이션의 특정 상태 관리 및 구성요소 수명 주기를 이해해야 합니다.
이 두 가지 고유한 지식 집합을 수동으로 연결하려면 일반적으로 누출당 여러 개발자와의 조사 및 공동작업에 며칠이 걸립니다.
해결책: 에이전트를 사용하여 힙 탐색
팀은 대규모 언어 모델이 정형 데이터에 대한 추론에 매우 뛰어나지만, 토큰 소비를 합리적으로 유지하면서 대규모 파일을 처리하려면 적절한 도구가 필요하다는 것을 깨달았습니다. 메모리 도구가 사용 설정된 오픈소스 Chrome DevTools MCP 서버 (--memoryDebugging=true)에 Google Antigravity를 연결하여 에이전트가 V8 힙에 직접 프로그래매틱 방식으로 액세스할 수 있도록 했습니다.
개발자가 Chrome DevTools 메모리 패널에서 리테이너 트리를 수동으로 확장하는 대신 에이전트가 스냅샷을 직접 쿼리했습니다. 팀은 간단한 프롬프트를 사용하여 프로세스를 시작했습니다.
Discover memory leaks in the heap snapshot chat.heapsnapshot using Chrome DevTools memory tools.
에이전트는 메모리 디버깅 기술을 사용하여 특정 MCP 도구를 사용하는 구조화된 워크플로를 실행했습니다.
- 스냅샷 캡처 또는 로드:
take_heapsnapshot와 같은 도구를 사용하거나 기존 파일을 읽습니다. - 힙 구조 분석:
get_heapsnapshot_details및compare_heapsnapshots를 사용하여 일반적인 누출 소스를 검색했습니다. - 유지 경로 추적:
get_heapsnapshot_retaining_paths를 사용하여 도미네이터 트리를 가비지 컬렉션 루트로 다시 이동했습니다. - 수정사항 생성: 유지 경로를 애플리케이션의 소스 코드와 교차 참조하여 매우 구체적인 패치를 출력했습니다.
기술적 통계: 세 가지 일반적인 메모리 누수 패턴
에이전트는 30개가 넘는 유출을 식별했습니다. 다음 세 가지는 에이전트가 발견한 가장 영향력 있는 패턴으로, 최신 웹 애플리케이션에서 가장 큰 메모리 절감 효과와 가장 일반적인 아키텍처 안티패턴을 나타냅니다.
정적 오류 스택 트레이스 트랩
에이전트는 가장 큰 단일 누수가 약 20MB의 메모리를 영구적으로 유지하는 사전 할당된 오류 객체와 관련이 있음을 확인했습니다. 예를 들어 단일 정적 오류 상수 (EMPTY_KEY_ERROR)는 자체적으로 14.1MB를 유지했습니다.
문제: V8과 같은 최신 JavaScript 엔진에서 Error 객체를 만들면 디버깅에 도움이 되도록 현재 호출 스택의 스냅샷이 캡처됩니다. 이 메커니즘으로 인해 대규모 Retained Size가 발생했습니다.
- 오류는 전역 상수였습니다: 초기 모듈 평가 중에 인스턴스화되었습니다.
- JavaScript 엔진이 스택을 캡처함: 당시 스택의 활성 함수는 애플리케이션의 기본 초기화 래퍼 (즉시 호출 함수 표현식 또는 IIFE)였습니다.
- 배열이 메모리를 가둠: 애플리케이션이 오류를 가비지 컬렉션하지 않았기 때문에 내부 스택 트레이스 배열이 기본 번들의 초기화 함수에 대한 영구 참조를 보유했습니다.
// Ineffective pattern: Instantiating Error objects at module evaluation time
export const EMPTY_KEY_ERROR = new Error("Key cannot be empty");
export function validateKey(key: string) {
if (!key) {
throw EMPTY_KEY_ERROR;
}
}
해결 방법: 팀은 오류가 발생할 때 동적으로 오류를 생성하도록 코드를 변경했습니다.
재사용 가능한 정적 오류가 반드시 필요한 경우 스택을 명시적으로 지우면(EMPTY_KEY_ERROR.stack = '';) 캡처된 V8 내부 프레임이 즉시 확보됩니다.
// Recommended: Construct errors dynamically
export function validateKey(key: string) {
if (!key) {
throw new Error("Key cannot be empty");
}
}
// Or clear the stack property if a static instance is necessary:
export const REUSABLE_ERROR = new Error("Static error");
REUSABLE_ERROR.stack = ""; // Releases the captured initialization frame
전역 기간 이벤트 리스너 확장
에이전트가 visibilitychange 이벤트 리스너와 관련된 메모리 확장 문제를 발견했습니다. 이는 엄격한 '누수'는 아니지만(구성요소가 마운트 해제될 때 결국 정리되므로) 화면의 모든 메시지에 리스너가 하나씩 있었기 때문에 메모리 사용량이 증가했습니다. 스냅샷에는 1,142개의 기본 제공 이벤트 리스너가 표시되었으며, 128개의 활성 리스너가 전역 창 객체에 직접 연결되어 있었습니다.
문제: JavaScript에서 이벤트 리스너 콜백은 클로저입니다. 클로저는 필요한 변수에 계속 액세스할 수 있도록 둘러싸는 어휘 범위에 대한 참조를 유지합니다. 에이전트는 이러한 리스너 중 126개를 단일 구성요소인 RelativeTimeController로 추적했습니다. Chat UI의 각 타임스탬프(예: '5분 전')는 자체 컨트롤러를 관리했으며, 이 컨트롤러는 비공개 공개 상태 리스너를 등록하여 시간을 업데이트했습니다. 각 리스너가 컨트롤러 인스턴스를 창 루트에 고정했기 때문에 활성 클로저의 밀집된 웹이 표시되는 채팅 대화목록의 수명 동안 상당한 메모리를 차지했습니다.
// Ineffective pattern: Attaching global window listeners in individual UI controllers
export class RelativeTimeController {
constructor() {
this.visibilityListener = () => this.updateTime();
window.addEventListener("visibilitychange", this.visibilityListener);
}
// If dispose() fails to run or unregister, the closure pins the controller
}
해결 방법: 팀에서 전역 창 이벤트를 중앙 집중화했습니다. 이제 공유 TimerService가 창에 정확히 하나의 공개 상태 리스너를 등록하고 모든 활성 타임스탬프 컨트롤러에 업데이트를 브로드캐스트합니다. 이렇게 하면 126개의 리스너가 정확히 1개로 즉시 축소됩니다.
// Recommended: Centralized event coordination using a shared service
export class VisibilityService {
private listeners = new Set<() => void>();
constructor() {
// Single global listener throttled for the entire application
window.addEventListener("visibilitychange", () => this.notifyAll());
}
subscribe(callback: () => void): () => void {
this.listeners.add(callback);
return () => this.listeners.delete(callback); // Unsubscribe handle
}
}
서드 파티 SDK 및 삽입된 iframe 리테이너
분리된 DOM 노드는 문서 트리에서 삭제되었지만 여전히 JavaScript 변수에서 참조되는 요소입니다. 분리된 DOM 트리의 단일 요소가 루트 변수에 의해 유지되는 경우 분리된 전체 트리를 회수할 수 없습니다.
문제: 사용자가 채팅 스페이스 간에 이동할 때 에이전트는 분리된 전체 DOM 트리가 메모리에 남아 있음을 발견했습니다. 이 도구는 채팅 스페이스 UI 내에 삽입된 미디어 플레이어로 다시 연결되는 유지 경로를 추적했습니다. 채팅 공간이 마운트 해제되면 UI 구성요소가 페이지에서 삭제되었지만 기본 미디어 플레이어 SDK는 명시적으로 폐기되지 않았습니다. 서드 파티 라이브러리는 활성 iframe ID의 내부 전역 사전을 유지하여 분리된 DOM을 활성 상태로 유지했습니다.
해결 방법: 모든 삽입된 iframe과 서드 파티 위젯이 구성요소 폐기 수명 주기에 연결되어 있는지 확인합니다. 플레이어에서 .destroy() 메서드를 명시적으로 호출하면 전역 SDK 레지스트리에서 항목이 삭제되고 DOM이 가비지 컬렉션될 수 있습니다.
// Recommended: Explicit teardown of embedded frames and SDK instances
export class MediaPlayerController {
private player: ThirdPartyPlayer | null = null;
dispose(): void {
if (this.player) {
this.player.destroy(); // Clears entry from global SDK registry
this.player = null;
}
}
}
추가 패턴
앞서 언급한 세 가지 패턴이 절약된 원시 메가바이트 측면에서 가장 큰 영향을 미쳤지만, 에이전트는 27개 이상의 다른 누수도 감지하고 수정했습니다. 여기에는 다음이 포함됩니다.
- 이미지 배열 원격 분석: 스크롤 없이 볼 수 있는 부분의 이미지 측정항목을 추적하는 맞춤 진단 배열에 의해 분리된 DOM 트리가 유지되는 경우
- 프레임워크 구성요소 수명 주기 버그: 문서 본문으로 호이스팅된 맞춤 드롭다운 메뉴가 원래 상위 요소의 마운트 해제를 우회하는 버그
- 암시적 인수 객체 클로저 트랩: 내부 클로저가 의도치 않게 외부 함수의 전체 인수 배열을 캡처하는 경우
영향: 워크플로로서의 사전 예방적 메모리 관리
Google Chat팀은 코딩 에이전트에 에이전트용 Chrome DevTools를 장착하여 고도로 전문화된 작업을 자동화했습니다. 복잡한 메모리 누수를 추적하는 데 걸리는 시간이 며칠 동안 수동으로 힙을 순회하는 것에서 몇 분 만에 자동 분석하는 것으로 줄었습니다.
P2ER이 UI 안정성을 위해 '필수 경험적 검증'을 구현한 것과 마찬가지로, 팀은 이 메모리 감사 접근 방식을 사전 예방적 가드레일로 채택할 수 있습니다. 개발자는 메모리 문제가 매우 크거나 복잡한 애플리케이션에서만 발생한다고 가정하여 메모리 문제를 무시하는 경우가 많습니다. 하지만 이 우수사례에서 볼 수 있듯이 표준 이벤트 리스너나 정적 오류와 같은 일반적인 패턴은 조용히 누적될 수 있습니다. 에이전트용 Chrome DevTools와 같은 도구를 일상적인 개발 주기에 통합하면 AI가 기본적인 코드 생성기에서 프로덕션에 도달하기 전에 조용한 회귀를 포착하는 신뢰할 수 있는 품질 게이트로 변환됩니다.