Vue.js 패턴 및 모범 사례
Composition API (<script setup>)를 사용한 Vue.js 3 개발을 위한 포괄적인 가이드입니다. 컴포넌트 설계, 반응형, 상태 관리, 라우팅, 테스트 및 SSR 패턴을 다룹니다. 바닐라 Vue와 다른 경우 Nuxt 관련 지침도 포함되어 있습니다.
활성화 시점
다음과 같은 경우 이 스킬을 활성화하세요:
- 프로젝트가 Vue.js (모든 버전), Nuxt, Vite + Vue 또는 Pinia를 사용하는 경우.
- 사용자가 Vue 컴포넌트 아키텍처, 컴포저블, 반응형 또는 상태 관리에 대해 질문하는 경우.
- Vue 단일 파일 컴포넌트 (
.vue파일)를 검토하는 경우. - Vue Router, Pinia 스토어 또는 Vite/Vitest 구성을 설정하는 경우.
- Vue 특정 성능, 보안 또는 SSR 패턴에 대해 논의하는 경우.
1. 프로젝트 구조
권장 레이아웃 (기능 우선)
src/
├── api/ # API 클라이언트 및 엔드포인트 정의
├── assets/ # 정적 자산 (이미지, 폰트, 아이콘)
├── components/ # 공유/재사용 가능한 컴포넌트
│ ├── base/ # 기본 UI 프리미티브 (Button, Input, Modal)
│ └── features/ # 기능별 공유 컴포넌트
├── composables/ # 재사용 가능한 Composition API 로직
├── layouts/ # 페이지 레이아웃 (선택 사항)
├── pages/ # 라우트 수준 페이지 컴포넌트
├── router/ # Vue Router 구성
├── stores/ # Pinia 스토어
├── types/ # TypeScript 타입 정의
├── utils/ # 순수 유틸리티 함수
└── App.vue # 루트 컴포넌트
파일 명명 규칙
| 규칙 | 사용 시점 |
|---|---|
PascalCase.vue | 모든 컴포넌트 (vue/multi-word-component-names에 의해 강제됨) |
useCamelCase.ts | 컴포저블 |
camelCase.ts | 유틸리티, API 클라이언트, 타입 |
kebab-case 디렉토리 | 라우트 세그먼트, 기능 폴더 |
2. 컴포넌트 아키텍처
단일 파일 컴포넌트 순서
<script setup lang="ts">
// 1. 임포트 (vue → 생태계 → 절대 경로 → 상대 경로)
// 2. Props & Emits & Slots
// 3. 컴포저블
// 4. 로컬 상태 (ref/reactive)
// 5. 계산된 속성
// 6. 메서드
// 7. 감시자
// 8. 라이프사이클 훅
</script>
<template>
<!-- 템플릿 콘텐츠 -->
</template>
<style scoped>
/* 스코프 스타일 */
</style>
표현형 vs 컨테이너
- 컨테이너 컴포넌트: 데이터 페칭, 상태 및 사이드 이펙트를 소유합니다. 표현형 컴포넌트를 렌더링합니다.
- 표현형 컴포넌트: props를 받고 이벤트를 발생시킵니다. API 호출이나 스토어 접근이 없습니다. 순수 렌더링입니다.
Props 모범 사례
// 기본값이 있는 타입 기반 props
interface Props {
label: string;
variant?: "primary" | "secondary";
disabled?: boolean;
items: Item[];
}
const props = withDefaults(defineProps<Props>(), {
variant: "primary",
disabled: false,
});
- 항상
type과 적절한required/default를 제공하세요. - Boolean props:
isXxx,hasXxx,canXxx. - props를 절대 변이하지 마세요. 대신 이벤트를 발생시키세요.
- v-model 바인딩의 경우
defineModel()(Vue 3.4+) 또는modelValue+update:modelValue를 사용하세요.
이벤트
const emit = defineEmits<{
submit: [];
"update:modelValue": [value: string];
select: [id: string, index: number];
}>();
- 템플릿에서는 kebab-case를 사용하세요 (
@update:model-value). - 스크립트에서는 camelCase를 사용하세요 (
emit("update:modelValue", val)).
3. 컴포저블 (재사용 가능한 로직)
구조
// composables/useDebounce.ts
export function useDebounce<T>(value: MaybeRef<T>, delay: number): Ref<T> {
const debounced = ref(toValue(value)) as Ref<T>;
let timer: ReturnType<typeof setTimeout>;
watch(
() => toValue(value),
(newVal) => {
clearTimeout(timer);
timer = setTimeout(() => { debounced.value = newVal; }, delay);
}
);
onUnmounted(() => clearTimeout(timer));
return readonly(debounced);
}
규칙
use접두사로 시작해야 합니다.- 반응형 값 (
ref,computed,reactive)을 반환하고, 일반 프리미티브는 절대 반환하지 마세요. MaybeRef/toRef()/toValue()를 통해 반응형 입력을 받으세요.onUnmounted또는 감시자onCleanup에서 사이드 이펙트를 정리하세요.- 모듈 수준의 사이드 이펙트는 없어야 합니다.
vs 믹스인
컴포저블은 Vue 2 믹스인을 완전히 대체합니다:
- 믹스인: 불투명한 데이터 흐름, 충돌하는 진리 소스, 이름 충돌.
- 컴포저블: 명시적인 임포트, 명확한 반환 값, 구성 가능하고 트리 셰이크 가능합니다.
4. 상태 관리
언제 무엇을 사용할까
| 패턴 | 사용 사례 |
|---|---|
ref() / reactive() | 로컬 컴포넌트 상태 |
| Props + Emits | 부모-자식 통신 |
| Provide / Inject | 테마, 설정, 플러그인 API |
| Pinia 스토어 | 전역, 공유, 복잡한 상태 |
| 서버 상태 컴포저블 | 캐싱이 있는 API 데이터 (fetch/TanStack Query 래핑) |
Pinia Setup Store (권장)
// stores/useCartStore.ts
export const useCartStore = defineStore("cart", () => {
const items = ref<CartItem[]>([]);
const isLoading = ref(false);
const totalPrice = computed(() =>
items.value.reduce((sum, i) => sum + i.price * i.quantity, 0)
);
const itemCount = computed(() =>
items.value.reduce((sum, i) => sum + i.quantity, 0)
);
async function addItem(productId: string) {
isLoading.value = true;
try {
const item = await fetchProduct(productId);
const existing = items.value.find(i => i.id === item.id);
if (existing) existing.quantity++;
else items.value.push({ ...item, quantity: 1 });
} finally {
isLoading.value = false;
}
}
return { items, isLoading, totalPrice, itemCount, addItem };
});
- Setup Store 구문 (Options Store 아님)을 사용하세요.
- 비즈니스 수준의 변이에는 액션을, 그룹화된 업데이트에는
$patch()를 선호하세요. - 모든 비동기 액션: 로딩 + 성공 + 오류를 처리하세요.
5. Vue Router
라우트 정의
const routes = [
{
path: "/users/:id",
name: "user-detail",
component: () => import("@/pages/UserDetail.vue"), // 지연 로딩
props: true, // params를 props로 전달
meta: { requiresAuth: true },
},
];
내비게이션 가드
router.beforeEach((to, from) => {
const { isLoggedIn } = useAuthStore();
if (to.meta.requiresAuth && !isLoggedIn) {
return { name: "login", query: { redirect: to.fullPath } };
}
});
반응형 라우트 파라미터
컴포넌트가 마운트된 상태로 유지되지만 라우트 파라미터가 변경될 때:
const route = useRoute();
const id = computed(() => route.params.id as string);
watch(id, (newId) => fetchItem(newId));
6. 템플릿 패턴
템플릿 구문
<!-- v-if/v-else-if/v-else -->
<div v-if="isLoading">로딩 중...</div>
<div v-else-if="error">오류: {{ error }}</div>
<div v-else>{{ content }}</div>
<!-- v-show: 빈번한 토글용 -->
<div v-show="isOpen">토글된 콘텐츠</div>
<!-- v-for: 안정적인 키 사용 -->
<div v-for="item in items" :key="item.id">{{ item.name }}</div>
<!-- 계산된 필터 목록 (같은 요소에 v-if + v-for 사용 금지) -->
<div v-for="item in activeItems" :key="item.id">{{ item.name }}</div>
<!-- 이벤트 처리 -->
<form @submit.prevent="handleSubmit">
<button type="submit">저장</button>
</form>
<!-- v-model -->
<input v-model="name" />
<CustomInput v-model="value" v-model:title="title" />
7. 성능
| 기술 | 사용 시점 |
|---|---|
v-memo | 거의 변경되지 않는 목록 항목 |
v-once | 한 번 렌더링되고 영원히 정적인 콘텐츠 |
shallowRef() | 통째로 교체되는 대규모 데이터 구조 |
shallowReactive() | 최상위 속성만 반응형인 경우 |
v-if 대신 v-show | 빈번한 가시성 토글 |
<KeepAlive :max="10"> | 토글된 뷰 캐싱 |
| 지연 라우트 | 중요하지 않은 라우트에 () => import(...) |
Suspense | 폴백이 있는 비동기 컴포넌트 로딩 |
8. 테스팅
스택
- Vitest: 단위 및 컴포넌트 테스트용
- Vue Test Utils: 마운팅 및 상호작용용
- @pinia/testing: 스토어 모킹용
- Playwright: E2E 테스트용
컴포넌트 테스트 패턴
import { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import UserCard from "./UserCard.vue";
beforeEach(() => { setActivePinia(createPinia()); });
it("렌더링 및 이벤트 발생", async () => {
const wrapper = mount(UserCard, {
props: { user: { id: "1", name: "Alice" } },
});
expect(wrapper.text()).toContain("Alice");
await wrapper.find("button").trigger("click");
expect(wrapper.emitted("select")![0]).toEqual(["1"]);
});
9. Nuxt 특정 패턴
자동 임포트
Nuxt는 ref, computed, watch, useFetch, useAsyncData 등을 자동 임포트합니다. 임포트 없이 직접 사용하세요. Nuxt가 아닌 프로젝트에서는 항상 명시적으로 임포트하세요.
useAsyncData / useFetch
const { data: user, pending, error, refresh } = await useAsyncData(
"user", // 캐싱을 위한 고유 키
() => $fetch(`/api/users/${id}`),
);
const { data: posts } = await useFetch("/api/posts", {
query: { page: 1 },
key: "posts-page-1", // 요청 중복 제거
});
서버 라우트
// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
const { id } = await getValidatedRouterParams(event, z.object({
id: z.string().uuid(),
}).parse);
// ... fetch and return
});
런타임 설정
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// 서버 전용
apiSecret: "",
// 공개 (클라이언트에 노출됨)
public: {
apiBase: "https://api.example.com",
},
},
});
10. Vue 3.5+ 새로운 API
반응형 Props 구조 분해
Vue 3.5는 반응형 props 구조 분해를 안정화했습니다 — defineProps()에서 구조 분해된 변수는 자동으로 반응형입니다:
// Vue 3.5+: 구조 분해된 props는 반응형입니다 (toRefs 필요 없음)
const { count = 0, msg = "hello" } = defineProps<{
count?: number;
msg?: string;
}>();
// 제한 사항: 구조 분해된 prop을 직접 감시할 수 없습니다
watch(() => count, (newVal) => { ... }); // 통과: getter 필요
useTemplateRef()
이름이 일치하는 일반 ref를 useTemplateRef()로 대체하여 템플릿 참조를 얻으세요:
import { useTemplateRef } from "vue";
const inputEl = useTemplateRef<HTMLInputElement>("input");
// "input"은 템플릿의 ref="input" 속성과 일치하며, 변수 이름이 아닙니다
동적 ref ID 지원: useTemplateRef(dynamicRefId).
onWatcherCleanup()
전역에서 임포트 가능한 감시자 정리 API (Vue 3.5+). 감시자 콜백 내부에서 동기적으로 호출되어야 합니다:
import { watch, onWatcherCleanup } from "vue";
watch(userId, async (newId) => {
const controller = new AbortController();
onWatcherCleanup(() => controller.abort());
// ... 신호로 fetch
});
useId()
폼 요소 및 접근성을 위한 SSR 안정적인 고유 ID 생성:
import { useId } from "vue";
const id = useId();
defer 텔레포트
<Teleport defer>를 사용하면 동일한 사이클에서 렌더링된 대상으로 텔레포트할 수 있습니다:
<Teleport defer to="#container">콘텐츠</Teleport>
<div id="container"></div>
지연 수화 (SSR)
defineAsyncComponent()는 이제 hydrate 전략을 지원합니다:
import { defineAsyncComponent, hydrateOnVisible } from "vue";
const AsyncComp = defineAsyncComponent({
loader: () => import("./Comp.vue"),
hydrate: hydrateOnVisible(),
});
안티 패턴
| 안티 패턴 | 잘못된 이유 | 해결 방법 |
|---|---|---|
defineProps() 구조 분해 (Vue < 3.5) | 스냅샷을 캡처하여 반응성을 잃음 | props.xxx로 접근하거나 toRefs() 사용 |
구조 분해된 prop에 watch() 사용 (Vue 3.5+) | 컴파일 타임 오류 — 구조 분해된 props는 직접 감시할 수 없음 | getter 래퍼 사용: watch(() => count, ...) |
같은 요소에 v-if + v-for 사용 | 모호한 실행 순서 | 계산된 필터 배열 사용 |
v-for 키 = 인덱스 | 재정렬 시 상태 손상 | 안정적인 데이터베이스 ID 사용 |
| props 변이 | 단방향 데이터 흐름 위반 | 이벤트 발생 또는 v-model 사용 |
사용자 콘텐츠에 v-html 사용 | XSS 취약점 | DOMPurify로 살균 |
| Vue 3에서 믹스인 사용 | 불투명하고 충돌하기 쉬움 | 컴포저블로 대체 |
| 컴포저블의 모듈 수준 사이드 이펙트 | 인스턴스 간에 공유됨 | onMounted + onUnmounted로 범위 지정 |
교체 가능한 상태에 reactive() 사용 | 교체 시 반응성 손상 | 대신 ref() 사용 |
| 정리 없는 감시자 | 메모리 누수, 경쟁 조건 | onCleanup 또는 onWatcherCleanup() 사용 (Vue 3.5+) |
| 새 Vue 3 코드에서 Options API 사용 | 생태계가 Composition API로 이동 중 | <script setup> 사용 |
| 템플릿 참조에 일반 ref 사용 | 동적 ref 지원 없음, 이름 일치가 취약함 | useTemplateRef() 사용 (Vue 3.5+) |
관련 스킬
accessibility— ARIA, 시맨틱 HTML, 포커스 관리frontend-patterns— 프레임워크를 넘나드는 프론트엔드 아키텍처typescript— Vue 프로젝트에 적용된 TypeScript 모범 사례coding-standards— 일반 코드 품질 표준