스코프드 슬롯은 자식 컴포넌트의 데이터를 부모의 슬롯 컨텐츠에서 사용할 수 있게 하는 패턴입니다. 컴포넌트 재사용성의 핵심 도구입니다.

면접에서 "슬롯과 스코프드 슬롯의 차이"를 물어보면, 데이터 흐름 방향의 차이를 설명할 수 있어야 합니다.


기본 슬롯 복습

VUE
<!-- Card.vue -->
<template>
  <div class="card">
    <slot>기본 컨텐츠</slot>
  </div>
</template>

<!-- 사용 -->
<Card>커스텀 컨텐츠</Card>
<Card />  <!-- "기본 컨텐츠" 표시 -->

이름 있는 슬롯

VUE
<!-- 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)

자식이 부모에게 데이터를 전달하는 패턴입니다.

VUE
<!-- 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>
VUE
<!-- 부모 — 자식의 데이터를 받아서 렌더링 방식을 결정 -->
<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는 부모가 결정하는 패턴입니다.

VUE
<!-- 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>
VUE
<!-- 사용 — 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가 렌더리스 컴포넌트보다 더 권장되는 패턴입니다. 하지만 슬롯 기반 접근이 더 자연스러운 경우도 있습니다.


동적 슬롯 이름

VUE
<script setup lang="ts">
import { ref } from 'vue'

const activeSlot = ref('tab1')
</script>

<template>
  <BaseLayout>
    <template #[activeSlot]>
      동적 슬롯 이름으로 렌더링
    </template>
  </BaseLayout>
</template>

$slots로 슬롯 존재 여부 확인

VUE
<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>

실전 패턴 — 테이블 컴포넌트

VUE
<!-- 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>
VUE
<!-- 사용 -->
<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로 슬롯 존재 여부를 확인하여 조건부 래퍼를 구현할 수 있습니다.

댓글 로딩 중...