- CLAUDE.md: 프로젝트 지침 파일 추가 (@import 방식으로 rules 참조) - docs/rules/html-structure.md: 시맨틱 HTML, 접근성, Vue 템플릿 구조 규칙 - docs/rules/tailwindcss-strategy.md: TailwindCSS v4 스타일링 전략 규칙 - docs/rules/nuxt-conventions.md: Nuxt 4 코딩 컨벤션 규칙 (라우팅/컴포저블/데이터패칭/Pinia 등) - docs/MARKUP_CONVENTION_GUIDE.md: 마크업 컨벤션 종합 가이드
25 KiB
25 KiB
Nuxt 4 코딩 컨벤션 Rules
Nuxt 4 + Vue 3 + TypeScript strict 환경 기준 최종 업데이트: 2026-04-07
빠른 참조 체크리스트
코드 작성 전/리뷰 시 아래 항목을 확인한다.
- 페이지 파일명이
kebab-case이고app/pages/아래에 위치하는가? - 컴포저블 파일명이
useprefix를 가진camelCase인가? (예:useUserProfile.ts) useFetch/useAsyncData의 key가 고유한가? (중복 시 캐시 충돌)useAsyncData의key를 함수명 + 파라미터 조합으로 지정했는가?- 서버 전용 코드에
server/디렉토리를 사용하고, 클라이언트 전용 코드와 혼용하지 않았는가? - 환경변수 접근 시
useRuntimeConfig()를 사용했는가? (process.env직접 사용 금지) - 미들웨어에서 리다이렉트 시
navigateTo()또는abortNavigation()을 사용했는가? - 플러그인에
provide/inject타입이 명시되었는가? - Pinia store가 Composition API 스타일(
defineStore()+ setup 함수)로 작성되었는가? <NuxtLink>에 내부 링크,<a>에 외부 링크를 사용했는가?- SEO를 위해 각 페이지에
useSeoMeta()또는useHead()가 설정되었는가? definePageMeta()가<script setup>내 최상단에 위치하는가?
규칙 상세
Rule 1: 디렉토리 구조 & 파일 네이밍
Nuxt 4 표준 디렉토리 구조
app/
├── assets/ # 빌드 처리될 에셋 (CSS, 이미지, 폰트)
├── components/ # 자동 임포트 컴포넌트 (PascalCase.vue)
│ └── ui/ # shadcn-vue 등 기본 UI 컴포넌트
├── composables/ # 자동 임포트 컴포저블 (useXxx.ts)
├── layouts/ # 레이아웃 컴포넌트 (kebab-case.vue)
├── middleware/ # 라우트 미들웨어 (kebab-case.ts)
├── pages/ # 파일 기반 라우팅 (kebab-case.vue)
├── plugins/ # 플러그인 (kebab-case.ts)
├── utils/ # 자동 임포트 유틸 함수 (camelCase.ts)
├── app.vue # 앱 루트
└── error.vue # 에러 페이지
server/
├── api/ # API 라우트 (kebab-case.ts)
├── middleware/ # 서버 미들웨어
└── utils/ # 서버 전용 유틸
shared/ # 클라이언트/서버 공유 타입 및 유틸
public/ # 정적 파일 (빌드 처리 없음)
파일 네이밍 규칙
| 파일 종류 | 네이밍 규칙 | 예시 |
|---|---|---|
| 컴포넌트 | PascalCase.vue |
UserProfile.vue |
| 페이지 | kebab-case.vue |
user-profile.vue |
| 레이아웃 | kebab-case.vue |
admin-panel.vue |
| 컴포저블 | useXxx.ts (camelCase) |
useUserProfile.ts |
| 미들웨어 | kebab-case.ts |
auth-guard.ts |
| 플러그인 | kebab-case.ts |
toast-plugin.ts |
| 유틸 함수 | camelCase.ts |
formatDate.ts |
| API 라우트 | kebab-case.ts |
user-profile.ts |
| Pinia store | useXxxStore.ts |
useAuthStore.ts |
DO: 올바른 파일 구조
app/
├── components/
│ ├── AppHeader.vue # 앱 전역 컴포넌트
│ ├── ProductCard.vue
│ └── ui/
│ ├── Button.vue
│ └── Badge.vue
├── composables/
│ ├── useAuth.ts
│ └── useProductList.ts
├── pages/
│ ├── index.vue # /
│ ├── about.vue # /about
│ └── products/
│ ├── index.vue # /products
│ └── [id].vue # /products/:id
DON'T: 잘못된 파일 네이밍
app/
├── components/
│ ├── userProfile.vue # 금지: PascalCase가 아님
│ └── User_Profile.vue # 금지: snake_case 사용
├── composables/
│ ├── authHelper.ts # 금지: use prefix 없음
│ └── GetUser.ts # 금지: 동사로 시작하는 PascalCase
├── pages/
│ └── UserList.vue # 금지: 페이지는 kebab-case
Rule 2: 페이지 & 라우팅
2-1. 파일 기반 라우팅 패턴
| 파일 경로 | 생성되는 라우트 | 설명 |
|---|---|---|
pages/index.vue |
/ |
홈 |
pages/about.vue |
/about |
정적 라우트 |
pages/products/index.vue |
/products |
네스티드 인덱스 |
pages/products/[id].vue |
/products/:id |
동적 세그먼트 |
pages/[...slug].vue |
/* |
Catch-all |
pages/(auth)/login.vue |
/login |
그룹 라우트 (URL에 미포함) |
2-2. definePageMeta 사용
definePageMeta()는 반드시 <script setup> 블록 내 최상단에 위치한다.
DO: 올바른 페이지 메타 설정
<!-- app/pages/dashboard.vue -->
<script setup lang="ts">
// 최상단에 위치 (컴파일러 매크로)
definePageMeta({
layout: 'dashboard',
middleware: ['auth'],
title: '대시보드',
})
// 이후 일반 로직
const { data } = await useAsyncData('dashboard-stats', () =>
$fetch('/api/stats')
)
</script>
<template>
<main>
<h1>대시보드</h1>
</main>
</template>
DON'T: 잘못된 definePageMeta 위치
<script setup lang="ts">
const count = ref(0)
// 금지: 최상단이 아닌 곳에 위치
definePageMeta({
layout: 'dashboard',
})
</script>
2-3. SEO 메타 설정
모든 페이지는 useSeoMeta() 또는 useHead()로 메타 정보를 설정한다.
DO: 올바른 SEO 설정
<script setup lang="ts">
definePageMeta({
title: '상품 목록',
})
// 반응형 SEO 메타
useSeoMeta({
title: '상품 목록 | MyStore',
description: '다양한 상품을 만나보세요',
ogTitle: '상품 목록 | MyStore',
ogDescription: '다양한 상품을 만나보세요',
ogImage: '/og-image.jpg',
})
</script>
DO: 동적 SEO 설정
<script setup lang="ts">
const route = useRoute()
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 3: 컴포저블 (Composables)
3-1. 컴포저블 작성 패턴
컴포저블은 use prefix로 시작하고, Vue 반응형 시스템과 라이프사이클에 의존한다.
DO: 올바른 컴포저블 구조
// 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: 안티 패턴
// 금지: 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 라우트에서만 사용한다. 컴포저블과 혼용 금지.
// 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 패턴
// 패턴: '엔티티-액션-파라미터'
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
// 금지: 범용적인 key는 다른 페이지/컴포넌트와 충돌 가능
const { data } = await useAsyncData('data', () => $fetch('/api/products'))
const { data } = await useAsyncData('list', () => $fetch('/api/users'))
4-3. 에러 처리 & 로딩 상태
DO: 올바른 에러/로딩 처리
<script setup lang="ts">
const { data: products, status, error, refresh } = await useAsyncData(
'product-list',
() => $fetch<Product[]>('/api/products'),
{
default: () => [] as Product[], // 기본값으로 타입 안정성 확보
}
)
</script>
<template>
<div>
<div v-if="status === 'pending'" class="flex justify-center py-8">
<LoadingSpinner />
</div>
<div v-else-if="error" class="text-destructive">
<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>
</div>
</template>
DON'T: 에러/로딩 처리 누락
<script setup lang="ts">
// 금지: 에러/로딩 상태 무시
const { data: products } = await useAsyncData('products', () => $fetch('/api/products'))
</script>
<template>
<!-- 로딩/에러 상태 없이 바로 렌더링 -->
<ul>
<li v-for="product in products" :key="product.id">{{ product.name }}</li>
</ul>
</template>
4-4. $fetch vs useFetch 선택
| 사용 위치 | 권장 방식 | 이유 |
|---|---|---|
<script setup> 최상단 (SSR) |
useFetch / useAsyncData |
SSR 중복 요청 방지, 하이드레이션 |
| 이벤트 핸들러 내부 | $fetch |
반응형/캐싱 불필요 |
| 서버 미들웨어/API | $fetch |
서버 컨텍스트 |
<script setup lang="ts">
// SSR에서 실행: useFetch/useAsyncData 사용
const { data: products } = await useAsyncData('products', () =>
$fetch<Product[]>('/api/products')
)
// 이벤트 핸들러: $fetch 직접 사용
async function handleSubmit(form: ProductForm) {
await $fetch('/api/products', { method: 'POST', body: form })
await refresh()
}
</script>
Rule 5: 상태관리 (Pinia)
5-1. Store 작성 패턴 (Composition API 스타일)
DO: Composition API 스타일
// app/stores/useAuthStore.ts
export const useAuthStore = defineStore('auth', () => {
// 상태
const user = ref<User | null>(null)
const token = ref<string | null>(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<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
}
// 초기화 (SSR 호환)
async function init() {
if (token.value) {
user.value = await $fetch<User>('/api/auth/me')
}
}
return { user: readonly(user), token: readonly(token), isAuthenticated, fullName, login, logout, init }
})
DON'T: Options API 스타일 (일관성 저해)
// 금지: Options API 스타일 혼용
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
doubled: (state) => state.count * 2,
},
actions: {
increment() { this.count++ },
},
})
5-2. Store 사용 규칙
<script setup lang="ts">
const authStore = useAuthStore()
// 구조분해 시 storeToRefs 사용 (반응성 유지)
const { user, isAuthenticated } = storeToRefs(authStore)
// 액션은 직접 구조분해 가능
const { login, logout } = authStore
</script>
Rule 6: 레이아웃
DO: 올바른 레이아웃 사용
<!-- 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>
<!-- app/pages/dashboard.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'dashboard', // layouts/dashboard.vue 적용
})
</script>
<!-- app/pages/auth/login.vue -->
<script setup lang="ts">
definePageMeta({
layout: false, // 레이아웃 비활성화 (전체 화면)
})
</script>
동적 레이아웃 변경
<script setup lang="ts">
const route = useRoute()
// 반응형으로 레이아웃 변경 (로그인 상태에 따라)
definePageMeta({
layout: 'default',
})
const authStore = useAuthStore()
// 인증 상태에 따른 레이아웃 동적 전환
watchEffect(() => {
setPageLayout(authStore.isAuthenticated ? 'dashboard' : 'default')
})
</script>
Rule 7: 미들웨어
미들웨어는 라우트 이동 전에 실행된다. navigateTo() 또는 abortNavigation()으로 흐름을 제어한다.
DO: 올바른 미들웨어 작성
// app/middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
// 로그인 후 원래 페이지로 돌아오기 위해 redirect 파라미터 추가
return navigateTo({
path: '/auth/login',
query: { redirect: to.fullPath },
})
}
})
// app/middleware/guest.ts (로그인한 사용자는 접근 불가)
export default defineNuxtRouteMiddleware(() => {
const authStore = useAuthStore()
if (authStore.isAuthenticated) {
return navigateTo('/dashboard')
}
})
// 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: 미들웨어 안티 패턴
// 금지: navigateTo 없이 window.location 사용 (SSR 오류 발생)
export default defineNuxtRouteMiddleware(() => {
window.location.href = '/login' // 서버에서 오류!
})
// 금지: 반환값 없는 조건부 리다이렉트 (미들웨어가 계속 실행됨)
export default defineNuxtRouteMiddleware((to) => {
if (!isAuthenticated) {
navigateTo('/login') // return 누락! 이후 코드도 계속 실행됨
}
// ... 이후 코드도 실행됨
})
Rule 8: 플러그인
플러그인은 앱 인스턴스 생성 시 한 번 실행된다. 전역 기능/서비스 등록에 사용한다.
DO: 올바른 플러그인 작성
// app/plugins/toast.client.ts (.client.ts: 클라이언트에서만 실행)
import { defineNuxtPlugin } from '#app'
export default defineNuxtPlugin(() => {
// provide로 전역 주입 (타입 선언 필수)
return {
provide: {
toast: {
success: (message: string) => { /* 구현 */ },
error: (message: string) => { /* 구현 */ },
},
},
}
})
// 플러그인 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 작성
// 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 }
})
// 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 안티 패턴
// 금지: 입력 유효성 검사 없이 바로 사용
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 사용
// 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',
},
},
})
// 서버 사이드에서 사용
// 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 직접 접근
// 금지: 클라이언트에서 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과 클라이언트 코드 혼용
// 금지: 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 사용
// 금지: 컴포저블/컴포넌트 설정 외부에서 사용
const globalRef = ref(0) // 모듈 레벨 → SSR에서 요청 간 공유됨!
// 올바른 예: Pinia store 또는 컴포저블 내부에서 사용
export const useGlobalCounter = defineStore('counter', () => {
const count = ref(0)
return { count }
})
Anti-Pattern 3: useAsyncData key 중복
// 금지: 같은 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 누락
// 금지: return 없으면 리다이렉트 후에도 계속 실행
export default defineNuxtRouteMiddleware(() => {
if (!isAuthenticated) {
navigateTo('/login') // return 없음 → 이후 코드 실행됨
}
// 인증 안 된 상태에서도 이 코드가 실행됨!
checkAdminPermission()
})
// 올바른 예
export default defineNuxtRouteMiddleware(() => {
if (!isAuthenticated) {
return navigateTo('/login') // return으로 즉시 종료
}
checkAdminPermission()
})
Anti-Pattern 5: 서버 API에서 입력값 무검증
// 금지: 사용자 입력 직접 사용
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 조건부 실행
<script setup lang="ts">
const isAdmin = computed(() => authStore.user?.role === 'admin')
// 금지: definePageMeta는 컴파일 타임에 정적으로 분석됨
if (isAdmin.value) {
definePageMeta({ layout: 'admin' })
}
// 올바른 예: 정적으로 선언 후 동적 변경
definePageMeta({ layout: 'default' })
watchEffect(() => {
if (isAdmin.value) setPageLayout('admin')
})
</script>
자주 하는 실수 TOP 5
1. useAsyncData key 미지정 또는 중복
key를 생략하거나 범용 이름을 사용하면 다른 컴포넌트의 데이터와 캐시가 충돌한다.
// 실수: 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 누락
직접 구조분해하면 반응성이 사라진다.
// 실수: 반응성 손실
const { user, isAuthenticated } = useAuthStore() // user는 더 이상 반응형이 아님
// 해결: storeToRefs로 ref 변환
const { user, isAuthenticated } = storeToRefs(useAuthStore())
3. 클라이언트 전용 API를 SSR에서 호출
window, document, localStorage 등은 서버에 없다.
// 실수: 서버에서 오류
const savedTheme = localStorage.getItem('theme')
// 해결: onMounted 또는 process.client 체크
onMounted(() => {
const savedTheme = localStorage.getItem('theme')
})
// 또는 플러그인 파일명에 .client.ts 사용
4. 미들웨어에서 navigateTo() return 누락
return 없으면 조건이 충족되어도 이후 코드가 계속 실행된다.
// 실수: 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가 된다.
// 실수
const apiUrl = process.env.API_URL // 클라이언트에서 undefined
// 해결: runtimeConfig 사용
const config = useRuntimeConfig()
const apiUrl = config.public.apiBase // 안전하게 클라이언트 접근 가능