다크 모드
Chapter 07. 게시판 DTO와 컴포넌트 경계선의 타입 계약
"6장까지 발전시킨
BoardApp.tsx의 상태들을 살펴보자. 백엔드 Spring Boot는 엄격한 클래스로 데이터를 검증하는데, 왜 프론트엔드는 여전히 느슨한 타입이나any에 의존하고 있는가? 도메인 계약서와 타입 가드, 그리고 컴포넌트 Props 경계선으로 빈틈없는 방어선을 구축하자."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
Java DTO, Lombok @Getter/@Builder | 백엔드가 JSON으로 내려줄 데이터 전송 클래스 | 7.1 🆕 박스 |
| Java → JSON → TS 타입 매핑 | Long, LocalDate 등을 TS 타입으로 옮기는 기준 | 7.1 매핑표 |
문자열 리터럴 유니온, Omit<T, K> | 허용 값을 제한하고 기존 타입에서 일부 필드를 제외한 타입 생성 | 7.1 [코드 7-1] |
제네릭 인터페이스 PageResult<T> | 내용물 타입을 사용하는 시점에 결정하는 페이징 응답 틀 | 7.1 🆕 박스 → 8장 |
사용자 정의 타입 가드 (x is Post) | 런타임에 외부 JSON이 실제 타입과 일치하는지 검증하는 함수 | 7.2 [코드 7-2] |
판별된 유니온, never 완전성 검사 | 종류 필드로 타입을 좁히고 누락된 분기를 컴파일 타임에 포착 | 7.2 |
| 모달 컴포넌트 Props 계약 | PostCreateModal, PostDetailModal 입출력 타입 엄격화 | 7.3 [코드 7-3, 7-4] |
| OpenAPI 타입 자동 생성, Zod | Swagger 기반 타입 생성과 런타임 스키마 파싱 | 7.2 🔬 심화 |
TIP
🏁 이 장의 시작 파일frontend/src/BoardApp.tsx가 snapshots/BoardApp.ch06.tsx와 같은 상태에서 시작합니다(막혔다면 그 파일을 복사해 오십시오). 이 장에서 손대는 파일: frontend/src/types/board.ts (및 하위 호환 별칭 frontend/src/types/post.ts), frontend/src/components/PostCreateModal.tsx, frontend/src/components/PostDetailModal.tsx, frontend/src/BoardApp.tsx
7.1 Spring Boot DTO와 TypeScript 인터페이스의 1:1 매핑
우리 사내 백엔드(backend/src/main/java/com/company/board/post/PostDto.java)는 다음과 같이 명확한 DTO 클래스로 클라이언트에 내려줄 데이터를 통제하고 있습니다:
java
// backend/src/main/java/com/company/board/post/PostDto.java
public class PostDto {
@Getter
@Builder
@AllArgsConstructor
public static class Response {
private Long id;
private String title;
private String author;
private String content;
private String category; // 'DEV', 'NOTICE', 'FREE'
private LocalDate createdAt;
private int viewCount;
private int likeCount;
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class CreateRequest {
@NotBlank @Size(min = 2, max = 100)
private String title;
@NotBlank
private String author;
@NotBlank @Size(min = 10)
private String content;
private String category = "FREE";
}
}🆕 처음 보는 문법: Java DTO와 Lombok, 그리고 TypeScript 유틸리티 타입
- DTO(Data Transfer Object): 서버와 클라이언트가 주고받는 데이터의 모양을 정의한 클래스입니다. 프론트엔드의
interface Post가 이 클래스의 1:1 짝입니다. @Getter,@Builder,@AllArgsConstructor: Lombok 애너테이션으로, Spring Boot의 Jackson 라이브러리는 getter 메서드를 기준으로 객체를 JSON 문자열로 직렬화합니다(getTitle()→"title").Omit<Post, 'id' | 'createdAt' | 'viewCount' | 'likeCount'>: 기존Post인터페이스에서 서버가 자동 생성하는 4개 필드를 제외한 나머지 필드(title,author,content,category)만 추려 새로운 타입을 만듭니다. 수동으로 필드를 중복 선언하지 않아Post가 바뀌면 요청 타입도 자동으로 동기화됩니다.PageResult<T>:T는 "아직 정하지 않은 타입"의 자리입니다. 게시글 목록이면PageResult<Post>, 직원 목록이면PageResult<Employee>처럼 쓰는 쪽에서 타입을 채웁니다.
📦 비유: 깐깐한 공항 세관(Java DTO) vs 라벨 없는 미확인 밀수 택배(
any)
백엔드 Spring Boot는@NotBlank,@Size를 붙여 공항 세관원처럼 데이터를 샅샅이 검사해서 보냅니다. 그런데 프론트엔드에서 귀찮다고const res = await axios.get<any>('/api/posts')로 받는 순간, "아 몰라, 아무거나 상자에 담아 와라!" 하고 눈을 질끈 감아버리는 것과 같습니다.
배송된 상자에 진짜 데이터가 들었는지 폭탄이 들었는지 까보기 전까지 모르다가, 결국 운영 서버 브라우저에서Cannot read properties of undefined로 화면이 폭발하고 맙니다! 프론트엔드도 백엔드 DTO와 토씨 하나 틀리지 않는 **1:1 타입 계약서(interface Post)**를 써야 두 다리 뻗고 잘 수 있습니다.
Java 타입 → JSON → TypeScript 타입 매핑표
Java 클래스를 TypeScript로 옮길 때는 **Java 타입이 아니라 "JSON으로 직렬화된 결과"**를 기준으로 매핑해야 합니다.
Java (PostDto.Response) | JSON으로 내려오는 값 | TypeScript | 주의할 점 |
|---|---|---|---|
Long id | 1 | number | JS number는 2^53 - 1(약 9천조)까지만 정확합니다. Snowflake 같은 64비트 ID 체계라면 서버에서 문자열로 직렬화하고 TS도 string으로 받아야 합니다. |
String title | "제목" | string | DB에서 null이 올 수 있는 컬럼이면 string | null로 정직하게 표현합니다. |
LocalDate createdAt | "2026-10-29" | string | Jackson은 날짜를 ISO-8601 문자열로 직렬화합니다. Date 객체가 아니므로 string으로 선언합니다. |
int viewCount | 15 | number | Java 원시 타입 int는 null이 될 수 없으므로 옵셔널(?)을 붙이지 않습니다. |
String category | "DEV" | 'DEV' | 'NOTICE' | 'FREE' | Java 쪽이 문자열이라도 허용 값이 정해져 있으므로 TS에서는 문자열 리터럴 유니온으로 좁혀 오타를 컴파일 타임에 잡습니다. |
[코드 7-1] 도메인 모델 계약서 (frontend/src/types/board.ts & post.ts)
프로젝트의 모든 게시판 타입과 런타임 타입 가드를 한곳에 모아 src/types/board.ts를 작성합니다. (기존 챕터와의 import 호환을 위해 src/types/post.ts에서 export * from './board'로 재내보내기합니다.)
typescript
// frontend/src/types/board.ts
export type PostCategory = 'DEV' | 'NOTICE' | 'FREE';
// 검색 필터 드롭다운용 타입 ('ALL'은 화면 전용 값)
export type CategoryFilter = PostCategory | 'ALL';
// 1. 백엔드 PostDto.Response와 1:1 매핑되는 핵심 도메인 모델
export interface Post {
id: number;
title: string;
author: string;
content: string;
category: PostCategory;
createdAt: string;
viewCount: number;
likeCount: number;
}
// 2. 등록 요청 DTO (서버 자동 생성 필드 제외)
export type CreatePostRequest = Omit<
Post,
'id' | 'createdAt' | 'viewCount' | 'likeCount'
>;
// 3. 수정 요청 DTO (제목, 본문, 카테고리만 부분 수정 허용)
export type UpdatePostRequest = Partial<Pick<Post, 'title' | 'content' | 'category'>>;
// 4. 페이징 응답 DTO (제네릭 인터페이스)
export interface PageResult<T> {
items: T[];
total: number;
page: number;
size: number;
totalPages: number;
}
// 5. 허용된 카테고리인지 런타임에 검증하는 상수 배열과 타입 가드
export const VALID_CATEGORIES: readonly PostCategory[] = ['DEV', 'NOTICE', 'FREE'] as const;
export function isPostCategory(value: unknown): value is PostCategory {
return typeof value === 'string' && (VALID_CATEGORIES as readonly string[]).includes(value);
}
// 6. 외부 JSON 응답이 Post 계약을 만족하는지 검사하는 사용자 정의 타입 가드
export function isPost(value: unknown): value is Post {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate.id === 'number' &&
typeof candidate.title === 'string' &&
typeof candidate.author === 'string' &&
typeof candidate.content === 'string' &&
isPostCategory(candidate.category) &&
typeof candidate.createdAt === 'string' &&
typeof candidate.viewCount === 'number' &&
typeof candidate.likeCount === 'number'
);
}typescript
// frontend/src/types/post.ts (기존 코드 호환용 배럴 파일)
export * from './board';7.2 타입 가드와 판별된 유니온(Discriminated Unions)
TypeScript의 타입은 빌드 시점에 모두 제거(Type Erasure)됩니다. 따라서 URL 쿼리스트링(?category=HACK)이나 외부 스토리지에서 읽은 unknown 값은 **사용자 정의 타입 가드(value is PostCategory)**를 통과시켜야 안전하게 좁혀집니다.
또한 게시판에 **일반글(GENERAL), 공지글(NOTICE - 만료일 필수), 비밀글(SECRET - 비밀번호 힌트 필수)**처럼 종류마다 필수 필드가 다른 다형성 요구사항이 들어왔다면 옵셔널(?)을 남발하는 대신 **판별된 유니온(Discriminated Unions)**을 사용합니다:
[코드 7-2] 판별된 유니온과 never 완전성 검사 (frontend/src/types/board.ts 하단 추가)
typescript
// frontend/src/types/board.ts (하단 추가)
export interface BaseCommunityPost {
id: number;
title: string;
author: string;
createdAt: string;
}
export interface GeneralPost extends BaseCommunityPost {
postType: 'GENERAL';
content: string;
}
export interface NoticePost extends BaseCommunityPost {
postType: 'NOTICE';
content: string;
pinnedUntil: string; // 공지글에는 상단 고정 만료일이 필수!
}
export interface SecretPost extends BaseCommunityPost {
postType: 'SECRET';
passwordHint: string; // 비밀글에는 힌트가 필수! (본문은 잠금 해제 전 미포함)
}
export type CommunityPost = GeneralPost | NoticePost | SecretPost;
export function formatPostBadge(post: CommunityPost): string {
switch (post.postType) {
case 'GENERAL':
return '일반';
case 'NOTICE':
// ✅ 여기서 post는 자동으로 NoticePost로 좁혀져 pinnedUntil 자동완성!
return `📌 공지 (~${post.pinnedUntil})`;
case 'SECRET':
// ✅ 여기서 post는 자동으로 SecretPost로 좁혀져 passwordHint 자동완성!
return `🔒 비밀글 (힌트: ${post.passwordHint})`;
default: {
// 🌟 완전성 검사(Exhaustiveness Check): 모든 분기를 처리했다면 post는 never 타입
const _exhaustiveCheck: never = post;
return _exhaustiveCheck;
}
}
}🔬 심화(건너뛰어도 됨): OpenAPI 타입 자동 생성과 Zod 런타임 스키마 검증
수백 개의 API가 있는 대규모 프로젝트에서는 손으로 DTO를 옮기지 않고 자동화 도구를 결합합니다.
springdoc-openapi+openapi-typescript: Spring Boot 서버의/v3/api-docs명세로부터npx openapi-typescript http://localhost:8080/v3/api-docs -o src/types/api.d.ts명령 한 줄로 전체 DTO 타입을 1초 만에 생성합니다.- Zod 런타임 검증:
const PostSchema = z.object({ id: z.number(), ... })를 선언하면type Post = z.infer<typeof PostSchema>로 정적 타입 추출과PostSchema.parse(data)런타임 검증을 동시에 해결할 수 있습니다.
7.3 컴포넌트 경계선의 타입 계약: 등록 모달과 상세 모달
도메인 타입 계약서가 준비되었다면, 이제 부모(BoardApp.tsx)와 자식 모달 컴포넌트들이 주고받는 Props와 콜백 시그니처를 엄격하게 잠글 차례입니다.
[코드 7-3] 새 글 작성 모달 (frontend/src/components/PostCreateModal.tsx)
tsx
// frontend/src/components/PostCreateModal.tsx
import React, { useState } from 'react';
import type { CreatePostRequest, PostCategory } from '@/types/board';
import { VALID_CATEGORIES } from '@/types/board';
export interface PostCreateModalProps {
isOpen: boolean;
isSubmitting?: boolean;
onClose: () => void;
// 🌟 부모에게 전달할 페이로드는 반드시 CreatePostRequest 계약을 준수해야 함!
onSubmit: (payload: CreatePostRequest) => Promise<void>;
}
export function PostCreateModal({
isOpen,
isSubmitting = false,
onClose,
onSubmit
}: PostCreateModalProps) {
const [title, setTitle] = useState('');
const [author, setAuthor] = useState('');
const [category, setCategory] = useState<PostCategory>('DEV');
const [content, setContent] = useState('');
const [errorMsg, setErrorMsg] = useState<string | null>(null);
if (!isOpen) return null;
async function handleFormSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (title.trim().length < 2) {
setErrorMsg('제목은 최소 2자 이상 입력해주세요.');
return;
}
if (content.trim().length < 10) {
setErrorMsg('본문은 최소 10자 이상 입력해주세요.');
return;
}
setErrorMsg(null);
const payload: CreatePostRequest = {
title: title.trim(),
author: author.trim() || '익명 사우',
category,
content: content.trim()
};
await onSubmit(payload);
setTitle('');
setAuthor('');
setCategory('DEV');
setContent('');
onClose();
}
return (
<div className="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="create-modal-title">
<div className="modal-panel">
<header className="modal-header">
<h3 id="create-modal-title">✏️ 새 기술 질문 등록</h3>
<button type="button" onClick={onClose} aria-label="닫기">✕</button>
</header>
<form onSubmit={handleFormSubmit} className="modal-body">
{errorMsg && <p className="error-banner" role="alert">{errorMsg}</p>}
<label className="form-field">
<span>카테고리</span>
<select
value={category}
onChange={(e) => setCategory(e.target.value as PostCategory)}
>
{VALID_CATEGORIES.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</label>
<label className="form-field">
<span>제목</span>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="제목을 입력하세요 (2자 이상)"
required
/>
</label>
<label className="form-field">
<span>작성자</span>
<input
type="text"
value={author}
onChange={(e) => setAuthor(e.target.value)}
placeholder="작성자 이름"
required
/>
</label>
<label className="form-field">
<span>본문</span>
<textarea
rows={5}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="질문 내용을 상세히 적어주세요 (10자 이상)"
required
/>
</label>
<footer className="modal-footer">
<button type="button" onClick={onClose} disabled={isSubmitting}>취소</button>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '등록 중...' : '게시글 등록'}
</button>
</footer>
</form>
</div>
</div>
);
}[코드 7-4] 게시글 상세 조회 모달 (frontend/src/components/PostDetailModal.tsx)
tsx
// frontend/src/components/PostDetailModal.tsx
import React from 'react';
import type { Post } from '@/types/board';
export interface PostDetailModalProps {
post: Post | null;
onClose: () => void;
onLike: (postId: number) => void;
onDelete: (postId: number) => void;
}
export function PostDetailModal({
post,
onClose,
onLike,
onDelete
}: PostDetailModalProps) {
// post가 null이면 렌더링하지 않음 (이후 아래에서 post는 Non-null Post로 완벽 추론됨!)
if (!post) return null;
return (
<div className="modal-backdrop" role="dialog" aria-modal="true">
<article className="modal-panel">
<header className="modal-header">
<span className="category-badge">{post.category}</span>
<h3>{post.title}</h3>
<button type="button" onClick={onClose} aria-label="닫기">✕</button>
</header>
<div className="post-meta">
<span>작성자: <strong>{post.author}</strong></span>
<span>작성일: {post.createdAt}</span>
<span>조회수: {post.viewCount}</span>
</div>
<div className="post-content">
<p>{post.content}</p>
</div>
<footer className="modal-footer">
<button type="button" className="btn-like" onClick={() => onLike(post.id)}>
❤️ 좋아요 ({post.likeCount})
</button>
<button
type="button"
className="btn-delete"
onClick={() => {
onDelete(post.id);
onClose();
}}
>
삭제
</button>
</footer>
</article>
</div>
);
}[코드 7-5] BoardApp.tsx에서 타입 계약 및 모달 연결
tsx
// frontend/src/BoardApp.tsx (7장 핵심 적용 부분)
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import type { Post, PageResult, CreatePostRequest } from '@/types/board';
import { PostCreateModal } from '@/components/PostCreateModal';
import { PostDetailModal } from '@/components/PostDetailModal';
export default function BoardApp() {
const [posts, setPosts] = useState<Post[]>([]);
const [selectedPost, setSelectedPost] = useState<Post | null>(null);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
async function loadPosts() {
// 🌟 PageResult<Post> 계약으로 응답 구조를 컴파일 타임에 보장
const res = await axios.get<PageResult<Post>>('/api/posts', {
params: { page: 1, size: 10 }
});
setPosts(res.data.items);
}
async function handleCreatePost(payload: CreatePostRequest) {
setIsSubmitting(true);
try {
await axios.post<Post>('/api/posts', payload);
await loadPosts();
} finally {
setIsSubmitting(false);
}
}
async function handleLikePost(postId: number) {
const res = await axios.post<Post>(`/api/posts/${postId}/like`);
setPosts((prev) => prev.map((p) => (p.id === postId ? res.data : p)));
setSelectedPost((prev) => (prev?.id === postId ? res.data : prev));
}
async function handleDeletePost(postId: number) {
await axios.delete(`/api/posts/${postId}`);
setPosts((prev) => prev.filter((p) => p.id !== postId));
}
useEffect(() => {
loadPosts();
}, []);
return (
<div className="board-container">
<header className="board-header">
<h2>사내 기술 Q&A 게시판 (v2.7)</h2>
<button type="button" onClick={() => setIsCreateOpen(true)}>
✏️ 새 질문 등록
</button>
</header>
<table className="board-table">
<thead>
<tr>
<th>ID</th><th>카테고리</th><th>제목</th><th>작성자</th><th>작성일</th><th>동작</th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.id}>
<td>{post.id}</td>
<td>{post.category}</td>
<td className="title-cell" onClick={() => setSelectedPost(post)}>
{post.title}
</td>
<td>{post.author}</td>
<td>{post.createdAt}</td>
<td>
<button type="button" onClick={() => handleDeletePost(post.id)}>삭제</button>
</td>
</tr>
))}
</tbody>
</table>
<PostCreateModal
isOpen={isCreateOpen}
isSubmitting={isSubmitting}
onClose={() => setIsCreateOpen(false)}
onSubmit={handleCreatePost}
/>
<PostDetailModal
post={selectedPost}
onClose={() => setSelectedPost(null)}
onLike={handleLikePost}
onDelete={handleDeletePost}
/>
</div>
);
}💡 [확인하기]:
frontend폴더에서npx tsc --noEmit을 실행해 에러가 0건인지 확인하십시오. 일부러post.title을post.titel로 바꾸고 다시 실행하면Property 'titel' does not exist on type 'Post'에러가 즉시 검출됩니다. 확인 후 원래대로 되돌리십시오.
7.4 [직접 해보기] 실습 미션 💪
🎯 미션: 임시저장 초안(PostDraft) 타입과 런타임 타입 가드 작성하기
10장에서 로컬 스토리지에 작성 중인 글 초안을 저장할 때 쓸 PostDraft 타입과, localStorage에서 꺼낸 JSON.parse() 결과가 올바른 PostDraft인지 검증하는 isPostDraft(value: unknown): value is PostDraft 타입 가드 함수를 작성해 보십시오.
- 요구 필드:
title: string,author: string,content: string,category: PostCategory,savedAt: number
💡 미션 정답 코드 확인하기
typescript
// frontend/src/types/board.ts 에 추가
import { isPostCategory, type CreatePostRequest } from './board';
// CreatePostRequest의 모든 필드에 savedAt 타임스탬프만 추가!
export interface PostDraft extends CreatePostRequest {
savedAt: number;
}
export function isPostDraft(value: unknown): value is PostDraft {
if (typeof value !== 'object' || value === null) return false;
const obj = value as Record<string, unknown>;
return (
typeof obj.title === 'string' &&
typeof obj.author === 'string' &&
typeof obj.content === 'string' &&
isPostCategory(obj.category) &&
typeof obj.savedAt === 'number'
);
}7.5 이번 장 핵심 요약 및 다음 단계 🚀
핵심 3줄
- 프론트엔드 타입은 Java 타입이 아니라 JSON으로 직렬화된 결과를 기준으로 매핑합니다(
Long→number,LocalDate→string). Post,CreatePostRequest(Omit활용),PageResult<T>를types/board.ts한 곳에 정의하면 백엔드 DTO가 바뀔 때 영향받는 모든 컴포넌트가 컴파일 타임에 드러납니다.- 외부 입력(URL 쿼리,
localStorage)은 사용자 정의 타입 가드(x is T)로 검증하고, 다형성 모델은 판별된 유니온 +never완전성 검사로 방어합니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
src/types/board.ts, src/types/post.ts | 도메인 모델, CreatePostRequest, PageResult<T>, 타입 가드 정의 |
src/components/PostCreateModal.tsx | CreatePostRequest 계약 기반의 글 작성 모달 컴포넌트 |
src/components/PostDetailModal.tsx | Post | null 타입 좁히기 기반의 상세 조회 모달 컴포넌트 |
src/BoardApp.tsx | 복제본 interface Post 제거 및 계약서·모달 컴포넌트 적용 |
자주 하는 실수
| 증상 | 원인 | 해결 |
|---|---|---|
Type '"ALL"' is not assignable to type 'PostCategory' | 검색 필터의 'ALL'을 PostCategory 변수에 대입함 | 필터 상태는 CategoryFilter(PostCategory | 'ALL')로 선언 |
Object is possibly 'null' (PostDetailModal) | if (!post) return null; 가드 전에 post.title에 접근함 | Early Return 가드 이후에 JSX에서 post 속성 참조 |
Property 'id' is missing in type 'CreatePostRequest' | axios.post 요청 바디 자리에 Post 타입을 잘못 지정함 | 요청 페이로드 타입은 CreatePostRequest로, 응답 타입만 Post로 지정 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
- 백엔드의
LocalDate createdAt을 TypeScript에서Date가 아니라string으로 받는 이유는 무엇인가요? CreatePostRequest를 직접 필드로 나열하지 않고Omit<Post, ...>으로 정의하면 어떤 장점이 있나요?formatPostBadge에서case 'SECRET':분기를 지우면 어느 줄에서 컴파일 에러가 나고, 왜 그런가요?
셀프 체크 정답
- JSON 규격에는 날짜 타입이 없고, Spring Boot(Jackson)가
LocalDate를"2026-10-29"같은 ISO-8601 문자열로 직렬화해 내려보내기 때문입니다. Post인터페이스의 필드명이나 타입(예:category: PostCategory)이 변경될 때CreatePostRequest에도 자동으로 반영되어 이중 관리 실수를 막아줍니다.default블록의const _exhaustiveCheck: never = post;줄입니다.'SECRET'케이스를 처리하지 않으면default까지SecretPost타입이 살아남아never타입 변수에 대입할 수 없기 때문입니다.
다음 챕터로 이어지는 질문
이제 BoardApp.tsx의 데이터와 모달 경계선은 탄탄한 타입 계약 위에서 동작합니다.
그런데 BoardApp.tsx 안에 하드코딩된 <table className="board-table"> 마크업을 보십시오. 사내 포털에는 자유게시판(Post)뿐만 아니라 공지사항(Notice), 직원 목록(Employee), 결재 내역(Approval) 등 수십 개의 테이블이 필요합니다.
이어지는 Chapter 08에서는 TypeScript의 **제네릭 컴포넌트(BoardTable<T>)**와 렌더 프롭(renderCell) 패턴을 활용하여, 어떤 도메인 객체든 안전하게 그려내는 만능 공통 테이블을 제작해 보겠습니다.