Skip to content

Vue 3 + Nuxt 3 코딩 컨벤션

Nuxt 3는 다수의 폴더를 자동 스캔하여 기능을 제공합니다. 폴더명은 kebab-case를 권장합니다.

  • Directorymy-app
    • app.vue # 최상위 앱 컴포넌트
    • nuxt.config.ts # Nuxt 설정 파일
    • Directoryassets/ # 스타일·폰트·이미지 등(빌드 도구가 처리)
    • Directorycomponents/ # 재사용 가능한 Vue 컴포넌트 (자동 임포트)
    • Directorycomposables/ # 재사용 가능한 composition 함수 (자동 임포트)
    • Directorylayouts/ # 페이지 레이아웃 (비동기 로드)
    • Directorymiddleware/ # 클라이언트 라우트 미들웨어
    • Directorypages/ # 파일 기반 라우팅 페이지 컴포넌트
    • Directoryplugins/ # Nuxt 플러그인 (자동 등록)
    • Directorystores/ # Pinia 상태 관리 스토어 (자동 임포트)
    • Directoryserver/ # 서버 API / routes / middleware / plugins
    • Directoryutils/ # 일반 유틸리티 (자동 임포트)
    • Directorypublic/ # 빌드 미처리 정적 파일 (robots.txt 등)
    • Directorytypes/ # TypeScript 타입 정의 (선택)

빌드 도구가 처리하는 정적 자산을 저장합니다.

  • CSS, Sass, 이미지, 폰트 등
  • 코드에서 직접 임포트
  • Vite/Webpack에 의해 최적화됨
<script setup>
import logo from '~/assets/images/logo.png'
</script>
<style>
@import '~/assets/styles/variables.scss';
</style>

Vue 컴포넌트를 저장하며 자동으로 임포트됩니다.

네이밍 규칙:

  • 하위 폴더 구조가 컴포넌트 이름을 결정
  • 예: components/base/foo/Button.vue<BaseFooButton />

특수 기능:

  • 전역 컴포넌트: .global.vue 접미사 (과용 금지)
  • 지연 로딩: Lazy 접두사 사용 → <LazyFooBar />
  • 클라이언트 전용: .client.vue 접미사
  • 서버 전용: .server.vue 접미사
<!-- 지연 로딩 예제 -->
<template>
<div>
<!-- 필요할 때만 로드 -->
<LazyHeavyComponent v-if="showComponent" />
</div>
</template>

Composition API 기반 재사용 가능한 로직을 저장합니다.

규칙:

  • 파일명: useSomething.ts 형태 권장
  • 기본적으로 최상위 파일만 스캔
  • 하위 폴더 사용 시 nuxt.config.ts에서 설정 필요
// nuxt.config.ts - 하위 폴더 스캔 설정
export default defineNuxtConfig({
imports: {
dirs: ['composables/**']
}
})

페이지의 공통 레이아웃을 정의합니다.

중요사항:

  • 비동기 로드됨
  • 이름은 kebab-case로 변환
  • 단일 루트 요소 필수 (루트가 <slot />이면 안 됨)
layouts/default.vue
<template>
<div>
<Header />
<main>
<slot />
</main>
<Footer />
</div>
</template>

라우트 네비게이션 가드를 정의합니다.

세 가지 유형:

  1. 익명 미들웨어: 페이지 내부에서 직접 정의
  2. 명명 미들웨어: 파일명으로 참조
  3. 전역 미들웨어: .global.ts 접미사
middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
const user = useUserStore()
if (!user.isAuthenticated) {
return navigateTo('/login')
}
})
pages/dashboard.vue
<script setup>
definePageMeta({
middleware: 'auth'
})
</script>

파일 기반 라우팅 시스템을 사용합니다.

라우팅 패턴:

파일라우트
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 (그룹화)

단일 루트 요소 필수

Pinia를 사용한 상태 관리 스토어입니다.

Setup 스토어 (권장)

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 스토어

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
// ✅ 간단한 상태: 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 }
})

서버 사이드 API와 로직을 정의합니다.

구조:

  • server/api//api/* 엔드포인트
  • server/routes//api 없는 라우트
  • server/middleware/ → 서버 미들웨어
  • server/utils/ → 서버 유틸리티
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/format.ts
export const formatDate = (date: Date) => {
return date.toLocaleDateString('ko-KR')
}
// 자동 임포트되어 어디서든 사용 가능
// const formatted = formatDate(new Date())

빌드 처리 없이 그대로 제공되는 정적 파일입니다.

  • public/robots.txt/robots.txt
  • public/favicon.ico/favicon.ico

  • PascalCase 또는 kebab-case하나를 선택해 일관성 있게.
  • 베이스 컴포넌트: Base, App, V 접두사 사용(예: BaseButton.vue, BaseTable.vue).
    • HTML 요소/다른 베이스 컴포넌트/외부 UI만 포함, 글로벌 상태 금지.
    • 접두사 정렬로 에디터 탐색 효율 ↑
  • 부모-자식 관계가 강한 경우, 부모 이름을 접두사로(예: TodoListItemButton.vue).
  • 일반 규칙: 상위 개념 → 하위 개념 순(예: SearchButtonClear.vue).
  • HTML 요소 충돌 방지를 위해 두 단어 이상으로 명명.
  • pages/index.vue/ 루트. 하위 폴더의 index.vue는 해당 디렉터리의 기본 경로.
  • 동적 라우트: [id].vue / 선택적 파라미터: [[param]].vue
  • 혼합 세그먼트: users-[group]/[id].vue
  • 캐치올: [...slug].vue
  • 라우트 그룹: pages/(group)/... (URL 영향 없음)
  • 페이지는 단일 루트 요소 필수.
  • 레이아웃 이름은 kebab-case로 변환. 파일명과 일치.
    • 예: layouts/custom.vuecustom 레이아웃
  • 중첩 폴더는 경로 기반 이름 부여
    • 예: ~/layouts/desktop/default.vuedesktop-default
  • 단일 루트 요소 필요(루트 <slot /> 금지).

2.4 컴포저블·유틸리티·스토어 파일 명

Section titled “2.4 컴포저블·유틸리티·스토어 파일 명”
  • 컴포저블: composables/useSomething.ts(함수명 useSomething). camelCase/kebab-case 파일명 허용하나 노출 함수는 camelCase.
  • 유틸리티: utils/random-entry.tsrandomEntry().
  • Pinia 스토어: stores/user.ts + defineStore('user', ...) → 내보내기 함수 useUserStore.
    • 각 파일 1 스토어 권장. 여러 스토어 사용 가능.

2.5 서버 API·미들웨어·플러그인 파일 명

Section titled “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. 함수·변수·컴포저블 네이밍 규칙

Section titled “3. 함수·변수·컴포저블 네이밍 규칙”
  • camelCase 사용(JS/TS 함수·변수·반응형 상태 포함).
  • 불린 변수: is*, has*, should* 접두사(예: isAuthenticated, hasPermission).
  • 이벤트 핸들러: handle*, on*(예: handleSubmit, onClick).
  • 비동기/데이터 패칭: fetch*, load*, get*(예: fetchUserProfile, loadPosts).
  • 컴포저블: use*(예: useAuth, useFetchData).
  • Pinia 스토어: use*Store(예: useUserStore).
  • 기본적으로 <script setup> 사용(간결/타입 추론 우수).
  • 각 파일 하나의 컴포넌트, export default 사용.
  • 화살표 함수 선호, 불필요한 중괄호·세미콜론 지양.

예제: 기본 컴포넌트 구조

<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+)

Section titled “defineModel을 활용한 양방향 바인딩 (Vue 3.4+)”

Vue 3.4부터 도입된 defineModel() 매크로는 v-model 양방향 바인딩을 간편하게 구현합니다.

기본 사용법

<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)

<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>

부모 컴포넌트에서 사용

<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 접근

<script setup lang="ts">
// modifiers 객체 접근
const [model, modifiers] = defineModel<string, 'trim' | 'uppercase'>()
// modifiers 예: { trim: true, uppercase: true }
console.log(modifiers)
</script>

참고: defineModel()은 이전의 defineProps + defineEmits 조합을 대체하여 더 간결한 양방향 바인딩을 제공합니다.

컴포저블은 Vue의 Composition API를 활용하여 재사용 가능한 상태 로직을 캡슐화합니다.

예제: 기본 컴포저블

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
}
}

예제: 비동기 데이터 페칭 컴포저블

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의 반응성 시스템과 독립적입니다.

예제: 유틸리티 함수

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.tsuseAuth())
  • 유틸리티: 순수 함수 지향, 부수 효과 회피
  • 스토어와 컴포저블 역할 분리: 컴포저블 내부에서 스토어를 직접 생성/의존하지 않도록 설계
  • Nuxt auto-import 적극 활용

PascalCase를 사용하며, I 접두사는 지양합니다.

// ❌ 나쁜 예
interface IUserProfile {
name: string
email: string
}
// ✅ 좋은 예
interface UserProfile {
name: string
email: string
}
type ApiResponse<T> = {
data: T
status: number
message?: string
}

enum 대신 상수 객체를 사용하여 타입 안전성과 유연성을 확보합니다.

// ❌ 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]

ESLint + Prettier를 사용하여 일관된 코드 스타일을 유지합니다.

// .eslintrc.json 예시
{
"extends": [
"@nuxt/eslint-config",
"plugin:vue/vue3-recommended",
"prettier"
],
"rules": {
"vue/multi-word-component-names": "error",
"vue/no-v-html": "warn"
}
}

구조 분해 시 반응성을 잃지 않도록 storeToRefs()를 사용합니다.

// ❌ 나쁜 예 - 반응성 손실
const userStore = useUserStore()
const { name, email } = userStore // 반응성 손실!
// ✅ 좋은 예 - 반응성 유지
const userStore = useUserStore()
const { name, email } = storeToRefs(userStore)
// 메서드는 직접 구조 분해 가능
const { updateProfile, logout } = userStore
// ❌ 나쁜 예
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 }}
// ✅ 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)를 지원하며, 컴포넌트 언마운트 시 자동으로 요청을 취소합니다.

<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.allsignal을 활용합니다.

// 병렬 요청 + 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

각 컴포넌트는 하나의 명확한 책임만 가져야 합니다.

<!-- ❌ 나쁜 예: 너무 많은 책임 -->
<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>

Vue 공식 스타일 가이드에 따라 Props 케이싱을 일관되게 사용합니다.

  • Props 선언: camelCase 사용
  • 템플릿에서 전달: kebab-case 사용
<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 스타일 또는 CSS Modules를 사용해야 합니다. 전역 스타일은 App.vue와 레이아웃 컴포넌트에서만 허용합니다.

<style scoped>
/* 컴포넌트에 한정된 스타일 */
.button {
background-color: #42b883;
}
</style>
<!-- 또는 CSS Modules 사용 -->
<style module>
.button {
background-color: #42b883;
}
</style>
<template>
<button
aria-label="메뉴 열기"
:aria-expanded="isMenuOpen"
@click="toggleMenu">
<IconMenu />
</button>
<nav :aria-hidden="!isMenuOpen">
<!-- 메뉴 내용 -->
</nav>
</template>
<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) 준수

본 가이드는 Vue 3 + Nuxt 3 프로젝트의 구조, 명명, 코드 스타일을 정리한 실무 템플릿입니다. 팀 합의와 리뷰 문화를 통해 지속적으로 개선하고, 공식 문서/스타일 가이드 업데이트를 주기적으로 반영하세요.