앵귤러 실전 강좌 #5 차트와 데이터 테이블
대시보드를 만든다면서 표 하나, 차트 하나 없으면 좀 허전합니다. #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 하고 컴포넌트 골격을 잡습니다.
@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 #
<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()로 시그널 세계로 가져옵니다.
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();
});
}
}<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를 함수로 갈아끼우면 됩니다.
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 셋업 #
npm i ng2-charts chart.jsStandalone 시대의 ng2-charts는 provideCharts로 설정을 등록합니다.
import { ApplicationConfig } from '@angular/core';
import { provideCharts, withDefaultRegisterables } from 'ng2-charts';
export const appConfig: ApplicationConfig = {
providers: [
// ... 기존 providers
provideCharts(withDefaultRegisterables()),
],
};withDefaultRegisterables()는 자주 쓰이는 차트 타입과 컨트롤러를 한 번에 등록해줍니다. 번들을 더 줄이고 싶다면 필요한 것만 골라 등록할 수 있는데, 보통은 기본값으로도 충분합니다.
매출 차트 (Line) — 월별 매출 시각화 #
대시보드 첫 번째 차트로 월별 매출 라인 차트를 그려봅시다.
@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) — 컴포넌트 안에서 데이터 가공 #
두 번째 차트는 카테고리별 매출 비중을 보여주는 도넛 차트입니다. 원시 트랜잭션 배열에서 카테고리별 합계를 만들어 차트에 넣어 봅시다.
@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 시그널을 갱신하면 도넛 차트도 자동으로 다시 그려집니다.
Date Picker(Mat) + 차트 연동 #
대시보드에 빠질 수 없는 “기간 선택”을 붙여 봅시다. Material의 Date Range Picker를 쓰면 시작일,종료일을 한 번에 받을 수 있습니다.
@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가 차트 데이터를 다시 만듭니다. 폼 → 시그널 → 차트로 흐르는 단방향 데이터 흐름이 자연스럽게 만들어집니다.
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 theming과 CSS 변수가 한층 자연스러워졌습니다. 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;
}토글 컴포넌트는 시그널 하나로 충분합니다.
@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 배포 옵션을 어떻게 고를지 정리해 보겠습니다. 트랙을 한 호흡으로 마무리하는 단계이니, 그때 뵙겠습니다.