Vue Syntax(2)

2026. 8. 1. 18:40·알면좋은것들
반응형

화면을 그리는 일은 결국 두 가지 질문으로 갈린다. "이걸 보여줄까 말까" 와 "이걸 몇 개 그릴까" 다. Vue는 각각에 v-if와 v-for를 준다. 쓰는 법 자체는 한 줄이면 끝날 만큼 간단하다.

문제는 그다음이다. 목록에 항목을 하나 추가했더니 엉뚱한 곳의 체크박스가 켜지고, v-if와 v-for를 같이 썼더니 undefined 에러가 나고, filter로 걸렀는데 화면이 안 바뀐다. Vue가 화면을 어떻게 다시 그리는지를 모르면 원인을 찾을 수 없는 문제들이다.

이 글은 그 구간을 정리한 것이다. v-if와 v-show의 갈림길에서 시작해, key가 왜 필요한지, 왜 인덱스를 쓰면 안 되는지까지 — 함정이 몰려 있는 곳을 중심으로 묶었다.

이 글은 [Vue 템플릿 문법과 디렉티브]에서 이어진다. 디렉티브의 구조와 v-bind가 낯설다면 그쪽을 먼저 보는 편이 좋다. [JavaScript 객체와 배열 완전 정리]의 배열 메서드도 10번 항목에서 그대로 다시 나온다.

 

📖 이 글에서 다루는 내용

  1. v-if — 조건에 따라 만들거나 없앤다
  2. <template> — 보이지 않는 wrapper
  3. v-show — 만들어 두고 숨긴다
  4. v-if와 v-show, 무엇을 쓸 것인가
  5. v-for — 목록을 그린다
  6. 중첩과 여러 요소 반복
  7. key — Vue가 요소를 알아보는 이름표
  8. 인덱스를 key로 쓰면 안 되는 이유
  9. v-for와 v-if를 같은 요소에 쓰지 않는다
  10. 배열을 필터링하고 정렬하기
  11. 자주 걸려 넘어지는 지점들
  12. 디렉티브 치트시트
  13. 핵심 요약

 


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.js — 조건부 렌더링
  • Vue.js — 리스트 렌더링
  • Vue.js — 특수 속성 key
  • Vue.js — 내장 디렉티브 API
  • Vue.js — 렌더링 메커니즘
  • MDN — <template> 요소
반응형

'알면좋은것들' 카테고리의 다른 글

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
'알면좋은것들' 카테고리의 다른 글
  • Vue - computed lifecycle
  • Vue - props & emit
  • Vue Syntax(1)
  • Django(6)
AglioOglio
AglioOglio
데이터분석, 딥러닝 공부하고 있습니다.
  • AglioOglio
    It것저것
    AglioOglio
  • 전체
    오늘
    어제
    • 분류 전체보기 (167) N
      • Stanford CS224N Lectures (10)
        • 강의정리 (8)
        • assignment (2)
      • 논문리뷰 (13)
        • NLP (11)
        • CV (1)
      • 구현 (3)
      • DL (5)
        • CV (4)
        • NLP (11)
        • 딥러닝 기초 (3)
        • 파이토치 기초 (5)
        • LLM & Agent (8)
      • 알고리즘 (53) N
        • BOJ (29)
        • SWEA (10) N
        • Programmers (3)
        • 이론 (11) N
      • 시계열분석(Time Series Analysis) (1)
      • ML (2)
      • EffectivePython_125 (21)
      • 알면좋은것들 (28)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    SWEA
    Baekjoon
    CS224N
    JS
    자연어처리
    django
    백준
    파이썬
    NLP
    논문리뷰
    Transformer
    effective python
    effective python 125
    코딩테스트
    알고리즘
    Vue
    Effective
    Python
    자료구조
    attention
  • 최근 댓글

  • 최근 글

  • 반응형
  • hELLO· Designed By정상우.v4.10.3
AglioOglio
Vue Syntax(2)
상단으로

티스토리툴바