컴포넌트 Props
컴포넌트 Props
부모 컴포넌트가 자식에게 데이터를 넘기는 가장 기본적인 통로가 props예요. props를 제대로 선언해 두면 컴포넌트가 받는 외부 입력이 명확해지고, 실수로 엉뚱한 타입을 넘겼을 때도 브라우저 콘솔에서 바로 경고를 받을 수 있어요. 여기서는 props를 선언하고, 다양한 값 타입을 넘기고, 검증하는 방법을 정리합니다.
Props 선언
Vue 컴포넌트는 받을 props를 명시적으로 선언해야 해요. 그래야 외부에서 전달된 속성 중 무엇을 props로 취급하고 무엇을 폴스루 속성(fallthrough attributes)으로 볼지 알 수 있거든요.
<script setup>을 쓰는 SFC에서는 defineProps() 매크로로 선언합니다.
<script setup>
const props = defineProps(['foo'])
console.log(props.foo)
</script>
<script setup>이 아닌 컴포넌트에서는 props 옵션으로 선언해요. setup()이 첫 번째 인자로 props를 받습니다.
export default {
props: ['foo'],
setup(props) {
// setup() 은 첫 번째 인자로 props 를 받아요.
console.log(props.foo)
}
}
defineProps()에 넘기는 인자는 props 옵션에 주는 값과 같아요. 두 선언 방식이 동일한 props 옵션 API를 공유합니다.
객체 문법으로 선언하기
문자열 배열로 선언하는 것 외에 객체 문법도 쓸 수 있어요. 각 속성의 key는 prop 이름, value는 기대되는 타입의 생성자 함수입니다.
// <script setup> 안에서
defineProps({
title: String,
likes: Number
})
// <script setup> 이 아닐 때
export default {
props: {
title: String,
likes: Number
}
}
이렇게 하면 컴포넌트를 문서화하는 동시에, 다른 개발자가 잘못된 타입을 넘기면 브라우저 콘솔에서 경고하게 됩니다.
TypeScript를 <script setup>과 함께 쓰면 순수 타입 표기만으로도 props를 선언할 수 있어요.
<script setup lang="ts">
defineProps<{
title?: string
likes?: number
}>()
</script>
반응형 Props 구조 분해 (3.5+)
Vue의 반응형 시스템은 속성 접근 기반으로 상태 사용을 추적해요. 예를 들어 computed getter나 watcher에서 props.foo에 접근하면 foo prop이 의존성으로 추적됩니다.
그래서 이런 코드가 있다고 해볼게요.
const { foo } = defineProps(['foo'])
watchEffect(() => {
// 3.5 이전에는 한 번만 실행
// 3.5+ 에서는 "foo" prop 이 바뀌면 다시 실행
console.log(foo)
})
3.4 이하에서는 foo가 실제 상수라 절대 변하지 않아요. 3.5 이상에서는 컴파일러가 같은 <script setup> 블록 안에서 defineProps에서 구조 분해한 변수에 접근할 때 props.를 자동으로 앞에 붙여줍니다. 그래서 위 코드는 아래와 동일해져요.
const props = defineProps(['foo'])
watchEffect(() => {
// `foo` 는 컴파일러가 `props.foo` 로 변환해요.
console.log(props.foo)
})
JavaScript의 기본값 문법으로 props의 기본값을 선언할 수도 있는데, 타입 기반 선언을 쓸 때 특히 유용합니다.
const { foo = 'hello' } = defineProps<{ foo?: string }>()
구조 분해한 props를 함수에 넘길 때
구조 분해한 prop을 함수에 그대로 넘기면, watch(foo, ...)는 값 하나를 전달하는 셈이라 기대대로 동작하지 않아요. 컴파일러가 이런 경우를 잡아 경고도 냅니다. 일반 prop을 watch(() => props.foo, ...)처럼 감시하듯, getter로 감싸면 됩니다.
watch(() => foo, /* ... */)
외부 함수에 구조 분해한 prop을 넘기며 반응형을 유지하고 싶을 때도 getter로 감싸서 전달하는 걸 권장해요.
useComposable(() => foo)
외부 함수는 computed나 watcher getter처럼 전달된 prop의 변화를 추적해야 할 때 그 getter를 호출하면 됩니다.
Props 전달 상세
prop 이름 대소문자
긴 prop 이름은 greetingMessage처럼 camelCase로 선언하는데, 속성 key로 쓸 때 따옴표가 필요 없고 유효한 JavaScript 식별자라 템플릿 표현식에서 바로 참조할 수 있기 때문이에요.
defineProps({
greetingMessage: String
})
<span>{{ greetingMessage }}</span>
자식에게 prop을 전달할 때는 camelCase도 기술적으로 가능하지만, HTML 속성에 맞추려면 모든 경우에 kebab-case를 쓰는 게 관례입니다.
<MyComponent greeting-message="hello" />
컴포넌트 태그는 가능하면 PascalCase를 쓰지만, prop을 전달할 때 camelCase를 쓰는 실익은 크지 않아서 각 언어의 관례를 따르는 편이에요.
정적 vs 동적 Props
지금까지 본 건 정적 값 전달이에요.
<BlogPost title="My journey with Vue" />
v-bind나 그 축약 :로 동적인 값도 할당할 수 있습니다.
<!-- 변수 값을 동적으로 할당 -->
<BlogPost :title="post.title" />
<!-- 복잡한 표현식의 값을 동적으로 할당 -->
<BlogPost :title="post.title + ' by ' + post.author.name" />
다양한 값 타입 전달
props에는 문자열뿐 아니라 어떤 타입이든 전달할 수 있어요. 다만 정적 값이라도 JavaScript 표현식이라는 걸 Vue에 알리려면 v-bind가 필요하다는 점을 기억하세요.
<!-- `42` 가 정적이어도 v-bind 로 표현식임을 알려줘요. -->
<BlogPost :likes="42" />
<!-- 변수 값 동적 할당 -->
<BlogPost :likes="post.likes" />
<!-- 값 없는 prop 은 `true` 를 의미해요. -->
<BlogPost is-published />
<!-- `false` 도 v-bind 로 표현식임을 알려줘요. -->
<BlogPost :is-published="false" />
<!-- 배열도 v-bind 로 감싸서 전달해요. -->
<BlogPost :comment-ids="[234, 266, 273]" />
<!-- 객체도 마찬가지예요. -->
<BlogPost
:author="{
name: 'Veronica',
company: 'Veridian Dynamics'
}"
/>
객체의 여러 속성을 한 번에 바인딩
객체의 모든 속성을 props로 넘기고 싶다면 인자 없는 v-bind를 쓰면 됩니다. post 객체가 있다고 할 때,
const post = {
id: 1,
title: 'My Journey with Vue'
}
이 템플릿은
<BlogPost v-bind="post" />
이것과 동일해요.
<BlogPost :id="post.id" :title="post.title" />
바인딩 병합 동작
같은 컴포넌트에 v-bind와 명시적 바인딩을 함께 쓰면 Vue가 내부적으로 mergeProps()를 호출해 합칩니다. 합병 전략은 key 타입에 따라 달라요.
- 일반 props — 마지막 값이 이깁니다.
<!-- title === 'bar' -->
<BlogPost title="foo" v-bind="{ title: 'bar' }" />
- 이벤트 리스너 —
v-bind객체에 리스너를 넘길 때는onEventNamekey 관례를 쓰고, 같은 이벤트의 모든 핸들러가 호출됩니다.
<!-- 1 과 2 를 모두 로그 -->
<BlogPost @click="console.log(1)" v-bind="{ onClick: () => console.log(2) }" />
class와style— 비슷한 병합 전략을 따릅니다. 전체 병합 규칙은mergeProps()API 레퍼런스에 자세히 나와 있어요.
단방향 데이터 흐름
모든 props는 자식 속성과 부모 속성 사이의 단방향(위에서 아래로) 바인딩을 이룹니다. 부모 속성이 갱신되면 아래로 흘러 자식에게 전달되지만, 그 반대는 없어요. 이건 자식 컴포넌트가 부모 상태를 실수로 바꿔 앱의 데이터 흐름을 이해하기 어렵게 만드는 걸 막아 줍니다.
부모 컴포넌트가 갱신될 때마다 자식의 모든 props는 최신 값으로 새로고침됩니다. 즉, 자식 컴포넌트 안에서 prop을 바꾸려 시도하면 안 돼요. 하면 Vue가 콘솔에서 경고합니다.
const props = defineProps(['foo'])
// ❌ 경고, props 는 읽기 전용이에요!
props.foo = 'bar'
prop을 바꾸고 싶어지는 경우는 보통 두 가지예요.
-
초기값으로만 쓰고, 이후엔 지역 데이터로 쓰고 싶은 경우 — prop을 초기값으로 쓰는 지역 데이터 속성을 정의하는 게 좋아요.
const props = defineProps(['initialCounter']) // counter 는 props.initialCounter 를 초기값으로만 써요. // 이후 prop 갱신과는 연결이 끊어집니다. const counter = ref(props.initialCounter) -
원시 값을 변형해야 하는 경우 — prop의 값을 이용해 computed 속성을 정의하는 게 좋아요.
const props = defineProps(['size']) // prop 이 바뀌면 자동 갱신되는 computed 속성 const normalizedSize = computed(() => props.size.trim().toLowerCase())
객체·배열 props를 바꾸는 경우
객체와 배열이 props로 전달되면, 자식은 prop 바인딩 자체는 바꾸지 못하지만 객체·배열의 중첩 속성은 바꿀 수 있습니다. JavaScript에서 객체·배열은 참조로 전달되고, Vue가 이런 변경을 모두 막는 건 비용이 지나치게 크기 때문이에요.
이런 변경의 문제는 자식이 부모에게 드러나지 않는 방식으로 부모 상태에 영향을 줄 수 있다는 점이에요. 데이터 흐름을 추론하기 어려워질 수 있죠. 부모·자식이 설계상 밀접하게 결합된 경우가 아니라면 피하는 게 좋은 습관입니다. 대부분의 경우 자식은 이벤트를 발생시켜 부모가 변경을 수행하게 해야 해요.
Props 검증
컴포넌트는 props에 요구사항을 명시할 수 있는데, 앞서 본 타입 검사가 그 예시예요. 요구사항이 맞지 않으면 브라우저 JavaScript 콘솔에서 경고합니다. 다른 사람이 쓸 컴포넌트를 개발할 때 특히 유용해요. 문자열 배열 대신 검증 요구사항을 담은 객체를 defineProps() 매크로(또는 props 옵션)에 넘기면 됩니다.
defineProps({
// 기본 타입 검사
// (`null` 과 `undefined` 값은 어떤 타입이든 허용해요)
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,
// 객체·배열 기본값은 팩토리 함수에서 반환해야 해요.
// 함수는 컴포넌트가 받은 원시 props 를 인자로 받아요.
default(rawProps) {
return { message: 'hello' }
}
},
// 커스텀 검증 함수
// 3.4+ 에서 전체 props 가 두 번째 인자로 전달돼요.
propG: {
validator(value, props) {
// 값은 이 문자열 중 하나와 일치해야 해요.
return ['success', 'warning', 'danger'].includes(value)
}
},
// 기본값이 있는 함수
propH: {
type: Function,
// 객체·배열 기본값과 달리 팩토리 함수가 아니라,
// 기본값으로 쓸 함수 그 자체예요.
default() {
return 'Default function'
}
}
})
defineProps() 인자 안의 코드는 <script setup>에 선언한 다른 변수에 접근할 수 없어요. 컴파일되면 전체 표현식이 외부 함수 스코프로 옮겨지기 때문이에요.
추가 세부 규칙이 몇 가지 있습니다.
- 모든 props는
required: true를 지정하지 않는 한 기본적으로 선택적이에요. Boolean이 아닌 선택적 prop이 전달되지 않으면undefined값을 가져요.Booleanprop이 전달되지 않으면false로 캐스팅됩니다.default: undefined처럼 기본값을 지정해 비-Boolean처럼 동작하게 바꿀 수 있어요.default값을 지정하면, 해석된 prop 값이undefined일 때(prop이 없거나 명시적undefined가 전달된 경우 포함) 그 기본값을 씁니다.
검증이 실패하면 개발 빌드에서 콘솔 경고가 발생합니다.
런타임 타입 검사
type은 다음 네이티브 생성자 중 하나일 수 있어요: String, Number, Boolean, Array, Object, Date, Function, Symbol, Error. 또한 type은 커스텀 클래스나 생성자 함수가 될 수 있고, instanceof 검사로 단언됩니다.
class Person {
constructor(firstName, lastName) {
this.firstName = firstName
this.lastName = lastName
}
}
defineProps({
author: Person
})
Vue는 author prop 값이 실제로 Person 클래스의 인스턴스인지 instanceof Person으로 검증합니다.
Null 허용 타입
타입이 필수인데 null을 허용해야 한다면 null을 포함한 배열 문법을 쓸 수 있어요.
defineProps({
id: {
type: [String, null],
required: true
}
})
type을 배열 문법 없이 그냥 null로 두면 어떤 타입이든 허용하게 된다는 점을 주의하세요.
Boolean 캐스팅
Boolean 타입 props는 네이티브 boolean 속성의 동작을 흉내 내는 특별한 캐스팅 규칙이 있어요. disabled: Boolean으로 선언한 <MyComponent>가 있다고 하면,
defineProps({
disabled: Boolean
})
이렇게 사용할 수 있습니다.
<!-- :disabled="true" 를 넘긴 것과 같아요. -->
<MyComponent disabled />
<!-- :disabled="false" 를 넘긴 것과 같아요. -->
<MyComponent />
여러 타입을 허용하는 선언에도 Boolean 캐스팅 규칙이 적용됩니다. 다만 String과 Boolean을 동시에 허용할 때는 Boolean이 String 앞에 있어야 캐스팅 규칙이 적용돼요.
// disabled 는 true 로 캐스팅돼요.
defineProps({
disabled: [Boolean, Number]
})
// disabled 는 true 로 캐스팅돼요.
defineProps({
disabled: [Boolean, String]
})
// disabled 는 true 로 캐스팅돼요.
defineProps({
disabled: [Number, Boolean]
})
// disabled 는 빈 문자열로 파싱돼요 (disabled="")
defineProps({
disabled: [String, Boolean]
})
더 알아보기
- 컴포넌트 기초 — props 전달의 첫걸음
- 컴포넌트 이벤트 — 반대 방향의 통신, 자식→부모
- Vue.js 공식 문서 - Props