Skip to content

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/material

2) [코드 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-antd

2) [코드 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 postcss

src/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 우선순위를 조정해 사용하십시오.

Moon HomeLab Engineering Knowledge Hub