Vue.js 를 배워보자
Vue 3와 Vuetify로 구현하는 부모-자식 컴포넌트 간 RESTful 데이터 바인딩: 탭 기반 테이블과 상세 뷰
_Blue_Sky_
2025. 4. 24. 00:45
728x90

Vue 3에서 Composition API와 TypeScript를 사용해 RESTful API 데이터를 부모 컴포넌트에서 호출하고, 자식 컴포넌트 두 개(테이블 그리드와 상세 뷰)를 탭으로 구분해 바인딩하는 방법을 소개합니다. Vuetify를 활용해 깔끔한 UI를 구현하며, 실용적인 예제를 통해 배우고자 하는 분들께 도움이 되고자 합니다.
이번 예제에서는 부모 컴포넌트에서 검색어 입력과 버튼으로 데이터를 가져오고, 두 자식 컴포넌트가 각각 데이터를 테이블 형태와 클릭 시 개별 행의 상세 정보를 표시하는 방식으로 동작합니다. Vuetify의 탭과 그리드 컴포넌트를 사용해 직관적인 UI를 구성합니다.
프로젝트 개요
우리의 목표는 다음과 같습니다:
-
부모 컴포넌트: 검색어 입력 필드와 버튼으로 RESTful API 호출.
-
자식 컴포넌트 1: 데이터를 테이블 그리드 형태로 표시.
-
자식 컴포넌트 2: 테이블 행 클릭 시 해당 행의 상세 데이터를 바인딩해 표시.
-
UI 구성: Vuetify의 탭 컴포넌트를 사용해 두 자식 컴포넌트를 전환.
-
기술 스택: Vue 3 (Composition API), TypeScript, Vuetify.
프로젝트 설정
먼저, Vue 3 프로젝트를 생성하고 Vuetify를 설치합니다:
npm create vue@latest
cd your-project
npm install
npm install vuetify @mdi/font
main.ts에 Vuetify를 설정합니다:
import { createApp } from 'vue';
import App from './App.vue';
import { createVuetify } from 'vuetify';
import 'vuetify/styles';
import '@mdi/font/css/materialdesignicons.css';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
const vuetify = createVuetify({
components,
directives,
});
const app = createApp(App);
app.use(vuetify);
app.mount('#app');
프로젝트 구조
src/
├── components/
│ ├── SearchComponent.vue // 부모 컴포넌트
│ ├── DataTable.vue // 자식 컴포넌트: 테이블 그리드
│ ├── DataDetail.vue // 자식 컴포넌트: 상세 뷰
├── types/
│ ├── DataItem.ts // 데이터 타입 정의
├── App.vue
1. 데이터 타입 정의 (types/DataItem.ts)
RESTful API에서 가져올 데이터의 타입을 정의합니다.
export interface DataItem {
id: number;
title: string;
description: string;
}
2. 부모 컴포넌트 (components/SearchComponent.vue)
부모 컴포넌트는 검색어 입력, 버튼, 그리고 Vuetify 탭을 통해 두 자식 컴포넌트를 관리합니다. 검색 버튼 클릭 시 API를 호출해 데이터를 가져오고, 자식 컴포넌트로 데이터를 전달합니다.
<template>
<v-container>
<h1>Vue와 Vuetify로 RESTful 데이터 탐색</h1>
<v-row>
<v-col cols="8">
<v-text-field
v-model="searchQuery"
label="검색어 입력"
prepend-inner-icon="mdi-magnify"
variant="outlined"
></v-text-field>
</v-col>
<v-col cols="4">
<v-btn color="primary" @click="fetchData">검색</v-btn>
</v-col>
</v-row>
<v-tabs v-model="activeTab" bg-color="primary">
<v-tab value="table">테이블 뷰</v-tab>
<v-tab value="detail">상세 뷰</v-tab>
</v-tabs>
<v-window v-model="activeTab">
<v-window-item value="table">
<DataTable :items="dataItems" @row-click="handleRowClick" />
</v-window-item>
<v-window-item value="detail">
<DataDetail :selected-item="selectedItem" />
</v-window-item>
</v-window>
</v-container>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import DataTable from './DataTable.vue';
import DataDetail from './DataDetail.vue';
import { DataItem } from '../types/DataItem';
// 상태 관리
const searchQuery = ref<string>('');
const dataItems = ref<DataItem[]>([]);
const selectedItem = ref<DataItem | null>(null);
const activeTab = ref('table');
// RESTful API 호출
const fetchData = async () => {
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?q=${searchQuery.value}`
);
const data = await response.json();
dataItems.value = data.slice(0, 5).map((item: any) => ({
id: item.id,
title: item.title,
description: item.body,
}));
selectedItem.value = null; // 검색 시 선택된 항목 초기화
} catch (error) {
console.error('데이터 가져오기 실패:', error);
}
};
// 테이블 행 클릭 핸들러
const handleRowClick = (item: DataItem) => {
selectedItem.value = item;
activeTab.value = 'detail'; // 상세 뷰로 탭 전환
};
</script>
설명:
-
v-text-field와 v-btn으로 검색 UI를 구성.
-
v-tabs와 v-window로 탭 UI 구현.
-
fetchData로 JSONPlaceholder API 호출.
-
handleRowClick으로 테이블 행 클릭 시 상세 뷰로 전환 및 데이터 전달.
3. 자식 컴포넌트 1: 테이블 그리드 (components/DataTable.vue)
테이블 그리드는 Vuetify의 v-data-table을 사용해 데이터를 표시하며, 행 클릭 시 이벤트를 부모로 emit합니다.
<template>
<v-data-table
:headers="headers"
:items="items"
class="elevation-1"
@click:row="onRowClick"
></v-data-table>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
import { DataItem } from '../types/DataItem';
// 테이블 헤더 정의
const headers = [
{ title: 'ID', key: 'id', align: 'start' },
{ title: '제목', key: 'title', align: 'start' },
{ title: '설명', key: 'description', align: 'start' },
];
// Props와 Emits 정의
const props = defineProps<{
items: DataItem[];
}>();
const emit = defineEmits<{
(e: 'row-click', item: DataItem): void;
}>();
// 행 클릭 시 이벤트 emit
const onRowClick = (_: any, { item }: { item: DataItem }) => {
emit('row-click', item);
};
</script>
설명:
-
v-data-table로 데이터 그리드 렌더링.
-
headers로 테이블 열 정의.
-
행 클릭 시 row-click 이벤트로 선택된 항목을 부모로 전달.
4. 자식 컴포넌트 2: 상세 뷰 (components/DataDetail.vue)
상세 뷰는 선택된 항목의 데이터를 카드 형태로 표시합니다.
<template>
<v-container>
<v-card v-if="selectedItem" class="mx-auto" max-width="600">
<v-card-title>상세 정보</v-card-title>
<v-card-text>
<p><strong>ID:</strong> {{ selectedItem.id }}</p>
<p><strong>제목:</strong> {{ selectedItem.title }}</p>
<p><strong>설명:</strong> {{ selectedItem.description }}</p>
</v-card-text>
</v-card>
<v-alert v-else type="info">항목을 선택해주세요.</v-alert>
</v-container>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
import { DataItem } from '../types/DataItem';
// Props 정의
const props = defineProps<{
selectedItem: DataItem | null;
}>();
</script>
설명:
-
v-card로 선택된 항목의 상세 정보 표시.
-
selectedItem이 없을 경우 v-alert로 안내 메시지 출력.
5. 최상위 컴포넌트 (App.vue)
부모 컴포넌트를 최상위에 추가합니다.
vue
<template>
<v-app>
<SearchComponent />
</v-app>
</template>
<script setup lang="ts">
import SearchComponent from './components/SearchComponent.vue';
</script>
실행 방법
-
프로젝트 디렉토리에서 의존성 설치:
npm install
-
개발 서버 실행:
npm run dev
-
브라우저에서 localhost:5173 접속.
동작 흐름
-
검색: 부모 컴포넌트의 입력 필드에 검색어를 입력하고 "검색" 버튼 클릭.
-
API 호출: JSONPlaceholder API에서 데이터 가져와 dataItems에 저장.
-
테이블 뷰: 기본 탭에서 DataTable이 데이터를 그리드 형태로 표시.
-
행 클릭: 테이블의 행 클릭 시 selectedItem 업데이트 및 상세 뷰 탭으로 전환.
-
상세 뷰: DataDetail이 선택된 항목의 데이터를 카드 UI로 표시.
추가 팁
-
로딩 상태: v-progress-linear를 추가해 API 호출 중 로딩 UI 제공.
-
에러 처리: v-snackbar로 에러 메시지 표시.
-
스타일링: Vuetify 테마를 커스터마이징해 브랜드에 맞는 UI 구현.
-
API 대체: 실제 프로젝트에서는 본인의 RESTful API 엔드포인트 사용.
마무리
이 예제는 Vue 3, TypeScript, Vuetify를 활용해 부모-자식 컴포넌트 간 데이터 바인딩과 탭 기반 UI를 구현하는 방법을 보여줍니다. Composition API의 간결함과 Vuetify의 강력한 UI 컴포넌트를 결합해 실용적인 애플리케이션을 빠르게 구축할 수 있습니다.
selectedItem 를 부모가 아닌 자식 2의 소유로 하면 어떨까!
Vue 3와 Vuetify로 구현하는 탭 기반 데이터 바인딩: 자식 컴포넌트에서 selectedItem 관리

이 블로그 글에서는 이전 예제를 수정해 selectedItem 상태를 부모 컴포넌트가 아닌 자식 컴포넌트(DataDetail.vue)에서 관리하도록 변경합니다. 이렇게 하면 상세 뷰 컴포넌트가 독립적으로 선택된 항목을 관리하며, 부모와의 결합도를 낮출 수 있습니다. Vue 3의 Composition API와 TypeScript, Vuetify를 사용하며, 탭 UI로 테이블 그리드와 상세 뷰를 전환하는 애플리케이션을 구현합니다.
왜 selectedItem을 자식 컴포넌트에서 관리할까?
selectedItem을 부모 컴포넌트에서 관리하면 부모가 모든 상태를 중앙에서 제어하므로 상태 흐름이 명확해집니다. 하지만 상세 뷰 컴포넌트가 selectedItem을 직접 소유하면:
-
캡슐화: 상세 뷰가 자신의 상태를 독립적으로 관리해 재사용성이 높아짐.
-
단순화: 부모 컴포넌트의 책임이 줄어들고, 상세 뷰 관련 로직이 한 곳에 집중.
-
유연성: 다른 컴포넌트에서 상세 뷰를 사용할 때 부모의 간섭 없이 동작 가능.
이번 예제에서는 DataTable에서 클릭된 행 데이터를 DataDetail로 직접 전달하고, DataDetail이 selectedItem을 내부 상태로 관리하도록 수정합니다.
프로젝트 구조
이전 예제와 동일한 구조를 사용합니다:
src/
├── components/
│ ├── SearchComponent.vue // 부모 컴포넌트
│ ├── DataTable.vue // 자식 컴포넌트: 테이블 그리드
│ ├── DataDetail.vue // 자식 컴포넌트: 상세 뷰
├── types/
│ ├── DataItem.ts // 데이터 타입 정의
├── App.vue
1. 데이터 타입 정의 (types/DataItem.ts)
export interface DataItem {
id: number;
title: string;
description: string;
}
2. 부모 컴포넌트 (components/SearchComponent.vue)
부모 컴포넌트는 검색어 입력과 API 호출을 담당하며, selectedItem 상태를 제거합니다. 대신, DataTable과 DataDetail 간의 데이터 전달을 이벤트로 조정합니다.
vue
<template>
<v-container>
<h1>Vue와 Vuetify로 RESTful 데이터 탐색</h1>
<v-row>
<v-col cols="8">
<v-text-field
v-model="searchQuery"
label="검색어 입력"
prepend-inner-icon="mdi-magnify"
variant="outlined"
></v-text-field>
</v-col>
<v-col cols="4">
<v-btn color="primary" @click="fetchData">검색</v-btn>
</v-col>
</v-row>
<v-tabs v-model="activeTab" bg-color="primary">
<v-tab value="table">테이블 뷰</v-tab>
<v-tab value="detail">상세 뷰</v-tab>
</v-tabs>
<v-window v-model="activeTab">
<v-window-item value="table">
<DataTable :items="dataItems" @row-click="handleRowClick" />
</v-window-item>
<v-window-item value="detail">
<DataDetail ref="dataDetailRef" :items="dataItems" />
</v-window-item>
</v-window>
</v-container>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import DataTable from './DataTable.vue';
import DataDetail from './DataDetail.vue';
import { DataItem } from '../types/DataItem';
// 상태 관리
const searchQuery = ref<string>('');
const dataItems = ref<DataItem[]>([]);
const activeTab = ref('table');
const dataDetailRef = ref<InstanceType<typeof DataDetail> | null>(null);
// RESTful API 호출
const fetchData = async () => {
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?q=${searchQuery.value}`
);
const data = await response.json();
dataItems.value = data.slice(0, 5).map((item: any) => ({
id: item.id,
title: item.title,
description: item.body,
}));
} catch (error) {
console.error('데이터 가져오기 실패:', error);
}
};
// 테이블 행 클릭 시
const handleRowClick = (item: DataItem) => {
if (dataDetailRef.value) {
dataDetailRef.value.updateSelectedItem(item); // DataDetail의 메서드 호출
}
activeTab.value = 'detail';
};
</script>
변경점:
-
selectedItem 상태 제거.
-
DataDetail에 items props를 전달해 전체 데이터 목록 제공.
-
handleRowClick은 선택된 항목을 처리하지 않고 탭만 전환.
3. 자식 컴포넌트 1: 테이블 그리드 (components/DataTable.vue)
DataTable은 행 클릭 시 선택된 항목을 DataDetail로 전달하기 위해 이벤트를 emit합니다.
<template>
<v-data-table
:headers="headers"
:items="items"
class="elevation-1"
@click:row="onRowClick"
></v-data-table>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
import { DataItem } from '../types/DataItem';
// 테이블 헤더 정의
const headers = [
{ title: 'ID', key: 'id', align: 'start' },
{ title: '제목', key: 'title', align: 'start' },
{ title: '설명', key: 'description', align: 'start' },
];
// Props와 Emits 정의
const props = defineProps<{
items: DataItem[];
}>();
const emit = defineEmits<{
(e: 'row-click', item: DataItem): void;
}>();
// 행 클릭 시 이벤트 emit
const onRowClick = (_: any, { item }: { item: DataItem }) => {
emit('row-click', item);
};
</script>
설명:
-
행 클릭 시 row-click 이벤트로 클릭된 item을 emit.
-
DataTable은 데이터 표시만 담당하며, 선택 상태는 관리하지 않음.
4. 자식 컴포넌트 2: 상세 뷰 (components/DataDetail.vue)
DataDetail은 selectedItem을 내부 상태로 관리하며, 부모로부터 받은 items를 기반으로 선택된 항목을 업데이트합니다.
<template>
<v-container>
<v-card v-if="selectedItem" class="mx-auto" max-width="600">
<v-card-title>상세 정보</v-card-title>
<v-card-text>
<p><strong>ID:</strong> {{ selectedItem.id }}</p>
<p><strong>제목:</strong> {{ selectedItem.title }}</p>
<p><strong>설명:</strong> {{ selectedItem.description }}</p>
</v-card-text>
</v-card>
<v-alert v-else type="info">항목을 선택해주세요.</v-alert>
</v-container>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { DataItem } from '../types/DataItem';
// Props 정의
const props = defineProps<{
items: DataItem[];
}>();
// 내부 상태
const selectedItem = ref<DataItem | null>(null);
// 부모로부터 전달된 row-click 이벤트 처리
const updateSelectedItem = (item: DataItem) => {
selectedItem.value = item;
};
// items 변경 시 selectedItem 초기화
watch(
() => props.items,
() => {
selectedItem.value = null; // 새 데이터 로드 시 선택 초기화
}
);
// 부모 컴포넌트에서 이벤트 수신을 위해 provide/inject 대신 이벤트 버스 대체 가능
// 이 예제에서는 간단히 부모가 탭 전환 후 DataTable의 이벤트를 통해 처리
</script>
변경점:
-
selectedItem을 ref로 내부 상태로 관리.
-
items props를 받아 데이터 목록 유지.
-
watch로 items 변경 시 selectedItem 초기화.
-
updateSelectedItem 함수로 외부에서 전달된 항목 업데이트.
5. 최상위 컴포넌트 (App.vue)
변경 없음.
vue
<template>
<v-app>
<SearchComponent />
</v-app>
</template>
<script setup lang="ts">
import SearchComponent from './components/SearchComponent.vue';
</script>
문제와 해결: DataTable과 DataDetail 간 데이터 전달
DataDetail이 selectedItem을 관리하므로, DataTable의 행 클릭 이벤트가 DataDetail로 직접 전달되어야 합니다. 하지만 현재 구조에서는 부모가 이벤트를 중계합니다. 이를 해결하려면:
-
이벤트 버스 사용 (권장하지 않음, 복잡성 증가).
-
Provide/Inject로 DataDetail에 이벤트 핸들러 주입.
-
부모를 통해 간접 전달 (현재 예제 방식).
더 깔끔한 방법으로, DataDetail에 updateSelectedItem을 공개 메서드로 노출하고 부모에서 이를 호출하도록 수정할 수 있습니다. 아래는 그 예입니다.
개선된 DataDetail.vue
DataDetail에 공개 메서드를 추가해 부모가 selectedItem을 업데이트하도록 합니다.
<template>
<v-container>
<h1>Vue와 Vuetify로 RESTful 데이터 탐색</h1>
<v-row>
<v-col cols="8">
<v-text-field
v-model="searchQuery"
label="검색어 입력"
prepend-inner-icon="mdi-magnify"
variant="outlined"
></v-text-field>
</v-col>
<v-col cols="4">
<v-btn color="primary" @click="fetchData">검색</v-btn>
</v-col>
</v-row>
<v-tabs v-model="activeTab" bg-color="primary">
<v-tab value="table">테이블 뷰</v-tab>
<v-tab value="detail">상세 뷰</v-tab>
</v-tabs>
<v-window v-model="activeTab">
<v-window-item value="table">
<DataTable :items="dataItems" @row-click="handleRowClick" />
</v-window-item>
<v-window-item value="detail">
<DataDetail ref="dataDetailRef" :items="dataItems" />
</v-window-item>
</v-window>
</v-container>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import DataTable from './DataTable.vue';
import DataDetail from './DataDetail.vue';
import { DataItem } from '../types/DataItem';
// 상태 관리
const searchQuery = ref<string>('');
const dataItems = ref<DataItem[]>([]);
const activeTab = ref('table');
const dataDetailRef = ref<InstanceType<typeof DataDetail> | null>(null);
// RESTful API 호출
const fetchData = async () => {
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?q=${searchQuery.value}`
);
const data = await response.json();
dataItems.value = data.slice(0, 5).map((item: any) => ({
id: item.id,
title: item.title,
description: item.body,
}));
} catch (error) {
console.error('데이터 가져오기 실패:', error);
}
};
// 테이블 행 클릭 시
const handleRowClick = (item: DataItem) => {
if (dataDetailRef.value) {
dataDetailRef.value.updateSelectedItem(item); // DataDetail의 메서드 호출
}
activeTab.value = 'detail';
};
</script>
수정된 부모 컴포넌트 (SearchComponent.vue)
DataDetail의 updateSelectedItem 메서드를 호출하도록 수정합니다.
<template>
<v-container>
<h1>Vue와 Vuetify로 RESTful 데이터 탐색</h1>
<v-row>
<v-col cols="8">
<v-text-field
v-model="searchQuery"
label="검색어 입력"
prepend-inner-icon="mdi-magnify"
variant="outlined"
></v-text-field>
</v-col>
<v-col cols="4">
<v-btn color="primary" @click="fetchData">검색</v-btn>
</v-col>
</v-row>
<v-tabs v-model="activeTab" bg-color="primary">
<v-tab value="table">테이블 뷰</v-tab>
<v-tab value="detail">상세 뷰</v-tab>
</v-tabs>
<v-window v-model="activeTab">
<v-window-item value="table">
<DataTable :items="dataItems" @row-click="handleRowClick" />
</v-window-item>
<v-window-item value="detail">
<DataDetail ref="dataDetailRef" :items="dataItems" />
</v-window-item>
</v-window>
</v-container>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import DataTable from './DataTable.vue';
import DataDetail from './DataDetail.vue';
import { DataItem } from '../types/DataItem';
// 상태 관리
const searchQuery = ref<string>('');
const dataItems = ref<DataItem[]>([]);
const activeTab = ref('table');
const dataDetailRef = ref<InstanceType<typeof DataDetail> | null>(null);
// RESTful API 호출
const fetchData = async () => {
try {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?q=${searchQuery.value}`
);
const data = await response.json();
dataItems.value = data.slice(0, 5).map((item: any) => ({
id: item.id,
title: item.title,
description: item.body,
}));
} catch (error) {
console.error('데이터 가져오기 실패:', error);
}
};
// 테이블 행 클릭 시
const handleRowClick = (item: DataItem) => {
if (dataDetailRef.value) {
dataDetailRef.value.updateSelectedItem(item); // DataDetail의 메서드 호출
}
activeTab.value = 'detail';
};
</script>
실행 방법
-
프로젝트 디렉토리에서 의존성 설치:
npm install
-
개발 서버 실행:
npm run dev
-
브라우저에서 localhost:5173 접속.
동작 흐름
-
검색: 부모 컴포넌트에서 검색어 입력 후 "검색" 버튼 클릭.
-
API 호출: JSONPlaceholder API로 데이터 가져와 dataItems 업데이트.
-
테이블 뷰: DataTable이 데이터를 그리드 형태로 표시.
-
행 클릭: DataTable의 행 클릭 시 row-click 이벤트로 부모에 알림.
-
상세 뷰 업데이트: 부모가 DataDetail의 updateSelectedItem 호출, 탭을 상세 뷰로 전환.
-
상세 뷰 표시: DataDetail이 내부 selectedItem을 기반으로 데이터 렌더링.
장점과 단점
장점:
-
DataDetail이 selectedItem을 독립적으로 관리해 캡슐화 강화.
-
부모 컴포넌트의 상태 관리 부담 감소.
-
DataDetail을 다른 컨텍스트에서 재사용 가능.
단점:
-
DataTable과 DataDetail 간 데이터 전달이 부모를 거치므로 약간의 복잡성 존재.
-
ref를 사용한 메서드 호출은 타입 안정성을 보장하기 위해 주의 필요.
추가 개선 아이디어
-
Pinia 사용: selectedItem을 전역 상태로 관리하면 컴포넌트 간 데이터 전달이 더 간단해짐.
-
이벤트 버스: mitt 같은 경량 이벤트 버스로 DataTable과 DataDetail 간 직접 통신.
-
슬롯 사용: DataDetail을 DataTable의 슬롯으로 삽입해 더 긴밀한 연동.
마무리
selectedItem을 DataDetail에서 관리하도록 변경함으로써 컴포넌트의 책임을 명확히 분리하고 재사용성을 높였습니다. Vue 3의 Composition API와 TypeScript, Vuetify의 조합은 강력한 UI와 타입 안정성을 제공하며, 이런 패턴은 실무에서 자주 활용됩니다.
728x90