Vue 3 Script Setup과 defineModel 2026년판: 모던 문법과 인터뷰 질문
Vue 3의 script setup 문법과 defineModel을 활용한 양방향 바인딩을 마스터합니다. 리액티브 props 구조 분해, TypeScript 패턴, Vue 인터뷰 빈출 질문을 다룹니다.

Vue 3의 script setup 문법은 보일러플레이트를 제거하고 TypeScript 통합을 개선하여 컴포넌트 작성 방식을 혁신적으로 변화시켰습니다. Vue 3.3에서 실험적으로 도입되고 Vue 3.4에서 안정화된 defineModel과 결합하면, 양방향 데이터 바인딩을 가진 컴포넌트 구축에 필요한 코드량이 Options API에 비해 크게 줄어듭니다.
Vue 3.5부터 defineProps에서 props를 직접 구조 분해해도 리액티비티가 유지됩니다. 이로써 대부분의 경우 toRefs가 더 이상 필요하지 않습니다.
Vue 3.5의 Script Setup 기초
<script setup> 문법은 Single File Component 내에서 Composition API를 사용하기 위한 컴파일 타임 신택스 슈가입니다. 최상위 레벨에서 선언된 변수는 자동으로 템플릿에 노출되며, import는 등록 없이 바로 사용할 수 있습니다.
<!-- UserCard.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { User } from '@/types'
// TypeScript 인터페이스를 사용한 Props
const props = defineProps<{
user: User
showEmail?: boolean
}>()
// 리액티브 상태
const isExpanded = ref(false)
// computed 프로퍼티
const displayName = computed(() => {
return `${props.user.firstName} ${props.user.lastName}`
})
// 타입이 지정된 페이로드를 가진 이벤트 이미터
const emit = defineEmits<{
select: [userId: string]
delete: [userId: string]
}>()
// 메서드는 일반 함수로 정의
function handleSelect() {
emit('select', props.user.id)
}
</script>
<template>
<div class="user-card" @click="handleSelect">
<h3>{{ displayName }}</h3>
<p v-if="showEmail">{{ user.email }}</p>
<button @click.stop="isExpanded = !isExpanded">
{{ isExpanded ? 'Collapse' : 'Expand' }}
</button>
</div>
</template>컴파일러가 export 문과 컴포넌트 등록을 자동으로 처리합니다. 이는 setup() 함수에서 명시적으로 값을 반환하는 것과 비교했을 때 인지 부하를 줄여줍니다.
리액티브 Props 구조 분해 (Vue 3.5 이상)
Vue 3.5 이전에는 props를 구조 분해하면 리액티비티가 깨졌습니다. 이는 JavaScript의 구조 분해가 해당 시점의 값 스냅샷을 생성하기 때문입니다. Vue 3.5에서는 구조 분해된 props의 리액티비티를 유지하는 컴파일러 레벨 추적이 도입되었습니다.
<!-- SearchInput.vue -->
<script setup lang="ts">
import { watch } from 'vue'
// 기본값이 있는 구조 분해 - 3.5 이상에서 리액티브 유지
const { query = '', placeholder = 'Search...' } = defineProps<{
query?: string
placeholder?: string
}>()
// watch는 구조 분해된 props에서도 동작
watch(
() => query,
(newQuery) => {
console.log('Query changed:', newQuery)
}
)
</script>
<template>
<input
:value="query"
:placeholder="placeholder"
@input="$emit('update:query', ($event.target as HTMLInputElement).value)"
/>
</template>이 문법은 Vue 3.5 이상이 필요합니다. Vue 3.5 릴리스 공지에서 리액티브 props 구조 분해 동작의 전체 목록을 확인할 수 있습니다.
defineModel을 활용한 양방향 바인딩
defineModel 도입 이전에는 커스텀 컴포넌트에서 v-model을 구현하려면 prop을 선언하고 업데이트 이벤트를 수동으로 emit해야 했습니다. 이 패턴은 수많은 Vue 코드베이스에서 발견됩니다:
<!-- 기존 방식 - 장황하지만 명시적 -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
function updateValue(event: Event) {
emit('update:modelValue', (event.target as HTMLInputElement).value)
}
</script>defineModel을 사용하면 동일한 컴포넌트가 훨씬 짧아집니다:
<!-- TextInput.vue -->
<script setup lang="ts">
// defineModel은 v-model과 동기화되는 ref를 생성
const model = defineModel<string>({ default: '' })
</script>
<template>
<input
v-model="model"
type="text"
class="text-input"
/>
</template>부모 컴포넌트는 표준 v-model 문법을 사용합니다:
<!-- ParentForm.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import TextInput from './TextInput.vue'
const username = ref('')
</script>
<template>
<TextInput v-model="username" />
<p>Username: {{ username }}</p>
</template>defineModel은 부모의 바인딩된 값과 동기화를 유지하는 ref를 반환합니다. 이 ref에 대한 변경은 자동으로 업데이트 이벤트를 emit합니다.
Vue.js / Nuxt.js 면접 준비가 되셨나요?
인터랙티브 시뮬레이터, flashcards, 기술 테스트로 연습하세요.
명명된 모델과 다중 v-model 바인딩
컴포넌트는 여러 개의 양방향 바인딩이 필요한 경우가 있습니다. Vue는 명명된 v-model 바인딩을 지원하며, defineModel은 이름 인자를 받아 이를 처리합니다.
<!-- DateRangePicker.vue -->
<script setup lang="ts">
// 다중 v-model 바인딩을 위한 명명된 모델
const startDate = defineModel<Date>('start', { required: true })
const endDate = defineModel<Date>('end', { required: true })
// 유효성 검사용 computed
const isValidRange = computed(() => {
if (!startDate.value || !endDate.value) return true
return startDate.value <= endDate.value
})
</script>
<template>
<div class="date-range-picker">
<input
type="date"
:value="startDate?.toISOString().split('T')[0]"
@input="startDate = new Date(($event.target as HTMLInputElement).value)"
/>
<span>to</span>
<input
type="date"
:value="endDate?.toISOString().split('T')[0]"
@input="endDate = new Date(($event.target as HTMLInputElement).value)"
/>
<p v-if="!isValidRange" class="error">End date must be after start date</p>
</div>
</template>부모는 두 모델을 모두 바인딩합니다:
<DateRangePicker v-model:start="filterStart" v-model:end="filterEnd" />defineModel에서의 v-model 수정자
Vue의 내장 수정자인 .trim, .number, .lazy는 defineModel과 자동으로 작동합니다. 커스텀 수정자는 반환되는 튜플의 두 번째 요소를 통해 명시적으로 처리해야 합니다.
<!-- CurrencyInput.vue -->
<script setup lang="ts">
// 두 번째 반환값으로 수정자에 접근
const [model, modifiers] = defineModel<number>({
default: 0,
// 커스텀 수정자를 위한 변환 함수
set(value) {
// 'round' 수정자가 있으면 소수점 둘째 자리로 반올림
if (modifiers.round) {
return Math.round(value * 100) / 100
}
return value
}
})
</script>
<template>
<div class="currency-input">
<span>$</span>
<input
type="number"
step="0.01"
:value="model"
@input="model = parseFloat(($event.target as HTMLInputElement).value) || 0"
/>
</div>
</template>커스텀 수정자 사용 예시:
<CurrencyInput v-model.round="price" />Script Setup에서의 TypeScript 패턴
<script setup> 컴포넌트에서의 타입 안전성은 컴파일러 매크로에 전달되는 제네릭에 의존합니다. 패턴은 props, emits, models에 따라 약간 다릅니다.
<!-- GenericList.vue -->
<script setup lang="ts" generic="T extends { id: string }">
import { computed } from 'vue'
// 제네릭 컴포넌트 props
const props = defineProps<{
items: T[]
selected?: T
}>()
const emit = defineEmits<{
select: [item: T]
remove: [id: string]
}>()
// 슬롯도 타입 지정 가능
defineSlots<{
default: (props: { item: T; index: number }) => any
empty: () => any
}>()
const hasItems = computed(() => props.items.length > 0)
</script>
<template>
<ul v-if="hasItems">
<li
v-for="(item, index) in items"
:key="item.id"
:class="{ selected: selected?.id === item.id }"
@click="emit('select', item)"
>
<slot :item="item" :index="index">
{{ item.id }}
</slot>
</li>
</ul>
<div v-else>
<slot name="empty">No items</slot>
</div>
</template>Vue 3.3 이후 사용 가능한 제네릭 컴포넌트는 사용하는 코드에서 타입 추론을 희생하지 않으면서 재사용 가능한 타입 안전 컴포넌트를 구현할 수 있게 합니다. 더 자세한 패턴은 TypeScript에 관한 Vue 문서에서 엣지 케이스를 다루고 있습니다.
Script Setup 관련 인터뷰 빈출 질문
Vue 지식을 테스트하는 면접관들은 script setup과 Options API의 차이점, 그리고 리액티비티 메커니즘에 초점을 맞추는 경우가 많습니다.
질문: props 구조 분해가 리액티비티를 왜 깨뜨리나요? Vue 3.5는 이를 어떻게 해결했나요?
JavaScript에서 구조 분해는 구조 분해 시점의 값을 담은 새 변수를 생성합니다. 나중에 props.count가 변경되더라도 const { count } = props로 구조 분해된 변수는 여전히 이전 값을 가지고 있습니다. Vue 3.5의 컴파일러는 구조 분해된 변수를 접근 시 재평가되는 getter로 변환하여 런타임 오버헤드 없이 리액티비티를 보존합니다.
질문: defineModel이 사용될 때 내부적으로 무슨 일이 발생하나요?
컴파일러는 defineModel을 prop 선언과 emit 등록으로 확장합니다. 런타임에서는 getter가 prop 값을 읽고 setter가 emit('update:modelValue', newValue)를 호출하는 ref를 생성합니다. 이 ref는 prop의 진정한 복사본이 아니라 읽기와 쓰기를 적절히 전달하는 프록시입니다.
질문: 컴포넌트에서 script setup과 일반 setup 함수 중 언제 어떤 것을 사용해야 하나요?
Script setup은 보일러플레이트를 줄이고 IDE 지원을 향상시키기 때문에 대부분의 Single File Component에서 권장됩니다. 일반 setup() 함수는 복잡한 패턴에서 defineExpose를 통해 부모 컴포넌트에 메서드를 노출해야 하거나, 렌더 함수를 직접 반환하는 렌더리스 컴포넌트를 구축할 때 필요합니다. 인터뷰 준비를 위해 Vue Composables 패턴도 살펴보시기 바랍니다.
Options API에서 Script Setup으로 마이그레이션
기존 코드베이스에는 마이그레이션으로 이점을 얻을 수 있는 Options API 컴포넌트가 종종 포함되어 있습니다. 변환은 예측 가능한 패턴을 따릅니다:
<!-- 마이그레이션 전: Options API -->
<script lang="ts">
import { defineComponent, PropType } from 'vue'
export default defineComponent({
props: {
items: { type: Array as PropType<string[]>, required: true }
},
emits: ['select'],
data() {
return { searchTerm: '' }
},
computed: {
filteredItems() {
return this.items.filter(item =>
item.toLowerCase().includes(this.searchTerm.toLowerCase())
)
}
},
methods: {
selectItem(item: string) {
this.$emit('select', item)
}
}
})
</script><!-- 마이그레이션 후: Script Setup -->
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{ items: string[] }>()
const emit = defineEmits<{ select: [item: string] }>()
const searchTerm = ref('')
const filteredItems = computed(() =>
props.items.filter(item =>
item.toLowerCase().includes(searchTerm.value.toLowerCase())
)
)
function selectItem(item: string) {
emit('select', item)
}
</script>마이그레이션된 버전은 더 짧고 PropType 캐스트 없이 타입 추론이 작동합니다. Vue 팀은 자동 마이그레이션을 위한 vue-codemod를 제공하지만, 복잡한 컴포넌트에는 수동 검토가 여전히 필요합니다.
Script Setup의 성능 고려사항
Script setup 컴포넌트는 컴파일러가 빌드 타임에 컴포넌트의 정확한 형태를 알고 있기 때문에 더 효율적인 코드로 컴파일됩니다. 구체적으로:
- 템플릿 바인딩이 런타임 프로퍼티 룩업이 아닌 컴파일 타임에 해결됨
- 사용하지 않는 import가 더 효과적으로 트리쉐이킹됨
- 래핑 함수가 없어 클로저 오버헤드가 감소함
번들 크기를 우선시하는 애플리케이션의 경우, script setup과 Vapor Mode(Vue 3.6 이상에서 사용 가능)를 결합하면 옵트인한 컴포넌트의 가상 DOM을 완전히 제거하고 대신 직접 DOM 연산을 생성합니다.
연습을 시작하세요!
면접 시뮬레이터와 기술 테스트로 지식을 테스트하세요.
Vue 3 Script Setup과 defineModel 핵심 요약
- Script setup은 최상위 바인딩을 자동으로 템플릿에 노출하고 컴포넌트 등록을 처리하여 컴포넌트 보일러플레이트를 줄임
defineModel은v-model의 수동 prop + emit 패턴을 대체하며, 부모의 바인딩된 값과 동기화되는 ref를 반환함- Vue 3.5는 리액티브 props 구조 분해를 도입하여
const { prop } = defineProps()에서도 리액티비티가 유지됨 - 명명된 모델은
defineModel('name')을 사용하여 단일 컴포넌트에서 다중v-model바인딩을 지원함 - TypeScript 제네릭은
defineProps,defineEmits,defineSlots, 그리고generic속성을 가진 제네릭 컴포넌트에서 작동함 - Options API에서의 마이그레이션은 기계적인 패턴을 따름: data는 ref로, computed는 computed로, methods는 함수가 되고, 컴파일러가 export를 처리함
Vue.js / Nuxt.js 코드의 버그를 찾을 수 있나요
실제 코드 한 조각, 숨은 버그 하나, 하루 한 번. 계정 없이 바로 도전할 수 있습니다.

작성자
Anthony Fillion-MailletSharpSkill 창업자
10년 이상 풀스택 개발을 해왔습니다. SharpSkill을 운영하며 이곳에 게시되는 모든 내용에 책임을 집니다.
2026년 9월 18일 업데이트
태그
공유
관련 기사

Vue 3 Reactivity Transform 2026: $ref, $computed 및 기술 면접 대비
Vue 3의 Reactivity Transform($ref, $computed)에 대해 심층적으로 분석합니다. 2026년 현재 상황, 폐기 이유, 현재 권장되는 대안, 그리고 기술 면접에서 자주 출제되는 질문들을 상세히 다룹니다.

Vue 3 컴포저블 심화 가이드: 재사용 가능한 패턴과 기술 면접 질문 2026
Vue 3 고급 컴포저블 패턴을 체계적으로 분석합니다. 비동기 처리, 의존성 주입, 폼 유효성 검사, 테스트 전략, 그리고 2026년 기술 면접에서 자주 출제되는 질문과 답변을 다룹니다.

2026년 Nuxt 4 완벽 가이드: 새로운 디렉터리 구조와 Nuxt 3 마이그레이션 전략
Nuxt 4에서 도입된 app/ 디렉터리 구조, 싱글톤 데이터 패칭 레이어, shallow reactivity, TypeScript 컨텍스트 분리를 코드 예제와 함께 상세히 분석합니다.