📝 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:
@@ -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;">
|
||||
미리보기 텍스트가 여기에 표시됩니다. ‌ ‌ ‌ ‌ ‌
|
||||
</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;"> </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;"> </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;"> </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;"> </td>
|
||||
</tr>
|
||||
|
||||
<!-- spacer td로 열 간격 -->
|
||||
<td width="20" style="width: 20px; font-size: 0; line-height: 0;"> </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;"
|
||||
>
|
||||
|
||||
</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;"> </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; }
|
||||
}
|
||||
```
|
||||
@@ -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">↗</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>
|
||||
```
|
||||
@@ -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 // 안전하게 클라이언트 접근 가능
|
||||
```
|
||||
@@ -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">
|
||||
```
|
||||
Reference in New Issue
Block a user