14.4 컴포넌트 시스템 — Props, Emits, Slots, Teleport, KeepAlive
Vue 3의 컴포넌트 시스템은 UI를 독립적이고 재사용 가능한 조각으로 나누는 핵심 메커니즘입니다. 컴포넌트끼리는 Props(부모→자식 데이터 전달), Emits(자식→부모 이벤트 전달), Slots(콘텐츠 주입)으로 소통합니다. 여기에 더해 Teleport와 KeepAlive 같은 내장 컴포넌트가 실전 요구사항을 우아하게 해결합니다.
1. Props — 부모에서 자식으로 데이터 전달
Props는 부모 컴포넌트가 자식 컴포넌트에 값을 전달하는 단방향 채널입니다. Vue 3.5의 <script setup>에서는 defineProps()로 선언합니다.
기본 Props 선언
<!-- ChildCard.vue -->
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
count: {
type: Number,
default: 0,
},
tags: {
type: Array,
default: () => [],
},
})
</script>
<template>
<div class="card">
<h2>{{ props.title }}</h2>
<p>방문 횟수: {{ props.count }}</p>
<ul>
<li v-for="tag in props.tags" :key="tag">{{ tag }}</li>
</ul>
</div>
</template>
<!-- ParentPage.vue -->
<script setup>
import ChildCard from './ChildCard.vue'
</script>
<template>
<ChildCard
title="Vue 3 컴포넌트"
:count="42"
:tags="['vue', 'composition', 'setup']"
/>
</template>
TypeScript와 함께 — withDefaults + 제네릭 Props (Vue 3.5)
Vue 3.5부터 제네릭 타입 파라미터를 <script setup lang="ts" generic="T"> 형태로 직접 선언할 수 있습니다.
<!-- DataList.vue -->
<script setup lang="ts" generic="T extends { id: number; label: string }">
defineProps<{
items: T[]
selected?: T | null
}>()
const emit = defineEmits<{
select: [item: T]
}>()
</script>
<template>
<ul>
<li
v-for="item in items"
:key="item.id"
:class="{ active: selected?.id === item.id }"
@click="emit('select', item)"
>
{{ item.label }}
</li>
</ul>
</template>
Props 검증 — 커스텀 validator
<script setup>
defineProps({
status: {
type: String,
validator(value) {
return ['pending', 'active', 'closed'].includes(value)
},
},
size: {
type: Number,
validator: (v) => v > 0 && v <= 100,
},
})
</script>
2. Emits — 자식에서 부모로 이벤트 전달
자식 컴포넌트는 defineEmits()로 이벤트를 선언하고 emit()으로 발생시킵니다. 부모는 v-on 또는 @로 수신합니다.
기본 Emits
<!-- SearchInput.vue -->
<script setup>
const emit = defineEmits(['search', 'clear'])
function handleInput(e) {
emit('search', e.target.value)
}
function handleClear() {
emit('clear')
}
</script>
<template>
<div class="search-box">
<input type="text" placeholder="검색어 입력..." @input="handleInput" />
<button @click="handleClear">초기화</button>
</div>
</template>
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import SearchInput from './SearchInput.vue'
const query = ref('')
function onSearch(value) {
query.value = value
console.log('검색:', value)
}
</script>
<template>
<SearchInput @search="onSearch" @clear="() => (query = '')" />
<p>현재 검색어: {{ query }}</p>
</template>
TypeScript Emits — 타입 안전 이벤트
<script setup lang="ts">
const emit = defineEmits<{
change: [value: string]
submit: [payload: { name: string; email: string }]
'update:modelValue': [value: number]
}>()
// v-model 양방향 바인딩 지원
defineProps<{ modelValue: number }>()
</script>
v-model 커스텀 (단일 & 다중)
<!-- RangeInput.vue — 다중 v-model -->
<script setup lang="ts">
defineProps<{
min: number
max: number
}>()
const emit = defineEmits<{
'update:min': [value: number]
'update:max': [value: number]
}>()
</script>
<template>
<div>
<input type="number" :value="min" @input="emit('update:min', +$event.target.value)" />
<span> ~ </span>
<input type="number" :value="max" @input="emit('update:max', +$event.target.value)" />
</div>
</template>
<!-- 사용처 -->
<RangeInput v-model:min="startValue" v-model:max="endValue" />
3. Slots — 콘텐츠 주입
Slots는 부모가 자식의 템플릿 안에 콘텐츠를 삽입할 수 있게 합니다. 기본 슬롯, 이름 있는 슬롯, 스코프드 슬롯 세 가지를 이해하면 충분합니다.
기본 슬롯
<!-- BaseCard.vue -->
<template>
<div class="base-card">
<slot><!-- 기본값 콘텐츠 --><p>내용이 없습니다.</p></slot>
</div>
</template>
<BaseCard>
<h3>커스텀 타이틀</h3>
<p>부모가 주입한 콘텐츠</p>
</BaseCard>
이름 있는 슬롯 (Named Slots)
<!-- PageLayout.vue -->
<template>
<div class="layout">
<header>
<slot name="header" />
</header>
<main>
<slot />
</main>
<footer>
<slot name="footer">
<p>기본 푸터 텍스트</p>
</slot>
</footer>
</div>
</template>
<PageLayout>
<template #header>
<h1>페이지 제목</h1>
<nav>...</nav>
</template>
<p>메인 콘텐츠 영역</p>
<template #footer>
<p>© 2025 My App</p>
</template>
</PageLayout>
스코프드 슬롯 (Scoped Slots) — 데이터를 역방향으로 전달
자식이 슬롯에 데이터를 노출하면 부모가 그 데이터를 받아 렌더링합니다.
<!-- DataTable.vue -->
<script setup>
defineProps({
rows: Array,
columns: Array,
})
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td v-for="col in columns" :key="col.key">
<!-- 스코프드 슬롯: col과 row를 부모에게 노출 -->
<slot :name="col.key" :row="row" :value="row[col.key]">
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<!-- 사용처 -->
<DataTable :rows="users" :columns="columns">
<!-- status 컬럼만 커스텀 렌더링 -->
<template #status="{ value }">
<span :class="`badge badge-${value}`">{{ value }}</span>
</template>
<!-- actions 컬럼 -->
<template #actions="{ row }">
<button @click="editUser(row)">편집</button>
<button @click="deleteUser(row.id)">삭제</button>
</template>
</DataTable>
4. Teleport — DOM 위치를 벗어나 렌더링
<Teleport>는 컴포넌트를 DOM 트리 어느 위치에든 렌더링할 수 있게 합니다. 모달, 툴팁, 알림 등 z-index 문제를 해결할 때 유용합니다.
<!-- Modal.vue -->
<script setup>
defineProps({
show: Boolean,
title: String,
})
const emit = defineEmits(['close'])
</script>
<template>
<!-- body 태그 바로 아래에 렌더링 -->
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="modal-overlay" @click.self="emit('close')">
<div class="modal-container" role="dialog" aria-modal="true">
<header class="modal-header">
<h2>{{ title }}</h2>
<button class="close-btn" @click="emit('close')" aria-label="닫기">✕</button>
</header>
<div class="modal-body">
<slot />
</div>
<footer class="modal-footer">
<slot name="footer">
<button @click="emit('close')">닫기</button>
</slot>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-container {
background: #fff;
border-radius: 8px;
padding: 1.5rem;
min-width: 320px;
max-width: 600px;
width: 90%;
}
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import Modal from './Modal.vue'
const isOpen = ref(false)
</script>
<template>
<button @click="isOpen = true">모달 열기</button>
<Modal :show="isOpen" title="확인" @close="isOpen = false">
<p>이 작업을 계속하시겠습니까?</p>
<template #footer>
<button @click="isOpen = false">취소</button>
<button @click="confirm">확인</button>
</template>
</Modal>
</template>
다중 Teleport (같은 대상에 여러 개)
<!-- 여러 알림을 하나의 컨테이너에 쌓기 -->
<Teleport to="#notifications">
<div class="toast" v-if="hasNotification">{{ message }}</div>
</Teleport>
5. KeepAlive — 컴포넌트 상태 유지
<KeepAlive>로 감싸진 컴포넌트는 언마운트되지 않고 캐시됩니다. 탭 전환, 스텝 폼 같이 상태를 유지해야 하는 UI에 적합합니다.
<!-- TabView.vue -->
<script setup>
import { ref, shallowRef } from 'vue'
import TabHome from './TabHome.vue'
import TabProfile from './TabProfile.vue'
import TabSettings from './TabSettings.vue'
const tabs = [
{ key: 'home', label: '홈', component: TabHome },
{ key: 'profile', label: '프로필', component: TabProfile },
{ key: 'settings', label: '설정', component: TabSettings },
]
const currentTab = shallowRef(tabs[0].component)
function switchTab(tab) {
currentTab.value = tab.component
}
</script>
<template>
<nav class="tab-nav">
<button
v-for="tab in tabs"
:key="tab.key"
@click="switchTab(tab)"
>
{{ tab.label }}
</button>
</nav>
<!-- include/exclude로 캐시 대상 제어 -->
<KeepAlive :include="['TabHome', 'TabProfile']" :max="5">
<component :is="currentTab" />
</KeepAlive>
</template>
activated / deactivated 훅
<!-- TabHome.vue -->
<script setup>
import { ref, onActivated, onDeactivated } from 'vue'
const scrollY = ref(0)
onActivated(() => {
// 캐시에서 복원될 때
window.scrollTo(0, scrollY.value)
console.log('탭 활성화')
})
onDeactivated(() => {
// 캐시로 이동될 때
scrollY.value = window.scrollY
console.log('탭 비활성화 — 스크롤 위치 저장:', scrollY.value)
})
</script>
6. 실전 예제 — 완전한 폼 마법사 (멀티스텝)
Props, Emits, Slots, KeepAlive를 모두 활용한 멀티스텝 폼입니다.
<!-- StepWizard.vue -->
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
steps: {
type: Array,
required: true,
// [{ title, component, validate }]
},
})
const emit = defineEmits(['complete', 'cancel'])
const currentStep = ref(0)
const formData = ref({})
const isFirst = computed(() => currentStep.value === 0)
const isLast = computed(() => currentStep.value === props.steps.length - 1)
const progress = computed(() =>
Math.round(((currentStep.value + 1) / props.steps.length) * 100)
)
async function next() {
const step = props.steps[currentStep.value]
if (step.validate) {
const valid = await step.validate(formData.value)
if (!valid) return
}
if (isLast.value) {
emit('complete', formData.value)
} else {
currentStep.value++
}
}
function prev() {
if (!isFirst.value) currentStep.value--
}
</script>
<template>
<div class="wizard">
<!-- 진행 표시 -->
<div class="wizard-progress">
<div class="progress-bar" :style="{ width: progress + '%' }" />
<span>{{ currentStep + 1 }} / {{ steps.length }}</span>
</div>
<!-- 스텝 타이틀 슬롯 -->
<header class="wizard-header">
<slot name="title" :step="steps[currentStep]" :index="currentStep">
<h2>{{ steps[currentStep].title }}</h2>
</slot>
</header>
<!-- 각 스텝 컴포넌트를 KeepAlive로 캐시 -->
<KeepAlive>
<component
:is="steps[currentStep].component"
v-model="formData"
:key="currentStep"
/>
</KeepAlive>
<!-- 네비게이션 슬롯 -->
<footer class="wizard-footer">
<slot
name="navigation"
:prev="prev"
:next="next"
:is-first="isFirst"
:is-last="isLast"
>
<button v-if="!isFirst" @click="prev">이전</button>
<button @click="emit('cancel')">취소</button>
<button @click="next" class="btn-primary">
{{ isLast ? '완료' : '다음' }}
</button>
</slot>
</footer>
</div>
</template>
<!-- App.vue — 마법사 사용 -->
<script setup>
import { ref } from 'vue'
import StepWizard from './StepWizard.vue'
import StepPersonal from './steps/StepPersonal.vue'
import StepAccount from './steps/StepAccount.vue'
import StepReview from './steps/StepReview.vue'
const steps = [
{
title: '개인 정보',
component: StepPersonal,
validate: (data) => !!(data.name && data.email),
},
{
title: '계정 설정',
component: StepAccount,
validate: (data) => data.password?.length >= 8,
},
{
title: '최종 확인',
component: StepReview,
},
]
const result = ref(null)
function onComplete(data) {
result.value = data
console.log('가입 완료:', data)
}
</script>
<template>
<StepWizard :steps="steps" @complete="onComplete" @cancel="console.log('취소')">
<template #title="{ step, index }">
<h2>{{ index + 1 }}단계: {{ step.title }}</h2>
</template>
</StepWizard>
<pre v-if="result">{{ JSON.stringify(result, null, 2) }}</pre>
</template>
7. 고수 팁
Props 구조분해 + reactivity 유지 (Vue 3.5)
Vue 3.5에서 defineProps()의 반환값을 구조분해해도 반응성이 유지됩니다.
<script setup>
// Vue 3.5 이전: props.title로 접근해야 반응성 유지
// Vue 3.5+: 구조분해해도 반응성 유지됨
const { title, count = 0 } = defineProps<{
title: string
count?: number
}>()
// title, count 모두 반응형 — 템플릿에서 바로 사용 가능
</script>
defineModel() — v-model 보일러플레이트 제거
<!-- 기존 방식: defineProps + defineEmits 양쪽 선언 필요 -->
<!-- Vue 3.4+ defineModel: 단 한 줄로 v-model 지원 -->
<script setup lang="ts">
const value = defineModel<string>({ default: '' })
const count = defineModel<number>('count', { default: 0 })
</script>
<template>
<input v-model="value" />
<button @click="count++">{{ count }}</button>
</template>
컴포넌트 expose — 외부 접근 제한
<!-- child.vue -->
<script setup>
import { ref } from 'vue'
const internalState = ref('비공개')
const publicMethod = () => console.log('공개 메서드')
// 명시적으로 노출할 항목만 지정
defineExpose({ publicMethod })
</script>
<!-- parent.vue -->
<script setup>
import { ref, onMounted } from 'vue'
import Child from './Child.vue'
const childRef = ref(null)
onMounted(() => {
childRef.value.publicMethod() // 가능
// childRef.value.internalState — undefined (비공개)
})
</script>
<template>
<Child ref="childRef" />
</template>
Teleport disabled 속성으로 조건부 텔레포트
<Teleport to="body" :disabled="isMobile">
<!-- 모바일에서는 현재 위치, 데스크탑에서는 body로 렌더링 -->
<Dropdown />
</Teleport>
슬롯 존재 여부 확인
<script setup>
import { useSlots } from 'vue'
const slots = useSlots()
const hasHeader = computed(() => !!slots.header)
</script>
<template>
<div :class="{ 'no-header': !hasHeader }">
<header v-if="hasHeader">
<slot name="header" />
</header>
<slot />
</div>
</template>