- 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: 마크업 컨벤션 종합 가이드
25 KiB
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-if와v-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> |
설명이 필요한 이미지, 다이어그램, 코드 블록 | figcaption은 figure 내부 첫째 또는 마지막 자식 |
<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">↗</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>