📝 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:
hyeonggil
2026-04-07 23:20:02 +09:00
parent 26ad9b65a6
commit 5fe888c88f
13 changed files with 1482 additions and 971 deletions

View 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>
```

View 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 // 올바름
```

View 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)
})
})
```

View File

@@ -1,3 +1,8 @@
---
paths:
- "**/*.html"
---
# 이메일 발송용 HTML Table 코딩 Rules
> Gmail, Naver Mail, Outlook 등 주요 이메일 클라이언트 호환 기준

View File

@@ -1,3 +1,8 @@
---
paths:
- "app/**/*.vue"
---
# HTML 구조 Rules
> Nuxt 4 + Vue 3 + TypeScript 환경 기준

View File

@@ -1,3 +1,9 @@
---
paths:
- "app/**/*.vue"
- "app/assets/**/*.css"
---
# TailwindCSS v4 스타일링 전략 Rules
> Nuxt 4 + Vue 3 + TailwindCSS v4 + shadcn-vue 환경 기준

View 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` — 전체 검수 체크리스트 (시각적/기능/스팸)

View 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.

View 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

View 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>
&nbsp;|&nbsp;
<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;">&nbsp;</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;">&nbsp;</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>
```

View 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()

View File

@@ -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

View File

@@ -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 // 안전하게 클라이언트 접근 가능
```