Skip to content

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 타입 자동 생성, ZodSwagger 기반 타입 생성과 런타임 스키마 파싱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 id1numberJS number는 2^53 - 1(약 9천조)까지만 정확합니다. Snowflake 같은 64비트 ID 체계라면 서버에서 문자열로 직렬화하고 TS도 string으로 받아야 합니다.
String title"제목"stringDB에서 null이 올 수 있는 컬럼이면 string | null로 정직하게 표현합니다.
LocalDate createdAt"2026-10-29"stringJackson은 날짜를 ISO-8601 문자열로 직렬화합니다. Date 객체가 아니므로 string으로 선언합니다.
int viewCount15numberJava 원시 타입 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를 옮기지 않고 자동화 도구를 결합합니다.

  1. 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초 만에 생성합니다.
  2. 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줄

  1. 프론트엔드 타입은 Java 타입이 아니라 JSON으로 직렬화된 결과를 기준으로 매핑합니다(Long → number, LocalDate → string).
  2. Post, CreatePostRequest(Omit 활용), PageResult<T>를 types/board.ts 한 곳에 정의하면 백엔드 DTO가 바뀔 때 영향받는 모든 컴포넌트가 컴파일 타임에 드러납니다.
  3. 외부 입력(URL 쿼리, localStorage)은 사용자 정의 타입 가드(x is T)로 검증하고, 다형성 모델은 판별된 유니온 + never 완전성 검사로 방어합니다.

바뀐 파일

파일무엇이 바뀌었나
src/types/board.ts, src/types/post.ts도메인 모델, CreatePostRequest, PageResult<T>, 타입 가드 정의
src/components/PostCreateModal.tsxCreatePostRequest 계약 기반의 글 작성 모달 컴포넌트
src/components/PostDetailModal.tsxPost | 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로 지정

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

  1. 백엔드의 LocalDate createdAt을 TypeScript에서 Date가 아니라 string으로 받는 이유는 무엇인가요?
  2. CreatePostRequest를 직접 필드로 나열하지 않고 Omit<Post, ...>으로 정의하면 어떤 장점이 있나요?
  3. formatPostBadge에서 case 'SECRET': 분기를 지우면 어느 줄에서 컴파일 에러가 나고, 왜 그런가요?
셀프 체크 정답
  1. JSON 규격에는 날짜 타입이 없고, Spring Boot(Jackson)가 LocalDate를 "2026-10-29" 같은 ISO-8601 문자열로 직렬화해 내려보내기 때문입니다.
  2. Post 인터페이스의 필드명이나 타입(예: category: PostCategory)이 변경될 때 CreatePostRequest에도 자동으로 반영되어 이중 관리 실수를 막아줍니다.
  3. 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) 패턴을 활용하여, 어떤 도메인 객체든 안전하게 그려내는 만능 공통 테이블을 제작해 보겠습니다.

Moon HomeLab Engineering Knowledge Hub