📝 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.
This commit is contained in:
115
.claude/rules/frontend/code-style.md
Normal file
115
.claude/rules/frontend/code-style.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 코드 스타일 & 네이밍 컨벤션
|
||||
|
||||
> Nuxt 4 + TypeScript strict 환경 기준
|
||||
> 최종 업데이트: 2026-04-07
|
||||
|
||||
---
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
```
|
||||
my-nuxt-app/
|
||||
├─ app/
|
||||
│ ├─ assets/
|
||||
│ ├─ components/ # PascalCase.vue
|
||||
│ │ └─ ui/ # shadcn-vue 컴포넌트
|
||||
│ ├─ composables/ # useXxx.ts
|
||||
│ ├─ layouts/ # kebab-case.vue
|
||||
│ ├─ middleware/ # kebab-case.ts
|
||||
│ ├─ pages/ # kebab-case.vue
|
||||
│ ├─ plugins/ # kebab-case.ts
|
||||
│ ├─ stores/ # useXxxStore.ts
|
||||
│ ├─ utils/ # camelCase.ts
|
||||
│ ├─ app.vue
|
||||
│ ├─ app.config.ts # 테마/UI 등 공개 설정값
|
||||
│ └─ error.vue
|
||||
├─ server/
|
||||
│ ├─ api/ # kebab-case.ts
|
||||
│ ├─ middleware/
|
||||
│ └─ utils/
|
||||
├─ shared/ # 클라이언트/서버 공유 타입 및 유틸
|
||||
├─ public/
|
||||
├─ nuxt.config.ts
|
||||
├─ tailwind.config.ts
|
||||
└─ tsconfig.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 네이밍 컨벤션
|
||||
|
||||
### 파일명
|
||||
|
||||
| 파일 종류 | 규칙 | 예시 |
|
||||
|-----------|------|------|
|
||||
| 컴포넌트 | `PascalCase.vue` | `UserProfile.vue` |
|
||||
| 페이지 / 레이아웃 / 미들웨어 / 플러그인 | `kebab-case` | `user-profile.vue` |
|
||||
| 컴포저블 | `useXxx.ts` | `useUserProfile.ts` |
|
||||
| 유틸 함수 | `camelCase.ts` | `formatDate.ts` |
|
||||
| Pinia store | `useXxxStore.ts` | `useAuthStore.ts` |
|
||||
| API 라우트 | `kebab-case.ts` | `user-profile.ts` |
|
||||
| 테스트 파일 | `*.spec.ts` / `*.test.ts` | `UserProfile.spec.ts` |
|
||||
|
||||
### 코드 내
|
||||
|
||||
| 대상 | 규칙 | 예시 |
|
||||
|------|------|------|
|
||||
| 변수 / 함수 | `camelCase` | `const userName`, `function fetchUser()` |
|
||||
| 컴포넌트명 | `PascalCase` | `UserProfile`, `AppHeader` |
|
||||
| **상수** | **`UPPER_SNAKE_CASE`** | `const MAX_RETRY_COUNT = 3` |
|
||||
| 타입 / 인터페이스 | `PascalCase` | `interface UserProfile` |
|
||||
|
||||
```typescript
|
||||
// DO
|
||||
const MAX_PAGE_SIZE = 100
|
||||
const DEFAULT_LOCALE = 'ko'
|
||||
|
||||
// DON'T
|
||||
const maxPageSize = 100 // 금지: 상수에 camelCase
|
||||
const defaultLocale = 'ko' // 금지
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TypeScript 엄격 모드
|
||||
|
||||
### any 타입 사용 금지
|
||||
|
||||
`any`는 타입 안전성을 무력화한다. `unknown` 또는 명시적 타입을 사용한다.
|
||||
|
||||
```typescript
|
||||
// DON'T
|
||||
async function fetchUser(id: any): Promise<any> {
|
||||
return await $fetch(`/api/users/${id}`)
|
||||
}
|
||||
|
||||
// DO
|
||||
async function fetchUser(id: string): Promise<User> {
|
||||
return await $fetch<User>(`/api/users/${id}`)
|
||||
}
|
||||
|
||||
// unknown 사용 시 타입 좁히기
|
||||
function parseResponse(raw: unknown): User {
|
||||
if (!isUser(raw)) throw new Error('유효하지 않은 응답입니다.')
|
||||
return raw
|
||||
}
|
||||
```
|
||||
|
||||
### Props 타입 명시
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// DO: interface로 분리 선언
|
||||
interface Props {
|
||||
title: string
|
||||
count: number
|
||||
variant?: 'primary' | 'secondary'
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'primary',
|
||||
})
|
||||
|
||||
// DON'T: 타입 없는 defineProps
|
||||
// const props = defineProps(['title', 'count'])
|
||||
</script>
|
||||
```
|
||||
449
.claude/rules/frontend/nuxt.md
Normal file
449
.claude/rules/frontend/nuxt.md
Normal file
@@ -0,0 +1,449 @@
|
||||
---
|
||||
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 — 반드시 최상단에
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 컴파일러 매크로: 최상단 필수
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
middleware: ['auth'],
|
||||
})
|
||||
|
||||
const { data } = await useAsyncData('dashboard-stats', () =>
|
||||
$fetch('/api/stats')
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- DON'T: 최상단이 아닌 위치, 조건부 실행 -->
|
||||
<script setup lang="ts">
|
||||
const count = ref(0)
|
||||
definePageMeta({ layout: 'dashboard' }) // 금지
|
||||
|
||||
if (isAdmin.value) {
|
||||
definePageMeta({ layout: 'admin' }) // 금지: 컴파일 타임 매크로
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### SEO 메타 설정
|
||||
|
||||
```vue
|
||||
<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 라우트 전용 |
|
||||
|
||||
### 올바른 컴포저블 구조
|
||||
|
||||
```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
|
||||
<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 스타일로 작성
|
||||
|
||||
```typescript
|
||||
// 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 }
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// DON'T: Options API 스타일
|
||||
export const useCounterStore = defineStore('counter', {
|
||||
state: () => ({ count: 0 }),
|
||||
actions: { increment() { this.count++ } },
|
||||
})
|
||||
```
|
||||
|
||||
### 구조분해 시 storeToRefs 필수
|
||||
|
||||
```vue
|
||||
<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: 레이아웃
|
||||
|
||||
```vue
|
||||
<!-- 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>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- 동적 레이아웃 변경 -->
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'default' })
|
||||
|
||||
const authStore = useAuthStore()
|
||||
watchEffect(() => {
|
||||
setPageLayout(authStore.isAuthenticated ? 'dashboard' : 'default')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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 // 올바름
|
||||
```
|
||||
119
.claude/rules/frontend/testing.md
Normal file
119
.claude/rules/frontend/testing.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
paths:
|
||||
- "**/*.spec.ts"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.spec.vue"
|
||||
- "**/*.test.vue"
|
||||
---
|
||||
|
||||
# 테스팅 컨벤션
|
||||
|
||||
> Vitest + @nuxt/test-utils 환경 기준
|
||||
> 최종 업데이트: 2026-04-07
|
||||
|
||||
---
|
||||
|
||||
## 테스트 파일 위치
|
||||
|
||||
테스트 파일은 대상 파일과 같은 디렉토리에 위치시킨다.
|
||||
|
||||
| 테스트 대상 | 위치 |
|
||||
|------------|------|
|
||||
| 컴포넌트 | `app/components/UserProfile.spec.ts` |
|
||||
| 컴포저블 | `app/composables/useAuth.spec.ts` |
|
||||
| 유틸 함수 | `app/utils/formatDate.spec.ts` |
|
||||
| 서버 API | `server/api/products/index.spec.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 환경 설정
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
import { defineVitestConfig } from '@nuxt/test-utils/config'
|
||||
|
||||
export default defineVitestConfig({
|
||||
test: {
|
||||
environment: 'nuxt',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 테스트
|
||||
|
||||
```typescript
|
||||
// app/components/UserProfile.spec.ts
|
||||
import { mountSuspended } from '@nuxt/test-utils/runtime'
|
||||
import UserProfile from './UserProfile.vue'
|
||||
|
||||
describe('UserProfile', () => {
|
||||
it('사용자 이름을 렌더링한다', async () => {
|
||||
const wrapper = await mountSuspended(UserProfile, {
|
||||
props: { user: { id: '1', firstName: '길동', lastName: '홍' } },
|
||||
})
|
||||
expect(wrapper.text()).toContain('홍길동')
|
||||
})
|
||||
|
||||
it('관리자 배지를 조건부로 표시한다', async () => {
|
||||
const wrapper = await mountSuspended(UserProfile, {
|
||||
props: { user: { id: '1', firstName: '길동', lastName: '홍', role: 'admin' } },
|
||||
})
|
||||
expect(wrapper.find('[data-testid="admin-badge"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 컴포저블 테스트
|
||||
|
||||
```typescript
|
||||
// app/composables/useCounter.spec.ts
|
||||
import { useCounter } from './useCounter'
|
||||
|
||||
describe('useCounter', () => {
|
||||
it('초기값으로 시작한다', () => {
|
||||
const { count } = useCounter(5)
|
||||
expect(count.value).toBe(5)
|
||||
})
|
||||
|
||||
it('increment가 count를 증가시킨다', () => {
|
||||
const { count, increment } = useCounter(0)
|
||||
increment()
|
||||
expect(count.value).toBe(1)
|
||||
})
|
||||
|
||||
it('reset이 초기값으로 되돌린다', () => {
|
||||
const { count, increment, reset } = useCounter(0)
|
||||
increment(3)
|
||||
reset()
|
||||
expect(count.value).toBe(0)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 서버 API 테스트
|
||||
|
||||
```typescript
|
||||
// server/api/products/index.spec.ts
|
||||
import { setup, $fetch } from '@nuxt/test-utils'
|
||||
|
||||
describe('GET /api/products', async () => {
|
||||
await setup()
|
||||
|
||||
it('상품 목록을 반환한다', async () => {
|
||||
const response = await $fetch('/api/products')
|
||||
expect(response).toHaveProperty('products')
|
||||
expect(Array.isArray(response.products)).toBe(true)
|
||||
})
|
||||
|
||||
it('page 파라미터를 처리한다', async () => {
|
||||
const response = await $fetch('/api/products?page=2')
|
||||
expect(response.page).toBe(2)
|
||||
})
|
||||
})
|
||||
```
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
paths:
|
||||
- "**/*.html"
|
||||
---
|
||||
|
||||
# 이메일 발송용 HTML Table 코딩 Rules
|
||||
|
||||
> Gmail, Naver Mail, Outlook 등 주요 이메일 클라이언트 호환 기준
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
paths:
|
||||
- "app/**/*.vue"
|
||||
---
|
||||
|
||||
# HTML 구조 Rules
|
||||
|
||||
> Nuxt 4 + Vue 3 + TypeScript 환경 기준
|
||||
@@ -1,3 +1,9 @@
|
||||
---
|
||||
paths:
|
||||
- "app/**/*.vue"
|
||||
- "app/assets/**/*.css"
|
||||
---
|
||||
|
||||
# TailwindCSS v4 스타일링 전략 Rules
|
||||
|
||||
> Nuxt 4 + Vue 3 + TailwindCSS v4 + shadcn-vue 환경 기준
|
||||
310
.claude/skills/edm-email-html/SKILL.md
Normal file
310
.claude/skills/edm-email-html/SKILL.md
Normal file
@@ -0,0 +1,310 @@
|
||||
---
|
||||
name: edm-email-html
|
||||
description: |
|
||||
EDM(이메일 다이렉트 마케팅) HTML을 구현하는 전체 워크플로우 스킬.
|
||||
Figma 디자인 → HTML table 마크업 → 아웃룩 호환 → 검수까지 단계별 가이드를 제공합니다.
|
||||
|
||||
다음 상황에서 반드시 사용하세요:
|
||||
- "EDM 만들어줘", "이메일 템플릿 구현", "뉴스레터 HTML"
|
||||
- "아웃룩에서 깨지는 이메일 수정", "이메일 HTML 마크업"
|
||||
- Figma 디자인을 받고 이메일 HTML로 변환할 때
|
||||
- "메일 발송용 HTML", "eDM 퍼블리싱", "HTML 이메일"
|
||||
- 이메일 클라이언트 호환성 문제가 있을 때
|
||||
---
|
||||
|
||||
# EDM HTML 구현 가이드
|
||||
|
||||
이메일 HTML은 일반 웹과 다른 세계입니다. 2000년대 테이블 코딩이 아직도 정답이며, Flexbox와 Grid는 쓸 수 없습니다. 이 스킬은 Figma 디자인에서 시작해 모든 이메일 클라이언트에서 깨지지 않는 HTML을 만드는 과정을 안내합니다.
|
||||
|
||||
## 워크플로우
|
||||
|
||||
```
|
||||
1. Figma 디자인 파악 → 2. HTML 마크업 → 3. 아웃룩 호환 → 4. 검수
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Figma 디자인 파악
|
||||
|
||||
### Figma MCP 사용 가능 시
|
||||
Claude Code에 Figma MCP가 설정되어 있다면 Figma URL로 직접 디자인 데이터를 읽을 수 있습니다. MCP가 연결되어 있는지 먼저 확인하고, 가능하다면 자동 추출을 시도하세요.
|
||||
|
||||
추출 가능한 속성:
|
||||
- 컬러 HEX값 (RGBA → HEX 자동 변환)
|
||||
- 폰트 패밀리, 사이즈(px), 굵기, 줄간격
|
||||
- 레이아웃 치수: 너비, 높이, padding, 섹션 간격
|
||||
- 이미지 에셋 URL (CDN 업로드 필요)
|
||||
- CTA 링크 (레이어 설명 필드에서 추출)
|
||||
|
||||
### Figma MCP 없이 진행 시
|
||||
사용자에게 다음 정보를 요청하거나 스크린샷으로 파악하세요.
|
||||
|
||||
**필수 확인 항목:**
|
||||
- 전체 이메일 너비 (권장: **600px**)
|
||||
- 각 섹션 배경색, 텍스트 색상 (HEX)
|
||||
- 폰트: 패밀리, 사이즈(px), 굵기, 줄간격
|
||||
- 이미지: 가로×세로(px)
|
||||
- 여백: 섹션 간 간격, 좌우 패딩
|
||||
- CTA 버튼: 크기, 색상, 텍스트, 링크 URL
|
||||
- 푸터: 회사 정보, 수신거부 링크
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: HTML table 마크업
|
||||
|
||||
### 절대 원칙
|
||||
|
||||
이메일 HTML에서 반드시 지켜야 하는 규칙들입니다. 이 규칙을 어기면 특정 클라이언트에서 레이아웃이 무너집니다:
|
||||
|
||||
| 규칙 | 이유 |
|
||||
|------|------|
|
||||
| `table`, `tr`, `td`만 레이아웃에 사용 | div는 Outlook 등에서 무시됨 |
|
||||
| inline CSS 우선 | Gmail이 `<head>` style 태그를 제거함 |
|
||||
| `width`/`height` 속성 필수 | CSS만으론 Outlook이 무시함 |
|
||||
| `margin` 사용 금지 | 빈 `<tr>`행이나 `padding`으로 대체 |
|
||||
| `padding` 개별 속성 사용 | 단축 속성(`padding: 10px 20px`)은 일부 클라이언트 미지원 |
|
||||
| 모든 `<table>`에 `cellpadding="0" cellspacing="0" border="0"` | 브라우저 기본 스타일 초기화 |
|
||||
|
||||
### 기본 템플릿
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no">
|
||||
<title>이메일 제목</title>
|
||||
<style type="text/css">
|
||||
body { margin: 0; padding: 0; width: 100%; background-color: #f5f5f5; }
|
||||
table { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { display: block; border: 0; outline: none; text-decoration: none; }
|
||||
|
||||
/* 미디어쿼리는 여기서만 (Outlook은 무시하지만 Gmail/Apple Mail에서 적용) */
|
||||
@media only screen and (max-width: 600px) {
|
||||
.mobile-full { width: 100% !important; display: block !important; }
|
||||
.mobile-padding { padding-left: 16px !important; padding-right: 16px !important; }
|
||||
.mobile-center { text-align: center !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f5f5;">
|
||||
|
||||
<!-- 외부 래퍼: 배경색과 수평 중앙 정렬 -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f5f5f5">
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 20px; padding-bottom: 20px;">
|
||||
|
||||
<!-- 600px 컨테이너 -->
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0"
|
||||
style="width: 600px; max-width: 100%; background-color: #ffffff;">
|
||||
|
||||
<!-- 헤더 -->
|
||||
<tr>
|
||||
<td style="padding-top: 0;">
|
||||
<!-- 로고 이미지 등 -->
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 본문 -->
|
||||
<tr>
|
||||
<td style="padding-top: 30px; padding-bottom: 30px;
|
||||
padding-left: 30px; padding-right: 30px;">
|
||||
<!-- 메인 콘텐츠 -->
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<tr>
|
||||
<td bgcolor="#f5f5f5"
|
||||
style="background-color: #f5f5f5;
|
||||
padding-top: 20px; padding-bottom: 20px;
|
||||
padding-left: 20px; padding-right: 20px;
|
||||
text-align: center;">
|
||||
<!-- 회사 정보 + 수신거부 링크 (필수) -->
|
||||
<p style="font-family: Arial, sans-serif; font-size: 12px;
|
||||
color: #999999; margin: 0; line-height: 1.5;">
|
||||
회사명 | 주소<br>
|
||||
<a href="[수신거부URL]"
|
||||
style="color: #999999; text-decoration: underline;">
|
||||
수신거부
|
||||
</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### 안전한 폰트
|
||||
|
||||
웹폰트(`@font-face`, Google Fonts)는 대부분의 이메일 클라이언트에서 지원하지 않습니다. Pretendard, Noto Sans KR 같은 폰트를 Figma에서 사용했어도 이메일에서는 안전 폰트로 대체해야 합니다.
|
||||
|
||||
```css
|
||||
/* 권장 스택 (한국어 이메일) */
|
||||
font-family: -apple-system, Arial, 'Helvetica Neue', Helvetica, sans-serif;
|
||||
|
||||
/* Outlook 전용 (MSO 조건부 주석 내) */
|
||||
font-family: Arial, sans-serif;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 아웃룩 호환성
|
||||
|
||||
아웃룩 2007~2019는 Word 엔진으로 이메일을 렌더링해서 현대 CSS를 거의 무시합니다. MSO 조건부 주석으로 아웃룩과 그 외 클라이언트를 분리해서 처리하세요.
|
||||
|
||||
### MSO 조건부 주석
|
||||
|
||||
```html
|
||||
<!--[if mso]>
|
||||
<!-- 아웃룩에서만 렌더링 -->
|
||||
<![endif]-->
|
||||
|
||||
<!--[if !mso]><!-->
|
||||
<!-- 아웃룩 제외 클라이언트에서 렌더링 -->
|
||||
<!--<![endif]-->
|
||||
```
|
||||
|
||||
### 아웃룩이 무시하는 주요 속성
|
||||
|
||||
| CSS 속성 | 아웃룩 동작 | 대체 방법 |
|
||||
|----------|-----------|---------|
|
||||
| `background-image` | 미지원 | `<img>` 태그 직접 사용 |
|
||||
| `border-radius` | 무시 | VML 사용 또는 이미지 버튼 |
|
||||
| `margin` | 무시 | `padding` 또는 빈 `<tr>` 행 |
|
||||
| `box-shadow` | 무시 | 포기 또는 이미지로 대체 |
|
||||
| `@media query` | 2007/2010 미지원 | 테이블 고정폭으로 데스크톱 설계 |
|
||||
|
||||
### VML 버튼 (반드시 사용)
|
||||
|
||||
아웃룩에서 CSS 버튼은 배경색 없는 텍스트 링크로 표시됩니다. CTA 버튼은 항상 VML을 포함하세요:
|
||||
|
||||
```html
|
||||
<div style="text-align: center;
|
||||
padding-top: 20px; padding-bottom: 20px;">
|
||||
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||
href="https://example.com"
|
||||
style="height: 44px; v-text-anchor: middle; width: 200px;"
|
||||
arcsize="5%"
|
||||
stroke="f"
|
||||
fillcolor="#FF6B6B">
|
||||
<w:anchorlock/>
|
||||
<center style="color: #ffffff; font-family: Arial, sans-serif;
|
||||
font-size: 16px; font-weight: bold;">
|
||||
지금 확인하기
|
||||
</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
|
||||
<!--[if !mso]><!-->
|
||||
<a href="https://example.com"
|
||||
style="background-color: #FF6B6B;
|
||||
color: #ffffff;
|
||||
display: inline-block;
|
||||
padding-top: 12px; padding-bottom: 12px;
|
||||
padding-left: 30px; padding-right: 30px;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: bold;">
|
||||
지금 확인하기
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
|
||||
</div>
|
||||
```
|
||||
|
||||
### 이미지 처리
|
||||
|
||||
이미지 차단 시에도 레이아웃이 깨지지 않도록 `alt` 텍스트와 배경색을 함께 지정하세요:
|
||||
|
||||
```html
|
||||
<td bgcolor="#FF6B6B" style="background-color: #FF6B6B;">
|
||||
<img src="https://cdn.example.com/banner.jpg"
|
||||
alt="7월 여름 세일 최대 50% 할인"
|
||||
width="600"
|
||||
height="300"
|
||||
style="display: block; width: 100%; max-width: 600px;
|
||||
height: auto; border: 0;">
|
||||
</td>
|
||||
```
|
||||
|
||||
이미지는 반드시 `https://` CDN 절대 경로를 사용하세요. 로컬 경로나 상대 경로는 이메일에서 작동하지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 검수 체크리스트
|
||||
|
||||
### 코드 구조 (필수)
|
||||
- [ ] 모든 `<table>`에 `cellpadding="0" cellspacing="0" border="0"`
|
||||
- [ ] 모든 `<img>`에 `width`, `height`, `alt` 속성
|
||||
- [ ] `margin` 미사용 (padding 또는 빈 `<tr>` 행으로 대체)
|
||||
- [ ] `padding` 단축 속성 제거 (개별 속성 사용)
|
||||
- [ ] CTA 버튼에 VML 코드 포함
|
||||
- [ ] 이미지 `src`가 HTTPS 절대 URL
|
||||
|
||||
### 콘텐츠 (필수)
|
||||
- [ ] 푸터에 수신거부 링크 포함
|
||||
- [ ] 모든 링크 href 유효성 확인
|
||||
- [ ] 이미지 alt 텍스트 의미있게 작성 (장식용이면 `alt=""`)
|
||||
|
||||
### Figma 디자인 대비 검수
|
||||
- [ ] 전체 너비 600px
|
||||
- [ ] 색상 HEX값 일치
|
||||
- [ ] 폰트 사이즈, 굵기 일치
|
||||
- [ ] 버튼 크기, 색상 일치
|
||||
- [ ] 섹션 간 여백 일치
|
||||
|
||||
### 테스트 도구
|
||||
|
||||
| 도구 | 용도 | 비용 |
|
||||
|------|------|------|
|
||||
| [Litmus](https://www.litmus.com) | 100+ 클라이언트 렌더링 미리보기 | 유료 |
|
||||
| [Email on Acid](https://www.emailonacid.com) | 크로스 클라이언트 + 접근성 감사 | 유료 |
|
||||
| [Mailtrap](https://mailtrap.io) | 개발 환경 샌드박스, 스팸 점수 | 무료 플랜 |
|
||||
| [SpamTest.io](https://spamtest.io/) | 스팸 점수, SPF/DKIM/DMARC 확인 | 무료 |
|
||||
|
||||
**최소 테스트 클라이언트:** Gmail 웹, Outlook (Windows), Apple Mail, 모바일 Gmail
|
||||
|
||||
---
|
||||
|
||||
## 2컬럼 레이아웃 예시
|
||||
|
||||
```html
|
||||
<!-- 데스크톱: 2열 | 모바일: 스택 -->
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0"
|
||||
style="width: 600px;">
|
||||
<tr>
|
||||
<td width="280" valign="top"
|
||||
style="width: 280px; padding-right: 20px;"
|
||||
class="mobile-full">
|
||||
<!-- 왼쪽 -->
|
||||
</td>
|
||||
<td width="280" valign="top"
|
||||
style="width: 280px; padding-left: 20px;"
|
||||
class="mobile-full">
|
||||
<!-- 오른쪽 -->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
상세 내용은 references 폴더를 참조하세요:
|
||||
- `references/html-patterns.md` — 헤더/푸터/버튼/이미지 완성 코드 패턴
|
||||
- `references/verification-checklist.md` — 전체 검수 체크리스트 (시각적/기능/스팸)
|
||||
24
.claude/skills/edm-email-html/assets/example_asset.txt
Normal file
24
.claude/skills/edm-email-html/assets/example_asset.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
# Example Asset File
|
||||
|
||||
This placeholder represents where asset files would be stored.
|
||||
Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed.
|
||||
|
||||
Asset files are NOT intended to be loaded into context, but rather used within
|
||||
the output Claude produces.
|
||||
|
||||
Example asset files from other skills:
|
||||
- Brand guidelines: logo.png, slides_template.pptx
|
||||
- Frontend builder: hello-world/ directory with HTML/React boilerplate
|
||||
- Typography: custom-font.ttf, font-family.woff2
|
||||
- Data: sample_data.csv, test_dataset.json
|
||||
|
||||
## Common Asset Types
|
||||
|
||||
- Templates: .pptx, .docx, boilerplate directories
|
||||
- Images: .png, .jpg, .svg, .gif
|
||||
- Fonts: .ttf, .otf, .woff, .woff2
|
||||
- Boilerplate code: Project directories, starter files
|
||||
- Icons: .ico, .svg
|
||||
- Data files: .csv, .json, .xml, .yaml
|
||||
|
||||
Note: This is a text placeholder. Actual assets can be any file type.
|
||||
34
.claude/skills/edm-email-html/references/api_reference.md
Normal file
34
.claude/skills/edm-email-html/references/api_reference.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Reference Documentation for Edm Email Html
|
||||
|
||||
This is a placeholder for detailed reference documentation.
|
||||
Replace with actual reference content or delete if not needed.
|
||||
|
||||
Example real reference docs from other skills:
|
||||
- product-management/references/communication.md - Comprehensive guide for status updates
|
||||
- product-management/references/context_building.md - Deep-dive on gathering context
|
||||
- bigquery/references/ - API references and query examples
|
||||
|
||||
## When Reference Docs Are Useful
|
||||
|
||||
Reference docs are ideal for:
|
||||
- Comprehensive API documentation
|
||||
- Detailed workflow guides
|
||||
- Complex multi-step processes
|
||||
- Information too lengthy for main SKILL.md
|
||||
- Content that's only needed for specific use cases
|
||||
|
||||
## Structure Suggestions
|
||||
|
||||
### API Reference Example
|
||||
- Overview
|
||||
- Authentication
|
||||
- Endpoints with examples
|
||||
- Error codes
|
||||
- Rate limits
|
||||
|
||||
### Workflow Guide Example
|
||||
- Prerequisites
|
||||
- Step-by-step instructions
|
||||
- Common patterns
|
||||
- Troubleshooting
|
||||
- Best practices
|
||||
327
.claude/skills/edm-email-html/references/html-patterns.md
Normal file
327
.claude/skills/edm-email-html/references/html-patterns.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# EDM HTML 코드 패턴 모음
|
||||
|
||||
---
|
||||
|
||||
## 1컬럼 레이아웃 (전체 템플릿)
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no">
|
||||
<title>이메일 제목</title>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
body, table, td, p, a { font-family: Arial, sans-serif !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<style type="text/css">
|
||||
body { margin: 0; padding: 0; width: 100%; background-color: #f5f5f5; }
|
||||
table { border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
||||
img { display: block; border: 0; outline: none; text-decoration: none; }
|
||||
a { color: inherit; }
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.container { width: 100% !important; }
|
||||
.mobile-full { width: 100% !important; display: block !important; }
|
||||
.mobile-padding { padding-left: 16px !important; padding-right: 16px !important; }
|
||||
.mobile-center { text-align: center !important; }
|
||||
.mobile-img { width: 100% !important; height: auto !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f5f5f5;">
|
||||
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f5f5f5">
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 20px; padding-bottom: 20px;">
|
||||
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0"
|
||||
class="container"
|
||||
style="width: 600px; max-width: 100%; background-color: #ffffff;">
|
||||
|
||||
<!-- 헤더 -->
|
||||
<tr>
|
||||
<td align="center"
|
||||
style="padding-top: 24px; padding-bottom: 24px;
|
||||
padding-left: 30px; padding-right: 30px;
|
||||
border-bottom: 1px solid #e5e7eb;">
|
||||
<img src="https://cdn.example.com/logo.png"
|
||||
alt="회사 로고" width="120" height="40"
|
||||
style="display: block; border: 0;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 히어로 이미지 -->
|
||||
<tr>
|
||||
<td style="padding: 0; line-height: 0;">
|
||||
<img src="https://cdn.example.com/hero.jpg"
|
||||
alt="이벤트 배너"
|
||||
width="600" height="280"
|
||||
class="mobile-img"
|
||||
style="display: block; width: 100%; max-width: 600px;
|
||||
height: auto; border: 0;">
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 본문 -->
|
||||
<tr>
|
||||
<td style="padding-top: 32px; padding-bottom: 32px;
|
||||
padding-left: 32px; padding-right: 32px;"
|
||||
class="mobile-padding">
|
||||
<h1 style="font-family: Arial, sans-serif;
|
||||
font-size: 24px; font-weight: bold;
|
||||
color: #111827; line-height: 1.3;
|
||||
margin: 0 0 16px 0;">
|
||||
이메일 제목이 여기 들어갑니다
|
||||
</h1>
|
||||
<p style="font-family: Arial, sans-serif;
|
||||
font-size: 15px; color: #374151;
|
||||
line-height: 1.7;
|
||||
margin: 0 0 24px 0;">
|
||||
본문 내용이 여기 들어갑니다. 가독성을 위해 line-height를
|
||||
1.5 이상으로 설정하는 것이 좋습니다.
|
||||
</p>
|
||||
|
||||
<!-- CTA 버튼 -->
|
||||
<div style="text-align: center;">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||
href="https://example.com"
|
||||
style="height: 48px; v-text-anchor: middle; width: 200px;"
|
||||
arcsize="8%" stroke="f" fillcolor="#1a56db">
|
||||
<w:anchorlock/>
|
||||
<center style="color: #ffffff; font-family: Arial, sans-serif;
|
||||
font-size: 16px; font-weight: bold;">
|
||||
자세히 보기
|
||||
</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<a href="https://example.com"
|
||||
style="background-color: #1a56db; color: #ffffff;
|
||||
display: inline-block;
|
||||
padding-top: 14px; padding-bottom: 14px;
|
||||
padding-left: 32px; padding-right: 32px;
|
||||
text-decoration: none; border-radius: 6px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 16px; font-weight: bold;">
|
||||
자세히 보기
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<tr>
|
||||
<td bgcolor="#f9fafb"
|
||||
style="background-color: #f9fafb;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 24px; padding-bottom: 24px;
|
||||
padding-left: 32px; padding-right: 32px;
|
||||
text-align: center;">
|
||||
<p style="font-family: Arial, sans-serif; font-size: 12px;
|
||||
color: #9ca3af; line-height: 1.6; margin: 0 0 8px 0;">
|
||||
<strong>회사명</strong> | 서울시 강남구 테헤란로 123
|
||||
</p>
|
||||
<p style="font-family: Arial, sans-serif; font-size: 12px;
|
||||
color: #9ca3af; line-height: 1.6; margin: 0;">
|
||||
<a href="https://example.com/unsubscribe"
|
||||
style="color: #9ca3af; text-decoration: underline;">
|
||||
수신거부
|
||||
</a>
|
||||
|
|
||||
<a href="https://example.com/privacy"
|
||||
style="color: #9ca3af; text-decoration: underline;">
|
||||
개인정보처리방침
|
||||
</a>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2컬럼 이미지 + 텍스트
|
||||
|
||||
```html
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0"
|
||||
style="width: 600px;">
|
||||
<tr>
|
||||
<!-- 이미지 열 (40%) -->
|
||||
<td width="220" valign="top"
|
||||
style="width: 220px; padding-right: 0;"
|
||||
class="mobile-full">
|
||||
<img src="https://cdn.example.com/product.jpg"
|
||||
alt="상품명" width="220" height="220"
|
||||
class="mobile-img"
|
||||
style="display: block; width: 100%; height: auto; border: 0;">
|
||||
</td>
|
||||
|
||||
<!-- 간격 -->
|
||||
<td width="20" style="width: 20px; min-width: 20px;"> </td>
|
||||
|
||||
<!-- 텍스트 열 (60%) -->
|
||||
<td width="360" valign="top"
|
||||
style="width: 360px; padding-top: 8px;"
|
||||
class="mobile-full mobile-padding">
|
||||
<h2 style="font-family: Arial, sans-serif;
|
||||
font-size: 18px; font-weight: bold;
|
||||
color: #111827; margin: 0 0 8px 0;
|
||||
line-height: 1.3;">
|
||||
상품명
|
||||
</h2>
|
||||
<p style="font-family: Arial, sans-serif; font-size: 14px;
|
||||
color: #6b7280; line-height: 1.6;
|
||||
margin: 0 0 16px 0;">
|
||||
상품 설명이 들어갑니다. 간결하게 핵심만 작성하세요.
|
||||
</p>
|
||||
<p style="font-family: Arial, sans-serif; font-size: 20px;
|
||||
font-weight: bold; color: #ef4444;
|
||||
margin: 0 0 16px 0;">
|
||||
₩29,900
|
||||
</p>
|
||||
<a href="https://example.com/product"
|
||||
style="background-color: #111827; color: #ffffff;
|
||||
display: inline-block;
|
||||
padding-top: 10px; padding-bottom: 10px;
|
||||
padding-left: 20px; padding-right: 20px;
|
||||
text-decoration: none; border-radius: 4px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 13px; font-weight: bold;">
|
||||
구매하기
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 헤더 배너 (이미지 기반)
|
||||
|
||||
이미지가 차단됐을 때도 배경색이 보이도록 `bgcolor` 속성을 함께 지정합니다:
|
||||
|
||||
```html
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0"
|
||||
style="width: 600px;">
|
||||
<tr>
|
||||
<td bgcolor="#1a56db" style="background-color: #1a56db; line-height: 0; padding: 0;">
|
||||
<img src="https://cdn.example.com/header-banner.jpg"
|
||||
alt="여름 세일 최대 70% 할인"
|
||||
width="600" height="240"
|
||||
style="display: block; width: 100%; max-width: 600px;
|
||||
height: auto; border: 0;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 섹션 구분선
|
||||
|
||||
```html
|
||||
<!-- 섹션 간 여백 -->
|
||||
<tr>
|
||||
<td height="32" style="height: 32px; line-height: 32px;"> </td>
|
||||
</tr>
|
||||
|
||||
<!-- 수평선 -->
|
||||
<tr>
|
||||
<td style="padding-left: 32px; padding-right: 32px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td height="1" bgcolor="#e5e7eb"
|
||||
style="height: 1px; background-color: #e5e7eb; line-height: 1px;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 아웃라인(외곽선) 버튼
|
||||
|
||||
```html
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml"
|
||||
xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||
href="https://example.com"
|
||||
style="height: 44px; v-text-anchor: middle; width: 180px;"
|
||||
arcsize="5%"
|
||||
stroke="t"
|
||||
strokeweight="2px"
|
||||
strokecolor="#1a56db"
|
||||
fillcolor="#ffffff">
|
||||
<w:anchorlock/>
|
||||
<center style="color: #1a56db; font-family: Arial, sans-serif;
|
||||
font-size: 14px; font-weight: bold;">
|
||||
더 알아보기
|
||||
</center>
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<a href="https://example.com"
|
||||
style="background-color: #ffffff;
|
||||
color: #1a56db;
|
||||
display: inline-block;
|
||||
padding-top: 12px; padding-bottom: 12px;
|
||||
padding-left: 24px; padding-right: 24px;
|
||||
text-decoration: none;
|
||||
border: 2px solid #1a56db;
|
||||
border-radius: 5px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: bold;">
|
||||
더 알아보기
|
||||
</a>
|
||||
<!--<![endif]-->
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 소셜 아이콘 행
|
||||
|
||||
```html
|
||||
<table cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="padding-right: 8px;">
|
||||
<a href="https://instagram.com/example" style="text-decoration: none;">
|
||||
<img src="https://cdn.example.com/icon-instagram.png"
|
||||
alt="Instagram" width="32" height="32"
|
||||
style="display: block; border: 0;">
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding-right: 8px;">
|
||||
<a href="https://facebook.com/example" style="text-decoration: none;">
|
||||
<img src="https://cdn.example.com/icon-facebook.png"
|
||||
alt="Facebook" width="32" height="32"
|
||||
style="display: block; border: 0;">
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://youtube.com/example" style="text-decoration: none;">
|
||||
<img src="https://cdn.example.com/icon-youtube.png"
|
||||
alt="YouTube" width="32" height="32"
|
||||
style="display: block; border: 0;">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
```
|
||||
19
.claude/skills/edm-email-html/scripts/example.py
Executable file
19
.claude/skills/edm-email-html/scripts/example.py
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example helper script for edm-email-html
|
||||
|
||||
This is a placeholder script that can be executed directly.
|
||||
Replace with actual implementation or delete if not needed.
|
||||
|
||||
Example real scripts from other skills:
|
||||
- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
|
||||
- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
|
||||
"""
|
||||
|
||||
def main():
|
||||
print("This is an example script for edm-email-html")
|
||||
# TODO: Add actual script logic here
|
||||
# This could be data processing, file conversion, API calls, etc.
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
76
CLAUDE.md
76
CLAUDE.md
@@ -1,9 +1,71 @@
|
||||
# fe-agent 프로젝트 지침
|
||||
# CLAUDE.md
|
||||
|
||||
## 마크업 컨벤션
|
||||
@docs/rules/html-structure.md
|
||||
@docs/rules/tailwindcss-strategy.md
|
||||
@docs/rules/email-html-table.md
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
Before implementing:
|
||||
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
## Nuxt 코딩 컨벤션
|
||||
@docs/rules/nuxt-conventions.md
|
||||
|
||||
@@ -1,964 +0,0 @@
|
||||
# Nuxt 4 코딩 컨벤션 Rules
|
||||
|
||||
> Nuxt 4 + Vue 3 + TypeScript strict 환경 기준
|
||||
> 최종 업데이트: 2026-04-07
|
||||
|
||||
---
|
||||
|
||||
## 빠른 참조 체크리스트
|
||||
|
||||
코드 작성 전/리뷰 시 아래 항목을 확인한다.
|
||||
|
||||
- [ ] 페이지 파일명이 `kebab-case`이고 `app/pages/` 아래에 위치하는가?
|
||||
- [ ] 컴포저블 파일명이 `use` prefix를 가진 `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: 올바른 페이지 메타 설정
|
||||
|
||||
```vue
|
||||
<!-- 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 위치
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const count = ref(0)
|
||||
|
||||
// 금지: 최상단이 아닌 곳에 위치
|
||||
definePageMeta({
|
||||
layout: 'dashboard',
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
#### 2-3. SEO 메타 설정
|
||||
|
||||
모든 페이지는 `useSeoMeta()` 또는 `useHead()`로 메타 정보를 설정한다.
|
||||
|
||||
##### DO: 올바른 SEO 설정
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
title: '상품 목록',
|
||||
})
|
||||
|
||||
// 반응형 SEO 메타
|
||||
useSeoMeta({
|
||||
title: '상품 목록 | MyStore',
|
||||
description: '다양한 상품을 만나보세요',
|
||||
ogTitle: '상품 목록 | MyStore',
|
||||
ogDescription: '다양한 상품을 만나보세요',
|
||||
ogImage: '/og-image.jpg',
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
##### DO: 동적 SEO 설정
|
||||
|
||||
```vue
|
||||
<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: 올바른 컴포저블 구조
|
||||
|
||||
```typescript
|
||||
// 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: 안티 패턴
|
||||
|
||||
```typescript
|
||||
// 금지: 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 라우트에서만 사용한다. 컴포저블과 혼용 금지.
|
||||
|
||||
```typescript
|
||||
// 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 패턴
|
||||
|
||||
```typescript
|
||||
// 패턴: '엔티티-액션-파라미터'
|
||||
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
|
||||
|
||||
```typescript
|
||||
// 금지: 범용적인 key는 다른 페이지/컴포넌트와 충돌 가능
|
||||
const { data } = await useAsyncData('data', () => $fetch('/api/products'))
|
||||
const { data } = await useAsyncData('list', () => $fetch('/api/users'))
|
||||
```
|
||||
|
||||
#### 4-3. 에러 처리 & 로딩 상태
|
||||
|
||||
##### DO: 올바른 에러/로딩 처리
|
||||
|
||||
```vue
|
||||
<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: 에러/로딩 처리 누락
|
||||
|
||||
```vue
|
||||
<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` | 서버 컨텍스트 |
|
||||
|
||||
```vue
|
||||
<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 스타일
|
||||
|
||||
```typescript
|
||||
// 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 스타일 (일관성 저해)
|
||||
|
||||
```typescript
|
||||
// 금지: Options API 스타일 혼용
|
||||
export const useCounterStore = defineStore('counter', {
|
||||
state: () => ({ count: 0 }),
|
||||
getters: {
|
||||
doubled: (state) => state.count * 2,
|
||||
},
|
||||
actions: {
|
||||
increment() { this.count++ },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### 5-2. Store 사용 규칙
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 구조분해 시 storeToRefs 사용 (반응성 유지)
|
||||
const { user, isAuthenticated } = storeToRefs(authStore)
|
||||
|
||||
// 액션은 직접 구조분해 가능
|
||||
const { login, logout } = authStore
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Rule 6: 레이아웃
|
||||
|
||||
#### DO: 올바른 레이아웃 사용
|
||||
|
||||
```vue
|
||||
<!-- 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>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- app/pages/dashboard.vue -->
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'dashboard', // layouts/dashboard.vue 적용
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- app/pages/auth/login.vue -->
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: false, // 레이아웃 비활성화 (전체 화면)
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
#### 동적 레이아웃 변경
|
||||
|
||||
```vue
|
||||
<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: 올바른 미들웨어 작성
|
||||
|
||||
```typescript
|
||||
// app/middleware/auth.ts
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
// 로그인 후 원래 페이지로 돌아오기 위해 redirect 파라미터 추가
|
||||
return navigateTo({
|
||||
path: '/auth/login',
|
||||
query: { redirect: to.fullPath },
|
||||
})
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// app/middleware/guest.ts (로그인한 사용자는 접근 불가)
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
return navigateTo('/dashboard')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 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: 미들웨어 안티 패턴
|
||||
|
||||
```typescript
|
||||
// 금지: navigateTo 없이 window.location 사용 (SSR 오류 발생)
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
window.location.href = '/login' // 서버에서 오류!
|
||||
})
|
||||
|
||||
// 금지: 반환값 없는 조건부 리다이렉트 (미들웨어가 계속 실행됨)
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
if (!isAuthenticated) {
|
||||
navigateTo('/login') // return 누락! 이후 코드도 계속 실행됨
|
||||
}
|
||||
// ... 이후 코드도 실행됨
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Rule 8: 플러그인
|
||||
|
||||
플러그인은 앱 인스턴스 생성 시 한 번 실행된다. 전역 기능/서비스 등록에 사용한다.
|
||||
|
||||
#### DO: 올바른 플러그인 작성
|
||||
|
||||
```typescript
|
||||
// app/plugins/toast.client.ts (.client.ts: 클라이언트에서만 실행)
|
||||
import { defineNuxtPlugin } from '#app'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
// provide로 전역 주입 (타입 선언 필수)
|
||||
return {
|
||||
provide: {
|
||||
toast: {
|
||||
success: (message: string) => { /* 구현 */ },
|
||||
error: (message: string) => { /* 구현 */ },
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 플러그인 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 작성
|
||||
|
||||
```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),
|
||||
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 }
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 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 안티 패턴
|
||||
|
||||
```typescript
|
||||
// 금지: 입력 유효성 검사 없이 바로 사용
|
||||
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 사용
|
||||
|
||||
```typescript
|
||||
// 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 서버 사이드에서 사용
|
||||
// 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 직접 접근
|
||||
|
||||
```typescript
|
||||
// 금지: 클라이언트에서 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과 클라이언트 코드 혼용
|
||||
|
||||
```typescript
|
||||
// 금지: 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 사용
|
||||
|
||||
```typescript
|
||||
// 금지: 컴포저블/컴포넌트 설정 외부에서 사용
|
||||
const globalRef = ref(0) // 모듈 레벨 → SSR에서 요청 간 공유됨!
|
||||
|
||||
// 올바른 예: Pinia store 또는 컴포저블 내부에서 사용
|
||||
export const useGlobalCounter = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
return { count }
|
||||
})
|
||||
```
|
||||
|
||||
#### Anti-Pattern 3: useAsyncData key 중복
|
||||
|
||||
```typescript
|
||||
// 금지: 같은 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 누락
|
||||
|
||||
```typescript
|
||||
// 금지: return 없으면 리다이렉트 후에도 계속 실행
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
if (!isAuthenticated) {
|
||||
navigateTo('/login') // return 없음 → 이후 코드 실행됨
|
||||
}
|
||||
// 인증 안 된 상태에서도 이 코드가 실행됨!
|
||||
checkAdminPermission()
|
||||
})
|
||||
|
||||
// 올바른 예
|
||||
export default defineNuxtRouteMiddleware(() => {
|
||||
if (!isAuthenticated) {
|
||||
return navigateTo('/login') // return으로 즉시 종료
|
||||
}
|
||||
checkAdminPermission()
|
||||
})
|
||||
```
|
||||
|
||||
#### Anti-Pattern 5: 서버 API에서 입력값 무검증
|
||||
|
||||
```typescript
|
||||
// 금지: 사용자 입력 직접 사용
|
||||
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 조건부 실행
|
||||
|
||||
```vue
|
||||
<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를 생략하거나 범용 이름을 사용하면 다른 컴포넌트의 데이터와 캐시가 충돌한다.
|
||||
|
||||
```typescript
|
||||
// 실수: 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 누락
|
||||
|
||||
직접 구조분해하면 반응성이 사라진다.
|
||||
|
||||
```typescript
|
||||
// 실수: 반응성 손실
|
||||
const { user, isAuthenticated } = useAuthStore() // user는 더 이상 반응형이 아님
|
||||
|
||||
// 해결: storeToRefs로 ref 변환
|
||||
const { user, isAuthenticated } = storeToRefs(useAuthStore())
|
||||
```
|
||||
|
||||
### 3. 클라이언트 전용 API를 SSR에서 호출
|
||||
|
||||
`window`, `document`, `localStorage` 등은 서버에 없다.
|
||||
|
||||
```typescript
|
||||
// 실수: 서버에서 오류
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
|
||||
// 해결: onMounted 또는 process.client 체크
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
})
|
||||
// 또는 플러그인 파일명에 .client.ts 사용
|
||||
```
|
||||
|
||||
### 4. 미들웨어에서 navigateTo() return 누락
|
||||
|
||||
return 없으면 조건이 충족되어도 이후 코드가 계속 실행된다.
|
||||
|
||||
```typescript
|
||||
// 실수: 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`가 된다.
|
||||
|
||||
```typescript
|
||||
// 실수
|
||||
const apiUrl = process.env.API_URL // 클라이언트에서 undefined
|
||||
|
||||
// 해결: runtimeConfig 사용
|
||||
const config = useRuntimeConfig()
|
||||
const apiUrl = config.public.apiBase // 안전하게 클라이언트 접근 가능
|
||||
```
|
||||
Reference in New Issue
Block a user