- 52개 이상의 컴포넌트에 isNextRedirectError 처리 추가 - Server Action의 redirect() 에러가 catch 블록에서 삼켜지는 문제 해결 - access_token + refresh_token 모두 만료 시 정상적으로 로그인 페이지로 리다이렉트 수정된 영역: - accounting: 10개 컴포넌트 - production: 12개 컴포넌트 - hr: 5개 컴포넌트 - settings: 8개 컴포넌트 - approval: 5개 컴포넌트 - items: 20개+ 컴포넌트 - board: 5개 컴포넌트 - quality: 4개 컴포넌트 - material, outbound, quotes 등 기타 컴포넌트 Co-Authored-By: Claude <noreply@anthropic.com>
538 lines
12 KiB
Markdown
538 lines
12 KiB
Markdown
# 모바일 반응형 패턴 가이드
|
|
|
|
> 작성일: 2026-01-10
|
|
> 적용 범위: SAM 프로젝트 전체
|
|
> 주요 대상 기기: Galaxy Z Fold 5 (접힌 상태 344px)
|
|
|
|
---
|
|
|
|
## 1. 브레이크포인트 정의
|
|
|
|
### 1.1 Tailwind 기본 브레이크포인트
|
|
|
|
| 접두사 | 최소 너비 | 대상 기기 |
|
|
|--------|----------|----------|
|
|
| (기본) | 0px | Galaxy Fold 접힌 (344px) |
|
|
| `xs` | 375px | iPhone SE, 소형 모바일 |
|
|
| `sm` | 640px | 대형 모바일, 소형 태블릿 |
|
|
| `md` | 768px | 태블릿 |
|
|
| `lg` | 1024px | 소형 데스크탑 |
|
|
| `xl` | 1280px | 데스크탑 |
|
|
| `2xl` | 1536px | 대형 데스크탑 |
|
|
|
|
### 1.2 커스텀 브레이크포인트 (tailwind.config.js)
|
|
|
|
```javascript
|
|
// tailwind.config.js
|
|
module.exports = {
|
|
theme: {
|
|
screens: {
|
|
'xs': '375px', // iPhone SE
|
|
'sm': '640px',
|
|
'md': '768px',
|
|
'lg': '1024px',
|
|
'xl': '1280px',
|
|
'2xl': '1536px',
|
|
// Galaxy Fold 전용 (선택적)
|
|
'fold': '344px',
|
|
},
|
|
},
|
|
}
|
|
```
|
|
|
|
### 1.3 주요 테스트 뷰포트
|
|
|
|
| 기기 | 너비 | 높이 | 우선순위 |
|
|
|------|------|------|----------|
|
|
| Galaxy Z Fold 5 (접힌) | **344px** | 882px | 🔴 필수 |
|
|
| iPhone SE | 375px | 667px | 🔴 필수 |
|
|
| iPhone 14 Pro | 393px | 852px | 🟡 권장 |
|
|
| iPad Mini | 768px | 1024px | 🟡 권장 |
|
|
| Desktop | 1280px+ | - | 🟢 기본 |
|
|
|
|
---
|
|
|
|
## 2. 공통 패턴별 해결책
|
|
|
|
### 2.1 그리드 레이아웃
|
|
|
|
#### 문제
|
|
344px에서 `grid-cols-2`는 각 항목이 ~160px로 좁아져 텍스트 오버플로우 발생
|
|
|
|
#### 해결 패턴
|
|
|
|
**패턴 A: 1열 → 2열 → 4열 (권장)**
|
|
```tsx
|
|
// Before
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
|
|
// After - 344px에서 1열
|
|
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-4">
|
|
```
|
|
|
|
**패턴 B: 최소 너비 보장**
|
|
```tsx
|
|
// 카드 최소 너비 보장 + 자동 열 조정
|
|
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
|
|
```
|
|
|
|
**패턴 C: Flex Wrap (항목 수 가변적일 때)**
|
|
```tsx
|
|
<div className="flex flex-wrap gap-4">
|
|
<div className="w-full xs:w-[calc(50%-0.5rem)] md:w-[calc(25%-0.75rem)]">
|
|
{/* 카드 내용 */}
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
#### 적용 기준
|
|
| 카드 개수 | 권장 패턴 |
|
|
|-----------|----------|
|
|
| 1-2개 | `grid-cols-1 xs:grid-cols-2` |
|
|
| 3-4개 | `grid-cols-1 xs:grid-cols-2 md:grid-cols-4` |
|
|
| 5개+ | `grid-cols-1 xs:grid-cols-2 md:grid-cols-3 lg:grid-cols-4` |
|
|
|
|
---
|
|
|
|
### 2.2 테이블 반응형
|
|
|
|
#### 문제
|
|
테이블이 344px 화면에서 가로 스크롤 없이 표시 불가
|
|
|
|
#### 해결 패턴
|
|
|
|
**패턴 A: 가로 스크롤 (기본)**
|
|
```tsx
|
|
<div className="overflow-x-auto -mx-4 px-4 md:mx-0 md:px-0">
|
|
<table className="min-w-[600px] w-full">
|
|
{/* 테이블 내용 */}
|
|
</table>
|
|
</div>
|
|
```
|
|
|
|
**패턴 B: 카드형 변환 (복잡한 데이터)**
|
|
```tsx
|
|
{/* 데스크탑: 테이블 */}
|
|
<table className="hidden md:table">
|
|
{/* 테이블 내용 */}
|
|
</table>
|
|
|
|
{/* 모바일: 카드 리스트 */}
|
|
<div className="md:hidden space-y-3">
|
|
{data.map((item) => (
|
|
<Card key={item.id}>
|
|
<CardContent className="p-4">
|
|
<div className="flex justify-between">
|
|
<span className="text-sm text-muted-foreground">거래처</span>
|
|
<span className="font-medium">{item.vendor}</span>
|
|
</div>
|
|
{/* 추가 필드 */}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
```
|
|
|
|
**패턴 C: 컬럼 숨김 (우선순위 기반)**
|
|
```tsx
|
|
<th className="hidden sm:table-cell">등록일</th>
|
|
<th className="hidden md:table-cell">수정일</th>
|
|
<th>필수 컬럼</th>
|
|
|
|
<td className="hidden sm:table-cell">{item.createdAt}</td>
|
|
<td className="hidden md:table-cell">{item.updatedAt}</td>
|
|
<td>{item.essential}</td>
|
|
```
|
|
|
|
---
|
|
|
|
### 2.3 카드 컴포넌트
|
|
|
|
#### 문제
|
|
카드 내 금액, 라벨이 좁은 화면에서 잘림
|
|
|
|
#### 해결 패턴
|
|
|
|
**패턴 A: 텍스트 크기 반응형**
|
|
```tsx
|
|
// Before
|
|
<p className="text-3xl font-bold">30,500,000,000원</p>
|
|
|
|
// After
|
|
<p className="text-xl xs:text-2xl md:text-3xl font-bold">30.5억원</p>
|
|
```
|
|
|
|
**패턴 B: 금액 포맷 함수 개선**
|
|
```typescript
|
|
// utils/format.ts
|
|
export const formatAmountResponsive = (amount: number, compact = false): string => {
|
|
if (compact || amount >= 100000000) {
|
|
// 억 단위
|
|
const billion = amount / 100000000;
|
|
return billion >= 1 ? `${billion.toFixed(1)}억원` : formatAmount(amount);
|
|
}
|
|
if (amount >= 10000) {
|
|
// 만 단위
|
|
const man = amount / 10000;
|
|
return `${man.toFixed(0)}만원`;
|
|
}
|
|
return new Intl.NumberFormat('ko-KR').format(amount) + '원';
|
|
};
|
|
```
|
|
|
|
**패턴 C: 라벨 줄바꿈 허용**
|
|
```tsx
|
|
// Before
|
|
<p className="text-sm whitespace-nowrap">현금성 자산 합계</p>
|
|
|
|
// After
|
|
<p className="text-sm break-keep">현금성 자산 합계</p>
|
|
```
|
|
|
|
**패턴 D: Truncate + Tooltip**
|
|
```tsx
|
|
<p className="text-sm truncate max-w-full" title={longLabel}>
|
|
{longLabel}
|
|
</p>
|
|
```
|
|
|
|
---
|
|
|
|
### 2.4 모달/다이얼로그
|
|
|
|
#### 문제
|
|
모달이 344px 화면에서 좌우 여백 없이 꽉 차거나 넘침
|
|
|
|
#### 해결 패턴
|
|
|
|
**패턴 A: 최대 너비 반응형**
|
|
```tsx
|
|
// Before
|
|
<DialogContent className="max-w-2xl">
|
|
|
|
// After
|
|
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-lg md:max-w-2xl">
|
|
```
|
|
|
|
**패턴 B: 전체 화면 모달 (복잡한 내용)**
|
|
```tsx
|
|
<DialogContent className="w-full h-full max-w-none sm:max-w-2xl sm:h-auto sm:max-h-[90vh]">
|
|
```
|
|
|
|
**패턴 C: 모달 내부 스크롤**
|
|
```tsx
|
|
<DialogContent className="max-h-[90vh] flex flex-col">
|
|
<DialogHeader className="flex-shrink-0">
|
|
{/* 헤더 */}
|
|
</DialogHeader>
|
|
<div className="flex-1 overflow-y-auto">
|
|
{/* 스크롤 가능한 내용 */}
|
|
</div>
|
|
<DialogFooter className="flex-shrink-0">
|
|
{/* 푸터 */}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
```
|
|
|
|
---
|
|
|
|
### 2.5 버튼 그룹
|
|
|
|
#### 문제
|
|
여러 버튼이 가로로 나열될 때 344px에서 넘침
|
|
|
|
#### 해결 패턴
|
|
|
|
**패턴 A: Flex Wrap**
|
|
```tsx
|
|
// Before
|
|
<div className="flex gap-2">
|
|
<Button>저장</Button>
|
|
<Button>취소</Button>
|
|
<Button>삭제</Button>
|
|
</div>
|
|
|
|
// After
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button className="flex-1 min-w-[80px]">저장</Button>
|
|
<Button className="flex-1 min-w-[80px]">취소</Button>
|
|
<Button className="flex-1 min-w-[80px]">삭제</Button>
|
|
</div>
|
|
```
|
|
|
|
**패턴 B: 세로 배치 (모바일)**
|
|
```tsx
|
|
<div className="flex flex-col xs:flex-row gap-2">
|
|
<Button className="w-full xs:w-auto">저장</Button>
|
|
<Button className="w-full xs:w-auto">취소</Button>
|
|
</div>
|
|
```
|
|
|
|
**패턴 C: 아이콘 전용 (극소 화면)**
|
|
```tsx
|
|
<Button className="gap-2">
|
|
<SaveIcon className="h-4 w-4" />
|
|
<span className="hidden xs:inline">저장</span>
|
|
</Button>
|
|
```
|
|
|
|
---
|
|
|
|
### 2.6 긴 텍스트 처리
|
|
|
|
#### 문제
|
|
긴 제목, 설명, 메시지가 좁은 화면에서 레이아웃 깨짐
|
|
|
|
#### 해결 패턴
|
|
|
|
**패턴 A: Truncate (한 줄)**
|
|
```tsx
|
|
<h3 className="truncate max-w-full" title={title}>
|
|
{title}
|
|
</h3>
|
|
```
|
|
|
|
**패턴 B: Line Clamp (여러 줄)**
|
|
```tsx
|
|
<p className="line-clamp-2 text-sm text-muted-foreground">
|
|
{description}
|
|
</p>
|
|
```
|
|
|
|
**패턴 C: Break Keep (한글 단어 단위)**
|
|
```tsx
|
|
<p className="break-keep">
|
|
가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의
|
|
</p>
|
|
```
|
|
|
|
**패턴 D: 반응형 텍스트 크기**
|
|
```tsx
|
|
<h1 className="text-lg xs:text-xl md:text-2xl font-bold break-keep">
|
|
{title}
|
|
</h1>
|
|
```
|
|
|
|
---
|
|
|
|
### 2.7 헤더/네비게이션
|
|
|
|
#### 문제
|
|
페이지 헤더의 타이틀과 액션 버튼이 충돌
|
|
|
|
#### 해결 패턴
|
|
|
|
**패턴 A: 세로 배치 (모바일)**
|
|
```tsx
|
|
<div className="flex flex-col xs:flex-row xs:items-center xs:justify-between gap-2">
|
|
<div>
|
|
<h1 className="text-xl font-bold">{title}</h1>
|
|
<p className="text-sm text-muted-foreground">{description}</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button size="sm">액션</Button>
|
|
</div>
|
|
</div>
|
|
```
|
|
|
|
**패턴 B: 아이콘 버튼 (극소 화면)**
|
|
```tsx
|
|
<Button size="sm" className="gap-1.5">
|
|
<SettingsIcon className="h-4 w-4" />
|
|
<span className="hidden xs:inline">항목 설정</span>
|
|
</Button>
|
|
```
|
|
|
|
---
|
|
|
|
### 2.8 패딩/마진 반응형
|
|
|
|
#### 문제
|
|
데스크탑용 패딩이 모바일에서 공간 낭비
|
|
|
|
#### 해결 패턴
|
|
|
|
```tsx
|
|
// Before
|
|
<div className="p-6">
|
|
|
|
// After
|
|
<div className="p-3 xs:p-4 md:p-6">
|
|
|
|
// 카드 내부
|
|
<CardContent className="p-3 xs:p-4 md:p-6">
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Tailwind 유틸리티 클래스 모음
|
|
|
|
### 3.1 자주 사용하는 반응형 패턴
|
|
|
|
```css
|
|
/* 그리드 */
|
|
.grid-responsive-1-2-4: grid-cols-1 xs:grid-cols-2 md:grid-cols-4
|
|
.grid-responsive-1-2-3: grid-cols-1 xs:grid-cols-2 md:grid-cols-3
|
|
.grid-responsive-1-3: grid-cols-1 md:grid-cols-3
|
|
|
|
/* 텍스트 */
|
|
.text-responsive-sm: text-xs xs:text-sm
|
|
.text-responsive-base: text-sm xs:text-base
|
|
.text-responsive-lg: text-base xs:text-lg md:text-xl
|
|
.text-responsive-xl: text-lg xs:text-xl md:text-2xl
|
|
.text-responsive-2xl: text-xl xs:text-2xl md:text-3xl
|
|
|
|
/* 패딩 */
|
|
.p-responsive: p-3 xs:p-4 md:p-6
|
|
.px-responsive: px-3 xs:px-4 md:px-6
|
|
.py-responsive: py-3 xs:py-4 md:py-6
|
|
|
|
/* 갭 */
|
|
.gap-responsive: gap-2 xs:gap-3 md:gap-4
|
|
|
|
/* Flex 방향 */
|
|
.flex-col-to-row: flex-col xs:flex-row
|
|
```
|
|
|
|
### 3.2 커스텀 유틸리티 (globals.css)
|
|
|
|
```css
|
|
/* globals.css */
|
|
@layer utilities {
|
|
.grid-responsive-cards {
|
|
@apply grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4;
|
|
}
|
|
|
|
.text-amount {
|
|
@apply text-xl xs:text-2xl md:text-3xl font-bold;
|
|
}
|
|
|
|
.card-padding {
|
|
@apply p-3 xs:p-4 md:p-6;
|
|
}
|
|
|
|
.section-padding {
|
|
@apply p-4 xs:p-5 md:p-6;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 4. 적용 체크리스트
|
|
|
|
### 4.1 페이지 단위 체크리스트
|
|
|
|
```markdown
|
|
## 페이지: [페이지명]
|
|
테스트 뷰포트: 344px (Galaxy Fold)
|
|
|
|
### 레이아웃
|
|
- [ ] 헤더 타이틀/액션 버튼 충돌 없음
|
|
- [ ] 그리드 카드 오버플로우 없음
|
|
- [ ] 사이드바 접힘 상태 정상
|
|
|
|
### 텍스트
|
|
- [ ] 제목 텍스트 잘림/줄바꿈 정상
|
|
- [ ] 금액 표시 가독성 확보
|
|
- [ ] 라벨 텍스트 truncate 또는 줄바꿈
|
|
|
|
### 테이블
|
|
- [ ] 가로 스크롤 정상 동작
|
|
- [ ] 필수 컬럼 표시 확인
|
|
- [ ] 체크박스/액션 버튼 접근 가능
|
|
|
|
### 카드
|
|
- [ ] 카드 내용 오버플로우 없음
|
|
- [ ] 터치 영역 충분 (최소 44px)
|
|
- [ ] 카드 간 간격 적절
|
|
|
|
### 모달
|
|
- [ ] 화면 내 완전히 표시
|
|
- [ ] 닫기 버튼 접근 가능
|
|
- [ ] 내부 스크롤 정상
|
|
|
|
### 버튼
|
|
- [ ] 버튼 그룹 wrap 정상
|
|
- [ ] 터치 영역 충분
|
|
- [ ] 아이콘/텍스트 가독성
|
|
```
|
|
|
|
### 4.2 컴포넌트 단위 체크리스트
|
|
|
|
```markdown
|
|
## 컴포넌트: [컴포넌트명]
|
|
|
|
### 필수 확인
|
|
- [ ] min-width 고정값 없음 또는 반응형 처리
|
|
- [ ] whitespace-nowrap 사용 시 truncate 동반
|
|
- [ ] grid-cols-N 사용 시 모바일 breakpoint 추가
|
|
- [ ] 패딩/마진 반응형 적용
|
|
|
|
### 권장 확인
|
|
- [ ] 텍스트 크기 반응형
|
|
- [ ] 버튼 크기 반응형
|
|
- [ ] 아이콘 크기 반응형
|
|
```
|
|
|
|
---
|
|
|
|
## 5. 적용 사례
|
|
|
|
### 5.1 CEO 대시보드 적용 예정
|
|
|
|
**현재 문제점**:
|
|
- `grid-cols-2 md:grid-cols-4` → 344px에서 카드당 ~160px
|
|
- 금액 "3,050,000,000원" 표시 → 잘림
|
|
- "현금성 자산 합계" 라벨 → 잘림
|
|
|
|
**적용 계획**:
|
|
1. 그리드: `grid-cols-1 xs:grid-cols-2 md:grid-cols-4`
|
|
2. 금액: `formatAmountResponsive()` 함수 사용 (억 단위)
|
|
3. 라벨: `break-keep` 또는 `truncate`
|
|
4. 카드 패딩: `p-3 xs:p-4 md:p-6`
|
|
5. 헤더 버튼: 아이콘 전용 옵션
|
|
|
|
**상세 계획**: `[PLAN] ceo-dashboard-refactoring.md` 참조
|
|
|
|
---
|
|
|
|
## 6. 테스트 방법
|
|
|
|
### 6.1 Chrome DevTools 설정
|
|
|
|
1. DevTools 열기 (F12)
|
|
2. Device Toolbar (Ctrl+Shift+M)
|
|
3. Edit → Add custom device:
|
|
- Name: `Galaxy Z Fold 5 (Folded)`
|
|
- Width: `344`
|
|
- Height: `882`
|
|
- Device pixel ratio: `3`
|
|
- User agent: Mobile
|
|
|
|
### 6.2 권장 테스트 순서
|
|
|
|
1. **344px**: 최소 지원 너비 (Galaxy Fold)
|
|
2. **375px**: iPhone SE
|
|
3. **768px**: 태블릿
|
|
4. **1280px**: 데스크탑
|
|
|
|
### 6.3 자동화 테스트 (Playwright)
|
|
|
|
```typescript
|
|
// playwright.config.ts
|
|
const devices = [
|
|
{ name: 'Galaxy Fold', viewport: { width: 344, height: 882 } },
|
|
{ name: 'iPhone SE', viewport: { width: 375, height: 667 } },
|
|
{ name: 'iPad', viewport: { width: 768, height: 1024 } },
|
|
{ name: 'Desktop', viewport: { width: 1280, height: 800 } },
|
|
];
|
|
```
|
|
|
|
---
|
|
|
|
## 변경 이력
|
|
|
|
| 날짜 | 버전 | 변경 내용 |
|
|
|------|------|----------|
|
|
| 2026-01-10 | 1.0 | 초기 작성 | |