2026년 Vue 3와 TypeScript: 타입 안전한 Props, Emits, 컴포저블

TypeScript로 타입 안전한 Vue 3 컴포넌트 마스터하기: 제네릭 defineProps, 튜플 defineEmits, 타입 지정 컴포저블, defineModel, InjectionKey와 면접 질문까지.

Vue 3 TypeScript 타입 안전한 props, emits, 컴포저블

2026년의 Vue 3와 TypeScript 조합은 props, 이벤트, 재사용 로직 전반에 걸쳐 완전한 정적 타입 검사를 제공합니다. <script setup> 구문과 defineProps, defineEmits 같은 컴파일러 매크로를 함께 사용하면, 과거에는 런타임 전용 객체 정의에 불과했던 것이 컴파일 타임 계약으로 바뀝니다. 이 가이드는 프로덕션 코드와 기술 면접 모두에서 모든 Vue 개발자에게 필요한 타입 안전 패턴을 다룹니다.

타입 안전한 props를 한 줄로

Vue 3.5 이상에서는 제네릭 defineProps<T>() 가 TypeScript 인터페이스로부터 prop 타입을 직접 추론합니다. 런타임 type 필드도, PropType 캐스팅도 필요하지 않습니다. 반응형 props 구조 분해와 결합하면 기본값도 인라인으로 작성할 수 있습니다: const { size = 'md' } = defineProps<Props>().

defineProps와 TypeScript 인터페이스로 props에 타입 지정하기

런타임 prop 선언(props: { title: String })도 동작하지만 타입 정보가 중복되고 정밀도가 떨어집니다. 유니온 타입, 중첩 객체, 함수 시그니처를 깔끔하게 표현할 수 없습니다. 제네릭 형태의 defineProps 는 TypeScript 타입으로부터 형태를 받아 Vue 컴파일러가 이를 올바른 런타임 선언으로 자동 변환함으로써 이 문제를 해결합니다.

vue
<!-- UserCard.vue -->
<script setup lang="ts">
interface Props {
  userId: number            // required primitive
  name: string
  role: 'admin' | 'member'  // union type, impossible with runtime props
  tags?: string[]           // optional array
  onSelect?: (id: number) => void  // typed callback prop
}

const props = defineProps<Props>()
</script>

<template>
  <article @click="props.onSelect?.(props.userId)">
    <h3>{{ props.name }}</h3>
    <span>{{ props.role }}</span>
  </article>
</template>

role prop은 이제 두 개의 리터럴 문자열로 제한되므로 role="guest" 를 전달하면 빌드가 실패합니다. 이것이 TypeScript와 함께 defineProps 를 사용하는 핵심 이유입니다. Vue 공식 TypeScript 가이드<script setup> 프로젝트에서는 제네릭 형태를 기본으로 취급합니다.

prop 기본값: withDefaults와 반응형 구조 분해

타입 전용 props는 그 자체만으로는 런타임 기본값 메커니즘이 없습니다. 전통적인 답은 매크로를 감싸 기본값 객체를 병합하는 withDefaults 였습니다.

vue
<!-- Button.vue -->
<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
}

// withDefaults keeps reactivity and applies fallback values
const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'md',
  disabled: false,
})
</script>

Vue 3.5에서 반응형 props 구조 분해가 안정화되었고, 이제 이것이 더 간결한 패턴입니다. defineProps 의 결과를 구조 분해하고 같은 문장에서 기본값을 할당해도 완전히 반응형으로 유지됩니다. 컴파일러가 내부적으로 각 접근을 다시 props.x 로 재작성하기 때문입니다.

vue
<!-- Badge.vue -->
<script setup lang="ts">
interface Props {
  label: string
  color?: 'green' | 'red'
  outlined?: boolean
}

// Defaults are declared inline; each variable stays reactive
const { label, color = 'green', outlined = false } = defineProps<Props>()
</script>

<template>
  <span :class="[color, { outlined }]">{{ label }}</span>
</template>
구조 분해된 props의 반응성 함정

구조 분해된 props는 템플릿과 computed 안에서는 반응형으로 유지되지만, 구조 분해된 값을 그대로 watch 나 컴포저블에 전달하면 한 번만 읽히고 반응형 연결이 끊깁니다. 게터로 감싸거나(watch(() => color, ...)), 하위에서 ref가 필요하면 toRef(props, 'color') 로 변환해야 합니다.

이것은 면접에서 흔한 함정입니다. 지원자는 구조 분해된 변수를 단순한 값이라고 가정하지만, 실제로는 컴파일러가 모든 읽기를 다시 props.color 로 연결해 두었습니다.

defineEmits로 타입 안전한 emits 구현하기

이벤트도 props와 같은 엄격함을 갖출 자격이 있습니다. 제네릭 defineEmits 는 각 이벤트 이름과 페이로드를 튜플로 기술하여, 부모 컴포넌트에는 자동 완성을, 자식 컴포넌트에는 올바른 인자가 emit되는지에 대한 컴파일 타임 검사를 제공합니다.

vue
<!-- SearchInput.vue -->
<script setup lang="ts">
// Tuple syntax: event name -> [ ...payload types ]
const emit = defineEmits<{
  search: [query: string]
  clear: []                       // no payload
  select: [id: number, label: string]  // multiple args
}>()

function onSubmit(value: string) {
  emit('search', value)   // ✅ typed
  // emit('search', 42)   // ❌ compile error: number not assignable to string
}
</script>

이 튜플 형태는 오래된 호출 시그니처 구문((e: 'search', q: string): void)을 대체했습니다. 더 읽기 쉽고 오버로드 없이 여러 이벤트를 지원하기 때문입니다. 이벤트가 데이터를 담지 않을 때는 빈 튜플 [] 이 이를 명시적으로 문서화합니다. 타입이 지정된 emits와 타입이 지정된 props를 짝지으면, 컴포넌트의 공개 인터페이스 전체가 런타임 전에 검증 가능해집니다. 이는 Vue Composition API 가이드에서 다루는 규율과 동일합니다.

재사용 로직을 위한 컴포저블에 타입 지정하기

컴포저블은 평범한 함수이므로 일반적인 TypeScript 규칙을 따릅니다. 다만 몇 가지 관례를 지키면 사용성이 좋게 유지됩니다. 추론이 자명하지 않을 때는 Ref 타입을 명시적으로 반환하고, API 응답 같은 임의의 데이터를 감쌀 때는 제네릭을 사용하세요.

useFetch.tstypescript
import { ref, type Ref } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<Error | null>
  loading: Ref<boolean>
}

// Generic <T> flows through to the caller's typed data
export function useFetch<T>(url: string): UseFetchReturn<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<Error | null>(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 가 완전히 타입 지정됩니다.

UserList.vue (script setup)typescript
interface User { id: number; name: string }

// data is Ref<User[] | null> — inferred from the generic
const { data: users, loading } = useFetch<User[]>('/api/users')

반환 객체에 명시적으로 타입을 주석 다는 것(UseFetchReturn<T>)은 몇 줄을 더 쓸 가치가 있습니다. 계약을 문서화하고, 내부 ref의 의도치 않은 노출을 막으며, 소비자에게 가져올 단일 타입을 제공하기 때문입니다. 인자 오버로드나 라이프사이클을 인식하는 정리 같은 더 깊은 컴포저블 패턴은 고급 Vue 컴포저블 가이드를 참고하세요.

Vue.js / Nuxt.js 면접 준비가 되셨나요?

인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.

양방향 바인딩을 위한 defineModel에 타입 지정하기

Vue 3.4부터 안정화된 defineModel 은 기존의 modelValue prop과 update:modelValue emit을 단일 쓰기 가능 ref로 합칩니다. 제네릭 매개변수가 바인딩의 양쪽 방향을 한 번에 타입 지정합니다.

vue
<!-- CurrencyInput.vue -->
<script setup lang="ts">
// model is Ref<number>; parent v-model is type-checked as number
const model = defineModel<number>({ required: true })

function increment() {
  model.value++   // writing back updates the parent
}
</script>

<template>
  <input :value="model" type="number" @input="model = Number(($event.target as HTMLInputElement).value)" />
  <button @click="increment">+1</button>
</template>

이름이 있는 모델(defineModel<string>('title'))은 v-model:title 에 대응하며 동일한 타입 지정을 받습니다. 이로써 수동 prop-emit 패턴이 숨기던 페이로드 불일치 버그 전체를 제거할 수 있습니다.

템플릿 ref와 컴포넌트 인스턴스에 타입 지정하기

ref 를 통해 DOM 노드나 자식 컴포넌트에 접근하는 지점이야말로 타입이 없는 Vue 코드가 가장 자주 any 로 후퇴하는 곳입니다. 해결책은 useTemplateRef(Vue 3.5 이상)나 ref 자체를 요소 타입으로 매개변수화하여 프로퍼티 접근이 실제 DOM 인터페이스에 대해 검사되도록 하는 것입니다.

vue
<!-- FocusField.vue -->
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'

// The ref is typed as HTMLInputElement | null
const inputRef = useTemplateRef<HTMLInputElement>('field')

onMounted(() => {
  // .focus() and .select() are known because the element type is explicit
  inputRef.value?.focus()
})
</script>

<template>
  <input ref="field" placeholder="Type to search" />
</template>

자식 컴포넌트에 대한 참조에는 InstanceType<typeof Child> 가 컴포넌트의 공개 타입을 추출하여 자식이 defineExpose 로 선언한 것을 노출합니다. 이렇게 하면 부모에서 자식으로의 메서드 호출이 추측이 아니라 완전히 검사된 상태로 유지됩니다.

vue
<!-- Parent.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import Modal from './Modal.vue'

// Ref is typed to Modal's exposed API (e.g. open / close methods)
const modal = useTemplateRef<InstanceType<typeof Modal>>('modal')

function launch() {
  modal.value?.open()   // ✅ checked against Modal's defineExpose
}
</script>

InjectionKey로 타입 안전한 provide와 inject 구현하기

컴포넌트 트리를 가로지르는 의존성 주입은 키가 타입 정보를 운반하지 않는 한 타입 정보를 잃습니다. InjectionKey<T> 는 값의 타입을 키에 묶는 타입 지정 심볼로, 수동 캐스팅 없이 provideinject 의 동기화를 유지합니다.

theme-key.tstypescript
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<ThemeContext> = Symbol('theme')
typescript
// 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<T>() 를 선호하는가? 제네릭 props는 런타임 선언으로는 불가능한 유니온 타입, 함수 시그니처, 중첩 형태를 표현하며, 타입과 런타임 정의의 중복을 제거합니다.
  • 구조 분해된 props는 반응형인가? Vue 3.5 이상에서는 그렇습니다. 컴파일러가 접근을 props.x 로 재작성하기 때문입니다. 다만 watch 나 컴포저블에 전달된 구조 분해된 값은 한 번만 읽힙니다. 게터나 toRef 를 사용하세요.
  • 페이로드가 없는 emit 이벤트에 어떻게 타입을 지정하는가? 빈 튜플입니다: defineEmits<{ close: [] }>().
  • defineModel 은 무엇을 대체하는가? modelValue prop과 update:modelValue emit 쌍을 단일 타입 지정 쓰기 가능 ref로 통합합니다.

이를 실제 문제 은행으로 연습하면 면접이 보상하는 반사 신경이 예리해집니다. Vue 컴포저블 면접 모듈은 바로 이 패턴들을 집중 훈련합니다. 도구도 중요합니다. CI에서 vue-tsc 를 실행하여 타입 오류가 병합을 막도록 하고, 컴포저블 제네릭이 얽힐 때는 TypeScript 핸드북에 기대세요. 에디터 경험은 Vue 공식 Volar 도구로 구동되며, 이 매크로들을 읽어 오류를 인라인으로 표시합니다.

결론

  • 런타임 선언이 담을 수 없는 유니온 타입, 선택적 필드, 콜백 prop을 표현하려면 제네릭 defineProps<Props>() 를 사용하세요.
  • Vue 3.5 이상에서는 인라인 기본값을 갖춘 반응형 props 구조 분해를 선호하고, 공유 기본값 객체가 더 명확할 때만 withDefaults 를 사용하세요.
  • 자식과 부모 모두에서 페이로드가 컴파일 타임에 검사되도록 튜플 형태의 defineEmits 로 이벤트에 타입을 지정하세요.
  • 컴포저블 반환 타입을 명시적으로 주석 달고, 제네릭으로 호출자가 제공한 데이터 타입을 처음부터 끝까지 전달하세요.
  • 양방향 바인딩에는 defineModel<T>() 를 채택하여 prop-emit 상용구를 단일 타입 지정 ref로 합치세요.
  • 타입 회귀가 프로덕션에 도달하기 전에 빌드를 실패시키도록 지속적 통합에서 vue-tsc 를 실행하세요.

연습을 시작하세요!

면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.

태그

#Vue 3
#TypeScript
#defineProps
#Composables
#Frontend

공유

관련 기사