# 2026년 Vue 3와 TypeScript: 타입 안전한 Props, Emits, 컴포저블
> TypeScript로 타입 안전한 Vue 3 컴포넌트 마스터하기: 제네릭 defineProps, 튜플 defineEmits, 타입 지정 컴포저블, defineModel, InjectionKey와 면접 질문까지.
- Published: 2026-07-07
- Updated: 2026-07-07
- Author: SharpSkill
- Tags: Vue 3, TypeScript, defineProps, Composables, Frontend
- Reading time: 8 min
---
2026년의 Vue 3와 TypeScript 조합은 props, 이벤트, 재사용 로직 전반에 걸쳐 완전한 정적 타입 검사를 제공합니다. `
{{ props.name }}
{{ props.role }}
```
`role` prop은 이제 두 개의 리터럴 문자열로 제한되므로 `role="guest"` 를 전달하면 빌드가 실패합니다. 이것이 TypeScript와 함께 `defineProps` 를 사용하는 핵심 이유입니다. [Vue 공식 TypeScript 가이드](https://vuejs.org/guide/typescript/composition-api.html)도 `
```
Vue 3.5에서 **반응형 props 구조 분해**가 안정화되었고, 이제 이것이 더 간결한 패턴입니다. `defineProps` 의 결과를 구조 분해하고 같은 문장에서 기본값을 할당해도 완전히 반응형으로 유지됩니다. 컴파일러가 내부적으로 각 접근을 다시 `props.x` 로 재작성하기 때문입니다.
```vue
{{ label }}
```
> **구조 분해된 props의 반응성 함정**
>
> 구조 분해된 props는 템플릿과 `computed` 안에서는 반응형으로 유지되지만, 구조 분해된 값을 그대로 `watch` 나 컴포저블에 전달하면 한 번만 읽히고 반응형 연결이 끊깁니다. 게터로 감싸거나(`watch(() => color, ...)`), 하위에서 ref가 필요하면 `toRef(props, 'color')` 로 변환해야 합니다.
이것은 면접에서 흔한 함정입니다. 지원자는 구조 분해된 변수를 단순한 값이라고 가정하지만, 실제로는 컴파일러가 모든 읽기를 다시 `props.color` 로 연결해 두었습니다.
## defineEmits로 타입 안전한 emits 구현하기
이벤트도 props와 같은 엄격함을 갖출 자격이 있습니다. 제네릭 `defineEmits` 는 각 이벤트 이름과 페이로드를 튜플로 기술하여, 부모 컴포넌트에는 자동 완성을, 자식 컴포넌트에는 올바른 인자가 emit되는지에 대한 컴파일 타임 검사를 제공합니다.
```vue
```
이 튜플 형태는 오래된 호출 시그니처 구문(`(e: 'search', q: string): void`)을 대체했습니다. 더 읽기 쉽고 오버로드 없이 여러 이벤트를 지원하기 때문입니다. 이벤트가 데이터를 담지 않을 때는 빈 튜플 `[]` 이 이를 명시적으로 문서화합니다. 타입이 지정된 emits와 타입이 지정된 props를 짝지으면, 컴포넌트의 공개 인터페이스 전체가 런타임 전에 검증 가능해집니다. 이는 [Vue Composition API 가이드](/blog/vue-nuxt/vue-3-composition-api-complete-guide)에서 다루는 규율과 동일합니다.
## 재사용 로직을 위한 컴포저블에 타입 지정하기
컴포저블은 평범한 함수이므로 일반적인 TypeScript 규칙을 따릅니다. 다만 몇 가지 관례를 지키면 사용성이 좋게 유지됩니다. 추론이 자명하지 않을 때는 `Ref` 타입을 명시적으로 반환하고, API 응답 같은 임의의 데이터를 감쌀 때는 제네릭을 사용하세요.
```ts
// useFetch.ts
import { ref, type Ref } from 'vue'
interface UseFetchReturn {
data: Ref
error: Ref
loading: Ref
}
// Generic flows through to the caller's typed data
export function useFetch(url: string): UseFetchReturn {
const data = ref(null) as Ref
const error = ref(null)
const loading = ref(true)
fetch(url)
.then((r) => r.json())
.then((json: T) => { data.value = json })
.catch((e: Error) => { error.value = e })
.finally(() => { loading.value = false })
return { data, error, loading }
}
```
호출 지점에서는 제네릭 매개변수 덕분에 추가 주석 없이도 `data` 가 완전히 타입 지정됩니다.
```ts
// UserList.vue (script setup)
interface User { id: number; name: string }
// data is Ref — inferred from the generic
const { data: users, loading } = useFetch('/api/users')
```
반환 객체에 명시적으로 타입을 주석 다는 것(`UseFetchReturn`)은 몇 줄을 더 쓸 가치가 있습니다. 계약을 문서화하고, 내부 ref의 의도치 않은 노출을 막으며, 소비자에게 가져올 단일 타입을 제공하기 때문입니다. 인자 오버로드나 라이프사이클을 인식하는 정리 같은 더 깊은 컴포저블 패턴은 [고급 Vue 컴포저블 가이드](/blog/vue-nuxt/advanced-vue-3-composables-reusable-patterns)를 참고하세요.
## 양방향 바인딩을 위한 defineModel에 타입 지정하기
Vue 3.4부터 안정화된 `defineModel` 은 기존의 `modelValue` prop과 `update:modelValue` emit을 단일 쓰기 가능 ref로 합칩니다. 제네릭 매개변수가 바인딩의 양쪽 방향을 한 번에 타입 지정합니다.
```vue
```
이름이 있는 모델(`defineModel('title')`)은 `v-model:title` 에 대응하며 동일한 타입 지정을 받습니다. 이로써 수동 prop-emit 패턴이 숨기던 페이로드 불일치 버그 전체를 제거할 수 있습니다.
## 템플릿 ref와 컴포넌트 인스턴스에 타입 지정하기
`ref` 를 통해 DOM 노드나 자식 컴포넌트에 접근하는 지점이야말로 타입이 없는 Vue 코드가 가장 자주 `any` 로 후퇴하는 곳입니다. 해결책은 `useTemplateRef`(Vue 3.5 이상)나 `ref` 자체를 요소 타입으로 매개변수화하여 프로퍼티 접근이 실제 DOM 인터페이스에 대해 검사되도록 하는 것입니다.
```vue
```
자식 컴포넌트에 대한 참조에는 `InstanceType` 가 컴포넌트의 공개 타입을 추출하여 자식이 `defineExpose` 로 선언한 것을 노출합니다. 이렇게 하면 부모에서 자식으로의 메서드 호출이 추측이 아니라 완전히 검사된 상태로 유지됩니다.
```vue
```
## InjectionKey로 타입 안전한 provide와 inject 구현하기
컴포넌트 트리를 가로지르는 의존성 주입은 키가 타입 정보를 운반하지 않는 한 타입 정보를 잃습니다. `InjectionKey` 는 값의 타입을 키에 묶는 타입 지정 심볼로, 수동 캐스팅 없이 `provide` 와 `inject` 의 동기화를 유지합니다.
```ts
// theme-key.ts
import type { InjectionKey, Ref } from 'vue'
export interface ThemeContext {
mode: Ref<'light' | 'dark'>
toggle: () => void
}
// The key permanently associates the ThemeContext type with this symbol
export const ThemeKey: InjectionKey = Symbol('theme')
```
```ts
// provider (script setup) — value must match ThemeContext or it fails to compile
provide(ThemeKey, { mode, toggle })
// consumer — theme is inferred as ThemeContext | undefined
const theme = inject(ThemeKey)
theme?.toggle()
```
`ThemeContext` 와 형태가 일치하지 않는 값을 제공하면 주입 지점에서 컴파일 오류가 발생하여, 멀리 떨어진 컴포넌트 간의 어긋남을 출시 전에 잡아냅니다.
## Vue TypeScript 관련 흔한 면접 질문
면접관은 지원자가 컴파일 타임 타입과 런타임 동작 사이의 경계를 이해하는지 살핍니다. 자주 나오는 질문과 그에 대한 날카로운 답을 몇 가지 소개합니다.
- **왜 런타임 객체 형태보다 `defineProps()` 를 선호하는가?** 제네릭 props는 런타임 선언으로는 불가능한 유니온 타입, 함수 시그니처, 중첩 형태를 표현하며, 타입과 런타임 정의의 중복을 제거합니다.
- **구조 분해된 props는 반응형인가?** Vue 3.5 이상에서는 그렇습니다. 컴파일러가 접근을 `props.x` 로 재작성하기 때문입니다. 다만 `watch` 나 컴포저블에 전달된 구조 분해*된 값*은 한 번만 읽힙니다. 게터나 `toRef` 를 사용하세요.
- **페이로드가 없는 emit 이벤트에 어떻게 타입을 지정하는가?** 빈 튜플입니다: `defineEmits<{ close: [] }>()`.
- **`defineModel` 은 무엇을 대체하는가?** `modelValue` prop과 `update:modelValue` emit 쌍을 단일 타입 지정 쓰기 가능 ref로 통합합니다.
이를 실제 문제 은행으로 연습하면 면접이 보상하는 반사 신경이 예리해집니다. [Vue 컴포저블 면접 모듈](/technologies/vue-nuxt/interview-questions/vue-composables)은 바로 이 패턴들을 집중 훈련합니다. 도구도 중요합니다. CI에서 `vue-tsc` 를 실행하여 타입 오류가 병합을 막도록 하고, 컴포저블 제네릭이 얽힐 때는 [TypeScript 핸드북](https://www.typescriptlang.org/docs/handbook/2/generics.html)에 기대세요. 에디터 경험은 [Vue 공식 Volar 도구](https://github.com/vuejs/language-tools)로 구동되며, 이 매크로들을 읽어 오류를 인라인으로 표시합니다.
## 결론
- 런타임 선언이 담을 수 없는 유니온 타입, 선택적 필드, 콜백 prop을 표현하려면 제네릭 `defineProps()` 를 사용하세요.
- Vue 3.5 이상에서는 인라인 기본값을 갖춘 반응형 props 구조 분해를 선호하고, 공유 기본값 객체가 더 명확할 때만 `withDefaults` 를 사용하세요.
- 자식과 부모 모두에서 페이로드가 컴파일 타임에 검사되도록 튜플 형태의 `defineEmits` 로 이벤트에 타입을 지정하세요.
- 컴포저블 반환 타입을 명시적으로 주석 달고, 제네릭으로 호출자가 제공한 데이터 타입을 처음부터 끝까지 전달하세요.
- 양방향 바인딩에는 `defineModel()` 를 채택하여 prop-emit 상용구를 단일 타입 지정 ref로 합치세요.
- 타입 회귀가 프로덕션에 도달하기 전에 빌드를 실패시키도록 지속적 통합에서 `vue-tsc` 를 실행하세요.
---
Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack.
HTML version of this page: https://sharpskill.dev/ko/blog/vue-nuxt/vue-3-typescript-type-safe-props-emits-composables