앵귤러 실전 강좌 #5 차트와 데이터 테이블

9 분 소요

대시보드를 만든다면서 표 하나, 차트 하나 없으면 좀 허전합니다. #4에서 상태 관리와 Product CRUD까지 마무리했으니, 이제 우리 대시보드가 실제로 “보이는” 화면이 될 차례입니다. 이번 글에서는 Angular Material의 MatTable로 정렬,페이지네이션,검색이 되는 표를 만들고, ng2-charts로 매출과 카테고리 차트를 그려보겠습니다. 마지막에 다크 모드까지 토글로 붙여서 대시보드의 골격을 마무리합니다.

이 시리즈를 처음 보시는 분들을 위해 짧게 회수하면, #1에서 Angular Material을 셋업하고 사이드바,툴바가 있는 대시보드 셸을 만들었고, 그 위에 라우팅,폼,인증,CRUD를 차례로 얹어왔습니다. 이번 글은 그 셸 안에 들어갈 “콘텐츠”를 만드는 단계라고 보시면 됩니다.

Angular Material Table — 왜 MatTable인가? #

Material Table(MatTable)은 Angular Material이 제공하는 표준 데이터 테이블 컴포넌트입니다. AG Grid나 PrimeNG 같은 강력한 그리드 라이브러리도 있지만, Material Table은 다음 세 가지 이유로 첫 선택지로 추천드립니다.

  • 이미 Material을 쓰고 있다면 별도 의존성이 없습니다.
  • MatPaginator, MatSort와 조합이 매끈합니다.
  • 디자인이 우리 앱의 다른 Material 컴포넌트와 자동으로 맞물립니다.

#4에서 만든 Product 목록 화면을 카드 그리드 대신 표 버전으로 바꿔보겠습니다. 먼저 필요한 Material 모듈들을 import 하고 컴포넌트 골격을 잡습니다.

src/app/products/product-table.component.ts
@Component({
  selector: 'app-product-table',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    MatTableModule, MatPaginatorModule, MatSortModule,
    MatFormFieldModule, MatInputModule, MatIconModule,
  ],
  templateUrl: './product-table.component.html',
})
export class ProductTableComponent {
  private productService = inject(ProductService);

  displayedColumns = ['name', 'category', 'price', 'stock', 'actions'];
  dataSource = new MatTableDataSource<Product>([]);

  paginator = viewChild.required(MatPaginator);
  sort = viewChild.required(MatSort);

  constructor() {
    this.productService.list().subscribe((rows) => (this.dataSource.data = rows));
    effect(() => {
      this.dataSource.paginator = this.paginator();
      this.dataSource.sort = this.sort();
    });
  }
}

viewChild.required()(Angular 17.2+)는 ViewChild의 시그널 버전입니다. effect() 안에서 paginator와 sort가 ViewChild로 바인딩되는 시점에 자동으로 데이터 소스에 연결됩니다. 예전의 @ViewChild + AfterViewInit 조합보다 코드가 한결 명료해졌습니다.

템플릿 — Column 정의와 Sort/Paginator #

src/app/products/product-table.component.html
<table mat-table [dataSource]="dataSource" matSort class="full-width">
  <ng-container matColumnDef="name">
    <th mat-header-cell *matHeaderCellDef mat-sort-header>이름</th>
    <td mat-cell *matCellDef="let row">{{ row.name }}</td>
  </ng-container>

  <ng-container matColumnDef="price">
    <th mat-header-cell *matHeaderCellDef mat-sort-header>가격</th>
    <td mat-cell *matCellDef="let row">{{ row.price | currency:'KRW':'symbol':'1.0-0' }}</td>
  </ng-container>

  <!-- category, stock 컬럼도 같은 패턴 -->

  <ng-container matColumnDef="actions">
    <th mat-header-cell *matHeaderCellDef></th>
    <td mat-cell *matCellDef="let row">
      <button mat-icon-button [routerLink]="['/products', row.id, 'edit']">
        <mat-icon>edit</mat-icon>
      </button>
    </td>
  </ng-container>

  <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
  <tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>

<mat-paginator [pageSize]="10" [pageSizeOptions]="[5, 10, 25, 50]" />

mat-sort-header가 붙은 컬럼은 클릭으로 정렬되고, MatPaginator는 자동으로 표 하단에 페이지 컨트롤을 그려줍니다. 표 하나 그렸을 뿐인데 우리는 이미 정렬과 페이지네이션을 공짜로 얻은 셈입니다.

MatTable 데이터 소스 — MatTableDataSource vs 시그널 #

MatTableDataSource는 클라이언트측 정렬,필터,페이지를 한꺼번에 처리해주는 편의 클래스입니다. 작은 데이터(수백 건 이하)에서는 이 정도면 충분하지만, 다음 두 가지 한계가 있습니다.

  • 시그널 친화적이지 않습니다. dataSource.data = rows 형태의 명령형 할당이라 OnPush 컴포넌트와 자연스럽게 어울리지 않습니다.
  • 큰 데이터를 서버에서 받아올 때는 결국 직접 데이터 흐름을 다뤄야 합니다.

modern Angular에서는 [dataSource]에 시그널,배열을 그대로 넘기고 정렬,필터를 우리가 컴퓨티드(computed)로 직접 잡는 패턴이 점점 표준화되고 있습니다. 표 자체는 데이터를 받아 그리기만 하고, 데이터 가공은 시그널 그래프에서 일어나는 것입니다. 이 패턴은 잠시 뒤 검색 필터에서 자연스럽게 등장합니다.

Sort / Pagination — 클라이언트측 vs 서버측 #

표를 만들 때 빠지지 않는 결정이 하나 있습니다. 정렬과 페이지네이션을 클라이언트에서 할까, 서버에서 할까?

  • 클라이언트측: 데이터를 한 번에 다 받아와 브라우저에서 정렬,페이지를 처리. 코드가 단순하고 즉각 반응. 데이터가 수백 건 이하일 때 적합.
  • 서버측: 페이지,정렬 키를 API 파라미터로 보내고, 서버에서 잘라서 응답. 데이터가 수천 건 이상이거나 권한별로 보이는 데이터가 다를 때 필수.

규모를 가르는 경계는 보통 “브라우저에서 한 번에 들고 있어도 무리 없는 양”입니다. 메모리도 메모리지만, 정렬 한 번에 수만 건을 다시 비교하면 메인 스레드가 끊깁니다. 본 강좌의 Product 목록은 시드 데이터가 적으니 클라이언트측으로 가지만, 실무 어드민이라면 처음부터 서버측을 가정하고 시작하는 편이 안전합니다.

검색 입력과 필터 — RxJS debounceTime + toSignal #

표 위에 검색 입력을 붙여 봅시다. 단순히 [(ngModel)]로 묶고 매 키스트로크마다 필터를 다시 실행하면 입력이 빠를 때 답답하게 느껴집니다. RxJS의 debounceTime으로 “타자가 잠깐 멈췄을 때만” 필터를 돌리는 게 정석입니다. 그리고 결과는 toSignal()로 시그널 세계로 가져옵니다.

src/app/products/product-table.component.ts
export class ProductTableComponent {
  searchCtrl = new FormControl('', { nonNullable: true });

  search = toSignal(
    this.searchCtrl.valueChanges.pipe(startWith(''), debounceTime(200)),
    { initialValue: '' },
  );

  constructor() {
    effect(() => {
      this.dataSource.filter = this.search().trim().toLowerCase();
    });
  }
}
src/app/products/product-table.component.html
<mat-form-field appearance="outline" class="search">
  <mat-label>이름,카테고리 검색</mat-label>
  <input matInput [formControl]="searchCtrl" />
  <mat-icon matSuffix>search</mat-icon>
</mat-form-field>

MatTableDataSource.filter에 문자열을 넣으면 기본 필터가 모든 컬럼 값을 join 해서 검색합니다. 컬럼별로 다르게 필터링하고 싶다면 dataSource.filterPredicate를 함수로 갈아끼우면 됩니다.

RxJS와 시그널을 섞을 때 “들어가는 입구”와 “나오는 출구”만 정해두면 헷갈리지 않습니다. 들어가는 입구는 toSignal()(Observable → Signal), 나오는 출구는 toObservable()(Signal → Observable). 이번 예시처럼 폼 컨트롤은 RxJS 쪽에서 끝까지 처리하고, 그 결과만 시그널로 받아 effect에서 표에 반영하는 흐름이 깔끔합니다.

차트 라이브러리 결정 — ng2-charts / ApexCharts / D3 #

차트 라이브러리는 종류가 너무 많아서 처음에는 고르는 것 자체가 일입니다. 자주 쓰이는 셋만 짧게 비교하면:

  • ng2-charts(Chart.js 래퍼): 가장 많이 쓰이는 무난한 선택. Chart.js의 강력함을 그대로 쓰면서 앵귤러 친화 API. 라이선스 부담 없음(MIT).
  • ApexCharts: 더 화려하고 인터랙티브. 대시보드 “예쁜 그림” 욕심이 있을 때 좋지만 번들이 무거움.
  • D3: 라이브러리라기보다 시각화 “재료”. 자유도는 무한대지만 직접 그려야 할 게 많아서 학습 곡선이 가파름.

본 강좌는 ng2-charts로 갑니다. 이유는 두 가지입니다. 첫째, 일반적인 대시보드(라인,바,도넛,파이)에서 이걸로 안 되는 게 없습니다. 둘째, Chart.js 자체의 자료가 압도적으로 많아서 막혔을 때 풀어가기 쉽습니다.

ng2-charts 셋업 #

terminal
npm i ng2-charts chart.js

Standalone 시대의 ng2-charts는 provideCharts로 설정을 등록합니다.

src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideCharts, withDefaultRegisterables } from 'ng2-charts';

export const appConfig: ApplicationConfig = {
  providers: [
    // ... 기존 providers
    provideCharts(withDefaultRegisterables()),
  ],
};

withDefaultRegisterables()는 자주 쓰이는 차트 타입과 컨트롤러를 한 번에 등록해줍니다. 번들을 더 줄이고 싶다면 필요한 것만 골라 등록할 수 있는데, 보통은 기본값으로도 충분합니다.

매출 차트 (Line) — 월별 매출 시각화 #

대시보드 첫 번째 차트로 월별 매출 라인 차트를 그려봅시다.

src/app/dashboard/sales-chart.component.ts
@Component({
  selector: 'app-sales-chart',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [BaseChartDirective],
  template: `<canvas baseChart [data]="data()" [options]="options" type="line"></canvas>`,
})
export class SalesChartComponent {
  raw = signal<SalesPoint[]>([
    { month: '1월', amount: 12_400_000 },
    { month: '2월', amount: 15_100_000 },
    { month: '3월', amount: 13_800_000 },
    { month: '4월', amount: 18_900_000 },
    { month: '5월', amount: 21_500_000 },
  ]);

  data = computed<ChartData<'line'>>(() => ({
    labels: this.raw().map((p) => p.month),
    datasets: [{
      label: '월별 매출 (원)',
      data: this.raw().map((p) => p.amount),
      tension: 0.3,
      fill: true,
    }],
  }));

  options: ChartConfiguration<'line'>['options'] = {
    responsive: true,
    maintainAspectRatio: false,
    plugins: { legend: { position: 'top' } },
  };
}

핵심은 두 가지입니다. data를 시그널 기반 computed로 만들었다는 점, 그리고 maintainAspectRatio: false로 컨테이너 크기에 맞게 늘어나게 했다는 점. 시그널로 만들어 두면 잠시 뒤 Date Picker로 기간을 바꿀 때 자동으로 다시 그려집니다.

카테고리 분포 (Doughnut) — 컴포넌트 안에서 데이터 가공 #

두 번째 차트는 카테고리별 매출 비중을 보여주는 도넛 차트입니다. 원시 트랜잭션 배열에서 카테고리별 합계를 만들어 차트에 넣어 봅시다.

src/app/dashboard/category-chart.component.ts
@Component({
  selector: 'app-category-chart',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [BaseChartDirective],
  template: `<canvas baseChart [data]="data()" type="doughnut"></canvas>`,
})
export class CategoryChartComponent {
  sales = input.required<Sale[]>();

  data = computed<ChartData<'doughnut'>>(() => {
    const sums = new Map<string, number>();
    for (const s of this.sales()) {
      sums.set(s.category, (sums.get(s.category) ?? 0) + s.amount);
    }
    const labels = [...sums.keys()];
    return {
      labels,
      datasets: [{ data: labels.map((l) => sums.get(l)!) }],
    };
  });
}

데이터 가공을 컴포넌트 안에서 Map으로 모았다가 라벨/데이터를 만들어냅니다. 부모 컴포넌트가 sales 시그널을 갱신하면 도넛 차트도 자동으로 다시 그려집니다.

노트
차트의 데이터 가공 로직이 커지기 시작하면, 차트 컴포넌트가 아니라 별도 “선택자(selector) 함수”나 서비스의 computed signal로 빼는 편이 깔끔합니다. 컴포넌트가 데이터를 “요리”하기 시작하면, 테스트가 어려워지고 다른 위젯에서 같은 가공이 필요할 때 중복이 생깁니다.

Date Picker(Mat) + 차트 연동 #

대시보드에 빠질 수 없는 “기간 선택”을 붙여 봅시다. Material의 Date Range Picker를 쓰면 시작일,종료일을 한 번에 받을 수 있습니다.

src/app/dashboard/range-picker.component.ts
@Component({
  selector: 'app-range-picker',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [provideNativeDateAdapter()],
  imports: [ReactiveFormsModule, MatFormFieldModule, MatDatepickerModule],
  template: `
    <mat-form-field appearance="outline">
      <mat-label>기간 선택</mat-label>
      <mat-date-range-input [formGroup]="form" [rangePicker]="picker">
        <input matStartDate formControlName="start" placeholder="시작" />
        <input matEndDate formControlName="end" placeholder="종료"
               (dateChange)="changed.emit(form.getRawValue())" />
      </mat-date-range-input>
      <mat-datepicker-toggle matIconSuffix [for]="picker" />
      <mat-date-range-picker #picker />
    </mat-form-field>
  `,
})
export class RangePickerComponent {
  changed = output<{ start: Date | null; end: Date | null }>();
  form = new FormGroup({
    start: new FormControl<Date | null>(null),
    end: new FormControl<Date | null>(null),
  });
}

부모 대시보드는 이 이벤트를 받아 시그널을 업데이트하고, filteredSales computed가 차트 데이터를 다시 만듭니다. 폼 → 시그널 → 차트로 흐르는 단방향 데이터 흐름이 자연스럽게 만들어집니다.

src/app/dashboard/dashboard.component.ts
export class DashboardComponent {
  range = signal<{ start: Date | null; end: Date | null }>({ start: null, end: null });
  allSales = signal<Sale[]>([/* ... */]);

  filteredSales = computed(() => {
    const { start, end } = this.range();
    if (!start || !end) return this.allSales();
    return this.allSales().filter((s) => s.date >= start && s.date <= end);
  });
}

다크 모드 — Material theme + CSS 변수 #

대시보드 마지막 단장으로 다크 모드 토글을 붙여봅시다. Angular Material 18부터는 system-level themingCSS 변수가 한층 자연스러워졌습니다. styles.scss에서 라이트/다크 팔레트를 모두 정의해두고, 본문 클래스로 갈아끼우는 방식이 가장 단순합니다.

src/styles.scss
@use '@angular/material' as mat;

html {
  @include mat.theme((
    color: (
      primary: mat.$indigo-palette,
      tertiary: mat.$pink-palette,
    ),
    typography: Roboto,
    density: 0,
  ));

  color-scheme: light;
}

html.dark {
  color-scheme: dark;
}

토글 컴포넌트는 시그널 하나로 충분합니다.

src/app/shared/theme-toggle.component.ts
@Component({
  selector: 'app-theme-toggle',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [MatIconButton, MatIcon],
  template: `
    <button mat-icon-button (click)="dark.update(v => !v)">
      <mat-icon>{{ dark() ? 'light_mode' : 'dark_mode' }}</mat-icon>
    </button>
  `,
})
export class ThemeToggleComponent {
  dark = signal<boolean>(localStorage.getItem('theme') === 'dark');

  constructor() {
    effect(() => {
      const isDark = this.dark();
      document.documentElement.classList.toggle('dark', isDark);
      localStorage.setItem('theme', isDark ? 'dark' : 'light');
    });
  }
}

color-scheme 속성은 브라우저 기본 스크롤바,폼 색상까지 같이 따라오게 해줍니다. 차트도 같은 흐름으로 다크 모드용 색을 넘겨주면 되는데, Chart.js의 Chart.defaults.color를 시그널 effect 안에서 갈아끼우는 패턴이 무난합니다.

마무리 #

이번 글에서는 대시보드의 “보이는 부분”을 채웠습니다. MatTable로 정렬,페이지네이션,검색이 되는 표를, ng2-charts로 매출과 카테고리 차트를, Material Date Range Picker로 기간 필터를, 그리고 Material theme + CSS 변수로 다크 모드까지 — 한 화면이 실제로 “쓸 만한 대시보드”가 되었습니다.

다음 글이자 실전 트랙의 마지막인 #6 테스트와 배포에서는, 우리가 만든 이 앱을 어떻게 단위 테스트(Jest/Karma + Testing Library)와 e2e 테스트(Playwright)로 보호할지, 그리고 정적 호스팅(Firebase/Vercel)과 SSR 배포 옵션을 어떻게 고를지 정리해 보겠습니다. 트랙을 한 호흡으로 마무리하는 단계이니, 그때 뵙겠습니다.

X