import { FileTree } from '@astrojs/starlight/components';
## 1. 프로젝트 디렉터리 구조
Nuxt 3는 다수의 폴더를 자동 스캔하여 기능을 제공합니다. **폴더명은 kebab-case**를 권장합니다.
### 1.1 디렉터리 트리 예시
<FileTree>
- my-app
- app.vue # 최상위 앱 컴포넌트
- nuxt.config.ts # Nuxt 설정 파일
- assets/ # 스타일·폰트·이미지 등(빌드 도구가 처리)
- components/ # 재사용 가능한 Vue 컴포넌트 (자동 임포트)
- composables/ # 재사용 가능한 composition 함수 (자동 임포트)
- layouts/ # 페이지 레이아웃 (비동기 로드)
- middleware/ # 클라이언트 라우트 미들웨어
- pages/ # 파일 기반 라우팅 페이지 컴포넌트
- plugins/ # Nuxt 플러그인 (자동 등록)
- stores/ # Pinia 상태 관리 스토어 (자동 임포트)
- server/ # 서버 API / routes / middleware / plugins
- utils/ # 일반 유틸리티 (자동 임포트)
- public/ # 빌드 미처리 정적 파일 (robots.txt 등)
- types/ # TypeScript 타입 정의 (선택)
</FileTree>
### 1.2 폴더별 상세 가이드
#### assets/
빌드 도구가 처리하는 정적 자산을 저장합니다.
- CSS, Sass, 이미지, 폰트 등
- 코드에서 직접 임포트
- Vite/Webpack에 의해 최적화됨
```vue
<script setup>
import logo from '~/assets/images/logo.png'
</script>
<style>
@import '~/assets/styles/variables.scss';
</style>
```
#### components/
Vue 컴포넌트를 저장하며 **자동으로 임포트**됩니다.
**네이밍 규칙:**
- 하위 폴더 구조가 컴포넌트 이름을 결정
- 예: `components/base/foo/Button.vue` → `<BaseFooButton />`
**특수 기능:**
- **전역 컴포넌트**: `.global.vue` 접미사 (과용 금지)
- **지연 로딩**: `Lazy` 접두사 사용 → `<LazyFooBar />`
- **클라이언트 전용**: `.client.vue` 접미사
- **서버 전용**: `.server.vue` 접미사
```vue
<!-- 지연 로딩 예제 -->
<template>
<div>
<!-- 필요할 때만 로드 -->
<LazyHeavyComponent v-if="showComponent" />
</div>
</template>
```
#### composables/
Composition API 기반 재사용 가능한 로직을 저장합니다.
**규칙:**
- 파일명: `useSomething.ts` 형태 권장
- 기본적으로 최상위 파일만 스캔
- 하위 폴더 사용 시 `nuxt.config.ts`에서 설정 필요
```typescript
// nuxt.config.ts - 하위 폴더 스캔 설정
export default defineNuxtConfig({
imports: {
dirs: ['composables/**']
}
})
```
#### layouts/
페이지의 공통 레이아웃을 정의합니다.
**중요사항:**
- 비동기 로드됨
- 이름은 kebab-case로 변환
- **단일 루트 요소** 필수 (루트가 `<slot />`이면 안 됨)
```vue
<!-- layouts/default.vue -->
<template>
<div>
<Header />
<main>
<slot />
</main>
<Footer />
</div>
</template>
```
#### middleware/
라우트 네비게이션 가드를 정의합니다.
**세 가지 유형:**
1. **익명 미들웨어**: 페이지 내부에서 직접 정의
2. **명명 미들웨어**: 파일명으로 참조
3. **전역 미들웨어**: `.global.ts` 접미사
```typescript
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const user = useUserStore()
if (!user.isAuthenticated) {
return navigateTo('/login')
}
})
```
```vue
<!-- pages/dashboard.vue -->
<script setup>
definePageMeta({
middleware: 'auth'
})
</script>
```
#### pages/
파일 기반 라우팅 시스템을 사용합니다.
**라우팅 패턴:**
| 파일 | 라우트 |
|------|--------|
| `pages/index.vue` | `/` |
| `pages/about.vue` | `/about` |
| `pages/posts/[id].vue` | `/posts/:id` (동적) |
| `pages/posts/[[id]].vue` | `/posts/:id?` (선택적) |
| `pages/[...slug].vue` | `/*` (캐치올) |
| `pages/(marketing)/about.vue` | `/about` (그룹화) |
**단일 루트 요소 필수**
#### stores/
Pinia를 사용한 상태 관리 스토어입니다.
**Setup 스토어 (권장)**
```typescript
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const name = ref('')
const email = ref('')
function updateProfile(newName: string, newEmail: string) {
name.value = newName
email.value = newEmail
}
return { name, email, updateProfile }
})
```
**Options 스토어**
```typescript
// stores/counter.ts
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
getters: {
doubleCount: (state) => state.count * 2
},
actions: {
increment() {
this.count++
}
}
})
```
**useState vs Pinia 선택 가이드**
| 상황 | 권장 솔루션 |
|------|------------|
| 단순 전역 상태 (테마, 로케일 등) | `useState` |
| 컴포넌트 간 공유되는 단일 값 | `useState` |
| 복잡한 상태 + 여러 액션 | Pinia |
| 상태 지속성 (localStorage 등) | Pinia |
| DevTools 디버깅 필요 | Pinia |
```typescript
// ✅ 간단한 상태: useState 사용
export const useTheme = () => useState<'light' | 'dark'>('theme', () => 'light')
export const useLocale = () => useState<string>('locale', () => 'ko-KR')
// ✅ 복잡한 상태: Pinia 사용
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const total = computed(() => items.value.reduce((sum, item) => sum + item.price, 0))
function addItem(item: CartItem) { /* ... */ }
function removeItem(id: string) { /* ... */ }
function checkout() { /* ... */ }
return { items, total, addItem, removeItem, checkout }
})
```
#### server/
서버 사이드 API와 로직을 정의합니다.
**구조:**
- `server/api/` → `/api/*` 엔드포인트
- `server/routes/` → `/api` 없는 라우트
- `server/middleware/` → 서버 미들웨어
- `server/utils/` → 서버 유틸리티
```typescript
// server/api/users.ts
export default defineEventHandler(async (event) => {
const users = await fetchUsers()
return users
})
// server/api/users/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
const user = await fetchUser(id)
return user
})
```
#### utils/
일반 유틸리티 함수를 저장하며 **자동 임포트**됩니다.
```typescript
// utils/format.ts
export const formatDate = (date: Date) => {
return date.toLocaleDateString('ko-KR')
}
// 자동 임포트되어 어디서든 사용 가능
// const formatted = formatDate(new Date())
```
#### public/
빌드 처리 없이 그대로 제공되는 정적 파일입니다.
- `public/robots.txt` → `/robots.txt`
- `public/favicon.ico` → `/favicon.ico`
---
## 2. 파일 및 컴포넌트 명명 규칙
### 2.1 컴포넌트 파일 명
- **PascalCase** 또는 **kebab-case** 중 **하나를 선택해 일관성** 있게.
- **베이스 컴포넌트**: `Base`, `App`, `V` 접두사 사용(예: `BaseButton.vue`, `BaseTable.vue`).
- HTML 요소/다른 베이스 컴포넌트/외부 UI만 포함, **글로벌 상태 금지**.
- 접두사 정렬로 에디터 탐색 효율 ↑
- **부모-자식 관계**가 강한 경우, **부모 이름을 접두사**로(예: `TodoListItemButton.vue`).
- **일반 규칙**: 상위 개념 → 하위 개념 순(예: `SearchButtonClear.vue`).
- **HTML 요소 충돌 방지**를 위해 **두 단어 이상**으로 명명.
### 2.2 페이지 파일 명
- `pages/index.vue` → `/` 루트. 하위 폴더의 `index.vue`는 해당 디렉터리의 기본 경로.
- **동적 라우트**: `[id].vue` / **선택적 파라미터**: `[[param]].vue`
- **혼합 세그먼트**: `users-[group]/[id].vue`
- **캐치올**: `[...slug].vue`
- **라우트 그룹**: `pages/(group)/...` (URL 영향 없음)
- 페이지는 **단일 루트 요소 필수**.
### 2.3 레이아웃 파일 명
- 레이아웃 이름은 **kebab-case**로 변환. 파일명과 일치.
- 예: `layouts/custom.vue` → `custom` 레이아웃
- 중첩 폴더는 경로 기반 이름 부여
- 예: `~/layouts/desktop/default.vue` → `desktop-default`
- **단일 루트 요소** 필요(루트 `<slot />` 금지).
### 2.4 컴포저블·유틸리티·스토어 파일 명
- **컴포저블**: `composables/useSomething.ts`(함수명 `useSomething`). camelCase/kebab-case 파일명 허용하나 **노출 함수는 camelCase**.
- **유틸리티**: `utils/random-entry.ts` → `randomEntry()`.
- **Pinia 스토어**: `stores/user.ts` + `defineStore('user', ...)` → 내보내기 함수 `useUserStore`.
- 각 파일 **1 스토어** 권장. 여러 스토어 사용 가능.
### 2.5 서버 API·미들웨어·플러그인 파일 명
- **API**: `server/api/hello.ts` → `/api/hello`
- 동적: `[name].ts`, 메서드별: `.get.ts`, `.post.ts`, ...
- **server routes**: `/api` 접두사 제거 필요 시 `server/routes/*`
- **server middleware**: `server/middleware/log.ts` (모든 요청 전 실행)
- **server plugins**: `server/plugins/myPlugin.ts` (Nitro 플러그인)
- **클라이언트 미들웨어**: `app/middleware/*.ts` (kebab-case, 전역은 `.global`)
---
## 3. 함수·변수·컴포저블 네이밍 규칙
### 3.1 일반 규칙
- **camelCase** 사용(JS/TS 함수·변수·반응형 상태 포함).
- **불린 변수**: `is*`, `has*`, `should*` 접두사(예: `isAuthenticated`, `hasPermission`).
- **이벤트 핸들러**: `handle*`, `on*`(예: `handleSubmit`, `onClick`).
- **비동기/데이터 패칭**: `fetch*`, `load*`, `get*`(예: `fetchUserProfile`, `loadPosts`).
- **컴포저블**: `use*`(예: `useAuth`, `useFetchData`).
- **Pinia 스토어**: `use*Store`(예: `useUserStore`).
### 3.2 Vue 컴포넌트 스크립트 작성
- 기본적으로 **`<script setup>`** 사용(간결/타입 추론 우수).
- 각 파일 **하나의 컴포넌트**, `export default` 사용.
- 화살표 함수 선호, 불필요한 중괄호·세미콜론 지양.
**예제: 기본 컴포넌트 구조**
```vue
<script setup lang="ts">
// Nuxt 3에서는 ref, computed, onMounted 등이 auto-import됨
// import { ref, computed, onMounted } from 'vue' ← 불필요
// Props 정의
const props = defineProps<{
title: string
count?: number
}>()
// Emits 정의 (Vue 3.3+ named tuple 문법 - 권장)
const emit = defineEmits<{
update: [value: number]
delete: []
}>()
// 또는 기존 call signature 문법 (Vue 3.3 이전 호환)
// const emit = defineEmits<{
// (e: 'update', value: number): void
// (e: 'delete'): void
// }>()
// 반응형 상태
const localCount = ref(props.count ?? 0)
// Computed
const doubleCount = computed(() => localCount.value * 2)
// 메서드
function increment() {
localCount.value++
emit('update', localCount.value)
}
// 라이프사이클
onMounted(() => {
console.log('Component mounted')
})
</script>
<template>
<div>
<h2>{{ title }}</h2>
<p>Count: {{ localCount }}</p>
<p>Double: {{ doubleCount }}</p>
<button @click="increment">Increment</button>
</div>
</template>
<style scoped>
button {
padding: 0.5rem 1rem;
background-color: #42b883;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
```
#### defineModel을 활용한 양방향 바인딩 (Vue 3.4+)
Vue 3.4부터 도입된 `defineModel()` 매크로는 `v-model` 양방향 바인딩을 간편하게 구현합니다.
**기본 사용법**
```vue
<script setup lang="ts">
// 기본 v-model 바인딩 (modelValue prop)
const model = defineModel()
// 타입 지정
const model = defineModel<string>()
// 필수 + 타입 지정
const model = defineModel<string>({ required: true })
// 기본값 지정
const model = defineModel({ default: 0 })
</script>
<template>
<input v-model="model" />
</template>
```
**Named Model (v-model:name)**
```vue
<script setup lang="ts">
// v-model:title 바인딩
const title = defineModel('title')
// 여러 v-model 지원
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>
<template>
<input v-model="firstName" placeholder="이름" />
<input v-model="lastName" placeholder="성" />
</template>
```
**부모 컴포넌트에서 사용**
```vue
<template>
<!-- 기본 v-model -->
<MyInput v-model="searchText" />
<!-- Named v-model -->
<UserNameForm
v-model:first-name="user.firstName"
v-model:last-name="user.lastName"
/>
</template>
```
**Modifiers 접근**
```vue
<script setup lang="ts">
// modifiers 객체 접근
const [model, modifiers] = defineModel<string, 'trim' | 'uppercase'>()
// modifiers 예: { trim: true, uppercase: true }
console.log(modifiers)
</script>
```
> **참고**: `defineModel()`은 이전의 `defineProps` + `defineEmits` 조합을 대체하여 더 간결한 양방향 바인딩을 제공합니다.
### 3.3 컴포저블 및 유틸리티 작성
#### 컴포저블 작성 가이드
컴포저블은 Vue의 Composition API를 활용하여 **재사용 가능한 상태 로직**을 캡슐화합니다.
**예제: 기본 컴포저블**
```typescript
// composables/useCounter.ts
export const useCounter = (initialValue = 0) => {
const count = ref(initialValue)
const doubleCount = computed(() => count.value * 2)
const increment = () => {
count.value++
}
const decrement = () => {
count.value--
}
const reset = () => {
count.value = initialValue
}
return {
count: readonly(count),
doubleCount,
increment,
decrement,
reset
}
}
```
**예제: 비동기 데이터 페칭 컴포저블**
```typescript
// composables/useFetch.ts
export const useFetch = <T>(url: string) => {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
const fetchData = async () => {
loading.value = true
error.value = null
try {
const response = await $fetch<T>(url)
data.value = response
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
// 자동 실행
onMounted(() => {
fetchData()
})
return {
data: readonly(data),
error: readonly(error),
loading: readonly(loading),
refetch: fetchData
}
}
```
#### 유틸리티 작성 가이드
유틸리티는 **순수 함수**로 작성하며, Vue의 반응성 시스템과 독립적입니다.
**예제: 유틸리티 함수**
```typescript
// utils/format.ts
export const formatCurrency = (value: number, locale = 'ko-KR', currency = 'KRW') => {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency
}).format(value)
}
export const formatDate = (date: Date | string, format = 'yyyy-MM-dd') => {
const d = typeof date === 'string' ? new Date(date) : date
// 포맷팅 로직
return d.toISOString().split('T')[0]
}
```
#### 주요 원칙
- 컴포저블: `ref`, `reactive`, `computed` 등으로 상태 구성 후 필요한 값 반환
- **파일명 = 함수명** 일치 권장 (예: `useAuth.ts` → `useAuth()`)
- 유틸리티: **순수 함수** 지향, 부수 효과 회피
- **스토어와 컴포저블 역할 분리**: 컴포저블 내부에서 스토어를 직접 생성/의존하지 않도록 설계
- Nuxt **auto-import** 적극 활용
### 3.4 기타 네이밍 컨벤션
#### 타입 및 인터페이스
**PascalCase**를 사용하며, `I` 접두사는 지양합니다.
```typescript
// ❌ 나쁜 예
interface IUserProfile {
name: string
email: string
}
// ✅ 좋은 예
interface UserProfile {
name: string
email: string
}
type ApiResponse<T> = {
data: T
status: number
message?: string
}
```
#### 상수 정의
enum 대신 **상수 객체**를 사용하여 타입 안전성과 유연성을 확보합니다.
```typescript
// ❌ enum 사용 지양
enum UserRole {
ADMIN = 'admin',
USER = 'user',
GUEST = 'guest'
}
// ✅ 상수 객체 사용 권장
export const USER_ROLE = {
ADMIN: 'admin',
USER: 'user',
GUEST: 'guest'
} as const
export type UserRole = typeof USER_ROLE[keyof typeof USER_ROLE]
```
---
## 4. 코드 스타일 및 모범 사례
### 4.1 포매팅 및 린팅
**ESLint + Prettier**를 사용하여 일관된 코드 스타일을 유지합니다.
```json
// .eslintrc.json 예시
{
"extends": [
"@nuxt/eslint-config",
"plugin:vue/vue3-recommended",
"prettier"
],
"rules": {
"vue/multi-word-component-names": "error",
"vue/no-v-html": "warn"
}
}
```
### 4.2 반응성 관리
#### Pinia 스토어 사용 시 주의사항
구조 분해 시 반응성을 잃지 않도록 `storeToRefs()`를 사용합니다.
```typescript
// ❌ 나쁜 예 - 반응성 손실
const userStore = useUserStore()
const { name, email } = userStore // 반응성 손실!
// ✅ 좋은 예 - 반응성 유지
const userStore = useUserStore()
const { name, email } = storeToRefs(userStore)
// 메서드는 직접 구조 분해 가능
const { updateProfile, logout } = userStore
```
#### Reactive 객체 구조 분해
```typescript
// ❌ 나쁜 예
const state = reactive({ count: 0, name: 'John' })
const { count, name } = state // 반응성 손실
// ✅ 좋은 예 1: toRefs 사용
const state = reactive({ count: 0, name: 'John' })
const { count, name } = toRefs(state)
// ✅ 좋은 예 2: 객체 그대로 사용
const state = reactive({ count: 0, name: 'John' })
// 템플릿에서: {{ state.count }}
```
### 4.3 데이터 패칭
#### 서버 사이드 렌더링과 통합
```typescript
// ✅ SSR 지원 - useFetch 사용 (status 포함)
const { data, status, pending, error, refresh } = await useFetch('/api/users')
// status: 'idle' | 'pending' | 'success' | 'error'
// ✅ SSR 지원 - useAsyncData 사용 (signal 옵션 포함 - 권장)
const { data, status, error } = await useAsyncData(
'users',
(_nuxtApp, { signal }) => $fetch('/api/users', { signal })
)
// ⚠️ 클라이언트 전용 - $fetch 사용
onMounted(async () => {
const users = await $fetch('/api/users')
})
```
> **참고**: `signal` 옵션은 요청 취소(abort)를 지원하며, 컴포넌트 언마운트 시 자동으로 요청을 취소합니다.
#### 데이터 패칭 모범 사례
```vue
<script setup lang="ts">
interface User {
id: number
name: string
email: string
}
// 기본 사용
const { data: users, pending, error, refresh } = await useFetch<User[]>('/api/users')
// 조건부 패칭
const userId = ref(1)
const { data: user } = await useFetch(() => `/api/users/${userId.value}`, {
// userId가 변경되면 자동으로 재요청
watch: [userId]
})
// 즉시 실행하지 않음
const { data, execute } = await useFetch('/api/users', {
immediate: false
})
// 버튼 클릭 시 실행
const handleClick = () => {
execute()
}
</script>
```
#### 병렬 데이터 페칭
여러 API를 동시에 호출할 때는 `Promise.all`과 `signal`을 활용합니다.
```typescript
// 병렬 요청 + signal 지원
const { data: dashboard, status } = await useAsyncData(
'dashboard',
async (_nuxtApp, { signal }) => {
const [users, posts, stats] = await Promise.all([
$fetch('/api/users', { signal }),
$fetch('/api/posts', { signal }),
$fetch('/api/stats', { signal })
])
return { users, posts, stats }
}
)
// 사용: dashboard.value.users, dashboard.value.posts, dashboard.value.stats
```
### 4.4 컴포넌트 설계 원칙
#### 단일 책임 원칙
각 컴포넌트는 하나의 명확한 책임만 가져야 합니다.
```vue
<!-- ❌ 나쁜 예: 너무 많은 책임 -->
<script setup lang="ts">
// UserDashboard.vue - 사용자 정보, 통계, 설정 등 모든 것을 처리
</script>
<!-- ✅ 좋은 예: 책임 분리 -->
<script setup lang="ts">
// UserDashboard.vue - 레이아웃만 담당
import UserProfile from './UserProfile.vue'
import UserStats from './UserStats.vue'
import UserSettings from './UserSettings.vue'
</script>
<template>
<div class="dashboard">
<UserProfile />
<UserStats />
<UserSettings />
</div>
</template>
```
#### Props 전달 규칙
Vue 공식 스타일 가이드에 따라 Props 케이싱을 일관되게 사용합니다.
- **Props 선언**: camelCase 사용
- **템플릿에서 전달**: kebab-case 사용
```vue
<script setup lang="ts">
// Props 선언 시 camelCase
const props = defineProps<{
greetingMessage: string
itemCount?: number
}>()
</script>
<template>
<!-- ✅ 좋은 예: kebab-case로 전달 -->
<MyComponent greeting-message="hello" :item-count="10" />
<!-- ❌ 피해야 할 예: camelCase로 전달 -->
<MyComponent greetingMessage="hello" :itemCount="10" />
</template>
```
#### Scoped 스타일 사용
모든 컴포넌트는 **scoped 스타일** 또는 **CSS Modules**를 사용해야 합니다. 전역 스타일은 `App.vue`와 레이아웃 컴포넌트에서만 허용합니다.
```vue
<style scoped>
/* 컴포넌트에 한정된 스타일 */
.button {
background-color: #42b883;
}
</style>
<!-- 또는 CSS Modules 사용 -->
<style module>
.button {
background-color: #42b883;
}
</style>
```
### 4.5 접근성 및 국제화
#### ARIA 속성 사용
```vue
<template>
<button
aria-label="메뉴 열기"
:aria-expanded="isMenuOpen"
@click="toggleMenu">
<IconMenu />
</button>
<nav :aria-hidden="!isMenuOpen">
<!-- 메뉴 내용 -->
</nav>
</template>
```
#### 국제화 (i18n)
```vue
<script setup lang="ts">
const { t } = useI18n()
</script>
<template>
<div>
<h1>{{ t('welcome.title') }}</h1>
<p>{{ t('welcome.description', { name: userName }) }}</p>
</div>
</template>
```
> **요약 체크리스트**
> - [ ] 폴더/파일명 규칙(PascalCase vs kebab-case) 팀 합의
> - [ ] 베이스 컴포넌트 접두사(`Base|App|V`) 사용
> - [ ] 페이지/레이아웃 단일 루트 요소 보장
> - [ ] 동적 라우트/캐치올/그룹 구조 명확화
> - [ ] 컴포저블 `use*`/스토어 `use*Store` 일관성
> - [ ] ESLint/Prettier 설정 및 CI 체크
> - [ ] Pinia 반응성(`storeToRefs`) 준수
---
## 5. 마무리
본 가이드는 Vue 3 + Nuxt 3 프로젝트의 **구조, 명명, 코드 스타일**을 정리한 실무 템플릿입니다. 팀 합의와 리뷰 문화를 통해 지속적으로 개선하고, 공식 문서/스타일 가이드 업데이트를 주기적으로 반영하세요.