Files
fe-agent/.claude/rules/frontend/nuxt.md
hyeonggil 5fe888c88f 📝 docs: Update CLAUDE.md and add frontend coding conventions
- Expanded CLAUDE.md with behavioral guidelines for LLM coding practices.
- Introduced new documents for frontend code style, Nuxt conventions, and testing conventions.
- Added detailed rules for email HTML structure and TailwindCSS styling strategy.
- Included a comprehensive EDM email HTML implementation guide.
2026-04-07 23:20:02 +09:00

11 KiB

paths
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 스타일로 작성되었는가?
  • <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            // 올바름