컴포넌트는 Vue 애플리케이션의 기본 빌딩 블록입니다. UI를 독립적이고 재사용 가능한 조각으로 나누는 것이 핵심입니다.

면접에서 "Props와 Emit의 관계를 설명해주세요"라는 질문은 단방향 데이터 흐름(One-Way Data Flow) 을 이해하고 있는지 확인하는 것입니다.


컴포넌트 정의와 사용

VUE
<!-- ButtonCounter.vue — 컴포넌트 정의 -->
<script setup lang="ts">
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
  <button @click="count++">
    {{ count }}번 클릭
  </button>
</template>
VUE
<!-- App.vue — 컴포넌트 사용 -->
<script setup lang="ts">
// import하면 자동으로 사용 가능 (script setup에서)
import ButtonCounter from './ButtonCounter.vue'
</script>

<template>
  <!-- 각 인스턴스는 독립적인 상태를 가짐 -->
  <ButtonCounter />
  <ButtonCounter />
  <ButtonCounter />
</template>

Props — 부모에서 자식으로 데이터 전달

VUE
<!-- 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>
VUE
<!-- 부모 컴포넌트 -->
<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)
camelCasekebab-case
isActiveis-active
userNameuser-name

단방향 데이터 흐름

Props는 부모 → 자식 방향으로만 흐릅니다. 자식에서 Props를 직접 수정하면 안 됩니다.

VUE
<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 — 자식에서 부모로 이벤트 전달

VUE
<!-- 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>
VUE
<!-- 부모 컴포넌트 -->
<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 — 컨텐츠 전달

VUE
<!-- 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>
VUE
<!-- 사용 -->
<template>
  <Card title="기본 카드">
    <p>이것은 기본 슬롯에 들어갑니다.</p>

    <template #header>
      <h2>커스텀 헤더</h2>
    </template>

    <template #footer>
      <button>확인</button>
    </template>
  </Card>
</template>

컴포넌트 등록

전역 등록 vs 지역 등록

TYPESCRIPT
// 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')
VUE
<!-- script setup에서는 import하면 자동으로 지역 등록 -->
<script setup lang="ts">
import LocalButton from './LocalButton.vue'
// LocalButton이 자동으로 이 컴포넌트에서 사용 가능
</script>
방식장점단점
전역 등록import 불필요, 어디서든 사용Tree-shaking 불가, 의존성 불명확
지역 등록Tree-shaking 가능, 의존성 명시적매번 import 필요

** 결론: 지역 등록(import)을 기본으로 사용하세요.**


Props 유효성 검사 (런타임)

VUE
<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)을 기본으로 사용하는 것이 권장됩니다.

댓글 로딩 중...