Skip to content

Chapter 07. 게시판 DTO와 컴포넌트 경계선의 타입 계약 ​

"6장까지 발전시킨 BoardApp.vue의 상태들을 살펴보자. 백엔드 Spring Boot는 엄격한 클래스로 데이터를 검증하는데, 왜 프론트엔드는 여전히 느슨한 타입에 의존하고 있는가?"


7.1 Spring Boot DTO와 TypeScript 인터페이스의 1:1 매핑 ​

우리 백엔드(backend/src/main/java/com/company/board/post/PostDto.java)는 다음과 같이 명확한 DTO로 클라이언트에 내려줄 데이터를 통제하고 있습니다:

java
// Spring Boot 백엔드 (PostDto.java)
public class PostDto {
    @Getter
    @Builder
    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;
    }
}

이제 6장까지 작성된 BoardApp.vue 내부의 느슨한 any[]와 불완전한 인터페이스를 걷어내고, 백엔드 DTO와 1:1로 완벽히 호환되는 프론트엔드 도메인 계약서를 작성합니다.

[코드 7-1] 도메인 모델 계약서 (frontend/src/types/post.ts) ​

typescript
// 백엔드 PostDto.Response와 1:1 매핑되는 TypeScript 모델
export interface Post {
  id: number;
  title: string;
  author: string;
  content: string;
  category: PostCategory;
  createdAt: string;
  viewCount: number;
  likeCount: number;
}

export type PostCategory = 'DEV' | 'NOTICE' | 'FREE';

// 등록 요청 DTO (CreateRequest)
export interface CreatePostRequest {
  title: string;
  author: string;
  content: string;
  category: PostCategory;
}

// 페이징 응답 DTO (PageResult<T>)
export interface PageResult<T> {
  items: T[];
  total: number;
  page: number;
  size: number;
  totalPages: number;
}

7.2 판별된 유니온(Discriminated Unions)으로 비즈니스 다형성 모델링 ​

BoardApp.vue에 새로운 요구사항이 들어왔습니다:

  1. 일반글(GENERAL): 본문과 댓글이 허용됨
  2. 공지글(NOTICE): 상단 고정 만료일(pinnedUntil) 필드가 필수
  3. 비밀글(SECRET): 비밀번호 힌트(passwordHint) 필드가 필수

이 세 가지 게시글을 옵셔널(?)로 처리하면 런타임 에러가 발생하기 쉽습니다.
TypeScript의 **판별된 유니온(Discriminated Unions)**을 적용해 컴파일 타임에 필드 유무를 엄격히 강제합니다:

typescript
export interface BasePost {
  id: number;
  title: string;
  author: string;
  createdAt: string;
}

export interface GeneralPost extends BasePost {
  type: 'GENERAL';
  content: string;
}

export interface NoticePost extends BasePost {
  type: 'NOTICE';
  content: string;
  pinnedUntil: string; // 공지글에는 만료일이 필수!
}

export interface SecretPost extends BasePost {
  type: 'SECRET';
  passwordHint: string; // 비밀글에는 힌트가 필수!
}

export type CommunityPost = GeneralPost | NoticePost | SecretPost;

컴포넌트 템플릿에서 post.type === 'NOTICE'로 분기하는 순간, TypeScript는 post를 자동으로 NoticePost로 좁혀주어 pinnedUntil 속성을 자동완성해 줍니다.


7.3 BoardApp.vue의 컴포넌트 경계선 엄격화: defineProps & defineEmits ​

하위 컴포넌트로 분리될 자식 컴포넌트(PostItem.vue)의 입출력 경계선도 매크로 기반으로 엄격하게 잠급니다:

html
<script setup lang="ts">
import type { Post } from '@/types/post';

// 1. Props: 타입 기반 선언
interface Props {
  post: Post;
  isSelected?: boolean;
}
const { post, isSelected = false } = defineProps<Props>();

// 2. Emits: 호출 시그니처를 통한 엄격한 이벤트 페이로드 검증
const emit = defineEmits<{
  (e: 'delete', postId: number): void;
  (e: 'select', post: Post): void;
}>();

function onDelete() {
  emit('delete', post.id); // 인자 타입 불일치 시 컴파일 에러!
}

7.4 다음 챕터로 이어지는 질문 ​

이제 BoardApp.vue의 모든 데이터는 TypeScript의 엄격한 가호 아래 안전해졌습니다.
그런데 템플릿의 <table> 태그를 다시 보십시오:

html
<!-- BoardApp.vue 내부의 테이블 마크업 -->
<table class="board-table">
  <thead>
    <tr><th>ID</th><th>제목</th><th>작성자</th>...</tr>
  </thead>
  <tbody>
    <tr v-for="post in posts" :key="post.id">...</tr>
  </tbody>
</table>

사내 인트라넷에는 자유게시판(Post), 공지사항(Notice), 직원 목록(Employee) 등 수많은 테이블이 필요합니다.
매번 이 <table> 마크업을 복붙할 수는 없습니다.
그렇다고 공통 테이블을 만들자니 행(Row)에 들어갈 데이터 타입이 제각각입니다.

이어지는 Chapter 08에서는 Vue 3.3+의 강력한 **제네릭 컴포넌트(generic="T")**를 활용하여, BoardApp.vue의 테이블 마크업을 사내 어디든 재사용할 수 있는 공통 BoardTable<T>로 우아하게 분리해 보겠습니다.

Moon HomeLab Engineering Knowledge Hub