Post

컴포넌트 심화(Props, Emit, v-model, Slot, Provide/Inject, 비동기 컴포넌트)

컴포넌트 심화(Props, Emit, v-model, Slot, Provide/Inject, 비동기 컴포넌트)

Vue 3 공식 문서의 컴포넌트 심화 섹션 전체를 다룬다. 컴포넌트 등록 방식부터 Props 검증, 커스텀 이벤트 설계, 컴포넌트 v-model, 폴스루 속성($attrs), 슬롯(기본/네임드/스코프드), Provide/Inject, 비동기 컴포넌트까지 — 재사용 가능한 컴포넌트를 올바르게 설계하기 위해 알아야 할 모든 것을 정리한다.


1. 개요

📌 이 글이 커버하는 공식 문서 페이지

페이지핵심 내용
컴포넌트 등록전역 vs 지역 등록, PascalCase 규칙
Props타입 검증, 단방향 흐름, Boolean 변환
컴포넌트 이벤트defineEmits, 이벤트 인자, 유효성 검사
컴포넌트 v-modeldefineModel, 다중 v-model, 커스텀 수식어
폴스루 속성$attrs, inheritAttrs, useAttrs
슬롯기본/네임드/스코프드 슬롯, 렌더리스 컴포넌트
Provide / InjectProps Drilling 해소, 반응형 provide
비동기 컴포넌트defineAsyncComponent, 로딩·에러 상태

📌 컴포넌트 간 통신 전체 지도

1
2
3
4
5
부모 → 자식: props
자식 → 부모: emit
부모 ↔ 자식: v-model (defineModel)
부모 → 콘텐츠 주입: slot
조상 → 자손 (깊이 무관): provide / inject

2. 개념 설명

📌 컴포넌트 등록 — 전역 vs 지역

전역 등록

1
2
3
4
5
6
7
import { createApp } from 'vue'
import MyComponent from './MyComponent.vue'

const app = createApp({})
app.component('MyComponent', MyComponent)
// 체이닝 가능
app.component('A', A).component('B', B)

전역 등록은 앱 어디서든 사용할 수 있지만 두 가지 단점이 있다.

  • 트리 셰이킹 불가: 사용하지 않아도 최종 번들에 포함된다.
  • 의존 관계 불명확: 어떤 컴포넌트가 어디서 쓰이는지 추적하기 어렵다.

지역 등록 (권장)

1
2
3
4
5
6
7
8
<script setup>
// <script setup>에서는 import만 하면 자동 등록
import ButtonCounter from './ButtonCounter.vue'
</script>

<template>
  <ButtonCounter />
</template>

💡 <script setup>에서 import한 컴포넌트는 별도의 components: 선언 없이 자동으로 템플릿에서 사용 가능하다.

컴포넌트 이름 규칙: SFC에서는 PascalCase를 사용한다. 네이티브 HTML 요소와 시각적으로 구분되고, IDE 자동완성에도 유리하다.


📌 Props — 타입 선언, 검증, 단방향 흐름

Props 선언

1
2
3
4
5
6
7
8
9
10
<script setup>
// 배열 문법 (간단)
defineProps(['title', 'likes'])

// 객체 문법 (타입 포함, 권장)
defineProps({
  title: String,
  likes: Number
})
</script>

Props 검증 전체 옵션

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
defineProps({
  // 기본 타입 체크
  propA: Number,

  // 여러 타입 허용
  propB: [String, Number],

  // 필수값
  propC: {
    type: String,
    required: true
  },

  // null 허용 필수값
  propD: {
    type: [String, null],
    required: true
  },

  // 기본값
  propE: {
    type: Number,
    default: 100
  },

  // 객체/배열 기본값은 팩토리 함수로
  propF: {
    type: Object,
    default(rawProps) {
      return { message: 'hello' }
    }
  },

  // 커스텀 검증 함수
  propG: {
    validator(value) {
      return ['success', 'warning', 'danger'].includes(value)
    }
  }
})

단방향 데이터 흐름 — props는 읽기 전용

1
2
3
4
const props = defineProps(['foo'])

// ❌ 경고 발생 — props 직접 변경 금지
props.foo = 'bar'

props를 변경하고 싶은 두 가지 패턴의 올바른 해결책:

1
2
3
4
5
6
7
// 패턴 1: 초기값으로만 사용 → 로컬 ref로 복사
const props = defineProps(['initialCounter'])
const counter = ref(props.initialCounter) // 이후 props와 독립적

// 패턴 2: 변환이 필요한 경우 → computed 활용
const props = defineProps(['size'])
const normalizedSize = computed(() => props.size.trim().toLowerCase())

객체 전체를 props로 펼치기

1
const post = { id: 1, title: 'Vue 3 가이드' }
1
2
3
<!-- 아래 두 코드는 동일 -->
<BlogPost v-bind="post" />
<BlogPost :id="post.id" :title="post.title" />

Boolean 타입 props 특수 규칙

1
2
3
4
5
<!-- :is-published="true" 와 동일 -->
<MyComponent is-published />

<!-- :is-published="false" 와 동일 -->
<MyComponent />

반응형 Props 구조 분해 (Vue 3.5+)

1
2
3
4
5
6
7
// 3.5+에서는 구조 분해해도 반응성 유지됨
const { foo = 'hello' } = defineProps(['foo'])
// 컴파일러가 자동으로 props.foo로 변환

// 단, watch할 때는 반드시 getter로 감싸야 함
watch(() => foo, callback) // ✅
watch(foo, callback)       // ❌ 반응성 없음

📌 컴포넌트 이벤트 — defineEmits와 emit

이벤트 발생

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 자식 컴포넌트 -->
<script setup>
const emit = defineEmits(['inFocus', 'submit'])

function buttonClick() {
  emit('submit')
}
</script>

<template>
  <!-- 템플릿에서는 $emit 직접 사용 가능 -->
  <button @click="$emit('someEvent')">클릭</button>
</template>

이벤트 인자 전달

1
2
3
4
5
6
7
8
<!-- 자식: 값과 함께 이벤트 발생 -->
<button @click="$emit('increaseBy', 1)">+1</button>

<!-- 부모: 인라인 화살표 함수로 수신 -->
<MyButton @increase-by="(n) => count += n" />

<!-- 또는 메서드로 수신 (첫 번째 인자로 전달됨) -->
<MyButton @increase-by="increaseCount" />

💡 컴포넌트 이벤트는 DOM 이벤트와 달리 버블링되지 않는다. 직접적인 부모만 리스닝할 수 있다.

이벤트 유효성 검사

1
2
3
4
5
6
7
8
9
10
11
const emit = defineEmits({
  // 유효성 없음
  click: null,

  // submit 페이로드 검증
  submit: ({ email, password }) => {
    if (email && password) return true
    console.warn('유효하지 않은 이벤트 페이로드!')
    return false
  }
})

이름 규칙: camelCase로 emit하고 부모에서는 kebab-case로 리스닝한다.

1
emit('myEvent')         // 자식에서 camelCase로 발생
1
<MyComp @my-event="handler" /> <!-- 부모에서 kebab-case로 리스닝 -->

📌 컴포넌트 v-model — defineModel

v-model을 컴포넌트에 구현하는 방법이다. Vue 3.4+에서는 defineModel()을 사용하는 것이 권장된다.

기본 사용법

1
2
3
4
5
6
7
8
9
<!-- 자식: Child.vue -->
<script setup>
const model = defineModel()
</script>

<template>
  <!-- model은 ref처럼 동작하며 부모 값과 양방향 동기화 -->
  <input v-model="model" />
</template>
1
2
<!-- 부모 -->
<Child v-model="searchText" />

내부 동작 원리defineModel은 아래 코드의 축약이다.

1
2
3
4
5
6
7
8
9
10
11
12
<!-- 3.4 이전 방식 (여전히 유효) -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>

<template>
  <input
    :value="props.modelValue"
    @input="emit('update:modelValue', $event.target.value)"
  />
</template>

v-model 인자 — 이름 지정

1
2
<!-- 부모 -->
<MyComponent v-model:title="bookTitle" />
1
2
3
4
<!-- 자식 -->
<script setup>
const title = defineModel('title', { required: true })
</script>

다중 v-model

1
2
3
4
5
<!-- 부모 -->
<UserName
  v-model:first-name="first"
  v-model:last-name="last"
/>
1
2
3
4
5
6
7
8
9
10
<!-- 자식 -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>

<template>
  <input v-model="firstName" />
  <input v-model="lastName" />
</template>

커스텀 수식어 처리

1
<MyComponent v-model.capitalize="myText" />
1
2
3
4
5
6
7
8
9
10
11
<script setup>
const [model, modifiers] = defineModel({
  set(value) {
    // capitalize 수식어가 있으면 첫 글자 대문자
    if (modifiers.capitalize) {
      return value.charAt(0).toUpperCase() + value.slice(1)
    }
    return value
  }
})
</script>

📌 폴스루 속성 — $attrs

“폴스루 속성”이란 컴포넌트에 전달됐지만 propsemits에 선언되지 않은 속성/이벤트 리스너다. class, style, id가 대표적이다.

기본 동작 — 루트 엘리먼트에 자동 추가

1
2
<!-- 부모 -->
<MyButton class="large" @click="handler" />
1
2
3
4
5
<!-- MyButton 내부 -->
<button>Click Me</button>

<!-- 렌더링 결과 -->
<button class="large">Click Me</button>  <!-- class가 자동 병합 -->

inheritAttrs: false + v-bind="$attrs" — 적용 위치 제어

루트가 아닌 특정 요소에 폴스루 속성을 적용하고 싶을 때 사용한다.

1
2
3
4
5
6
7
8
9
10
<script setup>
defineOptions({ inheritAttrs: false })
</script>

<template>
  <div class="btn-wrapper">
    <!-- $attrs를 내부 button에만 적용 -->
    <button class="btn" v-bind="$attrs">Click Me</button>
  </div>
</template>

JS에서 접근 — useAttrs()

1
2
3
4
5
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
// attrs는 반응형이 아님 — 변경 감지는 onUpdated 사용
</script>

💡 다중 루트 컴포넌트는 $attrs를 명시적으로 바인딩해야 한다. 그렇지 않으면 런타임 경고가 발생한다.


📌 슬롯 — 콘텐츠를 컴포넌트에 주입하기

기본 슬롯

1
2
3
4
5
6
<!-- FancyButton.vue -->
<template>
  <button class="fancy-btn">
    <slot></slot>  <!-- 부모가 전달한 콘텐츠가 여기에 렌더링 -->
  </button>
</template>
1
2
3
4
5
<!-- 부모 -->
<FancyButton>Click me!</FancyButton>

<!-- 렌더링 결과 -->
<button class="fancy-btn">Click me!</button>

슬롯 콘텐츠는 부모 스코프에서 평가된다. 자식 데이터에 접근할 수 없다.

폴백(기본) 콘텐츠

1
2
3
4
5
<button type="submit">
  <slot>
    Submit  <!-- 부모가 아무것도 전달하지 않으면 이게 렌더링됨 -->
  </slot>
</button>

네임드 슬롯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- BaseLayout.vue -->
<template>
  <div class="container">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>  <!-- name 없는 슬롯 = "default" -->
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 부모: v-slot: 또는 # 축약형 사용 -->
<BaseLayout>
  <template #header>
    <h1>페이지 제목</h1>
  </template>

  <!-- 기본 슬롯 (암묵적으로 #default) -->
  <p>본문 내용</p>

  <template #footer>
    <p>연락처 정보</p>
  </template>
</BaseLayout>

스코프드 슬롯 — 자식 데이터를 슬롯에 노출

슬롯 콘텐츠에서 자식 컴포넌트의 데이터가 필요할 때 사용한다.

1
2
3
4
5
6
7
8
9
<!-- 자식: FancyList.vue -->
<template>
  <ul>
    <li v-for="item in items">
      <!-- item 데이터를 슬롯 prop으로 전달 -->
      <slot name="item" v-bind="item"></slot>
    </li>
  </ul>
</template>
1
2
3
4
5
6
7
8
9
<!-- 부모: v-slot으로 슬롯 prop 수신 -->
<FancyList :api-url="url" :per-page="10">
  <template #item="{ body, username, likes }">
    <div class="item">
      <p></p>
      <p>by  |  likes</p>
    </div>
  </template>
</FancyList>

조건부 슬롯 — $slots로 콘텐츠 존재 여부 확인

1
2
3
4
5
6
7
8
9
10
<template>
  <div class="card">
    <div v-if="$slots.header" class="card-header">
      <slot name="header" />
    </div>
    <div v-if="$slots.default" class="card-content">
      <slot />
    </div>
  </div>
</template>

렌더리스 컴포넌트 패턴

로직만 캡슐화하고 UI 렌더링은 전적으로 슬롯에 위임하는 패턴이다.

1
2
3
<MouseTracker v-slot="{ x, y }">
  마우스 위치: , 
</MouseTracker>

💡 단순한 로직 재사용이라면 Composable이 더 효율적이다. 렌더리스 컴포넌트는 로직과 시각적 출력을 함께 조합해야 할 때 유용하다.


📌 Provide / Inject — Props Drilling 해소

문제: Props Drilling

컴포넌트 트리가 깊어지면 중간 컴포넌트들이 관심도 없는 props를 그냥 통과시켜야 한다. provide/inject가 이 문제를 해결한다.

1
2
3
부모 (provide)
  └─ 중간 컴포넌트 (props 통과 없이 무관)
       └─ 깊은 자식 (inject로 직접 수신)

Provide

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 조상 컴포넌트 -->
<script setup>
import { provide, ref } from 'vue'

const location = ref('North Pole')

function updateLocation() {
  location.value = 'South Pole'
}

// 반응형 값과 변경 함수를 함께 제공
provide('location', { location, updateLocation })
</script>

앱 레벨 provide:

1
app.provide('message', 'hello') // 플러그인에서 자주 활용

Inject

1
2
3
4
5
6
7
8
9
10
<!-- 깊이 중첩된 자손 컴포넌트 -->
<script setup>
import { inject } from 'vue'

const { location, updateLocation } = inject('location')
</script>

<template>
  <button @click="updateLocation"></button>
</template>

기본값 설정

1
2
3
4
const value = inject('message', '기본값')

// 비용이 큰 기본값은 팩토리 함수로
const value = inject('key', () => new ExpensiveClass(), true)

읽기 전용으로 제공 — readonly

1
2
3
4
import { ref, provide, readonly } from 'vue'

const count = ref(0)
provide('count', readonly(count)) // 주입자가 변경 불가

Symbol 키 — 충돌 방지

대규모 앱에서는 문자열 키 대신 Symbol을 사용한다.

1
2
// keys.js
export const injectionKey = Symbol()
1
2
3
4
5
6
7
// 제공자
import { injectionKey } from './keys.js'
provide(injectionKey, value)

// 주입자
import { injectionKey } from './keys.js'
const value = inject(injectionKey)

💡 반응성 원칙: 상태 변경은 provide한 쪽에서만 처리하는 것이 좋다. 변경 함수를 함께 provide하면 데이터와 변경 로직이 한 곳에 모인다.


📌 비동기 컴포넌트 — defineAsyncComponent

대형 앱에서 모든 컴포넌트를 초기에 로드하면 번들 크기가 커진다. 필요한 시점에 로드하는 코드 스플리팅defineAsyncComponent로 구현한다.

기본 사용법

1
2
3
4
5
6
import { defineAsyncComponent } from 'vue'

// 동적 import와 함께 사용 (번들 자동 분리)
const AsyncComp = defineAsyncComponent(() =>
  import('./components/MyComponent.vue')
)
1
2
3
4
5
6
7
8
9
10
11
12
<!-- <script setup> 안에서도 사용 가능 -->
<script setup>
import { defineAsyncComponent } from 'vue'

const AdminPage = defineAsyncComponent(() =>
  import('./components/AdminPageComponent.vue')
)
</script>

<template>
  <AdminPage />
</template>

로딩·에러 상태 처리

1
2
3
4
5
6
7
8
9
10
11
12
const AsyncComp = defineAsyncComponent({
  // 로더 함수
  loader: () => import('./Foo.vue'),

  // 로딩 중 표시할 컴포넌트
  loadingComponent: LoadingComponent,
  delay: 200, // 로딩 컴포넌트 표시 전 지연 (기본 200ms)

  // 에러 시 표시할 컴포넌트
  errorComponent: ErrorComponent,
  timeout: 3000 // 타임아웃 (기본 Infinity)
})

지연 하이드레이션 전략 (Vue 3.5+, SSR 환경)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { defineAsyncComponent, hydrateOnIdle, hydrateOnVisible, hydrateOnInteraction } from 'vue'

// 브라우저가 유휴 상태일 때 하이드레이션
const Comp1 = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: hydrateOnIdle()
})

// 뷰포트에 보일 때 하이드레이션
const Comp2 = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: hydrateOnVisible()
})

// 클릭 등 상호작용 시 하이드레이션
const Comp3 = defineAsyncComponent({
  loader: () => import('./Comp.vue'),
  hydrate: hydrateOnInteraction('click')
})

3. 종합 예제

아래는 이 Phase에서 다룬 개념들을 하나로 엮은 모달 컴포넌트 예제다.

/**

  • Props, Emit, v-model(defineModel), Slot, Provide/Inject를 조합한 예제.
  • ModalProvider가 모달 열기/닫기 상태를 provide하고,
  • BaseModal은 v-model로 표시 상태를 제어한다. */
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<!-- BaseModal.vue -->
<script setup>
// v-model로 부모와 open 상태 양방향 바인딩
const isOpen = defineModel({ default: false })

defineProps({
  title: {
    type: String,
    required: true
  },
  size: {
    type: String,
    default: 'md',
    validator: (v) => ['sm', 'md', 'lg'].includes(v)
  }
})
</script>

<template>
  <Teleport to="body">
    <div v-if="isOpen" class="modal-overlay" @click.self="isOpen = false">
      <div :class="`modal modal--${size}`">
        <header class="modal__header">
          <!-- 네임드 슬롯: header -->
          <slot name="header">
            <h2></h2>
          </slot>
          <button @click="isOpen = false"></button>
        </header>

        <main class="modal__body">
          <!-- 기본 슬롯: 본문 콘텐츠 -->
          <slot />
        </main>

        <footer class="modal__footer">
          <!-- 스코프드 슬롯: 닫기 함수를 부모에 노출 -->
          <slot name="footer" :close="() => (isOpen = false)">
            <button @click="isOpen = false">닫기</button>
          </slot>
        </footer>
      </div>
    </div>
  </Teleport>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- App.vue (사용 예시) -->
<script setup>
import { ref } from 'vue'
import BaseModal from './BaseModal.vue'

const isModalOpen = ref(false)
</script>

<template>
  <button @click="isModalOpen = true">모달 열기</button>

  <BaseModal v-model="isModalOpen" title="회원 정보" size="lg">
    <!-- 기본 슬롯 -->
    <p>회원 정보 내용이 들어갑니다.</p>

    <!-- 스코프드 푸터 슬롯: close 함수 활용 -->
    <template #footer="{ close }">
      <button @click="saveAndClose(close)">저장</button>
      <button @click="close">취소</button>
    </template>
  </BaseModal>
</template>

4. 정리

  • 컴포넌트 등록은 <script setup>에서 import만 하면 자동 지역 등록된다. 전역 등록은 트리 셰이킹이 안 되므로 꼭 필요한 경우에만 쓴다.
  • Props는 읽기 전용이다. 변경이 필요하면 로컬 ref로 복사하거나, computed를 활용한다.
  • Props 검증에 타입·required·default·validator를 적극 활용하면 컴포넌트 사용 계약이 명확해진다.
  • 컴포넌트 이벤트는 버블링되지 않는다. 형제 컴포넌트 간 통신은 상태 관리나 이벤트 버스를 써야 한다.
  • defineModel()modelValue prop + update:modelValue emit의 축약이다. 인자를 줘서 이름을 지정하면 다중 v-model도 가능하다.
  • 폴스루 속성($attrs)은 자동으로 루트 엘리먼트에 적용된다. 다른 곳에 적용하려면 inheritAttrs: false + v-bind="$attrs"를 사용한다.
  • 슬롯은 부모 스코프에서 평가된다. 자식 데이터가 필요하면 스코프드 슬롯(v-slot="slotProps")을 사용한다.
  • Provide/Inject는 Props Drilling을 피하기 위한 수단이다. 변경은 provide한 쪽에서만 처리하고, readonly()로 주입자가 수정하지 못하게 보호하는 것이 좋다.
  • defineAsyncComponent로 컴포넌트를 필요한 시점에 로드하면 초기 번들 크기를 줄일 수 있다.

5. 참고 자료

This post is licensed under CC BY 4.0 by the author.