Vue 3 + Nuxt 3 코딩 컨벤션
1. 프로젝트 디렉터리 구조
Section titled “1. 프로젝트 디렉터리 구조”Nuxt 3는 다수의 폴더를 자동 스캔하여 기능을 제공합니다. 폴더명은 kebab-case를 권장합니다.
1.1 디렉터리 트리 예시
Section titled “1.1 디렉터리 트리 예시”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 타입 정의 (선택)
- …
1.2 폴더별 상세 가이드
Section titled “1.2 폴더별 상세 가이드”assets/
Section titled “assets/”빌드 도구가 처리하는 정적 자산을 저장합니다.
- CSS, Sass, 이미지, 폰트 등
- 코드에서 직접 임포트
- Vite/Webpack에 의해 최적화됨
<script setup>import logo from '~/assets/images/logo.png'</script>
<style>@import '~/assets/styles/variables.scss';</style>components/
Section titled “components/”Vue 컴포넌트를 저장하며 자동으로 임포트됩니다.
네이밍 규칙:
- 하위 폴더 구조가 컴포넌트 이름을 결정
- 예:
components/base/foo/Button.vue→<BaseFooButton />
특수 기능:
- 전역 컴포넌트:
.global.vue접미사 (과용 금지) - 지연 로딩:
Lazy접두사 사용 →<LazyFooBar /> - 클라이언트 전용:
.client.vue접미사 - 서버 전용:
.server.vue접미사
<!-- 지연 로딩 예제 --><template> <div> <!-- 필요할 때만 로드 --> <LazyHeavyComponent v-if="showComponent" /> </div></template>composables/
Section titled “composables/”Composition API 기반 재사용 가능한 로직을 저장합니다.
규칙:
- 파일명:
useSomething.ts형태 권장 - 기본적으로 최상위 파일만 스캔
- 하위 폴더 사용 시
nuxt.config.ts에서 설정 필요
// nuxt.config.ts - 하위 폴더 스캔 설정export default defineNuxtConfig({ imports: { dirs: ['composables/**'] }})layouts/
Section titled “layouts/”페이지의 공통 레이아웃을 정의합니다.
중요사항:
- 비동기 로드됨
- 이름은 kebab-case로 변환
- 단일 루트 요소 필수 (루트가
<slot />이면 안 됨)
<template> <div> <Header /> <main> <slot /> </main> <Footer /> </div></template>middleware/
Section titled “middleware/”라우트 네비게이션 가드를 정의합니다.
세 가지 유형:
- 익명 미들웨어: 페이지 내부에서 직접 정의
- 명명 미들웨어: 파일명으로 참조
- 전역 미들웨어:
.global.ts접미사
export default defineNuxtRouteMiddleware((to, from) => { const user = useUserStore() if (!user.isAuthenticated) { return navigateTo('/login') }})<script setup>definePageMeta({ middleware: 'auth'})</script>pages/
Section titled “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/
Section titled “stores/”Pinia를 사용한 상태 관리 스토어입니다.
Setup 스토어 (권장)
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 스토어
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 }})server/
Section titled “server/”서버 사이드 API와 로직을 정의합니다.
구조:
server/api/→/api/*엔드포인트server/routes/→/api없는 라우트server/middleware/→ 서버 미들웨어server/utils/→ 서버 유틸리티
export default defineEventHandler(async (event) => { const users = await fetchUsers() return users})
// server/api/users/[id].get.tsexport default defineEventHandler(async (event) => { const id = getRouterParam(event, 'id') const user = await fetchUser(id) return user})utils/
Section titled “utils/”일반 유틸리티 함수를 저장하며 자동 임포트됩니다.
export const formatDate = (date: Date) => { return date.toLocaleDateString('ko-KR')}
// 자동 임포트되어 어디서든 사용 가능// const formatted = formatDate(new Date())public/
Section titled “public/”빌드 처리 없이 그대로 제공되는 정적 파일입니다.
public/robots.txt→/robots.txtpublic/favicon.ico→/favicon.ico
2. 파일 및 컴포넌트 명명 규칙
Section titled “2. 파일 및 컴포넌트 명명 규칙”2.1 컴포넌트 파일 명
Section titled “2.1 컴포넌트 파일 명”- PascalCase 또는 kebab-case 중 하나를 선택해 일관성 있게.
- 베이스 컴포넌트:
Base,App,V접두사 사용(예:BaseButton.vue,BaseTable.vue).- HTML 요소/다른 베이스 컴포넌트/외부 UI만 포함, 글로벌 상태 금지.
- 접두사 정렬로 에디터 탐색 효율 ↑
- 부모-자식 관계가 강한 경우, 부모 이름을 접두사로(예:
TodoListItemButton.vue). - 일반 규칙: 상위 개념 → 하위 개념 순(예:
SearchButtonClear.vue). - HTML 요소 충돌 방지를 위해 두 단어 이상으로 명명.
2.2 페이지 파일 명
Section titled “2.2 페이지 파일 명”pages/index.vue→/루트. 하위 폴더의index.vue는 해당 디렉터리의 기본 경로.- 동적 라우트:
[id].vue/ 선택적 파라미터:[[param]].vue - 혼합 세그먼트:
users-[group]/[id].vue - 캐치올:
[...slug].vue - 라우트 그룹:
pages/(group)/...(URL 영향 없음) - 페이지는 단일 루트 요소 필수.
2.3 레이아웃 파일 명
Section titled “2.3 레이아웃 파일 명”- 레이아웃 이름은 kebab-case로 변환. 파일명과 일치.
- 예:
layouts/custom.vue→custom레이아웃
- 예:
- 중첩 폴더는 경로 기반 이름 부여
- 예:
~/layouts/desktop/default.vue→desktop-default
- 예:
- 단일 루트 요소 필요(루트
<slot />금지).
2.4 컴포저블·유틸리티·스토어 파일 명
Section titled “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·미들웨어·플러그인 파일 명
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. 함수·변수·컴포저블 네이밍 규칙”3.1 일반 규칙
Section titled “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 컴포넌트 스크립트 작성
Section titled “3.2 Vue 컴포넌트 스크립트 작성”- 기본적으로
<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)
// Computedconst 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조합을 대체하여 더 간결한 양방향 바인딩을 제공합니다.
3.3 컴포저블 및 유틸리티 작성
Section titled “3.3 컴포저블 및 유틸리티 작성”컴포저블 작성 가이드
Section titled “컴포저블 작성 가이드”컴포저블은 Vue의 Composition API를 활용하여 재사용 가능한 상태 로직을 캡슐화합니다.
예제: 기본 컴포저블
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 }}예제: 비동기 데이터 페칭 컴포저블
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 }}유틸리티 작성 가이드
Section titled “유틸리티 작성 가이드”유틸리티는 순수 함수로 작성하며, Vue의 반응성 시스템과 독립적입니다.
예제: 유틸리티 함수
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 기타 네이밍 컨벤션
Section titled “3.4 기타 네이밍 컨벤션”타입 및 인터페이스
Section titled “타입 및 인터페이스”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]4. 코드 스타일 및 모범 사례
Section titled “4. 코드 스타일 및 모범 사례”4.1 포매팅 및 린팅
Section titled “4.1 포매팅 및 린팅”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" }}4.2 반응성 관리
Section titled “4.2 반응성 관리”Pinia 스토어 사용 시 주의사항
Section titled “Pinia 스토어 사용 시 주의사항”구조 분해 시 반응성을 잃지 않도록 storeToRefs()를 사용합니다.
// ❌ 나쁜 예 - 반응성 손실const userStore = useUserStore()const { name, email } = userStore // 반응성 손실!
// ✅ 좋은 예 - 반응성 유지const userStore = useUserStore()const { name, email } = storeToRefs(userStore)
// 메서드는 직접 구조 분해 가능const { updateProfile, logout } = userStoreReactive 객체 구조 분해
Section titled “Reactive 객체 구조 분해”// ❌ 나쁜 예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 데이터 패칭
Section titled “4.3 데이터 패칭”서버 사이드 렌더링과 통합
Section titled “서버 사이드 렌더링과 통합”// ✅ 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)를 지원하며, 컴포넌트 언마운트 시 자동으로 요청을 취소합니다.
데이터 패칭 모범 사례
Section titled “데이터 패칭 모범 사례”<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>병렬 데이터 페칭
Section titled “병렬 데이터 페칭”여러 API를 동시에 호출할 때는 Promise.all과 signal을 활용합니다.
// 병렬 요청 + 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.stats4.4 컴포넌트 설계 원칙
Section titled “4.4 컴포넌트 설계 원칙”단일 책임 원칙
Section titled “단일 책임 원칙”각 컴포넌트는 하나의 명확한 책임만 가져야 합니다.
<!-- ❌ 나쁜 예: 너무 많은 책임 --><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 전달 규칙
Section titled “Props 전달 규칙”Vue 공식 스타일 가이드에 따라 Props 케이싱을 일관되게 사용합니다.
- Props 선언: camelCase 사용
- 템플릿에서 전달: kebab-case 사용
<script setup lang="ts">// Props 선언 시 camelCaseconst 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 스타일 사용
Section titled “Scoped 스타일 사용”모든 컴포넌트는 scoped 스타일 또는 CSS Modules를 사용해야 합니다. 전역 스타일은 App.vue와 레이아웃 컴포넌트에서만 허용합니다.
<style scoped>/* 컴포넌트에 한정된 스타일 */.button { background-color: #42b883;}</style>
<!-- 또는 CSS Modules 사용 --><style module>.button { background-color: #42b883;}</style>4.5 접근성 및 국제화
Section titled “4.5 접근성 및 국제화”ARIA 속성 사용
Section titled “ARIA 속성 사용”<template> <button aria-label="메뉴 열기" :aria-expanded="isMenuOpen" @click="toggleMenu"> <IconMenu /> </button>
<nav :aria-hidden="!isMenuOpen"> <!-- 메뉴 내용 --> </nav></template>국제화 (i18n)
Section titled “국제화 (i18n)”<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. 마무리
Section titled “5. 마무리”본 가이드는 Vue 3 + Nuxt 3 프로젝트의 구조, 명명, 코드 스타일을 정리한 실무 템플릿입니다. 팀 합의와 리뷰 문화를 통해 지속적으로 개선하고, 공식 문서/스타일 가이드 업데이트를 주기적으로 반영하세요.