Vue를 쓰다 보면 이상한 지점에 도달한다. 같은 결과를 만드는 방법이 셋이나 있다. 할 일 목록의 개수에 따라 문구를 바꾸는 일을, methods로도 할 수 있고 computed로도 할 수 있고 watch로도 할 수 있다. 화면에 찍히는 글자는 똑같다.
똑같아 보이는데 셋이 존재한다는 건, 보이지 않는 곳에서 서로 다른 일이 벌어지고 있다는 뜻이다. 어떤 건 매번 다시 계산하고 어떤 건 캐시를 쓰며, 어떤 건 값을 만들고 어떤 건 일을 시킨다.
이 글은 그 기준을 정리한 것이다. 그리고 같은 질문의 연장선에서 Vue가 언제 무엇을 하는지(라이프사이클)와 화면을 어떻게 다시 그리는지(Virtual DOM)까지 이어 다룬다. querySelector를 쓰지 말라는 조언의 이유가 여기서 나온다.
이 글은 [Vue 컴포넌트 데이터 흐름 — props와 emit]에서 이어진다. [DOM과 Event 완전 정리]에서 다룬 직접 DOM 조작이 8번 항목에서 다시 등장한다.
📖 이 글에서 다루는 내용
computed— 템플릿에서 로직을 걷어낸다computedvsmethods— 캐싱이 가른다computed를 쓸 때 조심할 것watch— 변화를 감시하고 일을 시킨다computed와watch, 무엇을 쓸 것인가- 라이프사이클 훅 — Vue가 언제 무엇을 하는가
onMounted와onUpdated- Virtual DOM — Vue가 화면을 다시 그리는 방식
- 실제 DOM이 정말 필요할 때 — 템플릿 ref
- 자주 걸려 넘어지는 지점들
- 핵심 요약
1. computed — 템플릿에서 로직을 걷어낸다
computed 는 "계산된 속성"을 정의하는 함수다. 미리 계산해 둔 값을 쓰게 해서 템플릿의 표현식을 단순하게 만들고, 불필요한 반복 연산을 줄인다.
문제 상황부터 보자.

const todos = ref([
{ text: 'Vue 실습' },
{ text: '자격증 공부' },
{ text: 'TIL 작성' }
])
<p>{{ todos.length > 0 ? '아직 남았다' : '퇴근!' }}</p>
동작은 한다. 문제는 둘이다.
- 템플릿이 복잡해진다. 화면 구조를 읽으려는데 계산 로직이 섞여 있다
- 여러 곳에서 쓰면 그대로 반복된다. 조건을 바꾸려면 모든 자리를 찾아 고쳐야 한다
computed로 빼면 이렇게 된다.
import { ref, computed } from 'vue'
const restOfTodos = computed(() => {
return todos.value.length > 0 ? '아직 남았다' : '퇴근!'
})
<p>{{ restOfTodos }}</p>
특징 세 가지
- 반환값은 computed ref다. 일반
ref처럼.value로 참조하고, 템플릿에서는.value를 생략한다 - 의존하는 반응형 데이터를 자동으로 추적한다.
todos를 썼다는 사실을 Vue가 기억한다 - 의존 데이터가 변경될 때만 재평가된다.
todos가 그대로면 다시 계산하지 않는다
세 번째가 methods와 갈리는 지점이다.
2. computed vs methods — 캐싱이 가른다
같은 일을 메서드로도 만들 수 있다.
const getRestOfTodos = function () {
return todos.value.length > 0 ? '아직 남았다' : '퇴근!'
}
<p>{{ getRestOfTodos() }}</p>
화면에 찍히는 결과는 완전히 같다. 결정적인 차이는 캐싱이다.

computed |
method |
|
|---|---|---|
| 캐싱 | 의존하는 반응형 데이터를 기준으로 캐시된다 | 없다 |
| 재실행 시점 | 의존 데이터가 변경된 경우에만 재평가 | 다시 렌더링될 때마다 항상 실행 |
| 실행 트리거 | 의존 데이터 변경 시 자동 | 호출해야 실행 |
같은 값을 화면 세 곳에서 쓴다고 하자. method는 렌더링할 때마다 함수를 세 번 실행한다. computed는 한 번 계산하고 나머지 두 번은 캐시된 결과를 즉시 반환한다. 그리고 todos가 바뀌지 않는 한, 다음 렌더링에서도 다시 계산하지 않는다.
💡 캐시(Cache)란
데이터나 결과를 일시적으로 저장해 두는 임시 저장소다. 같은 것을 다시 계산하지 않고 빠르게 꺼내 쓰기 위한 장치다. 브라우저가 방문했던 페이지의 이미지를 저장해 뒀다가 재접속 시 즉시 보여주는 것과 같은 원리다.
그럼 무조건 computed인가
아니다. 목적이 다르다.
| 이럴 때 쓴다 | |
|---|---|
computed |
의존하는 데이터에 따라 결과가 바뀌는 값을 만들 때. 같은 값을 여러 곳에서 쓸 때 |
method |
단순히 특정 동작을 수행할 때. 인자를 받아야 할 때. 데이터 의존과 무관하게 항상 같은 결과를 낼 때 |
버튼 클릭 핸들러를 computed로 만들 수는 없다. 그건 값이 아니라 동작이다. 그리고 [Vue 조건부·리스트 렌더링] 편에서 본 것처럼, 인자를 받아야 하는 경우(중첩 v-for 안에서 항목마다 다른 필터링)에도 computed로는 만들 수 없어 메서드를 쓴다.
➡️ 무조건 computed가 아니라, 목적과 상황에 맞게 조합한다.
3. computed를 쓸 때 조심할 것
① 반환값은 읽기 전용이다

computed의 반환값은 의존하는 데이터에서 파생된 값이고, 이미 계산이 끝난 상태다. 일종의 스냅샷(snapshot) 이며 의존 데이터가 바뀔 때만 새 스냅샷이 만들어진다.
따라서 읽기 전용으로 취급해야 하고 직접 변경해서는 안 된다. 새 값을 원한다면 의존하는 원본 데이터를 바꾼다.
restOfTodos.value = '퇴근!' // 이러면 안 된다
todos.value.push(newTodo) // 원본을 바꾸면 계산이 따라온다
② 원본 배열을 변경하지 않는다
reverse()와 sort()는 원본 배열을 바꾼다. [JavaScript 객체와 배열 완전 정리]에서 본 변화 메서드다.
return numbers.reverse() // 원본이 뒤집힌다
return [...numbers].reverse() // 복사본을 만들어 뒤집는다
computed 안에서 원본을 건드리면, 계산이 원본을 바꾸고 그 변경이 다시 계산을 유발하는 구조가 된다. 정렬 전에 복사하는 습관이 필요하다.
4. watch — 변화를 감시하고 일을 시킨다
watch 는 하나 이상의 반응형 데이터를 감시하다가, 변경되면 콜백을 호출한다.
watch(source, (newValue, oldValue) => {
// 변경됐을 때 할 일
})
| 인자 | 설명 |
|---|---|
source |
감시 대상 (반응형 변수, 값을 반환하는 함수 등) |
| 콜백 | source가 변경될 때 호출된다 |
└ newValue |
바뀐 값 |
└ oldValue |
바뀌기 전 값 (선택) |
const count = ref(0)
watch(count, (newValue, oldValue) => {
console.log(`newValue: ${newValue}, oldValue: ${oldValue}`)
})
newValue: 1, oldValue: 0
newValue: 2, oldValue: 1
newValue: 3, oldValue: 2
여러 대상을 함께 감시하기
배열로 넘기면 된다. 콜백의 인자도 배열로 들어오므로 구조 분해로 받는다.
watch([foo, bar], ([newFoo, newBar], [prevFoo, prevBar]) => {
// 둘 중 하나라도 바뀌면 실행된다
})
💡
watch는 기본적으로 게으르다(lazy)
등록하는 시점에는 콜백이 실행되지 않고, 값이 바뀌어야 비로소 실행된다. 처음에 한 번 실행하고 시작하고 싶다면 세 번째 인자에 옵션을 준다.watch(keyword, fetchResults, { immediate: true })
⚠️ 객체를 감시하면
newValue와oldValue가 같아진다
객체나 배열의 내부가 바뀐 경우, 두 값 모두 같은 객체를 가리키는 주소라서 이전 값을 구분할 수 없다. [JavaScript 기초 문법 정리]의 참조 타입 이야기 그대로다. 이전 값을 비교해야 한다면 특정 속성만 감시하거나 직접 복사본을 남겨 둬야 한다.
5. computed와 watch, 무엇을 쓸 것인가
둘 다 반응형 데이터의 변화에 반응하지만 목적이 다르다.

computed |
watch |
|
|---|---|---|
| 동작 | 의존 데이터로부터 계산된 값을 반환한다 | 변화를 감지해 작업을 수행한다 (side effect) |
| 목적 | 계산 결과를 캐싱해 재사용 | 데이터 변화에 따른 특정 작업 |
| 예시 | 연산된 길이, 필터링된 목록 | API 호출, 로컬 스토리지 저장, 외부 라이브러리 연동 |
➡️ 값을 만들면
computed, 일을 시키면watch.
그리고 공통점이 하나 있다. 둘 다 감시하는 원본 데이터를 직접 변경하지 않는다.
📌 원본 노트의
watch활용 예시는computed로 써야 하는 경우다
노트에는 입력값의 길이를 구하는 예제가watch로 되어 있다.const message = ref('') const messageLength = ref(0) watch(message, (newValue) => { messageLength.value = newValue.length })동작은 한다. 하지만 이건 값을 만드는 일이므로 바로 다음 절의 기준("값을 만들면 computed")에 정면으로 어긋난다.
computed로 쓰면 한 줄이고, 상태를 하나 덜 만든다.const messageLength = computed(() => message.value.length)
ref두 개를 손으로 동기화하는 코드는 대개computed로 대체할 수 있다는 신호다.watch가 맞는 경우는 입력이 바뀔 때 서버에 검색 요청을 보내는 것처럼, 값이 아니라 바깥세상에 영향을 주는 일을 할 때다.
6. 라이프사이클 훅 — Vue가 언제 무엇을 하는가
라이프사이클 훅(Lifecycle Hooks) 은 Vue 인스턴스의 생애주기 중 특정 시점에 실행되는 함수다. Vue가 컴포넌트를 만들고, 화면에 붙이고, 갱신하고, 걷어내는 각 단계 사이사이에 개발자가 코드를 끼워 넣을 수 있게 열어 둔 자리다.

시점을 순서대로 놓으면 이렇다.
| 시점 | 훅 | 이때 무엇을 하나 |
|---|---|---|
| 인스턴스 생성 | <script setup> 본문 |
상태와 함수를 선언한다. 아직 DOM은 없다 |
| DOM 생성 완료 | onMounted |
서버에서 데이터 가져오기, DOM 요소 접근, 외부 라이브러리 초기화 |
| 데이터 변경 → DOM 갱신 후 | onUpdated |
갱신된 DOM에 대한 후처리 |
| 화면에서 제거되기 전 | onUnmounted |
타이머 해제, 이벤트 리스너 제거, 연결 정리 |
가장 많이 쓰는 것은 이 셋이고, 각 단계에는 onBeforeMount, onBeforeUpdate, onBeforeUnmount처럼 "직전" 버전도 있다.
⚠️ 훅은 반드시 동기적으로 등록해야 한다
Vue는 훅에 등록된 콜백을 어느 인스턴스의 것인지 자동으로 연결하는데, 이게 성립하려면setup이 실행되는 흐름 안에서 훅이 호출되어야 한다.setTimeout안이나await뒤에서onMounted를 부르면 연결이 끊겨 동작하지 않는다.
7. onMounted와 onUpdated
onMounted — DOM이 준비된 뒤
컴포넌트의 초기 렌더링과 DOM 요소 생성이 완료된 후 실행된다.
import { onMounted } from 'vue'
onMounted(() => {
console.log('mounted')
})
서버에서 초기 데이터를 받아오는 코드가 대개 여기 들어간다. [비동기와 AJAX 완전 정리]에서 본 axios.get()을 이 안에서 호출하는 것이 전형적인 패턴이다.
onUpdated — DOM이 갱신된 뒤
반응형 데이터의 변경으로 컴포넌트의 DOM이 업데이트된 후 실행된다.
import { ref, onUpdated } from 'vue'
const count = ref(0)
onUpdated(() => {
console.log('DOM이 갱신되었습니다')
})
⚠️
onUpdated안에서 반응형 데이터를 바꾸면 무한 루프가 될 수 있다
원본 노트의 예제가 정확히 그 형태다.onUpdated(() => { message.value = 'updated!' // message가 템플릿에 쓰이고 있다면 })데이터를 바꾸면 → DOM이 갱신되고 →
onUpdated가 다시 호출되고 → 또 데이터를 바꾸는 순환이 된다. 이 예제는 매번 같은 값을 대입해서 Vue가 변경으로 판단하지 않기에 멈추지만, 카운터를 증가시키는 식이었다면 브라우저가 멈춘다.onUpdated는 갱신된 DOM을 읽는 용도로 쓰고, 상태를 바꿔야 한다면watch를 쓴다.
onUnmounted — 뒷정리
컴포넌트가 화면에서 사라질 때 실행된다. onMounted에서 벌여 놓은 것을 여기서 걷는다.
import { onMounted, onUnmounted } from 'vue'
let timer
onMounted(() => {
timer = setInterval(fetchData, 5000)
})
onUnmounted(() => {
clearInterval(timer) // 정리하지 않으면 컴포넌트가 사라져도 계속 돈다
})
정리하지 않은 타이머와 리스너는 메모리 누수의 대표적인 원인이다. onMounted와 onUnmounted는 한 쌍으로 생각하는 편이 좋다.
8. Virtual DOM — Vue가 화면을 다시 그리는 방식
Virtual DOM은 가상의 DOM을 메모리에 저장해 두고 실제 DOM과 동기화하는 프로그래밍 개념이다. Vue의 내부 렌더링 기술이다.

과정은 이렇다.
- 컴포넌트와 반응형 상태가 가상 DOM 트리로 컴파일된다
- 이 트리가 실제 DOM에 마운트된다
- 반응형 상태가 변경되면 새 가상 DOM 트리를 만든다
- 이전 트리와 비교(패치) 해서 달라진 부분만 실제 DOM에 적용한다
[Vue 조건부·리스트 렌더링] 편에서 key가 왜 필요한지 설명하며 나온 그 비교 과정이 4단계다. key는 이 비교에서 "이전의 이 항목과 새 목록의 저 항목이 같은 것인가"를 판단하는 이름표였다.
| 장점 | 설명 |
|---|---|
| 효율성 | 실제 DOM 조작을 최소화하고 변경된 부분만 갱신한다 |
| 반응성 | 데이터 변경을 감지해 UI를 자동으로 업데이트한다 |
| 추상화 | 개발자는 DOM 조작을 Vue에 맡기고 컴포넌트와 템플릿으로 UI를 구성한다 |
📌 "Virtual DOM이 더 빠르다"는 오해에 선을 그어 둔다
손으로 최적화한 직접 DOM 조작보다 항상 빠른 것은 아니다. 가상 트리를 만들고 비교하는 비용 자체가 있기 때문이다.
Virtual DOM의 진짜 가치는 "선언적으로 쓰면서도 충분히 좋은 성능을 얻는 것" 에 있다. 어느 요소를 어떻게 고칠지 매번 지시하지 않고 "이 자리에 이 값이 들어간다"고 선언만 해도, 실제 갱신은 Vue가 알아서 최소한으로 해 준다. 세 번째 장점인 추상화가 핵심이고 나머지는 그 덕분에 따라오는 것이다.
실제 DOM을 직접 만지지 않는다
Vue가 가상 DOM 기준으로 화면을 관리하는데 우리가 실제 DOM을 직접 고치면, Vue가 알고 있는 상태와 화면이 어긋난다. 다음 갱신 때 Vue가 자기 기준으로 덮어쓰면서 변경이 사라지거나, 반대로 Vue가 관리하던 요소가 사라져 에러가 난다.
📌 원본 노트의 "
addEventListener사용 금지"는 범위를 좁혀 읽어야 한다
금지 대상은 Vue가 관리하는 요소를 직접 찾아 조작하는 것이다.document.querySelector로 컴포넌트 안의 요소를 찾아textContent를 바꾸거나createElement로 노드를 끼워 넣는 행위가 여기 해당한다.
반면window나document에 리스너를 다는 것은 정상적인 패턴이다. 스크롤 위치 추적, 화면 리사이즈 감지, 전역 키보드 단축키는 Vue의 템플릿 문법으로 표현할 방법이 없다.onMounted(() => window.addEventListener('scroll', onScroll)) onUnmounted(() => window.removeEventListener('scroll', onScroll))Vue의 영역 밖에 붙이고, 반드시 정리한다. 이 두 조건을 지키면 문제가 없다.
9. 실제 DOM이 정말 필요할 때 — 템플릿 ref
그래도 DOM 요소에 직접 접근해야 하는 경우가 있다. 입력창에 자동으로 포커스를 주거나, 차트 라이브러리에 그릴 자리를 넘겨줘야 할 때다.
이때는 querySelector가 아니라 ref 속성을 쓴다.

<template>
<input ref="input">
</template>
<script setup>
import { ref, onMounted } from 'vue'
const input = ref(null)
onMounted(() => {
console.log(input.value) // <input> 요소
input.value.focus()
})
</script>
조건이 둘이다.
- 템플릿의
ref="input"속성 이름과const input = ref(null)의 변수 이름이 일치해야 연결된다 - DOM이 생성된 후여야 하므로
onMounted안에서 접근한다
setup 실행 시점에는 아직 DOM이 없어서 input.value가 null이다. 6번 항목의 라이프사이클 순서가 여기서 그대로 적용된다.
💡 Vue 3.5부터는
useTemplateRef()도 쓸 수 있다import { useTemplateRef } from 'vue' const inputEl = useTemplateRef('input')변수 이름과
ref속성 이름을 일치시키지 않아도 되고, 무엇을 가리키는지가 코드에 드러난다. 기존 방식도 그대로 동작한다.
10. 자주 걸려 넘어지는 지점들
① 템플릿에서 computed에 괄호를 붙인다
<p>{{ restOfTodos() }}</p> <!-- computed는 값이지 함수가 아니다 -->
<p>{{ restOfTodos }}</p> <!-- 올바르다 -->
method는 괄호가 필요하고 computed는 필요 없다. 둘을 오가다 보면 헷갈린다.
② computed 안에서 .value를 빠뜨린다
const restOfTodos = computed(() => todos.length > 0 ? '...' : '...')
<script> 안이므로 todos.value.length여야 한다. todos는 ref 객체이고 .length는 undefined다. [Vue 기초] 편에서 본 그 규칙이다.
③ ref 두 개를 watch로 동기화한다
watch(a, (n) => { b.value = n * 2 }) // computed의 일이다
const b = computed(() => a.value * 2) // 이쪽이 맞다
5번 항목의 📌와 같은 이야기다. 한 값이 다른 값에서 자동으로 나온다면 computed다.
④ setup 본문에서 DOM에 접근한다
const input = ref(null)
console.log(input.value) // null — 아직 렌더링 전이다
⑤ onUnmounted를 빠뜨린다
setInterval, addEventListener, 웹소켓 연결은 컴포넌트가 사라져도 자동으로 정리되지 않는다.
✅ 핵심 요약
| 주제 | 꼭 기억할 것 |
|---|---|
computed |
계산된 값을 만든다. 의존 데이터를 자동 추적하고 캐싱한다 |
| 캐싱의 의미 | 같은 값을 여러 번 참조해도 한 번만 계산한다. method는 매 렌더링마다 실행 |
method를 쓸 때 |
동작을 수행할 때, 인자를 받아야 할 때 |
| 읽기 전용 | computed 반환값은 스냅샷이다. 바꾸려면 원본 데이터를 바꾼다 |
| 정렬 주의 | sort·reverse는 원본을 바꾼다. [...arr]로 복사한 뒤 쓴다 |
watch |
값을 만들지 않고 일을 시킨다. API 호출, 저장 같은 부수 효과 |
| 선택 기준 | 값을 만들면 computed, 일을 시키면 watch |
watch는 lazy |
등록 시점엔 실행되지 않는다. 필요하면 { immediate: true } |
| 라이프사이클 | setup에는 DOM이 없다 → onMounted부터 있다 |
onUpdated 주의 |
안에서 반응형 데이터를 바꾸면 무한 루프가 될 수 있다 |
onUnmounted |
타이머·리스너를 정리한다. onMounted와 한 쌍이다 |
| Virtual DOM | 새 트리를 만들어 이전과 비교하고 달라진 부분만 실제 DOM에 적용 |
| 진짜 가치 | 속도가 아니라 추상화 — 선언만 해도 갱신은 Vue가 맡는다 |
| DOM 접근 | Vue 영역 안은 만지지 않는다. 필요하면 템플릿 ref + onMounted |
🔗 참고 자료
'알면좋은것들' 카테고리의 다른 글
| 파이썬 기초(1) - 핵심문법 (0) | 2026.08.16 |
|---|---|
| Vue - pinia & router (2) | 2026.08.01 |
| Vue - props & emit (2) | 2026.08.01 |
| Vue Syntax(2) (0) | 2026.08.01 |
| Vue Syntax(1) (0) | 2026.08.01 |