computed란

computed는 반응형 데이터를 기반으로 자동 계산되는 값이다. 의존하는 데이터가 변경될 때만 재계산되고, 변경되지 않으면 캐싱된 값을 반환한다.

import { ref, computed } from 'vue'

const price = ref(10000)
const quantity = ref(3)

const total = computed(() => price.value * quantity.value)

console.log(total.value) // 30000

computed vs method

// computed — 캐싱됨
const fullName = computed(() => `${firstName.value} ${lastName.value}`)

// method — 호출할 때마다 실행
function getFullName() {
  return `${firstName.value} ${lastName.value}`
}

computedmethod캐싱의존 값 변경 시에만 재계산호출할 때마다 실행사용fullName.valuegetFullName()템플릿{{ fullName }}{{ getFullName() }}용도파생 데이터이벤트 핸들러, 액션

const list = ref([1, 2, 3, 4, 5])

// computed: list가 바뀔 때만 재계산
const evenNumbers = computed(() => list.value.filter(n => n % 2 === 0))

// method: 템플릿이 렌더링될 때마다 실행
function getEvenNumbers() {
  return list.value.filter(n => n % 2 === 0)
}

읽기 전용 computed

기본 형태. getter만 있다.

const count = ref(0)

const doubleCount = computed(() => count.value * 2)

doubleCount.value = 10 // 경고: computed는 읽기 전용

읽기/쓰기 computed

getter와 setter를 모두 정의할 수 있다.

const firstName = ref('길동')
const lastName = ref('홍')

const fullName = computed({
  get() {
    return `${lastName.value}${firstName.value}`
  },
  set(newValue: string) {
    lastName.value = newValue.charAt(0)
    firstName.value = newValue.slice(1)
  }
})

console.log(fullName.value) // "홍길동"
fullName.value = '김철수'
console.log(lastName.value)  // "김"
console.log(firstName.value) // "철수"

실무 패턴

1. 목록 필터링/정렬

const products = ref([
  { name: '노트북', price: 1500000, category: '전자기기' },
  { name: '마우스', price: 35000, category: '전자기기' },
  { name: '책상', price: 250000, category: '가구' },
])

const searchQuery = ref('')
const selectedCategory = ref('')
const sortBy = ref('name')

const filteredProducts = computed(() => {
  let result = products.value

  if (selectedCategory.value) {
    result = result.filter(p => p.category === selectedCategory.value)
  }

  if (searchQuery.value) {
    const q = searchQuery.value.toLowerCase()
    result = result.filter(p => p.name.toLowerCase().includes(q))
  }

  if (sortBy.value === 'price') {
    result = [...result].sort((a, b) => a.price - b.price)
  } else {
    result = [...result].sort((a, b) => a.name.localeCompare(b.name))
  }

  return result
})

2. 폼 유효성 검사

const email = ref('')
const password = ref('')

const isEmailValid = computed(() =>
  /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)
)

const isPasswordValid = computed(() =>
  password.value.length >= 8
)

const canSubmit = computed(() =>
  isEmailValid.value && isPasswordValid.value
)
<button :disabled="!canSubmit">가입</button>

3. 포맷팅

const price = ref(1500000)

const formattedPrice = computed(() =>
  price.value.toLocaleString('ko-KR') + '원'
)
// "1,500,000원"

const fileSize = ref(2548576)

const formattedSize = computed(() => {
  const kb = fileSize.value / 1024
  if (kb < 1024) return `${kb.toFixed(1)} KB`
  return `${(kb / 1024).toFixed(1)} MB`
})
// "2.4 MB"

4. 상태 파생

const cart = ref([
  { name: '상품A', price: 10000, qty: 2 },
  { name: '상품B', price: 5000, qty: 1 },
])

const itemCount = computed(() =>
  cart.value.reduce((sum, item) => sum + item.qty, 0)
)

const totalPrice = computed(() =>
  cart.value.reduce((sum, item) => sum + item.price * item.qty, 0)
)

const isEmpty = computed(() => cart.value.length === 0)

5. computed 체이닝

computed가 다른 computed를 참조할 수 있다.

const items = ref([1, 2, 3, 4, 5, 6])

const evenItems = computed(() =>
  items.value.filter(n => n % 2 === 0)
)

const evenSum = computed(() =>
  evenItems.value.reduce((a, b) => a + b, 0)
)

// items 변경 → evenItems 재계산 → evenSum 재계산

6. v-model과 함께 (Getter/Setter)

const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

const inputValue = computed({
  get() { return props.modelValue },
  set(val: string) { emit('update:modelValue', val) }
})
<input v-model="inputValue" />

주의사항

1. 사이드 이펙트를 넣지 않는다

// 잘못된 사용
const result = computed(() => {
  fetchData()          // API 호출 금지
  console.log('계산')   // 로그 금지
  otherRef.value = 10  // 다른 반응형 값 변경 금지
  return someValue.value * 2
})

// 사이드 이펙트는 watch를 사용
watch(someValue, (val) => {
  fetchData()
  console.log('변경됨:', val)
})

2. 비동기 로직을 넣지 않는다

// 잘못된 사용
const data = computed(async () => {
  const res = await fetch('/api/data')
  return res.json()
})

// 비동기는 watch나 함수로 처리
const data = ref(null)
watch(id, async (newId) => {
  data.value = await fetch(`/api/data/${newId}`).then(r => r.json())
}, { immediate: true })

3. computed 안에서 원본 배열을 변경하지 않는다

// 잘못된 사용 — 원본 배열을 직접 정렬
const sorted = computed(() => items.value.sort())

// 올바른 사용 — 복사 후 정렬
const sorted = computed(() => [...items.value].sort())

4. 불필요한 computed를 만들지 않는다

// 불필요 — ref를 그대로 감싸기만 함
const userName = computed(() => user.value.name)

// 그냥 직접 접근
// template: {{ user.name }}

computed vs watch

computedwatch목적값을 계산변화에 반응반환새 값없음 (사이드 이펙트 실행)캐싱있음없음비동기불가가능사용 예필터링, 포맷팅, 유효성API 호출, 로그, DOM 조작

// computed: "이 값은 무엇인가?"
const fullName = computed(() => `${first.value} ${last.value}`)

// watch: "이 값이 바뀌면 무엇을 할 것인가?"
watch(searchQuery, (query) => {
  fetchResults(query)
})

정리

  • computed는 반응형 데이터에서 파생된 값을 만들 때 사용
  • 의존 데이터가 변경되지 않으면 캐싱된 값을 반환하여 성능에 유리
  • getter만 있으면 읽기 전용, getter/setter 모두 정의하면 양방향 바인딩 가능
  • 사이드 이펙트, 비동기, 원본 변경은 피한다
  • "값을 계산"하려면 computed, "변화에 반응"하려면 watch