Files
fe-agent/docs/rules/nuxt-conventions.md
hyeonggil 234a84e3ee 📝 docs: 코딩 컨벤션 Rules 문서 추가 및 CLAUDE.md 설정
- 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: 마크업 컨벤션 종합 가이드
2026-04-07 22:13:37 +09:00

25 KiB

Nuxt 4 코딩 컨벤션 Rules

Nuxt 4 + Vue 3 + TypeScript strict 환경 기준 최종 업데이트: 2026-04-07


빠른 참조 체크리스트

코드 작성 전/리뷰 시 아래 항목을 확인한다.

  • 페이지 파일명이 kebab-case이고 app/pages/ 아래에 위치하는가?
  • 컴포저블 파일명이 use prefix를 가진 camelCase인가? (예: useUserProfile.ts)
  • useFetch / useAsyncData의 key가 고유한가? (중복 시 캐시 충돌)
  • useAsyncDatakey를 함수명 + 파라미터 조합으로 지정했는가?
  • 서버 전용 코드에 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.envundefined가 된다.

// 실수
const apiUrl = process.env.API_URL  // 클라이언트에서 undefined

// 해결: runtimeConfig 사용
const config = useRuntimeConfig()
const apiUrl = config.public.apiBase  // 안전하게 클라이언트 접근 가능