다크 모드
Appendix B. 사내 표준 UI 라이브러리(Angular Material / NG-ZORRO / Tailwind CSS) 브릿지 가이드
"우리가 14개 챕터에 걸쳐 완성한 코어 아키텍처(
PostStore,PostRepository,AuthStore, DTO 타입 계약)는 사내에서 어떤 UI 프레임워크를 쓰더라도 단 한 줄의 수정 없이 그대로 재사용된다. 엔터프라이즈 백오피스 양대 산맥인 Angular Material 및 NG-ZORRO(Ant Design), 그리고 Tailwind CSS v4와의 1:1 결합법을 마스터한다."
📋 이 부록에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
| 헤드리스(Headless) 비즈니스 분리 | 스토어와 레포지토리가 UI 라이브러리를 전혀 몰라 껍데기만 즉시 교체 가능한 구조 | B.1 |
MatTableModule (mat-table) | 구글 공식 Angular Material의 엔터프라이즈 데이터 테이블 바인딩 | B.2 [코드 B-1] |
NzTableModule (nz-table) | 엔터프라이즈 관리자 포털 표준인 NG-ZORRO(Ant Design) 테이블·팝컨펌 바인딩 | B.3 [코드 B-2] |
| Tailwind CSS v4 유틸리티 결합 | 8장에서 만든 제네릭 BoardTableComponent<T>에 유틸리티 클래스를 입히는 방법 | B.4 [코드 B-3] |
B.1 왜 비즈니스 로직과 UI 라이브러리를 철저히 분리했는가?
초보 개발자들은 흔히 UI 라이브러리의 테이블 컴포넌트(<table mat-table>, <nz-table>) 이벤트 핸들러 안에 직접 HttpClient.get()과 상태 조작 코드를 뒤섞어 작성합니다. 그렇게 짜인 코드는 사내 디자인 시스템이 바뀔 때 화면 전체를 처음부터 다시 짜야 합니다.
하지만 우리는 7장~12장을 거치며 도메인 계약(PostResponse), 전역 상태(PostStore), 네트워크 통신(PostRepository)을 UI 컴포넌트와 완전히 분리해 두었습니다.
이제 PostStore는 단 한 줄도 건드리지 않고 UI 껍데기만 갈아 끼워 봅시다!
B.2 실무 케이스 1: Google Angular Material (@angular/material) 1:1 결합
구글이 공식 유지보수하는 Angular Material은 접근성(WAI-ARIA)과 Angular CDK 호환성이 가장 뛰어난 표준 라이브러리입니다.
1) 설치
bash
cd frontend
ng add @angular/material2) [코드 B-1] Angular Material 게시판 컴포넌트 (src/app/views/material-board.component.ts)
mat-table의 [dataSource] 속성에 우리 PostStore의 store.posts() 신호를 그대로 연결합니다!
typescript
// frontend/src/app/views/material-board.component.ts
import { Component, ChangeDetectionStrategy, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { MatTableModule } from '@angular/material/table';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { PostStore } from '../stores/post.store';
import { PostResponse } from '../models/post.model';
@Component({
selector: 'app-material-board',
standalone: true,
imports: [
MatTableModule,
MatButtonModule,
MatChipsModule,
MatProgressBarModule,
MatSnackBarModule
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="mat-board-wrapper">
<header class="mat-header">
<div>
<h2>사내 기술 Q&A (Angular Material 에디션)</h2>
<p class="sub">총 {{ store.totalElements() }}개의 질문이 등록되어 있습니다.</p>
</div>
<button mat-flat-button color="primary" (click)="router.navigate(['/write'])">
✏️ 새 질문 작성
</button>
</header>
@if (store.loading()) {
<mat-progress-bar mode="indeterminate" />
}
<!-- 🌟 [dataSource]에 PostStore의 읽기 전용 신호 store.posts()를 직결! -->
<table mat-table [dataSource]="store.posts()" [trackBy]="trackById" class="mat-elevation-z2 full-table">
<!-- 1. ID 열 -->
<ng-container matColumnDef="id">
<th mat-header-cell *matHeaderCellDef>ID</th>
<td mat-cell *matCellDef="let post">{{ post.id }}</td>
</ng-container>
<!-- 2. 카테고리 열 -->
<ng-container matColumnDef="category">
<th mat-header-cell *matHeaderCellDef>분류</th>
<td mat-cell *matCellDef="let post">
<mat-chip>{{ post.category }}</mat-chip>
</td>
</ng-container>
<!-- 3. 제목 열 -->
<ng-container matColumnDef="title">
<th mat-header-cell *matHeaderCellDef>제목</th>
<td mat-cell *matCellDef="let post">
<strong>{{ post.title }}</strong>
@if (post.likeCount >= 5) {
<span class="hot-tag">🔥 인기</span>
}
</td>
</ng-container>
<!-- 4. 작성자 열 -->
<ng-container matColumnDef="author">
<th mat-header-cell *matHeaderCellDef>작성자</th>
<td mat-cell *matCellDef="let post">{{ post.author }}</td>
</ng-container>
<!-- 5. 좋아요 열 (10장 낙관적 업데이트 + 연타 방어 직결!) -->
<ng-container matColumnDef="likeCount">
<th mat-header-cell *matHeaderCellDef>좋아요</th>
<td mat-cell *matCellDef="let post">
<button
mat-stroked-button
[disabled]="store.isLikePending(post.id)"
(click)="onLike(post.id)"
>
❤️ {{ post.likeCount }}
</button>
</td>
</ng-container>
<!-- 6. 관리 열 -->
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>관리</th>
<td mat-cell *matCellDef="let post">
<button mat-button color="warn" (click)="onDelete(post.id)">
삭제
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
`,
styles: [`
.mat-board-wrapper { max-width: 1040px; margin: 32px auto; padding: 0 16px; }
.mat-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.sub { color: #64748b; font-size: 13px; margin: 4px 0 0; }
.full-table { width: 100%; background: #fff; border-radius: 8px; overflow: hidden; }
.hot-tag { margin-left: 8px; color: #dc2626; font-size: 12px; font-weight: 700; }
`]
})
export class MaterialBoardComponent implements OnInit {
readonly store = inject(PostStore);
readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
readonly displayedColumns: string[] = [
'id',
'category',
'title',
'author',
'likeCount',
'actions'
];
ngOnInit(): void {
this.store.fetchPosts();
}
trackById(_index: number, item: PostResponse): number {
return item.id;
}
async onLike(postId: number): Promise<void> {
try {
await this.store.toggleLikeOptimistic(postId);
} catch {
this.snackBar.open('네트워크 장애로 좋아요가 롤백되었습니다.', '닫기', { duration: 3000 });
}
}
async onDelete(postId: number): Promise<void> {
try {
await this.store.removePost(postId);
this.snackBar.open('게시글이 삭제되었습니다.', '확인', { duration: 2500 });
} catch {
this.snackBar.open('삭제 처리에 실패했습니다.', '닫기', { duration: 3000 });
}
}
}B.3 실무 케이스 2: NG-ZORRO (ng-zorro-antd) 엔터프라이즈 백오피스 결합
국내 금융권·대기업 사내 관리자 포털(ERP/백오피스)에서 가장 널리 쓰이는 Ant Design의 Angular 공식 구현체인 NG-ZORRO와의 결합 예시입니다. window.confirm 대신 세련된 풍선 확인창인 nz-popconfirm을 사용합니다.
1) 설치
bash
cd frontend
ng add ng-zorro-antd2) [코드 B-2] NG-ZORRO 게시판 컴포넌트 (src/app/views/zorro-board.component.ts)
typescript
// frontend/src/app/views/zorro-board.component.ts
import { Component, ChangeDetectionStrategy, OnInit, inject } from '@angular/core';
import { NzTableModule } from 'ng-zorro-antd/table';
import { NzCardModule } from 'ng-zorro-antd/card';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzTagModule } from 'ng-zorro-antd/tag';
import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
import { NzMessageService } from 'ng-zorro-antd/message';
import { PostStore } from '../stores/post.store';
@Component({
selector: 'app-zorro-board',
standalone: true,
imports: [
NzTableModule,
NzCardModule,
NzButtonModule,
NzTagModule,
NzPopconfirmModule
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="zorro-container">
<nz-card nzTitle="💼 사내 기술 Q&A (NG-ZORRO Ant Design 버전)">
<nz-table
#basicTable
[nzData]="store.posts()"
[nzLoading]="store.loading()"
[nzFrontPagination]="false"
nzSize="middle"
>
<thead>
<tr>
<th nzWidth="80px">ID</th>
<th nzWidth="100px">분류</th>
<th>제목</th>
<th nzWidth="120px">작성자</th>
<th nzWidth="110px">좋아요</th>
<th nzWidth="100px">관리</th>
</tr>
</thead>
<tbody>
@for (data of basicTable.data; track data.id) {
<tr>
<td>{{ data.id }}</td>
<td><nz-tag nzColor="blue">{{ data.category }}</nz-tag></td>
<td>
<span class="title-text">{{ data.title }}</span>
@if (data.likeCount >= 5) {
<nz-tag nzColor="error" style="margin-left: 8px;">🔥 인기</nz-tag>
}
</td>
<td>{{ data.author }}</td>
<td>
<button
nz-button
nzType="default"
nzSize="small"
[disabled]="store.isLikePending(data.id)"
(click)="handleLike(data.id)"
>
❤️ {{ data.likeCount }}
</button>
</td>
<td>
<!-- 🌟 Ant Design Popconfirm으로 우아한 삭제 확인 -->
<a
nz-popconfirm
nzPopconfirmTitle="정말 이 게시글을 삭제하시겠습니까?"
(nzOnConfirm)="handleRemove(data.id)"
class="danger-link"
>
삭제
</a>
</td>
</tr>
}
</tbody>
</nz-table>
</nz-card>
</div>
`,
styles: [`
.zorro-container { max-width: 1080px; margin: 32px auto; padding: 0 16px; }
.title-text { font-weight: 600; color: #0f172a; }
.danger-link { color: #ef4444; cursor: pointer; }
`]
})
export class ZorroBoardComponent implements OnInit {
readonly store = inject(PostStore);
private readonly message = inject(NzMessageService);
ngOnInit(): void {
this.store.fetchPosts();
}
async handleLike(id: number): Promise<void> {
try {
await this.store.toggleLikeOptimistic(id);
} catch {
this.message.error('네트워크 오류로 좋아요 처리가 롤백되었습니다.');
}
}
async handleRemove(id: number): Promise<void> {
try {
await this.store.removePost(id);
this.message.success('게시글이 삭제되었습니다.');
} catch {
this.message.error('삭제에 실패했습니다.');
}
}
}B.4 실무 케이스 3: Tailwind CSS v4 유틸리티와 제네릭 BoardTableComponent 결합
무거운 컴포넌트 프레임워크 대신 8장에서 직접 만든 제네릭 <app-board-table>에 Tailwind CSS v4 유틸리티 클래스만 입혀 가볍고 세련된 디자인 시스템을 구축할 수도 있습니다.
bash
npm install -D tailwindcss @tailwindcss/postcss postcsssrc/styles.css에 한 줄을 추가합니다:
css
@import "tailwindcss";[코드 B-3] <app-board-table>의 #cellTemplate에 Tailwind 유틸리티 적용
html
<app-board-table [items]="store.posts()" [columns]="columns" [loading]="store.loading()">
<ng-template #cellTemplate let-row let-columnKey="columnKey" let-value="value">
@switch (columnKey) {
@case ('title') {
<div class="flex items-center gap-2">
<span class="font-semibold text-slate-800 hover:text-blue-600 transition-colors">
{{ row.title }}
</span>
@if (row.likeCount >= 5) {
<span class="px-2 py-0.5 text-xs font-bold bg-rose-100 text-rose-600 rounded-full">
🔥 인기
</span>
}
</div>
}
@default {
{{ value }}
}
}
</ng-template>
<ng-template #actionTemplate let-row>
<button
type="button"
class="px-2.5 py-1 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded border border-red-200 transition"
(click)="handleDeletePost(row.id)"
>
삭제
</button>
</ng-template>
</app-board-table>WARNING
UI 라이브러리와 Tailwind CSS를 함께 쓸 때의 주의점
Tailwind CSS의 기본 리셋(Preflight)과 Angular Material / NG-ZORRO의 기본 버튼·테이블 스타일이 충돌해 테두리가 사라질 수 있습니다. 한 프로젝트에서는 주력 UI 체계를 하나로 통일하거나, Tailwind의 Preflight 충돌 시 @layer 우선순위를 조정해 사용하십시오.