- CLAUDE.md: 프로젝트 지침 파일 추가 (@import 방식으로 rules 참조) - docs/rules/html-structure.md: 시맨틱 HTML, 접근성, Vue 템플릿 구조 규칙 - docs/rules/tailwindcss-strategy.md: TailwindCSS v4 스타일링 전략 규칙 - docs/rules/nuxt-conventions.md: Nuxt 4 코딩 컨벤션 규칙 (라우팅/컴포저블/데이터패칭/Pinia 등) - docs/MARKUP_CONVENTION_GUIDE.md: 마크업 컨벤션 종합 가이드
1386 lines
36 KiB
Markdown
1386 lines
36 KiB
Markdown
# 마크업 컨벤션 가이드
|
|
|
|
> Nuxt 4 + Vue 3 + TypeScript + TailwindCSS v4 + shadcn-vue 환경 기준
|
|
> 최종 업데이트: 2026-04-07
|
|
|
|
---
|
|
|
|
## 목차
|
|
|
|
1. [HTML 구조 Rules](#1-html-구조-rules)
|
|
2. [TailwindCSS v4 스타일링 전략 Rules](#2-tailwindcss-v4-스타일링-전략-rules)
|
|
3. [SEO / GEO / AEO 전략 Rules](#3-seo--geo--aeo-전략-rules)
|
|
|
|
---
|
|
|
|
# 1. HTML 구조 Rules
|
|
|
|
## 1-1. 시맨틱 HTML 요소 사용 원칙
|
|
|
|
### 페이지 레벨 구조
|
|
|
|
페이지 레이아웃은 의미를 가진 시맨틱 요소로 구성한다. `div` 남용을 금지한다.
|
|
|
|
```vue
|
|
<!-- app/layouts/default.vue -->
|
|
<template>
|
|
<div class="min-h-screen flex flex-col">
|
|
<header class="sticky top-0 z-50 bg-white shadow-sm">
|
|
<nav aria-label="주요 네비게이션">
|
|
<!-- 주요 메뉴 -->
|
|
</nav>
|
|
</header>
|
|
|
|
<main id="main-content" class="flex-1">
|
|
<slot />
|
|
</main>
|
|
|
|
<aside aria-label="사이드바">
|
|
<!-- 보조 콘텐츠 -->
|
|
</aside>
|
|
|
|
<footer>
|
|
<!-- 푸터 정보 -->
|
|
</footer>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### 콘텐츠 레벨 구조
|
|
|
|
| 요소 | 사용 기준 |
|
|
|------|-----------|
|
|
| `<article>` | 독립적으로 배포 가능한 콘텐츠 (블로그 포스트, 카드, 뉴스 기사) |
|
|
| `<section>` | 주제별로 묶인 콘텐츠 그룹 (반드시 헤딩 포함) |
|
|
| `<aside>` | 본문과 간접적으로 연관된 보조 콘텐츠 |
|
|
| `<nav>` | 주요 탐색 링크 그룹 (페이지당 복수 허용, aria-label 필수) |
|
|
| `<figure>` / `<figcaption>` | 설명이 필요한 이미지, 다이어그램, 코드 블록 |
|
|
| `<time>` | 날짜/시간 정보 (datetime 속성 필수) |
|
|
|
|
```vue
|
|
<!-- 올바른 예 -->
|
|
<template>
|
|
<section aria-labelledby="product-section-title">
|
|
<h2 id="product-section-title">추천 상품</h2>
|
|
|
|
<ul class="grid grid-cols-3 gap-4">
|
|
<li v-for="product in products" :key="product.id">
|
|
<article class="card">
|
|
<figure>
|
|
<NuxtImg :src="product.imageUrl" :alt="product.name" />
|
|
<figcaption class="sr-only">{{ product.name }} 상품 이미지</figcaption>
|
|
</figure>
|
|
<h3>{{ product.name }}</h3>
|
|
<p>{{ product.description }}</p>
|
|
<time :datetime="product.releaseDate">{{ formatDate(product.releaseDate) }}</time>
|
|
</article>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## 1-2. 접근성(a11y) 규칙
|
|
|
|
### ARIA 속성 사용 원칙
|
|
|
|
**ARIA 사용 우선순위**: 네이티브 HTML 시맨틱 > ARIA 속성
|
|
|
|
ARIA는 네이티브 HTML로 의미를 전달할 수 없을 때만 사용한다.
|
|
|
|
```vue
|
|
<!-- ARIA가 필요한 경우: 동적 상태 전달 -->
|
|
<button
|
|
type="button"
|
|
:aria-expanded="isMenuOpen"
|
|
:aria-controls="menuId"
|
|
@click="toggleMenu"
|
|
>
|
|
메뉴
|
|
</button>
|
|
|
|
<ul
|
|
:id="menuId"
|
|
role="menu"
|
|
:aria-hidden="!isMenuOpen"
|
|
:hidden="!isMenuOpen"
|
|
>
|
|
<li role="menuitem" v-for="item in menuItems" :key="item.id">
|
|
{{ item.label }}
|
|
</li>
|
|
</ul>
|
|
```
|
|
|
|
```vue
|
|
<!-- 라이브 리전: 동적 콘텐츠 변경 알림 -->
|
|
<div aria-live="polite" aria-atomic="true" class="sr-only">
|
|
{{ statusMessage }}
|
|
</div>
|
|
|
|
<!-- 에러 메시지 연결 -->
|
|
<div>
|
|
<label for="email">이메일</label>
|
|
<input
|
|
id="email"
|
|
type="email"
|
|
:aria-describedby="emailError ? 'email-error' : undefined"
|
|
:aria-invalid="!!emailError"
|
|
/>
|
|
<p v-if="emailError" id="email-error" role="alert" class="text-red-600">
|
|
{{ emailError }}
|
|
</p>
|
|
</div>
|
|
```
|
|
|
|
### alt 텍스트 규칙
|
|
|
|
```vue
|
|
<!-- 정보 전달 이미지: 맥락을 포함한 대체 텍스트 -->
|
|
<NuxtImg src="/profile.jpg" alt="김철수 팀장 프로필 사진" />
|
|
|
|
<!-- 장식 이미지: 빈 alt (스크린리더가 무시) -->
|
|
<NuxtImg src="/decorative-wave.svg" alt="" aria-hidden="true" />
|
|
|
|
<!-- 기능 이미지 (버튼 내부): 기능을 설명 -->
|
|
<button type="button" aria-label="장바구니 열기">
|
|
<NuxtImg src="/cart-icon.svg" alt="" aria-hidden="true" />
|
|
</button>
|
|
```
|
|
|
|
### 키보드 네비게이션
|
|
|
|
```vue
|
|
<template>
|
|
<!-- 스킵 네비게이션: 키보드 사용자를 위한 콘텐츠 바로가기 -->
|
|
<a
|
|
href="#main-content"
|
|
class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white focus:rounded"
|
|
>
|
|
본문 바로가기
|
|
</a>
|
|
|
|
<!-- tabindex 규칙: 0 또는 -1만 사용, 양수 금지 -->
|
|
<div
|
|
role="listbox"
|
|
tabindex="0"
|
|
@keydown="handleKeydown($event, selectedIndex)"
|
|
>
|
|
<div
|
|
v-for="(item, index) in items"
|
|
:key="item.id"
|
|
role="option"
|
|
:tabindex="index === selectedIndex ? 0 : -1"
|
|
:aria-selected="index === selectedIndex"
|
|
>
|
|
{{ item.label }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### 색상 대비 (WCAG 2.1 AA 기준)
|
|
|
|
- 일반 텍스트: 대비율 **4.5:1** 이상
|
|
- 큰 텍스트(18px 이상): 대비율 **3:1** 이상
|
|
- UI 컴포넌트 및 그래픽: 대비율 **3:1** 이상
|
|
- **색상만으로 정보를 전달하지 않는다** — 색상 + 아이콘 + 텍스트 조합 사용
|
|
- `focus-visible:ring-2 focus-visible:ring-blue-600` 사용, `outline: none` 전역 제거 금지
|
|
|
|
---
|
|
|
|
## 1-3. Vue/Nuxt 템플릿 구조 규칙
|
|
|
|
### Fragment vs 단일 루트
|
|
|
|
```vue
|
|
<!-- Fragment 허용: 논리적으로 묶이지 않는 경우 -->
|
|
<template>
|
|
<dt>이름</dt>
|
|
<dd>홍길동</dd>
|
|
</template>
|
|
|
|
<!-- 단일 루트 권장: 레이아웃/스타일 적용이 필요한 경우 -->
|
|
<template>
|
|
<article class="card p-4 rounded-lg shadow">
|
|
<h3>{{ title }}</h3>
|
|
<p>{{ description }}</p>
|
|
</article>
|
|
</template>
|
|
```
|
|
|
|
### v-if / v-for 사용 규칙
|
|
|
|
**v-if와 v-for를 같은 요소에 사용 금지**
|
|
|
|
```vue
|
|
<!-- 금지 -->
|
|
<li v-for="item in items" v-if="item.isActive" :key="item.id">
|
|
|
|
<!-- 올바른 예: computed로 필터링 -->
|
|
<script setup lang="ts">
|
|
const activeItems = computed(() => items.value.filter((item) => item.isActive))
|
|
</script>
|
|
<template>
|
|
<li v-for="item in activeItems" :key="item.id">{{ item.name }}</li>
|
|
</template>
|
|
```
|
|
|
|
**v-for key 규칙**: index 사용 금지, 고유 ID 사용
|
|
|
|
```vue
|
|
<!-- 금지 -->
|
|
<li v-for="(item, index) in items" :key="index">
|
|
|
|
<!-- 권장 -->
|
|
<li v-for="item in items" :key="item.id">
|
|
```
|
|
|
|
**v-show vs v-if**
|
|
- `v-if`: 토글 빈도 낮거나 초기 렌더링 불필요한 경우
|
|
- `v-show`: 토글 빈도가 높은 경우 (항상 렌더링)
|
|
|
|
---
|
|
|
|
## 1-4. 폼 요소 규칙
|
|
|
|
모든 입력 요소는 명시적 `<label>`과 연결한다. placeholder는 label을 대체할 수 없다.
|
|
|
|
```vue
|
|
<template>
|
|
<form novalidate @submit.prevent="handleSubmit">
|
|
<fieldset>
|
|
<legend>개인 정보</legend>
|
|
|
|
<div class="form-field">
|
|
<label for="name">
|
|
이름
|
|
<span aria-hidden="true" class="text-red-600">*</span>
|
|
</label>
|
|
<input
|
|
id="name"
|
|
v-model="form.name"
|
|
type="text"
|
|
autocomplete="name"
|
|
required
|
|
:aria-required="true"
|
|
:aria-invalid="!!errors.name"
|
|
:aria-describedby="errors.name ? 'name-error' : 'name-hint'"
|
|
/>
|
|
<p id="name-hint" class="text-sm text-gray-500">실명을 입력해주세요.</p>
|
|
<p v-if="errors.name" id="name-error" role="alert" class="text-sm text-red-700">
|
|
{{ errors.name }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- 라디오 그룹: fieldset + legend 필수 -->
|
|
<fieldset>
|
|
<legend>성별</legend>
|
|
<label class="flex items-center gap-2">
|
|
<input v-model="form.gender" type="radio" name="gender" value="male" />
|
|
남성
|
|
</label>
|
|
<label class="flex items-center gap-2">
|
|
<input v-model="form.gender" type="radio" name="gender" value="female" />
|
|
여성
|
|
</label>
|
|
</fieldset>
|
|
</fieldset>
|
|
|
|
<button type="submit">제출</button>
|
|
</form>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## 1-5. 이미지/미디어 최적화 규칙
|
|
|
|
모든 이미지는 `<NuxtImg>` 또는 `<NuxtPicture>` 사용. 직접 `<img>` 태그 사용 금지.
|
|
|
|
```vue
|
|
<template>
|
|
<!-- LCP 이미지: fetchpriority + eager loading -->
|
|
<NuxtImg
|
|
src="/images/hero.jpg"
|
|
alt="서비스 메인 이미지"
|
|
width="1200"
|
|
height="600"
|
|
loading="eager"
|
|
fetchpriority="high"
|
|
class="w-full h-auto"
|
|
/>
|
|
|
|
<!-- 목록 이미지: lazy loading + sizes -->
|
|
<NuxtImg
|
|
:src="product.thumbnail"
|
|
:alt="`${product.name} 썸네일`"
|
|
width="400"
|
|
height="300"
|
|
loading="lazy"
|
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px"
|
|
class="w-full h-auto object-cover"
|
|
/>
|
|
|
|
<!-- 반응형 포맷 분기: NuxtPicture -->
|
|
<NuxtPicture
|
|
src="/images/banner.jpg"
|
|
:imgAttrs="{ alt: '프로모션 배너', class: 'w-full h-auto', loading: 'lazy' }"
|
|
sizes="sm:100vw md:768px lg:1200px"
|
|
formats="avif,webp,jpg"
|
|
width="1200"
|
|
height="400"
|
|
/>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## 1-6. 헤딩 계층 구조
|
|
|
|
- 페이지당 `<h1>`은 **1개**만 사용
|
|
- 헤딩 단계는 **순서대로** (건너뛰기 금지)
|
|
- 시각적 크기를 위해 헤딩 태그 선택 금지 → CSS로 처리
|
|
|
|
```vue
|
|
<!-- 동적 헤딩 레벨 컴포넌트 -->
|
|
<script setup lang="ts">
|
|
type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6
|
|
interface Props { level: HeadingLevel; class?: string }
|
|
const props = withDefaults(defineProps<Props>(), { level: 2 })
|
|
const tag = computed(() => `h${props.level}` as const)
|
|
</script>
|
|
|
|
<template>
|
|
<component :is="tag" :class="props.class"><slot /></component>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## 1-7. 링크 규칙
|
|
|
|
| 사용 | 기준 |
|
|
|------|------|
|
|
| `<NuxtLink>` | 내부 라우팅 (SPA 네비게이션) |
|
|
| `<a>` | 외부 URL, 앵커(#), 파일 다운로드, `mailto:`, `tel:` |
|
|
|
|
```vue
|
|
<template>
|
|
<!-- 내부 링크 -->
|
|
<NuxtLink :to="{ name: 'products-id', params: { id: product.id } }">
|
|
{{ product.name }}
|
|
</NuxtLink>
|
|
|
|
<!-- 외부 링크 -->
|
|
<a
|
|
href="https://external.example.com"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
:aria-label="`${linkText} (새 탭에서 열림)`"
|
|
>
|
|
{{ linkText }}
|
|
<span aria-hidden="true">↗</span>
|
|
</a>
|
|
|
|
<!-- 시각적 "더 보기" + 접근성 -->
|
|
<NuxtLink :to="`/products/${product.id}`">
|
|
<span aria-hidden="true">더 보기</span>
|
|
<span class="sr-only">{{ product.name }} 더 보기</span>
|
|
</NuxtLink>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## 1-8. 금지사항 (Anti-Patterns)
|
|
|
|
```vue
|
|
<!-- 금지 1: div/span에 클릭 핸들러 -->
|
|
<div @click="handleClick" class="cursor-pointer">클릭</div> <!-- 금지 -->
|
|
<button type="button" @click="handleClick">클릭</button> <!-- 권장 -->
|
|
|
|
<!-- 금지 2: br 태그를 여백 용도로 사용 -->
|
|
<br /><br /> <!-- 금지 → class="mt-8" 사용 -->
|
|
|
|
<!-- 금지 3: tabindex 양수 사용 -->
|
|
<button tabindex="3"> <!-- 금지 -->
|
|
|
|
<!-- 금지 4: outline 전역 제거 -->
|
|
<style>* { outline: none; }</style> <!-- 금지 -->
|
|
|
|
<!-- 금지 5: label 없는 input -->
|
|
<input type="text" placeholder="이름" /> <!-- 금지 -->
|
|
|
|
<!-- 금지 6: button type 생략 (폼 내 의도치 않은 submit) -->
|
|
<button @click="handleReset">초기화</button> <!-- 금지 -->
|
|
<button type="button" @click="handleReset">초기화</button> <!-- 권장 -->
|
|
|
|
<!-- 금지 7: v-html 무분별 사용 (XSS 위험) -->
|
|
<div v-html="userInput" /> <!-- 금지 → DOMPurify 살균 후 사용 -->
|
|
|
|
<!-- 금지 8: key 없는 v-for -->
|
|
<li v-for="item in items"> <!-- 금지 -->
|
|
|
|
<!-- 금지 9: 직접 img 태그 사용 -->
|
|
<img src="/photo.jpg" alt="사진" /> <!-- 금지 → NuxtImg 사용 -->
|
|
```
|
|
|
|
---
|
|
|
|
# 2. TailwindCSS v4 스타일링 전략 Rules
|
|
|
|
## 2-1. 클래스 순서 컨벤션
|
|
|
|
클래스는 **레이아웃 → 박스 모델 → 타이포그래피 → 시각 효과 → 인터랙티브** 순서로 작성한다.
|
|
`prettier-plugin-tailwindcss`로 자동 정렬을 강제한다.
|
|
|
|
```
|
|
1. 레이아웃 → display, position, z-index, overflow
|
|
2. 박스 모델 → width, height, margin, padding, border, rounded
|
|
3. 플렉스/그리드 → flex-*, grid-*, gap, justify-*, items-*
|
|
4. 타이포그래피 → font-*, text-*, leading-*, tracking-*
|
|
5. 시각 효과 → bg-*, shadow-*, opacity-, ring-*
|
|
6. 트랜지션/애니메이션 → transition-*, duration-*, animate-*
|
|
7. 인터랙티브 → cursor-*, select-*, pointer-events-*
|
|
8. 상태 변형자 → hover:, focus:, active:, disabled:
|
|
9. 반응형 변형자 → sm:, md:, lg:, xl:, 2xl:
|
|
10. 다크모드 → dark:
|
|
```
|
|
|
|
```json
|
|
// .prettierrc
|
|
{
|
|
"plugins": ["prettier-plugin-tailwindcss"],
|
|
"tailwindConfig": "./tailwind.config.ts",
|
|
"tailwindFunctions": ["cn", "cva", "clsx", "twMerge"]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 2-2. 반응형 디자인 전략 (Mobile-first)
|
|
|
|
**기본 스타일은 모바일 기준으로 작성하고, 더 큰 화면에서 덮어쓴다.**
|
|
|
|
```
|
|
기본(base) → 0px~ : 모바일 (prefix 없음)
|
|
sm → 640px~ : 대형 모바일, 소형 태블릿
|
|
md → 768px~ : 태블릿
|
|
lg → 1024px~ : 소형 데스크탑
|
|
xl → 1280px~ : 데스크탑
|
|
2xl → 1536px~ : 대형 모니터
|
|
```
|
|
|
|
```vue
|
|
<template>
|
|
<!-- 올바른 Mobile-first -->
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
<ProductCard v-for="item in products" :key="item.id" :product="item" />
|
|
</div>
|
|
|
|
<!-- 반응형 숨김/표시 -->
|
|
<MobileNav class="block sm:hidden" />
|
|
<DesktopNav class="hidden sm:flex" />
|
|
|
|
<!-- 반응형 컨테이너 -->
|
|
<div class="mx-auto w-full max-w-screen-xl px-4 sm:px-6 lg:px-8">
|
|
<slot />
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
**금지: Desktop-first `max-*` 남용**
|
|
|
|
```vue
|
|
<!-- 금지 -->
|
|
<div class="grid-cols-4 max-lg:grid-cols-3 max-md:grid-cols-2 max-sm:grid-cols-1">
|
|
```
|
|
|
|
---
|
|
|
|
## 2-3. 컴포넌트 스타일링 전략
|
|
|
|
### `@apply` 사용 기준
|
|
|
|
`@apply`는 **3개 이상의 컴포넌트에서 반복되는 복합 패턴**에만 허용한다.
|
|
|
|
```css
|
|
/* 허용: 반복적으로 사용되는 공통 패턴 */
|
|
.btn-base {
|
|
@apply inline-flex items-center justify-center rounded-md text-sm font-medium
|
|
transition-colors focus-visible:outline-none focus-visible:ring-2
|
|
focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50;
|
|
}
|
|
|
|
/* 금지: 단일 클래스 추상화 */
|
|
.text-blue { @apply text-blue-500; } /* 금지 */
|
|
```
|
|
|
|
### `cva` + `cn()` 패턴
|
|
|
|
```typescript
|
|
// app/lib/utils.ts
|
|
import { type ClassValue, clsx } from 'clsx'
|
|
import { twMerge } from 'tailwind-merge'
|
|
|
|
export function cn(...inputs: ClassValue[]): string {
|
|
return twMerge(clsx(inputs))
|
|
}
|
|
```
|
|
|
|
```typescript
|
|
// app/components/ui/button/index.ts
|
|
import { cva, type VariantProps } from 'class-variance-authority'
|
|
|
|
export const buttonVariants = cva(
|
|
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
|
{
|
|
variants: {
|
|
variant: {
|
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
},
|
|
size: {
|
|
default: 'h-10 px-4 py-2',
|
|
sm: 'h-9 rounded-md px-3',
|
|
lg: 'h-11 rounded-md px-8',
|
|
icon: 'h-10 w-10',
|
|
},
|
|
},
|
|
defaultVariants: { variant: 'default', size: 'default' },
|
|
}
|
|
)
|
|
```
|
|
|
|
### shadcn-vue 커스터마이징
|
|
|
|
shadcn-vue 컴포넌트를 직접 수정하지 않고 **래퍼 컴포넌트**로 확장한다.
|
|
|
|
```vue
|
|
<!-- app/components/AppButton.vue -->
|
|
<script setup lang="ts">
|
|
import { Button } from '@/components/ui/button'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
interface Props {
|
|
variant?: 'default' | 'destructive' | 'outline' | 'ghost'
|
|
size?: 'default' | 'sm' | 'lg' | 'icon'
|
|
loading?: boolean
|
|
class?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), { variant: 'default', size: 'default', loading: false })
|
|
</script>
|
|
|
|
<template>
|
|
<Button :variant="props.variant" :size="props.size" :disabled="props.loading" :class="cn('gap-2', props.class)">
|
|
<span v-if="props.loading" class="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
<slot />
|
|
</Button>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## 2-4. 다크모드 전략
|
|
|
|
CSS 변수 기반 테마를 사용하며 `dark:` prefix로 다크모드를 적용한다.
|
|
|
|
```css
|
|
/* app/assets/css/main.css */
|
|
@import "tailwindcss";
|
|
|
|
:root {
|
|
--background: 0 0% 100%;
|
|
--foreground: 222.2 84% 4.9%;
|
|
--primary: 221.2 83.2% 53.3%;
|
|
--primary-foreground: 210 40% 98%;
|
|
--muted: 210 40% 96.1%;
|
|
--muted-foreground: 215.4 16.3% 46.9%;
|
|
--border: 214.3 31.8% 91.4%;
|
|
--radius: 0.5rem;
|
|
}
|
|
|
|
.dark {
|
|
--background: 222.2 84% 4.9%;
|
|
--foreground: 210 40% 98%;
|
|
--primary: 217.2 91.2% 59.8%;
|
|
--border: 217.2 32.6% 17.5%;
|
|
}
|
|
```
|
|
|
|
```vue
|
|
<template>
|
|
<!-- CSS 변수 우선 사용 (자동으로 다크모드 반영) -->
|
|
<div class="bg-background text-foreground border border-border rounded-lg p-4">
|
|
<h2 class="text-foreground">제목</h2>
|
|
<p class="text-muted-foreground">설명</p>
|
|
</div>
|
|
|
|
<!-- dark: prefix는 CSS 변수로 처리 불가한 경우에 보완 -->
|
|
<div class="shadow-sm dark:shadow-md">
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## 2-5. 상태 기반 스타일링
|
|
|
|
### 기본 상태 변형자
|
|
|
|
```vue
|
|
<template>
|
|
<button
|
|
class="
|
|
rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground
|
|
transition-all duration-200
|
|
hover:bg-primary/90 hover:shadow-md
|
|
focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
|
|
active:scale-95
|
|
disabled:cursor-not-allowed disabled:opacity-50
|
|
"
|
|
>
|
|
제출
|
|
</button>
|
|
</template>
|
|
```
|
|
|
|
### `group` 패턴 (부모 상태 기반)
|
|
|
|
```vue
|
|
<template>
|
|
<div class="group relative rounded-xl border bg-card p-6 transition-all hover:border-primary hover:shadow-lg">
|
|
<h3 class="transition-colors group-hover:text-primary">카드 제목</h3>
|
|
<ArrowRight class="absolute right-4 top-4 transition-transform group-hover:translate-x-1 group-hover:text-primary" />
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### `peer` 패턴 (형제 상태 기반)
|
|
|
|
```vue
|
|
<template>
|
|
<label class="flex cursor-pointer items-center gap-3">
|
|
<input type="checkbox" class="peer h-4 w-4 rounded accent-primary" />
|
|
<span class="text-muted-foreground transition-colors peer-checked:font-medium peer-checked:text-foreground">
|
|
약관에 동의합니다
|
|
</span>
|
|
</label>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## 2-6. TailwindCSS v4 신기능 활용
|
|
|
|
### `@theme` 디렉티브 (CSS-first 설정)
|
|
|
|
```css
|
|
/* app/assets/css/main.css */
|
|
@import "tailwindcss";
|
|
|
|
@theme {
|
|
--font-sans: "Pretendard", "Noto Sans KR", system-ui, sans-serif;
|
|
|
|
--color-brand-500: oklch(60% 0.20 250);
|
|
--color-brand-600: oklch(51% 0.19 250);
|
|
|
|
--spacing-18: 4.5rem;
|
|
|
|
--animate-fade-in: fade-in 0.3s ease-out;
|
|
--animate-slide-up: slide-up 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
|
|
|
--shadow-soft: 0 2px 8px rgb(0 0 0 / 0.08), 0 1px 3px rgb(0 0 0 / 0.06);
|
|
}
|
|
|
|
@keyframes fade-in {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
@keyframes slide-up {
|
|
from { transform: translateY(8px); opacity: 0; }
|
|
to { transform: translateY(0); opacity: 1; }
|
|
}
|
|
```
|
|
|
|
### 컨테이너 쿼리 (`@container`)
|
|
|
|
```vue
|
|
<template>
|
|
<!-- 부모 컨테이너 크기 기반 반응형 -->
|
|
<div class="@container">
|
|
<div class="grid grid-cols-1 @sm:grid-cols-2 @lg:grid-cols-3 gap-4">
|
|
<ProductCard v-for="item in items" :key="item.id" :product="item" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## 2-7. 성능 최적화
|
|
|
|
### 동적 클래스 생성 금지 (Critical)
|
|
|
|
TailwindCSS v4는 소스 코드를 정적 분석하여 사용된 클래스만 번들에 포함한다.
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
// 금지: 동적 문자열 생성 (빌드 시 제외됨)
|
|
const badClass = `text-${color}-500` // 금지
|
|
const badBg = `bg-${props.color}-100` // 금지
|
|
|
|
// 올바른 방법: 완전한 클래스명을 객체로 매핑
|
|
const COLOR_CLASS_MAP: Record<string, string> = {
|
|
blue: 'text-blue-500 bg-blue-100',
|
|
red: 'text-red-500 bg-red-100',
|
|
green: 'text-green-500 bg-green-100',
|
|
} as const
|
|
|
|
const colorClass = computed(() => COLOR_CLASS_MAP[props.color] ?? COLOR_CLASS_MAP.blue)
|
|
</script>
|
|
```
|
|
|
|
---
|
|
|
|
## 2-8. 재사용 가능한 패턴
|
|
|
|
### 디자인 토큰 (CSS 변수 체계)
|
|
|
|
```css
|
|
/* app/assets/css/tokens.css */
|
|
:root {
|
|
--header-height: 4rem;
|
|
--sidebar-width: 16rem;
|
|
--content-max-width: 75rem;
|
|
|
|
/* Z-index 계층 */
|
|
--z-dropdown: 100;
|
|
--z-sticky: 200;
|
|
--z-modal: 400;
|
|
--z-toast: 600;
|
|
--z-tooltip: 700;
|
|
|
|
/* 트랜지션 */
|
|
--duration-fast: 100ms;
|
|
--duration-base: 200ms;
|
|
--ease-spring: cubic-bezier(0.16, 1, 0.3, 1);
|
|
}
|
|
```
|
|
|
|
### 상태별 클래스 매핑
|
|
|
|
```typescript
|
|
// app/utils/statusStyles.ts
|
|
type StatusVariant = 'success' | 'warning' | 'error' | 'info' | 'neutral'
|
|
|
|
export const STATUS_STYLES: Record<StatusVariant, { badge: string; text: string }> = {
|
|
success: {
|
|
badge: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
|
|
text: 'text-emerald-700 dark:text-emerald-400',
|
|
},
|
|
warning: {
|
|
badge: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
|
text: 'text-amber-700 dark:text-amber-400',
|
|
},
|
|
error: {
|
|
badge: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
|
text: 'text-red-700 dark:text-red-400',
|
|
},
|
|
info: {
|
|
badge: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
|
text: 'text-blue-700 dark:text-blue-400',
|
|
},
|
|
neutral: {
|
|
badge: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400',
|
|
text: 'text-gray-700 dark:text-gray-400',
|
|
},
|
|
} as const
|
|
```
|
|
|
|
---
|
|
|
|
## 2-9. 금지사항 (Anti-Patterns)
|
|
|
|
```vue
|
|
<!-- 금지 1: 인라인 스타일 사용 -->
|
|
<div :style="{ marginTop: '16px', color: '#3b82f6' }"> <!-- 금지 -->
|
|
<div class="mt-4 text-blue-500"> <!-- 권장 -->
|
|
|
|
<!-- 금지 2: 동적 클래스 문자열 생성 -->
|
|
const cls = `bg-${color}-500` <!-- 금지 -->
|
|
|
|
<!-- 금지 3: !important 남용 -->
|
|
<div class="!mt-0 !p-0"> <!-- 금지 -->
|
|
|
|
<!-- 금지 4: @apply 과용 -->
|
|
.text-blue { @apply text-blue-500; } /* 금지: 단일 클래스 추상화 */
|
|
|
|
<!-- 금지 5: magic number 임의값 남용 -->
|
|
<div class="mt-[17px] w-[213px] text-[13.5px]"> <!-- 금지 → 디자인 토큰 사용 -->
|
|
|
|
<!-- 금지 6: 하드코딩된 색상 -->
|
|
<div class="bg-[#1a1a2e] text-[#eaeaea]"> <!-- 금지 → CSS 변수 사용 -->
|
|
|
|
<!-- 금지 7: 불필요한 반복 반응형 클래스 -->
|
|
<p class="text-xs sm:text-xs md:text-sm lg:text-sm"> <!-- 금지 -->
|
|
<p class="text-xs sm:text-sm"> <!-- 권장 -->
|
|
```
|
|
|
|
---
|
|
|
|
# 3. SEO / GEO / AEO 전략 Rules
|
|
|
|
## 3-1. SEO 기본 규칙
|
|
|
|
### useSeoMeta 우선 사용 원칙
|
|
|
|
Nuxt 4에서는 `useSeoMeta`를 `useHead`보다 우선 사용한다. TypeScript 자동완성 지원 및 XSS 안전성 보장.
|
|
|
|
```typescript
|
|
// app/composables/useSeo.ts
|
|
interface SeoOptions {
|
|
title: string
|
|
description: string
|
|
image?: string
|
|
url?: string
|
|
type?: 'website' | 'article' | 'product'
|
|
noindex?: boolean
|
|
}
|
|
|
|
export function useSeo(options: SeoOptions) {
|
|
const config = useRuntimeConfig()
|
|
const route = useRoute()
|
|
|
|
const canonicalUrl = options.url ?? `${config.public.siteUrl}${route.path}`
|
|
const ogImage = options.image ?? `${config.public.siteUrl}/og-default.png`
|
|
|
|
useSeoMeta({
|
|
title: options.title,
|
|
description: options.description,
|
|
ogTitle: options.title,
|
|
ogDescription: options.description,
|
|
ogImage: ogImage,
|
|
ogUrl: canonicalUrl,
|
|
ogType: options.type ?? 'website',
|
|
ogSiteName: config.public.siteName,
|
|
twitterCard: 'summary_large_image',
|
|
twitterTitle: options.title,
|
|
twitterDescription: options.description,
|
|
twitterImage: ogImage,
|
|
robots: options.noindex ? 'noindex,nofollow' : 'index,follow',
|
|
})
|
|
|
|
useHead({
|
|
link: [{ rel: 'canonical', href: canonicalUrl }],
|
|
})
|
|
}
|
|
```
|
|
|
|
### 페이지 타이틀 템플릿
|
|
|
|
```typescript
|
|
// nuxt.config.ts
|
|
export default defineNuxtConfig({
|
|
app: {
|
|
head: {
|
|
titleTemplate: '%s | 사이트명',
|
|
title: '기본 타이틀',
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
### robots.txt 설정
|
|
|
|
```
|
|
# public/robots.txt
|
|
User-agent: *
|
|
Allow: /
|
|
Disallow: /admin/
|
|
Disallow: /api/
|
|
Disallow: /private/
|
|
|
|
# AI 크롤러 허용 (GEO 전략)
|
|
User-agent: GPTBot
|
|
Allow: /
|
|
|
|
User-agent: ClaudeBot
|
|
Allow: /
|
|
|
|
User-agent: PerplexityBot
|
|
Allow: /
|
|
|
|
User-agent: Google-Extended
|
|
Allow: /
|
|
|
|
Sitemap: https://example.com/sitemap.xml
|
|
```
|
|
|
|
### sitemap.xml 동적 생성
|
|
|
|
```typescript
|
|
// nuxt.config.ts
|
|
export default defineNuxtConfig({
|
|
site: { url: 'https://example.com', name: '사이트명' },
|
|
sitemap: {
|
|
sources: ['/api/sitemap/urls'],
|
|
defaults: { changefreq: 'weekly', priority: 0.8 },
|
|
exclude: ['/admin/**', '/api/**', '/private/**'],
|
|
},
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 3-2. 구조화 데이터 Schema.org JSON-LD
|
|
|
|
모든 JSON-LD는 SSR 시점에 렌더링. 페이지 콘텐츠와 구조화 데이터는 반드시 일치해야 함.
|
|
|
|
```typescript
|
|
// app/composables/useJsonLd.ts
|
|
export function useJsonLd(schema: Record<string, unknown> | Record<string, unknown>[]) {
|
|
useHead({
|
|
script: [{
|
|
type: 'application/ld+json',
|
|
innerHTML: JSON.stringify(schema),
|
|
}],
|
|
})
|
|
}
|
|
```
|
|
|
|
### Organization + WebSite (전역 app.vue)
|
|
|
|
```typescript
|
|
useJsonLd([
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Organization',
|
|
'@id': 'https://example.com/#organization',
|
|
name: '회사명',
|
|
url: 'https://example.com',
|
|
logo: { '@type': 'ImageObject', url: 'https://example.com/logo.png' },
|
|
sameAs: ['https://www.instagram.com/example', 'https://twitter.com/example'],
|
|
},
|
|
{
|
|
'@context': 'https://schema.org',
|
|
'@type': 'WebSite',
|
|
'@id': 'https://example.com/#website',
|
|
url: 'https://example.com',
|
|
name: '사이트명',
|
|
publisher: { '@id': 'https://example.com/#organization' },
|
|
potentialAction: {
|
|
'@type': 'SearchAction',
|
|
target: { '@type': 'EntryPoint', urlTemplate: 'https://example.com/search?q={search_term_string}' },
|
|
'query-input': 'required name=search_term_string',
|
|
},
|
|
},
|
|
])
|
|
```
|
|
|
|
### BreadcrumbList
|
|
|
|
```typescript
|
|
// app/composables/useBreadcrumb.ts
|
|
interface BreadcrumbItem { name: string; path: string }
|
|
|
|
export function useBreadcrumb(items: BreadcrumbItem[]) {
|
|
const config = useRuntimeConfig()
|
|
useJsonLd({
|
|
'@context': 'https://schema.org',
|
|
'@type': 'BreadcrumbList',
|
|
itemListElement: items.map((item, index) => ({
|
|
'@type': 'ListItem',
|
|
position: index + 1,
|
|
name: item.name,
|
|
item: `${config.public.siteUrl}${item.path}`,
|
|
})),
|
|
})
|
|
}
|
|
```
|
|
|
|
### FAQPage
|
|
|
|
```typescript
|
|
// app/composables/useFaqSchema.ts
|
|
export function useFaqSchema(faqs: Array<{ question: string; answer: string }>) {
|
|
useJsonLd({
|
|
'@context': 'https://schema.org',
|
|
'@type': 'FAQPage',
|
|
mainEntity: faqs.map((faq) => ({
|
|
'@type': 'Question',
|
|
name: faq.question,
|
|
acceptedAnswer: { '@type': 'Answer', text: faq.answer },
|
|
})),
|
|
})
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3-3. GEO (Generative Engine Optimization)
|
|
|
|
GEO는 ChatGPT, Perplexity, Claude, Gemini 등 AI 검색엔진이 콘텐츠를 정확하게 이해하고 인용할 수 있도록 최적화하는 전략이다.
|
|
|
|
### llms.txt 파일 설정
|
|
|
|
```markdown
|
|
<!-- public/llms.txt -->
|
|
# 사이트명
|
|
|
|
> 사이트에 대한 한 문장 설명.
|
|
|
|
## 소개
|
|
|
|
이 사이트는 [핵심 주제]에 관한 전문 정보를 제공합니다.
|
|
주요 독자: [타겟 독자층]
|
|
콘텐츠 언어: 한국어
|
|
마지막 업데이트: 2026-04-07
|
|
|
|
## 주요 페이지
|
|
|
|
- [홈](https://example.com/): 서비스 전체 소개
|
|
- [서비스 소개](https://example.com/services): 핵심 서비스 상세 설명
|
|
- [블로그](https://example.com/blog): 전문 지식 아티클 모음
|
|
- [FAQ](https://example.com/faq): 자주 묻는 질문과 답변
|
|
|
|
## AI 사용 지침
|
|
|
|
이 사이트의 콘텐츠는 인용 가능합니다.
|
|
출처 표기 요청: "출처: 사이트명 (example.com)"
|
|
```
|
|
|
|
### AI 인용 가능한 콘텐츠 구조화
|
|
|
|
```
|
|
[AI 인용을 위한 콘텐츠 작성 규칙]
|
|
|
|
1. 첫 문단 원칙: 페이지의 핵심 답변을 첫 150자 이내에 포함
|
|
- 나쁜 예: "오늘은 SEO에 대해 알아보겠습니다..."
|
|
- 좋은 예: "SEO는 웹사이트가 Google 등 검색엔진에서 상위에 노출되도록
|
|
콘텐츠와 기술 구조를 개선하는 과정입니다."
|
|
|
|
2. 인용 가능한 단위 구성:
|
|
- 하나의 H2/H3 = 하나의 완결된 질문에 대한 답변
|
|
- 150~300자 내외의 핵심 단락
|
|
- 수치/통계는 출처와 연도 명시
|
|
|
|
3. 직접 정의 패턴: "[용어]는 [정의]입니다."
|
|
|
|
4. 목록 활용: 3~7개 항목, 각 항목은 독립적으로 이해 가능
|
|
```
|
|
|
|
### E-E-A-T 강화
|
|
|
|
```vue
|
|
<!-- app/components/content/AuthorBio.vue -->
|
|
<script setup lang="ts">
|
|
interface Props {
|
|
name: string
|
|
title: string
|
|
experience: string
|
|
credentials: string[]
|
|
profileImage: string
|
|
}
|
|
const props = defineProps<Props>()
|
|
|
|
useJsonLd({
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Person',
|
|
name: props.name,
|
|
jobTitle: props.title,
|
|
hasCredential: props.credentials.map((c) => ({
|
|
'@type': 'EducationalOccupationalCredential',
|
|
name: c,
|
|
})),
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div itemscope itemtype="https://schema.org/Person">
|
|
<img :src="profileImage" :alt="`${name} 프로필 사진`" itemprop="image" />
|
|
<span itemprop="name">{{ name }}</span>
|
|
<span itemprop="jobTitle">{{ title }}</span>
|
|
<p itemprop="description">{{ experience }}</p>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
---
|
|
|
|
## 3-4. AEO (Answer Engine Optimization)
|
|
|
|
AEO는 Featured Snippet, 음성 검색, AI 직접 답변에서 콘텐츠가 채택될 수 있도록 최적화한다.
|
|
|
|
### Featured Snippet 최적화
|
|
|
|
```vue
|
|
<!-- 정의형 Snippet 구조 -->
|
|
<template>
|
|
<div>
|
|
<h2>{{ question }}</h2>
|
|
<!-- 핵심 답변: 40-60자, Featured Snippet 추출 대상 -->
|
|
<p><strong>{{ answer }}</strong></p>
|
|
<p v-if="detail">{{ detail }}</p>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
```vue
|
|
<!-- 순서형 Snippet 구조 -->
|
|
<template>
|
|
<div>
|
|
<h2>Nuxt 4 프로젝트를 시작하는 방법</h2>
|
|
<ol>
|
|
<li>Node.js 22 이상 설치</li>
|
|
<li><code>npm create nuxt@latest my-app</code> 실행</li>
|
|
<li>패키지 매니저로 pnpm 선택</li>
|
|
<li><code>pnpm dev</code>로 개발 서버 시작</li>
|
|
</ol>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### FAQ 마크업
|
|
|
|
```vue
|
|
<!-- app/pages/faq.vue -->
|
|
<template>
|
|
<main>
|
|
<h1>자주 묻는 질문</h1>
|
|
<section v-for="category in groupedFaqs" :key="category.name" :aria-label="`${category.name} 관련 질문`">
|
|
<h2>{{ category.name }}</h2>
|
|
<!-- details/summary: JS 없이 동작하는 접근 가능한 아코디언 -->
|
|
<details
|
|
v-for="faq in category.items"
|
|
:key="faq.question"
|
|
itemscope
|
|
itemtype="https://schema.org/Question"
|
|
>
|
|
<summary itemprop="name">{{ faq.question }}</summary>
|
|
<div itemprop="acceptedAnswer" itemscope itemtype="https://schema.org/Answer">
|
|
<p itemprop="text">{{ faq.answer }}</p>
|
|
</div>
|
|
</details>
|
|
</section>
|
|
</main>
|
|
</template>
|
|
```
|
|
|
|
### 음성 검색 최적화
|
|
|
|
```typescript
|
|
// speakable 스키마 (Google Assistant, Alexa 등)
|
|
export function useVoiceSearchSeo(cssSelectors: string[]) {
|
|
useJsonLd({
|
|
'@context': 'https://schema.org',
|
|
'@type': 'WebPage',
|
|
speakable: {
|
|
'@type': 'SpeakableSpecification',
|
|
cssSelector: cssSelectors, // 예: ['.core-answer', 'h1']
|
|
},
|
|
url: useRequestURL().href,
|
|
})
|
|
}
|
|
```
|
|
|
|
```
|
|
[음성 검색 콘텐츠 작성 규칙]
|
|
- 헤딩에 질문 형태 사용: "Core Web Vitals란 무엇인가요?"
|
|
- 첫 문장에 핵심 답변 완결 (약 75단어 이내)
|
|
- "...에 대해 알아보겠습니다" 금지 → "[주제]는 [정의]입니다" 형식 선호
|
|
```
|
|
|
|
---
|
|
|
|
## 3-5. Core Web Vitals 최적화
|
|
|
|
### LCP (Largest Contentful Paint) — 목표: 2.5초 이하
|
|
|
|
```vue
|
|
<!-- LCP 이미지 최적화 -->
|
|
<template>
|
|
<NuxtImg
|
|
src="/hero-image.webp"
|
|
alt="히어로 이미지"
|
|
:width="1200"
|
|
:height="630"
|
|
fetchpriority="high"
|
|
loading="eager"
|
|
format="webp"
|
|
quality="85"
|
|
sizes="100vw sm:100vw md:1200px"
|
|
/>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
// preload 설정
|
|
useHead({
|
|
link: [{ rel: 'preload', as: 'image', href: '/hero-image.webp', fetchpriority: 'high' }],
|
|
})
|
|
</script>
|
|
```
|
|
|
|
**LCP 체크리스트:**
|
|
- 서버 응답 시간(TTFB) 800ms 이하
|
|
- 히어로 이미지 WebP/AVIF 변환
|
|
- `fetchpriority="high"` + `loading="eager"` 적용
|
|
- 폰트 `font-display: swap` + preload 설정
|
|
|
|
### CLS (Cumulative Layout Shift) — 목표: 0.1 이하
|
|
|
|
```vue
|
|
<!-- 모든 이미지 width/height 명시 (CLS 방지) -->
|
|
<img
|
|
src="/product.webp"
|
|
alt="제품 이미지"
|
|
width="400"
|
|
height="300"
|
|
style="aspect-ratio: 4/3"
|
|
/>
|
|
```
|
|
|
|
```css
|
|
/* 동적 콘텐츠 영역 크기 사전 예약 */
|
|
.ad-slot { min-height: 250px; width: 100%; }
|
|
.dynamic-content { min-height: 200px; }
|
|
|
|
/* 웹폰트 CLS 방지 */
|
|
@font-face {
|
|
font-family: 'Noto Sans KR Fallback';
|
|
src: local('Apple SD Gothic Neo');
|
|
size-adjust: 96%;
|
|
}
|
|
```
|
|
|
|
### INP (Interaction to Next Paint) — 목표: 200ms 이하
|
|
|
|
```typescript
|
|
// app/composables/useOptimizedInteraction.ts
|
|
export function useDeferredTask() {
|
|
function scheduleTask(task: () => void) {
|
|
if ('scheduler' in window) {
|
|
(window as unknown as { scheduler: { postTask: (fn: () => void, opts: object) => void } })
|
|
.scheduler.postTask(task, { priority: 'background' })
|
|
return
|
|
}
|
|
setTimeout(task, 0)
|
|
}
|
|
|
|
return { scheduleTask }
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3-6. Nuxt 4 특화 SEO
|
|
|
|
### 렌더링 모드 선택 기준
|
|
|
|
```typescript
|
|
// nuxt.config.ts
|
|
export default defineNuxtConfig({
|
|
routeRules: {
|
|
'/': { isr: 3600 }, // ISR: 1시간 캐시
|
|
'/about': { prerender: true }, // SSG
|
|
'/pricing': { prerender: true },
|
|
'/blog/**': { isr: 1800 }, // ISR: 30분
|
|
'/products/**': { isr: 3600 },
|
|
'/search': { ssr: true }, // SSR: 실시간
|
|
'/dashboard/**': { ssr: false }, // CSR: 인증 필요
|
|
},
|
|
})
|
|
```
|
|
|
|
| 렌더링 방식 | 적합한 페이지 |
|
|
|-----------|------------|
|
|
| **SSG** | 마케팅, 블로그, 약관 (변경 빈도 낮음) |
|
|
| **ISR** | 제품, 카테고리 (주기적 업데이트) |
|
|
| **SSR** | 검색, 개인화, 실시간 재고/가격 |
|
|
| **CSR** | 대시보드 (noindex 처리) |
|
|
|
|
### i18n SEO 전략
|
|
|
|
```typescript
|
|
// nuxt.config.ts
|
|
export default defineNuxtConfig({
|
|
i18n: {
|
|
locales: [
|
|
{ code: 'ko', language: 'ko-KR', file: 'ko.json' },
|
|
{ code: 'en', language: 'en-US', file: 'en.json' },
|
|
],
|
|
defaultLocale: 'ko',
|
|
strategy: 'prefix_except_default',
|
|
},
|
|
})
|
|
```
|
|
|
|
```vue
|
|
<script setup lang="ts">
|
|
// hreflang 자동 생성
|
|
const hreflangLinks = computed(() =>
|
|
locales.value.map((loc) => ({
|
|
rel: 'alternate',
|
|
hreflang: loc.language,
|
|
href: `${config.public.siteUrl}${loc.code !== 'ko' ? `/${loc.code}` : ''}${route.path}`,
|
|
}))
|
|
)
|
|
|
|
useHead({
|
|
link: [
|
|
...hreflangLinks.value,
|
|
{ rel: 'alternate', hreflang: 'x-default', href: `${config.public.siteUrl}${route.path}` },
|
|
],
|
|
htmlAttrs: { lang: locale.value },
|
|
})
|
|
</script>
|
|
```
|
|
|
|
---
|
|
|
|
## 3-7. 콘텐츠 최적화 체크리스트
|
|
|
|
### SEO 기본 요소
|
|
- [ ] 타이틀: 30~60자, 핵심 키워드 앞쪽, 각 페이지 고유
|
|
- [ ] 메타 디스크립션: 120~160자, CTA 포함, 각 페이지 고유
|
|
- [ ] H1: 페이지당 1개, 핵심 키워드 포함
|
|
- [ ] 헤딩 구조: H1 → H2 → H3 논리적 계층 (건너뜀 금지)
|
|
- [ ] 이미지: alt 속성, WebP/AVIF, width/height 명시
|
|
- [ ] 내부 링크: 관련 페이지 3~5개, 설명적 앵커 텍스트
|
|
- [ ] 캐노니컬: 모든 페이지 자기 참조
|
|
- [ ] 구조화 데이터: Rich Results Test 검증
|
|
|
|
### GEO/AEO 최적화
|
|
- [ ] 첫 150자 내 핵심 답변 완결
|
|
- [ ] 명확한 정의 문장 포함 ("[용어]는 [정의]입니다")
|
|
- [ ] 수치/통계 출처 표기
|
|
- [ ] 저자 정보 및 E-E-A-T 신호 포함
|
|
- [ ] FAQ 섹션 + FAQPage 스키마
|
|
- [ ] llms.txt 최신 내용 반영
|
|
- [ ] AI 크롤러 허용 robots.txt 확인
|
|
|
|
### Core Web Vitals
|
|
- [ ] LCP 이미지: `fetchpriority="high"` + preload
|
|
- [ ] 모든 이미지: `width`/`height` 명시
|
|
- [ ] 동적 콘텐츠 영역 최소 높이 예약
|
|
- [ ] 웹폰트 `font-display: swap`
|
|
- [ ] PageSpeed Insights 모바일 90점 이상
|
|
|
|
---
|
|
|
|
## 참고 자료
|
|
|
|
- [Google Search Central](https://developers.google.com/search/docs)
|
|
- [Schema.org](https://schema.org)
|
|
- [llmstxt.org](https://llmstxt.org) — llms.txt 공식 명세
|
|
- [web.dev Core Web Vitals](https://web.dev/explore/learn-core-web-vitals)
|
|
- [Nuxt SEO 모듈](https://nuxtseo.com)
|
|
- [Google Rich Results Test](https://search.google.com/test/rich-results)
|
|
- [WCAG 2.1](https://www.w3.org/TR/WCAG21/)
|
|
- [WAI-ARIA 1.2](https://www.w3.org/TR/wai-aria-1.2/)
|