컴포넌트 기초
컴포넌트는 Vue 애플리케이션의 기본 빌딩 블록입니다. UI를 독립적이고 재사용 가능한 조각으로 나누는 것이 핵심입니다.
면접에서 "Props와 Emit의 관계를 설명해주세요"라는 질문은 단방향 데이터 흐름(One-Way Data Flow) 을 이해하고 있는지 확인하는 것입니다.
컴포넌트 정의와 사용
<!-- ButtonCounter.vue — 컴포넌트 정의 -->
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count++">
{{ count }}번 클릭
</button>
</template>
<!-- App.vue — 컴포넌트 사용 -->
<script setup lang="ts">
// import하면 자동으로 사용 가능 (script setup에서)
import ButtonCounter from './ButtonCounter.vue'
</script>
<template>
<!-- 각 인스턴스는 독립적인 상태를 가짐 -->
<ButtonCounter />
<ButtonCounter />
<ButtonCounter />
</template>
Props — 부모에서 자식으로 데이터 전달
<!-- UserCard.vue -->
<script setup lang="ts">
// TypeScript 타입 기반 Props 선언
interface Props {
name: string
age: number
role?: string // 선택적 prop
isActive?: boolean // 선택적 prop
}
// withDefaults로 기본값 설정
const props = withDefaults(defineProps<Props>(), {
role: '개발자',
isActive: true
})
</script>
<template>
<div class="user-card" :class="{ active: props.isActive }">
<h3>{{ props.name }}</h3>
<p>나이: {{ props.age }}</p>
<p>역할: {{ props.role }}</p>
</div>
</template>
<!-- 부모 컴포넌트 -->
<script setup lang="ts">
import UserCard from './UserCard.vue'
</script>
<template>
<!-- 정적 prop -->
<UserCard name="김개발" :age="25" role="프론트엔드" />
<!-- 동적 prop — v-bind로 바인딩 -->
<UserCard :name="userName" :age="userAge" :is-active="false" />
<!-- 객체로 한번에 전달 -->
<UserCard v-bind="{ name: '이서버', age: 28, role: '백엔드' }" />
</template>
Props 네이밍 컨벤션
| JavaScript (script) | HTML (template) |
|---|---|
camelCase | kebab-case |
isActive | is-active |
userName | user-name |
단방향 데이터 흐름
Props는 부모 → 자식 방향으로만 흐릅니다. 자식에서 Props를 직접 수정하면 안 됩니다.
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
initialCount: number
title: string
}>()
// 패턴 1: prop을 초기값으로 사용하고, 로컬 상태로 관리
const localCount = ref(props.initialCount)
// 패턴 2: prop을 기반으로 computed 값 생성
const normalizedTitle = computed(() =>
props.title.trim().toLowerCase()
)
// 나쁜 예: props를 직접 수정 — Vue가 경고를 발생시킴
// props.initialCount = 10 // 이렇게 하면 안 됨!
</script>
Emit — 자식에서 부모로 이벤트 전달
<!-- SearchInput.vue -->
<script setup lang="ts">
import { ref } from 'vue'
const emit = defineEmits<{
search: [query: string]
clear: []
'update:modelValue': [value: string]
}>()
const query = ref('')
const handleSearch = () => {
emit('search', query.value)
}
const handleClear = () => {
query.value = ''
emit('clear')
}
</script>
<template>
<div class="search-input">
<input v-model="query" @keyup.enter="handleSearch" placeholder="검색어 입력" />
<button @click="handleSearch">검색</button>
<button @click="handleClear">초기화</button>
</div>
</template>
<!-- 부모 컴포넌트 -->
<script setup lang="ts">
import SearchInput from './SearchInput.vue'
const handleSearch = (query: string) => {
console.log('검색어:', query)
}
const handleClear = () => {
console.log('검색 초기화')
}
</script>
<template>
<SearchInput @search="handleSearch" @clear="handleClear" />
</template>
Slots — 컨텐츠 전달
<!-- Card.vue -->
<script setup lang="ts">
defineProps<{
title: string
}>()
</script>
<template>
<div class="card">
<div class="card-header">
<!-- 이름 있는 슬롯 -->
<slot name="header">
<!-- 기본 컨텐츠 (폴백) -->
<h3>{{ title }}</h3>
</slot>
</div>
<div class="card-body">
<!-- 기본 슬롯 -->
<slot>
<p>컨텐츠가 없습니다.</p>
</slot>
</div>
<div class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
<!-- 사용 -->
<template>
<Card title="기본 카드">
<p>이것은 기본 슬롯에 들어갑니다.</p>
<template #header>
<h2>커스텀 헤더</h2>
</template>
<template #footer>
<button>확인</button>
</template>
</Card>
</template>
컴포넌트 등록
전역 등록 vs 지역 등록
// main.ts — 전역 등록
import { createApp } from 'vue'
import App from './App.vue'
import GlobalButton from './components/GlobalButton.vue'
const app = createApp(App)
// 전역 등록 — 모든 컴포넌트에서 import 없이 사용 가능
app.component('GlobalButton', GlobalButton)
app.mount('#app')
<!-- script setup에서는 import하면 자동으로 지역 등록 -->
<script setup lang="ts">
import LocalButton from './LocalButton.vue'
// LocalButton이 자동으로 이 컴포넌트에서 사용 가능
</script>
| 방식 | 장점 | 단점 |
|---|---|---|
| 전역 등록 | import 불필요, 어디서든 사용 | Tree-shaking 불가, 의존성 불명확 |
| 지역 등록 | Tree-shaking 가능, 의존성 명시적 | 매번 import 필요 |
** 결론: 지역 등록(import)을 기본으로 사용하세요.**
Props 유효성 검사 (런타임)
<script setup lang="ts">
// 런타임 유효성 검사가 필요한 경우
const props = defineProps({
// 기본 타입 체크
title: String,
// 필수 + 타입
id: {
type: Number,
required: true
},
// 기본값
status: {
type: String,
default: 'active'
},
// 커스텀 유효성 검사
age: {
type: Number,
validator: (value: number) => {
return value >= 0 && value <= 150
}
}
})
</script>
면접 팁
- "Props는 아래로, Events는 위로" — 이 단방향 데이터 흐름이 Vue 컴포넌트 통신의 기본 원칙입니다
- Props를 직접 수정하면 안 되는 이유를 ** 데이터 추적 가능성 **과 연결해서 설명하면 좋습니다
script setup에서 import하면 자동으로 컴포넌트가 등록되는 것은 ** 컴파일러 매크로** 덕분입니다
요약
Vue 컴포넌트는 Props(부모→자식), Emit(자식→부모), Slots(컨텐츠 전달)의 세 가지 방식으로 통신합니다. script setup과 TypeScript를 사용하면 타입 안전한 컴포넌트를 간결하게 정의할 수 있고, 지역 등록(import)을 기본으로 사용하는 것이 권장됩니다.
댓글 로딩 중...