화면을 그리는 일은 결국 두 가지 질문으로 갈린다. "이걸 보여줄까 말까" 와 "이걸 몇 개 그릴까" 다. Vue는 각각에 v-if와 v-for를 준다. 쓰는 법 자체는 한 줄이면 끝날 만큼 간단하다.
문제는 그다음이다. 목록에 항목을 하나 추가했더니 엉뚱한 곳의 체크박스가 켜지고, v-if와 v-for를 같이 썼더니 undefined 에러가 나고, filter로 걸렀는데 화면이 안 바뀐다. Vue가 화면을 어떻게 다시 그리는지를 모르면 원인을 찾을 수 없는 문제들이다.
이 글은 그 구간을 정리한 것이다. v-if와 v-show의 갈림길에서 시작해, key가 왜 필요한지, 왜 인덱스를 쓰면 안 되는지까지 — 함정이 몰려 있는 곳을 중심으로 묶었다.
이 글은 [Vue 템플릿 문법과 디렉티브]에서 이어진다. 디렉티브의 구조와
v-bind가 낯설다면 그쪽을 먼저 보는 편이 좋다. [JavaScript 객체와 배열 완전 정리]의 배열 메서드도 10번 항목에서 그대로 다시 나온다.
📖 이 글에서 다루는 내용
v-if— 조건에 따라 만들거나 없앤다<template>— 보이지 않는 wrapperv-show— 만들어 두고 숨긴다v-if와v-show, 무엇을 쓸 것인가v-for— 목록을 그린다- 중첩과 여러 요소 반복
key— Vue가 요소를 알아보는 이름표- 인덱스를
key로 쓰면 안 되는 이유 v-for와v-if를 같은 요소에 쓰지 않는다- 배열을 필터링하고 정렬하기
- 자주 걸려 넘어지는 지점들
- 디렉티브 치트시트
- 핵심 요약
1. v-if — 조건에 따라 만들거나 없앤다
v-if는 표현식의 참/거짓을 기준으로 요소를 조건부로 렌더링한다. 거짓이면 그 요소는 DOM에 아예 존재하지 않는다.

const isSeen = ref(true)
<p v-if="isSeen">true일 때 보여요</p>
<p v-else>false일 때 보여요</p>
<button @click="isSeen = !isSeen">토글</button>
v-else-if로 분기를 늘릴 수 있다. JavaScript의 if / else if / else와 구조가 같다.
const name = ref('Cathy')
<div v-if="name === 'Alice'">Alice입니다</div>
<div v-else-if="name === 'Bella'">Bella입니다</div>
<div v-else-if="name === 'Cathy'">Cathy입니다</div>
<div v-else>아무도 아닙니다.</div>
⚠️
v-else와v-else-if는 바로 앞 형제 요소를 따라간다v-if가 붙은 요소의 직전 형제가 아니면 인식되지 않는다. 사이에 다른 요소를 끼워 넣으면 조용히 동작이 깨지거나 컴파일 경고가 뜬다.<p v-if="ok">A</p> <hr> <!-- 이게 끼면 --> <p v-else>B</p> <!-- v-else가 짝을 잃는다 -->
2. <template> — 보이지 않는 wrapper
조건을 여러 요소에 한꺼번에 걸고 싶을 때가 있다. <div>로 감싸면 되지만, 그러면 원하지 않는 <div>가 화면 구조에 남는다.
HTML의 <template> 요소가 이 문제를 푼다. 자기 자신은 렌더링되지 않으면서 자식들을 묶는 그릇 역할을 한다.

<template v-if="name === 'Cathy'">
<div>Cathy입니다</div>
<div>나이는 30살입니다</div>
</template>
렌더링 결과에는 <template> 태그가 포함되지 않는다. 두 <div>만 남는다.
💡
<ul>이나<table>안에서 특히 필요하다<ul>바로 아래에는<li>만 와야 하고<tbody>아래에는<tr>만 와야 하는데, 조건부 묶음을 만들자고<div>를 끼우면 HTML 구조가 깨진다.<template>은 흔적을 남기지 않으므로 이런 곳에서 유일한 선택지가 된다.
3. v-show — 만들어 두고 숨긴다
v-show는 참/거짓에 따라 요소의 가시성(visibility) 을 전환한다. v-if와 결정적으로 다른 점은 요소가 항상 DOM에 렌더링되어 있다는 것이다. CSS의 display 속성만 바꾼다.
const isShow = ref(false)
<div v-show="isShow">v-show</div>
<!-- → <div style="display: none;">v-show</div> -->
개발자도구로 확인하면 차이가 한눈에 보인다. v-if가 거짓이면 그 자리에 아무것도 없고, v-show가 거짓이면 요소는 그대로 있고 display: none이 붙어 있다.
4. v-if와 v-show, 무엇을 쓸 것인가

v-if |
v-show |
|
|---|---|---|
| 거짓일 때 | DOM에 없다 | DOM에 있고 display: none |
| 초기 렌더링 | 조건이 거짓이면 아무 작업도 하지 않는다 | 조건과 무관하게 항상 렌더링한다 |
| 토글 비용 | 높다 (요소를 만들고 없앤다) | 낮다 (CSS 한 줄) |
| 요약 | 초기 로드가 싸고 토글이 비싸다 | 초기 로드가 비싸고 토글이 싸다 |
➡️ 자주 전환한다면
v-show, 실행 중에 조건이 거의 바뀌지 않는다면v-if.
탭 전환이나 토글 버튼처럼 사용자가 계속 껐다 켜는 UI는v-show가 맞고, 권한에 따라 한 번 결정되고 끝나는 메뉴는v-if가 맞다.
⚠️
v-show는<template>에 쓸 수 없고v-else와도 짝이 되지 않는다v-show는 결국 요소의style을 건드리는 방식인데,<template>은 렌더링되지 않으므로 스타일을 붙일 대상이 없다. 같은 이유로v-else도v-if전용이다.
💡 보안이 걸린 정보에는
v-show를 쓰지 않는다display: none은 화면에서 안 보일 뿐 DOM에는 그대로 있다. 개발자도구를 열면 내용이 그대로 읽힌다. 권한이 없는 사용자에게 감춰야 하는 값이라면v-if로 아예 만들지 않거나, 애초에 서버에서 내려주지 않아야 한다.
5. v-for — 목록을 그린다
v-for는 소스 데이터를
기반으로 요소나 템플릿 블록을 여러 번 렌더링한다. 배열, 객체, 숫자, 문자열, 반복 가능한 객체를 받는다.

문법은 alias in expression 이다.
<div v-for="item in items">{{ item.text }}</div>
두 번째, 세 번째 자리에 별칭을 더 받을 수 있는데 대상이 배열이냐 객체냐에 따라 의미가 달라진다.
| 대상 | 첫 번째 | 두 번째 | 세 번째 |
|---|---|---|---|
| 배열 | 요소 | 인덱스 | — |
| 객체 | 값(value) | 키(key) | 인덱스 |
<!-- 배열 -->
<div v-for="(item, index) in myArr">{{ index }} / {{ item.name }}</div>
<!-- 객체 -->
<div v-for="(value, key, index) in myObj">{{ index }} / {{ key }} / {{ value }}</div>
객체에서 값이 먼저 오고 키가 나중에 온다는 점이 헷갈리는 지점이다. for...in과 순서가 반대라고 기억하면 된다.
💡
in대신of도 쓸 수 있고, 숫자도 받는다<div v-for="item of items"></div> <!-- in과 동일하게 동작한다 --> <span v-for="n in 5">{{ n }}</span> <!-- 1 2 3 4 5 -->숫자를 넘기면 1부터 시작한다. 배열 인덱스처럼 0부터일 거라 생각하면 어긋난다. 페이지네이션 버튼이나 별점 표시에 편하다.
6. 중첩과 여러 요소 반복
<template>으로 여러 요소를 함께 반복하기
2번에서 본 것과 같은 이유다. 반복 단위가 여러 요소일 때 <template>으로 묶는다.
<ul>
<template v-for="item in myArr" :key="item.id">
<li>{{ item.name }}</li>
<li>{{ item.age }}</li>
<hr>
</template>
</ul>
:key는 <template>에 붙인다. 안쪽 개별 요소가 아니라 반복 단위에 붙는 것이다.
중첩된 v-for
안쪽 v-for에서 바깥 범위의 변수에 접근할 수 있다. JavaScript의 중첩 반복문과 스코프 규칙이 같다.
const myInfo = ref([
{ name: 'Alice', age: 20, friends: ['Bella', 'Cathy', 'Dan'] },
{ name: 'Bella', age: 21, friends: ['Alice', 'Cathy'] }
])
<ul v-for="item in myInfo" :key="item.name">
<li v-for="friend in item.friends" :key="friend">
{{ item.name }} - {{ friend }}
</li>
</ul>
안쪽 <li>에서 바깥의 item.name을 그대로 쓴다.
7. key — Vue가 요소를 알아보는 이름표
여기서부터가 이 글의 핵심이다. v-for를 쓸 때 :key는 사실상 필수다. 왜 그런지는 Vue가 화면을 다시 그리는 방식을 알아야 이해된다.
데이터가 바뀌면 Vue는 화면 전체를 새로 만들지 않는다. 바뀌기 전 목록과 바뀐 후 목록을 비교해 달라진 부분만 고친다. 그런데 비교하려면 "이전 목록의 이 항목"과 "새 목록의 저 항목"이 같은 것인지 판단할 기준이 있어야 한다. 그 기준이 key다.

let id = 0
const items = ref([
{ id: id++, name: 'Alice' },
{ id: id++, name: 'Bella' },
])
<div v-for="item in items" :key="item.id">
{{ item.name }}
</div>
key가 있으면 Vue는 이름표를 보고 같은 항목을 찾아낸다. key가 없으면 자리 순서로만 대응시킨다.
key에 대한 규칙
- 반드시 각 항목을 고유하게 식별하는 값이어야 한다
- number 또는 string만 쓴다. 객체를 넣으면 안 된다
- 형제 요소들 사이에서 유일하면 된다. 전역적으로 유일할 필요는 없다
| 권장하는 값 | 피해야 할 값 |
|---|---|
| 데이터베이스의 고유 ID | 배열 인덱스 |
| 항목의 고유 식별자 (UUID 등) | 객체 자체 |
| 중복되지 않는 문자열 필드 | 매번 새로 만드는 랜덤값 |
⚠️
key에Math.random()을 넣으면 안 된다
고유하기만 하면 되는 줄 알고 랜덤값을 넣는 경우가 있는데, 매 렌더링마다 값이 달라지므로 Vue는 모든 항목을 새 항목으로 판단한다. 목록 전체가 매번 파괴되고 다시 만들어져서 성능도 나쁘고 입력 상태도 전부 날아간다.key는 같은 항목이면 항상 같은 값이어야 한다.
8. 인덱스를 key로 쓰면 안 되는 이유
:key="index"는 가장 흔한 실수다. 고유하긴 하니까 괜찮아 보이는데, 인덱스는 항목의 정체가 아니라 그 순간의 위치일 뿐이다.

<div v-for="(item, index) in items" :key="index"> <!-- 쓰지 않는다 -->
목록 맨 앞에 항목을 하나 삽입한다고 하자.
| 삽입 전 | 삽입 후 | |
|---|---|---|
| index 0 | Alice | New |
| index 1 | Bella | Alice |
| index 2 | Cathy | Bella |
Vue 입장에서는 "key 0번 항목의 내용이 Alice에서 New로 바뀌었다" 로 보인다. 실제로는 새 항목이 끼어든 것인데, Vue는 기존 요소를 재사용하면서 텍스트만 갈아 끼운다.
텍스트만 있다면 결과는 같아 보인다. 문제는 요소가 자기 상태를 가질 때 터진다.
<li v-for="(todo, index) in todos" :key="index">
<input type="checkbox"> <!-- 체크 상태는 DOM이 가지고 있다 -->
{{ todo.name }}
</li>
Alice 항목의 체크박스를 켠 뒤 맨 앞에 New를 삽입하면, 체크 표시가 New에 남고 Alice는 꺼진다. DOM 요소는 자리에 그대로 있고 텍스트만 바뀌었기 때문이다. 사용자가 입력하던 값, 스크롤 위치, 애니메이션 상태도 마찬가지로 어긋난다.
➡️
key를 생략하는 것과 인덱스를 넣는 것은 사실상 같은 결과다. Vue의 기본 동작이 자리 기반 매칭이기 때문이다. "일단 경고나 없애자"고:key="index"를 붙이는 것은 문제를 감추는 셈이다.
💡 고유 ID가 없는 데이터라면
서버가 ID를 주지 않는다면 데이터를 받는 시점에 직접 붙여 준다.let uid = 0 const items = ref(rawList.map((item) => ({ ...item, id: uid++ })))목록이 절대 재정렬되거나 중간에 삽입되지 않는 정적 목록이라면 인덱스도 문제가 없다. 다만 나중에 정렬 기능이 붙는 순간 조용히 깨지므로, 습관적으로 쓰지 않는 편이 안전하다.
9. v-for와 v-if를 같은 요소에 쓰지 않는다
"완료되지 않은 할 일만 보여주기"를 구현한다고 하자. 자연스러운 발상은 이것이다.
let id = 0
const todos = ref([
{ id: id++, name: '복습', isComplete: true },
{ id: id++, name: '예습', isComplete: false },
{ id: id++, name: '저녁식사', isComplete: true },
{ id: id++, name: '노래방', isComplete: false }
])
<ul>
<li v-for="todo in todos" v-if="!todo.isComplete" :key="todo.id">
{{ todo.name }}
</li>
</ul>
동작하지 않는다.
Uncaught TypeError: Cannot read properties of undefined (reading 'isComplete')

원인 — v-if가 먼저 평가된다
같은 요소에서는 v-if가 v-for보다 우선순위가 높다. 그런데 todo라는 변수는 v-for가 만들어 주는 것이다. v-if가 먼저 실행되는 시점에는 todo가 아직 존재하지 않는다. 그래서 undefined를 읽으려다 에러가 난다.
📌 Vue 2에서는 우선순위가 정반대였다
Vue 2는v-for가v-if보다 우선이어서 이 코드가 그대로 동작했다. 그래서 오래된 한글 자료나 블로그를 보면 "같이 써도 된다"는 설명이 나온다. Vue 3에서 우선순위가 뒤집혔다. 검색으로 찾은 예제가 동작하지 않는다면 버전 차이를 먼저 의심해 볼 지점이다.
Vue 3는 이 조합에 대해 컴파일 단계에서 경고도 내보낸다.
9-1. 두 가지 해결법

① computed로 걸러낸 목록을 만든다 — 권장
필터링을 템플릿이 아니라 <script>에서 처리한다.
import { ref, computed } from 'vue'
const incompleteTodos = computed(() => {
return todos.value.filter((todo) => !todo.isComplete)
})
<ul>
<li v-for="todo in incompleteTodos" :key="todo.id">
{{ todo.name }}
</li>
</ul>
템플릿이 "무엇을 그릴지"만 말하고 "무엇을 고를지"는 스크립트가 맡는다. 조건이 복잡해질수록 이 분리가 유리하다.
📌 원본 노트의 예제에서 변수명과 조건이 어긋나 있어 맞췄다
목표는 "이미 처리한 todo 출력"이라고 적혀 있는데 코드의 조건은!todo.isComplete(미완료)였고, 해결법의 변수명도completeTodos인데 담기는 것은 미완료 목록이었다. 이 글에서는 "완료되지 않은 항목"으로 목표를 통일하고 변수명도incompleteTodos로 맞췄다. 우선순위 문제라는 핵심에는 영향이 없다.
② <template>으로 위치를 분리한다
v-for를 <template>에 걸고, v-if는 안쪽 요소로 내린다. 서로 다른 요소에 있으므로 우선순위 충돌이 없다.
<ul>
<template v-for="todo in todos" :key="todo.id">
<li v-if="!todo.isComplete">
{{ todo.name }}
</li>
</template>
</ul>
:key는 반복 단위인 <template>에 붙는다는 점에 주의한다.
➡️ 기본은
computed.<template>방식은 모든 항목을 순회하면서 그리지 않을 것까지 검사하므로, 목록이 크면computed가 낫다. 다만 [Vue 컴포넌트 데이터 흐름] 편에서 다룰 중첩v-for안쪽처럼computed를 만들기 어려운 경우에는 이쪽이 답이 된다.
10. 배열을 필터링하고 정렬하기
v-for로 그리는 배열을 다룰 때는 [JavaScript 객체와 배열 완전 정리]에서 본 그 구분이 그대로 중요해진다.

| 구분 | 동작 | 메서드 |
|---|---|---|
| 변화 메서드 | 원본 배열을 직접 바꾼다 | push pop shift unshift splice sort reverse |
| 교체 메서드 | 원본은 그대로 두고 새 배열을 반환한다 | filter concat slice map |
Vue의 반응성은 변화 메서드를 감지한다. todos.value.push(...)를 부르면 화면이 알아서 갱신된다.
교체 메서드는 반환값을 다시 대입해야 반응한다.
todos.value.filter((t) => !t.isComplete) // 화면이 바뀌지 않는다
todos.value = todos.value.filter((t) => !t.isComplete) // 이제 바뀐다
원본을 지키면서 걸러 보여주기
그런데 위처럼 원본을 덮어쓰면 걸러진 항목이 영영 사라진다. 필터를 해제할 방법이 없다. 화면에 보여줄 목록만 따로 만드는 게 맞다.
① computed — 대부분의 경우 이쪽이다.
const numbers = ref([1, 2, 3, 4, 5])
const evenNumbers = computed(() => {
return numbers.value.filter((number) => number % 2 === 0)
})
<li v-for="num in evenNumbers" :key="num">{{ num }}</li>
② 메서드 — 중첩 v-for처럼 computed로 만들기 어려울 때 쓴다.
const numberSets = ref([
[1, 2, 3, 4, 5],
[6, 7, 8, 9, 10]
])
const evenNumbers = function (numbers) {
return numbers.filter((number) => number % 2 === 0)
}
<ul v-for="(numbers, i) in numberSets" :key="i">
<li v-for="num in evenNumbers(numbers)" :key="num">{{ num }}</li>
</ul>
바깥 항목마다 필터 대상이 달라지므로 하나의 computed로 묶을 수가 없다. 이때는 인자를 받는 함수를 쓴다.
⚠️
sort와reverse는 원본을 바꾼다const sorted = computed(() => items.value.sort(...)) // 원본이 정렬된다 const sorted = computed(() => [...items.value].sort(...)) // 복사한 뒤 정렬한다
computed안에서 원본을 건드리면 계산이 원본을 바꾸고 그 변경이 다시 계산을 유발하는 구조가 된다. 정렬 전에 복사한다는 습관이 필요하다.
11. 자주 걸려 넘어지는 지점들
① key를 정적인 값으로 준다
<li v-for="item in items" key="item.id"> <!-- 'item.id' 문자열이 전부 같은 key가 된다 -->
<li v-for="item in items" :key="item.id"> <!-- 콜론을 붙인다 -->
3편에서 본 class와 :class의 차이가 그대로 재현된다. 콜론이 없으면 문자열 그대로다.
② 객체 순회에서 값과 키의 순서를 헷갈린다
<div v-for="(key, value) in obj"> <!-- 이름만 그럴듯하고 실제로는 반대다 -->
<div v-for="(value, key) in obj"> <!-- 값이 먼저다 -->
③ v-show에 v-else를 붙인다
<div v-show="ok">A</div>
<div v-else>B</div> <!-- 동작하지 않는다 -->
v-else는 v-if 전용이다. v-show로 양쪽을 처리하려면 각각 조건을 적어 준다.
④ 숫자 반복이 0부터 시작할 거라 생각한다
<span v-for="n in 3">{{ n }}</span> <!-- 1 2 3 -->
배열 인덱스와 달리 1부터다.
⑤ filter 결과를 대입하지 않는다
todos.value.filter((t) => !t.isComplete) // 반환값을 버리고 있다
[JavaScript 객체와 배열 완전 정리]에서 본 그 실수다. filter는 원본을 바꾸지 않는다.
⑥ 하나만 걸러내려고 filter를 쓴다
const target = todos.value.filter((t) => t.id === 3) // 길이 1짜리 배열
const target = todos.value.find((t) => t.id === 3) // 항목 하나
배열이 반환되므로 target.name이 undefined가 된다.
12. 디렉티브 치트시트
3편과 4편에서 다룬 것을 한자리에 모았다.
| 디렉티브 | 단축어 | 역할 |
|---|---|---|
v-bind |
: |
속성·클래스·스타일을 동적으로 바인딩한다 |
v-on |
@ |
이벤트 리스너를 연결하고 수신한다 |
v-model |
— | form 요소와 양방향 바인딩한다 |
v-html |
— | Raw HTML을 출력한다 (XSS 주의) |
v-if / v-else-if / v-else |
— | 조건부 렌더링 — DOM을 만들고 없앤다 |
v-show |
— | 가시성 전환 — display만 토글한다 |
v-for |
— | 목록 렌더링 — :key와 함께 쓴다 |
✅ 핵심 요약
| 주제 | 꼭 기억할 것 |
|---|---|
v-if |
거짓이면 DOM에 아예 없다. v-else는 바로 앞 형제를 따라간다 |
v-show |
항상 렌더링되고 display만 바뀐다. <template>과 v-else에는 못 쓴다 |
| 선택 기준 | 자주 토글하면 v-show, 조건이 잘 안 바뀌면 v-if |
| 보안 | 감춰야 할 정보에 v-show를 쓰지 않는다. DOM에 그대로 남는다 |
<template> |
렌더링되지 않는 wrapper. 여러 요소에 조건·반복을 걸 때 쓴다 |
v-for 별칭 |
배열은 (요소, 인덱스), 객체는 (값, 키, 인덱스). 값이 먼저다 |
| 숫자 반복 | v-for="n in 5"는 1부터 시작한다 |
key의 정체 |
이전 목록과 새 목록을 비교할 때 같은 항목을 알아보게 하는 이름표다 |
key 규칙 |
number 또는 string. 같은 항목이면 항상 같은 값이어야 한다 |
| 인덱스 금지 | 인덱스는 정체가 아니라 위치다. 중간 삽입 시 상태가 어긋난다 |
| 우선순위 | 같은 요소에서 v-if가 v-for보다 먼저다. Vue 2와 반대다 |
| 해결법 | 기본은 computed로 걸러낸 목록. 안 되면 <template>으로 분리 |
| 배열 메서드 | 변화 메서드는 자동 반영, 교체 메서드는 반환값을 대입해야 한다 |
| 정렬 | sort는 원본을 바꾼다. computed 안에서는 복사한 뒤 정렬한다 |
🔗 참고 자료
'알면좋은것들' 카테고리의 다른 글
| Vue - computed lifecycle (0) | 2026.08.01 |
|---|---|
| Vue - props & emit (2) | 2026.08.01 |
| Vue Syntax(1) (0) | 2026.08.01 |
| Django(6) (0) | 2026.07.31 |
| Django(5) (0) | 2026.07.31 |