Files
fe-agent/docs/MARKUP_CONVENTION_GUIDE.md
hyeonggil 234a84e3ee 📝 docs: 코딩 컨벤션 Rules 문서 추가 및 CLAUDE.md 설정
- 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: 마크업 컨벤션 종합 가이드
2026-04-07 22:13:37 +09:00

36 KiB

마크업 컨벤션 가이드

Nuxt 4 + Vue 3 + TypeScript + TailwindCSS v4 + shadcn-vue 환경 기준 최종 업데이트: 2026-04-07


목차

  1. HTML 구조 Rules
  2. TailwindCSS v4 스타일링 전략 Rules
  3. SEO / GEO / AEO 전략 Rules

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 사용 기준

@apply3개 이상의 컴포넌트에서 반복되는 복합 패턴에만 허용한다.

/* 허용: 반복적으로 사용되는 공통 패턴 */
.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에서는 useSeoMetauseHead보다 우선 사용한다. 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 직접 답변에서 콘텐츠가 채택될 수 있도록 최적화한다.

<!-- 정의형 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점 이상

참고 자료