Skip to content

Chapter 08. 어디든 재사용 가능한 제네릭 BoardTableComponent<T> 제작 ​

"사내 포털에는 게시판 테이블만 있는 것이 아니다. 직원 목록, 매출 내역, 결재 문서함 등 똑같이 생긴 수십 개의 테이블이 존재한다. 제네릭(Generic) 컴포넌트와 contentChild, ngTemplateOutlet으로 만능 테이블 틀을 완성하자."


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

개념한 줄 요약설명 위치
제네릭 컴포넌트 class Comp<T>컴포넌트가 다룰 데이터 타입 T를 사용하는 부모가 결정하는 기능8.1~8.2
타입 제약 T extends { id: number | string }최소한 고유 id 속성을 가진 객체만 행 데이터로 허용하는 안전장치8.2
TableColumn<T> & keyof T & stringT에 실제 존재하는 문자열 속성 이름만 컬럼 키로 허용8.2 🆕 박스
contentChild & <ng-template>부모가 자식 컴포넌트 태그 사이에 넣은 커스텀 템플릿 조각을 신호로 조회8.2 해부 박스
NgTemplateOutlet (*ngTemplateOutlet)행 데이터($implicit, row)를 부모 템플릿에 건네주며 셀 안에 렌더링8.2 해부 박스

TIP

🏁 이 장의 시작 파일
frontend/src/app/board-app/board-app.component.ts가 7장 완료 상태(snapshots/board-app.ch07.component.ts)에서 시작합니다.
이 장에서 작성·수정하는 파일:

  • frontend/src/app/components/board-table.component.ts (사내 공통 제네릭 테이블 컴포넌트)
  • frontend/src/app/board-app/board-app.component.ts (하드코딩된 <table>을 <app-board-table>로 교체)

8.1 왜 제네릭 테이블인가? ​

7장까지 우리의 테이블 마크업은 BoardAppComponent 안에 직접 작성되어 있었고, PostResponse 타입에 강하게 결합되어 있었습니다.
인사팀에서 직원 관리 테이블(Employee: 사번, 이름, 부서, 입사일)을 요청했을 때, <table> HTML과 CSS를 복사해 붙여 넣으면 사내 디자인이 바뀔 때마다 수십 개 화면을 모두 고쳐야 합니다.

그렇다고 공통 테이블 컴포넌트에서 데이터를 items = input<any[]>()로 받으면, 컬럼 키에 오타('titel')를 내도 컴파일러가 전혀 잡아내지 못합니다. 해답은 TypeScript 클래스 제네릭 <T>를 컴포넌트에 그대로 적용하는 것입니다!

🐟 비유: 만능 붕어빵 기계(BoardTableComponent<T>)와 속재료(T)
팥 붕어빵 틀, 슈크림 붕어빵 틀, 피자 붕어빵 틀을 따로 만들지 않고, 쇠틀(BoardTableComponent<T>) 하나에 속재료(T: PostResponse, T: Employee)만 갈아 끼우는 것과 같습니다! 단, 붕어빵 틀에 쇠구슬(string[] 원시값)을 부으려 하면 기계가 망가지므로, TypeScript가 *"반드시 고유 식별자(id)를 가진 객체(T extends { id: number | string })만 넣으라"*고 사전에 막아 줍니다.


8.2 [실습 1] 제네릭 BoardTableComponent<T> 구현 ​

우리 공통 테이블이 갖추어야 할 실무 요구사항은 4가지입니다:

  1. 엄격한 컬럼 키 검증: columns = input.required<TableColumn<T>[]>()로 받아 T에 실제 있는 필드명만 허용합니다.
  2. 행 클릭 이벤트: 행을 클릭하면 rowClick = output<T>()로 정확한 T 타입 객체를 방출합니다.
  3. 커스텀 셀 렌더링 (#cellTemplate): 제목 옆에 🔥 인기 배지를 붙이거나 카테고리 칩을 그릴 수 있도록 부모의 <ng-template #cellTemplate>을 contentChild로 받아 ngTemplateOutlet으로 렌더링합니다.
  4. 동작 열 (#actionTemplate): 삭제·좋아요 버튼처럼 데이터 필드가 아닌 액션 버튼 열을 맨 오른쪽에 추가할 수 있습니다.

[코드 8-1] 사내 공통 제네릭 테이블 (frontend/src/app/components/board-table.component.ts) ​

typescript
// frontend/src/app/components/board-table.component.ts
import {
  Component,
  ChangeDetectionStrategy,
  TemplateRef,
  computed,
  contentChild,
  input,
  output
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';

// 🌟 컬럼 명세 인터페이스: key는 반드시 T 객체에 존재하는 문자열 키여야 함!
export interface TableColumn<T> {
  key: keyof T & string;
  header: string;
  width?: string;
  align?: 'left' | 'center' | 'right';
}

// 부모의 #cellTemplate으로 전달할 컨텍스트 타입
export interface CellTemplateContext<T> {
  $implicit: T;           // let-row 로 바로 받을 수 있는 기본 행 데이터
  row: T;                 // let-row="row" 명시적 바인딩용
  columnKey: keyof T & string;
  value: T[keyof T & string];
}

// 부모의 #actionTemplate으로 전달할 컨텍스트 타입
export interface ActionTemplateContext<T> {
  $implicit: T;
  row: T;
}

@Component({
  selector: 'app-board-table',
  standalone: true,
  imports: [NgTemplateOutlet],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="table-wrapper">
      <table class="board-table">
        <thead>
          <tr>
            @for (col of columns(); track col.key) {
              <th
                [style.width]="col.width ?? 'auto'"
                [style.text-align]="col.align ?? 'left'"
              >
                {{ col.header }}
              </th>
            }
            @if (actionTemplate()) {
              <th style="width: 140px; text-align: center;">동작</th>
            }
          </tr>
        </thead>

        <tbody>
          @if (loading()) {
            <tr>
              <td [attr.colspan]="totalColumns()" class="status-cell">
                ⏳ 데이터를 불러오는 중입니다...
              </td>
            </tr>
          } @else {
            @for (item of items(); track item.id) {
              <tr class="table-row" (click)="rowClick.emit(item)">
                @for (col of columns(); track col.key) {
                  <td [style.text-align]="col.align ?? 'left'">
                    <!-- 🌟 부모가 #cellTemplate을 제공했으면 커스텀 렌더링, 아니면 기본 속성값 출력 -->
                    @if (cellTemplate(); as customCell) {
                      <ng-container
                        *ngTemplateOutlet="
                          customCell;
                          context: {
                            $implicit: item,
                            row: item,
                            columnKey: col.key,
                            value: item[col.key]
                          }
                        "
                      />
                    } @else {
                      {{ item[col.key] }}
                    }
                  </td>
                }

                @if (actionTemplate(); as customAction) {
                  <td class="action-cell" (click)="$event.stopPropagation()">
                    <ng-container
                      *ngTemplateOutlet="
                        customAction;
                        context: { $implicit: item, row: item }
                      "
                    />
                  </td>
                }
              </tr>
            } @empty {
              <tr>
                <td [attr.colspan]="totalColumns()" class="status-cell">
                  {{ emptyMessage() }}
                </td>
              </tr>
            }
          }
        </tbody>
      </table>
    </div>
  `,
  styles: [`
    .table-wrapper { overflow-x: auto; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; }
    .board-table { width: 100%; border-collapse: collapse; font-size: 14px; }
    .board-table th {
      background: #f8fafc; color: #475569; font-weight: 600;
      padding: 12px 14px; border-bottom: 1px solid #e2e8f0;
    }
    .board-table td { padding: 12px 14px; border-bottom: 1px solid #f1f5f9; color: #1e293b; }
    .table-row { cursor: pointer; transition: background-color 0.15s ease; }
    .table-row:hover { background-color: #f8fafc; }
    .action-cell { text-align: center; white-space: nowrap; }
    .status-cell { text-align: center; padding: 36px 16px; color: #64748b; }
  `]
})
export class BoardTableComponent<T extends { id: number | string }> {
  // 1. 필수 제네릭 입력 신호
  readonly items = input.required<T[]>();
  readonly columns = input.required<TableColumn<T>[]>();

  // 2. 옵션 입력 신호
  readonly loading = input<boolean>(false);
  readonly emptyMessage = input<string>('등록된 데이터가 없습니다.');

  // 3. 행 클릭 출력 신호 (T 타입 그대로 방출)
  readonly rowClick = output<T>();

  // 4. 🌟 부모가 투영(Content Projection)한 커스텀 템플릿 조각을 신호로 탐색
  readonly cellTemplate = contentChild<TemplateRef<CellTemplateContext<T>>>('cellTemplate');
  readonly actionTemplate = contentChild<TemplateRef<ActionTemplateContext<T>>>('actionTemplate');

  // 전체 열 개수 계산 (동작 열 포함 여부 자동 반영)
  readonly totalColumns = computed(
    () => this.columns().length + (this.actionTemplate() ? 1 : 0)
  );
}

🆕 처음 보는 문법: keyof T & string과 contentChild + ngTemplateOutlet

  • keyof T & string: T가 PostResponse라면 keyof T는 'id' | 'title' | 'author' | ... 유니온이 됩니다. 여기에 & string을 교차시켜 Symbol이나 숫자 키를 배제하고 순수 문자열 키만 허용합니다.
  • contentChild('cellTemplate'): 부모 컴포넌트가 <app-board-table> ... </app-board-table> 태그 사이에 넣어준 <ng-template #cellTemplate>을 찾아 Signal로 반환합니다. 부모가 템플릿을 넣지 않았으면 undefined가 됩니다.
  • *ngTemplateOutlet="customCell; context: { $implicit: item, columnKey: col.key, value: item[col.key] }": 자식이 보유한 행 데이터(item)와 현재 열 이름(col.key)을 부모의 <ng-template>으로 거꾸로 주입(역제어, Inversion of Control)하여 렌더링합니다. Vue의 Scoped Slot, React의 Render Props와 정확히 같은 역할을 수행합니다.

8.3 [실습 2] BoardAppComponent에서 제네릭 테이블 장착하기 ​

이제 BoardAppComponent의 원시 <table> 태그를 걷어내고 <app-board-table>을 장착합니다. [items]="posts()"를 바인딩하는 순간, Angular 컴파일러는 <app-board-table>의 T를 자동으로 PostResponse로 추론합니다!

[코드 8-2] BoardAppComponent 전체 통합 코드 (frontend/src/app/board-app/board-app.component.ts) ​

typescript
// frontend/src/app/board-app/board-app.component.ts
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, viewChild } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import {
  PostResponse,
  CreatePostRequest,
  isPageResponse,
  isPostResponse
} from '../models/post.model';
import { BoardTableComponent, TableColumn } from '../components/board-table.component';
import { PostCreateModalComponent } from '../components/post-create-modal.component';
import { PostDetailModalComponent } from '../components/post-detail-modal.component';

@Component({
  selector: 'app-board-app',
  standalone: true,
  imports: [BoardTableComponent, PostCreateModalComponent, PostDetailModalComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="board-container">
      <header class="board-header">
        <h2>사내 기술 Q&A 게시판 (v3.0 제네릭 테이블)</h2>
        <button type="button" class="btn-primary" (click)="isCreateModalOpen.set(true)">
          ✏️ 새 글 쓰기
        </button>
      </header>

      <!-- 🌟 하드코딩된 <table>을 제네릭 BoardTableComponent로 교체 -->
      <app-board-table
        [items]="posts()"
        [columns]="columns"
        [loading]="isLoading()"
        emptyMessage="등록된 게시글이 없습니다. 첫 질문을 남겨보세요!"
        (rowClick)="selectedPost.set($event)"
      >
        <!-- 1. 셀 커스텀 템플릿: 카테고리와 제목 열만 특별하게 꾸미고 나머지는 기본값 출력 -->
        <ng-template #cellTemplate let-row let-columnKey="columnKey" let-value="value">
          @switch (columnKey) {
            @case ('category') {
              <span class="cat-chip" [attr.data-cat]="row.category">{{ row.category }}</span>
            }
            @case ('title') {
              <span class="post-title">{{ row.title }}</span>
              @if (row.likeCount >= 5) {
                <span class="hot-badge">🔥 인기</span>
              }
            }
            @default {
              {{ value }}
            }
          }
        </ng-template>

        <!-- 2. 동작 열 템플릿: 삭제 버튼 배치 -->
        <ng-template #actionTemplate let-row>
          <button
            type="button"
            class="btn-delete"
            (click)="handleDeletePost(row.id)"
          >
            삭제
          </button>
        </ng-template>
      </app-board-table>

      <app-post-create-modal
        [isOpen]="isCreateModalOpen()"
        [submitting]="isSubmitting()"
        (closed)="isCreateModalOpen.set(false)"
        (submitted)="handleCreatePost($event)"
      />

      <app-post-detail-modal
        [post]="selectedPost()"
        (closed)="selectedPost.set(null)"
        (deleted)="handleDeletePost($event)"
      />
    </div>
  `,
  styles: [`
    .board-container { max-width: 960px; margin: 24px auto; padding: 0 16px; }
    .board-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
    .btn-primary { background: #2563eb; color: #fff; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; }
    .cat-chip { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 999px; background: #e2e8f0; color: #334155; }
    .post-title { font-weight: 600; color: #0f172a; }
    .hot-badge { margin-left: 8px; background: #fef2f2; color: #ef4444; font-size: 11px; padding: 2px 6px; border-radius: 999px; }
    .btn-delete { background: #fee2e2; color: #ef4444; border: 1px solid #fca5a5; padding: 4px 10px; border-radius: 4px; cursor: pointer; }
  `]
})
export class BoardAppComponent implements OnInit {
  private readonly http = inject(HttpClient);
  private readonly createModal = viewChild(PostCreateModalComponent);

  readonly posts = signal<PostResponse[]>([]);
  readonly isLoading = signal(false);
  readonly isCreateModalOpen = signal(false);
  readonly isSubmitting = signal(false);
  readonly selectedPost = signal<PostResponse | null>(null);

  // 🌟 TableColumn<PostResponse> 타입 덕분에 key에 오타를 내면 즉시 컴파일 에러!
  readonly columns: TableColumn<PostResponse>[] = [
    { key: 'id', header: 'ID', width: '70px', align: 'center' },
    { key: 'category', header: '분류', width: '90px', align: 'center' },
    { key: 'title', header: '제목' },
    { key: 'author', header: '작성자', width: '110px', align: 'center' },
    { key: 'createdAt', header: '작성일', width: '140px', align: 'center' }
  ];

  ngOnInit(): void {
    this.loadPosts();
  }

  loadPosts(): void {
    this.isLoading.set(true);
    this.http.get<unknown>('/api/posts?page=1&size=10').subscribe({
      next: (raw) => {
        if (isPageResponse(raw, isPostResponse)) {
          this.posts.set(raw.content);
        } else if (Array.isArray(raw) && raw.every(isPostResponse)) {
          this.posts.set(raw);
        }
        this.isLoading.set(false);
      },
      error: () => this.isLoading.set(false)
    });
  }

  handleCreatePost(payload: CreatePostRequest): void {
    this.isSubmitting.set(true);
    this.http.post<PostResponse>('/api/posts', payload).subscribe({
      next: () => {
        this.isSubmitting.set(false);
        this.isCreateModalOpen.set(false);
        this.createModal()?.resetForm();
        this.loadPosts();
      },
      error: () => this.isSubmitting.set(false)
    });
  }

  handleDeletePost(postId: number): void {
    if (!confirm('정말 이 게시글을 삭제하시겠습니까?')) return;
    this.http.delete(`/api/posts/${postId}`).subscribe({
      next: () => {
        this.selectedPost.set(null);
        this.posts.update((list) => list.filter((p) => p.id !== postId));
      }
    });
  }
}
🔬 심화(건너뛰어도 됨): ngTemplateContextGuard로 <ng-template> 내부 변수까지 완벽히 타입 추론하기

기본적으로 Angular의 <ng-template let-row> 변수는 any로 추론될 수 있습니다. 만약 템플릿 내부의 let-row까지 PostResponse로 칼같이 추론하게 만들고 싶다면, 지시어(Directive)에 정적 메서드 static ngTemplateContextGuard를 선언하는 패턴을 조합할 수 있습니다.

typescript
@Directive({ selector: 'ng-template[appTableCell]', standalone: true })
export class TableCellDirective<T> {
  readonly appTableCell = input.required<T[]>();
  static ngTemplateContextGuard<T>(
    _dir: TableCellDirective<T>,
    _ctx: unknown
  ): _ctx is CellTemplateContext<T> {
    return true;
  }
}

이렇게 하면 <ng-template [appTableCell]="posts()" let-row> 안에서 row.을 찍는 순간 IDE가 PostResponse의 모든 속성을 자동완성해 줍니다!


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

🎯 미션: 인사팀 직원 목록(Employee)에 BoardTableComponent 재사용하기 ​

BoardTableComponent를 단 한 줄도 수정하지 않고, 아래 Employee 인터페이스를 표로 출력하는 EmployeeListComponent의 columns 배열을 정의해 보세요. (salary처럼 Employee에 없는 키를 넣었을 때 컴파일 에러가 나는지도 확인해 보세요!)

  • interface Employee { id: string; name: string; department: string; joinedAt: string; }
💡 미션 정답 코드 확인하기
typescript
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { BoardTableComponent, TableColumn } from './board-table.component';

export interface Employee {
  id: string;
  name: string;
  department: string;
  joinedAt: string;
}

@Component({
  selector: 'app-employee-list',
  standalone: true,
  imports: [BoardTableComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h3>👥 사내 임직원 명부</h3>
    <app-board-table [items]="employees()" [columns]="columns" />
  `
})
export class EmployeeListComponent {
  readonly employees = signal<Employee[]>([
    { id: 'EMP-001', name: '김개발', department: '플랫폼개발팀', joinedAt: '2024-03-02' },
    { id: 'EMP-002', name: '이아키', department: '코어아키텍처팀', joinedAt: '2022-07-15' }
  ]);

  // ✅ T가 Employee로 추론되어 'id' | 'name' | 'department' | 'joinedAt'만 허용됩니다!
  readonly columns: TableColumn<Employee>[] = [
    { key: 'id', header: '사번', width: '100px', align: 'center' },
    { key: 'name', header: '성명', width: '120px' },
    { key: 'department', header: '소속 부서' },
    { key: 'joinedAt', header: '입사일', width: '130px', align: 'center' }
  ];
}

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

핵심 3줄

  1. export class BoardTableComponent<T extends { id: number | string }>로 선언하면 부모가 넘긴 [items] 배열 타입으로부터 T가 자동 추론됩니다.
  2. TableColumn<T>의 key: keyof T & string 제약 덕분에 컬럼 키 오타가 브라우저를 띄우기 전에 컴파일 에러로 차단됩니다.
  3. contentChild와 *ngTemplateOutlet을 결합하면 공통 테이블 코드를 고치지 않고도 부모가 원하는 셀의 UI를 자유자재로 커스터마이징할 수 있습니다.

바뀐 파일

파일무엇이 바뀌었나
src/app/components/board-table.component.ts제네릭 테이블 컴포넌트, TableColumn<T>, cellTemplate/actionTemplate 슬롯 구현
src/app/board-app/board-app.component.ts원시 <table> 제거 후 <app-board-table> 장착

자주 하는 실수

증상원인해결
Type '"creatAt"' is not assignable to type '"id" | "title" | ...'columns 배열의 key에 오타 발생PostResponse에 정의된 정확한 속성명으로 수정
삭제 버튼을 눌렀는데 상세 조회 모달까지 같이 열림클릭 이벤트가 상위 <tr>의 (click)="rowClick.emit(item)"으로 버블링됨action-cell의 <td>에 (click)="$event.stopPropagation()" 적용 (코드 8-1에 포함됨)
#cellTemplate을 넣었는데 모든 열에 똑같은 내용만 나옴@switch (columnKey) 분기 없이 한 가지 마크업만 작성함@switch (columnKey)와 @default로 기본 출력(&#123;&#123; value &#125;&#125;) 분기 처리

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

  1. BoardTableComponent<T>의 타입 매개변수에 extends { id: number | string } 제약을 건 이유는 무엇인가요?
  2. 부모 컴포넌트가 <ng-template #actionTemplate>을 제공하지 않으면 "동작" 헤더 열과 colspan은 어떻게 처리되나요?
  3. *ngTemplateOutlet의 context 객체에서 $implicit 키는 어떤 역할을 하나요?
셀프 체크 정답
  1. 템플릿의 @for (item of items(); track item.id)에서 각 행의 고유 추적 키로 item.id를 안전하게 참조하고, 원시값 배열(string[])이 잘못 전달되는 것을 막기 위해서입니다.
  2. actionTemplate() 신호가 undefined가 되어 헤더의 <th>동작</th> 자체가 렌더링되지 않으며, totalColumns computed 신호도 columns().length + 0으로 정확히 계산됩니다.
  3. 부모 템플릿에서 let-row처럼 값을 지정하지 않은 기본 변수 선언에 자동으로 바인딩되는 기본값(Default Context Value) 역할을 합니다.

다음 챕터로 이어지는 질문
UI는 제네릭 테이블로 완벽히 모듈화되었습니다. 그런데 사용자가 다른 메뉴(예: 로그인 화면)에 갔다가 게시판으로 돌아올 때마다 BoardAppComponent가 파괴되었다가 다시 생성되면서 서버에 똑같은 목록 조회 API를 매번 다시 요청하고 화면이 깜빡입니다.
이어지는 Chapter 09에서는 컴포넌트 로컬 상태를 떠나 **캡슐화된 전역 PostStore (Signals Store)**와 1분 TTL 캐싱을 구축합니다.

Moon HomeLab Engineering Knowledge Hub