컴포넌트 심화
컴포넌트 기초를 넘어서면 Provide/Inject, 동적 컴포넌트, 비동기 로딩 같은 고급 패턴이 필요합니다. 이 패턴들은 대규모 앱 설계에 필수적입니다.
면접에서 "Props Drilling 문제를 어떻게 해결하나요?"라는 질문에 Provide/Inject를 설명할 수 있으면, 컴포넌트 설계에 대한 이해도를 보여줄 수 있습니다.
Provide / Inject — Props Drilling 해결
깊이 중첩된 컴포넌트에 데이터를 전달할 때, 중간 컴포넌트들이 불필요하게 Props를 전달하는 문제(Props Drilling)를 해결합니다.
<!-- 조상 컴포넌트 -->
<script setup lang="ts">
import { provide, ref, readonly } from 'vue'
const theme = ref<'light' | 'dark'>('light')
const user = ref({ name: '심정훈', role: 'developer' })
// 반응형 값 provide
provide('theme', readonly(theme)) // readonly로 자식의 변경 방지
provide('user', readonly(user))
// 값 변경 함수도 함께 provide
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide('toggleTheme', toggleTheme)
</script>
<!-- 자손 컴포넌트 (깊이 상관없이 사용 가능) -->
<script setup lang="ts">
import { inject } from 'vue'
import type { Ref } from 'vue'
// 타입과 기본값 지정
const theme = inject<Ref<'light' | 'dark'>>('theme')
const toggleTheme = inject<() => void>('toggleTheme')
// 기본값 제공 — provide가 없을 때 사용
const user = inject('user', { name: '게스트', role: 'visitor' })
</script>
<template>
<div :class="theme">
<p>현재 테마: {{ theme }}</p>
<button @click="toggleTheme?.()">테마 전환</button>
</div>
</template>
Symbol을 key로 사용하기 (권장)
// injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
export const ThemeKey: InjectionKey<Ref<'light' | 'dark'>> = Symbol('theme')
export const ToggleThemeKey: InjectionKey<() => void> = Symbol('toggleTheme')
<script setup lang="ts">
import { provide, ref } from 'vue'
import { ThemeKey, ToggleThemeKey } from './injection-keys'
const theme = ref<'light' | 'dark'>('light')
provide(ThemeKey, theme) // 타입 자동 추론
</script>
<script setup lang="ts">
import { inject } from 'vue'
import { ThemeKey } from './injection-keys'
const theme = inject(ThemeKey) // Ref<'light' | 'dark'> | undefined 타입
</script>
동적 컴포넌트
<component :is> 를 사용하면 런타임에 컴포넌트를 전환할 수 있습니다.
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import TabHome from './TabHome.vue'
import TabProfile from './TabProfile.vue'
import TabSettings from './TabSettings.vue'
// shallowRef — 컴포넌트 객체는 깊은 반응형이 불필요
const currentTab = shallowRef(TabHome)
const tabs = [
{ label: '홈', component: TabHome },
{ label: '프로필', component: TabProfile },
{ label: '설정', component: TabSettings }
]
</script>
<template>
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.label"
:class="{ active: currentTab === tab.component }"
@click="currentTab = tab.component"
>
{{ tab.label }}
</button>
</div>
<!-- 동적 컴포넌트 렌더링 -->
<component :is="currentTab" />
</template>
비동기 컴포넌트 (Lazy Loading)
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
// 기본 사용 — 필요할 때 로드
const AsyncModal = defineAsyncComponent(() =>
import('./HeavyModal.vue')
)
// 고급 옵션 — 로딩/에러 상태 처리
const AsyncChart = defineAsyncComponent({
loader: () => import('./HeavyChart.vue'),
loadingComponent: () => import('./LoadingSpinner.vue'),
errorComponent: () => import('./ErrorDisplay.vue'),
delay: 200, // 로딩 컴포넌트 표시 전 대기 (ms)
timeout: 10000 // 타임아웃 (ms)
})
</script>
<template>
<AsyncModal v-if="showModal" @close="showModal = false" />
<AsyncChart :data="chartData" />
</template>
비동기 컴포넌트는 번들 분할(Code Splitting)을 자동으로 처리합니다. Vite/Webpack이 별도 청크로 분리하여 초기 로딩을 줄여줍니다.
Fallthrough Attributes
컴포넌트에 전달된 속성 중 props나 emits로 선언되지 않은 것들은 자동으로 루트 엘리먼트에 전달됩니다.
<!-- CustomInput.vue -->
<script setup lang="ts">
defineProps<{
label: string
}>()
// inheritAttrs를 false로 설정하면 자동 전달 비활성화
defineOptions({
inheritAttrs: false
})
</script>
<template>
<div class="input-group">
<label>{{ label }}</label>
<!-- $attrs로 수동 전달 — 원하는 엘리먼트에 적용 -->
<input v-bind="$attrs" />
</div>
</template>
<!-- 사용 -->
<template>
<!-- class, placeholder, @input 등이 $attrs를 통해 input에 전달됨 -->
<CustomInput
label="이름"
class="custom-class"
placeholder="이름 입력"
@input="handleInput"
/>
</template>
useAttrs로 접근
<script setup lang="ts">
import { useAttrs } from 'vue'
const attrs = useAttrs()
console.log(attrs.class) // 전달된 class
console.log(attrs.style) // 전달된 style
</script>
재귀 컴포넌트
컴포넌트가 자기 자신을 참조하는 패턴입니다. 트리 구조를 렌더링할 때 유용합니다.
<!-- TreeItem.vue -->
<script setup lang="ts">
import { ref } from 'vue'
interface TreeNode {
id: number
label: string
children?: TreeNode[]
}
const props = defineProps<{
node: TreeNode
depth?: number
}>()
const isOpen = ref(true)
const hasChildren = props.node.children && props.node.children.length > 0
</script>
<template>
<div :style="{ paddingLeft: `${(depth ?? 0) * 20}px` }">
<span @click="isOpen = !isOpen" style="cursor: pointer">
{{ hasChildren ? (isOpen ? '📂' : '📁') : '📄' }}
{{ node.label }}
</span>
<!-- 재귀 — 자기 자신을 참조 -->
<template v-if="isOpen && hasChildren">
<TreeItem
v-for="child in node.children"
:key="child.id"
:node="child"
:depth="(depth ?? 0) + 1"
/>
</template>
</div>
</template>
컴포넌트 expose
script setup에서는 기본적으로 내부 상태가 외부에 노출되지 않습니다. defineExpose로 명시적으로 공개할 수 있습니다.
<!-- ChildForm.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const formData = ref({ name: '', email: '' })
const validate = (): boolean => {
return formData.value.name.length > 0
}
const reset = () => {
formData.value = { name: '', email: '' }
}
// 외부에 공개할 속성/메서드 선택
defineExpose({ validate, reset })
</script>
<!-- 부모 컴포넌트 -->
<script setup lang="ts">
import { ref } from 'vue'
import ChildForm from './ChildForm.vue'
const formRef = ref<InstanceType<typeof ChildForm>>()
const handleSubmit = () => {
if (formRef.value?.validate()) {
console.log('유효함')
}
}
</script>
<template>
<ChildForm ref="formRef" />
<button @click="handleSubmit">제출</button>
<button @click="formRef?.reset()">초기화</button>
</template>
면접 팁
- Provide/Inject는 DI(의존성 주입) 패턴 의 프론트엔드 버전입니다. Spring의 DI와 비교해서 설명하면 백엔드 면접관에게 좋은 인상
defineAsyncComponent는 초기 번들 크기 최적화 의 핵심 도구입니다. 성능 최적화 질문에서 활용하세요defineExpose는 캡슐화 원칙 — 필요한 것만 공개하는 것이 좋은 컴포넌트 설계입니다
요약
Provide/Inject는 Props Drilling 문제를 해결하고, 동적 컴포넌트는 <component :is>로 런타임 전환을 가능하게 합니다. 비동기 컴포넌트는 코드 분할로 초기 로딩을 최적화하며, Fallthrough Attributes와 defineExpose는 컴포넌트 인터페이스를 세밀하게 제어합니다.