# Nuxt 4 코딩 컨벤션 Rules > Nuxt 4 + Vue 3 + TypeScript strict 환경 기준 > 최종 업데이트: 2026-04-07 --- ## 빠른 참조 체크리스트 코드 작성 전/리뷰 시 아래 항목을 확인한다. - [ ] 페이지 파일명이 `kebab-case`이고 `app/pages/` 아래에 위치하는가? - [ ] 컴포저블 파일명이 `use` prefix를 가진 `camelCase`인가? (예: `useUserProfile.ts`) - [ ] `useFetch` / `useAsyncData`의 key가 고유한가? (중복 시 캐시 충돌) - [ ] `useAsyncData`의 `key`를 함수명 + 파라미터 조합으로 지정했는가? - [ ] 서버 전용 코드에 `server/` 디렉토리를 사용하고, 클라이언트 전용 코드와 혼용하지 않았는가? - [ ] 환경변수 접근 시 `useRuntimeConfig()`를 사용했는가? (`process.env` 직접 사용 금지) - [ ] 미들웨어에서 리다이렉트 시 `navigateTo()` 또는 `abortNavigation()`을 사용했는가? - [ ] 플러그인에 `provide`/`inject` 타입이 명시되었는가? - [ ] Pinia store가 Composition API 스타일(`defineStore()` + setup 함수)로 작성되었는가? - [ ] ``에 내부 링크, ``에 외부 링크를 사용했는가? - [ ] SEO를 위해 각 페이지에 `useSeoMeta()` 또는 `useHead()`가 설정되었는가? - [ ] `definePageMeta()`가 ` ``` ##### DON'T: 잘못된 definePageMeta 위치 ```vue ``` #### 2-3. SEO 메타 설정 모든 페이지는 `useSeoMeta()` 또는 `useHead()`로 메타 정보를 설정한다. ##### DO: 올바른 SEO 설정 ```vue ``` ##### DO: 동적 SEO 설정 ```vue ``` --- ### Rule 3: 컴포저블 (Composables) #### 3-1. 컴포저블 작성 패턴 컴포저블은 `use` prefix로 시작하고, Vue 반응형 시스템과 라이프사이클에 의존한다. ##### DO: 올바른 컴포저블 구조 ```typescript // app/composables/useCounter.ts export function useCounter(initialValue: number = 0) { // 상태: ref/computed/reactive 사용 const count = ref(initialValue) const doubled = computed(() => count.value * 2) const isPositive = computed(() => count.value > 0) // 액션 function increment(step: number = 1) { count.value += step } function decrement(step: number = 1) { count.value -= step } function reset() { count.value = initialValue } // 라이프사이클 (필요한 경우) onMounted(() => { // 마운트 시 초기화 로직 }) onUnmounted(() => { // 정리 로직 }) // 명시적 반환 return { count: readonly(count), doubled, isPositive, increment, decrement, reset, } } ``` ##### DON'T: 안티 패턴 ```typescript // 금지: use prefix 없음 export function counter() { ... } // 금지: Vue 반응형 없이 순수 함수처럼 작성 (이건 utils/에 위치해야 함) export function useFormatPrice(price: number) { return `₩${price.toLocaleString()}` // 반응형이 없으면 composables/가 아닌 utils/에 위치 } // 금지: 내부 상태가 컴포저블 밖에 선언됨 (모든 호출자가 공유하는 전역 상태가 됨) const count = ref(0) // 모듈 레벨에 선언하면 전역 상태! export function useCounter() { return { count } } ``` #### 3-2. 서버 사이드 컴포저블 `server/utils/` 파일은 서버 API 라우트에서만 사용한다. 컴포저블과 혼용 금지. ```typescript // server/utils/useDatabase.ts → 서버 전용 // app/composables/useDatabase.ts → 클라이언트 전용 (또는 SSR 범용) ``` --- ### Rule 4: 데이터 패칭 (useFetch / useAsyncData) #### 4-1. useFetch vs useAsyncData 선택 기준 | 상황 | 권장 방식 | |------|----------| | 단순 API 호출 (URL 기반) | `useFetch` | | 복잡한 로직, 변환, 조건부 패칭 | `useAsyncData` | | 클라이언트 전용 패칭 (SSR 불필요) | `useFetch({ server: false })` | | 전역 데이터 공유 (Pinia 활용) | Pinia store + `useAsyncData` | #### 4-2. key 규칙 `useAsyncData`의 key는 **고유**해야 한다. 중복 시 캐시가 공유되어 의도하지 않은 데이터가 반환된다. ##### DO: 고유한 key 패턴 ```typescript // 패턴: '엔티티-액션-파라미터' const { data: product } = await useAsyncData( `product-detail-${route.params.id}`, () => $fetch(`/api/products/${route.params.id}`) ) const { data: userPosts } = await useAsyncData( `user-posts-${userId.value}-page-${page.value}`, () => $fetch('/api/posts', { params: { userId: userId.value, page: page.value } }), { watch: [userId, page] } // 의존성 변경 시 자동 재패칭 ) ``` ##### DON'T: 중복 가능한 key ```typescript // 금지: 범용적인 key는 다른 페이지/컴포넌트와 충돌 가능 const { data } = await useAsyncData('data', () => $fetch('/api/products')) const { data } = await useAsyncData('list', () => $fetch('/api/users')) ``` #### 4-3. 에러 처리 & 로딩 상태 ##### DO: 올바른 에러/로딩 처리 ```vue ``` ##### DON'T: 에러/로딩 처리 누락 ```vue ``` #### 4-4. $fetch vs useFetch 선택 | 사용 위치 | 권장 방식 | 이유 | |-----------|----------|------| | ` ``` --- ### Rule 5: 상태관리 (Pinia) #### 5-1. Store 작성 패턴 (Composition API 스타일) ##### DO: Composition API 스타일 ```typescript // app/stores/useAuthStore.ts export const useAuthStore = defineStore('auth', () => { // 상태 const user = ref(null) const token = ref(null) // 게터 (computed) const isAuthenticated = computed(() => !!user.value && !!token.value) const fullName = computed(() => { if (!user.value) return '' return `${user.value.firstName} ${user.value.lastName}` }) // 액션 async function login(credentials: LoginCredentials) { const response = await $fetch('/api/auth/login', { method: 'POST', body: credentials, }) user.value = response.user token.value = response.token } async function logout() { await $fetch('/api/auth/logout', { method: 'POST' }) user.value = null token.value = null } // 초기화 (SSR 호환) async function init() { if (token.value) { user.value = await $fetch('/api/auth/me') } } return { user: readonly(user), token: readonly(token), isAuthenticated, fullName, login, logout, init } }) ``` ##### DON'T: Options API 스타일 (일관성 저해) ```typescript // 금지: Options API 스타일 혼용 export const useCounterStore = defineStore('counter', { state: () => ({ count: 0 }), getters: { doubled: (state) => state.count * 2, }, actions: { increment() { this.count++ }, }, }) ``` #### 5-2. Store 사용 규칙 ```vue ``` --- ### Rule 6: 레이아웃 #### DO: 올바른 레이아웃 사용 ```vue ``` ```vue ``` ```vue ``` #### 동적 레이아웃 변경 ```vue ``` --- ### Rule 7: 미들웨어 미들웨어는 라우트 이동 전에 실행된다. `navigateTo()` 또는 `abortNavigation()`으로 흐름을 제어한다. #### DO: 올바른 미들웨어 작성 ```typescript // app/middleware/auth.ts export default defineNuxtRouteMiddleware((to) => { const authStore = useAuthStore() if (!authStore.isAuthenticated) { // 로그인 후 원래 페이지로 돌아오기 위해 redirect 파라미터 추가 return navigateTo({ path: '/auth/login', query: { redirect: to.fullPath }, }) } }) ``` ```typescript // app/middleware/guest.ts (로그인한 사용자는 접근 불가) export default defineNuxtRouteMiddleware(() => { const authStore = useAuthStore() if (authStore.isAuthenticated) { return navigateTo('/dashboard') } }) ``` ```typescript // app/middleware/role-check.ts (역할 기반 접근 제어) export default defineNuxtRouteMiddleware((to) => { const authStore = useAuthStore() const requiredRole = to.meta.requiredRole as string | undefined if (requiredRole && authStore.user?.role !== requiredRole) { return abortNavigation({ statusCode: 403, message: '접근 권한이 없습니다.', }) } }) ``` #### DON'T: 미들웨어 안티 패턴 ```typescript // 금지: navigateTo 없이 window.location 사용 (SSR 오류 발생) export default defineNuxtRouteMiddleware(() => { window.location.href = '/login' // 서버에서 오류! }) // 금지: 반환값 없는 조건부 리다이렉트 (미들웨어가 계속 실행됨) export default defineNuxtRouteMiddleware((to) => { if (!isAuthenticated) { navigateTo('/login') // return 누락! 이후 코드도 계속 실행됨 } // ... 이후 코드도 실행됨 }) ``` --- ### Rule 8: 플러그인 플러그인은 앱 인스턴스 생성 시 한 번 실행된다. 전역 기능/서비스 등록에 사용한다. #### DO: 올바른 플러그인 작성 ```typescript // app/plugins/toast.client.ts (.client.ts: 클라이언트에서만 실행) import { defineNuxtPlugin } from '#app' export default defineNuxtPlugin(() => { // provide로 전역 주입 (타입 선언 필수) return { provide: { toast: { success: (message: string) => { /* 구현 */ }, error: (message: string) => { /* 구현 */ }, }, }, } }) ``` ```typescript // 플러그인 provide 타입 선언 (shared/types/nuxt.d.ts) declare module '#app' { interface NuxtApp { $toast: { success: (message: string) => void error: (message: string) => void } } } declare module 'vue' { interface ComponentCustomProperties { $toast: NuxtApp['$toast'] } } export {} ``` #### 플러그인 실행 순서 파일명 숫자 prefix로 실행 순서를 제어한다. ``` plugins/ ├── 01.i18n.ts # 먼저 실행 ├── 02.auth.ts # 두 번째 └── toast.client.ts # 클라이언트만 (순서 무관) ``` --- ### Rule 9: 서버 API (Nitro) #### DO: 올바른 서버 API 작성 ```typescript // server/api/products/index.get.ts import { z } from 'zod' // 쿼리 파라미터 스키마 const QuerySchema = z.object({ page: z.coerce.number().min(1).default(1), limit: z.coerce.number().min(1).max(100).default(20), category: z.string().optional(), }) export default defineEventHandler(async (event) => { // 입력 유효성 검사 const query = await getValidatedQuery(event, QuerySchema.parse) // 서버 전용 로직 const products = await fetchProductsFromDB({ page: query.page, limit: query.limit, category: query.category, }) return { products, total: products.length, page: query.page } }) ``` ```typescript // server/api/products/[id].delete.ts (메서드 지정) export default defineEventHandler(async (event) => { const id = getRouterParam(event, 'id') if (!id) { throw createError({ statusCode: 400, message: '상품 ID가 필요합니다.' }) } await deleteProductById(id) setResponseStatus(event, 204) }) ``` #### 서버 API 파일명 패턴 | 파일명 | HTTP 메서드 | |--------|-----------| | `index.get.ts` | GET | | `index.post.ts` | POST | | `[id].put.ts` | PUT | | `[id].patch.ts` | PATCH | | `[id].delete.ts` | DELETE | | `index.ts` | 모든 메서드 (내부에서 분기) | #### DON'T: 서버 API 안티 패턴 ```typescript // 금지: 입력 유효성 검사 없이 바로 사용 export default defineEventHandler(async (event) => { const body = await readBody(event) await db.insert(body) // 검증 없이 직접 DB 삽입 → SQL 인젝션, 타입 오류 위험 }) // 금지: 클라이언트 코드에서 직접 DB 접근 // server/ 디렉토리 밖에서 DB 드라이버 임포트 금지 import { db } from '@/server/utils/db' // 클라이언트 번들에 포함됨! ``` --- ### Rule 10: 환경변수 & 설정 #### DO: runtimeConfig 사용 ```typescript // nuxt.config.ts export default defineNuxtConfig({ runtimeConfig: { // 서버 전용 (클라이언트에 노출 안 됨) databaseUrl: process.env.DATABASE_URL, jwtSecret: process.env.JWT_SECRET, // 클라이언트에 노출 (public) public: { apiBase: process.env.NUXT_PUBLIC_API_BASE ?? '/api', appVersion: process.env.NUXT_PUBLIC_APP_VERSION ?? '1.0.0', }, }, }) ``` ```typescript // 서버 사이드에서 사용 // server/api/products/index.get.ts export default defineEventHandler(() => { const config = useRuntimeConfig() const dbUrl = config.databaseUrl // 서버 전용 값 }) // 클라이언트/서버 공통에서 사용 // app/composables/useApi.ts export function useApi() { const config = useRuntimeConfig() return config.public.apiBase // public 값만 접근 가능 } ``` #### DON'T: process.env 직접 접근 ```typescript // 금지: 클라이언트에서 process.env 직접 접근 (빌드 후 undefined) const apiUrl = process.env.API_URL // 클라이언트에서 작동 안 함 // 금지: 서버 전용 환경변수를 public에 노출 runtimeConfig: { public: { jwtSecret: process.env.JWT_SECRET, // 클라이언트에 노출됨! }, } ``` --- ### Rule 11: 금지사항 (Anti-Patterns) #### Anti-Pattern 1: SSR과 클라이언트 코드 혼용 ```typescript // 금지: SSR에서 실행되면 'window is not defined' 오류 export function useBrowserFeature() { const width = window.innerWidth // 서버에서 오류! } // 올바른 예: process.client 또는 onMounted에서 접근 export function useBrowserFeature() { const width = ref(0) onMounted(() => { width.value = window.innerWidth }) return { width } } ``` #### Anti-Pattern 2: 컴포저블 밖에서 Vue 반응형 API 사용 ```typescript // 금지: 컴포저블/컴포넌트 설정 외부에서 사용 const globalRef = ref(0) // 모듈 레벨 → SSR에서 요청 간 공유됨! // 올바른 예: Pinia store 또는 컴포저블 내부에서 사용 export const useGlobalCounter = defineStore('counter', () => { const count = ref(0) return { count } }) ``` #### Anti-Pattern 3: useAsyncData key 중복 ```typescript // 금지: 같은 key를 다른 컴포넌트에서 사용하면 데이터 공유됨 // ProductList.vue const { data } = useAsyncData('items', () => $fetch('/api/products')) // UserList.vue const { data } = useAsyncData('items', () => $fetch('/api/users')) // 같은 캐시! // 올바른 예: 고유한 key 사용 // ProductList.vue const { data } = useAsyncData('product-list', () => $fetch('/api/products')) // UserList.vue const { data } = useAsyncData('user-list', () => $fetch('/api/users')) ``` #### Anti-Pattern 4: 미들웨어에서 return 누락 ```typescript // 금지: return 없으면 리다이렉트 후에도 계속 실행 export default defineNuxtRouteMiddleware(() => { if (!isAuthenticated) { navigateTo('/login') // return 없음 → 이후 코드 실행됨 } // 인증 안 된 상태에서도 이 코드가 실행됨! checkAdminPermission() }) // 올바른 예 export default defineNuxtRouteMiddleware(() => { if (!isAuthenticated) { return navigateTo('/login') // return으로 즉시 종료 } checkAdminPermission() }) ``` #### Anti-Pattern 5: 서버 API에서 입력값 무검증 ```typescript // 금지: 사용자 입력 직접 사용 export default defineEventHandler(async (event) => { const { name, email } = await readBody(event) await db.users.insert({ name, email }) // 유효성 검사 없음! }) // 올바른 예: 스키마로 검증 import { z } from 'zod' const UserSchema = z.object({ name: z.string().min(1).max(50), email: z.string().email(), }) export default defineEventHandler(async (event) => { const body = await readValidatedBody(event, UserSchema.parse) await db.users.insert(body) }) ``` #### Anti-Pattern 6: definePageMeta 조건부 실행 ```vue ``` --- ## 자주 하는 실수 TOP 5 ### 1. useAsyncData key 미지정 또는 중복 key를 생략하거나 범용 이름을 사용하면 다른 컴포넌트의 데이터와 캐시가 충돌한다. ```typescript // 실수: key 없음 또는 범용 이름 const { data } = useAsyncData(() => $fetch('/api/products')) const { data } = useAsyncData('list', () => $fetch('/api/products')) // 해결: 고유하고 명확한 key const { data } = useAsyncData( `product-list-${route.query.page}`, () => $fetch('/api/products', { params: { page: route.query.page } }) ) ``` ### 2. Pinia store 구조분해 시 storeToRefs 누락 직접 구조분해하면 반응성이 사라진다. ```typescript // 실수: 반응성 손실 const { user, isAuthenticated } = useAuthStore() // user는 더 이상 반응형이 아님 // 해결: storeToRefs로 ref 변환 const { user, isAuthenticated } = storeToRefs(useAuthStore()) ``` ### 3. 클라이언트 전용 API를 SSR에서 호출 `window`, `document`, `localStorage` 등은 서버에 없다. ```typescript // 실수: 서버에서 오류 const savedTheme = localStorage.getItem('theme') // 해결: onMounted 또는 process.client 체크 onMounted(() => { const savedTheme = localStorage.getItem('theme') }) // 또는 플러그인 파일명에 .client.ts 사용 ``` ### 4. 미들웨어에서 navigateTo() return 누락 return 없으면 조건이 충족되어도 이후 코드가 계속 실행된다. ```typescript // 실수: return 누락 export default defineNuxtRouteMiddleware(() => { if (!isAuthenticated) navigateTo('/login') doSomethingElse() // 인증 안 된 상태에서도 실행됨! }) // 해결: return 필수 export default defineNuxtRouteMiddleware(() => { if (!isAuthenticated) return navigateTo('/login') doSomethingElse() }) ``` ### 5. process.env 클라이언트에서 직접 사용 Nuxt 빌드 후 클라이언트 번들에서 `process.env`는 `undefined`가 된다. ```typescript // 실수 const apiUrl = process.env.API_URL // 클라이언트에서 undefined // 해결: runtimeConfig 사용 const config = useRuntimeConfig() const apiUrl = config.public.apiBase // 안전하게 클라이언트 접근 가능 ```