📝 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.
This commit is contained in:
hyeonggil
2026-04-07 23:20:02 +09:00
parent 26ad9b65a6
commit 5fe888c88f
13 changed files with 1482 additions and 971 deletions

View File

@@ -1,747 +0,0 @@
# 이메일 발송용 HTML Table 코딩 Rules
> Gmail, Naver Mail, Outlook 등 주요 이메일 클라이언트 호환 기준
> 최종 업데이트: 2026-04-07
---
## 왜 이메일 HTML은 특별한가?
이메일 클라이언트는 웹 브라우저와 다르게 동작한다.
| 클라이언트 | 렌더링 엔진 | 주요 제한 |
|-----------|------------|---------|
| Gmail (웹) | WebKit 기반 | `<head>` 스타일 일부 제거, `<style>` 지원 제한적 |
| Gmail (앱) | Gmail 자체 파서 | 외부 CSS 불가, 인라인 스타일 필수 |
| Outlook 2016~2021 | Microsoft Word (MSHTML) | Flexbox/Grid 미지원, CSS 한계 |
| Outlook 365 (웹) | WebKit | 비교적 현대적 |
| Naver Mail (웹) | WebKit 기반 | `<style>` 범위 제한 |
| Apple Mail | WebKit | 현대 CSS 대부분 지원 |
| Samsung Mail | WebKit 기반 | 대부분 지원 |
**결론**: 가장 제한적인 Outlook + Gmail 앱을 기준으로 코딩해야 한다.
---
## 빠른 참조 체크리스트
코드 작성 전/리뷰 시 아래 항목을 확인한다.
- [ ] 레이아웃에 `<table>` 구조를 사용했는가? (Flexbox/Grid 금지)
- [ ] 모든 스타일이 인라인(`style=""`)으로 작성되었는가?
- [ ] `<table>``role="presentation"` 속성이 있는가?
- [ ] `<table>``border="0" cellpadding="0" cellspacing="0"`이 설정되어 있는가?
- [ ] 너비를 `width` 속성과 `style="width:"`**동시에** 명시했는가?
- [ ] 모든 색상이 6자리 HEX(`#ffffff`)로 표기되어 있는가? (3자리, `rgb()`, `rgba()` 금지)
- [ ] 폰트에 웹 안전 폰트(fallback 포함)를 사용했는가?
- [ ] 이미지에 `alt`, `width`, `height`, `border="0"`, `display:block`이 모두 있는가?
- [ ] 전체 컨테이너 너비가 600px 이하인가?
- [ ] `<html>`, `<head>`, `<body>` 태그를 모두 포함했는가?
- [ ] `<meta charset="UTF-8">`과 viewport 메타 태그가 있는가?
- [ ] MSO 조건부 주석(`<!--[if mso]>`)으로 Outlook 버튼을 대응했는가?
- [ ] 배경 이미지 대신 배경색(`bgcolor`)을 주요 배경으로 사용했는가?
- [ ] `<td>``line-height`를 명시했는가? (Outlook 기본값 불일치)
---
## 규칙 상세
### Rule 1: 기본 HTML 골격
모든 이메일 HTML은 아래 골격을 기반으로 시작한다.
#### DO: 표준 이메일 골격
```html
<!DOCTYPE html>
<html lang="ko" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<title>이메일 제목</title>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<style>
/* 리셋 스타일: <head>에서만 전역 리셋 허용 */
* { box-sizing: border-box; }
body { margin: 0; padding: 0; width: 100% !important; }
img { -ms-interpolation-mode: bicubic; }
a { text-decoration: none; }
/* 모바일 반응형 */
@media only screen and (max-width: 600px) {
.email-container { width: 100% !important; }
.stack-on-mobile { display: block !important; width: 100% !important; }
.hide-on-mobile { display: none !important; }
.show-on-mobile { display: block !important; }
.full-width-image img { width: 100% !important; height: auto !important; }
}
</style>
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f4; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%;">
<!-- 이메일 미리보기 텍스트 (받은편지함 목록에서 표시) -->
<div style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">
미리보기 텍스트가 여기에 표시됩니다.&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;&nbsp;&zwnj;
</div>
<!-- 이메일 전체 래퍼 테이블 -->
<table
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
width="100%"
style="background-color: #f4f4f4;"
>
<tr>
<td align="center" style="padding: 20px 10px;">
<!-- 이메일 본문 컨테이너 (최대 600px) -->
<table
class="email-container"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
width="600"
style="max-width: 600px; width: 600px; background-color: #ffffff;"
>
<tr>
<td>
<!-- 섹션별 콘텐츠 삽입 -->
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
```
#### DON'T: 불완전한 골격
```html
<!-- 금지: DOCTYPE, head 없이 body 내용만 작성 -->
<table>
<tr>
<td>내용</td>
</tr>
</table>
<!-- 금지: div로 레이아웃 구성 -->
<div style="max-width: 600px; margin: 0 auto;">
<div style="display: flex;">내용</div>
</div>
```
---
### Rule 2: 레이아웃 - Table 구조 필수
이메일에서는 **모든 레이아웃을 `<table>`로 구성**한다. `div`, Flexbox, CSS Grid는 Outlook에서 동작하지 않는다.
#### 열 분할 패턴
##### DO: 2열 레이아웃 (테이블 중첩)
```html
<!-- 2열 레이아웃: 각 열을 <td>로 분리 -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="600">
<tr>
<!-- 왼쪽 열: 280px -->
<td
class="stack-on-mobile"
valign="top"
width="280"
style="width: 280px; padding: 16px;"
>
왼쪽 콘텐츠
</td>
<!-- 간격 열: 40px -->
<td width="40" style="width: 40px;">&nbsp;</td>
<!-- 오른쪽 열: 280px -->
<td
class="stack-on-mobile"
valign="top"
width="280"
style="width: 280px; padding: 16px;"
>
오른쪽 콘텐츠
</td>
</tr>
</table>
```
##### DO: 헤더 / 본문 / 푸터 섹션 구조
```html
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="600" style="width: 600px;">
<!-- 헤더 섹션 -->
<tr>
<td align="center" bgcolor="#1a56db" style="background-color: #1a56db; padding: 24px 32px;">
<img src="https://example.com/logo.png" alt="서비스명" width="120" height="40"
style="display: block; width: 120px; height: 40px; border: 0;" />
</td>
</tr>
<!-- 구분선 -->
<tr>
<td bgcolor="#e5e7eb" style="background-color: #e5e7eb; height: 1px; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<!-- 본문 섹션 -->
<tr>
<td style="padding: 40px 32px;">
<!-- 본문 콘텐츠 -->
</td>
</tr>
<!-- 구분선 -->
<tr>
<td bgcolor="#e5e7eb" style="background-color: #e5e7eb; height: 1px; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<!-- 푸터 섹션 -->
<tr>
<td align="center" bgcolor="#f9fafb" style="background-color: #f9fafb; padding: 24px 32px;">
<!-- 푸터 콘텐츠 -->
</td>
</tr>
</table>
```
##### DON'T: div 또는 Flexbox 사용
```html
<!-- 금지: Outlook에서 깨짐 -->
<div style="display: flex; gap: 20px;">
<div style="flex: 1;">왼쪽</div>
<div style="flex: 1;">오른쪽</div>
</div>
<!-- 금지: CSS Grid 사용 -->
<div style="display: grid; grid-template-columns: 1fr 1fr;">
<div>왼쪽</div>
<div>오른쪽</div>
</div>
```
---
### Rule 3: 인라인 스타일 필수 (CSS 클래스 금지)
Gmail 앱과 일부 이메일 클라이언트는 `<head>``<style>` 블록을 제거한다. 모든 스타일은 인라인으로 작성한다.
`<head>``<style>`**모바일 반응형(`@media`)과 전역 리셋에만** 허용한다.
#### DO: 인라인 스타일 사용
```html
<td style="
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans KR', Arial, sans-serif;
font-size: 16px;
line-height: 1.6;
color: #111827;
padding: 24px 32px;
background-color: #ffffff;
">
안내 메시지가 여기에 들어갑니다.
</td>
```
#### DON'T: 클래스 기반 스타일
```html
<!-- 금지: Gmail 앱에서 클래스 스타일이 제거됨 -->
<td class="content-cell">
안내 메시지
</td>
```
---
### Rule 4: 색상 표기 규칙
이메일 클라이언트 간 색상 호환을 위해 표기 방식을 통일한다.
| 표기법 | 지원 여부 | 사용 여부 |
|--------|---------|---------|
| `#ffffff` (6자리 HEX) | 전체 클라이언트 | **사용** |
| `#fff` (3자리 HEX) | Outlook 일부 미지원 | **금지** |
| `rgb(255, 255, 255)` | Outlook 미지원 | **금지** |
| `rgba(255, 255, 255, 0.5)` | Outlook 미지원 | **금지** |
| `hsl(...)` | 대부분 미지원 | **금지** |
| 색상명 (`red`, `blue`) | 일부만 지원 | **금지** |
#### DO: 6자리 HEX + bgcolor 이중 설정
```html
<!-- bgcolor 속성(HTML 4)과 style 속성 동시 설정: 최대 호환성 -->
<td bgcolor="#1a56db" style="background-color: #1a56db;">
내용
</td>
<table bgcolor="#f9fafb" style="background-color: #f9fafb;">
...
</table>
```
#### DON'T: rgba 또는 3자리 HEX 사용
```html
<!-- 금지: Outlook에서 렌더링 안 됨 -->
<td style="background-color: rgba(26, 86, 219, 0.9);">내용</td>
<td style="color: #fff;">내용</td>
```
---
### Rule 5: 폰트 및 타이포그래피
#### 웹 안전 폰트 스택 (한국어 지원)
```html
<!-- 한국어 지원 폰트 스택 -->
<td style="
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans KR',
'Apple SD Gothic Neo', '맑은 고딕', Malgun Gothic, Arial, sans-serif;
font-size: 16px;
line-height: 1.6;
color: #111827;
">
한국어 텍스트
</td>
```
#### 타이포그래피 규칙
| 속성 | 규칙 | 이유 |
|------|------|------|
| `font-size` | `px` 단위 사용 | `em`/`rem` 클라이언트별 기준값 상이 |
| `line-height` | 숫자 또는 `px` | 모든 `<td>`에 명시 (Outlook 기본값 문제) |
| `font-weight` | `bold` 또는 숫자 (`700`) | 둘 다 명시 권장 |
| 웹 폰트 | **사용 금지** | Gmail/Outlook 미지원, fallback만 렌더링됨 |
#### DO: 올바른 타이포그래피
```html
<!-- 제목 -->
<h1 style="
margin: 0 0 16px 0;
font-family: -apple-system, 'Noto Sans KR', Arial, sans-serif;
font-size: 24px;
font-weight: bold;
line-height: 1.3;
color: #111827;
">
이메일 제목입니다
</h1>
<!-- 본문 단락 -->
<p style="
margin: 0 0 16px 0;
font-family: -apple-system, 'Noto Sans KR', Arial, sans-serif;
font-size: 15px;
line-height: 1.7;
color: #374151;
">
본문 텍스트 내용입니다.
</p>
<!-- 링크 -->
<a href="https://example.com" style="color: #1a56db; text-decoration: underline;">
링크 텍스트
</a>
```
#### DON'T: 웹 폰트, em 단위 사용
```html
<!-- 금지: 웹 폰트 (대부분 이메일 클라이언트에서 fallback으로 치환됨) -->
<td style="font-family: 'Pretendard', sans-serif;">내용</td>
<!-- 금지: em 단위 (기준값이 클라이언트마다 다름) -->
<p style="font-size: 1em; line-height: 1.5em;">내용</p>
```
---
### Rule 6: 이미지
#### DO: 올바른 이미지 태그
```html
<!-- 모든 필수 속성 포함 -->
<img
src="https://example.com/images/banner.png"
alt="이벤트 배너: 7월 여름 세일 최대 50% 할인"
width="600"
height="200"
border="0"
style="
display: block;
width: 100%;
max-width: 600px;
height: auto;
border: 0;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
"
/>
```
#### 이미지 필수 속성 체크리스트
| 속성 | 이유 |
|------|------|
| `src` | 반드시 **절대 경로(https://)** 사용. 상대 경로 금지 |
| `alt` | 이미지 차단 시 대체 텍스트 표시 |
| `width` + `height` | 레이아웃 깨짐 방지 (HTML 속성 + style 이중 설정) |
| `border="0"` | IE/Outlook에서 이미지 링크 테두리 제거 |
| `display: block` | 이미지 하단 여백(inline 기본값) 제거 |
| `-ms-interpolation-mode: bicubic` | IE/Outlook 이미지 보간 품질 향상 |
#### DON'T: 잘못된 이미지 사용
```html
<!-- 금지: 상대 경로 사용 -->
<img src="/images/banner.png" alt="배너" />
<!-- 금지: width/height 누락 -->
<img src="https://example.com/banner.png" alt="배너" />
<!-- 금지: display:block 누락 (하단 여백 발생) -->
<img src="https://example.com/banner.png" alt="배너" width="600" height="200" />
<!-- 금지: alt 없음 (이미지 차단 시 빈 공간) -->
<img src="https://example.com/banner.png" width="600" height="200" style="display:block;" />
```
---
### Rule 7: 버튼 (CTA)
Outlook은 CSS `border-radius`를 지원하지 않는다. 둥근 버튼은 **VML(MSO 조건부 주석)** 을 사용해야 한다.
#### DO: 크로스 클라이언트 버튼 (VML 포함)
```html
<!-- 버튼 래퍼 td -->
<td align="center" style="padding: 24px 32px;">
<!--[if mso]>
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
href="https://example.com/cta"
style="height:48px; v-text-anchor:middle; width:200px;"
arcsize="10%"
stroke="f"
fillcolor="#1a56db">
<w:anchorlock/>
<center style="color:#ffffff; font-family:Arial,sans-serif; font-size:15px; font-weight:bold;">
지금 시작하기
</center>
</v:roundrect>
<![endif]-->
<!--[if !mso]><!-->
<a
href="https://example.com/cta"
style="
display: inline-block;
padding: 14px 32px;
background-color: #1a56db;
color: #ffffff;
font-family: -apple-system, 'Noto Sans KR', Arial, sans-serif;
font-size: 15px;
font-weight: bold;
line-height: 1;
text-decoration: none;
border-radius: 6px;
mso-hide: all;
"
>
지금 시작하기
</a>
<!--<![endif]-->
</td>
```
#### DON'T: 버튼 이미지 사용, CSS만으로 구현
```html
<!-- 금지: 이미지 버튼 (이미지 차단 시 CTA 소실) -->
<td>
<a href="https://example.com">
<img src="https://example.com/btn.png" alt="지금 시작하기" />
</a>
</td>
<!-- 금지: VML 없이 CSS border-radius만 사용 (Outlook에서 사각형으로 표시) -->
<a href="..." style="border-radius: 6px; background-color: #1a56db;">버튼</a>
```
---
### Rule 8: 간격 조절
Outlook은 `margin``<td>`, `<table>` 등에 신뢰할 수 없게 적용한다. 간격은 `padding`과 빈 `<tr>`을 활용한다.
#### DO: padding과 spacer tr로 간격 조절
```html
<!-- padding으로 내부 여백 -->
<td style="padding: 24px 32px 0 32px;">
<h2 style="margin: 0 0 16px 0; ...">제목</h2>
<p style="margin: 0 0 16px 0; ...">본문</p>
</td>
<!-- spacer tr로 섹션 간 여백 -->
<tr>
<td style="height: 24px; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
<!-- spacer td로 열 간격 -->
<td width="20" style="width: 20px; font-size: 0; line-height: 0;">&nbsp;</td>
```
#### DON'T: margin에만 의존
```html
<!-- 금지: table/td에 margin 사용 (Outlook에서 무시됨) -->
<table style="margin: 0 auto; margin-top: 24px;">
<tr>
<td style="margin-bottom: 16px;">내용</td>
</tr>
</table>
```
---
### Rule 9: 모바일 반응형
이메일도 반응형이 필요하다. `<head>` `<style>``@media` 쿼리를 활용한다.
#### DO: 모바일 반응형 패턴
```html
<!-- head의 style 블록 -->
<style>
@media only screen and (max-width: 600px) {
/* 전체 너비로 확장 */
.email-container { width: 100% !important; }
/* 2열 -> 1열 스택 */
.stack-on-mobile {
display: block !important;
width: 100% !important;
}
/* 모바일에서 숨김 */
.hide-on-mobile { display: none !important; }
/* 모바일에서만 표시 */
.show-on-mobile {
display: block !important;
max-height: none !important;
}
/* 텍스트 크기 조정 */
.mobile-text-lg { font-size: 20px !important; line-height: 1.3 !important; }
.mobile-padding { padding: 16px !important; }
/* 이미지 전체 너비 */
.full-width-image img { width: 100% !important; height: auto !important; }
}
</style>
<!-- 2열 레이아웃 (모바일에서 스택으로) -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="600">
<tr>
<td class="stack-on-mobile" width="280" style="width: 280px; padding: 16px;">
왼쪽 콘텐츠
</td>
<td class="stack-on-mobile" width="280" style="width: 280px; padding: 16px;">
오른쪽 콘텐츠
</td>
</tr>
</table>
```
#### 모바일/데스크탑 전용 콘텐츠
```html
<!-- 데스크탑에서만 표시 -->
<tr class="hide-on-mobile">
<td>데스크탑 전용 콘텐츠</td>
</tr>
<!-- 모바일에서만 표시 (기본: max-height:0; overflow:hidden) -->
<tr class="show-on-mobile" style="display: none; max-height: 0; overflow: hidden; mso-hide: all;">
<td>모바일 전용 콘텐츠</td>
</tr>
```
---
### Rule 10: 구분선 및 장식 요소
CSS `border``<hr>`은 이메일 클라이언트마다 다르게 렌더링된다. 테이블 셀로 구분선을 만든다.
#### DO: 테이블 기반 구분선
```html
<!-- 가로 구분선 -->
<tr>
<td
bgcolor="#e5e7eb"
style="background-color: #e5e7eb; height: 1px; font-size: 0; line-height: 0; mso-line-height-rule: exactly;"
>
&nbsp;
</td>
</tr>
<!-- 여백 + 구분선 -->
<tr>
<td style="padding: 0 32px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td bgcolor="#e5e7eb" style="background-color: #e5e7eb; height: 1px; font-size: 0; line-height: 0;">&nbsp;</td>
</tr>
</table>
</td>
</tr>
```
#### DON'T: hr 태그 또는 border 속성으로 구분선
```html
<!-- 금지: hr은 클라이언트별 스타일 불일치 -->
<hr style="border: 1px solid #e5e7eb;" />
<!-- 금지: border-bottom으로 구분선 -->
<td style="border-bottom: 1px solid #e5e7eb;">내용</td>
```
---
### Rule 11: 텍스트 이메일 대비 (접근성)
HTML 이메일은 반드시 텍스트 버전과 함께 발송한다(멀티파트 MIME). 이미지 차단 시에도 내용 전달이 가능해야 한다.
- 모든 이미지에 의미 있는 `alt` 텍스트 작성
- 버튼/CTA는 텍스트 링크 형태로도 제공
- 중요 정보를 이미지에만 담지 않는다 (이미지 차단 시 소실)
---
### Rule 12: Outlook 전용 조건부 주석 패턴
```html
<!-- Outlook 전용 콘텐츠 -->
<!--[if mso]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td style="width: 600px;">
<![endif]-->
<!-- 비-Outlook 콘텐츠 -->
<!--[if !mso]><!-->
<div style="max-width: 600px;">
<!--<![endif]-->
<!-- ... 공통 콘텐츠 ... -->
<!--[if !mso]><!-->
</div>
<!--<![endif]-->
<!--[if mso]>
</td>
</tr>
</table>
<![endif]-->
```
---
## 금지사항 요약 (Anti-patterns)
| 금지 항목 | 대안 |
|----------|------|
| `display: flex` / `display: grid` | `<table>` 레이아웃 |
| `position: absolute/fixed` | 테이블 레이아웃으로 배치 |
| `background-image` (배경) | `bgcolor` + `background-color` |
| `border-radius` (Outlook) | VML `<v:roundrect>` |
| 3자리 HEX (`#fff`) | 6자리 HEX (`#ffffff`) |
| `rgb()` / `rgba()` / `hsl()` | 6자리 HEX |
| 웹 폰트 (`@font-face`, Google Fonts) | 시스템 폰트 스택 |
| `em` / `rem` 단위 | `px` 단위 |
| `margin` (table/td) | `padding` + spacer td/tr |
| `<hr>` 구분선 | bgcolor td (height: 1px) |
| 이미지 상대 경로 | 절대 경로 (https://) |
| `<img>` display 미설정 | `style="display: block;"` |
| CSS 클래스만으로 스타일 | 인라인 스타일 + 클래스 병행 |
| `!important` 남용 | 클라이언트별 조건부 주석 활용 |
---
## 자주 하는 실수 TOP 5
### 1. Outlook에서 버튼이 사각형으로 표시됨
`border-radius`는 Outlook Word 렌더러에서 무시된다. VML 조건부 주석으로 반드시 대응한다. (Rule 7 참조)
### 2. 이미지가 차단되었을 때 레이아웃 붕괴
이미지에 `width`, `height`, `display: block`을 모두 설정하지 않으면, 이미지 차단 시 레이아웃이 무너진다.
```html
<!-- 반드시 width/height/display:block 명시 -->
<img src="..." alt="..." width="600" height="200"
style="display: block; width: 100%; max-width: 600px; height: auto; border: 0;" />
```
### 3. Outlook에서 줄 간격이 너무 좁거나 넓음
Outlook은 `line-height` 기본값이 다르다. 모든 `<td>``line-height`를 명시하고 `mso-line-height-rule: exactly`를 추가한다.
```html
<td style="font-size: 15px; line-height: 24px; mso-line-height-rule: exactly;">
텍스트 내용
</td>
```
### 4. Gmail 앱에서 스타일이 전혀 적용되지 않음
Gmail 앱은 `<head>` `<style>` 블록을 완전히 제거한다. 레이아웃과 타이포그래피는 반드시 인라인 스타일로 작성한다.
### 5. 모바일에서 좁은 화면에 600px 고정 레이아웃
이메일 컨테이너에 `width="600"`과 함께 `max-width: 600px`을 설정하고, 모바일 반응형 클래스를 `@media` 쿼리로 관리한다.
```html
<table class="email-container" width="600" style="width: 600px; max-width: 600px;">
```
```css
@media only screen and (max-width: 600px) {
.email-container { width: 100% !important; }
}
```

View File

@@ -1,958 +0,0 @@
# 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">&#8599;</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>
```

View File

@@ -1,964 +0,0 @@
# Nuxt 4 코딩 컨벤션 Rules
> Nuxt 4 + Vue 3 + TypeScript strict 환경 기준
> 최종 업데이트: 2026-04-07
---
## 빠른 참조 체크리스트
코드 작성 전/리뷰 시 아래 항목을 확인한다.
- [ ] 페이지 파일명이 `kebab-case`이고 `app/pages/` 아래에 위치하는가?
- [ ] 컴포저블 파일명이 `use` prefix를 가진 `camelCase`인가? (예: `useUserProfile.ts`)
- [ ] `useFetch` / `useAsyncData`의 key가 고유한가? (중복 시 캐시 충돌)
- [ ] `useAsyncData``key`를 함수명 + 파라미터 조합으로 지정했는가?
- [ ] 서버 전용 코드에 `server/` 디렉토리를 사용하고, 클라이언트 전용 코드와 혼용하지 않았는가?
- [ ] 환경변수 접근 시 `useRuntimeConfig()`를 사용했는가? (`process.env` 직접 사용 금지)
- [ ] 미들웨어에서 리다이렉트 시 `navigateTo()` 또는 `abortNavigation()`을 사용했는가?
- [ ] 플러그인에 `provide`/`inject` 타입이 명시되었는가?
- [ ] Pinia store가 Composition API 스타일(`defineStore()` + setup 함수)로 작성되었는가?
- [ ] `<NuxtLink>`에 내부 링크, `<a>`에 외부 링크를 사용했는가?
- [ ] SEO를 위해 각 페이지에 `useSeoMeta()` 또는 `useHead()`가 설정되었는가?
- [ ] `definePageMeta()``<script setup>` 내 최상단에 위치하는가?
---
## 규칙 상세
### Rule 1: 디렉토리 구조 & 파일 네이밍
#### Nuxt 4 표준 디렉토리 구조
```
app/
├── assets/ # 빌드 처리될 에셋 (CSS, 이미지, 폰트)
├── components/ # 자동 임포트 컴포넌트 (PascalCase.vue)
│ └── ui/ # shadcn-vue 등 기본 UI 컴포넌트
├── composables/ # 자동 임포트 컴포저블 (useXxx.ts)
├── layouts/ # 레이아웃 컴포넌트 (kebab-case.vue)
├── middleware/ # 라우트 미들웨어 (kebab-case.ts)
├── pages/ # 파일 기반 라우팅 (kebab-case.vue)
├── plugins/ # 플러그인 (kebab-case.ts)
├── utils/ # 자동 임포트 유틸 함수 (camelCase.ts)
├── app.vue # 앱 루트
└── error.vue # 에러 페이지
server/
├── api/ # API 라우트 (kebab-case.ts)
├── middleware/ # 서버 미들웨어
└── utils/ # 서버 전용 유틸
shared/ # 클라이언트/서버 공유 타입 및 유틸
public/ # 정적 파일 (빌드 처리 없음)
```
#### 파일 네이밍 규칙
| 파일 종류 | 네이밍 규칙 | 예시 |
|-----------|------------|------|
| 컴포넌트 | `PascalCase.vue` | `UserProfile.vue` |
| 페이지 | `kebab-case.vue` | `user-profile.vue` |
| 레이아웃 | `kebab-case.vue` | `admin-panel.vue` |
| 컴포저블 | `useXxx.ts` (camelCase) | `useUserProfile.ts` |
| 미들웨어 | `kebab-case.ts` | `auth-guard.ts` |
| 플러그인 | `kebab-case.ts` | `toast-plugin.ts` |
| 유틸 함수 | `camelCase.ts` | `formatDate.ts` |
| API 라우트 | `kebab-case.ts` | `user-profile.ts` |
| Pinia store | `useXxxStore.ts` | `useAuthStore.ts` |
#### DO: 올바른 파일 구조
```
app/
├── components/
│ ├── AppHeader.vue # 앱 전역 컴포넌트
│ ├── ProductCard.vue
│ └── ui/
│ ├── Button.vue
│ └── Badge.vue
├── composables/
│ ├── useAuth.ts
│ └── useProductList.ts
├── pages/
│ ├── index.vue # /
│ ├── about.vue # /about
│ └── products/
│ ├── index.vue # /products
│ └── [id].vue # /products/:id
```
#### DON'T: 잘못된 파일 네이밍
```
app/
├── components/
│ ├── userProfile.vue # 금지: PascalCase가 아님
│ └── User_Profile.vue # 금지: snake_case 사용
├── composables/
│ ├── authHelper.ts # 금지: use prefix 없음
│ └── GetUser.ts # 금지: 동사로 시작하는 PascalCase
├── pages/
│ └── UserList.vue # 금지: 페이지는 kebab-case
```
---
### Rule 2: 페이지 & 라우팅
#### 2-1. 파일 기반 라우팅 패턴
| 파일 경로 | 생성되는 라우트 | 설명 |
|-----------|---------------|------|
| `pages/index.vue` | `/` | 홈 |
| `pages/about.vue` | `/about` | 정적 라우트 |
| `pages/products/index.vue` | `/products` | 네스티드 인덱스 |
| `pages/products/[id].vue` | `/products/:id` | 동적 세그먼트 |
| `pages/[...slug].vue` | `/*` | Catch-all |
| `pages/(auth)/login.vue` | `/login` | 그룹 라우트 (URL에 미포함) |
#### 2-2. definePageMeta 사용
`definePageMeta()`는 반드시 `<script setup>` 블록 내 **최상단**에 위치한다.
##### DO: 올바른 페이지 메타 설정
```vue
<!-- app/pages/dashboard.vue -->
<script setup lang="ts">
// 최상단에 위치 (컴파일러 매크로)
definePageMeta({
layout: 'dashboard',
middleware: ['auth'],
title: '대시보드',
})
// 이후 일반 로직
const { data } = await useAsyncData('dashboard-stats', () =>
$fetch('/api/stats')
)
</script>
<template>
<main>
<h1>대시보드</h1>
</main>
</template>
```
##### DON'T: 잘못된 definePageMeta 위치
```vue
<script setup lang="ts">
const count = ref(0)
// 금지: 최상단이 아닌 곳에 위치
definePageMeta({
layout: 'dashboard',
})
</script>
```
#### 2-3. SEO 메타 설정
모든 페이지는 `useSeoMeta()` 또는 `useHead()`로 메타 정보를 설정한다.
##### DO: 올바른 SEO 설정
```vue
<script setup lang="ts">
definePageMeta({
title: '상품 목록',
})
// 반응형 SEO 메타
useSeoMeta({
title: '상품 목록 | MyStore',
description: '다양한 상품을 만나보세요',
ogTitle: '상품 목록 | MyStore',
ogDescription: '다양한 상품을 만나보세요',
ogImage: '/og-image.jpg',
})
</script>
```
##### DO: 동적 SEO 설정
```vue
<script setup lang="ts">
const route = useRoute()
const { data: product } = await useAsyncData(
`product-${route.params.id}`,
() => $fetch(`/api/products/${route.params.id}`)
)
useSeoMeta({
title: () => `${product.value?.name} | MyStore`,
description: () => product.value?.description,
})
</script>
```
---
### Rule 3: 컴포저블 (Composables)
#### 3-1. 컴포저블 작성 패턴
컴포저블은 `use` prefix로 시작하고, Vue 반응형 시스템과 라이프사이클에 의존한다.
##### DO: 올바른 컴포저블 구조
```typescript
// app/composables/useCounter.ts
export function useCounter(initialValue: number = 0) {
// 상태: ref/computed/reactive 사용
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
const isPositive = computed(() => count.value > 0)
// 액션
function increment(step: number = 1) {
count.value += step
}
function decrement(step: number = 1) {
count.value -= step
}
function reset() {
count.value = initialValue
}
// 라이프사이클 (필요한 경우)
onMounted(() => {
// 마운트 시 초기화 로직
})
onUnmounted(() => {
// 정리 로직
})
// 명시적 반환
return {
count: readonly(count),
doubled,
isPositive,
increment,
decrement,
reset,
}
}
```
##### DON'T: 안티 패턴
```typescript
// 금지: use prefix 없음
export function counter() { ... }
// 금지: Vue 반응형 없이 순수 함수처럼 작성 (이건 utils/에 위치해야 함)
export function useFormatPrice(price: number) {
return `₩${price.toLocaleString()}`
// 반응형이 없으면 composables/가 아닌 utils/에 위치
}
// 금지: 내부 상태가 컴포저블 밖에 선언됨 (모든 호출자가 공유하는 전역 상태가 됨)
const count = ref(0) // 모듈 레벨에 선언하면 전역 상태!
export function useCounter() {
return { count }
}
```
#### 3-2. 서버 사이드 컴포저블
`server/utils/` 파일은 서버 API 라우트에서만 사용한다. 컴포저블과 혼용 금지.
```typescript
// server/utils/useDatabase.ts → 서버 전용
// app/composables/useDatabase.ts → 클라이언트 전용 (또는 SSR 범용)
```
---
### Rule 4: 데이터 패칭 (useFetch / useAsyncData)
#### 4-1. useFetch vs useAsyncData 선택 기준
| 상황 | 권장 방식 |
|------|----------|
| 단순 API 호출 (URL 기반) | `useFetch` |
| 복잡한 로직, 변환, 조건부 패칭 | `useAsyncData` |
| 클라이언트 전용 패칭 (SSR 불필요) | `useFetch({ server: false })` |
| 전역 데이터 공유 (Pinia 활용) | Pinia store + `useAsyncData` |
#### 4-2. key 규칙
`useAsyncData`의 key는 **고유**해야 한다. 중복 시 캐시가 공유되어 의도하지 않은 데이터가 반환된다.
##### DO: 고유한 key 패턴
```typescript
// 패턴: '엔티티-액션-파라미터'
const { data: product } = await useAsyncData(
`product-detail-${route.params.id}`,
() => $fetch(`/api/products/${route.params.id}`)
)
const { data: userPosts } = await useAsyncData(
`user-posts-${userId.value}-page-${page.value}`,
() => $fetch('/api/posts', { params: { userId: userId.value, page: page.value } }),
{ watch: [userId, page] } // 의존성 변경 시 자동 재패칭
)
```
##### DON'T: 중복 가능한 key
```typescript
// 금지: 범용적인 key는 다른 페이지/컴포넌트와 충돌 가능
const { data } = await useAsyncData('data', () => $fetch('/api/products'))
const { data } = await useAsyncData('list', () => $fetch('/api/users'))
```
#### 4-3. 에러 처리 & 로딩 상태
##### DO: 올바른 에러/로딩 처리
```vue
<script setup lang="ts">
const { data: products, status, error, refresh } = await useAsyncData(
'product-list',
() => $fetch<Product[]>('/api/products'),
{
default: () => [] as Product[], // 기본값으로 타입 안정성 확보
}
)
</script>
<template>
<div>
<div v-if="status === 'pending'" class="flex justify-center py-8">
<LoadingSpinner />
</div>
<div v-else-if="error" class="text-destructive">
<p>데이터를 불러오지 못했습니다: {{ error.message }}</p>
<button type="button" @click="refresh">다시 시도</button>
</div>
<ul v-else>
<li v-for="product in products" :key="product.id">
{{ product.name }}
</li>
</ul>
</div>
</template>
```
##### DON'T: 에러/로딩 처리 누락
```vue
<script setup lang="ts">
// 금지: 에러/로딩 상태 무시
const { data: products } = await useAsyncData('products', () => $fetch('/api/products'))
</script>
<template>
<!-- 로딩/에러 상태 없이 바로 렌더링 -->
<ul>
<li v-for="product in products" :key="product.id">{{ product.name }}</li>
</ul>
</template>
```
#### 4-4. $fetch vs useFetch 선택
| 사용 위치 | 권장 방식 | 이유 |
|-----------|----------|------|
| `<script setup>` 최상단 (SSR) | `useFetch` / `useAsyncData` | SSR 중복 요청 방지, 하이드레이션 |
| 이벤트 핸들러 내부 | `$fetch` | 반응형/캐싱 불필요 |
| 서버 미들웨어/API | `$fetch` | 서버 컨텍스트 |
```vue
<script setup lang="ts">
// SSR에서 실행: useFetch/useAsyncData 사용
const { data: products } = await useAsyncData('products', () =>
$fetch<Product[]>('/api/products')
)
// 이벤트 핸들러: $fetch 직접 사용
async function handleSubmit(form: ProductForm) {
await $fetch('/api/products', { method: 'POST', body: form })
await refresh()
}
</script>
```
---
### Rule 5: 상태관리 (Pinia)
#### 5-1. Store 작성 패턴 (Composition API 스타일)
##### DO: Composition API 스타일
```typescript
// app/stores/useAuthStore.ts
export const useAuthStore = defineStore('auth', () => {
// 상태
const user = ref<User | null>(null)
const token = ref<string | null>(null)
// 게터 (computed)
const isAuthenticated = computed(() => !!user.value && !!token.value)
const fullName = computed(() => {
if (!user.value) return ''
return `${user.value.firstName} ${user.value.lastName}`
})
// 액션
async function login(credentials: LoginCredentials) {
const response = await $fetch<AuthResponse>('/api/auth/login', {
method: 'POST',
body: credentials,
})
user.value = response.user
token.value = response.token
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
user.value = null
token.value = null
}
// 초기화 (SSR 호환)
async function init() {
if (token.value) {
user.value = await $fetch<User>('/api/auth/me')
}
}
return { user: readonly(user), token: readonly(token), isAuthenticated, fullName, login, logout, init }
})
```
##### DON'T: Options API 스타일 (일관성 저해)
```typescript
// 금지: Options API 스타일 혼용
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
doubled: (state) => state.count * 2,
},
actions: {
increment() { this.count++ },
},
})
```
#### 5-2. Store 사용 규칙
```vue
<script setup lang="ts">
const authStore = useAuthStore()
// 구조분해 시 storeToRefs 사용 (반응성 유지)
const { user, isAuthenticated } = storeToRefs(authStore)
// 액션은 직접 구조분해 가능
const { login, logout } = authStore
</script>
```
---
### Rule 6: 레이아웃
#### DO: 올바른 레이아웃 사용
```vue
<!-- app/layouts/default.vue -->
<template>
<div class="flex min-h-screen flex-col">
<AppHeader />
<main id="main-content" class="flex-1">
<slot />
</main>
<AppFooter />
</div>
</template>
```
```vue
<!-- app/pages/dashboard.vue -->
<script setup lang="ts">
definePageMeta({
layout: 'dashboard', // layouts/dashboard.vue 적용
})
</script>
```
```vue
<!-- app/pages/auth/login.vue -->
<script setup lang="ts">
definePageMeta({
layout: false, // 레이아웃 비활성화 (전체 화면)
})
</script>
```
#### 동적 레이아웃 변경
```vue
<script setup lang="ts">
const route = useRoute()
// 반응형으로 레이아웃 변경 (로그인 상태에 따라)
definePageMeta({
layout: 'default',
})
const authStore = useAuthStore()
// 인증 상태에 따른 레이아웃 동적 전환
watchEffect(() => {
setPageLayout(authStore.isAuthenticated ? 'dashboard' : 'default')
})
</script>
```
---
### Rule 7: 미들웨어
미들웨어는 라우트 이동 전에 실행된다. `navigateTo()` 또는 `abortNavigation()`으로 흐름을 제어한다.
#### DO: 올바른 미들웨어 작성
```typescript
// app/middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
// 로그인 후 원래 페이지로 돌아오기 위해 redirect 파라미터 추가
return navigateTo({
path: '/auth/login',
query: { redirect: to.fullPath },
})
}
})
```
```typescript
// app/middleware/guest.ts (로그인한 사용자는 접근 불가)
export default defineNuxtRouteMiddleware(() => {
const authStore = useAuthStore()
if (authStore.isAuthenticated) {
return navigateTo('/dashboard')
}
})
```
```typescript
// app/middleware/role-check.ts (역할 기반 접근 제어)
export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore()
const requiredRole = to.meta.requiredRole as string | undefined
if (requiredRole && authStore.user?.role !== requiredRole) {
return abortNavigation({
statusCode: 403,
message: '접근 권한이 없습니다.',
})
}
})
```
#### DON'T: 미들웨어 안티 패턴
```typescript
// 금지: navigateTo 없이 window.location 사용 (SSR 오류 발생)
export default defineNuxtRouteMiddleware(() => {
window.location.href = '/login' // 서버에서 오류!
})
// 금지: 반환값 없는 조건부 리다이렉트 (미들웨어가 계속 실행됨)
export default defineNuxtRouteMiddleware((to) => {
if (!isAuthenticated) {
navigateTo('/login') // return 누락! 이후 코드도 계속 실행됨
}
// ... 이후 코드도 실행됨
})
```
---
### Rule 8: 플러그인
플러그인은 앱 인스턴스 생성 시 한 번 실행된다. 전역 기능/서비스 등록에 사용한다.
#### DO: 올바른 플러그인 작성
```typescript
// app/plugins/toast.client.ts (.client.ts: 클라이언트에서만 실행)
import { defineNuxtPlugin } from '#app'
export default defineNuxtPlugin(() => {
// provide로 전역 주입 (타입 선언 필수)
return {
provide: {
toast: {
success: (message: string) => { /* 구현 */ },
error: (message: string) => { /* 구현 */ },
},
},
}
})
```
```typescript
// 플러그인 provide 타입 선언 (shared/types/nuxt.d.ts)
declare module '#app' {
interface NuxtApp {
$toast: {
success: (message: string) => void
error: (message: string) => void
}
}
}
declare module 'vue' {
interface ComponentCustomProperties {
$toast: NuxtApp['$toast']
}
}
export {}
```
#### 플러그인 실행 순서
파일명 숫자 prefix로 실행 순서를 제어한다.
```
plugins/
├── 01.i18n.ts # 먼저 실행
├── 02.auth.ts # 두 번째
└── toast.client.ts # 클라이언트만 (순서 무관)
```
---
### Rule 9: 서버 API (Nitro)
#### DO: 올바른 서버 API 작성
```typescript
// server/api/products/index.get.ts
import { z } from 'zod'
// 쿼리 파라미터 스키마
const QuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
category: z.string().optional(),
})
export default defineEventHandler(async (event) => {
// 입력 유효성 검사
const query = await getValidatedQuery(event, QuerySchema.parse)
// 서버 전용 로직
const products = await fetchProductsFromDB({
page: query.page,
limit: query.limit,
category: query.category,
})
return { products, total: products.length, page: query.page }
})
```
```typescript
// server/api/products/[id].delete.ts (메서드 지정)
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
if (!id) {
throw createError({ statusCode: 400, message: '상품 ID가 필요합니다.' })
}
await deleteProductById(id)
setResponseStatus(event, 204)
})
```
#### 서버 API 파일명 패턴
| 파일명 | HTTP 메서드 |
|--------|-----------|
| `index.get.ts` | GET |
| `index.post.ts` | POST |
| `[id].put.ts` | PUT |
| `[id].patch.ts` | PATCH |
| `[id].delete.ts` | DELETE |
| `index.ts` | 모든 메서드 (내부에서 분기) |
#### DON'T: 서버 API 안티 패턴
```typescript
// 금지: 입력 유효성 검사 없이 바로 사용
export default defineEventHandler(async (event) => {
const body = await readBody(event)
await db.insert(body) // 검증 없이 직접 DB 삽입 → SQL 인젝션, 타입 오류 위험
})
// 금지: 클라이언트 코드에서 직접 DB 접근
// server/ 디렉토리 밖에서 DB 드라이버 임포트 금지
import { db } from '@/server/utils/db' // 클라이언트 번들에 포함됨!
```
---
### Rule 10: 환경변수 & 설정
#### DO: runtimeConfig 사용
```typescript
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// 서버 전용 (클라이언트에 노출 안 됨)
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
// 클라이언트에 노출 (public)
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? '/api',
appVersion: process.env.NUXT_PUBLIC_APP_VERSION ?? '1.0.0',
},
},
})
```
```typescript
// 서버 사이드에서 사용
// server/api/products/index.get.ts
export default defineEventHandler(() => {
const config = useRuntimeConfig()
const dbUrl = config.databaseUrl // 서버 전용 값
})
// 클라이언트/서버 공통에서 사용
// app/composables/useApi.ts
export function useApi() {
const config = useRuntimeConfig()
return config.public.apiBase // public 값만 접근 가능
}
```
#### DON'T: process.env 직접 접근
```typescript
// 금지: 클라이언트에서 process.env 직접 접근 (빌드 후 undefined)
const apiUrl = process.env.API_URL // 클라이언트에서 작동 안 함
// 금지: 서버 전용 환경변수를 public에 노출
runtimeConfig: {
public: {
jwtSecret: process.env.JWT_SECRET, // 클라이언트에 노출됨!
},
}
```
---
### Rule 11: 금지사항 (Anti-Patterns)
#### Anti-Pattern 1: SSR과 클라이언트 코드 혼용
```typescript
// 금지: SSR에서 실행되면 'window is not defined' 오류
export function useBrowserFeature() {
const width = window.innerWidth // 서버에서 오류!
}
// 올바른 예: process.client 또는 onMounted에서 접근
export function useBrowserFeature() {
const width = ref(0)
onMounted(() => {
width.value = window.innerWidth
})
return { width }
}
```
#### Anti-Pattern 2: 컴포저블 밖에서 Vue 반응형 API 사용
```typescript
// 금지: 컴포저블/컴포넌트 설정 외부에서 사용
const globalRef = ref(0) // 모듈 레벨 → SSR에서 요청 간 공유됨!
// 올바른 예: Pinia store 또는 컴포저블 내부에서 사용
export const useGlobalCounter = defineStore('counter', () => {
const count = ref(0)
return { count }
})
```
#### Anti-Pattern 3: useAsyncData key 중복
```typescript
// 금지: 같은 key를 다른 컴포넌트에서 사용하면 데이터 공유됨
// ProductList.vue
const { data } = useAsyncData('items', () => $fetch('/api/products'))
// UserList.vue
const { data } = useAsyncData('items', () => $fetch('/api/users')) // 같은 캐시!
// 올바른 예: 고유한 key 사용
// ProductList.vue
const { data } = useAsyncData('product-list', () => $fetch('/api/products'))
// UserList.vue
const { data } = useAsyncData('user-list', () => $fetch('/api/users'))
```
#### Anti-Pattern 4: 미들웨어에서 return 누락
```typescript
// 금지: return 없으면 리다이렉트 후에도 계속 실행
export default defineNuxtRouteMiddleware(() => {
if (!isAuthenticated) {
navigateTo('/login') // return 없음 → 이후 코드 실행됨
}
// 인증 안 된 상태에서도 이 코드가 실행됨!
checkAdminPermission()
})
// 올바른 예
export default defineNuxtRouteMiddleware(() => {
if (!isAuthenticated) {
return navigateTo('/login') // return으로 즉시 종료
}
checkAdminPermission()
})
```
#### Anti-Pattern 5: 서버 API에서 입력값 무검증
```typescript
// 금지: 사용자 입력 직접 사용
export default defineEventHandler(async (event) => {
const { name, email } = await readBody(event)
await db.users.insert({ name, email }) // 유효성 검사 없음!
})
// 올바른 예: 스키마로 검증
import { z } from 'zod'
const UserSchema = z.object({
name: z.string().min(1).max(50),
email: z.string().email(),
})
export default defineEventHandler(async (event) => {
const body = await readValidatedBody(event, UserSchema.parse)
await db.users.insert(body)
})
```
#### Anti-Pattern 6: definePageMeta 조건부 실행
```vue
<script setup lang="ts">
const isAdmin = computed(() => authStore.user?.role === 'admin')
// 금지: definePageMeta는 컴파일 타임에 정적으로 분석됨
if (isAdmin.value) {
definePageMeta({ layout: 'admin' })
}
// 올바른 예: 정적으로 선언 후 동적 변경
definePageMeta({ layout: 'default' })
watchEffect(() => {
if (isAdmin.value) setPageLayout('admin')
})
</script>
```
---
## 자주 하는 실수 TOP 5
### 1. useAsyncData key 미지정 또는 중복
key를 생략하거나 범용 이름을 사용하면 다른 컴포넌트의 데이터와 캐시가 충돌한다.
```typescript
// 실수: key 없음 또는 범용 이름
const { data } = useAsyncData(() => $fetch('/api/products'))
const { data } = useAsyncData('list', () => $fetch('/api/products'))
// 해결: 고유하고 명확한 key
const { data } = useAsyncData(
`product-list-${route.query.page}`,
() => $fetch('/api/products', { params: { page: route.query.page } })
)
```
### 2. Pinia store 구조분해 시 storeToRefs 누락
직접 구조분해하면 반응성이 사라진다.
```typescript
// 실수: 반응성 손실
const { user, isAuthenticated } = useAuthStore() // user는 더 이상 반응형이 아님
// 해결: storeToRefs로 ref 변환
const { user, isAuthenticated } = storeToRefs(useAuthStore())
```
### 3. 클라이언트 전용 API를 SSR에서 호출
`window`, `document`, `localStorage` 등은 서버에 없다.
```typescript
// 실수: 서버에서 오류
const savedTheme = localStorage.getItem('theme')
// 해결: onMounted 또는 process.client 체크
onMounted(() => {
const savedTheme = localStorage.getItem('theme')
})
// 또는 플러그인 파일명에 .client.ts 사용
```
### 4. 미들웨어에서 navigateTo() return 누락
return 없으면 조건이 충족되어도 이후 코드가 계속 실행된다.
```typescript
// 실수: return 누락
export default defineNuxtRouteMiddleware(() => {
if (!isAuthenticated) navigateTo('/login')
doSomethingElse() // 인증 안 된 상태에서도 실행됨!
})
// 해결: return 필수
export default defineNuxtRouteMiddleware(() => {
if (!isAuthenticated) return navigateTo('/login')
doSomethingElse()
})
```
### 5. process.env 클라이언트에서 직접 사용
Nuxt 빌드 후 클라이언트 번들에서 `process.env``undefined`가 된다.
```typescript
// 실수
const apiUrl = process.env.API_URL // 클라이언트에서 undefined
// 해결: runtimeConfig 사용
const config = useRuntimeConfig()
const apiUrl = config.public.apiBase // 안전하게 클라이언트 접근 가능
```

View File

@@ -1,995 +0,0 @@
# TailwindCSS v4 스타일링 전략 Rules
> Nuxt 4 + Vue 3 + TailwindCSS v4 + shadcn-vue 환경 기준
> 최종 업데이트: 2026-04-07
---
## 빠른 참조 체크리스트
스타일링 작업 전 아래 항목을 확인한다.
- [ ] 클래스 순서가 레이아웃 -> 박스 -> 타이포 -> 시각 -> 인터랙티브 순인가?
- [ ] 기본 스타일이 모바일 기준이고, `sm:` / `md:` / `lg:` 순서로 확장하는가?
- [ ] 동적 클래스를 문자열 보간(`` `bg-${color}-500` ``)으로 생성하지 않았는가?
- [ ] 색상/간격에 하드코딩 값(`bg-[#1a1a2e]`, `mt-[17px]`) 대신 CSS 변수 또는 디자인 토큰을 사용했는가?
- [ ] 다크모드 대응 시 CSS 변수(`bg-background`, `text-foreground`)를 우선 사용했는가?
- [ ] `@apply`가 3개 이상 컴포넌트에서 반복되는 복합 패턴에만 사용되었는가?
- [ ] 변형(variant) 처리는 `cva` + `cn()` 패턴을 사용하는가?
- [ ] shadcn-vue 컴포넌트를 직접 수정하지 않고 래퍼 컴포넌트로 확장했는가?
- [ ] `prettier-plugin-tailwindcss`가 설정되어 자동 정렬이 적용되는가?
- [ ] `!important`(`!` prefix)를 사용하지 않았는가?
---
## 규칙 상세
### Rule 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:
```
**DO**
```vue
<template>
<!-- 레이아웃 -> 박스 -> 플렉스 -> 타이포 -> 시각 -> 인터랙티브 순서 -->
<button
class="
inline-flex
h-10 rounded-md px-4 py-2
items-center justify-center gap-2
text-sm font-medium
bg-primary text-primary-foreground shadow-sm
transition-colors duration-200
cursor-pointer
hover:bg-primary/90
focus-visible:ring-2 focus-visible:ring-ring
disabled:cursor-not-allowed disabled:opacity-50
"
>
저장
</button>
</template>
```
**DON'T**
```vue
<template>
<!-- 순서 뒤섞임: 가독성 저하 일관성 훼손 -->
<button
class="
hover:bg-primary/90 bg-primary text-sm cursor-pointer
px-4 py-2 inline-flex font-medium shadow-sm
h-10 rounded-md items-center disabled:opacity-50
"
>
저장
</button>
</template>
```
**Prettier 설정**
```json
// .prettierrc
{
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindConfig": "./tailwind.config.ts",
"tailwindFunctions": ["cn", "cva", "clsx", "twMerge"]
}
```
> `tailwindFunctions`에 `cn`, `cva` 등을 등록해야 유틸 함수 내부의 클래스도 자동 정렬된다.
---
### Rule 2: Mobile-first 반응형 (기본=모바일, sm/md/lg/xl 순서)
기본 스타일은 모바일 기준으로 작성하고, 더 큰 화면에서 순서대로 덮어쓴다.
```
기본(base) -> 0px~ : 모바일 (prefix 없음)
sm -> 640px~ : 대형 모바일, 소형 태블릿
md -> 768px~ : 태블릿
lg -> 1024px~ : 소형 데스크탑
xl -> 1280px~ : 데스크탑
2xl -> 1536px~ : 대형 모니터
```
**DO**
```vue
<template>
<!-- Mobile-first: 기본 1 -> sm 2 -> lg 3 -> xl 4 -->
<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>
```
**DON'T**
```vue
<template>
<!-- Desktop-first: max-* 남용으로 역방향 덮어쓰기 발생 -->
<div class="grid-cols-4 max-xl:grid-cols-3 max-lg:grid-cols-2 max-sm:grid-cols-1">
<!-- 가독성 저하, 모바일 기준 불명확 -->
</div>
<!-- 변경되지 않는 값에 불필요한 반응형 prefix 반복 -->
<p class="text-xs sm:text-xs md:text-sm lg:text-sm">...</p>
<!-- text-xs sm:text-sm 으로 충분 -->
</template>
```
**반응형 타이포그래피 패턴**
```vue
<template>
<!-- 모바일에서 작게, 태블릿부터 크게 -->
<h1 class="text-2xl font-bold sm:text-3xl lg:text-4xl">페이지 제목</h1>
<!-- 모바일에서 세로 정렬, 데스크탑에서 가로 정렬 -->
<div class="flex flex-col gap-4 md:flex-row md:items-center md:gap-8">
<div>왼쪽 콘텐츠</div>
<div>오른쪽 콘텐츠</div>
</div>
</template>
```
---
### Rule 3: 컴포넌트 스타일링 (cva+cn() 패턴, @apply 기준, shadcn-vue 확장)
#### cn() 유틸리티
`clsx` + `tailwind-merge`를 조합한 `cn()` 함수를 사용한다. 조건부 클래스 병합 및 Tailwind 클래스 충돌 해결을 자동으로 처리한다.
```typescript
// 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))
}
```
**DO**
```vue
<script setup lang="ts">
import { cn } from '@/lib/utils'
interface Props {
class?: string
active?: boolean
}
const props = defineProps<Props>()
</script>
<template>
<!-- cn()으로 외부 클래스와 내부 클래스를 안전하게 병합 -->
<div :class="cn(
'rounded-lg border p-4 transition-colors',
props.active && 'border-primary bg-primary/5',
props.class
)">
<slot />
</div>
</template>
```
**DON'T**
```vue
<template>
<!-- 클래스 충돌 예측 불가 (p-4 p-2 동시 적용) -->
<div :class="`rounded-lg border p-4 ${props.class}`">
<slot />
</div>
</template>
```
#### cva (class-variance-authority) 패턴
변형(variant)이 있는 컴포넌트는 반드시 `cva`로 정의한다.
**DO**
```typescript
// app/components/ui/badge/index.ts
import { cva, type VariantProps } from 'class-variance-authority'
export const badgeVariants = cva(
// 기본 클래스
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground',
secondary: 'bg-secondary text-secondary-foreground',
outline: 'border border-input text-foreground',
destructive: 'bg-destructive text-destructive-foreground',
},
},
defaultVariants: { variant: 'default' },
}
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
```
```vue
<!-- app/components/ui/badge/Badge.vue -->
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { badgeVariants, type BadgeVariants } from '.'
interface Props {
variant?: BadgeVariants['variant']
class?: string
}
const props = withDefaults(defineProps<Props>(), { variant: 'default' })
</script>
<template>
<span :class="cn(badgeVariants({ variant: props.variant }), props.class)">
<slot />
</span>
</template>
```
**DON'T**
```vue
<script setup lang="ts">
// v-if 분기로 변형 처리 -> 유지보수 어려움, 누락 위험
const props = defineProps<{ variant: string }>()
</script>
<template>
<span
:class="{
'bg-primary text-white': props.variant === 'default',
'bg-gray-200 text-gray-800': props.variant === 'secondary',
'border text-gray-800': props.variant === 'outline',
}"
>
<slot />
</span>
</template>
```
#### @apply 사용 기준
`@apply`는 **3개 이상의 컴포넌트에서 반복되는 복합 패턴**에만 허용한다.
**DO**
```css
/* 허용: 여러 컴포넌트에서 반복 사용되는 복합 패턴 */
.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;
}
```
**DON'T**
```css
/* 금지: 단일 클래스 추상화 -> 의미 없는 간접참조 */
.text-blue { @apply text-blue-500; }
.mt-small { @apply mt-2; }
.flex-center { @apply flex items-center justify-center; }
```
#### shadcn-vue 커스터마이징
shadcn-vue 컴포넌트 원본 파일을 직접 수정하지 않는다. 래퍼 컴포넌트로 확장한다.
**DO**
```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>
```
**DON'T**
```vue
<!-- 금지: shadcn-vue 원본 파일(components/ui/button/Button.vue) 직접 수정 -->
<!-- 업데이트 충돌 발생, 변경 추적 불가 -->
```
---
### Rule 4: 다크모드 (CSS 변수 우선, dark: prefix 보완용)
CSS 변수 기반 테마를 우선 사용한다. `dark:` prefix는 CSS 변수로 처리할 수 없는 경우에만 보완 용도로 사용한다.
**CSS 변수 정의**
```css
/* 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%;
}
```
**DO**
```vue
<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">
<!-- shadow 값은 CSS 변수로 관리하기 어렵기 때문에 dark: 사용 -->
</div>
</template>
```
**DON'T**
```vue
<template>
<!-- 금지: 모든 속성에 dark: prefix 중복 사용 -->
<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-white border-gray-200 dark:border-gray-700 p-4">
<h2 class="text-gray-900 dark:text-white">제목</h2>
<p class="text-gray-600 dark:text-gray-400">설명</p>
</div>
<!-- CSS 변수를 쓰면 dark: 없이 해결 -->
</template>
```
**다크모드 토글 구현**
```vue
<script setup lang="ts">
// useColorMode (Nuxt 내장)으로 다크모드 전환
const colorMode = useColorMode()
function toggleDark() {
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
}
</script>
<template>
<button type="button" @click="toggleDark" aria-label="테마 전환">
<SunIcon v-if="colorMode.value === 'dark'" class="h-5 w-5" />
<MoonIcon v-else class="h-5 w-5" />
</button>
</template>
```
---
### Rule 5: 상태 기반 스타일링 (hover/focus/active/disabled, group, peer)
#### 기본 상태 변형자 순서
상태 변형자는 `hover` -> `focus-visible` -> `active` -> `disabled` 순서로 작성한다.
**DO**
```vue
<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>
```
**DON'T**
```vue
<template>
<!-- focus 대신 focus-visible 사용: 마우스 클릭 불필요한 포커스 방지 -->
<button
class="
rounded-md bg-primary px-4 py-2
focus:ring-2 focus:ring-blue-500
focus:outline-none
"
>
제출
</button>
<!-- outline: none 전역 제거는 접근성 위반 -->
</template>
```
#### group 패턴 (부모 상태 -> 자식 스타일)
부모 요소에 `group` 클래스를 부여하고, 자식에서 `group-hover:`, `group-focus:` 등으로 반응한다.
**DO**
```vue
<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>
<p class="text-muted-foreground">카드 설명</p>
<ArrowRight
class="absolute right-4 top-4 text-muted-foreground transition-transform group-hover:translate-x-1 group-hover:text-primary"
/>
</div>
</template>
```
**중첩 group이 필요한 경우** `group/{name}` 문법을 사용한다.
```vue
<template>
<div class="group/card rounded-xl border p-6">
<div class="group/header flex items-center gap-2">
<h3 class="group-hover/header:underline">제목</h3>
</div>
<p class="group-hover/card:text-foreground">본문</p>
</div>
</template>
```
#### peer 패턴 (형제 상태 -> 형제 스타일)
`peer` 클래스를 가진 요소의 상태에 따라 **뒤에 오는 형제** 요소의 스타일을 변경한다.
**DO**
```vue
<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>
<!-- 입력 필드 유효성에 따른 메시지 표시 -->
<div>
<input type="email" class="peer w-full rounded-md border px-3 py-2 peer-invalid:border-red-500" required />
<p class="mt-1 hidden text-sm text-red-500 peer-invalid:block">
올바른 이메일을 입력하세요.
</p>
</div>
</template>
```
**DON'T**
```vue
<template>
<!-- 금지: peer 요소보다 앞에 있는 형제는 peer 상태를 참조할 없다 -->
<span class="peer-checked:font-medium">라벨</span>
<input type="checkbox" class="peer" />
<!-- peer 대상은 반드시 형제보다 먼저 DOM에 위치해야 한다 -->
</template>
```
---
### Rule 6: TailwindCSS v4 신기능 (@theme, @container 쿼리)
#### @theme 디렉티브 (CSS-first 설정)
TailwindCSS v4에서는 `tailwind.config.ts` 대신 CSS 파일 내 `@theme` 디렉티브로 토큰을 정의한다.
**DO**
```css
/* 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; }
}
```
```vue
<template>
<!-- @theme에서 정의한 토큰은 일반 Tailwind 클래스처럼 사용 가능 -->
<div class="font-sans text-brand-500 shadow-soft animate-fade-in p-18">
커스텀 토큰 사용 예시
</div>
</template>
```
**DON'T**
```css
/* 금지: v4에서 @theme 대신 옛 방식(tailwind.config.ts extend)으로 토큰 정의 */
/* tailwind.config.ts의 theme.extend는 여전히 동작하지만 @theme이 권장 방식 */
```
#### @container 쿼리
뷰포트가 아닌 **부모 컨테이너 크기** 기반으로 반응형 스타일을 적용한다. 사이드바, 카드 리스트 등 배치 위치에 따라 크기가 달라지는 컴포넌트에 유용하다.
**DO**
```vue
<template>
<!-- 부모에 @container 선언 -->
<div class="@container">
<!-- 컨테이너 크기 기반 반응형: @sm, @md, @lg 사용 -->
<div class="grid grid-cols-1 gap-4 @sm:grid-cols-2 @lg:grid-cols-3">
<ProductCard v-for="item in items" :key="item.id" :product="item" />
</div>
</div>
<!-- 이름이 있는 컨테이너: 중첩 특정 조상 참조 -->
<div class="@container/sidebar">
<nav class="flex flex-col @md/sidebar:flex-row">
<!-- sidebar 컨테이너 크기 기준으로 레이아웃 변경 -->
</nav>
</div>
</template>
```
**DON'T**
```vue
<template>
<!-- 금지: 뷰포트 반응형으로 컨테이너 내부 레이아웃 제어 -->
<!-- 사이드바에 배치하면 의도와 다르게 동작 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
<!-- 뷰포트가 lg여도 사이드바 안에서는 공간이 좁다 -->
</div>
</template>
```
---
### Rule 7: 성능 최적화 (동적 클래스 생성 금지, 클래스 매핑 패턴)
TailwindCSS v4는 소스 코드를 정적 분석하여 사용된 클래스만 번들에 포함한다. 런타임에 문자열을 조합하면 빌드 시 해당 클래스가 누락된다.
#### 동적 클래스 생성 금지 (Critical)
**DO**
```vue
<script setup lang="ts">
// 완전한 클래스명을 객체 맵으로 정의
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>
<template>
<span :class="colorClass">{{ props.label }}</span>
</template>
```
**DON'T**
```vue
<script setup lang="ts">
// 금지: 문자열 보간으로 클래스 동적 생성 -> 빌드 시 누락됨
const badClass = `text-${props.color}-500`
const badBg = `bg-${props.color}-100`
</script>
<template>
<!-- 금지: 템플릿에서도 동적 문자열 조합 금지 -->
<span :class="`text-${color}-500 bg-${color}-100`">{{ label }}</span>
</template>
```
#### 조건부 클래스 패턴
**DO**
```vue
<template>
<!-- 삼항 연산자: 완전한 클래스명 사용 -->
<div :class="isActive ? 'bg-primary text-white' : 'bg-muted text-muted-foreground'">
콘텐츠
</div>
<!-- 객체 문법: 완전한 클래스명 사용 -->
<div :class="{
'border-primary bg-primary/5': isSelected,
'border-border bg-background': !isSelected,
'opacity-50 pointer-events-none': isDisabled,
}">
콘텐츠
</div>
</template>
```
**DON'T**
```vue
<script setup lang="ts">
// 금지: 배열 인덱스나 변수로 Tailwind 클래스 구성
const sizes = ['text-sm', 'text-base', 'text-lg']
const sizeClass = sizes[props.sizeIndex] // 정적 분석 불가
</script>
```
---
### Rule 8: 재사용 패턴 (디자인 토큰, 상태 클래스 매핑)
#### 디자인 토큰 (CSS 변수 체계)
프로젝트 전역에서 사용하는 레이아웃 수치, z-index, 트랜지션 등은 CSS 변수로 중앙 관리한다.
```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);
}
```
```vue
<template>
<!-- 토큰 참조: 임의값(arbitrary value) 문법으로 CSS 변수 활용 -->
<header class="sticky top-0 z-[var(--z-sticky)] h-[var(--header-height)]">
<!-- 헤더 내용 -->
</header>
</template>
```
#### 상태별 클래스 매핑
반복되는 상태 스타일을 타입 안전한 매핑 객체로 관리한다.
**DO**
```typescript
// 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
```
```vue
<script setup lang="ts">
import { STATUS_STYLES } from '@/utils/statusStyles'
interface Props {
status: keyof typeof STATUS_STYLES
label: string
}
const props = defineProps<Props>()
</script>
<template>
<span :class="['inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium', STATUS_STYLES[props.status].badge]">
{{ props.label }}
</span>
</template>
```
**DON'T**
```vue
<template>
<!-- 금지: 매번 인라인으로 상태별 스타일 작성 -> 불일치 발생 -->
<span
:class="{
'bg-green-100 text-green-800': status === 'success',
'bg-yellow-100 text-yellow-800': status === 'warning',
'bg-red-100 text-red-800': status === 'error',
}"
>
{{ label }}
</span>
<!-- 다른 컴포넌트에서 같은 상태인데 다른 색상 사용 위험 -->
</template>
```
---
### Rule 9: 금지사항 (Anti-patterns 코드 예시)
#### 1. 인라인 스타일 사용 금지
```vue
<!-- DON'T -->
<div :style="{ marginTop: '16px', color: '#3b82f6' }">콘텐츠</div>
<!-- DO -->
<div class="mt-4 text-blue-500">콘텐츠</div>
```
> 예외: JS로 계산된 동적 수치(드래그 위치, 애니메이션 좌표 등)는 인라인 스타일 허용.
#### 2. 동적 클래스 문자열 생성 금지
```typescript
// DON'T
const cls = `bg-${color}-500`
const size = `text-${props.size}`
// DO
const COLOR_MAP = { blue: 'bg-blue-500', red: 'bg-red-500' } as const
const cls = COLOR_MAP[color]
```
#### 3. !important 남용 금지
```vue
<!-- DON'T -->
<div class="!mt-0 !p-0 !text-sm">강제 오버라이드</div>
<!-- DO: 클래스 순서나 cn()으로 충돌 해결 -->
<div :class="cn('mt-4 p-4', props.class)">올바른 병합</div>
```
> `!important`가 필요한 상황은 대부분 클래스 적용 순서 문제. `cn()`이나 구조 변경으로 해결한다.
#### 4. @apply 과용 금지
```css
/* DON'T: 단일 유틸리티 추상화 */
.text-blue { @apply text-blue-500; }
.mt-small { @apply mt-2; }
.flex-center { @apply flex items-center justify-center; }
/* DO: 3개 이상 컴포넌트에서 공유하는 복합 패턴에만 사용 */
.input-base {
@apply w-full rounded-md border border-input bg-background px-3 py-2
text-sm ring-offset-background
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring;
}
```
#### 5. Magic number 임의값 남용 금지
```vue
<!-- DON'T -->
<div class="mt-[17px] w-[213px] text-[13.5px] bg-[#1a1a2e]">
디자인 시스템 무시
</div>
<!-- DO: 디자인 토큰 또는 가장 가까운 표준값 사용 -->
<div class="mt-4 w-52 text-sm bg-background">
디자인 시스템 준수
</div>
```
> 임의값(`[...]`)은 디자인 토큰에 없는 값이 **정말로** 필요한 경우에만 최소한으로 사용한다. 반복되면 `@theme`에 토큰으로 등록한다.
#### 6. 하드코딩 색상 금지
```vue
<!-- DON'T -->
<div class="bg-[#1a1a2e] text-[#eaeaea]">하드코딩 색상</div>
<!-- DO: CSS 변수 또는 Tailwind 색상 팔레트 사용 -->
<div class="bg-background text-foreground">CSS 변수 사용</div>
```
#### 7. 불필요한 반응형 클래스 반복 금지
```vue
<!-- DON'T: 값이 변하지 않는 브레이크포인트에서 동일 반복 -->
<p class="text-xs sm:text-xs md:text-sm lg:text-sm xl:text-base">텍스트</p>
<!-- DO: 값이 변경되는 브레이크포인트만 명시 -->
<p class="text-xs md:text-sm xl:text-base">텍스트</p>
```
---
## 자주 하는 실수 TOP 5
### 1. 문자열 보간으로 동적 클래스 생성
가장 흔하고 치명적인 실수. TailwindCSS v4의 정적 분석에 감지되지 않아 프로덕션에서 스타일이 누락된다.
```typescript
// 실수
const colorClass = `text-${props.color}-500` // 빌드 시 누락
// 해결
const COLOR_MAP: Record<string, string> = {
blue: 'text-blue-500',
red: 'text-red-500',
} as const
const colorClass = computed(() => COLOR_MAP[props.color] ?? 'text-blue-500')
```
### 2. Desktop-first로 반응형 작성
큰 화면 기준으로 먼저 작성하고 `max-*`로 축소하면 모바일에서 예상치 못한 스타일 충돌이 발생한다.
```vue
<!-- 실수 -->
<div class="grid-cols-4 max-lg:grid-cols-2 max-sm:grid-cols-1">
<!-- 해결: 항상 Mobile-first -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
```
### 3. CSS 변수 대신 dark: prefix 남발
모든 요소에 `dark:` 변형을 달면 코드량이 2배로 늘어나고 관리가 어려워진다.
```vue
<!-- 실수 -->
<div class="bg-white text-black dark:bg-slate-900 dark:text-white">
<!-- 해결: CSS 변수 사용 -->
<div class="bg-background text-foreground">
```
### 4. cn() 없이 외부 클래스를 문자열로 합침
`cn()` 없이 단순 문자열 연결을 하면 Tailwind 클래스 간 충돌(예: `p-4``p-2`)이 해결되지 않아 예측 불가능한 스타일이 적용된다.
```vue
<!-- 실수 -->
<div :class="`base-classes ${props.class}`">
<!-- 해결 -->
<div :class="cn('base-classes', props.class)">
```
### 5. focus 대신 focus-visible 사용 누락
`focus:`는 마우스 클릭에도 포커스 링이 표시되어 시각적 노이즈를 만든다. `focus-visible:`은 키보드 탐색 시에만 표시된다.
```vue
<!-- 실수 -->
<button class="focus:ring-2 focus:ring-blue-500 focus:outline-none">
<!-- 해결 -->
<button class="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
```