반응형 기초

반응형 기초

Vue 화면이 데이터를 따라 알아서 갱신되는 건, 상태를 "반응형"으로 선언하기 때문이에요. 일반 변수 대신 반응형 값으로 만들어 두면, 값이 바뀔 때 Vue가 감지해서 연결된 DOM을 다시 그려 준답니다. 여기서는 Composition API 기준으로 반응형 상태를 선언하고 다루는 방법을 차근차근 살펴볼게요.

출처: Vue.js 공식 문서 - Reactivity Fundamentals

ref()로 단일 값 다루기

Composition API에서 반응형 상태를 선언하는 가장 권장되는 방법은 ref() 함수예요. 인자로 받은 값을 .value 속성을 가진 ref 객체에 담아 돌려줍니다.

import { ref } from 'vue'

const count = ref(0)

ref()가 만든 객체는 내부 값을 .value로 꺼내고 바꿀 수 있어요.

const count = ref(0)

console.log(count) // { value: 0 }
console.log(count.value) // 0

count.value++
console.log(count.value) // 1

컴포넌트 템플릿에서 ref를 쓰려면, setup()에서 선언하고 반환해 주면 됩니다.

import { ref } from 'vue'

export default {
  // `setup` 은 Composition API 전용 훅이에요.
  setup() {
    const count = ref(0)

    // 템플릿에서 쓸 수 있도록 반환해 주세요.
    return {
      count
    }
  }
}
<div>{{ count }}</div>

템플릿 안에서는 .value를 붙이지 않아도 돼요. 편의를 위해 ref는 템플릿에서 자동으로 풀려서(unwrapped) 사용되거든요. 이벤트 핸들러에서도 ref를 직접 바꿀 수 있습니다.

<button @click="count++">
  {{ count }}
</button>

조금 더 복잡한 로직이 필요하면 같은 스코프에서 ref를 바꾸는 함수를 만들고, 상태와 함께 반환하면 돼요.

import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)

    function increment() {
      // JavaScript 안에서는 .value 가 필요해요.
      count.value++
    }

    // 함수도 빠뜨리지 말고 반환하세요.
    return {
      count,
      increment
    }
  }
}
<button @click="increment">
  {{ count }}
</button>

<script setup>으로 간결하게

setup()에서 상태와 메서드를 일일이 반환하는 건 번거로울 수 있어요. 단일 파일 컴포넌트(SFC)를 쓰면 <script setup> 블록으로 이 수고를 덜 수 있습니다. 파일 최상위에 선언한 import·변수·함수는 같은 컴포넌트의 템플릿에서 자동으로 사용할 수 있어요. 템플릿이 같은 스코프에 선언된 JavaScript 함수라고 생각하면, 옆에 함께 선언된 것들을 자연스럽게 접근할 수 있죠.

<script setup>
import { ref } from 'vue'

const count = ref(0)

function increment() {
  count.value++
}
</script>

<template>
  <button @click="increment">
    {{ count }}
  </button>
</template>

이후 문서에서는 Composition API 코드 예시를 대부분 SFC + <script setup> 문법으로 보여드릴게요. SFC를 쓰지 않더라도 setup() 옵션으로 Composition API를 계속 쓸 수 있습니다.

왜 ref가 필요한가

값이 아니라 .value를 가진 객체로 감싸는 이유가 궁금할 수 있어요. 그건 Vue의 반응형 시스템이 어떻게 동작하는지와 연결됩니다. 템플릿에서 ref를 쓰고 나중에 그 값을 바꾸면, Vue가 변경을 감지해 DOM을 갱신해요. 이건 의존성 추적 기반의 반응형 시스템 덕분이에요. 컴포넌트가 처음 렌더링될 때 Vue는 렌더링 중 사용된 모든 ref를 추적(track) 하고, 나중에 그 ref가 바뀌면 추적 중인 컴포넌트의 재렌더링을 트리거(trigger) 합니다.

일반 JavaScript에서는 그저 변수 하나의 접근·변경을 감지할 방법이 없어요. 하지만 객체 속성은 getter와 setter로 가로챌 수 있죠. .value 속성이 바로 Vue에게 ref가 읽히거나 바뀌었는지 감지할 기회를 주는 겁니다. 내부적으로 Vue는 getter에서 추적을, setter에서 트리거를 수행해요. 개념상으로 ref는 이런 모양의 객체라고 생각하면 됩니다.

// 실제 구현이 아니라 개념을 보여주는 의사 코드예요.
const myRef = {
  _value: 0,
  get value() {
    track()
    return this._value
  },
  set value(newValue) {
    this._value = newValue
    trigger()
  }
}

ref는 일반 변수와 달리, 함수에 전달해도 최신 값과 반응형 연결을 그대로 유지할 수 있어요. 복잡한 로직을 재사용 가능한 코드로 리팩터링할 때 특히 유용합니다.

깊은 반응형(Deep Reactivity)

ref는 어떤 값 타입이든 담을 수 있어요. 깊이 중첩된 객체·배열, Map 같은 자바스크립트 내장 구조도 가능하지요. ref는 그 값을 깊게 반응형으로 만듭니다. 중첩된 객체나 배열을 바꿔도 변경이 감지된다는 뜻이에요.

import { ref } from 'vue'

const obj = ref({
  nested: { count: 0 },
  arr: ['foo', 'bar']
})

function mutateDeeply() {
  // 이 둘은 기대한 대로 동작해요.
  obj.value.nested.count++
  obj.value.arr.push('baz')
}

객체 같은 비단일 값은 아래에서 다룰 reactive()를 거쳐 반응형 프록시로 바뀝니다. 큰 객체의 관찰 비용을 피하고 싶을 때는 얕은 ref(shallow ref)로 깊은 반응형을 선택 해제할 수 있어요. 얕은 ref에서는 .value 접근만 반응형으로 추적됩니다.

DOM 갱신 타이밍

반응형 상태를 바꾸면 DOM이 자동으로 갱신되지만, 그 적용은 동기적이지 않아요. Vue는 상태를 몇 번 바꾸든 "다음 틱(next tick)"까지 버퍼에 모아 두고, 컴포넌트를 한 번만 갱신하게 합니다. 상태 변경 후 DOM 갱신 완료를 기다리려면 nextTick() 전역 API를 쓰면 됩니다.

import { nextTick } from 'vue'

async function increment() {
  count.value++
  await nextTick()
  // 이제 DOM 이 갱신됐어요.
}

reactive()로 객체 그 자체를 반응형으로

ref()가 내부 값을 특별한 객체로 감싸는 것과 달리, reactive()는 객체 그 자체를 반응형으로 만듭니다.

import { reactive } from 'vue'

const state = reactive({ count: 0 })

템플릿에서는 이렇게 씁니다.

<button @click="state.count++">
  {{ state.count }}
</button>

반응형 객체는 JavaScript Proxy로, 일반 객체처럼 동작해요. 차이라면 Vue가 반응형 추적과 트리거를 위해 모든 속성의 접근·변경을 가로챌 수 있다는 점이죠. reactive()는 객체를 깊이 변환해서, 중첩 객체도 접근할 때 그때그때 reactive()로 감쌉니다. ref 값이 객체일 때 ref() 내부에서도 호출되는 함수예요.

반응형 프록시와 원본 객체

reactive()가 돌려주는 값은 원본 객체의 Proxy라서 원본과 같지 않아요.

const raw = {}
const proxy = reactive(raw)

// proxy 는 원본과 같지 않아요.
console.log(proxy === raw) // false

반응형인 건 프록시뿐이라, 원본 객체를 바꾸면 갱신이 일어나지 않아요. 그래서 반응형 시스템을 쓸 때는 항상 프록시 버전만 사용하는 게 좋은 습관입니다. 같은 객체에 reactive()를 몇 번 호출해도 같은 프록시를, 프록시에 reactive()를 호출해도 그 자신을 돌려줘서 일관된 접근을 보장해요.

reactive()의 한계

reactive()에는 몇 가지 제약이 있습니다.

  1. 값 타입 제한 — 객체·배열·Map·Set 같은 컬렉션 타입에서만 동작해요. string·number·boolean 원시 타입은 담지 못합니다.

  2. 전체 객체 교체 불가 — 반응형 추적은 속성 접근 위에서 동작하다 보니, 반응형 객체의 같은 참조를 계속 유지해야 해요. 객체를 통째로 "교체"하면 기존 참조의 반응형 연결이 끊어집니다.

    let state = reactive({ count: 0 })
    
    // 위 ({ count: 0 }) 참조는 더 이상 추적되지 않아요.
    // (반응형 연결이 끊어졌어요!)
    state = reactive({ count: 1 })
    
  3. 구조 분해에 취약 — 반응형 객체의 원시 타입 속성을 지역 변수로 구조 분해하거나 함수에 넘기면 반응형 연결이 끊겨요.

    const state = reactive({ count: 0 })
    
    // 구조 분해하면 count 는 state.count 와 연결이 끊겨요.
    let { count } = state
    // 원본 state 에는 영향을 주지 않아요.
    count++
    
    // 함수는 평범한 숫자를 받으므로 state.count 의 변화를 추적하지 못해요.
    // 반응형을 유지하려면 객체 전체를 넘겨야 합니다.
    callSomeFunction(state.count)
    

이런 제약 때문에, 반응형 상태 선언의 주 API로는 ref()를 권장합니다.

ref 언랩핑의 세부 규칙

반응형 객체의 속성으로 쓸 때

ref는 반응형 객체의 속성으로 접근·변경되면 자동으로 풀려서(unwrapped), 평범한 속성처럼 동작합니다.

const count = ref(0)
const state = reactive({
  count
})

console.log(state.count) // 0

state.count = 1
console.log(count.value) // 1

기존 ref와 연결된 속성에 새 ref를 할당하면, 그 새 ref로 교체됩니다. 이 언랩핑은 깊은 반응형 객체 안에 중첩됐을 때만 일어나고, 얕은 반응형 객체에서는 적용되지 않아요.

배열·컬렉션에서의 주의점

반응형 객체와 달리, 반응형 배열이나 Map 같은 네이티브 컬렉션의 요소로 접근할 때는 언랩핑이 일어나지 않아요.

const books = reactive([ref('Vue 3 Guide')])
// 여기서는 .value 가 필요해요.
console.log(books[0].value)

const map = reactive(new Map([['count', ref(0)]]))
// 여기서도 .value 가 필요해요.
console.log(map.get('count').value)

템플릿에서 풀 때의 주의점

템플릿에서의 ref 언랩핑은, 그 ref가 템플릿 렌더 컨텍스트의 최상위 속성일 때만 적용됩니다. 아래 예시에서 countobject는 최상위 속성이지만, object.id는 그렇지 않아요.

const count = ref(0)
const object = { id: ref(1) }

그래서 이 표현식은 기대대로 동작하고,

{{ count + 1 }}

이 표현식은 그렇지 않습니다.

{{ object.id + 1 }}

object.id는 평가할 때 언랩핑되지 않고 ref 객체로 남아서, 결과가 [object Object]1로 렌더링돼요. 이 문제를 고치려면 id를 최상위 속성으로 구조 분해하면 됩니다.

const { id } = object
{{ id + 1 }}

이제 결과는 2가 됩니다. 다만 텍스트 보간 {{ }} 안에서 최종 평가값이 ref면 그때는 풀려서 렌더링되요. {{ object.id }}{{ object.id.value }}와 같다고 보면 됩니다.

더 알아보기