Skip to content

Chapter 13. 10,000개 댓글 렌더링 최적화(CDK Virtual Scroll과 OnPush) ​

"10,000개의 대용량 댓글이 쏟아지는 순간, 브라우저는 멈춘다. Angular CDK 가상 스크롤(CdkVirtualScrollViewport)과 Angular 22 기본 OnPush 변경 감지, afterRender 측정, 그리고 DestroyRef 메모리 누수 방어로 60fps의 극한 성능을 달성하자."


📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.

개념한 줄 요약설명 위치
ChangeDetectionStrategy.OnPush신호 변경이나 입력(input()) 참조가 바뀔 때만 서브트리를 갱신하는 고성능 전략 (Angular 22 기본값)13.1
계층형 트리 평탄화 (flattenCommentTree)재귀 댓글 트리를 들여쓰기 깊이(depth)를 가진 1차원 배열로 변환13.2 [코드 13-1]
CdkVirtualScrollViewport & *cdkVirtualFor화면 뷰포트에 보이는 10~15개 행만 실제 DOM에 그리는 공식 가상 스크롤 모듈13.3 [코드 13-2]
afterNextRender / afterRender브라우저가 DOM 렌더링을 마친 직후 실제 DOM 노드 수와 소요 시간을 측정하는 훅13.3 [코드 13-2]
DestroyRef & takeUntilDestroyed컴포넌트 파괴 시 타이머, 전역 리스너, RxJS 구독을 자동 해제하는 누수 방어 API13.4 [코드 13-3]

TIP

🏁 이 장의 시작 파일
이 장은 기존 BoardAppComponent를 망가뜨리지 않고 대용량 성능을 실험 및 재사용할 수 있도록 다음 파일들을 작성합니다:

  • frontend/src/app/utils/comment-tree.util.ts (계층형 댓글 평탄화 및 10,000개 더미 생성기)
  • frontend/src/app/components/virtual-comment-list.component.ts (@angular/cdk/scrolling 기반 60fps 가상 스크롤 컴포넌트)
  • frontend/src/app/services/board-shortcut.service.ts (DestroyRef 기반 메모리 누수 방어 서비스)

13.1 10,000개 댓글과 브라우저의 한계, 그리고 Angular 22 OnPush의 원리 ​

사내 인기 기술 게시글에 댓글과 대댓글이 10,000개 달렸습니다. 6장에서 만든 재귀 CommentItemComponent를 일반적인 @for로 한꺼번에 렌더링하면 어떤 일이 벌어질까요?

  1. DOM 노드 폭발: 댓글 하나당 div, span, p, button 등 최소 6개의 태그가 생기므로 60,000개 이상의 DOM 노드가 메모리에 생성됩니다.
  2. 메인 스레드 프리징(Long Task): 레이아웃 계산(Reflow)과 페인트에 수 초가 걸려 브라우저가 멈추고, 스크롤할 때마다 프레임이 60fps(16.7ms)를 지키지 못해 뚝뚝 끊깁니다(Jank).

Angular 22의 변경 감지 혁신: ChangeDetectionStrategy.OnPush 기본화 ​

과거 Zone.js 시절의 기본 전략(Default)은 화면 어디선가 마우스 클릭이나 setTimeout이 한 번만 일어나도 컴포넌트 트리 전체를 위에서 아래로 샅샅이 다시 검사(Dirty Checking)했습니다.
반면 Angular 22부터 신규 컴포넌트의 표준 기본값이 된 ChangeDetectionStrategy.OnPush와 Signals 조합은 다음과 같이 동작합니다:

하지만 OnPush와 Signals가 아무리 빨라도 최초 마운트 시점에 10,000개의 DOM 요소를 물리적으로 생성하는 브라우저 비용 자체는 피할 수 없습니다. 보이지 않는 9,985개의 댓글은 아예 DOM에서 빼버리는 **가상 스크롤(Virtual Scroll)**이 필요한 이유입니다.


13.2 [실습 1] 계층형 댓글 트리를 가상 스크롤용 1차원 배열로 평탄화하기 ​

6장의 댓글 데이터는 children?: CommentNode[]를 가진 재귀 트리 구조입니다. 가상 스크롤 뷰포트는 각 행의 높이를 계산해 인덱스로 잘라내야 하므로, 먼저 재귀 트리를 depth(들여쓰기 단계) 속성이 포함된 **1차원 배열(FlatCommentNode[])로 평탄화(Flattening)**합니다.

[코드 13-1] 댓글 평탄화 유틸리티 및 10,000개 생성기 (frontend/src/app/utils/comment-tree.util.ts) ​

typescript
// frontend/src/app/utils/comment-tree.util.ts

export interface CommentNode {
  id: number;
  parentId: number | null;
  author: string;
  content: string;
  createdAt: string;
  children?: CommentNode[];
}

export interface FlatCommentNode {
  id: number;
  parentId: number | null;
  author: string;
  content: string;
  createdAt: string;
  depth: number; // 🌟 화면에서 들여쓰기(padding-left)에 사용할 계층 깊이 (0, 1, 2...)
}

/**
 * 1. 계층형 댓글 트리(CommentNode[])를 가상 스크롤용 1차원 배열(FlatCommentNode[])로 변환합니다.
 */
export function flattenCommentTree(
  nodes: readonly CommentNode[],
  depth = 0,
  acc: FlatCommentNode[] = []
): FlatCommentNode[] {
  for (const node of nodes) {
    acc.push({
      id: node.id,
      parentId: node.parentId,
      author: node.author,
      content: node.content,
      createdAt: node.createdAt,
      depth
    });

    if (node.children && node.children.length > 0) {
      flattenCommentTree(node.children, depth + 1, acc);
    }
  }
  return acc;
}

/**
 * 2. 성능 측정 실습용: 루트 댓글 2,000개 × (본인 + 4단계 대댓글) = 정확히 10,000개 트리 생성
 */
export function createMockCommentTree(rootCount = 2000, depthPerRoot = 4): CommentNode[] {
  let nextId = 1;
  const roots: CommentNode[] = [];

  for (let r = 0; r < rootCount; r++) {
    const root: CommentNode = {
      id: nextId++,
      parentId: null,
      author: `사우#${r + 1}`,
      content: `[루트 질문 ${r + 1}] 대용량 렌더링 최적화가 궁금합니다.`,
      createdAt: '2026-11-01 10:00',
      children: []
    };

    let currentParent = root;
    for (let d = 1; d <= depthPerRoot; d++) {
      const reply: CommentNode = {
        id: nextId++,
        parentId: currentParent.id,
        author: `답변자#${d}`,
        content: `${d}단계 대댓글: CDK Virtual Scroll로 해결할 수 있습니다!`,
        createdAt: '2026-11-01 10:05',
        children: []
      };
      currentParent.children!.push(reply);
      currentParent = reply;
    }

    roots.push(root);
  }

  return roots;
}

13.3 [실습 2] @angular/cdk/scrolling 가상 스크롤과 afterRender 성능 측정 ​

🎭 비유: 무대 위 배우 15명 vs 대기실의 10,000명 엑스트라
10,000명이 등장하는 초대형 뮤지컬을 상연한다고 상상해 봅시다. 무대(화면 뷰포트 480px)는 크기가 정해져 있어서 한 번에 배우 10여 명만 올라갈 수 있습니다. 무대 감독이 10,000명의 엑스트라를 좁은 무대 위에 억지로 다 올리면 무대 바닥이 무너져 내립니다(브라우저 프리징).
Angular CDK의 **CdkVirtualScrollViewport**는 무대 위에는 지금 관객 눈에 보이는 10~15명의 배우만 DOM으로 올리고, 관객이 스크롤할 때마다 무대 위 배우의 대사(데이터)만 빛의 속도로 바꿔치기합니다!

먼저 프로젝트에 공식 Angular CDK 패키지가 설치되어 있는지 확인합니다:

bash
npm install @angular/cdk

이제 CdkVirtualScrollViewport와 *cdkVirtualFor를 사용하고, Angular의 afterRender 훅으로 실제 DOM에 몇 개의 .comment-row 요소만 존재하는지 실시간으로 측정해 화면에 표시하는 컴포넌트를 작성합니다.

[코드 13-2] 10,000개 댓글 가상 스크롤 컴포넌트 (frontend/src/app/components/virtual-comment-list.component.ts) ​

typescript
// frontend/src/app/components/virtual-comment-list.component.ts
import {
  Component,
  ChangeDetectionStrategy,
  ElementRef,
  afterNextRender,
  afterRender,
  computed,
  inject,
  input,
  output,
  signal
} from '@angular/core';
import {
  CdkVirtualScrollViewport,
  CdkFixedSizeVirtualScroll,
  CdkVirtualForOf
} from '@angular/cdk/scrolling';
import {
  CommentNode,
  FlatCommentNode,
  flattenCommentTree,
  createMockCommentTree
} from '../utils/comment-tree.util';

@Component({
  selector: 'app-virtual-comment-list',
  standalone: true,
  imports: [CdkVirtualScrollViewport, CdkFixedSizeVirtualScroll, CdkVirtualForOf],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <section class="virtual-comment-section">
      <header class="perf-header">
        <div>
          <h3>💬 계층형 댓글 가상 스크롤 ({{ flatComments().length.toLocaleString() }}건)</h3>
          <p class="perf-metrics">
            전체 데이터: <strong>{{ flatComments().length.toLocaleString() }}개</strong> ·
            실제 DOM 렌더링 노드: <strong class="highlight">{{ renderedDomCount() }}개</strong> ·
            초기 마운트 측정: <strong>{{ initialRenderMs() }}ms</strong>
          </p>
        </div>
        <button type="button" class="btn-bench" (click)="loadTenThousandComments()">
          🚀 10,000개 댓글 스트레스 테스트 로드
        </button>
      </header>

      <!-- 🌟 CDK 가상 스크롤 뷰포트: itemSize="56" (행 높이 56px 고정) -->
      <cdk-virtual-scroll-viewport
        itemSize="56"
        minBufferPx="280"
        maxBufferPx="560"
        class="comment-viewport"
      >
        <div
          *cdkVirtualFor="let item of flatComments(); trackBy: trackByCommentId"
          class="comment-row"
          [style.padding-left.px]="16 + item.depth * 22"
        >
          <div class="comment-main">
            @if (item.depth > 0) {
              <span class="reply-arrow">↳</span>
            }
            <strong class="author">{{ item.author }}</strong>
            <span class="content">{{ item.content }}</span>
          </div>

          <div class="comment-meta">
            <span class="date">{{ item.createdAt }}</span>
            <button
              type="button"
              class="btn-del-small"
              (click)="deleted.emit(item.id)"
            >
              삭제
            </button>
          </div>
        </div>
      </cdk-virtual-scroll-viewport>
    </section>
  `,
  styles: [`
    .virtual-comment-section { margin-top: 24px; background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; }
    .perf-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; flex-wrap: wrap; gap: 8px; }
    .perf-metrics { font-size: 13px; color: #475569; margin: 4px 0 0; }
    .highlight { color: #16a34a; font-size: 14px; }
    .btn-bench { background: #0f172a; color: #fff; border: none; padding: 8px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; }
    .comment-viewport { height: 480px; width: 100%; border: 1px solid #cbd5e1; border-radius: 8px; background: #f8fafc; }
    /* 🌟 고정 높이 가상 스크롤의 핵심: 모든 행의 높이가 itemSize(56px)와 정확히 일치해야 함! */
    .comment-row {
      height: 56px; box-sizing: border-box; display: flex; justify-content: space-between;
      align-items: center; padding-right: 16px; border-bottom: 1px solid #e2e8f0; background: #fff;
    }
    .comment-main { display: flex; align-items: center; gap: 8px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
    .reply-arrow { color: #94a3b8; font-weight: 700; }
    .author { color: #1e293b; font-size: 13px; flex-shrink: 0; }
    .content { color: #334155; font-size: 13px; overflow: hidden; text-overflow: ellipsis; }
    .comment-meta { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
    .date { font-size: 11px; color: #94a3b8; }
    .btn-del-small { font-size: 11px; color: #ef4444; background: #fef2f2; border: 1px solid #fecaca; padding: 2px 8px; border-radius: 4px; cursor: pointer; }
  `]
})
export class VirtualCommentListComponent {
  private readonly hostEl = inject<ElementRef<HTMLElement>>(ElementRef);
  private readonly renderStartTime = performance.now();

  // 부모로부터 댓글 트리를 입력받거나, 자체 스트레스 테스트 트리를 사용
  readonly comments = input<CommentNode[]>([]);
  readonly deleted = output<number>();

  private readonly _overrideTree = signal<CommentNode[] | null>(null);

  // 🌟 트리 참조가 바뀔 때만 1번 평탄화를 다시 계산하는 computed 신호!
  readonly flatComments = computed<FlatCommentNode[]>(() => {
    const source = this._overrideTree() ?? this.comments();
    return flattenCommentTree(source);
  });

  readonly renderedDomCount = signal<number>(0);
  readonly initialRenderMs = signal<number>(0);

  constructor() {
    // 1. 최초 1회 렌더링 직후 마운트 소요 시간(ms) 측정
    afterNextRender(() => {
      const elapsed = Math.round((performance.now() - this.renderStartTime) * 10) / 10;
      this.initialRenderMs.set(elapsed);
      this.measureDomNodes();
    });

    // 2. 스크롤 등으로 DOM이 갱신된 뒤 실제 렌더링된 .comment-row 개수 측정
    afterRender(() => {
      this.measureDomNodes();
    });
  }

  loadTenThousandComments(): void {
    this._overrideTree.set(createMockCommentTree(2000, 4)); // 10,000개 생성!
  }

  // *cdkVirtualFor가 DOM 노드를 재사용할 때 식별자로 쓰는 함수
  trackByCommentId(_index: number, item: FlatCommentNode): number {
    return item.id;
  }

  private measureDomNodes(): void {
    const count = this.hostEl.nativeElement.querySelectorAll('.comment-row').length;
    if (this.renderedDomCount() !== count) {
      this.renderedDomCount.set(count);
    }
  }
}

🆕 처음 보는 문법: CdkVirtualScrollViewport와 afterNextRender / afterRender

  • <cdk-virtual-scroll-viewport itemSize="56">: 각 행의 높이가 56px임을 알려주면, 현재 스크롤 위치(scrollTop)를 56으로 나누어 화면에 들어오는 시작·끝 인덱스만 계산합니다. minBufferPx="280"과 maxBufferPx="560"은 빠르게 스크롤할 때 흰 여백이 보이지 않도록 위아래로 5~10개를 미리 더 그려두는 버퍼입니다.
  • *cdkVirtualFor="let item of flatComments(); trackBy: trackByCommentId": 일반 @for 대신 뷰포트가 계산한 슬라이스 구간만 DOM에 렌더링하고, 화면 밖으로 밀려난 DOM 요소를 파괴하지 않고 아래쪽 새 행으로 **재활용(DOM Recycling)**합니다.
  • afterNextRender() / afterRender(): 브라우저가 실제 DOM 업데이트와 페인트 준비를 마친 직후 실행되는 모던 Angular 생명주기 훅입니다. SSR(서버 사이드 렌더링) 환경에서는 자동으로 건너뛰므로 document나 ElementRef 측정에 가장 안전합니다.

13.4 [실습 3] SPA의 3대 메모리 누수 방어 (DestroyRef & takeUntilDestroyed) ​

사내 인트라넷은 아침에 출근해서 퇴근할 때까지 브라우저 탭을 끄지 않고 화면을 수백 번 이동합니다. 컴포넌트가 파괴(Unmount)될 때 정리하지 않으면 메모리가 야금야금 차오르는 3대 메모리 누수(Memory Leak) 주범은 다음과 같습니다:

순위누수 원인왜 GC(가비지 컬렉터)가 회수하지 못하나?Angular 22 해결책
1위window.addEventListener (keydown, resize)전역 window 객체가 이벤트 콜백(클로저)을 통해 죽은 컴포넌트 인스턴스를 계속 붙잡고 있음DestroyRef.onDestroy()에서 removeEventListener 호출
2위setInterval / 재귀 setTimeout브라우저 타이머 큐가 해제 전까지 콜백을 영구 실행함DestroyRef.onDestroy()에서 clearInterval 호출
3위끝나지 않는 RxJS Observable.subscribe()interval(), fromEvent(), 스토어 스트림 구독이 컴포넌트 파괴 후에도 살아 있음파이프에 takeUntilDestroyed() 장착

과거에는 ngOnDestroy() 생명주기 메서드를 만들고 Subscription 변수를 일일이 해제했지만, 이제는 **inject(DestroyRef)**와 **takeUntilDestroyed()**로 생성 시점에 정리 로직을 한데 묶어 응집도를 극대화합니다!

[코드 13-3] 메모리 누수 제로 단축키·자동갱신 훅 (frontend/src/app/services/board-shortcut.service.ts) ​

typescript
// frontend/src/app/services/board-shortcut.service.ts
import { DestroyRef, inject } from '@angular/core';
import { fromEvent, interval } from 'rxjs';
import { filter } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

/**
 * 컴포넌트의 constructor()에서 호출하여
 * 1) Alt + R 단축키 새로고침과 2) 5분 주기 자동 폴링을 등록하고,
 * 컴포넌트가 파괴될 때 단 1바이트의 메모리 누수도 없이 자동 해제합니다.
 */
export function registerBoardShortcuts(onRefresh: () => void): void {
  // 현재 컴포넌트의 파괴 시점을 추적하는 참조 획득
  const destroyRef = inject(DestroyRef);

  // 🌟 방어 1: RxJS 전역 키보드 이벤트 스트림 + takeUntilDestroyed() 자동 구독 해제!
  fromEvent<KeyboardEvent>(window, 'keydown')
    .pipe(
      filter((e) => e.altKey && e.key.toLowerCase() === 'r'),
      takeUntilDestroyed(destroyRef)
    )
    .subscribe((e) => {
      e.preventDefault();
      onRefresh();
    });

  // 🌟 방어 2: 네이티브 타이머(setInterval) 등록 즉시 onDestroy 해제 콜백을 짝지어 선언!
  const pollTimerId = window.setInterval(() => {
    onRefresh();
  }, 300_000); // 5분

  destroyRef.onDestroy(() => {
    window.clearInterval(pollTimerId);
    console.log('🧹 [DestroyRef] 게시판 자동 갱신 타이머가 안전하게 해제되었습니다.');
  });
}

13.5 [직접 해보기] 실습 미션 💪 ​

🎯 미션: 특정 깊이의 대댓글만 필터링하거나 접어두는 maxVisibleDepth 신호 추가하기 ​

VirtualCommentListComponent에 readonly maxVisibleDepth = signal<number>(99) 신호를 추가하고, 사용자가 [대댓글 접기 (루트만 보기)] 버튼을 누르면 maxVisibleDepth를 0으로 바꾸어 flatComments에서 depth === 0인 루트 댓글만 가상 스크롤에 표시되도록 computed 로직을 확장해 보세요.

💡 미션 정답 코드 확인하기
typescript
// VirtualCommentListComponent 내부에 추가
readonly maxVisibleDepth = signal<number>(99);

readonly flatComments = computed<FlatCommentNode[]>(() => {
  const source = this._overrideTree() ?? this.comments();
  const maxDepth = this.maxVisibleDepth();
  return flattenCommentTree(source).filter((item) => item.depth <= maxDepth);
});

toggleRootOnly(): void {
  this.maxVisibleDepth.update((current) => (current === 0 ? 99 : 0));
}

13.6 이번 장 핵심 요약 및 다음 단계 🚀 ​

핵심 3줄

  1. Angular 22의 기본 ChangeDetectionStrategy.OnPush와 Signals는 값이 바뀐 뷰 노드만 핀포인트로 갱신하여 불필요한 트리 전체 검사를 제거합니다.
  2. 10,000개의 계층형 댓글은 flattenCommentTree로 1차원 배열로 펼친 뒤 CdkVirtualScrollViewport(itemSize="56")로 감싸면, 실제 DOM에는 항상 20개 미만의 행만 유지되어 60fps 스크롤이 보장됩니다.
  3. 전역 이벤트 리스너, setInterval, RxJS 스트림은 DestroyRef.onDestroy()와 takeUntilDestroyed()로 생성 즉시 해제 훅을 묶어 메모리 누수를 원천 차단합니다.

바뀐 파일

파일무엇이 바뀌었나
src/app/utils/comment-tree.util.tsflattenCommentTree 평탄화 함수 및 10,000개 댓글 생성기 작성
src/app/components/virtual-comment-list.component.tsCDK 가상 스크롤 뷰포트 및 afterRender DOM 노드 측정 구현
src/app/services/board-shortcut.service.tsDestroyRef & takeUntilDestroyed 기반 단축키·폴링 클린업 구현

자주 하는 실수

증상원인해결
cdk-virtual-scroll-viewport 안에 댓글이 하나도 안 보임뷰포트 태그에 CSS height(예: 480px)를 지정하지 않아 높이가 0px로 계산됨.comment-viewport { height: 480px; }처럼 반드시 명시적 높이를 부여
스크롤할 때 댓글 행들이 서로 겹치거나 스크롤바가 덜덜 떨림CSS의 .comment-row 높이가 itemSize="56"과 다르거나 긴 글이 2줄로 줄바꿈됨.comment-row에 height: 56px; white-space: nowrap; overflow: hidden; 고정
afterRender 안에서 신호 값을 무조건 .set() 했더니 무한 루프 발생신호 변경 → 렌더링 → afterRender 실행 → 다시 신호 변경 반복if (this.renderedDomCount() !== count)처럼 값이 실제로 달라졌을 때만 갱신

셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.

  1. 댓글이 10,000개일 때 CdkVirtualScrollViewport(height: 480px, itemSize: 56, maxBufferPx: 560)가 실제 DOM에 유지하는 .comment-row 요소는 대략 몇 개 수준인가요?
  2. 왜 6장의 재귀 컴포넌트 구조를 그대로 쓰지 않고 flattenCommentTree로 1차원 배열로 평탄화한 뒤 가상 스크롤에 넘겼나요?
  3. 전통적인 ngOnDestroy() 메서드 대신 inject(DestroyRef)와 takeUntilDestroyed()를 쓰면 어떤 점이 더 좋은가요?
셀프 체크 정답
  1. 화면에 보이는 행(ceil(480 / 56) = 9개)에 위아래 버퍼(560 / 56 = 10개씩)를 더해 약 20~30개 내외의 DOM 노드만 유지됩니다. 데이터가 100,000개로 늘어나도 실제 DOM 노드 수는 변하지 않습니다.
  2. 재귀 컴포넌트는 부모 댓글 하나 안에 자식 대댓글 수백 개가 중첩되어 있어 행 단위 높이가 일정하지 않고 인덱스로 잘라낼(Slice) 수 없기 때문입니다. 1차원 배열로 펼치고 depth로 들여쓰기만 주면 모든 행을 고정 높이(56px)로 균일하게 가상화할 수 있습니다.
  3. 리소스를 생성하는 코드(리스너·타이머 등록) 바로 옆에 정리(해제) 코드를 함께 작성할 수 있어 해제를 깜빡하는 실수가 사라지고, 컴포넌트 바깥의 재사용 함수(registerBoardShortcuts)로도 손쉽게 분리할 수 있습니다.

다음 챕터로 이어지는 질문
1장의 jQuery 스파게티 게시판에서 출발해 타입 계약, 제네릭 테이블, Signals Store, 낙관적 업데이트, 라우터 가드, Single-Flight 인터셉터, 그리고 10,000개 가상 스크롤까지 완성했습니다!
마지막 관문인 Chapter 14에서는 백엔드 서버를 띄우지 않고도 게시판 화면 전체의 3대 핵심 시나리오(목록 렌더링, 글 작성, 좋아요 롤백)를 0.3초 만에 자동 검증하는 TestBed + Vitest + MSW v2 통합 테스트를 완성합니다.

Moon HomeLab Engineering Knowledge Hub