홈으로
← 책장으로

Vue 3 (Composition API 기준)

공부방 자료는 keysub가 AI를 활용해 작성·편집했습니다.

1. Vue 3 준비 및 시작하기 (Introduction)

내용 펼치기 / 접기

1.1 개발 환경 세팅 (Node.js, VS Code, 확장 프로그램)

1.1.1 Node.js

  • Node.js 는 브라우저가 아닌 컴퓨터(서버/로컬 환경)에서 JavaScript를 실행할 수 있게 해주는 런타임(실행 환경)
  • 개발 도구(Vite / Vue CLI) 실행: Vue 프로젝트를 생성하고, 저장할 때마다 코드를 실시간으로 새로고침해 주는 개발 서버를 구동하는 데 필수적입니다.
  • 패키지 관리 (NPM): Vue 라이브러리 자체는 물론, 상태 관리를 위한 Pinia, 라우팅을 위한 Vue Router 등 개발에 필요한 다양한 외부 도구를 명령어 하나로 다운로드하고 관리(NPM / Yarn / PNPM)해 줍니다
  • 최신 문법 변환 (빌드/컴파일): Vue 3의 컴포지션 API, TypeScript, .vue 파일 등 브라우저가 직접 읽지 못하는 최신 코드를 일반 HTML, CSS, JavaScript로 압축 및 변환해 줍니다.
  • 단 완성된 Vue 결과물은 일반 웹 파일이 되므로 실제 서비스 운영 단계에서는 Node.js가 필수가 아닙니다.

1.1.2 Vite를 활용한 Vue 프로젝트 생성

vite 는 Vue 프로젝트 폴더 구성 그대로 동작하는 내부 서버를 구동. 개발용, 배포용 파일생성해주는 도구

# 1. Vite 기반 Vue 프로젝트 생성 명령어 실행
npm create vue@latest

# 2. 프로젝트 설정 선택 (키보드 방향키와 Enter로 선택)
# ✔ Project name: vue-first-app (원하는 이름 입력)
# ✔ Add TypeScript? No (필요에 따라 Yes)
# ✔ Add JSX Support? No
# ✔ Add Vue Router for Single Page Application development? Yes (권장)
# ✔ Add Pinia for state management? Yes (권장)
# ...이후 옵션은 기본값(No) 선택

# 3. 생성된 프로젝트 폴더로 이동
cd vue-first-app
# 4. 필요한 라이브러리(의존성) 설치
npm install
# 5. 로컬 개발 서버 실행
npm run dev 

1.1.3 프로젝트 핵심 폴더/파일 구조

📂 node_modules/ : npm install로 다운로드된 외부 라이브러리들이 저장되는 곳입니다. (수정 금지)
📂 public/ : 파비콘(favicon.ico)처럼 빌드(압축) 프로세스 없이 브라우저에 그대로 노출될 정적 자원을 넣습니다.
📂 src/ : 실제 코드를 작성하는 가장 중요한 폴더입니다.
📂 assets/ : CSS, 이미지, 폰트 등 컴포넌트 내부에서 불러와 사용할 자원들을 관리합니다.
📂 components/ : 화면에 조립할 재사용 가능한 UI 조각(버튼, 모달, 헤더 등) .vue 파일들을 모아둡니다.
📂 router/ : 웹사이트의 주소(URL) 경로에 따라 어떤 페이지를 보여줄지 설정합니다.
📂 stores/ : (Pinia 선택 시) 여러 컴포넌트가 공유하는 전역 데이터를 관리합니다.
📂 views/ : 홈, 마이페이지 등 하나의 완성된 '화면(페이지)' 단위 컴포넌트들을 모아둡니다.
📄 App.vue : 최상위 루트 컴포넌트입니다. 다른 모든 컴포넌트들이 이 안에 조립되어 화면에 출력됩니다.
📄 main.js (또는 main.ts) : 프로젝트의 진입점(Entry)입니다. Vue 앱 인스턴스를 만들고, App.vue를 로드하여 HTML에 연결하며, 플러그인(Router, Pinia 등)을 등록합니다.
📄 index.html : 브라우저가 가장 먼저 읽는 유일한 HTML 파일입니다. 이 파일 내부의 <div id="app"></div> 자리에 우리가 만든 Vue 컴포넌트들이 실시간으로 갈아끼워지며 화면이 바뀝니다.
📄 vite.config.js : Vite 개발 도구의 환경 설정 파일입니다. 플러그인 추가나 서버 포트 변경 등을 설정합니다.
📄 package.json : 프로젝트 정보, 설치한 라이브러리 목록, 실행 명령어(dev, build 등)가 적혀 있는 명세서입니다

index.html -실행-> main.ts -읽기-> App.vue -> 하위 컴포넌트들


2. 템플릿 문법과 핵심 디렉티브 (Basics)

내용 펼치기 / 접기

2.1 데이터 바인딩과 텍스트 보간법 (Interpolation)

  • Vue.js에서 데이터 바인딩(Data Binding)은 자바스크립트 객체의 데이터를 HTML 화면(DOM)에 연결하여 동적으로 표현하는 핵심 기술입니다
  • 이 중 텍스트 보간법(Text Interpolation)은 데이터 바인딩의 가장 기본적인 형태로, 자바스크립트의 데이터를 HTML 텍스트 노드에 주입할 때 사용됩니다

2.1.1 텍스트 보간법 (Text Interpolation)

데이터를 화면에 출력하기 위해 가장 널리 쓰이는 방식으로, 이중 중괄호 {{ }}(일명: 콧수염 표현식/Mustache Syntax)를 사용합니다.

  • 동작 방식: Vue 인스턴스(컴포넌트)의 data 객체에 있는 속성 이름을 중괄호 안에 넣으면, 해당 값이 문자열로 변환되어 화면에 렌더링됩니다.
  • 실시간 갱신: 데이터 값이 변경되면 화면의 해당 부분도 자동으로 다시 그려집니다.
  • 표현식 허용: 단순 변수뿐만 아니라 간단한 자바스크립트 연산이나 메서드 호출도 가능합니다.
<template>
  <div>
    <!-- 기본 변수 바인딩 -->
    <p>안녕하세요, {{ name }}님!</p>

    <!-- JavaScript 연산 및 삼항 연산자 -->
    <p>상태: {{ isActive ? '활성화' : '비활성화' }}</p>

    <!-- 메서드 호출 -->
    <p>계산된 값: {{ calculateTotal() }}</p>
  </div>
</template>

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

const name = '김개발';
const isActive = ref(true);

const calculateTotal = () => {
  return 1000 + 2000;
};
</script>

2.2 디렉티브

디렉티브는 v-로 시작하는 특수 속성으로, HTML 엘리먼트에 반응형 동작을 부여합니다. Vue가 DOM에 어떤 변화를 적용할지 지시하는 역할을 합니다.

2.2.1 제어 디렉티브

2.2.1.1 조건분기 디렉티브 (v-if, v-else, v-show)

v-if와 v-else는 자바스크립트의 if-else 조건문처럼 조건에 따라 HTML 요소를 화면에 그릴지 말지 결정하는 디렉티브입니다.

<template>
  <div>
    <p v-if="score >= 90">학점: A</p>
    <p v-else-if="score >= 80">학점: B</p>
    <p v-else-if="score >= 70">학점: C</p>
    <p v-else>학점: F</p>
  </div>
</template>

<script setup>
import { ref } from 'vue';
const score = ref(85); // 80 이상이므로 '학점: B'가 출력됨
</script>
2.2.1.2 (참조) 여러 개의 태그를 묶어서 제어할 때 (<template>)

여러 HTML 요소를 하나의 조건으로 묶어서 켜고 끄고 싶을 때, 무의미한 <div> 태그로 감싸면 마크업 구조가 지저분해집니다. 이때 <template> 태그를 쓰면 유용합니다.

<template>
  <!-- template 태그는 조건 검사용으로만 쓰이고 화면 렌더링 시 흔적 없이 사라집니다 -->
  <template v-if="isLoggedIn">
    <h2>환영합니다!</h2>
    <p>마이페이지 바로가기</p>
    <button>로그아웃</button>
  </template>

  <template v-else>
    <h2>로그인이 필요합니다.</h2>
    <button>로그인 하러가기</button>
  </template>
</template>
2.2.1.3 v-show 디렉티브
  • v-show는 v-if와 마찬가지로 조건에 따라 화면에 요소를 보여줄지 말지 결정하는 디렉티브입니다.
  • 사용법은 v-if와 거의 똑같지만, 동작하는 원리(메커니즘)가 완전히 다릅니다.
  • 동작 방식: 조건의 참/거짓 여부와 상관없이 HTML 태그는 무조건 생성됩니다. 대신 조건이 false가 되면 Vue가 알아서 CSS 속성인 display: none;을 추가하여 눈에만 안 보이게 가립니다.
  • template 태그는 랜더링 할때 사라지므로 v-show 태그와 함께 쓰면 에러발생 — display: none;을 추가할 수 없음
<template>
  <div>
    <!-- 버튼을 누르면 열리고 닫히는 모달창/팝업 예시 -->
    <button @click="isOpen = !isOpen">팝업 토글</button>

    <!-- isOpen이 false가 되면 display: none 스타일이 자동으로 붙습니다 -->
    <div v-show="isOpen" class="popup">
      <p>안녕하세요! 중요한 공지사항입니다.</p>
    </div>
  </div>
</template>

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

const isOpen = ref(false); // 초기 상태는 닫힘(숨김)
</script>
2.2.1.4 리스트 렌더링과 Key의 중요성 (v-for 디렉티브)
  • 기본 문법 (배열 반복하기): v-for="아이템 in 배열" 형태로 사용합니다.
2.2.1.4.1 기본 사용법과 :key

v-for를 쓸 때는 반드시 :key라는 속성을 함께 적고, 그 안에 중복되지 않는 고유한 값(ID 등)을 넣어주어야 합니다.

<template>
  <div>
    <h3>오늘의 할 일 목록</h3>
    <ul>
      <!-- todos 배열의 개수만큼 <li> 태그가 반복되어 생성됩니다 -->
      <li v-for="todo in todos" :key="todo.id">
        {{ todo.text }}
      </li>
    </ul>
  </div>
</template>

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

const todos = ref([
  { id: 1, text: 'Vue 공부하기' },
  { id: 2, text: '운동하기' },
  { id: 3, text: '장보러 가기' }
]);
</script>
2.2.1.4.2 인덱스(Index) 활용하기
<template>
  <!-- 순번을 매겨서 출력 (1. Vue 공부하기, 2. 운동하기...) -->
  <p v-for="(todo, index) in todos" :key="todo.id">
    {{ index + 1 }}. {{ todo.text }}
  </p>
</template>
2.2.1.4.3 객체(Object) 반복하기

v-for는 배열뿐만 아니라 하나의 객체가 가진 속성(Property)들을 반복할 때도 쓸 수 있습니다. 이때 값(value), 키(key), 인덱스(index)를 모두 뽑아낼 수 있습니다. - 순서는 반드시 value, key 순서

<template>
  <ul>
    <!-- 순서 주의: (값, 키, 인덱스) -->
    <li v-for="(value, key, index) in myProfile" :key="key">
      [{{ index }}] {{ key }} : {{ value }}
    </li>
  </ul>
</template>

<script setup>
const myProfile = {
  name: '홍길동',
  age: 25,
  job: '개발자'
};
</script>

2.2.2 이벤트 디렉티브

2.2.2.1 이벤트 핸들링과 수식어 다루기 (v-on, @)
2.2.2.1.1 기본 이벤트 바인딩

v-on 뒤에 이벤트 타입을 콜론(:)으로 연결하여 사용하며, 주로 짧고 편리한 @ 기호로 축약하여 작성합니다.

<!-- v-on 사용 -->
<button v-on:click="counter += 1">Add 1</button>

<!-- @ 축약형 사용 (권장) -->
<button @click="counter += 1">Add 1</button>
<template>
  <button @click="count++">증가</button>
  <button @click="count--">감소</button>
  <button @click="reset">초기화</button>
  <p>카운트: {{ count }}</p>

  <!-- 이벤트 수식어 -->
  <form @submit.prevent="onSubmit">
    <input @keyup.enter="onEnter" placeholder="엔터를 눌러보세요">
    <button type="submit">제출</button>
  </form>
</template>

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

const count = ref(0)

function reset() {
  count.value = 0   // 스크립트 안에서는 .value 필요
}
function onSubmit() {
  alert('폼이 제출되었습니다 (새로고침 방지됨)')
}
function onEnter() {
  alert('엔터 키가 눌렸습니다')
}
</script>
2.2.2.1.2 메서드 핸들러 바인딩
<!-- 메서드 이름만 지정 (자동으로 event 객체 전달) -->
<button @click="greet">인사하기</button>

<!-- 인자 전달하기 -->
<button @click="say('안녕하세요')">메시지 출력</button>

<!-- 인자와 원본 DOM 이벤트 객체($event) 동시에 전달 -->
<button @click="warn('양식이 제출되지 않았습니다.', $event)">제출</button>
// 컴포넌트 스크립트 영역 (Options API)
<script setup>
// 일반 함수로 정의하면 템플릿에서 바로 사용할 수 있습니다.

// 1. 인자가 없을 때: 자동으로 event 객체를 받음
const greet = (event) => {
  alert(`안녕하세요! 클릭된 요소: ${event.target.tagName}`);
};

// 2. 인자를 직접 전달받음
const say = (message) => {
  alert(message);
};

// 3. 인자와 원본 DOM 이벤트 객체를 함께 받음
const warn = (message, event) => {
  if (event) {
    event.preventDefault(); // 기본 동작 막기
  }
  alert(message);
};
</script>
2.2.2.1.3 이벤트 수식어 (Event Modifiers)

DOM 이벤트 제어 기능을 템플릿 안에서 처리할 수 있도록 수식어를 제공합니다. 점(.)으로 연결하여 사용합니다.

  • .stop: 이벤트 전파를 중단합니다 (event.stopPropagation()).
  • .prevent: 태그의 기본 동작을 막습니다 (event.preventDefault()).
  • .capture: 이벤트 캡처링 모드를 사용합니다.
  • .self: 이벤트가 자식 요소가 아닌 엘리먼트 자체에서 발생했을 때만 실행합니다.
  • .once: 이벤트를 단 한 번만 실행합니다.
  • .passive: 스크롤 성능 향상을 위해 브라우저에게 기본 동작을 막지 않을 것임을 미리 알립니다.
<!-- 클릭 이벤트 전파(버블링) 중단 -->
<a @click.stop="doThis"></a>

<!-- 제출 이벤트가 페이지를 새로고침하지 않도록 제어 -->
<form @submit.prevent="onSubmit"></form>

<!-- 수식어 체이닝(연속 사용) -->
<a @click.stop.prevent="doThat"></a>

<!-- 클릭 핸들러가 최대 한 번만 실행 -->
<button @click.once="doSomething">한 번만 실행</button>
2.2.2.1.4 키 수식어 (Key Modifiers)

키보드 이벤트를 수신할 때 특정 키가 눌렸을 때만 이벤트를 발생시킵니다. Vue에서 제공하는 주요 별칭은 다음과 같습니다.

  • .enter: 엔터 키
  • .tab: 탭 키
  • .delete: 백스페이스 및 딜리트 키
  • .esc: 이스케이프 키
  • .space: 스페이스바 키
  • .up / .down / .left / .right: 화살표 키
<!-- Input 창에서 Enter를 눌렀을 때만 submit 메서드 호출 -->
<input @keyup.enter="submit" placeholder="이름을 입력하고 엔터를 누르세요" />

<!-- 케밥 케이스(kebab-case)를 사용해 실제 키 이름을 직접 지정 -->
<input @keyup.page-down="onPageDown" />
2.2.2.1.5 시스템 보완 키 수식어 (System Modifier Keys)

특정 보조 키(Ctrl, Alt, Shift 등)가 함께 눌려 있어야만 핸들러를 실행하도록 제한합니다.

  • .ctrl
  • .alt
  • .shift
  • .meta (Mac의 Command 키 / Windows의 Windows 키)
<!-- Alt + Enter 키가 함께 눌렸을 때 실행 -->
<input @keyup.alt.enter="clear" />

<!-- Ctrl 키를 누른 상태에서 클릭했을 때 실행 -->
<div @click.ctrl="doSomething">Ctrl 클릭 구역</div>

.exact 수식어

지정된 시스템 키의 정확한 조합만을 제어하고 싶을 때 사용합니다.

<!-- Ctrl 키만 눌려 있어야 실행 (Alt나 Shift가 같이 눌리면 실행 안 됨) -->
<button @click.ctrl.exact="onCtrlClick">A</button>
2.2.2.1.6 마우스 버튼 수식어 (Mouse Button Modifiers)

특정 마우스 버튼으로 클릭했을 때만 이벤트를 제한합니다.

  • .left: 마우스 왼쪽 버튼
  • .right: 마우스 오른쪽 버튼
  • .middle: 마우스 휠/가운데 버튼
<!-- 마우스 오른쪽 클릭 시 기본 컨텍스트 메뉴를 막고 메서드 실행 -->
<div @contextmenu.prevent="openCustomMenu">우클릭 하세요</div>

<!-- 마우스 오른쪽 버튼 클릭만 허용 -->
<button @click.right="onRightClick">우클릭</button>

2.2.3 데이터 바인딩 디렉티브: v-bind

개념

v-bind는 Vue 컴포넌트의 데이터를 HTML 속성(attribute)을 데이터와 동적으로 바인딩합니다. JavaScript 값을 속성에 반영하려면 반드시 v-bind를 사용해야 합니다.

v-bind:속성명="표현식"

축약형(shorthand)으로 콜론(:)만 사용할 수 있으며, 실무에서는 대부분 축약형을 사용합니다.

:속성명="표현식"

예제 1. 기본 속성 바인딩 (이미지 src, 링크 href)

<template>
  <img :src="imageUrl" alt="상품 이미지" width="200">
  <a :href="linkUrl">공식 홈페이지 바로가기</a>
</template>

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

const imageUrl = ref('https://vuejs.org/images/logo.png')
const linkUrl = ref('https://vuejs.org')
</script>

imageUrl, linkUrl 값이 바뀌면 화면의 src, href 속성도 자동으로 갱신됩니다.

예제 2. class 바인딩 - 객체 문법

조건에 따라 클래스를 켜고 끌 때 가장 많이 쓰이는 패턴입니다.

<template>
  <p :class="{ active: isActive, 'text-danger': hasError }">
    상태에 따라 스타일이 바뀝니다.
  </p>
  <button @click="isActive = !isActive">active 토글</button>
</template>

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

const isActive = ref(true)
const hasError = ref(false)
</script>

<style scoped>
.active { color: blue; font-weight: bold; }
.text-danger { color: red; }
</style>
  • 객체의 key는 클래스명, value는 boolean 표현식입니다.
  • isActive가 true일 때만 active 클래스가 적용됩니다.
  • <style scoped>를 쓰면 이 스타일이 현재 컴포넌트에만 적용됩니다.

예제 3. class 바인딩 - 배열 문법

<template>
  <p :class="[baseClass, sizeClass]">배열 문법 예제</p>
</template>

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

const baseClass = ref('box')
const sizeClass = ref('large')
</script>

결과적으로 <p class="box large">로 렌더링됩니다. 배열과 객체 문법을 혼합해서 사용하는 것도 가능합니다: :class="[baseClass, { active: isActive }]"

예제 4. style 바인딩

<template>
  <div :style="{ color: textColor, fontSize: fontSize + 'px' }">
    동적으로 스타일이 적용되는 텍스트입니다.
  </div>
</template>

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

const textColor = ref('green')
const fontSize = ref(24)
</script>

CSS 속성명은 camelCase(fontSize) 또는 kebab-case 문자열('font-size') 모두 사용 가능합니다.

예제 5. 여러 속성 한 번에 바인딩

객체 형태로 여러 속성을 묶어 관리할 때는 ref 대신 reactive를 사용하는 것이 자연스럽습니다.

<template>
  <img v-bind="imgAttrs">
</template>

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

const imgAttrs = reactive({
  src: 'photo.jpg',
  alt: '설명 이미지',
  width: 300,
  height: 200
})
</script>

객체 하나를 v-bind에 통째로 넘기면 객체의 각 key가 개별 속성으로 전개됩니다.

참고: ref vs reactive - ref(값) : 숫자, 문자열, boolean 같은 원시값에 주로 사용. 스크립트에서는 .value로 접근. - reactive(객체) : 객체/배열 전체를 반응형으로 다룰 때 사용. .value 없이 속성에 바로 접근(imgAttrs.width).

v-bind는 boolean 속성(disabled, checked 등)에도 사용되며, 표현식이 false, null, undefined이면 해당 속성 자체가 렌더링되지 않습니다. ```vue 제출

import { ref } from 'vue' const isLoading = ref(false) ```


2.2.4 양방향 데이터 바인딩 디렉티브: v-model

개념

v-model은 폼 요소(input, textarea, select 등)와 Vue 데이터를 양방향으로 동기화합니다. - 데이터가 바뀌면 화면(폼 요소)이 갱신되고, - 사용자가 폼 요소를 조작하면 데이터도 함께 갱신됩니다.

내부적으로는 :value 바인딩과 @input 이벤트 리스너를 합쳐놓은 문법적 설탕(syntactic sugar)입니다.

예제 1. 텍스트 입력 (input, textarea)

<template>
  <input v-model="username" placeholder="이름을 입력하세요">
  <p>입력한 이름: {{ username }}</p>

  <textarea v-model="message" placeholder="메시지"></textarea>
  <p>미리보기: {{ message }}</p>
</template>

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

const username = ref('')
const message = ref('')
</script>

예제 2. 체크박스 (단일 / 다중)

<template>
  <!-- 단일 체크박스: boolean 값 -->
  <label><input type="checkbox" v-model="agree"> 약관에 동의합니다</label>
  <p>동의 여부: {{ agree }}</p>

  <!-- 다중 체크박스: 배열에 선택값이 담김 -->
  <p>좋아하는 과일 선택:</p>
  <label><input type="checkbox" value="사과" v-model="fruits"> 사과</label>
  <label><input type="checkbox" value="바나나" v-model="fruits"> 바나나</label>
  <label><input type="checkbox" value="포도" v-model="fruits"> 포도</label>
  <p>선택된 과일: {{ fruits }}</p>
</template>

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

const agree = ref(false)
const fruits = ref([])
</script>

예제 3. 라디오 버튼

<template>
  <label><input type="radio" value="남성" v-model="gender"> 남성</label>
  <label><input type="radio" value="여성" v-model="gender"> 여성</label>
  <p>선택된 성별: {{ gender }}</p>
</template>

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

const gender = ref('')
</script>

예제 4. 셀렉트 박스

<template>
  <select v-model="selectedCity">
    <option disabled value="">도시를 선택하세요</option>
    <option value="seoul">서울</option>
    <option value="busan">부산</option>
    <option value="incheon">인천</option>
  </select>
  <p>선택된 도시: {{ selectedCity }}</p>
</template>

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

const selectedCity = ref('')
</script>

예제 5. v-model 수식어(Modifiers)

수식어 설명
.lazy input 이벤트 대신 change 이벤트 시점에 동기화 (포커스 아웃 시)
.number 입력값을 자동으로 숫자 타입으로 변환
.trim 입력값 앞뒤 공백을 자동으로 제거
<template>
  <input v-model.lazy="msg" placeholder="포커스를 벗어나야 반영">
  <input v-model.number="age" type="number" placeholder="나이">
  <input v-model.trim="nickname" placeholder="닉네임">

  <p>msg: {{ msg }} / age 타입: {{ typeof age }} / nickname: [{{ nickname }}]</p>
</template>

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

const msg = ref('')
const age = ref(0)
const nickname = ref('')
</script>

비교 정리 - v-bind: 한쪽 방향 (데이터 → 화면). 속성/스타일/클래스처럼 "표시"만 필요할 때 - v-model: 양방향 (데이터 ↔ 화면). 사용자 입력을 받아야 할 때 (폼 요소 전용)


2.2.5 기타 디렉티브

자주 쓰는 이벤트 수식어: .prevent(preventDefault), .stop(stopPropagation), .once(한 번만 실행), .enter, .esc 등 키 수식어

1) v-html — HTML 콘텐츠 렌더링

문자열을 HTML로 해석해서 그대로 삽입합니다. 신뢰할 수 없는 외부 입력에 사용하면 XSS 공격에 노출되므로 반드시 안전한 데이터에만 사용해야 합니다.

<template>
  <div v-html="richContent"></div>
</template>

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

const richContent = ref('<strong>굵게</strong> 표시되고 <em>기울임</em>도 적용됩니다.')
</script>

2) v-text — 텍스트 콘텐츠 렌더링

{{ }} 콧수염 문법과 동일한 결과를 만들지만, 엘리먼트의 textContent 전체를 덮어씁니다.

<template>
  <span v-text="greeting"></span>
  <!-- 위는 아래와 동일 -->
  <span>{{ greeting }}</span>
</template>

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

const greeting = ref('안녕하세요!')
</script>

3) v-once — 한 번만 렌더링

이후 데이터가 변경돼도 다시 렌더링되지 않습니다. 정적인 콘텐츠의 렌더링 성능 최적화에 사용합니다.

<template>
  <p v-once>최초 값: {{ count }}</p>
  <p>현재 값: {{ count }}</p>
  <button @click="count++">증가</button>
</template>

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

const count = ref(0)
</script>

4) v-pre — 컴파일 생략

Vue의 컴파일 과정을 건너뛰어 콧수염 문법 등을 있는 그대로 출력합니다. 코드 예시를 그대로 보여줄 때 유용하며, 스크립트 로직 없이도 동작합니다.

<template>
  <span v-pre>{{ 이 문장은 그대로 출력됩니다 }}</span>
</template>

5) v-cloak — 컴파일 전 깜빡임 방지

Vue가 로드/컴파일되기 전 {{ }} 문법이 그대로 노출되는 현상(FOUC)을 CSS로 감춥니다.

<template>
  <div v-cloak>
    {{ message }}
  </div>
</template>

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

const message = ref('로딩이 완료되었습니다!')
</script>

<style scoped>
[v-cloak] { display: none; }
</style>

참고: v-cloak은 브라우저가 CDN으로 불러온 Vue를 런타임에 컴파일하는 방식(이전 강좌에서 다룬 CDN 예제)에서 주로 의미가 있습니다. <script setup> + Vite처럼 빌드 시점에 템플릿이 미리 컴파일되는 환경에서는 화면에 {{ }} 코드가 그대로 노출되는 현상 자체가 거의 발생하지 않아 실무에서는 잘 쓰이지 않습니다. 다만 디렉티브 자체의 개념과 존재는 알아두는 것이 좋습니다.

디렉티브 정리표

디렉티브 목적 축약형
v-bind 속성/클래스/스타일 단방향 바인딩 :
v-model 폼 요소 양방향 바인딩 없음
v-if / v-else-if / v-else 조건부 렌더링(DOM 생성/제거) 없음
v-show 조건부 표시(CSS display) 없음
v-for 리스트 반복 렌더링 없음
v-on 이벤트 처리 @
v-html HTML 삽입 (XSS 주의) 없음
v-text 텍스트 삽입 없음
v-once 최초 1회만 렌더링 없음
v-pre 컴파일 생략(원본 출력) 없음
v-cloak 컴파일 전 깜빡임 방지 없음

<script setup> 핵심 요약

항목 설명
import { ref, reactive } from 'vue' 필요한 Composition API 함수를 명시적으로 가져옴
ref(값) 원시값/단일 값 반응형 변수. 스크립트에서는 .value, 템플릿에서는 자동 언래핑
reactive(객체) 객체/배열 전체를 반응형으로 관리. .value 없이 속성 바로 접근
return 불필요 <script setup>에서 선언한 변수·함수는 자동으로 템플릿에 노출됨
실행 방법 Vue SFC Playground(play.vuejs.org) 또는 Vite 프로젝트(npm create vue@latest)

3. Vue 3의 핵심, 컴포지션 API (Composition API)

내용 펼치기 / 접기

이번 섹션에서는 Vue 3를 진짜 Vue 3답게 만들어주는 컴포지션 API의 핵심 개념들을 하나씩 파헤쳐 봅니다. 모든 예제는 <script setup> 문법으로 작성됩니다.

3.1 반응형 상태 관리 기초 (ref vs reactive)

Composition API에서 반응형 데이터를 만드는 두 가지 방법이 있습니다.

ref — 원시값도, 객체도 감쌀 수 있는 만능 상자

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

const count = ref(0)

function increment() {
  count.value++ // 스크립트 안에서는 .value로 접근
}
</script>

<template>
  <!-- 템플릿에서는 .value 없이 자동 언래핑 -->
  <button @click="increment">
    클릭 횟수: {{ count }}
  </button>
</template>

🖥️ 실행 결과

[버튼] 클릭 횟수: 0
→ 클릭 시
[버튼] 클릭 횟수: 1
[버튼] 클릭 횟수: 2 ...

💡 핵심 포인트: ref로 만든 값은 스크립트 내부에서는 반드시 .value를 붙여야 하지만, 템플릿에서는 Vue가 자동으로 언래핑(unwrap)해줍니다.

reactive — 객체 전용 반응형 프록시

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

const user = reactive({
  name: '홍길동',
  age: 25
})

function birthday() {
  user.age++ // .value 없이 바로 접근
}
</script>

<template>
  <p>{{ user.name }}님은 {{ user.age }}살입니다.</p>
  <button @click="birthday">생일 지나기</button>
</template>

🖥️ 실행 결과

홍길동님은 25살입니다.
→ 버튼 클릭
홍길동님은 26살입니다.

ref vs reactive 비교표

구분 ref reactive
감쌀 수 있는 값 원시값 + 객체 모두 객체(배열, Map 등 포함)만
스크립트 접근 .value 필요 바로 접근
템플릿 접근 자동 언래핑 바로 접근
재할당 count.value = {} 가능 통째로 재할당 불가 (반응성 끊김)
구조 분해 .value 유지하면 안전 구조 분해 시 반응성 상실 (→ toRefs 필요)

⚠️ 주의: const { name, age } = reactive({...}) 처럼 구조 분해하면 반응성이 사라집니다. 이 문제는 3.6절 toRefs에서 해결합니다.


3.2 파생된 상태 만들기: 계산된 속성 (computed)

기존 상태를 기반으로 자동으로 재계산되는 값을 만들 때 사용합니다.

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

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

// price나 quantity가 바뀌면 total도 자동으로 재계산됨
const total = computed(() => price.value * quantity.value)
</script>

<template>
  <p>단가: {{ price }}원</p>
  <p>수량: {{ quantity }}개</p>
  <p><strong>총액: {{ total }}원</strong></p>
  <button @click="quantity++">수량 추가</button>
</template>

🖥️ 실행 결과

단가: 10000원
수량: 3개
총액: 30000원
→ 버튼 클릭
수량: 4개
총액: 40000원   ← total이 자동으로 다시 계산됨!

💡 왜 메서드 대신 computed? 메서드로 getTotal()을 만들면 렌더링될 때마다 매번 재실행되지만, computed는 의존한 값(price, quantity)이 바뀔 때만 재계산하고 그 외엔 캐싱된 값을 재사용합니다. 성능상 훨씬 유리합니다.


3.3 데이터 변화 감지하기: 감시자 (watch, watchEffect)

watch — 특정 값을 콕 집어서 감시

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

const keyword = ref('')
const searchResult = ref('')

watch(keyword, (newValue, oldValue) => {
  console.log(`검색어가 "${oldValue}" → "${newValue}" 로 변경됨`)
  searchResult.value = `"${newValue}"에 대한 검색 결과...`
})
</script>

<template>
  <input v-model="keyword" placeholder="검색어 입력">
  <p>{{ searchResult }}</p>
</template>

🖥️ 실행 결과 (input에 "vue" 입력 시)

콘솔: 검색어가 "" → "v" 로 변경됨
콘솔: 검색어가 "v" → "vu" 로 변경됨
콘솔: 검색어가 "vu" → "vue" 로 변경됨
화면: "vue"에 대한 검색 결과...

watchEffect — 사용된 반응형 값을 자동으로 추적

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

const width = ref(100)
const height = ref(50)

// 콜백 안에서 사용된 width, height를 자동으로 추적하여 감시
watchEffect(() => {
  console.log(`현재 넓이: ${width.value * height.value}`)
})
</script>

<template>
  <input type="number" v-model.number="width">
  <input type="number" v-model.number="height">
</template>

🖥️ 실행 결과

콘솔: 현재 넓이: 5000   ← 즉시 1회 실행됨 (초기 실행)
→ width를 200으로 변경
콘솔: 현재 넓이: 10000

watch vs watchEffect 비교

구분 watch watchEffect
감시 대상 명시적으로 지정 콜백 내에서 자동 추적
초기 실행 기본적으로 실행 안 됨 즉시 1회 실행됨
이전 값 접근 가능 (oldValue) 불가능
사용 시점 "무엇을" 감시할지 명확할 때 "여러 값"을 한 번에 반응적으로 다루고 싶을 때

3.4 컴포넌트 생명주기 훅 (Lifecycle Hooks)

Options API의 생명주기 옵션이 Composition API에서는 on 접두사가 붙은 함수로 바뀝니다.

Options API Composition API 호출 시점
beforeCreate / created 불필요 (setup 자체가 이 시점) 인스턴스 초기화
beforeMount onBeforeMount DOM 마운트 직전
mounted onMounted DOM 마운트 직후
beforeUpdate onBeforeUpdate 반응형 데이터 변경 → 리렌더링 직전
updated onUpdated 리렌더링 직후
beforeUnmount onBeforeUnmount 컴포넌트 제거 직전
unmounted onUnmounted 컴포넌트 제거 직후
<script setup>
import { ref, onMounted, onUpdated, onUnmounted } from 'vue'

const seconds = ref(0)
let timer = null

onMounted(() => {
  console.log('✅ 마운트 완료 - 타이머 시작')
  timer = setInterval(() => {
    seconds.value++
  }, 1000)
})

onUpdated(() => {
  console.log('🔄 화면 업데이트됨, 현재 값:', seconds.value)
})

onUnmounted(() => {
  console.log('❌ 언마운트 - 타이머 정리')
  clearInterval(timer)
})
</script>

<template>
  <p>경과 시간: {{ seconds }}초</p>
</template>

🖥️ 실행 결과

콘솔: ✅ 마운트 완료 - 타이머 시작
(1초 후)
콘솔: 🔄 화면 업데이트됨, 현재 값: 1
화면: 경과 시간: 1초
(2초 후)
콘솔: 🔄 화면 업데이트됨, 현재 값: 2
화면: 경과 시간: 2초
...
(컴포넌트가 사라질 때)
콘솔: ❌ 언마운트 - 타이머 정리

⚠️ 메모리 누수 주의: onMounted에서 시작한 타이머나 이벤트 리스너는 반드시 onUnmounted에서 정리해줘야 합니다.


3.5 <script setup> 문법

<script setup>은 Composition API를 사용하기 위한 컴파일 타임 문법 설탕(syntactic sugar) 입니다.

기존 setup() 함수 방식

<script>
import { ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    function increment() {
      count.value++
    }
    // 템플릿에서 쓰려면 반드시 return 필요!
    return { count, increment }
  }
}
</script>

<script setup> 방식 (권장)

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

const count = ref(0)
function increment() {
  count.value++
}
// return 불필요! 최상위 변수/함수가 자동으로 템플릿에 노출됨
</script>

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

🖥️ 실행 결과 (두 방식 동일)

[버튼] 0
→ 클릭
[버튼] 1

<script setup>의 장점

장점 설명
적은 보일러플레이트 return, components 옵션 등록 불필요
더 나은 런타임 성능 컴파일러가 더 효율적인 코드로 변환
뛰어난 타입 추론 TypeScript와 궁합이 훨씬 좋음
컴포넌트 자동 등록 import한 컴포넌트를 바로 템플릿에서 사용 가능
<script setup>
// 이렇게 import만 하면 바로 <MyButton />으로 사용 가능
import MyButton from './MyButton.vue'
</script>

<template>
  <MyButton />
</template>

3.6 반응형 데이터 구조 분해 할당 (toRefs)

❌ 문제 상황: reactive 객체를 구조 분해하면 반응성이 끊긴다

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

const state = reactive({ name: '김철수', age: 30 })

// 구조 분해하는 순간 name, age는 그냥 평범한 값이 되어버림
const { name, age } = state

function haveBirthday() {
  state.age++ // state.age는 바뀌지만
}
</script>

<template>
  <!-- age는 원본과 연결이 끊어져서 화면이 갱신되지 않음! -->
  <p>{{ name }}님은 {{ age }}살</p>
  <button @click="haveBirthday">생일</button>
</template>

🖥️ 실행 결과

김철수님은 30살
→ 버튼 클릭 (여러 번)
김철수님은 30살   ← 화면이 갱신되지 않음! (버그)

✅ 해결: toRefs로 반응성을 유지하며 구조 분해

<script setup>
import { reactive, toRefs } from 'vue'

const state = reactive({ name: '김철수', age: 30 })

// toRefs로 감싸면 각 프로퍼티가 원본과 연결된 ref가 됨
const { name, age } = toRefs(state)

function haveBirthday() {
  age.value++ // ref이므로 .value로 접근
}
</script>

<template>
  <p>{{ name }}님은 {{ age }}살</p>
  <button @click="haveBirthday">생일</button>
</template>

🖥️ 실행 결과

김철수님은 30살
→ 버튼 클릭
김철수님은 31살   ← 정상적으로 반응성 유지!

💡 실전 활용 팁: toRefs는 주로 Composable 함수(커스텀 훅)에서 여러 상태값을 반환할 때 자주 사용됩니다.

<!-- useCounter.js -->
import { reactive, toRefs } from 'vue'

export function useCounter() {
  const state = reactive({ count: 0, doubled: 0 })

  function increment() {
    state.count++
    state.doubled = state.count * 2
  }

  // 구조 분해해도 반응성이 살아있도록 toRefs로 반환
  return { ...toRefs(state), increment }
}
<!-- 사용하는 컴포넌트 -->
<script setup>
import { useCounter } from './useCounter'

const { count, doubled, increment } = useCounter()
</script>

<template>
  <p>{{ count }} / 두 배: {{ doubled }}</p>
  <button @click="increment">+1</button>
</template>

🖥️ 실행 결과

0 / 두 배: 0
→ 클릭
1 / 두 배: 2
2 / 두 배: 4

📝 섹션 3 요약

개념 한 줄 정리
ref 어떤 값이든 감싸는 반응형 상자, .value로 접근
reactive 객체 전용 반응형 프록시, 구조 분해 시 주의 필요
computed 의존값 기반 캐싱되는 파생 상태
watch 특정 값을 지정해서 감시, 이전 값 접근 가능
watchEffect 콜백 내 사용값 자동 추적, 즉시 실행
생명주기 훅 onMounted, onUnmounted 등 on 접두사 함수
<script setup> 보일러플레이트 없는 Composition API 문법 설탕
toRefs reactive 객체 구조 분해 시 반응성 유지

다음 섹션에서는 컴포넌트 연계(props, emit, provide/inject, slot)로 이어집니다 🚀


4. 컴포넌트 연계

내용 펼치기 / 접기

컴포넌트는 혼자 있을 때보다 서로 연결되어 있을 때 진짜 힘을 발휘합니다. 이 섹션에서는 부모와 자식 컴포넌트가 값과 이벤트를 주고받는 6가지 방법을, 실무에서 자주 쓰는 화면 예제와 실행 결과로 함께 살펴봅니다.

  • props — 아래로 내려가는 데이터
  • emit — 위로 올라가는 이벤트
  • provide / inject — 단계를 건너뛰는 전달
  • slot — 내용물을 끼워 넣는 자리

실습 안내 각 예제의 코드 패널은 실제 프로젝트(Vite + <script setup>)에서 작성하는 방식 그대로 보여줍니다.


4.1 컴포넌트란? — 독립성과 Scoped 스타일

컴포넌트는 템플릿·로직·스타일을 하나로 묶은 재사용 단위입니다. 같은 컴포넌트를 여러 번 사용해도 각 인스턴스는 서로의 상태를 알지 못하고, <style scoped>를 쓰면 클래스 이름이 같아도 스타일이 서로의 영역을 침범하지 않습니다.

Pro tip 실제 프로젝트에서는 컴파일러가 컴포넌트마다 고유한 data-v-xxxxxx 속성을 자동으로 붙여 .box[data-v-xxxxxx] 형태로 CSS를 스코핑합니다.

CardA.vue / CardB.vue

<!-- CardA.vue -->
<template>
  <div class="scope-card">
    <p class="box">A 컴포넌트의 .box</p>
  </div>
</template>
<style scoped>
.box { background: #3ddc97; }  /* A 안에서만 적용 */
</style>

<!-- CardB.vue : 클래스 이름이 같아도 서로 충돌하지 않음 -->
<template>
  <div class="scope-card">
    <p class="box">B 컴포넌트의 .box</p>
  </div>
</template>
<style scoped>
.box { background: #ffb454; }  /* B 안에서만 적용 */
</style>

📝 실제 브라우저 텍스트 및 색상 매칭

  • CardA.vue 실행 화면
  • 텍스트: A 컴포넌트의 .box
  • 배경색: 초록색 계열 (#3ddc97)
  • CardB.vue 실행 화면
  • 텍스트: B 컴포넌트의 .box
  • 배경색: 주황색 계열 (#ffb454)

4.2 Props & Emits — 부모 ↔ 자식 데이터 전달

부모는 props로 자식에게 값을 내려주고, 자식은 emit으로 부모에게 이벤트를 알립니다. 실무에서 가장 많이 쓰는 조합은 상품 카드가 정보를 받아 표시하고, "장바구니 담기" 클릭을 부모에게 알리는 패턴입니다.

App.vue

<script setup lang="ts">
import { ref } from 'vue'
import ProductCard from './components/ProductCard.vue' 

// 1. 부모와 자식이 함께 공유할 상품 데이터 타입 정의
interface ProductType {
  id: number
  title: string
  price: number
  discountRate?: number 
}

// 2. ref 객체 데이터 선언
const product = ref<ProductType>({
  id: 1,
  title: '무선 마우스',
  price: 50000,
  discountRate: 10 
})

// 3. 자식의 장바구니 담기 신호(Emit)를 처리할 함수
function handleAddToCart(payload: { id: number; title: string; price: number }) {
  alert(`장바구니에 추가되었습니다!\nID: ${payload.id}\n상품명: ${payload.title}\n가격: ${payload.price}원`)
}
</script>

<template>
  <div class="app-container">
    <h1>🛒 상품 상세 페이지 (부모)</h1>

    <!-- 
      4. 자식 컴포넌트 호출 및 데이터/이벤트 바인딩
      - v-bind(:) : 부모의 데이터를 자식의 Props로 주입
      - v-on(@)   : 자식이 보낸 'add-to-cart' 이벤트를 부모의 handleAddToCart 함수와 연결
    -->
    <ProductCard 
      :product="product"
      @add-to-cart="handleAddToCart"
    />
  </div>
</template>

ProductCard.vue

<script setup lang="ts">
// 1. 부모가 보내주는 product 객체의 내부 타입을 먼저 정의합니다.
interface ProductType {
  id: number
  title: string
  price: number
  discountRate?: number
}

// 2. Props 정의: 부모가 'product'라는 이름의 객체를 넘겨주므로 아래와 같이 선언합니다.
interface Props {
  product: ProductType
}

// 3.1 props 선언 (객체 타입이므로 위 인터페이스 구조를 따릅니다)
//const props = defineProps<Props>()

// 3.2 기본값을 지정하기 위해서는 defineProps 가 아닌 withDefaults 사용 
const props = withDefaults(defineProps<Props>(), {
  product: () => ({
    id: 0,
    title: '상품 정보 없음',
    price: 0,
    discountRate: 0 // 부모가 product 객체를 통째로 안 보냈을 때 작동할 기본값입니다.
  })
})


// 4. 부모에게 전달할 이벤트 정의
const emit = defineEmits<{
  (e: 'add-to-cart', payload: { id: number; title: string; price: number }): void
}>()

// 5. 버튼 클릭 시 props.product 내부의 값을 꺼내서 부모에게 보냅니다.
function addToCart() {
  emit('add-to-cart', { 
    id: props.product.id, 
    title: props.product.title, 
    price: props.product.price 
  })
}
</script>

<template>
  <div class="product-card">
    <!-- 6. 템플릿에서 값을 쓸 때는 props 뒤에 붙은 product를 거쳐서 접근합니다 -->
    <p class="pc-title">{{ product.title }}</p>

    <p class="pc-price">
      {{ product.price.toLocaleString('ko-KR') }}원
      <!-- discountRate가 존재하고 0보다 클 때만 실행 -->
      <span v-if="product.discountRate && product.discountRate > 0" class="discount-badge">
        {{ product.discountRate }}% 할인
      </span>
    </p>

    <button class="cart-btn" @click="addToCart">장바구니 담기</button>
  </div>
</template>

defineProps <script setup lang="ts">에서 부모 컴포넌트가 전달한 데이터(Props)를 자식 컴포넌트에서 받기 위해 사용하는 컴파일러 매크로 함수.

withDefaults <script setup lang="ts">에서 타입으로 props를 선언할 때는 defineProps만으로 기본값을 줄 수 없어, withDefaults()로 감싸 기본값을 지정합니다. 이 값은 부모가 해당 prop을 아예 전달하지 않거나 undefined를 넘겼을 때 적용됩니다.

defineEmits <script setup lang="ts">에서 defineProps와 쌍을 이루는 컴파일러 매크로입니다. "이 자식 컴포넌트는 부모에게 이러이러한 이벤트를 보낼 것이다"라고 미리 선언해 두는 계약서와 같습니다.

e: add-to-cart : e는 Event(이벤트 이름) 의 약자입니다. payload: { id: number; title: string; price: number } 는 payload는 이벤트를 보낼 때 함께 실어 보낼 데이터 를 의미합니다.

1️⃣ 할인율(discountRate)을 안 보냈을 때 (기본값 0) - 전달한 값: title: "기계식 키보드", price: 150000 (discountRate 생략) - 브라우저 화면 텍스트 결과: text 기계식 키보드 150,000원 [장바구니 담기] (할인율이 0이므로 v-if 조건에 의해 몇 퍼센트 할인 문구는 화면에 나타나지 않습니다.)

2️⃣ 할인율(discountRate)을 10%로 보냈을 때 - 전달한 값: title: "무선 마우스", price: 50000, discountRate: 10 - 브라우저 화면 텍스트 결과: text 무선 마우스 50,000원 10% 할인 [장바구니 담기]


4.3 v-model — 컴포넌트 양방향 바인딩

v-model은 modelValue prop과 update:modelValue emit을 한 번에 묶어주는 문법 설탕입니다. 이름을 지정하면(v-model:keyword) 한 컴포넌트에 여러 개의 양방향 바인딩도 만들 수 있습니다.

① 단일 v-model — 수량 스테퍼

App.vue 부모 컴포넌트에서는 qty 상태 값을 선언하고, 자식 컴포넌트에 v-model="qty" 형태로 연결합니다. 최대 수량 제한(:max="10") 속성도 함께 전달합니다.

<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import QuantityStepper from './components/QuantityStepper.vue' // 자식 컴포넌트 임포트

// 부모가 관리하는 수량 데이터 (초기값 1)
const qty = ref(1)
</script>

<template>
  <div>
    <h1>🛒 장바구니 수량 선택 (부모)</h1>

    <!-- 
      인터페이스로 정의된 규격에 맞춰 안전하게 v-model과 max 속성 전달
    -->
    <QuantityStepper v-model="qty" :max="10" />

    <!-- 부모 쪽에서도 변경된 수량이 실시간으로 반영되는지 확인 -->
    <p>현재 선택된 총 수량: {{ qty }}개</p>
  </div>
</template>

QuantityStepper.vue 부모가 생략한 min과 modelValue는 기본값(default: 1)이 적용되며, 플러스/마이너스 버튼 클릭 시 emit을 통해 부모에게 변경된 값을 전달합니다.

<!-- QuantityStepper.vue -->

<script setup>

// 1. 자식에서 사용할 Props 구조를 인터페이스로 미리 정의합니다.
// 선택적 속성('?')으로 만들어 부모가 생략할 수 있게 합니다.
interface StepperProps {
  min?: number
  max?: number
}

// 2. 부모의 v-model과 연동될 타입을 제네릭 <number>로 선언하고 기본값을 지정합니다.
const count = defineModel<number>({ default: 1 })

// 3. 미리 정의한 인터페이스를 주입하고, 생략된 속성들의 기본값을 withDefaults로 채워줍니다.
const props = withDefaults(defineProps<StepperProps>(), {
  min: 1,
  max: 10
})

// 감소 로직 (props.min은 기본값 1이 보장되므로 안전하게 비교 가능)
function dec() {
  if (count.value > props.min) {
    count.value--
  }
}

// 증가 로직 (props.max는 기본값 10가 보장되므로 안전하게 비교 가능)
function inc() {
  if (count.value < props.max) {
    count.value++
  }
}
</script>

<template>
  <div class="stepper">
    <button @click="dec">−</button>
    <span>{{ count }}</span>
    <button @click="inc">+</button>
  </div>
</template>

v-model 양방향 데이터 바인딩(Two-way Data Binding)

defineModel v-model에 담아준 값을 가져오는 것

브라우저 최종 실행 결과 (텍스트)

처음 화면이 켜졌을 때의 상태와 버튼을 클릭했을 때 브라우저에 노출되는 화면 텍스트 결과입니다.

💡 처음 로드되었을 때 (초기 상태)

🛒 장바구니 수량 선택 (부모)
[−] 1 [+]
현재 선택된 총 수량: 1개

🖱️ [+] 버튼을 3번 클릭했을 때 결과

🛒 장바구니 수량 선택 (부모)
[−] 4 [+]
현재 선택된 총 수량: 4개

🚫 최대 수량(max="10")인 상태에서 [+] 버튼을 계속 누를 때 결과

🛒 장바구니 수량 선택 (부모)
[−] 10 [+]
현재 선택된 총 수량: 10개

(자식 컴포넌트의 props.modelValue < props.max 조건문 덕분에 10 이상으로는 숫자가 올라가지 않고 고정됩니다.)

② 이름 있는 v-model 여러 개 — 검색 필터 바

App.vue

<script setup>
import { ref } from 'vue'
import SearchFilterBar from './components/SearchFilterBar.vue'

// 부모가 관리하는 두 가지 검색 필터 상태
const searchKeyword = ref('')
const searchCategory = ref('전체')
</script>

<template>
  <div class="app-container">
    <h1>🔍 상품 검색 필터 (부모)</h1>

    <!-- 
      v-model:이름="변수" 형태로 여러 개의 양방향 바인딩을 동시에 처리합니다.
    -->
    <SearchFilterBar
      v-model:keyword="searchKeyword"
      v-model:category="searchCategory"
    />

    <!-- 실시간 동기화 결과 확인 -->
    <div class="preview-box">
      <p>현재 검색어: "{{ searchKeyword }}"</p>
      <p>선택 카테고리: [{{ searchCategory }}]</p>
    </div>
  </div>
</template>

SearchFilterBar.vue

<script setup>
// 1. 부모가 v-model:뒤에 붙여서 보낸 이름 그대로 Props를 정의합니다.
defineProps({
  keyword: { type: String, default: '' },
  category: { type: String, default: '전체' }
})

// 2. 부모의 v-model 값을 업데이트하기 위해 고정 규격인 'update:이름' 형태로 이벤트를 정의합니다.
defineEmits([
  'update:keyword', 
  'update:category'
])
</script>

<template>
  <div class="filter-bar">
    <!-- 
      3. 단방향 바인딩(:value)으로 값을 보여주고, 
         값이 변경될 때마다(@input) 부모에게 update:keyword 이벤트를 새 값과 함께 발송합니다.
    -->
    <input 
      :value="keyword" 
      @input="$emit('update:keyword', $event.target.value)" 
      type="text"
      placeholder="검색어를 입력하세요"
    />

    <!-- 
      4. select 태그 역시 단방향 바인딩(:value) 후, 
         선택 항목이 바뀔 때(@change) 부모에게 update:category 이벤트를 새 값과 함께 발송합니다.
    -->
    <select 
      :value="category" 
      @change="$emit('update:category', $event.target.value)"
    >
      <option value="전체">전체</option>
      <option value="전자기기">전자기기</option>
      <option value="도서">도서</option>
    </select>
  </div>
</template>

브라우저 최종 실행 결과 (텍스트)

💡 처음 로드되었을 때 (초기 상태)

🔍 상품 검색 필터 (부모)
[ 검색어를 입력하세요          ] [ 전체     ▼ ]
현재 검색어: ""
선택 카테고리: [전체]

🖱️ 사용자가 키보드로 "맥북"을 입력하고, 카테고리를 "전자기기"로 변경했을 때

🔍 상품 검색 필터 (부모)
[ 맥북                        ] [ 전자기기 ▼ ]
현재 검색어: "맥북"
선택 카테고리: [전자기기]

defineModel, defineProps, defineEmits defineModel이라는 최신 문법이 나오기 전(Vue 3 초기 버전)에는 부모의 v-model을 defineProps로 받아서 처리


4.4 Provide / Inject — 여러 단계 아래로 값 전달하기

Props는 한 단계씩 손으로 전달해야 해서, 중간 컴포넌트가 필요도 없는 값을 억지로 물려받는 "prop 드릴링"이 생깁니다. 다크 모드처럼 앱 전역에서 깊숙한 곳까지 필요한 값은 provide/inject로 곧장 전달합니다.

App.vue → AppHeader → Toolbar → ThemeToggle.vue

<!-- App.vue (최상위, provide 실행) -->
<script setup>
const theme = ref('light')
function toggleTheme() {
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
provide('theme', theme)
provide('toggleTheme', toggleTheme)
</script>
<template>
  <AppHeader />  <!-- theme를 모른 채 그냥 통과시킴 -->
</template>

<!-- ThemeToggle.vue (3단계 아래 자손) -->
<script setup>
const theme = inject('theme')
const toggleTheme = inject('toggleTheme')
</script>
<template>
  <button @click="toggleTheme">{{ theme === 'dark' ? '다크 모드' : '라이트 모드' }}</button>
</template>

1️⃣ 처음 화면이 켜졌을 때 (초기 상태) - 내부 테마 데이터 상태: theme.value = 'light' - 브라우저 실제 화면 텍스트 결과: text [ 라이트 모드 ] <-- 버튼에 표시되는 글자

2️⃣ 사용자가 버튼을 1번 클릭했을 때 - 동작: 버튼을 누르면 toggleTheme() 함수가 실행되면서 최상위의 테마 상태가 'dark'로 변경됩니다. - 내부 테마 데이터 상태: theme.value = 'dark' - 브라우저 실제 화면 텍스트 결과: text [ 다크 모드 ] <-- 버튼에 표시되는 글자가 자동으로 바뀜


4.5 Slots — 재사용 가능한 레이아웃 만들기

Slot은 컴포넌트 안에 "내용을 끼워 넣을 자리"를 만들어 둡니다. 부모가 아무것도 넣지 않으면 폴백 콘텐츠(기본값)가 대신 표시되고, v-slot에 데이터를 실어 보내는 스코프드 슬롯을 쓰면 같은 컴포넌트로 전혀 다른 화면을 그릴 수도 있습니다.

src/components/BaseCard.vue

<!-- BaseCard.vue : 카드 레이아웃의 틀을 잡고, 부모가 주는 HTML 덩어리를 끼워 넣을 구역(`slot`)을 지정합니다.-->

<template>
  <div class="base-card">
    <!-- 이름을 지정한 헤더 슬롯 (부모가 안 채우면 '기본 헤더' 출력) -->
    <header>
      <slot name="header">기본 헤더</slot>
    </header>

    <!-- 기본(디폴트) 슬롯 (부모가 안 채우면 '내용이 없습니다.' 출력) -->
    <div class="body">
      <slot>내용이 없습니다.</slot>
    </div>

    <!-- 이름을 지정한 푸터 슬롯 (부모가 안 채우면 [확인] 버튼 출력) -->
    <footer>
      <slot name="footer">
        <button>확인</button>
      </slot>
    </footer>
  </div>
</template>

src/components/DataList.vue

<!-- DataList.vue : 배열 데이터를 받아 반복문(`v-for`)만 돌려줄 뿐, 는 전혀 모르는 껍데기 컴포넌트 -->
<script setup>
// 
defineProps({
  items: Array
})
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <!--  slot :item="item" : 스코프드 슬롯(Scoped Slot) : 
        자식이 가진 item 데이터를 :item 속성에 담아 
        부모 컴포넌트의 v-slot 영역으로 거꾸로 쏘아 올려줍니다.
      -->
      <slot :item="item" />
    </li>
  </ul>
</template>

App.vue

<!-- App.vue -->
<script setup>
import { ref } from 'vue'
import BaseCard from './components/BaseCard.vue'
import DataList from './components/DataList.vue'

// 1. 스코프드 슬롯 테스트용 예시 데이터 정의
const products = ref([
  { id: 101, name: '기계식 키보드', price: 120000 },
  { id: 102, name: '무선 게이밍 마우스', price: 75000 }
])

const members = ref([
  { id: 1, name: '홍길동', role: '관리자' },
  { id: 2, name: '이순신', role: '일반회원' }
])
</script>

<template>
  <div class="app-container">
    <h1>📦 1. 이름 있는 슬롯 & 폴백 콘텐츠 테스트</h1>

    <!-- 예제 ①: 헤더와 푸터를 부모가 직접 커스텀해서 채우는 케이스 -->
    <div class="demo-section">
      <h3>케이스 ①: 슬롯 전체 주입</h3>
      <BaseCard>
    <!--
      #header`는 이름이 지정된 슬롯(Named Slot) 에 부모 컴포넌트의 HTML 콘텐츠를 주입하기 위한  v-slot:header 프로퍼티의 단축 문법 입니다.
    * **원래 문법:** `<template v-slot:header>`
    * **단축 문법:** `<template #header>`  
    -->
        <template #header>
          <strong>📦 배송 안내</strong>
        </template>

        <p>오늘 오후 출고되어 내일 도착 예정입니다.</p>

        <template #footer>
          <button>닫기</button>
          <button>자세히 보기</button>
        </template>
      </BaseCard>
    </div>

    <!-- 예제 ②: 알맹이만 채우고 헤더/푸터를 비워두어 자식의 기본값이 나오는 케이스 -->
    <div class="demo-section">
      <h3>케이스 ②: 일부 슬롯 생략 (기본 폴백 출력)</h3>
      <BaseCard>
        <p>헤더·푸터를 채우지 않으면 기본값이 보입니다.</p>
      </BaseCard>
    </div>

    <hr />

    <h1>👥 2. 스코프드 슬롯 테스트 (동일 컴포넌트, 다른 UI)</h1>

    <!-- 예제 ③: DataList를 활용해 상품 리스트 레이아웃 그리기 -->
    <div class="demo-section">
      <h3>케이스 ③: 상품 목록 스타일</h3>
      <DataList :items="products" v-slot="{ item }">
        {{ item.name }} — {{ item.price.toLocaleString() }}원
      </DataList>
    </div>

    <!-- 예제 ④: 똑같은 DataList를 활용해 회원 목록 레이아웃 전혀 다르게 그리기 -->
    <div class="demo-section">
      <h3>케이스 ④: 회원 목록 스타일</h3>
      <DataList :items="members" v-slot="{ item }">
        <strong>{{ item.name }}</strong> 회원님 · <mark>{{ item.role }}</mark>
      </DataList>
    </div>
  </div>
</template>

4.6 동적 컴포넌트 & keep-alive

<component :is="...">는 값에 따라 어떤 컴포넌트를 그 자리에 끼울지 바꿔 끼웁니다. 탭 UI가 대표적인 예이며, 문제는 탭을 바꾸면 이전 컴포넌트는 사라지고 새 컴포넌트가 다시 만들어져 입력값이 날아간다는 점입니다. <keep-alive>로 감싸면 이 상태를 메모리에 살려둡니다.

TabsPanel.vue

<script setup>
import { ref, computed } from 'vue'
import ProfileTab from './ProfileTab.vue'
import SettingsTab from './SettingsTab.vue'

// 1. 탭 데이터 정의 (컴포넌트 객체를 직접 매핑)
const tabs = [
  { key: 'profile', label: '프로필 수정', comp: ProfileTab },
  { key: 'settings', label: '설정', comp: SettingsTab }
]

// 2. 현재 활성화된 탭의 key 상태 관리
const current = ref('profile')

// 3. 현재 key에 해당하는 컴포넌트를 반환하는 계산된 속성(Computed)
const currentComp = computed(() => {
  return tabs.find(t => t.key === current.value)?.comp
})
</script>

<template>
  <div class="tabs-container">
    <!-- 탭 네비게이션 버튼 -->
    <div class="tabs-nav">
      <button 
        v-for="t in tabs" 
        :key="t.key" 
        :class="{ active: current === t.key }"
        @click="current = t.key"
      >
        {{ t.label }}
      </button>
    </div>

    <!-- 탭 콘텐츠 영역 -->
    <div class="tabs-content">
      <!-- keep-alive로 감싸면 탭을 벗어나도 내부 상태가 유지됨 -->
      <keep-alive>
        <component :is="currentComp" />
      </keep-alive>
    </div>
  </div>
</template>

ProfileTab.vue

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

const name = ref('')
</script>

<template>
  <div class="tab-item">
    <h3>👤 프로필 수정 탭</h3>
    <p>이름을 입력한 후 다른 탭에 갔다와도 값이 유지됩니다.</p>

    <div class="form-group">
      <label for="username">이름: </label>
      <input 
        id="username"
        v-model="name" 
        type="text" 
        placeholder="이름을 입력하세요" 
      />
    </div>

    <p v-if="name" class="preview">입력한 이름: <strong>{{ name }}</strong></p>
  </div>
</template>

SettingsTab.vue

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

const pushEnabled = ref(false)
const emailEnabled = ref(false)
</script>

<template>
  <div class="tab-item">
    <h3>⚙️ 설정 탭</h3>
    <p>원하는 설정을 체크한 후 다른 탭을 클릭해 보세요.</p>

    <div class="checkbox-group">
      <label>
        <input v-model="pushEnabled" type="checkbox" />
        푸시 알림 받기
      </label>
    </div>

    <div class="checkbox-group">
      <label>
        <input v-model="emailEnabled" type="checkbox" />
        이메일 뉴스레터 받기
      </label>
    </div>

    <div class="status">
      <p>푸시 상태: {{ pushEnabled ? '🟢 ON' : '🔴 OFF' }}</p>
      <p>이메일 상태: {{ emailEnabled ? '🟢 ON' : '🔴 OFF' }}</p>
    </div>
  </div>
</template>

💡 주요 기술 포인트 (Key Points)

  1. 동적 컴포넌트 (<component :is="..." />) - Vue에서 제공하는 내장 컴포넌트로, :is 속성에 컴포넌트 객체나 이름을 바인딩하여 상황에 맞는 컴포넌트를 동적으로 렌더링합니다.
  2. 상태 유지 (<keep-alive>) - 동적 컴포넌트가 바뀔 때 기존 컴포넌트는 완전히 파괴(Unmount)되지만, <keep-alive>로 감싸주면 컴포넌트가 메모리에 캐싱됩니다. - 따라서 입력 폼의 텍스트나 체크박스 상태가 그대로 보존됩니다.
  3. 컴포넌트 바인딩 방식 - tabs 배열 내부에 문자열이 아닌 컴포넌트 인스턴스 자체(ProfileTab, SettingsTab)를 comp 필드에 직접 할당하여 코드가 직관적이고 깔끔합니다.

Cheat Sheet: 실무에서는 언제 무엇을 쓰나

상황 방법
부모가 자식에게 값을 한 번 내려주면 될 때 Props
자식이 부모에게 클릭·완료 같은 이벤트나 데이터를 알려야 할 때 Emits
폼 요소처럼 부모의 값과 자식의 입력이 실시간으로 서로 동기화되어야 할 때 v-model
여러 단계 아래 자손까지 값을 보내야 하는데, 중간 컴포넌트들은 그 값과 아무 상관이 없을 때 Provide / Inject
레이아웃(뼈대)은 재사용하되 내부 콘텐츠는 사용하는 쪽에서 자유롭게 채우고 싶을 때 Slots
탭·마법사처럼 여러 화면을 하나의 자리에서 조건에 따라 갈아 끼워야 할 때 <component :is>
화면을 전환해도 입력값·스크롤 위치 같은 상태를 유지하고 싶을 때 <keep-alive>

5. 라우팅 및 상태 관리 (Ecosystem)

내용 펼치기 / 접기

이번 섹션에서는 페이지 전환을 담당하는 Vue Router 4와, 컴포넌트 간 데이터를 효율적으로 공유하는 Pinia를 학습합니다.

5.1 Vue Router 4 기본 설정 및 페이지 이동

5.1.1 Vue Router란?

Vue Router는 Vue의 공식 라우팅 라이브러리입니다. SPA(Single Page Application)에서 브라우저 새로고침 없이 URL에 따라 다른 컴포넌트를 화면에 보여줄 수 있게 해줍니다.

없을 때 있을 때
페이지 이동 시 서버에 새로 요청 → 전체 새로고침 JS가 URL만 바꾸고 컴포넌트만 교체 → 빠른 전환

5.1.2 설치

npm install vue-router@4

5.1.3 경로 표시: @ 기호

Vite 프로젝트에서는 src 폴더를 가리키는 별칭(alias)으로 @를 관례적으로 사용합니다. vite.config.ts에 아래처럼 설정되어 있는 경우가 많습니다.

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})

이렇게 해두면 다음과 같이 상대경로(../../) 대신 항상 동일한 절대경로 스타일로 import할 수 있습니다.

// Before
import Home from '../../views/Home.vue'

// After (@ = src/)
import Home from '@/views/Home.vue'

5.1.4 프로젝트 폴더 구조 예시

src/
 ├─ views/
 │   ├─ Home.vue
 │   ├─ About.vue
 │   └─ UserProfile.vue
 ├─ router/
 │   └─ index.ts
 ├─ App.vue
 └─ main.ts

5.1.5 router/index.ts 작성하기

핵심 키워드: createRouter(), routes, export

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'

const routes = [
  { path: '/', name: 'home', component: Home },
  { path: '/about', name: 'about', component: About },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

export default router
  • createRouter(): 라우터 인스턴스를 생성하는 함수
  • history: createWebHistory()(깔끔한 URL, /about) 또는 createWebHashHistory()(/#/about) 중 선택
  • routes: 경로(path)와 컴포넌트(component)를 매핑한 배열
  • export default: 다른 파일(main.ts)에서 이 라우터를 가져다 쓸 수 있도록 내보냄

5.1.6 main.ts에 라우터 등록

// src/main.ts
import { createApp } from 'vue'
import App from '@/App.vue'
import router from '@/router'

createApp(App).use(router).mount('#app')
<!-- App.vue -->
<template>
  <nav>
    <RouterLink to="/">홈</RouterLink>
    <RouterLink to="/about">소개</RouterLink>
  </nav>

  <RouterView />
</template>
  • RouterLink: <a> 태그를 대체하며, 클릭 시 전체 새로고침 없이 경로만 변경
  • RouterView: 현재 경로에 매칭된 컴포넌트가 실제로 렌더링되는 자리(placeholder)
<!-- App.vue -->
<RouterView v-slot="{ Component }">
  <component 
    :is="Component" 
    :level2Menus="level2Menus"
    :activeLevel2="activeLevel2"
    @onSelect="handleSetActiveLevel2"
  />
</RouterView>
  • v-slot="{ Component } : 현재 매칭된 컴포넌트(화면 객체)를 그대로 그리지 않고, 일단 Component라는 임시 이름(변수)으로 꺼내서 내부에서 직접 다루겠다는 의미입니다.

  • <component :is="Component" ... /> : Vue의 동적 컴포넌트 태그입니다. :is 속성에 들어오는 컴포넌트를 화면에 실제로 렌더링합니다. -- 앞서 v-slot으로 받아온 Component를 지정했으므로, 결과적으로는 현재 주소에 맞는 화면이 여기에 그려지게 됩니다.

실행 결과

브라우저 주소창: http://localhost:5173/
┌─────────────────────────────┐
│ [홈]  [소개]                 │  ← RouterLink로 만들어진 내비게이션
├─────────────────────────────┤
│ Home.vue 내용이 여기 표시됨    │  ← RouterView 위치
└─────────────────────────────┘

'소개' 클릭 → 주소창만 /about 으로 바뀌고
              새로고침 없이 About.vue 내용으로 교체됨

5.2 동적 라우팅 및 네비게이션 가드

5.2.1 동적 라우팅 — params

URL의 일부를 변수처럼 사용하고 싶을 때 :이름 형태로 정의합니다.

// router/index.ts
const routes = [
  { path: '/user/:id', name: 'user', component: () => import('@/views/UserProfile.vue') },
]
<!-- UserProfile.vue -->
<script setup lang="ts">
import { useRoute } from 'vue-router'

const route = useRoute()
</script>

<template>
  <p>사용자 ID: {{ route.params.id }}</p>
</template>

실행 결과

/user/42 접속 시:
  → "사용자 ID: 42" 출력

/user/99 접속 시:
  → "사용자 ID: 99" 출력  (컴포넌트 재사용, 새로 마운트되지 않음)

💡 실무 팁: 같은 컴포넌트를 재사용하면서 params만 바뀌는 경우, watch(() => route.params.id, ...)로 변경을 감지해야 데이터를 다시 불러올 수 있습니다.

5.2.2 children — 중첩 라우트

레이아웃 안에 하위 페이지를 넣고 싶을 때 사용합니다.

const routes = [
  {
    path: '/settings',
    component: () => import('@/views/SettingsLayout.vue'),
    children: [
      { path: '', component: () => import('@/views/SettingsGeneral.vue') },   // /settings
      { path: 'account', component: () => import('@/views/SettingsAccount.vue') }, // /settings/account
    ],
  },
]
<!-- SettingsLayout.vue -->
<template>
  <h2>설정</h2>
  <RouterView /> <!-- children이 이 안에 렌더링됨 -->
</template>

실행 결과

/settings          → "설정" 제목 + SettingsGeneral 내용
/settings/account   → "설정" 제목 + SettingsAccount 내용

5.2.3 redirect — 경로 리다이렉트

const routes = [
  { path: '/home', redirect: '/' },
  { path: '/user/:id', redirect: (to) => `/user/${to.params.id}/profile` },
]

/home으로 접속하면 즉시 /로 이동합니다. 구버전 URL을 유지하면서 새 구조로 안내할 때 자주 씁니다.

5.2.4 네비게이션 가드 — 로그인 처리 예제

가드는 라우팅이 실행되기 전/후에 특정 로직을 끼워 넣는 훅입니다. 로그인하지 않은 사용자의 특정 페이지 접근을 막을 때 대표적으로 사용됩니다.

// router/index.ts
router.beforeEach((to, from, next) => {
  const isLoggedIn = !!localStorage.getItem('token')

  if (to.meta.requiresAuth && !isLoggedIn) {
    next({ name: 'login' })
  } else {
    next()
  }
})

라우트에 meta 속성으로 인증 필요 여부를 표시합니다.

const routes = [
  { path: '/login', name: 'login', component: () => import('@/views/Login.vue') },
  {
    path: '/mypage',
    name: 'mypage',
    component: () => import('@/views/MyPage.vue'),
    meta: { requiresAuth: true },
  },
]

실행 결과

[로그인 안 한 상태]
/mypage 로 이동 시도
  → beforeEach 가드가 isLoggedIn = false 감지
  → 자동으로 /login 으로 리다이렉트

[로그인 한 상태]
/mypage 로 이동 시도
  → isLoggedIn = true → 정상적으로 MyPage.vue 렌더링

5.2.5 참고: Nuxt

Nuxt는 Vue 기반의 상위 프레임워크로, 지금까지 직접 작성한 router/index.ts 설정을 자동화해줍니다.

  • pages/ 폴더에 .vue 파일만 만들면 파일명 기반으로 라우트가 자동 생성됨 (pages/about.vue → /about)
  • 폴더명이 [id].vue이면 자동으로 동적 라우팅(params) 처리
  • SSR(서버 사이드 렌더링) / SSG(정적 사이트 생성) 기본 지원

작은 프로젝트나 러닝커브를 낮추고 싶을 때, 혹은 SEO가 중요한 서비스에서 Nuxt 도입을 고려합니다.


5.3 상태 관리의 필요성과 Pinia 소개

5.3.1 Prop Drilling 문제

컴포넌트 트리가 깊어지면, 최상위 데이터를 최하위 컴포넌트까지 전달하기 위해 중간 컴포넌트들이 굳이 필요 없는 props를 계속 릴레이해야 합니다.

App.vue
 └─ Layout.vue        (user를 그냥 통과만 시킴)
     └─ Header.vue     (user를 그냥 통과만 시킴)
         └─ UserMenu.vue  (진짜로 user가 필요한 곳)

이런 prop drilling이 심해지면 코드 유지보수가 어려워집니다. 이를 해결하기 위해 어디서든 접근 가능한 전역 저장소(store)가 필요하며, Vue 생태계에서는 Pinia를 사용합니다.

5.3.2 Pinia vs Vuex

항목 Vuex Pinia
Vue 3 공식 권장 ❌ (레거시) ✅ (공식 권장)
TypeScript 지원 부족함 (별도 타입 정의 필요) 매우 우수 (자동 타입 추론)
Mutation 개념 있음 (state 변경은 반드시 mutation 경유) 없음 (actions에서 state 직접 변경 가능)
모듈 구조 namespaced 모듈 (설정 복잡) 각 store가 독립적 (설정 단순)
코드량 많음 (boilerplate) 적음
Devtools 지원 있음 있음 (더 상세함)

핵심 차이 한 줄 요약: Vuex는 state를 바꾸려면 반드시 mutation을 거쳐야 하지만, Pinia는 actions 안에서 this.state = ... 처럼 바로 바꿀 수 있어 훨씬 간결합니다.

5.3.3 설치 및 등록

npm install pinia
// main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from '@/App.vue'
import router from '@/router'

const app = createApp(App)

app.use(createPinia())
app.use(router)
app.mount('#app')

5.4 Pinia를 활용한 전역 상태 관리

5.4.1 Store 정의 — State, Getters, Actions

// src/stores/counter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // State: 저장할 데이터
  state: () => ({
    count: 0,
  }),

  // Getters: state를 가공한 계산값 (computed와 유사)
  getters: {
    doubleCount: (state) => state.count * 2,
  },

  // Actions: state를 변경하는 메서드 (동기/비동기 모두 가능)
  actions: {
    increment() {
      this.count++
    },
    reset() {
      this.count = 0
    },
  },
})
  • defineStore('counter', {...}): 첫 번째 인자는 store의 고유 id (devtools에서 이 이름으로 표시됨)
  • state: 반드시 함수로 반환 (컴포넌트의 data()와 동일한 이유)
  • getters: 값이 자동 캐싱되며, 의존하는 state가 바뀔 때만 재계산
  • actions: this.count++ 처럼 state를 직접 수정 가능 (Vuex의 mutation 불필요)

5.4.2 컴포넌트에서 사용하기

<!-- Counter.vue -->
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

<template>
  <p>Count: {{ counter.count }}</p>
  <p>Double: {{ counter.doubleCount }}</p>
  <button @click="counter.increment()">+1</button>
  <button @click="counter.reset()">초기화</button>
</template>

실행 결과

초기 화면:
  Count: 0
  Double: 0

'+1' 버튼 3번 클릭 후:
  Count: 3
  Double: 6   ← getter가 자동으로 재계산됨

'초기화' 버튼 클릭 후:
  Count: 0
  Double: 0

💡 다른 컴포넌트(Header.vue 등)에서도 동일하게 useCounterStore()를 호출하면 같은 데이터를 공유합니다. prop을 한 단계씩 내려줄 필요가 없습니다.

5.4.3 실무 예제 — 로그인 사용자 정보 전역 관리

앞서 다룬 네비게이션 가드와 함께 실무에서 가장 많이 쓰이는 패턴입니다.

// src/stores/auth.ts
import { defineStore } from 'pinia'

interface User {
  id: number
  name: string
}

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null as User | null,
    token: '' as string,
  }),

  getters: {
    isLoggedIn: (state) => !!state.token,
  },

  actions: {
    async login(email: string, password: string) {
      const res = await fetch('/api/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      })
      const data = await res.json()

      this.user = data.user
      this.token = data.token
      localStorage.setItem('token', data.token)
    },

    logout() {
      this.user = null
      this.token = ''
      localStorage.removeItem('token')
    },
  },
})
<!-- Login.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const email = ref('')
const password = ref('')
const auth = useAuthStore()
const router = useRouter()

async function handleLogin() {
  await auth.login(email.value, password.value)
  router.push('/mypage')
}
</script>

<template>
  <input v-model="email" placeholder="이메일" />
  <input v-model="password" type="password" placeholder="비밀번호" />
  <button @click="handleLogin">로그인</button>
</template>

실행 결과

로그인 폼 제출
  → auth.login() 실행 → API 응답으로 user, token 저장
  → auth.isLoggedIn 이 true 로 변경
  → router.push('/mypage') 로 이동
  → (5.2.4절의 네비게이션 가드가 localStorage 토큰을 확인)
  → 정상적으로 MyPage.vue 진입

이제 Header.vue, MyPage.vue 등 어떤 컴포넌트에서든 useAuthStore()만 호출하면 로그인 여부와 사용자 정보를 바로 꺼내 쓸 수 있습니다.

<!-- Header.vue -->
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
</script>

<template>
  <span v-if="auth.isLoggedIn">{{ auth.user?.name }}님 환영합니다</span>
  <span v-else>로그인이 필요합니다</span>
</template>

정리

개념 한 줄 요약
createRouter() 라우터 인스턴스 생성
RouterView / RouterLink 페이지 표시 영역 / 이동 링크
params URL에 동적으로 담기는 값 (/user/:id)
children 레이아웃 안 중첩 라우트 구성
redirect 특정 경로로 자동 이동
beforeEach (네비게이션 가드) 라우팅 전에 로그인 등 조건 검사
Pinia state 저장할 원본 데이터
Pinia getters state를 가공한 계산값
Pinia actions state를 변경하는 로직 (동기/비동기)

6. 고급 활용 및 최적화 (Advanced Topics)

내용 펼치기 / 접기

6.1 비동기 컴포넌트와 Suspense 처리

정의 - 비동기 컴포넌트(Async Component): defineAsyncComponent()를 사용해 필요한 시점에 동적으로 로드(lazy load)하는 컴포넌트. 초기 번들 크기를 줄이는 데 유용합니다. - <Suspense>: 하위 트리에 존재하는 비동기 의존성(비동기 컴포넌트, <script setup> 내부의 top-level await 등)이 모두 처리될 때까지 대체 콘텐츠(fallback)를 보여주는 내장 컴포넌트. 아직 공식 문서상 실험적(experimental) 기능입니다.

예제 코드

<!-- AsyncComp.vue -->
<script setup>
// top-level await 사용 -> 이 컴포넌트는 "비동기 의존성"이 됨
const res = await new Promise((resolve) =>
  setTimeout(() => resolve('서버에서 불러온 데이터'), 1500)
)
</script>

<template>
  <p>{{ res }}</p>
</template>
<!-- App.vue -->
<script setup>
import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() => import('./AsyncComp.vue'))
</script>

<template>
  <Suspense>
    <template #default>
      <AsyncComp />
    </template>
    <template #fallback>
      <p>⏳ 로딩 중입니다...</p>
    </template>
  </Suspense>
</template>

실행 결과 1. 화면 첫 렌더링 시 ⏳ 로딩 중입니다... 가 즉시 표시됨 2. 1.5초 후 AsyncComp의 비동기 처리가 완료되면 서버에서 불러온 데이터 문구로 자동 전환됨

참고 - defineAsyncComponent에 객체 형태로 loadingComponent, errorComponent, delay, timeout 옵션을 줄 수도 있습니다. - 에러 발생 시 처리를 위해 <Suspense>에 #fallback 외에 상위에서 onErrorCaptured를 함께 쓰는 경우가 많습니다.


6.2 재사용 가능한 로직 분리: 컴포저블(Composables) 함수 만들기

정의 - 컴포저블(Composable): Composition API의 함수들(ref, computed, watch 등)을 이용해 상태를 가진 로직을 캡슐화하고, 여러 컴포넌트에서 재사용할 수 있게 만든 함수 - 관례적으로 이름을 use로 시작합니다. (예: useCounter, useMouse, useFetch)

예제 코드

// composables/useCounter.js
import { ref } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)

  const increment = () => count.value++
  const decrement = () => count.value--
  const reset = () => (count.value = initialValue)

  return { count, increment, decrement, reset }
}
<!-- CounterView.vue -->
<script setup>
import { useCounter } from './composables/useCounter'

const { count, increment, decrement, reset } = useCounter(10)
</script>

<template>
  <p>현재 값: {{ count }}</p>
  <button @click="increment">+1</button>
  <button @click="decrement">-1</button>
  <button @click="reset">초기화</button>
</template>

실행 결과 - 최초 화면: 현재 값: 10 - +1 클릭 → 11, 12 ... 순으로 증가 - 초기화 클릭 → 다시 10으로 복귀

장점 - 동일한 로직(카운터, 마우스 좌표 추적, 폼 유효성 검사 등)을 여러 컴포넌트에서 중복 없이 재사용 - 로직과 UI가 분리되어 단위 테스트 작성이 쉬움 - Mixin 방식보다 데이터 출처가 명확해 가독성이 좋음


6.3 DOM 요소 직접 접근하기 (Template Refs)

정의 - Template Ref: 템플릿 내 특정 DOM 요소나 자식 컴포넌트 인스턴스에 직접 접근하기 위한 참조 - ref()로 선언한 변수 이름과 템플릿의 ref="변수명" 속성 이름을 일치시키면, <script setup>에서는 별도 등록 없이 자동으로 바인딩됩니다.

예제 코드

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

const inputRef = ref(null)

onMounted(() => {
  inputRef.value.focus()
})
</script>

<template>
  <input ref="inputRef" placeholder="페이지 진입 시 자동 포커스" />
</template>

실행 결과 - 컴포넌트가 마운트되는 즉시 input 요소에 커서(포커스)가 자동으로 위치함 - 사용자가 클릭하지 않아도 바로 타이핑 가능한 상태가 됨

참고 - v-for와 함께 사용하면 ref는 자동으로 배열로 수집됩니다.

<script setup>
import { ref } from 'vue'
const items = ref(['a', 'b', 'c'])
const itemRefs = ref([])
</script>

<template>
  <li v-for="item in items" :key="item" ref="itemRefs">{{ item }}</li>
</template>
  • 자식 컴포넌트에 ref를 걸 경우, <script setup>을 쓰는 자식은 기본적으로 내부가 캡슐화되어 있으므로 defineExpose()로 명시적으로 노출한 속성/메서드만 부모가 접근할 수 있습니다.

6.4 트랜지션(Transition)을 활용한 애니메이션 효과

정의 - <Transition>: 요소나 컴포넌트가 화면에 삽입/제거될 때 애니메이션을 적용해주는 Vue 내장 컴포넌트 - 상태 변화 시 6개의 CSS 클래스가 순차적으로 부여됩니다: v-enter-from → v-enter-active → v-enter-to, v-leave-from → v-leave-active → v-leave-to - name 속성을 지정하면 접두사가 v-에서 해당 이름으로 바뀝니다. (예: name="fade" → fade-enter-active 등)

예제 코드

<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>

<template>
  <button @click="show = !show">토글</button>

  <Transition name="fade">
    <p v-if="show">안녕하세요! 페이드 효과가 적용됩니다.</p>
  </Transition>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

실행 결과 - 토글 버튼 클릭 시 문구가 즉시 사라지거나 나타나지 않고, 0.5초 동안 서서히 opacity가 변하며 부드럽게 페이드 인/아웃됨

참고 - 여러 요소를 리스트로 다룰 때는 <TransitionGroup>을 사용하면 각 항목에 개별 애니메이션을 줄 수 있습니다. - JavaScript 훅(@before-enter, @enter, @leave 등)을 사용하면 GSAP 같은 애니메이션 라이브러리와도 연동할 수 있습니다.


6.5 Axios / Fetch를 활용한 REST API 비동기 통신

6.5.1 Fetch 함수는 비동기 함수

정의 - fetch()는 브라우저에 내장된 함수로, 네트워크 요청을 보내고 Promise를 반환하는 비동기 함수입니다. - 별도 라이브러리 설치 없이 바로 사용 가능하지만, HTTP 4xx/5xx 에러도 정상 응답(resolve)으로 처리하기 때문에 response.ok를 직접 확인해야 합니다.

예제 코드

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

const todo = ref(null)
const error = ref(null)

onMounted(async () => {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos/1')
    if (!response.ok) {
      throw new Error(`HTTP 오류: ${response.status}`)
    }
    todo.value = await response.json()
  } catch (err) {
    error.value = err.message
  }
})
</script>

<template>
  <div v-if="error">⚠️ 에러 발생: {{ error }}</div>
  <div v-else-if="todo">
    <p>제목: {{ todo.title }}</p>
    <p>완료 여부: {{ todo.completed }}</p>
  </div>
  <div v-else>로딩 중...</div>
</template>

실행 결과

{
  "userId": 1,
  "id": 1,
  "title": "delectus aut autem",
  "completed": false
}

화면 출력:

제목: delectus aut autem
완료 여부: false

6.5.2 Fetch를 사용하기 쉽게 만든 라이브러리 Axios

정의 - Axios는 Promise 기반의 HTTP 클라이언트 라이브러리입니다. - Fetch 대비 장점: - 응답 데이터를 자동으로 JSON 변환해줌 (.json() 호출 불필요) - 요청/응답 인터셉터(interceptor) 지원 - HTTP 에러 상태 코드(4xx/5xx)를 자동으로 reject 처리해 catch에서 바로 잡을 수 있음 - 요청 타임아웃 설정이 간편함

설치

npm install axios

예제 코드

<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'

const todo = ref(null)
const error = ref(null)

onMounted(async () => {
  try {
    const { data } = await axios.get('https://jsonplaceholder.typicode.com/todos/1')
    todo.value = data
  } catch (err) {
    error.value = err.message
  }
})
</script>

<template>
  <div v-if="error">⚠️ 에러 발생: {{ error }}</div>
  <div v-else-if="todo">
    <p>제목: {{ todo.title }}</p>
    <p>완료 여부: {{ todo.completed }}</p>
  </div>
  <div v-else>로딩 중...</div>
</template>

실행 결과 - Fetch 예제와 동일한 데이터가 출력되지만, response.json()을 직접 호출할 필요 없이 data 속성으로 바로 접근할 수 있음

Fetch vs Axios 비교

구분 Fetch Axios
설치 불필요 (브라우저 내장) npm install axios 필요
JSON 변환 .json() 수동 호출 자동 변환
에러 처리 4xx/5xx도 resolve 처리됨 → response.ok로 직접 체크 4xx/5xx는 자동으로 reject → catch로 처리
요청 취소 AbortController 사용 AbortController 또는 CancelToken 사용
인터셉터 미지원 지원 (요청/응답 가로채기 가능)
타임아웃 직접 구현 필요 timeout 옵션으로 간단히 설정

요약

주제 핵심 API 언제 쓰나
비동기 컴포넌트 / Suspense defineAsyncComponent, <Suspense> 초기 로딩 최적화, 코드 스플리팅이 필요할 때
컴포저블 use* 함수 여러 컴포넌트에서 동일 로직을 재사용할 때
Template Refs ref, ref="..." DOM 요소나 자식 컴포넌트에 직접 접근해야 할 때
Transition <Transition>, <TransitionGroup> 요소 삽입/삭제 시 애니메이션이 필요할 때
Fetch / Axios fetch(), axios.get() REST API와 비동기 통신할 때

7. 실전 프로젝트 및 배포 (Real-world Project)

내용 펼치기 / 접기

Vue 3 Composition API(<script setup>) + Vite 기준으로 작성되었습니다.

7.1 API 연동 및 CRUD 기능 구현

정의 - CRUD: Create(생성), Read(조회), Update(수정), Delete(삭제)의 앞글자를 딴 용어로, REST API와 통신하는 애플리케이션의 기본 동작 4가지를 의미합니다. - 실무에서는 API 호출 로직을 컴포저블(useXXX)로 분리해 컴포넌트와 통신 로직을 분리하는 것이 일반적입니다.

예제 코드

// composables/useTodos.js
import { ref } from 'vue'
import axios from 'axios'

const API_URL = 'https://jsonplaceholder.typicode.com/todos'

export function useTodos() {
  const todos = ref([])
  const loading = ref(false)
  const error = ref(null)

  // Read
  const fetchTodos = async () => {
    loading.value = true
    try {
      const { data } = await axios.get(`${API_URL}?_limit=5`)
      todos.value = data
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  // Create
  const addTodo = async (title) => {
    const { data } = await axios.post(API_URL, { title, completed: false })
    todos.value.unshift(data) // 서버 응답으로 받은 항목을 목록 맨 앞에 추가
  }

  // Update
  const updateTodo = async (id, payload) => {
    await axios.put(`${API_URL}/${id}`, payload)
    const idx = todos.value.findIndex((t) => t.id === id)
    if (idx !== -1) todos.value[idx] = { ...todos.value[idx], ...payload }
  }

  // Delete
  const deleteTodo = async (id) => {
    await axios.delete(`${API_URL}/${id}`)
    todos.value = todos.value.filter((t) => t.id !== id)
  }

  return { todos, loading, error, fetchTodos, addTodo, updateTodo, deleteTodo }
}
<!-- TodoApp.vue -->
<script setup>
import { onMounted, ref } from 'vue'
import { useTodos } from './composables/useTodos'

const { todos, loading, fetchTodos, addTodo, updateTodo, deleteTodo } = useTodos()
const newTitle = ref('')

onMounted(fetchTodos)

const handleAdd = () => {
  if (!newTitle.value.trim()) return
  addTodo(newTitle.value)
  newTitle.value = ''
}
</script>

<template>
  <input v-model="newTitle" placeholder="할 일 입력" @keyup.enter="handleAdd" />
  <button @click="handleAdd">추가</button>

  <p v-if="loading">불러오는 중...</p>
  <ul v-else>
    <li v-for="todo in todos" :key="todo.id">
      <input
        type="checkbox"
        :checked="todo.completed"
        @change="updateTodo(todo.id, { completed: !todo.completed })"
      />
      <span :style="{ textDecoration: todo.completed ? 'line-through' : 'none' }">
        {{ todo.title }}
      </span>
      <button @click="deleteTodo(todo.id)">삭제</button>
    </li>
  </ul>
</template>

실행 결과 - 페이지 진입 시 서버에서 할 일 5개를 불러와 목록으로 표시 - 입력창에 텍스트 입력 후 추가 클릭 → 목록 맨 위에 새 항목 즉시 반영 - 체크박스 클릭 → 완료 항목에 취소선 표시 - 삭제 클릭 → 해당 항목이 화면에서 즉시 제거

⚠️ JSONPlaceholder는 실제로 데이터를 저장하지 않는 테스트용 API이므로, 새로고침하면 서버 데이터는 원래대로 돌아옵니다. 화면(클라이언트 상태)만 변경을 반영합니다.


7.2 Vue Devtools를 활용한 디버깅 팁

정의 - Vue Devtools: Vue 애플리케이션을 디버깅하기 위한 브라우저 확장 프로그램(Chrome/Edge/Firefox) 또는 독립 실행형 앱 - 컴포넌트 트리, 반응형 상태(state), 라우터, Pinia/Vuex 스토어, 성능(타임라인)을 시각적으로 확인 가능

사용 예시 1. Components 탭: 컴포넌트 트리를 클릭해 각 컴포넌트의 props, data, computed, setup() 반환값을 실시간으로 확인 2. 상태 직접 수정: Components 탭에서 반응형 값을 더블클릭하면 값을 직접 바꿔볼 수 있음 (예: count 값을 999로 바꿔 즉시 화면 반영 확인) 3. Timeline 탭: 클릭, 이벤트, 컴포넌트 업데이트 등이 시간순으로 기록되어 "왜 리렌더링이 발생했는지" 추적 가능 4. Pinia 탭: 스토어의 상태 변화, 액션 호출 이력을 타임트래블(time-travel) 방식으로 확인/되돌리기 가능

// main.js - 개발 환경에서만 devtools 관련 힌트를 켜고 싶을 때
// (Vite 프로젝트는 기본적으로 개발 모드에서 자동 연동됨)
if (import.meta.env.DEV) {
  console.log('Devtools 연동 모드로 실행 중입니다.')
}

실행 결과 - 브라우저 개발자 도구에 Vue 탭이 새로 생기고, 클릭 시 현재 페이지의 컴포넌트 트리가 표시됨 - 예: TodoApp 컴포넌트를 선택하면 우측 패널에 todos, loading, newTitle 등의 현재 값이 실시간으로 표시됨

참고 - 프로덕션 빌드(npm run build)에서는 기본적으로 Devtools 연동이 비활성화됩니다. - Vite 프로젝트에서 안 보인다면 npm run dev로 개발 서버가 실행 중인지 확인하세요.


7.3 프로젝트 최적화 및 빌드 (Vite Build)

정의 - vite build: 개발용 소스 코드를 압축(minify)·번들링해 프로덕션 배포용 정적 파일(dist 폴더)로 만드는 명령 - 내부적으로 Rollup을 사용해 트리 쉐이킹(tree-shaking), 코드 스플리팅(code splitting), 에셋 해싱(cache busting)을 자동 처리

예제 코드

# 빌드 실행
npm run build

# 로컬에서 빌드 결과 미리보기
npm run preview

라우트 단위 코드 스플리팅 (자주 쓰이는 최적화 기법):

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
  { path: '/', component: () => import('../views/Home.vue') },
  { path: '/about', component: () => import('../views/About.vue') }, // 진입 시점에만 로드
]

export default createRouter({ history: createWebHistory(), routes })

vite.config.js에서 청크 분리 설정:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router'], // 라이브러리를 별도 청크로 분리
        },
      },
    },
  },
})

실행 결과

vite v5.2.0 building for production...
✓ 34 modules transformed.
dist/index.html                   0.46 kB │ gzip:  0.30 kB
dist/assets/vendor-8f2c1a3d.js    58.12 kB │ gzip: 22.90 kB
dist/assets/index-4f3a2b1c.css     1.42 kB │ gzip:  0.74 kB
dist/assets/index-8d3e9f21.js      6.35 kB │ gzip:  2.71 kB
dist/assets/About-7b1e0a2c.js      0.81 kB │ gzip:  0.45 kB
✓ built in 812ms
  • dist/ 폴더에 정적 파일들이 생성됨
  • About.vue가 별도의 작은 청크(About-*.js)로 분리되어, /about 경로에 진입할 때만 로드됨을 확인 가능

참고 - npm run build -- --report 또는 rollup-plugin-visualizer 같은 플러그인으로 번들 크기를 시각화해 최적화 포인트를 찾을 수 있습니다. - 이미지 등 정적 에셋은 vite-plugin-imagemin 같은 플러그인으로 추가 압축 가능합니다.


7.4 Vercel / Netlify 등을 활용한 무료 웹 호스팅 배포

정의 - Vercel / Netlify: 정적 사이트 및 프론트엔드 프로젝트를 무료로 호스팅해주는 클라우드 플랫폼. Git 저장소(GitHub 등)와 연동하면 git push만으로 자동 빌드·배포(CI/CD)가 이루어집니다.

예제: Netlify 배포

# netlify.toml (프로젝트 루트에 생성)
[build]
  command = "npm run build"
  publish = "dist"

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200
# CLI로 배포하는 경우
npm install -g netlify-cli
netlify deploy --prod

예제: Vercel 배포

// vercel.json (SPA 라우팅을 위한 리라이트 설정)
{
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
# CLI로 배포하는 경우
npm install -g vercel
vercel --prod

실행 결과 - GitHub 저장소를 Vercel/Netlify에 연결하면, main 브랜치에 git push 할 때마다 자동으로 빌드 로그가 출력되고 배포가 진행됨 - 배포 완료 후 https://프로젝트명.vercel.app 또는 https://프로젝트명.netlify.app 형태의 URL이 자동 발급됨 - 이후 커밋마다 새 URL(프리뷰 배포)과 프로덕션 URL이 갱신됨

참고 - Vue Router를 history 모드로 사용 중이라면, 새로고침 시 404가 뜨지 않도록 반드시 리라이트(rewrite) 규칙을 설정해야 합니다. (위 netlify.toml, vercel.json 예제 참고) - 환경변수(API 키 등)는 각 플랫폼의 대시보드 Environment Variables 메뉴에서 등록합니다.


7.5 참고: 브라우저의 로컬스토리지와 세션스토리지

정의 - Web Storage API의 일종으로, 브라우저에 키-값(key-value) 형태로 데이터를 저장하는 기능입니다. - 서버 없이 클라이언트(브라우저)에만 저장되며, 도메인(origin) 단위로 격리됩니다.

구분 localStorage sessionStorage
유지 기간 명시적으로 삭제하기 전까지 영구 보관 탭/창을 닫으면 자동 삭제
공유 범위 같은 출처(origin)의 모든 탭/창이 공유 해당 탭(세션) 내에서만 유효
용량 약 5~10MB (브라우저마다 다름) 약 5~10MB
API setItem, getItem, removeItem, clear setItem, getItem, removeItem, clear (동일)
주 용도 로그인 유지, 다크모드 설정, 장바구니 등 폼 임시 입력값, 탭 단위 세션 데이터 등

7.5.1 localStorage 객체

예제 코드

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

const username = ref('')

function save() {
  localStorage.setItem('username', username.value)
}

onMounted(() => {
  const saved = localStorage.getItem('username')
  if (saved) username.value = saved
})
</script>

<template>
  <input v-model="username" @change="save" placeholder="이름 입력 후 새로고침 해보세요" />
  <p>저장된 값: {{ username }}</p>
</template>

실행 결과 - 이름을 입력하고 브라우저를 새로고침하거나 완전히 껐다 켜도 입력했던 값이 그대로 유지됨 - 개발자 도구 → Application 탭 → Local Storage에서 username 키로 값이 저장된 것을 확인 가능

7.5.2 sessionStorage 객체

예제 코드

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

const draft = ref('')

function save() {
  sessionStorage.setItem('draft', draft.value)
}

onMounted(() => {
  const saved = sessionStorage.getItem('draft')
  if (saved) draft.value = saved
})
</script>

<template>
  <textarea v-model="draft" @input="save" placeholder="임시 작성 글"></textarea>
</template>

실행 결과 - 같은 탭에서 새로고침해도 값이 유지됨 (localStorage와 동일하게 동작) - 그러나 탭을 닫았다가 새로 열면 값이 사라짐 — localStorage와의 핵심 차이점 - 개발자 도구 → Application 탭 → Session Storage에서 draft 키 확인 가능


요약

주제 핵심 개념 언제 쓰나
API 연동 / CRUD axios + 컴포저블 패턴 서버 데이터를 생성/조회/수정/삭제할 때
Vue Devtools 컴포넌트/상태 실시간 검사 반응형 데이터 흐름 디버깅, 성능 분석
Vite Build vite build, 코드 스플리팅 배포 전 번들 최적화
Vercel / Netlify Git 연동 CI/CD 배포 무료로 빠르게 정적 사이트를 배포할 때
localStorage / sessionStorage Web Storage API 브라우저에 간단한 데이터를 로컬 저장할 때

7.6 참고: AG Grid

AG Grid 기본 구조

<script setup lang="ts">
import { ref } from 'vue';
import { AgGridVue } from 'ag-grid-vue3';

  1. 컬럼 정의
const columnDefs = ref([
  { field: "make", headerName: "제조사" },
  { field: "model", headerName: "모델명" },
  { field: "price", headerName: "가격" }
]);

  2. 로우 데이터
const rowData = ref([
  { make: "Toyota", model: "Celica", price: 35000 },
  { make: "Ford", model: "Mondeo", price: 32000 },
  { make: "Porsche", model: "Boxster", price: 72000 }
]);

  3. 기본 컬럼 설정
const defaultColDef = {
  flex: 1,
  sortable: true,
  filter: true,
};
<//script>

<template>
  <!-- AG Grid 컴포넌트 사용 -->
  <ag-grid-vue
    class="ag-theme-alpine"
    style="height: 400px; width: 600px;"
    :columnDefs="columnDefs"
    :rowData="rowData"
    :defaultColDef="defaultColDef"
  >
  <//ag-grid-vue>
<//template>

7.6.1. 그리드 초기화 및 제어

GridReadyEvent: 그리드가 로드된 직후 실행되는 @grid-ready 이벤트의 타입입니다. GridApi: 그리드를 프로그래밍적으로 제어(데이터 필터링, 정렬, 선택 등)하기 위한 API 객체의 타입입니다. themeQuartz: AG Grid의 최신 Quartz 테마를 적용할 때 사용합니다.

  1. 데이터 가공 및 서식 (Column Definition) 컬럼 설정(columnDefs) 내에서 데이터를 어떻게 보여주고 수정할지 정의할 때 사용합니다. ValueGetterParams: 여러 필드를 합치거나 계산된 값을 보여줄 때 (valueGetter) 사용합니다. ValueFormatterParams: 화면에 표시되는 텍스트의 포맷(예: 날짜 형식, 천 단위 콤마)을 지정할 때 사용합니다. ValueSetterParams: 사용자가 셀을 수정했을 때 데이터를 어떻게 저장할지 정의합니다.

  2. 이벤트 및 스타일링 CellValueChangedEvent: 셀 값이 바뀌었을 때 발생하는 이벤트 타입으로, DB 저장 로직 등을 연결할 때 유용합니다. CellClassParams: 특정 조건에 따라 셀의 CSS 클래스를 동적으로 변경할 때 (cellClassRules) 사용합니다.