- 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: 마크업 컨벤션 종합 가이드
36 KiB
마크업 컨벤션 가이드
Nuxt 4 + Vue 3 + TypeScript + TailwindCSS v4 + shadcn-vue 환경 기준 최종 업데이트: 2026-04-07
목차
1. HTML 구조 Rules
1-1. 시맨틱 HTML 요소 사용 원칙
페이지 레벨 구조
페이지 레이아웃은 의미를 가진 시맨틱 요소로 구성한다. div 남용을 금지한다.
<!-- 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 속성 필수) |
<!-- 올바른 예 -->
<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로 의미를 전달할 수 없을 때만 사용한다.
<!-- 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>
<!-- 라이브 리전: 동적 콘텐츠 변경 알림 -->
<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 텍스트 규칙
<!-- 정보 전달 이미지: 맥락을 포함한 대체 텍스트 -->
<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>
키보드 네비게이션
<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 단일 루트
<!-- 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를 같은 요소에 사용 금지
<!-- 금지 -->
<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 사용
<!-- 금지 -->
<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을 대체할 수 없다.
<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> 태그 사용 금지.
<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로 처리
<!-- 동적 헤딩 레벨 컴포넌트 -->
<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: |
<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)
<!-- 금지 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:
// .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~ : 대형 모니터
<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-* 남용
<!-- 금지 -->
<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개 이상의 컴포넌트에서 반복되는 복합 패턴에만 허용한다.
/* 허용: 반복적으로 사용되는 공통 패턴 */
.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() 패턴
// 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))
}
// 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 컴포넌트를 직접 수정하지 않고 래퍼 컴포넌트로 확장한다.
<!-- 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로 다크모드를 적용한다.
/* 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%;
}
<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. 상태 기반 스타일링
기본 상태 변형자
<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 패턴 (부모 상태 기반)
<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 패턴 (형제 상태 기반)
<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 설정)
/* 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)
<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는 소스 코드를 정적 분석하여 사용된 클래스만 번들에 포함한다.
<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 변수 체계)
/* 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);
}
상태별 클래스 매핑
// 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)
<!-- 금지 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 안전성 보장.
// 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 }],
})
}
페이지 타이틀 템플릿
// 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 동적 생성
// 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 시점에 렌더링. 페이지 콘텐츠와 구조화 데이터는 반드시 일치해야 함.
// 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)
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
// 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
// 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 파일 설정
<!-- 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 강화
<!-- 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 최적화
<!-- 정의형 Snippet 구조 -->
<template>
<div>
<h2>{{ question }}</h2>
<!-- 핵심 답변: 40-60자, Featured Snippet 추출 대상 -->
<p><strong>{{ answer }}</strong></p>
<p v-if="detail">{{ detail }}</p>
</div>
</template>
<!-- 순서형 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 마크업
<!-- 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>
음성 검색 최적화
// 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초 이하
<!-- 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 이하
<!-- 모든 이미지 width/height 명시 (CLS 방지) -->
<img
src="/product.webp"
alt="제품 이미지"
width="400"
height="300"
style="aspect-ratio: 4/3"
/>
/* 동적 콘텐츠 영역 크기 사전 예약 */
.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 이하
// 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
렌더링 모드 선택 기준
// 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 전략
// 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',
},
})
<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점 이상