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];
}>();
- 템플릿에서는 케밥 케이스 사용 (
@update:model-value). - 스크립트에서는 카멜 케이스 사용 (
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 Mixins
컴포저블은 Vue 2 믹스인을 완전히 대체합니다:
- Mixins: 불투명한 데이터 흐름, 소스 충돌, 이름 충돌.
- 컴포저블: 명시적인 임포트, 명확한 반환 값, 구성 가능하고 트리 쉐이킹 가능.
4. 상태 관리
무엇을 언제 사용할지
| 패턴 | 사용 사례 |
|---|---|
ref() / reactive() | 로컬 컴포넌트 상태 |
| Props + Emits | 부모-자식 통신 |
| Provide / Inject | 테마, 설정, 플러그인 API |
| Pinia 스토어 | 전역, 공유, 복잡한 상태 |
| 서버 상태 컴포저블 | 캐싱을 포함한 API 데이터 (fetch/TanStack Query 래핑) |
Pinia 셋업 스토어 (권장)
// 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 };
});
- 셋업 스토어 구문 (옵션 스토어 아님)을 사용하세요.
- 비즈니스 수준의 변이에는 actions를, 그룹화된 업데이트에는
$patch()를 선호하세요. - 모든 비동기 action: 로딩 + 성공 + 오류를 처리하세요.
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: "앨리스" } },
});
expect(wrapper.text()).toContain("앨리스");
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);
// ... 가져오기 및 반환
});
런타임 설정
// 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());
// ... signal을 사용하여 가져오기
});
useId()
양식 요소 및 접근성을 위한 SSR 안정적인 고유 ID 생성:
import { useId } from "vue";
const id = useId();
defer Teleport
<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 key = index | 재정렬 시 상태 손상 | 안정적인 데이터베이스 ID 사용 |
| props 변이 | 단방향 데이터 흐름 위반 | 이벤트 발생 또는 v-model 사용 |
사용자 콘텐츠에 v-html 사용 | XSS 취약점 | DOMPurify로 살균 |
| Vue 3에서 Mixins 사용 | 불투명하고 충돌 가능성 | 컴포저블로 대체 |
| 컴포저블의 모듈 범위 부수 효과 | 인스턴스 간 공유 | 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— 일반 코드 품질 표준