From 5fe888c88ff615db95764e9bba5d39892f79cf66 Mon Sep 17 00:00:00 2001 From: hyeonggil <> Date: Tue, 7 Apr 2026 23:20:02 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20docs:=20Update=20CLAUDE.md=20and?= =?UTF-8?q?=20add=20frontend=20coding=20conventions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .claude/rules/frontend/code-style.md | 115 +++ .claude/rules/frontend/nuxt.md | 449 ++++++++ .claude/rules/frontend/testing.md | 119 +++ .../rules/markup}/email-html-table.md | 5 + .../rules/markup}/html-structure.md | 5 + .../rules/markup}/tailwindcss-strategy.md | 6 + .claude/skills/edm-email-html/SKILL.md | 310 ++++++ .../edm-email-html/assets/example_asset.txt | 24 + .../references/api_reference.md | 34 + .../references/html-patterns.md | 327 ++++++ .../skills/edm-email-html/scripts/example.py | 19 + CLAUDE.md | 76 +- docs/rules/nuxt-conventions.md | 964 ------------------ 13 files changed, 1482 insertions(+), 971 deletions(-) create mode 100644 .claude/rules/frontend/code-style.md create mode 100644 .claude/rules/frontend/nuxt.md create mode 100644 .claude/rules/frontend/testing.md rename {docs/rules => .claude/rules/markup}/email-html-table.md (99%) rename {docs/rules => .claude/rules/markup}/html-structure.md (99%) rename {docs/rules => .claude/rules/markup}/tailwindcss-strategy.md (99%) create mode 100644 .claude/skills/edm-email-html/SKILL.md create mode 100644 .claude/skills/edm-email-html/assets/example_asset.txt create mode 100644 .claude/skills/edm-email-html/references/api_reference.md create mode 100644 .claude/skills/edm-email-html/references/html-patterns.md create mode 100755 .claude/skills/edm-email-html/scripts/example.py delete mode 100644 docs/rules/nuxt-conventions.md diff --git a/.claude/rules/frontend/code-style.md b/.claude/rules/frontend/code-style.md new file mode 100644 index 0000000..07d033a --- /dev/null +++ b/.claude/rules/frontend/code-style.md @@ -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 { + return await $fetch(`/api/users/${id}`) +} + +// DO +async function fetchUser(id: string): Promise { + return await $fetch(`/api/users/${id}`) +} + +// unknown 사용 시 타입 좁히기 +function parseResponse(raw: unknown): User { + if (!isUser(raw)) throw new Error('유효하지 않은 응답입니다.') + return raw +} +``` + +### Props 타입 명시 + +```vue + +``` diff --git a/.claude/rules/frontend/nuxt.md b/.claude/rules/frontend/nuxt.md new file mode 100644 index 0000000..38ca552 --- /dev/null +++ b/.claude/rules/frontend/nuxt.md @@ -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 스타일로 작성되었는가? +- [ ] ``에 내부 링크, ``에 외부 링크를 사용했는가? +- [ ] 각 페이지에 `useSeoMeta()` 또는 `useHead()`가 설정되었는가? +- [ ] `definePageMeta()`가 ` +``` + +```vue + + +``` + +### SEO 메타 설정 + +```vue + +``` + +--- + +## 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 + + + +``` + +--- + +## Rule 4: 상태관리 (Pinia) + +### Composition API 스타일로 작성 + +```typescript +// app/stores/useAuthStore.ts +export const useAuthStore = defineStore('auth', () => { + const user = ref(null) + const token = ref(null) + + const isAuthenticated = computed(() => !!user.value && !!token.value) + + async function login(credentials: LoginCredentials) { + const response = await $fetch('/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 + +``` + +--- + +## Rule 5: 레이아웃 + +```vue + + +``` + +```vue + + +``` + +--- + +## 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 // 올바름 +``` diff --git a/.claude/rules/frontend/testing.md b/.claude/rules/frontend/testing.md new file mode 100644 index 0000000..2c99118 --- /dev/null +++ b/.claude/rules/frontend/testing.md @@ -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) + }) +}) +``` diff --git a/docs/rules/email-html-table.md b/.claude/rules/markup/email-html-table.md similarity index 99% rename from docs/rules/email-html-table.md rename to .claude/rules/markup/email-html-table.md index 44beb77..19642ae 100644 --- a/docs/rules/email-html-table.md +++ b/.claude/rules/markup/email-html-table.md @@ -1,3 +1,8 @@ +--- +paths: + - "**/*.html" +--- + # 이메일 발송용 HTML Table 코딩 Rules > Gmail, Naver Mail, Outlook 등 주요 이메일 클라이언트 호환 기준 diff --git a/docs/rules/html-structure.md b/.claude/rules/markup/html-structure.md similarity index 99% rename from docs/rules/html-structure.md rename to .claude/rules/markup/html-structure.md index 102e331..c9c2ca2 100644 --- a/docs/rules/html-structure.md +++ b/.claude/rules/markup/html-structure.md @@ -1,3 +1,8 @@ +--- +paths: + - "app/**/*.vue" +--- + # HTML 구조 Rules > Nuxt 4 + Vue 3 + TypeScript 환경 기준 diff --git a/docs/rules/tailwindcss-strategy.md b/.claude/rules/markup/tailwindcss-strategy.md similarity index 99% rename from docs/rules/tailwindcss-strategy.md rename to .claude/rules/markup/tailwindcss-strategy.md index 3c08f12..0b5a470 100644 --- a/docs/rules/tailwindcss-strategy.md +++ b/.claude/rules/markup/tailwindcss-strategy.md @@ -1,3 +1,9 @@ +--- +paths: + - "app/**/*.vue" + - "app/assets/**/*.css" +--- + # TailwindCSS v4 스타일링 전략 Rules > Nuxt 4 + Vue 3 + TailwindCSS v4 + shadcn-vue 환경 기준 diff --git a/.claude/skills/edm-email-html/SKILL.md b/.claude/skills/edm-email-html/SKILL.md new file mode 100644 index 0000000..893cb73 --- /dev/null +++ b/.claude/skills/edm-email-html/SKILL.md @@ -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이 `` style 태그를 제거함 | +| `width`/`height` 속성 필수 | CSS만으론 Outlook이 무시함 | +| `margin` 사용 금지 | 빈 ``행이나 `padding`으로 대체 | +| `padding` 개별 속성 사용 | 단축 속성(`padding: 10px 20px`)은 일부 클라이언트 미지원 | +| 모든 ``에 `cellpadding="0" cellspacing="0" border="0"` | 브라우저 기본 스타일 초기화 | + +### 기본 템플릿 + +```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 + + + + + + +``` + +### 아웃룩이 무시하는 주요 속성 + +| CSS 속성 | 아웃룩 동작 | 대체 방법 | +|----------|-----------|---------| +| `background-image` | 미지원 | `` 태그 직접 사용 | +| `border-radius` | 무시 | VML 사용 또는 이미지 버튼 | +| `margin` | 무시 | `padding` 또는 빈 `` 행 | +| `box-shadow` | 무시 | 포기 또는 이미지로 대체 | +| `@media query` | 2007/2010 미지원 | 테이블 고정폭으로 데스크톱 설계 | + +### VML 버튼 (반드시 사용) + +아웃룩에서 CSS 버튼은 배경색 없는 텍스트 링크로 표시됩니다. CTA 버튼은 항상 VML을 포함하세요: + +```html +
+ + + + + + 지금 확인하기 + + + +
+``` + +### 이미지 처리 + +이미지 차단 시에도 레이아웃이 깨지지 않도록 `alt` 텍스트와 배경색을 함께 지정하세요: + +```html + + 7월 여름 세일 최대 50% 할인 + +``` + +이미지는 반드시 `https://` CDN 절대 경로를 사용하세요. 로컬 경로나 상대 경로는 이메일에서 작동하지 않습니다. + +--- + +## Phase 4: 검수 체크리스트 + +### 코드 구조 (필수) +- [ ] 모든 ``에 `cellpadding="0" cellspacing="0" border="0"` +- [ ] 모든 ``에 `width`, `height`, `alt` 속성 +- [ ] `margin` 미사용 (padding 또는 빈 `` 행으로 대체) +- [ ] `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 + +
+ + + + +
+ + + +
+``` + +--- + +## 참고 자료 + +상세 내용은 references 폴더를 참조하세요: +- `references/html-patterns.md` — 헤더/푸터/버튼/이미지 완성 코드 패턴 +- `references/verification-checklist.md` — 전체 검수 체크리스트 (시각적/기능/스팸) diff --git a/.claude/skills/edm-email-html/assets/example_asset.txt b/.claude/skills/edm-email-html/assets/example_asset.txt new file mode 100644 index 0000000..d0ac204 --- /dev/null +++ b/.claude/skills/edm-email-html/assets/example_asset.txt @@ -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. diff --git a/.claude/skills/edm-email-html/references/api_reference.md b/.claude/skills/edm-email-html/references/api_reference.md new file mode 100644 index 0000000..a85b6f2 --- /dev/null +++ b/.claude/skills/edm-email-html/references/api_reference.md @@ -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 diff --git a/.claude/skills/edm-email-html/references/html-patterns.md b/.claude/skills/edm-email-html/references/html-patterns.md new file mode 100644 index 0000000..fad7973 --- /dev/null +++ b/.claude/skills/edm-email-html/references/html-patterns.md @@ -0,0 +1,327 @@ +# EDM HTML 코드 패턴 모음 + +--- + +## 1컬럼 레이아웃 (전체 템플릿) + +```html + + + + + + + + 이메일 제목 + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+ 회사 로고 +
+ 이벤트 배너 +
+

+ 이메일 제목이 여기 들어갑니다 +

+

+ 본문 내용이 여기 들어갑니다. 가독성을 위해 line-height를 + 1.5 이상으로 설정하는 것이 좋습니다. +

+ + + +
+

+ 회사명 | 서울시 강남구 테헤란로 123 +

+

+ + 수신거부 + +  |  + + 개인정보처리방침 + +

+
+
+ + + +``` + +--- + +## 2컬럼 이미지 + 텍스트 + +```html + + + + + + + + + + + +
+ 상품명 +   +

+ 상품명 +

+

+ 상품 설명이 들어갑니다. 간결하게 핵심만 작성하세요. +

+

+ ₩29,900 +

+ + 구매하기 + +
+``` + +--- + +## 헤더 배너 (이미지 기반) + +이미지가 차단됐을 때도 배경색이 보이도록 `bgcolor` 속성을 함께 지정합니다: + +```html + + + + +
+ 여름 세일 최대 70% 할인 +
+``` + +--- + +## 섹션 구분선 + +```html + + +   + + + + + + + + + +
+
+ + +``` + +--- + +## 아웃라인(외곽선) 버튼 + +```html + + + + 더 알아보기 + + +``` + +--- + +## 소셜 아이콘 행 + +```html + + + + + + +
+ + Instagram + + + + Facebook + + + + YouTube + +
+``` diff --git a/.claude/skills/edm-email-html/scripts/example.py b/.claude/skills/edm-email-html/scripts/example.py new file mode 100755 index 0000000..c68af58 --- /dev/null +++ b/.claude/skills/edm-email-html/scripts/example.py @@ -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() diff --git a/CLAUDE.md b/CLAUDE.md index a3e17f0..61cd43d 100644 --- a/CLAUDE.md +++ b/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 diff --git a/docs/rules/nuxt-conventions.md b/docs/rules/nuxt-conventions.md deleted file mode 100644 index ed21239..0000000 --- a/docs/rules/nuxt-conventions.md +++ /dev/null @@ -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 함수)로 작성되었는가? -- [ ] ``에 내부 링크, ``에 외부 링크를 사용했는가? -- [ ] SEO를 위해 각 페이지에 `useSeoMeta()` 또는 `useHead()`가 설정되었는가? -- [ ] `definePageMeta()`가 ` - - -``` - -##### DON'T: 잘못된 definePageMeta 위치 - -```vue - -``` - -#### 2-3. SEO 메타 설정 - -모든 페이지는 `useSeoMeta()` 또는 `useHead()`로 메타 정보를 설정한다. - -##### DO: 올바른 SEO 설정 - -```vue - -``` - -##### DO: 동적 SEO 설정 - -```vue - -``` - ---- - -### 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 - - - -``` - -##### DON'T: 에러/로딩 처리 누락 - -```vue - - - -``` - -#### 4-4. $fetch vs useFetch 선택 - -| 사용 위치 | 권장 방식 | 이유 | -|-----------|----------|------| -| ` -``` - ---- - -### Rule 5: 상태관리 (Pinia) - -#### 5-1. Store 작성 패턴 (Composition API 스타일) - -##### DO: Composition API 스타일 - -```typescript -// app/stores/useAuthStore.ts -export const useAuthStore = defineStore('auth', () => { - // 상태 - const user = ref(null) - const token = ref(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('/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('/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 - -``` - ---- - -### Rule 6: 레이아웃 - -#### DO: 올바른 레이아웃 사용 - -```vue - - -``` - -```vue - - -``` - -```vue - - -``` - -#### 동적 레이아웃 변경 - -```vue - -``` - ---- - -### 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 - -``` - ---- - -## 자주 하는 실수 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 // 안전하게 클라이언트 접근 가능 -```