--- paths: - "app/**/*.{vue,ts}" - "server/**/*.ts" - "shared/**/*.ts" --- # Nuxt 4 코딩 컨벤션 > Nuxt 4 + Vue 3 + TypeScript strict 환경 기준 > 최종 업데이트: 2026-04-07 --- ## 빠른 참조 체크리스트 - [ ] `useFetch` / `useAsyncData`의 key가 고유한가? (중복 시 캐시 충돌) - [ ] 서버 전용 코드에 `server/` 디렉토리를 사용하고 클라이언트 코드와 혼용하지 않았는가? - [ ] 환경변수 접근 시 `useRuntimeConfig()`를 사용했는가? (`process.env` 직접 사용 금지) - [ ] 미들웨어에서 리다이렉트 시 `return navigateTo()` 또는 `return abortNavigation()`인가? - [ ] 플러그인에 `provide` 타입이 명시되었는가? - [ ] Pinia store가 Composition API 스타일로 작성되었는가? - [ ] ``에 내부 링크, ``에 외부 링크를 사용했는가? - [ ] 각 페이지에 `useSeoMeta()` 또는 `useHead()`가 설정되었는가? - [ ] `definePageMeta()`가 ` ``` ```vue ``` ### SEO 메타 설정 ```vue ``` --- ## Rule 2: 컴포저블 ### composables vs utils 구분 | 위치 | 기준 | |------|------| | `app/composables/` | Vue 반응형 API 또는 라이프사이클 사용 | | `app/utils/` | 순수 함수 (반응형 없음) | | `server/utils/` | 서버 API 라우트 전용 | ### 올바른 컴포저블 구조 ```typescript // app/composables/useCounter.ts export function useCounter(initialValue: number = 0) { const count = ref(initialValue) const doubled = computed(() => count.value * 2) function increment(step = 1) { count.value += step } function reset() { count.value = initialValue } onUnmounted(() => { /* 정리 로직 */ }) return { count: readonly(count), doubled, increment, reset } } ``` ```typescript // DON'T export function counter() { ... } // use prefix 없음 export function useFormatPrice(price: number) { // 반응형 없음 → utils/에 return `₩${price.toLocaleString()}` } const count = ref(0) // 모듈 레벨 전역 상태! export function useCounter() { return { count } } ``` --- ## Rule 3: 데이터 패칭 ### useFetch vs useAsyncData 선택 | 상황 | 권장 | |------|------| | 단순 URL 기반 호출 | `useFetch` | | 복잡한 로직 / 조건부 패칭 | `useAsyncData` | | 클라이언트 전용 (SSR 불필요) | `useFetch({ server: false })` | | 이벤트 핸들러 내부 | `$fetch` | ### key 규칙 — 반드시 고유하게 ```typescript // DO: '엔티티-액션-파라미터' 패턴 const { data: product } = await useAsyncData( `product-detail-${route.params.id}`, () => $fetch(`/api/products/${route.params.id}`) ) const { data: posts } = await useAsyncData( `user-posts-${userId.value}-page-${page.value}`, () => $fetch('/api/posts', { params: { userId: userId.value, page: page.value } }), { watch: [userId, page] } ) // DON'T: 다른 컴포넌트와 캐시 충돌 const { data } = await useAsyncData('data', () => $fetch('/api/products')) const { data } = await useAsyncData('list', () => $fetch('/api/users')) ``` ### 에러 / 로딩 상태 처리 ```vue ``` --- ## Rule 4: 상태관리 (Pinia) ### Composition API 스타일로 작성 ```typescript // app/stores/useAuthStore.ts export const useAuthStore = defineStore('auth', () => { const user = ref(null) const token = ref(null) const isAuthenticated = computed(() => !!user.value && !!token.value) 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 } return { user: readonly(user), isAuthenticated, login, logout } }) ``` ```typescript // DON'T: Options API 스타일 export const useCounterStore = defineStore('counter', { state: () => ({ count: 0 }), actions: { increment() { this.count++ } }, }) ``` ### 구조분해 시 storeToRefs 필수 ```vue ``` --- ## Rule 5: 레이아웃 ```vue ``` ```vue ``` --- ## Rule 6: 미들웨어 `navigateTo()` / `abortNavigation()` 앞에 반드시 `return`을 붙인다. ```typescript // app/middleware/auth.ts export default defineNuxtRouteMiddleware((to) => { const authStore = useAuthStore() if (!authStore.isAuthenticated) { return navigateTo({ path: '/auth/login', query: { redirect: to.fullPath } }) } }) // 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: '접근 권한이 없습니다.' }) } }) ``` ```typescript // DON'T export default defineNuxtRouteMiddleware(() => { window.location.href = '/login' // SSR 오류 if (!isAuth) navigateTo('/login') // return 누락 → 이후 코드 실행됨 }) ``` --- ## Rule 7: 플러그인 ```typescript // app/plugins/toast.client.ts export default defineNuxtPlugin(() => { return { provide: { toast: { success: (message: string) => { /* 구현 */ }, error: (message: string) => { /* 구현 */ }, }, }, } }) ``` ```typescript // shared/types/nuxt.d.ts — provide 타입 선언 필수 declare module '#app' { interface NuxtApp { $toast: { success: (message: string) => void; error: (message: string) => void } } } declare module 'vue' { interface ComponentCustomProperties { $toast: NuxtApp['$toast'] } } export {} ``` --- ## Rule 8: 서버 API (Nitro) ### 파일명 패턴 | 파일명 | HTTP 메서드 | |--------|------------| | `index.get.ts` | GET | | `index.post.ts` | POST | | `[id].put.ts` | PUT | | `[id].delete.ts` | DELETE | ### 입력 유효성 검사 필수 ```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), }) export default defineEventHandler(async (event) => { const query = await getValidatedQuery(event, QuerySchema.parse) const products = await fetchProductsFromDB(query) return { products, page: query.page } }) ``` ```typescript // DON'T: 검증 없이 DB 삽입 → 보안 위험 export default defineEventHandler(async (event) => { const body = await readBody(event) await db.insert(body) }) // DON'T: 클라이언트 코드에서 DB 직접 임포트 import { db } from '@/server/utils/db' // 클라이언트 번들에 포함됨! ``` --- ## Rule 9: 환경변수 & 설정 ```typescript // nuxt.config.ts export default defineNuxtConfig({ runtimeConfig: { databaseUrl: process.env.DATABASE_URL, // 서버 전용 jwtSecret: process.env.JWT_SECRET, // 서버 전용 public: { apiBase: process.env.NUXT_PUBLIC_API_BASE ?? '/api', }, }, }) ``` | 설정 종류 | 위치 | |---------|------| | 비밀 환경변수 (DB URL, API 키) | `runtimeConfig` (서버 전용) | | 공개 환경변수 | `runtimeConfig.public` | | 테마/UI 설정 (빌드 후 변경 가능) | `app/app.config.ts` | ```typescript // DON'T const apiUrl = process.env.API_URL // 클라이언트에서 undefined runtimeConfig: { public: { jwtSecret: ... } } // 비밀값 노출! ``` --- ## 자주 하는 실수 TOP 5 ### 1. useAsyncData key 중복 ```typescript // 실수: 다른 컴포넌트와 캐시 공유 const { data } = useAsyncData('list', () => $fetch('/api/products')) // 해결 const { data } = useAsyncData(`product-list-${route.query.page}`, () => $fetch('/api/products', { params: { page: route.query.page } }) ) ``` ### 2. Pinia storeToRefs 누락 ```typescript const { user } = useAuthStore() // 반응성 손실 const { user } = storeToRefs(useAuthStore()) // 올바름 ``` ### 3. window/localStorage를 SSR에서 호출 ```typescript const theme = localStorage.getItem('theme') // 서버에서 오류 onMounted(() => { const theme = localStorage.getItem('theme') // 올바름 }) ``` ### 4. 미들웨어 return 누락 ```typescript if (!isAuth) navigateTo('/login') // 이후 코드도 실행됨 if (!isAuth) return navigateTo('/login') // 올바름 ``` ### 5. process.env 클라이언트에서 직접 사용 ```typescript const apiUrl = process.env.API_URL // undefined const config = useRuntimeConfig() const apiUrl = config.public.apiBase // 올바름 ```