Files
fe-agent/.claude/rules/markup/html-structure.md
hyeonggil 5fe888c88f 📝 docs: Update CLAUDE.md and add frontend coding conventions
- Expanded CLAUDE.md with behavioral guidelines for LLM coding practices.
- Introduced new documents for frontend code style, Nuxt conventions, and testing conventions.
- Added detailed rules for email HTML structure and TailwindCSS styling strategy.
- Included a comprehensive EDM email HTML implementation guide.
2026-04-07 23:20:02 +09:00

25 KiB

paths
paths
app/**/*.vue

HTML 구조 Rules

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


빠른 참조 체크리스트

코드 작성 전/리뷰 시 아래 항목을 확인한다.

  • 레이아웃에 <header>, <main>, <footer>, <nav>, <aside> 시맨틱 요소를 사용했는가?
  • <div>를 의미 없이 남용하지 않았는가?
  • 페이지당 <h1>은 1개이며, 헤딩 순서(h1 > h2 > h3)를 건너뛰지 않았는가?
  • 모든 이미지에 적절한 alt 속성이 있는가? (장식 이미지는 alt="")
  • 모든 <input><label>이 연결되어 있는가?
  • v-for에 고유한 :key를 사용했는가? (index 사용 금지)
  • v-ifv-for를 같은 요소에 사용하지 않았는가?
  • 클릭 가능한 요소에 <button> 또는 <a>를 사용했는가? (<div @click> 금지)
  • 이미지에 <NuxtImg> 또는 <NuxtPicture>를 사용했는가? (<img> 직접 사용 금지)
  • 내부 링크에 <NuxtLink>를 사용했는가?
  • 폼 내부 버튼에 type 속성을 명시했는가?
  • 키보드로 모든 인터랙티브 요소에 접근 가능한가?
  • v-html 사용 시 DOMPurify 살균 처리를 했는가?

규칙 상세

Rule 1: 시맨틱 HTML

<div>는 스타일링 래퍼로만 사용한다. 콘텐츠의 의미를 전달하는 곳에는 반드시 시맨틱 요소를 사용한다.

시맨틱 요소 사용 기준 표

요소 사용 기준 주의사항
<header> 페이지 또는 섹션의 머리말 페이지 전체 <header>는 보통 1개
<nav> 주요 탐색 링크 그룹 복수 사용 가능, 각각 aria-label 필수
<main> 페이지의 핵심 콘텐츠 영역 페이지당 1개, id="main-content" 권장
<footer> 페이지 또는 섹션의 바닥글 저작권, 연락처 등
<article> 독립적으로 배포 가능한 콘텐츠 블로그 포스트, 카드, 뉴스 기사
<section> 주제별로 묶인 콘텐츠 그룹 반드시 헤딩(h2~h6) 포함
<aside> 본문과 간접적으로 연관된 보조 콘텐츠 사이드바, 관련 링크 등
<figure> / <figcaption> 설명이 필요한 이미지, 다이어그램, 코드 블록 figcaptionfigure 내부 첫째 또는 마지막 자식
<time> 날짜/시간 정보 datetime 속성 필수
<details> / <summary> JS 없이 동작하는 아코디언 FAQ, 접이식 섹션에 사용

DO: 올바른 페이지 레이아웃 구조

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

DON'T: div만으로 레이아웃 구성

<!-- 금지: 의미 없는 div 남용 -->
<template>
  <div class="min-h-screen flex flex-col">
    <div class="sticky top-0 z-50">
      <div class="flex items-center gap-4">
        <!-- 메뉴 -->
      </div>
    </div>
    <div class="flex-1">
      <slot />
    </div>
    <div>
      <!-- 푸터 -->
    </div>
  </div>
</template>

DO: 콘텐츠 레벨에서 시맨틱 요소 활용

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

DON'T: 헤딩 없는 section, article 없는 카드 목록

<!-- 금지: section에 헤딩이 없음 -->
<template>
  <section>
    <div v-for="product in products" :key="product.id" class="card">
      <div>{{ product.name }}</div>
      <div>{{ product.description }}</div>
    </div>
  </section>
</template>

Rule 2: 접근성 (a11y)

2-1. ARIA 속성 사용 원칙

우선순위: 네이티브 HTML 시맨틱 > ARIA 속성

ARIA는 네이티브 HTML로 의미를 전달할 수 없는 동적 상태에만 사용한다.

DO: 동적 상태에 ARIA 사용
<template>
  <!-- 메뉴 토글: 동적 상태를 ARIA로 전달 -->
  <button
    type="button"
    :aria-expanded="isMenuOpen"
    :aria-controls="menuId"
    @click="toggleMenu"
  >
    메뉴
  </button>

  <ul
    :id="menuId"
    role="menu"
    :aria-hidden="!isMenuOpen"
    :hidden="!isMenuOpen"
  >
    <li v-for="item in menuItems" :key="item.id" role="menuitem">
      {{ item.label }}
    </li>
  </ul>

  <!-- 라이브 리전: 동적 콘텐츠 변경을 스크린리더에 알림 -->
  <div aria-live="polite" aria-atomic="true" class="sr-only">
    {{ statusMessage }}
  </div>
</template>
DON'T: 네이티브 HTML로 충분한 곳에 ARIA 남용
<!-- 금지: button에 불필요한 role="button" -->
<button role="button" type="button">클릭</button>

<!-- 금지: nav에 불필요한 role="navigation" -->
<nav role="navigation">...</nav>

<!-- 올바른 : 네이티브 시맨틱만으로 충분 -->
<button type="button">클릭</button>
<nav aria-label="주요 네비게이션">...</nav>

2-2. alt 텍스트 규칙

이미지 유형 alt 작성법 예시
정보 전달 이미지 맥락을 포함한 대체 텍스트 alt="김철수 팀장 프로필 사진"
장식 이미지 빈 alt + aria-hidden alt="" aria-hidden="true"
기능 이미지 (버튼 내부) 버튼에 aria-label, 이미지는 숨김 아래 예시 참조
DO: 용도에 맞는 alt 텍스트
<template>
  <!-- 정보 전달 이미지 -->
  <NuxtImg src="/profile.jpg" 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>
DON'T: 무의미하거나 누락된 alt
<!-- 금지: alt 누락 -->
<NuxtImg src="/profile.jpg" />

<!-- 금지: 파일명을 alt로 사용 -->
<NuxtImg src="/profile.jpg" alt="profile.jpg" />

<!-- 금지: "이미지"라는 단어 반복 -->
<NuxtImg src="/profile.jpg" alt="프로필 이미지 사진" />

2-3. 키보드 네비게이션

DO: 스킵 네비게이션 + 올바른 tabindex
<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>
DON'T: tabindex 양수 사용
<!-- 금지: tabindex 양수는 자연스러운  순서를 깨뜨림 -->
<button tabindex="1"> 번째</button>
<button tabindex="3"> 번째</button>
<button tabindex="2"> 번째</button>

<!-- 올바른 : DOM 순서가   순서 -->
<button> 번째</button>
<button> 번째</button>
<button> 번째</button>

2-4. 색상 대비 (WCAG 2.1 AA 기준)

대상 최소 대비율
일반 텍스트 (18px 미만) 4.5:1
큰 텍스트 (18px 이상) 3:1
UI 컴포넌트 및 그래픽 3:1

핵심 원칙:

  • 색상만으로 정보를 전달하지 않는다 (색상 + 아이콘 + 텍스트 조합 사용)
  • 포커스 표시를 제거하지 않는다
DO: 올바른 포커스 스타일
<template>
  <!-- focus-visible 사용: 키보드 포커스만 시각적 표시 -->
  <button
    type="button"
    class="rounded-md bg-primary px-4 py-2 text-sm
           focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
  >
    제출
  </button>
</template>
DON'T: 전역 outline 제거
/* 금지: 키보드 사용자의 포커스 표시를 완전히 제거 */
* {
  outline: none;
}

/* 금지: 개별 요소에서도 대체 포커스 없이 outline 제거 */
button:focus {
  outline: none;
}

Rule 3: Vue/Nuxt 템플릿 구조

3-1. Fragment vs 단일 루트

Vue 3에서는 여러 루트 요소(Fragment)를 허용한다. 상황에 맞게 선택한다.

DO: 적절한 Fragment 사용
<!-- Fragment 허용: 정의 목록처럼 논리적으로 묶이지 않는 경우 -->
<template>
  <dt>이름</dt>
  <dd>홍길동</dd>
</template>

<!-- 단일 루트 권장: 레이아웃/스타일 적용이 필요한 경우 -->
<template>
  <article class="card p-4 rounded-lg shadow">
    <h3>{{ title }}</h3>
    <p>{{ description }}</p>
  </article>
</template>
DON'T: 불필요한 래핑 div 추가
<!-- 금지: Fragment로 충분한 곳에 의미 없는 div 래핑 -->
<template>
  <div>
    <dt>이름</dt>
    <dd>홍길동</dd>
  </div>
</template>

3-2. v-if / v-for 사용 규칙

v-if와 v-for를 같은 요소에 사용 금지 (Vue 3에서 v-if가 우선 평가되어 v-for 변수에 접근 불가)

DO: computed로 필터링 후 v-for
<script setup lang="ts">
const activeItems = computed(() =>
  items.value.filter((item) => item.isActive)
)
</script>

<template>
  <ul>
    <li v-for="item in activeItems" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>
DON'T: v-if와 v-for 동시 사용
<!-- 금지: 같은 요소에 v-if + v-for -->
<template>
  <ul>
    <li v-for="item in items" v-if="item.isActive" :key="item.id">
      {{ item.name }}
    </li>
  </ul>
</template>

3-3. v-for key 규칙

:key에는 반드시 고유 ID를 사용한다. 배열 index 사용 금지.

DO: 고유 ID 사용
<li v-for="item in items" :key="item.id">
  {{ item.name }}
</li>
DON'T: index를 key로 사용
<!-- 금지: index는 항목 순서 변경/삭제  렌더링 버그 유발 -->
<li v-for="(item, index) in items" :key="index">
  {{ item.name }}
</li>

3-4. v-show vs v-if 선택 기준

디렉티브 사용 시점 동작 방식
v-if 토글 빈도 낮음, 초기 렌더링 불필요 DOM에서 완전 제거/생성
v-show 토글 빈도 높음 display: none으로 숨김 (항상 렌더링)
<template>
  <!-- v-if: 조건부 렌더링 (로딩 완료 전까지 DOM에 없음) -->
  <LoadingSpinner v-if="isLoading" />
  <DataTable v-else :data="tableData" />

  <!-- v-show: 빈번한 토글 ( 전환 ) -->
  <TabContent v-show="activeTab === 'info'" />
  <TabContent v-show="activeTab === 'settings'" />
</template>

Rule 4: 폼 요소

모든 입력 요소는 명시적 <label>과 연결한다. placeholder는 label을 대체할 수 없다.

DO: 올바른 폼 구조

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

    <button type="submit">제출</button>
  </form>
</template>

폼 요소 규칙 정리

규칙 설명
<label> 연결 모든 <input>, <select>, <textarea>for/id로 연결
placeholder 보조 힌트로만 사용, label 대체 금지
<fieldset> + <legend> 관련 입력 그룹 (라디오, 체크박스 등)에 필수
autocomplete 사용자 편의를 위해 적절한 값 지정 (name, email, tel 등)
novalidate <form>에 설정 후 JavaScript 유효성 검사 수행
에러 메시지 aria-describedby로 연결, role="alert" 부여
필수 표시 required + aria-required="true", 시각적 표시는 aria-hidden="true"

DO: 라디오/체크박스 그룹

<template>
  <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>
</template>

DON'T: label 없는 입력, placeholder만 사용

<!-- 금지: label 없이 placeholder만 사용 -->
<template>
  <input type="text" placeholder="이름을 입력하세요" />
  <input type="email" placeholder="이메일" />
</template>

<!-- 금지: fieldset/legend 없는 라디오 그룹 -->
<template>
  <div>
    <input type="radio" name="gender" value="male" /> 남성
    <input type="radio" name="gender" value="female" /> 여성
  </div>
</template>

Rule 5: 이미지/미디어

모든 이미지는 <NuxtImg> 또는 <NuxtPicture> 사용. <img> 직접 사용 금지.

로딩 전략

위치 로딩 방식 속성
LCP 히어로 이미지 즉시 로딩 loading="eager" + fetchpriority="high"
뷰포트 밖 이미지 (목록 등) 지연 로딩 loading="lazy"
아이콘/장식 이미지 인라인 SVG 또는 지연 로딩 상황에 따라 결정

DO: 올바른 이미지 사용

<template>
  <!-- LCP 이미지: 즉시 로딩 + 높은 우선순위 -->
  <NuxtImg
    src="/images/hero.jpg"
    alt="서비스 메인 이미지"
    width="1200"
    height="600"
    loading="eager"
    fetchpriority="high"
    class="w-full h-auto"
  />

  <!-- 목록 이미지: 지연 로딩 + 반응형 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>

DON'T: img 직접 사용, width/height 누락

<!-- 금지: <img> 직접 사용 -->
<img src="/photo.jpg" alt="사진" />

<!-- 금지: width/height 누락 (CLS 유발) -->
<NuxtImg src="/photo.jpg" alt="사진" />

<!-- 금지: alt 없는 이미지 -->
<NuxtImg src="/photo.jpg" width="400" height="300" />

이미지 필수 속성 체크리스트

  • alt: 모든 이미지에 필수 (장식 이미지는 alt="")
  • width + height: CLS 방지를 위해 필수
  • loading: 위치에 따라 eager 또는 lazy 선택
  • fetchpriority: LCP 이미지에는 "high" 설정

Rule 6: 헤딩 계층 구조

  • 페이지당 <h1>1개만 사용한다
  • 헤딩 단계는 순서대로 사용한다 (건너뛰기 금지: h1 > h2 > h3)
  • 시각적 크기를 위해 헤딩 태그를 선택하지 않는다 (CSS로 처리)

DO: 올바른 헤딩 계층

<template>
  <main>
    <h1>상품 목록</h1>

    <section>
      <h2>카테고리: 전자기기</h2>

      <article>
        <h3>MacBook Pro 16인치</h3>
        <p>설명...</p>
      </article>

      <article>
        <h3>iPad Air</h3>
        <p>설명...</p>
      </article>
    </section>

    <section>
      <h2>카테고리: 의류</h2>
      <!-- ... -->
    </section>
  </main>
</template>

DON'T: 헤딩 건너뛰기, 시각적 크기 목적 사용

<!-- 금지: h1 다음에 바로 h3 (h2를 건너뜀) -->
<template>
  <h1>상품 목록</h1>
  <h3>카테고리: 전자기기</h3>
</template>

<!-- 금지: 텍스트 크기를 위해 h4 대신 h2 사용 -->
<!-- 올바른 방법: 적절한 헤딩 레벨 + CSS 클래스로 크기 조절 -->
<h4 class="text-xl font-bold">작은 헤딩이지만 크게 보이게</h4>

DO: 동적 헤딩 레벨 컴포넌트

재사용 컴포넌트에서 헤딩 레벨이 사용 위치에 따라 달라져야 할 때 활용한다.

<!-- app/components/DynamicHeading.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>
<!-- 사용 : 같은 카드 컴포넌트가 다른 헤딩 레벨로 사용됨 -->
<template>
  <section>
    <h2>추천 상품</h2>
    <!-- 여기서는 h3이 적절 -->
    <ProductCard :heading-level="3" />
  </section>

  <aside>
    <h3>관련 상품</h3>
    <!-- 여기서는 h4가 적절 -->
    <ProductCard :heading-level="4" />
  </aside>
</template>

Rule 7: 링크

사용 요소 기준
<NuxtLink> 내부 라우팅 (SPA 네비게이션)
<a> 외부 URL, 앵커(#), 파일 다운로드, mailto:, tel:

DO: 올바른 링크 사용

<template>
  <!-- 내부 링크: NuxtLink 사용 (SPA 네비게이션) -->
  <NuxtLink :to="{ name: 'products-id', params: { id: product.id } }">
    {{ product.name }}
  </NuxtLink>

  <!-- 외부 링크: <a> + target="_blank" + rel="noopener noreferrer" -->
  <a
    href="https://external.example.com"
    target="_blank"
    rel="noopener noreferrer"
    :aria-label="`${linkText} ( 탭에서 열림)`"
  >
    {{ linkText }}
    <span aria-hidden="true">&#8599;</span>
  </a>

  <!-- 접근성 있는 "더 보기" 링크 -->
  <NuxtLink :to="`/products/${product.id}`">
    <span aria-hidden="true"> 보기</span>
    <span class="sr-only">{{ product.name }}  보기</span>
  </NuxtLink>
</template>

DON'T: 잘못된 링크 사용

<!-- 금지: 내부 링크에 <a> 사용 (전체 페이지 리로드 발생) -->
<a href="/products/123">상품 보기</a>

<!-- 금지: 외부 링크에 target="_blank" 있고 rel 없음 (보안 위험) -->
<a href="https://external.com" target="_blank">외부 링크</a>

<!-- 금지: "더 보기" 있는 링크 (스크린리더 사용자가 맥락 파악 불가) -->
<NuxtLink to="/products/123"> 보기</NuxtLink>

<!-- 금지: 링크처럼 보이는 div -->
<div class="text-blue-500 cursor-pointer" @click="$router.push('/about')">
  회사 소개
</div>

Rule 8: 금지사항 (Anti-Patterns)

아래는 코드 리뷰에서 반드시 거부해야 하는 패턴들이다.

Anti-Pattern 1: div/span에 클릭 핸들러

<!-- 금지 -->
<div @click="handleClick" class="cursor-pointer">클릭</div>
<span @click="handleDelete">삭제</span>

<!-- 올바른  -->
<button type="button" @click="handleClick">클릭</button>
<button type="button" @click="handleDelete">삭제</button>

이유: <div>, <span>은 키보드 접근 불가, 스크린리더가 인터랙티브 요소로 인식하지 못함

Anti-Pattern 2: br 태그를 여백 용도로 사용

<!-- 금지 -->
<p> 번째 문단</p>
<br /><br />
<p> 번째 문단</p>

<!-- 올바른 : Tailwind 마진 사용 -->
<p> 번째 문단</p>
<p class="mt-8"> 번째 문단</p>

Anti-Pattern 3: tabindex 양수 사용

<!-- 금지: 자연스러운  순서 파괴 -->
<button tabindex="3"> 번째</button>
<button tabindex="1"> 번째</button>

<!-- 올바른 : DOM 순서로  순서 제어 -->
<button> 번째</button>
<button> 번째</button>

Anti-Pattern 4: outline 전역 제거

/* 금지: 키보드 사용자의 포커스 표시 완전 제거 */
* { outline: none; }
:focus { outline: none; }

Anti-Pattern 5: label 없는 input

<!-- 금지: placeholder는 label을 대체할  없음 -->
<input type="text" placeholder="이름" />

<!-- 올바른  -->
<label for="user-name">이름</label>
<input id="user-name" type="text" placeholder="예: 홍길동" />

Anti-Pattern 6: button type 생략

<!-- 금지:  내부에서 type 생략  기본값이 "submit" -->
<form @submit.prevent="handleSubmit">
  <button @click="handleReset">초기화</button>
</form>

<!-- 올바른  -->
<form @submit.prevent="handleSubmit">
  <button type="button" @click="handleReset">초기화</button>
  <button type="submit">제출</button>
</form>

Anti-Pattern 7: v-html 무분별 사용

<!-- 금지: XSS 공격 위험 -->
<div v-html="userInput" />

<!-- 올바른 : DOMPurify로 살균  사용 -->
<script setup lang="ts">
import DOMPurify from 'dompurify'

const sanitizedHtml = computed(() => DOMPurify.sanitize(userInput.value))
</script>

<template>
  <div v-html="sanitizedHtml" />
</template>

Anti-Pattern 8: key 없는 v-for

<!-- 금지 -->
<li v-for="item in items">{{ item.name }}</li>

<!-- 올바른  -->
<li v-for="item in items" :key="item.id">{{ item.name }}</li>

Anti-Pattern 9: img 직접 사용

<!-- 금지: Nuxt 이미지 최적화 미적용 -->
<img src="/photo.jpg" alt="사진" />

<!-- 올바른  -->
<NuxtImg src="/photo.jpg" alt="사진" width="400" height="300" loading="lazy" />

자주 하는 실수 TOP 5

1. div에 클릭 이벤트 바인딩

가장 흔한 실수. <div @click>은 키보드 접근이 불가하고 스크린리더가 인식하지 못한다.

<!-- 실수 -->
<div class="card cursor-pointer" @click="goToDetail(item.id)">
  {{ item.name }}
</div>

<!-- 수정: 카드 전체가 클릭 가능해야 하면 내부에 링크/버튼 배치 -->
<article class="card">
  <NuxtLink :to="`/items/${item.id}`" class="block p-4">
    {{ item.name }}
  </NuxtLink>
</article>

2. v-for에 index를 key로 사용

항목이 추가/삭제/정렬될 때 렌더링 버그가 발생한다. 특히 입력 필드가 포함된 리스트에서 문제가 심각하다.

<!-- 실수 -->
<div v-for="(todo, index) in todos" :key="index">
  <input v-model="todo.text" />
</div>

<!-- 수정 -->
<div v-for="todo in todos" :key="todo.id">
  <input v-model="todo.text" />
</div>

3. 폼 버튼 type 누락

<form> 내부에서 type을 생략하면 기본값이 "submit"이다. "취소" 버튼을 눌렀는데 폼이 제출되는 버그가 발생한다.

<!-- 실수 -->
<form @submit.prevent="save">
  <button @click="cancel">취소</button>  <!-- 클릭   제출됨! -->
  <button>저장</button>
</form>

<!-- 수정 -->
<form @submit.prevent="save">
  <button type="button" @click="cancel">취소</button>
  <button type="submit">저장</button>
</form>

4. 이미지 width/height 누락으로 CLS 발생

이미지가 로딩되면서 레이아웃이 밀리는 현상(Layout Shift)이 발생한다. Core Web Vitals CLS 점수에 악영향을 준다.

<!-- 실수 -->
<NuxtImg :src="product.image" :alt="product.name" loading="lazy" />

<!-- 수정: width/height 명시로 브라우저가 미리 공간 확보 -->
<NuxtImg
  :src="product.image"
  :alt="product.name"
  width="400"
  height="300"
  loading="lazy"
  class="w-full h-auto"
/>

5. 헤딩 레벨 건너뛰기

시각적 크기를 이유로 h1 다음에 바로 h3를 사용하는 실수. 스크린리더 사용자가 문서 구조를 파악할 수 없게 된다.

<!-- 실수 -->
<h1>회사 소개</h1>
<h3>우리의 비전</h3>    <!-- h2를 건너뜀 -->
<h5>핵심 가치</h5>      <!-- h3, h4를 건너뜀 -->

<!-- 수정: 올바른 순서 + CSS로 크기 조절 -->
<h1>회사 소개</h1>
<h2>우리의 비전</h2>
<h3 class="text-lg">핵심 가치</h3>