Slots 심화
스코프드 슬롯은 자식 컴포넌트의 데이터를 부모의 슬롯 컨텐츠에서 사용할 수 있게 하는 패턴입니다. 컴포넌트 재사용성의 핵심 도구입니다.
면접에서 "슬롯과 스코프드 슬롯의 차이"를 물어보면, 데이터 흐름 방향의 차이를 설명할 수 있어야 합니다.
기본 슬롯 복습
<!-- Card.vue -->
<template>
<div class="card">
<slot>기본 컨텐츠</slot>
</div>
</template>
<!-- 사용 -->
<Card>커스텀 컨텐츠</Card>
<Card /> <!-- "기본 컨텐츠" 표시 -->
이름 있는 슬롯
<!-- Layout.vue -->
<template>
<header><slot name="header" /></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
</template>
<!-- 사용 -->
<Layout>
<template #header>
<h1>페이지 제목</h1>
</template>
<p>메인 컨텐츠 (기본 슬롯)</p>
<template #footer>
<p>푸터 내용</p>
</template>
</Layout>
스코프드 슬롯 (Scoped Slots)
자식이 부모에게 데이터를 전달하는 패턴입니다.
<!-- DataList.vue — 자식 컴포넌트 -->
<script setup lang="ts">
interface Item {
id: number
name: string
price: number
}
defineProps<{
items: Item[]
}>()
</script>
<template>
<ul>
<li v-for="(item, index) in items" :key="item.id">
<!-- 슬롯 props로 자식 데이터를 부모에 전달 -->
<slot :item="item" :index="index" :isFirst="index === 0" :isLast="index === items.length - 1" />
</li>
</ul>
</template>
<!-- 부모 — 자식의 데이터를 받아서 렌더링 방식을 결정 -->
<script setup lang="ts">
const products = [
{ id: 1, name: '노트북', price: 1500000 },
{ id: 2, name: '키보드', price: 150000 },
{ id: 3, name: '마우스', price: 50000 }
]
</script>
<template>
<!-- 기본 렌더링 -->
<DataList :items="products">
<template #default="{ item, index }">
<span>{{ index + 1 }}. {{ item.name }} — {{ item.price.toLocaleString() }}원</span>
</template>
</DataList>
<!-- 다른 렌더링 — 같은 컴포넌트, 다른 UI -->
<DataList :items="products">
<template #default="{ item, isLast }">
<div class="card">
<h3>{{ item.name }}</h3>
<p>{{ item.price.toLocaleString() }}원</p>
<hr v-if="!isLast" />
</div>
</template>
</DataList>
</template>
렌더리스 컴포넌트 (Renderless Components)
로직만 제공하고 UI는 부모가 결정하는 패턴입니다.
<!-- MouseTracker.vue — 렌더리스 컴포넌트 -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const x = ref(0)
const y = ref(0)
const update = (e: MouseEvent) => {
x.value = e.pageX
y.value = e.pageY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>
<template>
<!-- 슬롯으로만 렌더링 — 자체 UI 없음 -->
<slot :x="x" :y="y" />
</template>
<!-- 사용 — UI를 부모가 결정 -->
<template>
<MouseTracker v-slot="{ x, y }">
<div>마우스: {{ x }}, {{ y }}</div>
</MouseTracker>
<MouseTracker v-slot="{ x, y }">
<div :style="{ position: 'absolute', left: x + 'px', top: y + 'px' }">
커서 따라다니기
</div>
</MouseTracker>
</template>
참고: 현재는 Composables가 렌더리스 컴포넌트보다 더 권장되는 패턴입니다. 하지만 슬롯 기반 접근이 더 자연스러운 경우도 있습니다.
동적 슬롯 이름
<script setup lang="ts">
import { ref } from 'vue'
const activeSlot = ref('tab1')
</script>
<template>
<BaseLayout>
<template #[activeSlot]>
동적 슬롯 이름으로 렌더링
</template>
</BaseLayout>
</template>
$slots로 슬롯 존재 여부 확인
<script setup lang="ts">
import { useSlots } from 'vue'
const slots = useSlots()
const hasHeader = !!slots.header
const hasFooter = !!slots.footer
</script>
<template>
<div class="card">
<div v-if="hasHeader" class="card-header">
<slot name="header" />
</div>
<div class="card-body">
<slot />
</div>
<div v-if="hasFooter" class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
실전 패턴 — 테이블 컴포넌트
<!-- DataTable.vue -->
<script setup lang="ts" generic="T extends Record<string, any>>">
interface Column {
key: string
label: string
width?: string
}
defineProps<{
columns: Column[]
data: T[]
}>()
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key" :style="{ width: col.width }">
<slot :name="`header-${col.key}`" :column="col">
{{ col.label }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in data" :key="index">
<td v-for="col in columns" :key="col.key">
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]" :index="index">
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<!-- 사용 -->
<template>
<DataTable :columns="columns" :data="users">
<!-- 특정 컬럼만 커스터마이징 -->
<template #cell-status="{ value }">
<span :class="value === 'active' ? 'badge-green' : 'badge-red'">
{{ value }}
</span>
</template>
<template #cell-actions="{ row }">
<button @click="editUser(row)">편집</button>
<button @click="deleteUser(row.id)">삭제</button>
</template>
</DataTable>
</template>
면접 팁
- 일반 슬롯은 부모 → 자식 컨텐츠 전달, 스코프드 슬롯은 ** 자식 → 부모** 데이터 전달입니다
- 렌더리스 컴포넌트 패턴은 ** 관심사 분리(로직 vs UI)**의 좋은 예시입니다
- React의 Render Props 패턴과 Vue의 스코프드 슬롯을 비교할 수 있으면 좋습니다
요약
스코프드 슬롯은 자식의 데이터를 부모의 슬롯 컨텐츠에서 사용할 수 있게 합니다. 렌더리스 컴포넌트는 로직만 제공하고 UI는 부모가 결정하는 패턴이며, $slots로 슬롯 존재 여부를 확인하여 조건부 래퍼를 구현할 수 있습니다.
댓글 로딩 중...