다크 모드
Chapter 08. 어디든 재사용 가능한 제네릭 BoardTable<T> 제작
"7장까지 진화한
BoardApp.vue의 템플릿에는 여전히 50줄짜리<table>마크업이 박혀있다. 이를 도려내어 사내 어디든 끼워 쓸 수 있는 제네릭 컴포넌트로 만들 수는 없을까?"
8.1 BoardApp.vue에 갇혀있는 <table> 마크업
현재 BoardApp.vue의 템플릿에는 테이블 렌더링 마크업이 직접 작성되어 있습니다:
html
<!-- BoardApp.vue 템플릿 일부 -->
<table class="board-table">
<thead>
<tr>
<th>ID</th><th>제목</th><th>작성자</th><th>작성일</th><th>동작</th>
</tr>
</thead>
<tbody>
<tr v-for="post in posts" :key="post.id">
<td>{{ post.id }}</td>
<td>{{ post.title }}</td>
<td>{{ post.author }}</td>
<td>{{ post.createdAt }}</td>
<td><button @click="handleDelete(post.id)">삭제</button></td>
</tr>
</tbody>
</table>사내 동료가 "직원 목록 화면"이나 "공지사항 화면"을 만들 때, 이 테이블 HTML 구조와 CSS를 그대로 복사해가고 있습니다.
이 테이블을 공통 컴포넌트(BoardTable.vue)로 분리하고 싶지만, 테이블 행에 들어갈 데이터가 Post일 수도 있고 Employee일 수도 있어 타입이 맞지 않습니다.
8.2 Vue 3.3+ Generic Component (<script setup generic="...">)
Vue 3.3부터 지원되는 제네릭 컴포넌트 문법을 적용하면, 컴포넌트 내부에서 임의의 타입 T를 사용할 수 있습니다.
새로운 공통 컴포넌트 파일을 생성합니다: frontend/src/components/common/BoardTable.vue
[코드 8-1] 사내 공통 제네릭 테이블 (frontend/src/components/common/BoardTable.vue)
html
<template>
<div class="generic-table-wrapper">
<table class="custom-table">
<thead>
<tr>
<th
v-for="col in columns"
:key="String(col.key)"
:style="{ width: col.width }"
>
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td :colspan="columns.length" class="text-center">데이터를 불러오는 중입니다...</td>
</tr>
<tr v-else-if="items.length === 0">
<td :colspan="columns.length" class="text-center">
<slot name="empty">등록된 데이터가 없습니다.</slot>
</td>
</tr>
<tr v-else v-for="(item, index) in items" :key="getRowKey(item, index)">
<td v-for="col in columns" :key="String(col.key)">
<!-- 🌟 Scoped Slot: 행 데이터(T)를 부모에게 전달하여 자유로운 커스텀 허용! -->
<slot :name="`cell-${String(col.key)}`" :row="item" :value="item[col.key]">
{{ item[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts" generic="T extends object">
export interface TableColumn<T> {
key: keyof T; // 객체 T에 실제 존재하는 키만 허용!
label: string;
width?: string;
}
interface Props<T> {
items: T[];
columns: TableColumn<T>[];
loading?: boolean;
rowKeyField?: keyof T;
}
const props = defineProps<Props<T>>();
function getRowKey(item: T, index: number): string | number {
if (props.rowKeyField && item[props.rowKeyField]) {
return String(item[props.rowKeyField]);
}
return index;
}8.3 BoardApp.vue에서 공통 테이블 적용하기
이제 BoardApp.vue의 50줄짜리 하드코딩된 <table> 태그를 걷어내고, 방금 만든 BoardTable을 장착합니다.:items="posts"를 넘기는 순간, 컴파일러가 T의 정체를 7장에서 정의한 Post 타입으로 자동 추론합니다!
[코드 8-2] BoardApp.vue에 적용된 제네릭 테이블 (완전한 소스 코드)
html
<template>
<div class="board-container">
<h2>사내 기술 Q&A 게시판 (v3.0)</h2>
<!-- 1. 검색 바 -->
<div class="filter-bar">
<select v-model="selectedCategory">
<option value="ALL">전체</option>
<option value="DEV">개발</option>
<option value="NOTICE">공지</option>
</select>
<input v-model="inputKeyword" placeholder="검색어" @keyup.enter="handleSearch" />
<button @click="handleSearch">검색</button>
<button @click="handleReset">초기화</button>
</div>
<!-- 2. 등록 폼 -->
<form class="form-box" @submit.prevent="handleSubmit">
<input v-model="form.title" placeholder="제목" />
<span v-if="errors.title" class="error">{{ errors.title }}</span>
<input v-model="form.author" placeholder="작성자" />
<textarea v-model="form.content" placeholder="내용"></textarea>
<span v-if="errors.content" class="error">{{ errors.content }}</span>
<button type="submit" :disabled="isSubmitting">등록</button>
</form>
<!-- 🌟 3. 50줄짜리 <table> 마크업이 단 10줄의 제네릭 컴포넌트로 완벽 대체! -->
<BoardTable
:items="posts"
:columns="columns"
:loading="isLoading"
rowKeyField="id"
>
<template #empty>
<span>등록된 기술 Q&A 게시글이 없습니다. 첫 질문을 남겨보세요! 🎉</span>
</template>
<!-- 제목 열 커스터마이징 (Scoped Slot) -->
<template #cell-title="{ row }">
<span class="post-title">{{ row.title }}</span>
<span v-if="row.likeCount > 5" class="hot-badge">🔥 인기</span>
</template>
<!-- 동작 열 커스터마이징 -->
<template #cell-id="{ value }">
<button class="btn-delete" @click="handleDelete(value)">삭제</button>
</template>
</BoardTable>
<!-- 4. 페이지네이션 -->
<div class="pagination">
<button :disabled="!hasPrevPage" @click="prevPage">이전</button>
<span>{{ currentPage }} / {{ totalPages }} (총 {{ totalCount }}건)</span>
<button :disabled="!hasNextPage" @click="nextPage">다음</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import axios from 'axios';
import BoardTable, { type TableColumn } from '@/components/common/BoardTable.vue';
import { usePagination } from '@/composables/usePagination';
import { usePostFilter } from '@/composables/usePostFilter';
import { useFormValidation } from '@/composables/useFormValidation';
import type { Post } from '@/types/post';
// columns 정의 시 오타(예: 'creatAt')를 치면 컴파일 에러 발생!
const columns: TableColumn<Post>[] = [
{ key: 'id', label: 'ID', width: '60px' },
{ key: 'title', label: '제목' },
{ key: 'author', label: '작성자', width: '100px' },
{ key: 'createdAt', label: '작성일', width: '120px' }
];
const posts = ref<Post[]>([]);
const totalCount = ref(0);
const isLoading = ref(false);
const isSubmitting = ref(false);
const form = reactive({ title: '', author: '', content: '' });
const { keyword, category, applyFilter, resetFilter } = usePostFilter(() => {
currentPage.value = 1;
loadPosts();
});
const inputKeyword = ref(keyword.value);
const selectedCategory = ref(category.value);
function handleSearch() { applyFilter(inputKeyword.value, selectedCategory.value); }
function handleReset() { inputKeyword.value = ''; selectedCategory.value = 'ALL'; resetFilter(); }
const { currentPage, pageSize, totalPages, hasNextPage, hasPrevPage, nextPage, prevPage } = usePagination({
totalCount,
onPageChange: () => loadPosts()
});
const { errors, validate, clearErrors } = useFormValidation();
async function loadPosts() {
isLoading.value = true;
try {
const res = await axios.get('/api/posts', {
params: {
page: currentPage.value,
size: pageSize.value,
keyword: keyword.value,
category: category.value
}
});
posts.value = res.data.items;
totalCount.value = res.data.total;
} finally {
isLoading.value = false;
}
}
async function handleDelete(id: number) {
if (!confirm('정말 삭제하시겠습니까?')) return;
await axios.delete(`/api/posts/${id}`);
loadPosts();
}
async function handleSubmit() {
if (!validate(form.title, form.content)) return;
isSubmitting.value = true;
try {
await axios.post('/api/posts', { ...form, category: 'DEV' });
form.title = ''; form.author = ''; form.content = '';
clearErrors();
loadPosts();
} finally {
isSubmitting.value = false;
}
}
onMounted(() => { loadPosts(); });8.4 다음 챕터로 이어지는 질문
이제 BoardApp.vue의 UI는 공통 테이블 컴포넌트로 완벽하게 모듈화되었습니다.
하지만 상태 관리 관점에서 새로운 문제가 생겼습니다:
사용자가 특정 게시글을 클릭해서 상세 페이지로 갔다가, 브라우저 [뒤로 가기]를 눌러 다시 목록으로 돌아왔다고 가정해 봅시다.
현재 BoardApp.vue는 화면이 다시 렌더링될 때마다 매번 서버 API를 처음부터 다시 호출하고 있습니다. 화면이 깜빡이고 서버에도 불필요한 부하를 줍니다.
"게시글 목록을 메모리에 캐싱해두고, 뒤로 가기를 눌렀을 때는 0.001초 만에 즉시 화면을 띄워줄 수는 없을까?"
이어지는 Chapter 09에서는 BoardApp.vue 내부의 로컬 상태 posts를 엔터프라이즈 전역 스토어인 Pinia로 이전하여, 스마트한 캐싱과 데이터 동기화를 구현해 보겠습니다.