'Vue 기본기 다지기 3편'에서는 컴포넌트를 만들고, props와 emit을 통해 데이터를 주고받는 방법을 배웠습니다.
이를 통해 우리는 앱의 구조를 체계적으로 잡을 수 있게 되었습니다.
이번 4편에서는 컴포넌트 내부의 '상태(state)', 즉 데이터를 어떻게 다루는지에 대해 깊이 있게 알아보겠습니다.
Vue의 가장 큰 매력 중 하나는 바로
반응성(Reactivity)
입니다.
반응성이란, 데이터가 변경되었을 때 그 데이터를 사용하고 있는 화면 부분이 알아서 즉시 업데이트되는 똑똑한 특징을 말합니다.
이 반응성을 구현하기 위해 Vue 3의 Composition API가 제공하는 핵심 도구들인 ref, reactive, computed, watch에 대해 자세히 살펴보겠습니다.
엑셀 스프레드시트를 한번 떠올려 봅시다.
A1 셀에 10을 입력하고, B1 셀에 20을 입력한 뒤, C1 셀에 =A1+B1이라는 수식을 넣으면 C1에는 30이 표시됩니다.
만약 여기서 A1 셀의 값을 15로 바꾸면, 우리는 C1 셀을 건드리지 않았음에도 그 값이 35로 자동 계산됩니다.
이것이 바로 반응성입니다.
일반적인 JavaScript 변수로는 이런 반응성을 만들 수 없습니다.
let a = 10;let b = 20;let c = a + b; // c는 30a = 15; // a가 바뀌었지만 c는 여전히 30입니다.
Vue에서는 데이터가 변경되었을 때 화면도 함께 변경되는, 마법 같은 반응성을 구현하기 위해 특별한 함수들을 제공합니다.
그것이 바로 ref와 reactive입니다.
ref는 문자열(String), 숫자(Number), 불리언(Boolean)과 같은 원시 타입(primitive type)의 데이터를 반응형으로 만들 때 주로 사용합니다.
ref로 감싸진 데이터는 특별한 객체로 변환되어 Vue가 그 값의 변경을 추적할 수 있게 됩니다.
가장 고전적인 카운터 예제를 통해 ref의 사용법을 알아보겠습니다.
/App.vue
<script setup>import { ref } from 'vue'// 0이라는 값을 가진 반응형 데이터 'count'를 생성합니다.const count = ref(0)function increment() { // 스크립트 안에서 값을 변경하거나 접근할 때는 .value를 사용해야 합니다. count.value++}</script><template> <button @click="increment"> 카운트: {{ count }} </button></template>
여기서 가장 중요한 규칙은, <script> 영역에서는 반드시 .value를 통해 실제 값에 접근해야 한다는 점입니다.
ref는 데이터를 객체로 감싸고, 실제 값은 그 객체의 value 속성에 담기 때문입니다.
하지만 <template> 영역에서는 Vue가 자동으로 .value를 풀어주어(unwrapping) 우리가 편리하게 사용할 수 있도록 돕습니다.
: 원시 타입과 객체 타입 모두 사용 가능합니다. 스크립트에서 항상 .value로 접근해야 합니다.
reactive
: 객체 타입만 사용 가능합니다. .value 없이 직접 속성에 접근합니다.
reactive에는 한 가지 중요한 주의사항이 있습니다.
reactive로 만든 객체는 변수 자체를 새로운 객체로 재할당하거나, 구조 분해 할당을 하면 반응성을 잃게 됩니다.
let state = reactive({ count: 0 })// 이 코드는 state 객체의 count 속성을 바꾸므로 반응성이 유지됩니다.state.count++// 이 코드는 state 변수 자체를 새로운 객체로 교체해버립니다.// 이 순간 기존에 연결된 반응성이 끊어집니다. (주의!)state = reactive({ count: 1 })
이런 함정 때문에 많은 Vue 개발자들은 일관성을 위해 원시 타입이든 객체 타입이든 관계없이 ref를 사용하는 것을 선호하기도 합니다.
computed는 기존의 반응형 데이터들을 조합하여 새로운 데이터를 만들어낼 때 사용합니다.
예를 들어, firstName과 lastName 데이터가 있을 때, 이 둘을 합친 fullName 데이터를 만드는 경우입니다.
/App.vue
<script setup>import { ref, computed } from 'vue'const firstName = ref('Promlee')const lastName = ref('Blog')// firstName이나 lastName이 바뀔 때만 fullName이 알아서 다시 계산됩니다.const fullName = computed(() => { return firstName.value + ' ' + lastName.value})</script><template> <p>성: <input v-model="firstName"></p> <p>이름: <input v-model="lastName"></p> <p>전체 이름: {{ fullName }}</p></template>
computed의 가장 강력한 특징은
캐싱(Caching)
입니다.
위 예제에서 firstName이나 lastName이 바뀌지 않는 한 fullName은 재계산되지 않고 이전에 계산된 값을 즉시 반환합니다.
만약 computed 대신 일반 함수(메소드)를 사용했다면, 화면이 렌더링될 때마다 불필요하게 계속해서 함수가 호출될 것입니다.
따라서 종속된 데이터 기반의 계산은 computed를 사용하는 것이 훨씬 효율적입니다.
변하는지 지켜보다가, 변경이 감지되면 특정 작업을 수행하게 만드는 함수입니다.
마치 데이터에 감시 카메라를 붙여놓는 것과 같습니다.
주로 데이터 변경에 대한 응답으로 API를 호출하거나, 로컬 스토리지에 값을 저장하는 등의 부수 효과(side effect)를 처리할 때 사용됩니다.
/App.vue
<script setup>import { ref, watch } from 'vue'const question = ref('')const answer = ref('질문에는 보통 물음표가 포함됩니다.')// question 데이터가 변경되는 것을 감시합니다.watch(question, async (newQuestion, oldQuestion) => { if (newQuestion.includes('?')) { answer.value = '생각 중...' // 실제로는 여기에 API 호출 로직이 들어갈 수 있습니다. await new Promise(resolve => setTimeout(resolve, 1000)) answer.value = '네, watch는 매우 유용합니다.' }})</script><template> <p> <label>질문을 입력하세요:</label> <input v-model="question"> </p> <p>{{ answer }}</p></template>
watch 함수는 첫 번째 인자로 감시할 대상을 받고, 두 번째 인자로 변경이 일어났을 때 실행될 콜백 함수를 받습니다.
콜백 함수는 변경된 새로운 값(newQuestion)과 변경되기 전의 이전 값(oldQuestion)을 인자로 받을 수 있어 더욱 정교한 로직을 구현할 수 있습니다.
watch는 객체 내부의 특정 속성만 감시하거나, 여러 개의 데이터를 동시에 감시하는 등 다양한 옵션을 제공하여 복잡한 시나리오에 효과적으로 대응할 수 있습니다.