다크 모드
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 & string | T에 실제 존재하는 문자열 속성 이름만 컬럼 키로 허용 | 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가지입니다:
- 엄격한 컬럼 키 검증:
columns = input.required<TableColumn<T>[]>()로 받아T에 실제 있는 필드명만 허용합니다. - 행 클릭 이벤트: 행을 클릭하면
rowClick = output<T>()로 정확한T타입 객체를 방출합니다. - 커스텀 셀 렌더링 (
#cellTemplate): 제목 옆에🔥 인기배지를 붙이거나 카테고리 칩을 그릴 수 있도록 부모의<ng-template #cellTemplate>을contentChild로 받아ngTemplateOutlet으로 렌더링합니다. - 동작 열 (
#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줄
export class BoardTableComponent<T extends { id: number | string }>로 선언하면 부모가 넘긴[items]배열 타입으로부터T가 자동 추론됩니다.TableColumn<T>의key: keyof T & string제약 덕분에 컬럼 키 오타가 브라우저를 띄우기 전에 컴파일 에러로 차단됩니다.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로 기본 출력({{ value }}) 분기 처리 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
BoardTableComponent<T>의 타입 매개변수에extends { id: number | string }제약을 건 이유는 무엇인가요?- 부모 컴포넌트가
<ng-template #actionTemplate>을 제공하지 않으면 "동작" 헤더 열과colspan은 어떻게 처리되나요? *ngTemplateOutlet의context객체에서$implicit키는 어떤 역할을 하나요?
셀프 체크 정답
- 템플릿의
@for (item of items(); track item.id)에서 각 행의 고유 추적 키로item.id를 안전하게 참조하고, 원시값 배열(string[])이 잘못 전달되는 것을 막기 위해서입니다. actionTemplate()신호가undefined가 되어 헤더의<th>동작</th>자체가 렌더링되지 않으며,totalColumnscomputed 신호도columns().length + 0으로 정확히 계산됩니다.- 부모 템플릿에서
let-row처럼 값을 지정하지 않은 기본 변수 선언에 자동으로 바인딩되는 기본값(Default Context Value) 역할을 합니다.
다음 챕터로 이어지는 질문
UI는 제네릭 테이블로 완벽히 모듈화되었습니다. 그런데 사용자가 다른 메뉴(예: 로그인 화면)에 갔다가 게시판으로 돌아올 때마다 BoardAppComponent가 파괴되었다가 다시 생성되면서 서버에 똑같은 목록 조회 API를 매번 다시 요청하고 화면이 깜빡입니다.
이어지는 Chapter 09에서는 컴포넌트 로컬 상태를 떠나 **캡슐화된 전역 PostStore (Signals Store)**와 1분 TTL 캐싱을 구축합니다.