964 lines
25 KiB
Markdown
964 lines
25 KiB
Markdown
---
|
|
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-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: 올바른 페이지 레이아웃 구조
|
|
|
|
```vue
|
|
<!-- app/layouts/default.vue -->
|
|
<template>
|
|
<div class="min-h-screen flex flex-col">
|
|
<header class="sticky top-0 z-50 bg-white shadow-sm">
|
|
<nav aria-label="주요 네비게이션">
|
|
<!-- 주요 메뉴 -->
|
|
</nav>
|
|
</header>
|
|
|
|
<main id="main-content" class="flex-1">
|
|
<slot />
|
|
</main>
|
|
|
|
<aside aria-label="사이드바">
|
|
<!-- 보조 콘텐츠 -->
|
|
</aside>
|
|
|
|
<footer>
|
|
<!-- 푸터 정보 -->
|
|
</footer>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
#### DON'T: div만으로 레이아웃 구성
|
|
|
|
```vue
|
|
<!-- 금지: 의미 없는 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: 콘텐츠 레벨에서 시맨틱 요소 활용
|
|
|
|
```vue
|
|
<template>
|
|
<section aria-labelledby="product-section-title">
|
|
<h2 id="product-section-title">추천 상품</h2>
|
|
|
|
<ul class="grid grid-cols-3 gap-4">
|
|
<li v-for="product in products" :key="product.id">
|
|
<article class="card">
|
|
<figure>
|
|
<NuxtImg :src="product.imageUrl" :alt="product.name" />
|
|
<figcaption class="sr-only">{{ product.name }} 상품 이미지</figcaption>
|
|
</figure>
|
|
<h3>{{ product.name }}</h3>
|
|
<p>{{ product.description }}</p>
|
|
<time :datetime="product.releaseDate">
|
|
{{ formatDate(product.releaseDate) }}
|
|
</time>
|
|
</article>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
</template>
|
|
```
|
|
|
|
#### DON'T: 헤딩 없는 section, article 없는 카드 목록
|
|
|
|
```vue
|
|
<!-- 금지: 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 사용
|
|
|
|
```vue
|
|
<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 남용
|
|
|
|
```vue
|
|
<!-- 금지: 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 텍스트
|
|
|
|
```vue
|
|
<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
|
|
|
|
```vue
|
|
<!-- 금지: alt 누락 -->
|
|
<NuxtImg src="/profile.jpg" />
|
|
|
|
<!-- 금지: 파일명을 alt로 사용 -->
|
|
<NuxtImg src="/profile.jpg" alt="profile.jpg" />
|
|
|
|
<!-- 금지: "이미지"라는 단어 반복 -->
|
|
<NuxtImg src="/profile.jpg" alt="프로필 이미지 사진" />
|
|
```
|
|
|
|
#### 2-3. 키보드 네비게이션
|
|
|
|
##### DO: 스킵 네비게이션 + 올바른 tabindex
|
|
|
|
```vue
|
|
<template>
|
|
<!-- 스킵 네비게이션: 키보드 사용자를 위한 콘텐츠 바로가기 -->
|
|
<a
|
|
href="#main-content"
|
|
class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white focus:rounded"
|
|
>
|
|
본문 바로가기
|
|
</a>
|
|
|
|
<!-- tabindex: 0 또는 -1만 사용 -->
|
|
<div
|
|
role="listbox"
|
|
tabindex="0"
|
|
@keydown="handleKeydown($event, selectedIndex)"
|
|
>
|
|
<div
|
|
v-for="(item, index) in items"
|
|
:key="item.id"
|
|
role="option"
|
|
:tabindex="index === selectedIndex ? 0 : -1"
|
|
:aria-selected="index === selectedIndex"
|
|
>
|
|
{{ item.label }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
##### DON'T: tabindex 양수 사용
|
|
|
|
```vue
|
|
<!-- 금지: 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: 올바른 포커스 스타일
|
|
|
|
```vue
|
|
<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 제거
|
|
|
|
```css
|
|
/* 금지: 키보드 사용자의 포커스 표시를 완전히 제거 */
|
|
* {
|
|
outline: none;
|
|
}
|
|
|
|
/* 금지: 개별 요소에서도 대체 포커스 없이 outline 제거 */
|
|
button:focus {
|
|
outline: none;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Rule 3: Vue/Nuxt 템플릿 구조
|
|
|
|
#### 3-1. Fragment vs 단일 루트
|
|
|
|
Vue 3에서는 여러 루트 요소(Fragment)를 허용한다. 상황에 맞게 선택한다.
|
|
|
|
##### DO: 적절한 Fragment 사용
|
|
|
|
```vue
|
|
<!-- Fragment 허용: 정의 목록처럼 논리적으로 묶이지 않는 경우 -->
|
|
<template>
|
|
<dt>이름</dt>
|
|
<dd>홍길동</dd>
|
|
</template>
|
|
|
|
<!-- 단일 루트 권장: 레이아웃/스타일 적용이 필요한 경우 -->
|
|
<template>
|
|
<article class="card p-4 rounded-lg shadow">
|
|
<h3>{{ title }}</h3>
|
|
<p>{{ description }}</p>
|
|
</article>
|
|
</template>
|
|
```
|
|
|
|
##### DON'T: 불필요한 래핑 div 추가
|
|
|
|
```vue
|
|
<!-- 금지: 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
|
|
|
|
```vue
|
|
<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 동시 사용
|
|
|
|
```vue
|
|
<!-- 금지: 같은 요소에 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 사용
|
|
|
|
```vue
|
|
<li v-for="item in items" :key="item.id">
|
|
{{ item.name }}
|
|
</li>
|
|
```
|
|
|
|
##### DON'T: index를 key로 사용
|
|
|
|
```vue
|
|
<!-- 금지: 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`으로 숨김 (항상 렌더링) |
|
|
|
|
```vue
|
|
<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: 올바른 폼 구조
|
|
|
|
```vue
|
|
<template>
|
|
<form novalidate @submit.prevent="handleSubmit">
|
|
<fieldset>
|
|
<legend>개인 정보</legend>
|
|
|
|
<div class="form-field">
|
|
<label for="name">
|
|
이름
|
|
<span aria-hidden="true" class="text-red-600">*</span>
|
|
</label>
|
|
<input
|
|
id="name"
|
|
v-model="form.name"
|
|
type="text"
|
|
autocomplete="name"
|
|
required
|
|
:aria-required="true"
|
|
:aria-invalid="!!errors.name"
|
|
:aria-describedby="errors.name ? 'name-error' : 'name-hint'"
|
|
/>
|
|
<p id="name-hint" class="text-sm text-gray-500">
|
|
실명을 입력해주세요.
|
|
</p>
|
|
<p
|
|
v-if="errors.name"
|
|
id="name-error"
|
|
role="alert"
|
|
class="text-sm text-red-700"
|
|
>
|
|
{{ errors.name }}
|
|
</p>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<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: 라디오/체크박스 그룹
|
|
|
|
```vue
|
|
<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만 사용
|
|
|
|
```vue
|
|
<!-- 금지: 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: 올바른 이미지 사용
|
|
|
|
```vue
|
|
<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 누락
|
|
|
|
```vue
|
|
<!-- 금지: <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: 올바른 헤딩 계층
|
|
|
|
```vue
|
|
<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: 헤딩 건너뛰기, 시각적 크기 목적 사용
|
|
|
|
```vue
|
|
<!-- 금지: h1 다음에 바로 h3 (h2를 건너뜀) -->
|
|
<template>
|
|
<h1>상품 목록</h1>
|
|
<h3>카테고리: 전자기기</h3>
|
|
</template>
|
|
|
|
<!-- 금지: 텍스트 크기를 위해 h4 대신 h2 사용 -->
|
|
<!-- 올바른 방법: 적절한 헤딩 레벨 + CSS 클래스로 크기 조절 -->
|
|
<h4 class="text-xl font-bold">작은 헤딩이지만 크게 보이게</h4>
|
|
```
|
|
|
|
#### DO: 동적 헤딩 레벨 컴포넌트
|
|
|
|
재사용 컴포넌트에서 헤딩 레벨이 사용 위치에 따라 달라져야 할 때 활용한다.
|
|
|
|
```vue
|
|
<!-- 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>
|
|
```
|
|
|
|
```vue
|
|
<!-- 사용 예: 같은 카드 컴포넌트가 다른 헤딩 레벨로 사용됨 -->
|
|
<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: 올바른 링크 사용
|
|
|
|
```vue
|
|
<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: 잘못된 링크 사용
|
|
|
|
```vue
|
|
<!-- 금지: 내부 링크에 <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에 클릭 핸들러
|
|
|
|
```vue
|
|
<!-- 금지 -->
|
|
<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 태그를 여백 용도로 사용
|
|
|
|
```vue
|
|
<!-- 금지 -->
|
|
<p>첫 번째 문단</p>
|
|
<br /><br />
|
|
<p>두 번째 문단</p>
|
|
|
|
<!-- 올바른 예: Tailwind 마진 사용 -->
|
|
<p>첫 번째 문단</p>
|
|
<p class="mt-8">두 번째 문단</p>
|
|
```
|
|
|
|
#### Anti-Pattern 3: tabindex 양수 사용
|
|
|
|
```vue
|
|
<!-- 금지: 자연스러운 탭 순서 파괴 -->
|
|
<button tabindex="3">세 번째</button>
|
|
<button tabindex="1">첫 번째</button>
|
|
|
|
<!-- 올바른 예: DOM 순서로 탭 순서 제어 -->
|
|
<button>첫 번째</button>
|
|
<button>두 번째</button>
|
|
```
|
|
|
|
#### Anti-Pattern 4: outline 전역 제거
|
|
|
|
```css
|
|
/* 금지: 키보드 사용자의 포커스 표시 완전 제거 */
|
|
* { outline: none; }
|
|
:focus { outline: none; }
|
|
```
|
|
|
|
#### Anti-Pattern 5: label 없는 input
|
|
|
|
```vue
|
|
<!-- 금지: placeholder는 label을 대체할 수 없음 -->
|
|
<input type="text" placeholder="이름" />
|
|
|
|
<!-- 올바른 예 -->
|
|
<label for="user-name">이름</label>
|
|
<input id="user-name" type="text" placeholder="예: 홍길동" />
|
|
```
|
|
|
|
#### Anti-Pattern 6: button type 생략
|
|
|
|
```vue
|
|
<!-- 금지: 폼 내부에서 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 무분별 사용
|
|
|
|
```vue
|
|
<!-- 금지: 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
|
|
|
|
```vue
|
|
<!-- 금지 -->
|
|
<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 직접 사용
|
|
|
|
```vue
|
|
<!-- 금지: Nuxt 이미지 최적화 미적용 -->
|
|
<img src="/photo.jpg" alt="사진" />
|
|
|
|
<!-- 올바른 예 -->
|
|
<NuxtImg src="/photo.jpg" alt="사진" width="400" height="300" loading="lazy" />
|
|
```
|
|
|
|
---
|
|
|
|
## 자주 하는 실수 TOP 5
|
|
|
|
### 1. div에 클릭 이벤트 바인딩
|
|
|
|
가장 흔한 실수. `<div @click>`은 키보드 접근이 불가하고 스크린리더가 인식하지 못한다.
|
|
|
|
```vue
|
|
<!-- 실수 -->
|
|
<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로 사용
|
|
|
|
항목이 추가/삭제/정렬될 때 렌더링 버그가 발생한다. 특히 입력 필드가 포함된 리스트에서 문제가 심각하다.
|
|
|
|
```vue
|
|
<!-- 실수 -->
|
|
<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"`이다. "취소" 버튼을 눌렀는데 폼이 제출되는 버그가 발생한다.
|
|
|
|
```vue
|
|
<!-- 실수 -->
|
|
<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 점수에 악영향을 준다.
|
|
|
|
```vue
|
|
<!-- 실수 -->
|
|
<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`를 사용하는 실수. 스크린리더 사용자가 문서 구조를 파악할 수 없게 된다.
|
|
|
|
```vue
|
|
<!-- 실수 -->
|
|
<h1>회사 소개</h1>
|
|
<h3>우리의 비전</h3> <!-- h2를 건너뜀 -->
|
|
<h5>핵심 가치</h5> <!-- h3, h4를 건너뜀 -->
|
|
|
|
<!-- 수정: 올바른 순서 + CSS로 크기 조절 -->
|
|
<h1>회사 소개</h1>
|
|
<h2>우리의 비전</h2>
|
|
<h3 class="text-lg">핵심 가치</h3>
|
|
```
|