11 KiB
11 KiB
paths
| paths | |||
|---|---|---|---|
|
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 스타일로 작성되었는가?
<NuxtLink>에 내부 링크,<a>에 외부 링크를 사용했는가?- 각 페이지에
useSeoMeta()또는useHead()가 설정되었는가? definePageMeta()가<script setup>내 최상단에 위치하는가?
Rule 1: 페이지 & 라우팅
파일 기반 라우팅 패턴
| 파일 경로 | 라우트 |
|---|---|
pages/index.vue |
/ |
pages/products/[id].vue |
/products/:id |
pages/[...slug].vue |
/* |
pages/(auth)/login.vue |
/login (URL에 auth 미포함) |
definePageMeta — 반드시 최상단에
<script setup lang="ts">
// 컴파일러 매크로: 최상단 필수
definePageMeta({
layout: 'dashboard',
middleware: ['auth'],
})
const { data } = await useAsyncData('dashboard-stats', () =>
$fetch('/api/stats')
)
</script>
<!-- DON'T: 최상단이 아닌 위치, 조건부 실행 -->
<script setup lang="ts">
const count = ref(0)
definePageMeta({ layout: 'dashboard' }) // 금지
if (isAdmin.value) {
definePageMeta({ layout: 'admin' }) // 금지: 컴파일 타임 매크로
}
</script>
SEO 메타 설정
<script setup lang="ts">
// 정적
useSeoMeta({
title: '상품 목록 | MyStore',
description: '다양한 상품을 만나보세요',
ogImage: '/og-image.jpg',
})
// 동적
const { data: product } = await useAsyncData(
`product-${route.params.id}`,
() => $fetch(`/api/products/${route.params.id}`)
)
useSeoMeta({
title: () => `${product.value?.name} | MyStore`,
description: () => product.value?.description,
})
</script>
Rule 2: 컴포저블
composables vs utils 구분
| 위치 | 기준 |
|---|---|
app/composables/ |
Vue 반응형 API 또는 라이프사이클 사용 |
app/utils/ |
순수 함수 (반응형 없음) |
server/utils/ |
서버 API 라우트 전용 |
올바른 컴포저블 구조
// 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 }
}
// 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 규칙 — 반드시 고유하게
// 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'))
에러 / 로딩 상태 처리
<script setup lang="ts">
const { data: products, status, error, refresh } = await useAsyncData(
'product-list',
() => $fetch<Product[]>('/api/products'),
{ default: () => [] as Product[] }
)
</script>
<template>
<LoadingSpinner v-if="status === 'pending'" />
<div v-else-if="error">
<p>{{ error.message }}</p>
<button type="button" @click="refresh">다시 시도</button>
</div>
<ul v-else>
<li v-for="product in products" :key="product.id">{{ product.name }}</li>
</ul>
</template>
Rule 4: 상태관리 (Pinia)
Composition API 스타일로 작성
// app/stores/useAuthStore.ts
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = ref<string | null>(null)
const isAuthenticated = computed(() => !!user.value && !!token.value)
async function login(credentials: LoginCredentials) {
const response = await $fetch<AuthResponse>('/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 }
})
// DON'T: Options API 스타일
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
actions: { increment() { this.count++ } },
})
구조분해 시 storeToRefs 필수
<script setup lang="ts">
const authStore = useAuthStore()
// DO: 반응성 유지
const { user, isAuthenticated } = storeToRefs(authStore)
const { login, logout } = authStore // 액션은 직접 구조분해 가능
// DON'T: 반응성 손실
// const { user, isAuthenticated } = useAuthStore()
</script>
Rule 5: 레이아웃
<!-- app/layouts/default.vue -->
<template>
<div class="flex min-h-screen flex-col">
<AppHeader />
<main id="main-content" class="flex-1"><slot /></main>
<AppFooter />
</div>
</template>
<!-- 동적 레이아웃 변경 -->
<script setup lang="ts">
definePageMeta({ layout: 'default' })
const authStore = useAuthStore()
watchEffect(() => {
setPageLayout(authStore.isAuthenticated ? 'dashboard' : 'default')
})
</script>
Rule 6: 미들웨어
navigateTo() / abortNavigation() 앞에 반드시 return을 붙인다.
// 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: '접근 권한이 없습니다.' })
}
})
// DON'T
export default defineNuxtRouteMiddleware(() => {
window.location.href = '/login' // SSR 오류
if (!isAuth) navigateTo('/login') // return 누락 → 이후 코드 실행됨
})
Rule 7: 플러그인
// app/plugins/toast.client.ts
export default defineNuxtPlugin(() => {
return {
provide: {
toast: {
success: (message: string) => { /* 구현 */ },
error: (message: string) => { /* 구현 */ },
},
},
}
})
// 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 |
입력 유효성 검사 필수
// 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 }
})
// 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: 환경변수 & 설정
// 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 |
// DON'T
const apiUrl = process.env.API_URL // 클라이언트에서 undefined
runtimeConfig: { public: { jwtSecret: ... } } // 비밀값 노출!
자주 하는 실수 TOP 5
1. useAsyncData key 중복
// 실수: 다른 컴포넌트와 캐시 공유
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 누락
const { user } = useAuthStore() // 반응성 손실
const { user } = storeToRefs(useAuthStore()) // 올바름
3. window/localStorage를 SSR에서 호출
const theme = localStorage.getItem('theme') // 서버에서 오류
onMounted(() => {
const theme = localStorage.getItem('theme') // 올바름
})
4. 미들웨어 return 누락
if (!isAuth) navigateTo('/login') // 이후 코드도 실행됨
if (!isAuth) return navigateTo('/login') // 올바름
5. process.env 클라이언트에서 직접 사용
const apiUrl = process.env.API_URL // undefined
const config = useRuntimeConfig()
const apiUrl = config.public.apiBase // 올바름