단순 성능 이슈인 줄 알았는데 구조적 문제였던 AG Grid 렌더링 문제

pyozzi·2025년 4월 30일

최적화

목록 보기
1/1

Angular + AG Grid 실시간 모니터링 화면에서의 메모리 누수와 렌더링 최적화 경험

🧩 문제 발생과 인지 계기

Angular 16 기반의 실시간 모니터링 화면을 개발하면서,
AG Grid 28 버전을 도입해 서버에서 주기적으로(5초 간격) 데이터를 받아와 그리드에 표시하는 구조를 구현했습니다.
각 row마다 다양한 상태를 표현하기 위해 AG Grid의 커스텀 렌더러(ICellRendererAngularComp)도 다수 사용하고 있었습니다.

처음에는 기능 자체는 정상적으로 동작했지만, 시간이 지날수록 브라우저의 메모리 사용량이 증가하고,
UI 반응 속도가 느려지는 현상이 발생하기 시작했습니다. 크롬 개발자 도구의 Performance 탭과 Memory snapshot을 분석한 결과
다음과 같은 문제가 발견되었습니다

Detached <div _ngcontent-wwq-c198="" class="mat-tooltip-trigger text-cell-with-icon" style="cursor: pointer;">
Detached <app-ip-port-country-renderer _nghost-wwq-c198="" class="ng-star-inserted" style="">
Detached <div _ngcontent-wwq-c198="" class="mat-tooltip-trigger text-cell-with-icon" style="cursor: pointer;">
Detached <img _ngcontent-wwq-c198="" src="assets/images/country/--.png" class="ng-star-inserted">
Detached <div comp-id="855718" style="top: 270px" row-index="10" class="ag-row-even ag-row-no-focus ag-row ag-row-level-0 ag-row-position-absolute" aria-rowindex="12" aria-label="Press SPACE to select this row.
Detached <div comp-id="855660" class="ag-cell-value ag-cell ag-cell-not-inline-editing ag-cell-normal-height" aria-colindex="6" col-id="dst_ip" role="gridcell" style="left: 626.233px; width: 125px;“
  • Angular의 커스텀 renderer 컴포넌트 인스턴스가 GC 후에도 여전히 메모리에 남아 있었고,
  • Detached DOM, Orphaned subscription 등의 흔적이 누적되고 있었습니다.

즉, 시간이 지날수록 성능 저하와 메모리 누수가 동시에 발생하고 있는 구조였습니다.


🔍 문제 해결을 위한 분석과 시도

1. AG Grid 버전 불일치 및 설정 미스 확인

먼저 AG Grid 관련 모듈들을 확인했을 때,
ag-grid-community와 ag-grid-angular의 버전이 서로 다르게 설치되어 있었습니다 (28.0.x vs 28.2.x).

이는 AG Grid 내부의 renderer 관리 및 Angular wrapper와의 lifecycle 연동에서 충돌을 유발했고,
특히 immutableData: true를 설정했지만 getRowId()를 설정하지 않아,
AG Grid가 매번 모든 row를 삭제 후 재생성하는 방식으로 동작하게 되어 있었던 것이 확인되었습니다.

이로 인해

  • Angular 컴포넌트가 제대로 파괴되지 않고 메모리에 잔류
  • ngOnDestroy()가 호출되지 않아 setInterval, subscribe() 등 부작용 로직이 쌓임

메모리 누수의 주요 원인

2. 커스텀 renderer 내부 문제 분석

문제의 핵심은 단순히 AG Grid의 row lifecycle이 아니라,
Angular의 change detection과 커스텀 renderer 간의 불일치였습니다.

renderer 내부에는 다음과 같은 코드가 다수 존재했는데

ngOnInit() {
  this.subscription = this.translateService.get(...).subscribe(...);
  this.timer = setInterval(() => this.updateStatus(), 1000);
}

ngOnDestroy() {
  this.subscription.unsubscribe();
  clearInterval(this.timer);
}
  • refresh()를 구현하지 않아 renderer가 매번 새로 attach되고,
  • 버전 불일치 + getRowId 누락으로 인해 renderer가 재사용되지 않았으며,
  • 심지어 ngOnDestroy()도 호출되지 않는 경우가 다수 발생

이러한 문제를 확인하기 위해

  • rowNode.__renderedCellComponents를 console로 추적
  • Chrome Memory Snapshot 비교로 ngOnDestroy() 미호출 인스턴스 확인
  • 수천 개의 renderer 인스턴스가 남아있는 상태를 발견

3. 해결을 위한 구조적 개선 시도

✅ (1) AG Grid 설정 정비

  • 모든 AG Grid 관련 패키지를 28.2.1로 버전 일치
  • getRowId를 명시하여 row identity 기준을 고정
gridOptions = {
  immutableData: true,
  getRowId: params => params.data.id
};

➡️ AG Grid가 row 단위로 diff patch만 수행하도록 유도

✅ (2) refresh() 구현 및 컴포넌트 수명주기 정비

  • 모든 커스텀 renderer에 refresh()를 구현하여 인스턴스를 재사용하도록 구조화
refresh(params: ICellRendererParams): boolean {
  this.params = params;
  this.updateView();
  return true;
}
  • 동시에 ngOnDestroy()에서 구독 해제, 타이머 정리 등 필수 cleanup 수행

✅ (3) 문제는 남아 있었다 — Angular의 Change Detection 비용
비록 AG Grid는 renderer 인스턴스를 재사용하게 되었지만,
Angular 측에서는 rowData = [...newData] 식으로 매번 새로운 배열이 들어오고 있었고,
이로 인해 Angular는 여전히 매번 change detection을 수행했습니다.

결과적으로

  • renderer의 @Input() params가 매번 새로 들어옴
  • ngOnChanges() 또는 기타 detection hook이 계속 실행
  • 내부 로직 (translate.get().subscribe(), DOM 업데이트)도 매번 재실행됨

➡️ 렌더링 성능 문제와 FPS 저하 현상은 여전히 존재

✅ 최종 해결: Angular 16의 signal() + TanStack Query 기반 상태 구조 개선

▶ signal의 도입
Angular 16에서 새로 도입된 signal()을 사용해 rowData를 선언적 상태로 관리하되,
동일한 데이터일 경우 불필요한 상태 전달이 발생하지 않도록 guard했습니다.

readonly rowData = signal<any[]>([]);

effect(() => {
  const fetched = this.query.data;
  if (!deepEqual(fetched, this.rowData())) {
    this.rowData.set(fetched);
  }
});

→ 결과적으로 Angular는 rowData의 참조가 바뀌지 않으면 change detection을 skip
→ renderer에 불필요한 @Input() 전달이 일어나지 않음
→ 렌더링 비용과 side effect 재실행 감소

▶ TanStack Query와의 연계
기존에는 setInterval로 5초마다 무조건 API를 호출했지만,
@tanstack/query를 도입하여 데이터가 바뀐 경우에만 fetch되도록 개선했습니다.

this.query = useQuery({
  queryKey: ['monitoringData'],
  queryFn: () => this.api.getData(),
  refetchInterval: 5000,
  staleTime: 4000,
});
  • 서버 fetch 비용 절감
  • 캐시 기반 상태 갱신으로 network/CPU 부담 완화
  • signal과 연계하여 Angular 전체 상태 전달 안정화

🎉 결과 및 성능 개선 효과

  • 개선전

  • 개선후

-> data node 및 js 힙이 일정하게 유지됨

항목개선 전 (24시간 기준)개선 후 (24시간 기준)
renderer 인스턴스 수약 86만 개 이상 생성 누적 (GC 누락 포함)약 ±2,000개 이내에서 일정 유지 (GC 후 안정)
Angular Change Detection 발생 횟수약 760,000회 이상 (매 요청마다 전체 발생)약 60,000~80,000회 (변경 시에만 감지됨)
API 호출 횟수17,280회약 7,000~9,000회 (데이터 변경 발생 시에만)
Memory Heap (Chrome 기준)150MB → 1GB 이상 (Heap Snapshot 증가 추세)150MB → 250~300MB 수준에서 안정 유지

📌 시나리오 가정:
실시간 모니터링 시스템에서 매 5초마다 1,000개의 row 데이터를 갱신하는 Angular + AG Grid 화면을 24시간 동안 연속 실행했을 때의 수치를 기반으로 성능 비교를 정리했습니다. 실제 프로젝트와 유사한 조건에서 DevTools와 로그 기반으로 측정한 수치입니다.

🧪 측정 방법 및 기준

  • 측정 도구: Chrome DevTools → Memory Snapshot, Performance Timeline

  • 시나리오 기반 측정 조건
    데이터 건수: 1000건
    호출 주기: 5초
    총 시간: 24시간 = 86,400초
    총 요청 횟수: 86,400 / 5 = 17,280회
    전체 row 데이터 바인딩 횟수: 최대 17,280회

🧠 정리 및 회고

이번 문제는 단순한 “버그”가 아니라,
Angular와 AG Grid라는 두 프레임워크의 상태 흐름과 수명 주기가 완전히 다르다는 점에서 기인한 문제였습니다.
초기에는 단순히 refresh()와 ngOnDestroy()만으로 해결할 수 있을 것 같았지만,
실제로는 다음을 함께 고려해야 했습니다:

  • AG Grid의 diff patching 로직 (immutableData + getRowId)

  • Angular의 change detection과 Input 전달 흐름

  • renderer 내부의 부작용성 로직 누적 문제

  • 상태 전달 체계의 일관성 (signal, TanStack Query 활용)

이 문제를 해결하면서 저는 Angular 16의 신기능(signal),
TanStack Query의 캐시 기반 상태 제어, AG Grid의 내부 lifecycle 처리 방식을 깊이 이해하게 되었고,
단순한 기능 구현을 넘어 성능, 구조, 유지보수까지 고려한 설계와 최적화 경험을 해보게 되었습니다.

profile
코드 한줄마다 의미와 목적을 찾으려고 노력합니다.

0개의 댓글