timsquad 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +347 -0
- package/bin/tsq.js +6 -0
- package/dist/commands/feedback.d.ts +3 -0
- package/dist/commands/feedback.d.ts.map +1 -0
- package/dist/commands/feedback.js +142 -0
- package/dist/commands/feedback.js.map +1 -0
- package/dist/commands/full.d.ts +3 -0
- package/dist/commands/full.d.ts.map +1 -0
- package/dist/commands/full.js +87 -0
- package/dist/commands/full.js.map +1 -0
- package/dist/commands/git/commit.d.ts +3 -0
- package/dist/commands/git/commit.d.ts.map +1 -0
- package/dist/commands/git/commit.js +88 -0
- package/dist/commands/git/commit.js.map +1 -0
- package/dist/commands/git/index.d.ts +5 -0
- package/dist/commands/git/index.d.ts.map +1 -0
- package/dist/commands/git/index.js +5 -0
- package/dist/commands/git/index.js.map +1 -0
- package/dist/commands/git/pr.d.ts +3 -0
- package/dist/commands/git/pr.d.ts.map +1 -0
- package/dist/commands/git/pr.js +138 -0
- package/dist/commands/git/pr.js.map +1 -0
- package/dist/commands/git/release.d.ts +3 -0
- package/dist/commands/git/release.d.ts.map +1 -0
- package/dist/commands/git/release.js +158 -0
- package/dist/commands/git/release.js.map +1 -0
- package/dist/commands/git/sync.d.ts +3 -0
- package/dist/commands/git/sync.d.ts.map +1 -0
- package/dist/commands/git/sync.js +132 -0
- package/dist/commands/git/sync.js.map +1 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +150 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/log.d.ts +3 -0
- package/dist/commands/log.d.ts.map +1 -0
- package/dist/commands/log.js +271 -0
- package/dist/commands/log.js.map +1 -0
- package/dist/commands/metrics.d.ts +3 -0
- package/dist/commands/metrics.d.ts.map +1 -0
- package/dist/commands/metrics.js +299 -0
- package/dist/commands/metrics.js.map +1 -0
- package/dist/commands/quick.d.ts +3 -0
- package/dist/commands/quick.d.ts.map +1 -0
- package/dist/commands/quick.js +136 -0
- package/dist/commands/quick.js.map +1 -0
- package/dist/commands/retro.d.ts +3 -0
- package/dist/commands/retro.d.ts.map +1 -0
- package/dist/commands/retro.js +280 -0
- package/dist/commands/retro.js.map +1 -0
- package/dist/commands/status.d.ts +3 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +127 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/watch.d.ts +3 -0
- package/dist/commands/watch.d.ts.map +1 -0
- package/dist/commands/watch.js +213 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/config.d.ts +34 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +108 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/project.d.ts +47 -0
- package/dist/lib/project.d.ts.map +1 -0
- package/dist/lib/project.js +191 -0
- package/dist/lib/project.js.map +1 -0
- package/dist/lib/template.d.ts +33 -0
- package/dist/lib/template.d.ts.map +1 -0
- package/dist/lib/template.js +151 -0
- package/dist/lib/template.js.map +1 -0
- package/dist/types/config.d.ts +75 -0
- package/dist/types/config.d.ts.map +1 -0
- package/dist/types/config.js +66 -0
- package/dist/types/config.js.map +1 -0
- package/dist/types/feedback.d.ts +59 -0
- package/dist/types/feedback.d.ts.map +1 -0
- package/dist/types/feedback.js +26 -0
- package/dist/types/feedback.js.map +1 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/project.d.ts +89 -0
- package/dist/types/project.d.ts.map +1 -0
- package/dist/types/project.js +44 -0
- package/dist/types/project.js.map +1 -0
- package/dist/utils/colors.d.ts +30 -0
- package/dist/utils/colors.d.ts.map +1 -0
- package/dist/utils/colors.js +54 -0
- package/dist/utils/colors.js.map +1 -0
- package/dist/utils/date.d.ts +25 -0
- package/dist/utils/date.d.ts.map +1 -0
- package/dist/utils/date.js +65 -0
- package/dist/utils/date.js.map +1 -0
- package/dist/utils/fs.d.ts +49 -0
- package/dist/utils/fs.d.ts.map +1 -0
- package/dist/utils/fs.js +84 -0
- package/dist/utils/fs.js.map +1 -0
- package/dist/utils/prompts.d.ts +31 -0
- package/dist/utils/prompts.d.ts.map +1 -0
- package/dist/utils/prompts.js +95 -0
- package/dist/utils/prompts.js.map +1 -0
- package/dist/utils/yaml.d.ts +21 -0
- package/dist/utils/yaml.d.ts.map +1 -0
- package/dist/utils/yaml.js +40 -0
- package/dist/utils/yaml.js.map +1 -0
- package/package.json +71 -0
- package/templates/common/CLAUDE.md.template +254 -0
- package/templates/common/claude/agents/tsq-dba.md +290 -0
- package/templates/common/claude/agents/tsq-designer.md +304 -0
- package/templates/common/claude/agents/tsq-developer.md +118 -0
- package/templates/common/claude/agents/tsq-planner.md +90 -0
- package/templates/common/claude/agents/tsq-prompter.md +336 -0
- package/templates/common/claude/agents/tsq-qa.md +134 -0
- package/templates/common/claude/agents/tsq-retro.md +168 -0
- package/templates/common/claude/agents/tsq-security.md +190 -0
- package/templates/common/claude/skills/architecture/SKILL.md +123 -0
- package/templates/common/claude/skills/backend/node/SKILL.md +1015 -0
- package/templates/common/claude/skills/coding/SKILL.md +171 -0
- package/templates/common/claude/skills/database/prisma/SKILL.md +357 -0
- package/templates/common/claude/skills/frontend/nextjs/SKILL.md +279 -0
- package/templates/common/claude/skills/frontend/react/SKILL.md +1729 -0
- package/templates/common/claude/skills/methodology/bdd/SKILL.md +234 -0
- package/templates/common/claude/skills/methodology/ddd/SKILL.md +311 -0
- package/templates/common/claude/skills/methodology/tdd/SKILL.md +512 -0
- package/templates/common/claude/skills/planning/SKILL.md +90 -0
- package/templates/common/claude/skills/security/SKILL.md +234 -0
- package/templates/common/claude/skills/testing/SKILL.md +146 -0
- package/templates/common/claude/skills/typescript/SKILL.md +435 -0
- package/templates/common/config.template.yaml +131 -0
- package/templates/common/timsquad/architectures/clean/ARCHITECTURE.md +49 -0
- package/templates/common/timsquad/architectures/clean/backend.xml +210 -0
- package/templates/common/timsquad/architectures/clean/frontend.xml +148 -0
- package/templates/common/timsquad/architectures/fsd/ARCHITECTURE.md +67 -0
- package/templates/common/timsquad/architectures/fsd/frontend.xml +288 -0
- package/templates/common/timsquad/architectures/hexagonal/ARCHITECTURE.md +60 -0
- package/templates/common/timsquad/architectures/hexagonal/backend.xml +300 -0
- package/templates/common/timsquad/constraints/competency-framework.xml +501 -0
- package/templates/common/timsquad/constraints/ssot-schema.xml +433 -0
- package/templates/common/timsquad/feedback/feedback-router.sh +341 -0
- package/templates/common/timsquad/feedback/routing-rules.yaml +352 -0
- package/templates/common/timsquad/generators/data-design.xml +290 -0
- package/templates/common/timsquad/generators/prd.xml +280 -0
- package/templates/common/timsquad/generators/requirements.xml +220 -0
- package/templates/common/timsquad/generators/service-spec.xml +266 -0
- package/templates/common/timsquad/logs/_example.md +81 -0
- package/templates/common/timsquad/logs/_template.md +46 -0
- package/templates/common/timsquad/patterns/cqrs.xml +127 -0
- package/templates/common/timsquad/patterns/event-sourcing.xml +85 -0
- package/templates/common/timsquad/patterns/repository.xml +64 -0
- package/templates/common/timsquad/process/state-machine.xml +343 -0
- package/templates/common/timsquad/process/validation-rules.xml +308 -0
- package/templates/common/timsquad/process/workflow-base.xml +202 -0
- package/templates/common/timsquad/retrospective/cycle-report.template.md +205 -0
- package/templates/common/timsquad/retrospective/metrics/metrics-schema.json +203 -0
- package/templates/common/timsquad/retrospective/patterns/failure-patterns.md +199 -0
- package/templates/common/timsquad/retrospective/patterns/success-patterns.md +262 -0
- package/templates/common/timsquad/retrospective/retrospective-config.xml +294 -0
- package/templates/common/timsquad/retrospective/retrospective-state.xml +210 -0
- package/templates/common/timsquad/ssot/adr/ADR-000-template.md +121 -0
- package/templates/common/timsquad/ssot/adr/ADR-001-example.md +115 -0
- package/templates/common/timsquad/ssot/data-design.template.md +132 -0
- package/templates/common/timsquad/ssot/deployment-spec.template.md +384 -0
- package/templates/common/timsquad/ssot/env-config.template.md +346 -0
- package/templates/common/timsquad/ssot/error-codes.template.md +114 -0
- package/templates/common/timsquad/ssot/functional-spec.template.md +185 -0
- package/templates/common/timsquad/ssot/glossary.template.md +148 -0
- package/templates/common/timsquad/ssot/integration-spec.template.md +391 -0
- package/templates/common/timsquad/ssot/planning.template.md +94 -0
- package/templates/common/timsquad/ssot/prd.template.md +102 -0
- package/templates/common/timsquad/ssot/requirements.template.md +117 -0
- package/templates/common/timsquad/ssot/security-spec.template.md +309 -0
- package/templates/common/timsquad/ssot/service-spec.template.md +194 -0
- package/templates/common/timsquad/ssot/test-spec.template.md +264 -0
- package/templates/common/timsquad/ssot/ui-ux-spec.template.md +262 -0
- package/templates/common/timsquad/state/workspace.xml +217 -0
|
@@ -0,0 +1,1729 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: react
|
|
3
|
+
description: React 컴포넌트 개발 가이드라인
|
|
4
|
+
user-invocable: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<skill name="react">
|
|
8
|
+
<purpose>유지보수 가능한 React 컴포넌트 개발 가이드라인</purpose>
|
|
9
|
+
|
|
10
|
+
<philosophy>
|
|
11
|
+
<principle>컴포넌트는 단일 책임</principle>
|
|
12
|
+
<principle>UI와 로직 분리 (커스텀 훅)</principle>
|
|
13
|
+
<principle>Props로 명시적 데이터 흐름</principle>
|
|
14
|
+
<principle>서버 상태와 클라이언트 상태 분리</principle>
|
|
15
|
+
</philosophy>
|
|
16
|
+
|
|
17
|
+
<component-patterns>
|
|
18
|
+
<pattern name="함수형 컴포넌트 기본">
|
|
19
|
+
<example type="good">
|
|
20
|
+
<![CDATA[
|
|
21
|
+
interface UserCardProps {
|
|
22
|
+
user: User;
|
|
23
|
+
onEdit?: (user: User) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function UserCard({ user, onEdit }: UserCardProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="user-card">
|
|
29
|
+
<Avatar src={user.avatar} alt={user.name} />
|
|
30
|
+
<h3>{user.name}</h3>
|
|
31
|
+
{onEdit && (
|
|
32
|
+
<Button onClick={() => onEdit(user)}>Edit</Button>
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
]]>
|
|
38
|
+
</example>
|
|
39
|
+
</pattern>
|
|
40
|
+
|
|
41
|
+
<pattern name="컴포넌트 구조 템플릿">
|
|
42
|
+
<template>
|
|
43
|
+
<![CDATA[
|
|
44
|
+
// 1. 타입 정의
|
|
45
|
+
interface Props { ... }
|
|
46
|
+
|
|
47
|
+
// 2. 컴포넌트
|
|
48
|
+
export function ComponentName({ prop1, prop2 }: Props) {
|
|
49
|
+
// 3. 훅 (순서 중요 - 항상 최상위)
|
|
50
|
+
const [state, setState] = useState();
|
|
51
|
+
const { data } = useQuery();
|
|
52
|
+
const router = useRouter();
|
|
53
|
+
|
|
54
|
+
// 4. 파생 값 (계산된 값)
|
|
55
|
+
const derivedValue = useMemo(() => ..., [deps]);
|
|
56
|
+
|
|
57
|
+
// 5. 이벤트 핸들러
|
|
58
|
+
const handleClick = useCallback(() => ..., [deps]);
|
|
59
|
+
|
|
60
|
+
// 6. 이펙트 (최소화 - 정말 필요한 경우만)
|
|
61
|
+
useEffect(() => ..., [deps]);
|
|
62
|
+
|
|
63
|
+
// 7. Early Return (조건부 렌더링)
|
|
64
|
+
if (isLoading) return <Skeleton />;
|
|
65
|
+
if (error) return <ErrorMessage error={error} />;
|
|
66
|
+
|
|
67
|
+
// 8. 메인 렌더링
|
|
68
|
+
return ( ... );
|
|
69
|
+
}
|
|
70
|
+
]]>
|
|
71
|
+
</template>
|
|
72
|
+
</pattern>
|
|
73
|
+
|
|
74
|
+
<pattern name="Controlled vs Uncontrolled">
|
|
75
|
+
<description>폼 컴포넌트의 두 가지 패턴</description>
|
|
76
|
+
<example type="controlled">
|
|
77
|
+
<![CDATA[
|
|
78
|
+
// Controlled: 부모가 상태 관리 (권장)
|
|
79
|
+
interface InputProps {
|
|
80
|
+
value: string;
|
|
81
|
+
onChange: (value: string) => void;
|
|
82
|
+
placeholder?: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function Input({ value, onChange, placeholder }: InputProps) {
|
|
86
|
+
return (
|
|
87
|
+
<input
|
|
88
|
+
value={value}
|
|
89
|
+
onChange={(e) => onChange(e.target.value)}
|
|
90
|
+
placeholder={placeholder}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 사용
|
|
96
|
+
function Form() {
|
|
97
|
+
const [name, setName] = useState('');
|
|
98
|
+
return <Input value={name} onChange={setName} />;
|
|
99
|
+
}
|
|
100
|
+
]]>
|
|
101
|
+
</example>
|
|
102
|
+
<example type="uncontrolled">
|
|
103
|
+
<![CDATA[
|
|
104
|
+
// Uncontrolled: 내부에서 상태 관리 (간단한 경우)
|
|
105
|
+
interface SearchInputProps {
|
|
106
|
+
onSearch: (query: string) => void;
|
|
107
|
+
defaultValue?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function SearchInput({ onSearch, defaultValue = '' }: SearchInputProps) {
|
|
111
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
112
|
+
|
|
113
|
+
const handleSubmit = () => {
|
|
114
|
+
if (inputRef.current) {
|
|
115
|
+
onSearch(inputRef.current.value);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div>
|
|
121
|
+
<input ref={inputRef} defaultValue={defaultValue} />
|
|
122
|
+
<button onClick={handleSubmit}>Search</button>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
]]>
|
|
127
|
+
</example>
|
|
128
|
+
</pattern>
|
|
129
|
+
|
|
130
|
+
<pattern name="Compound Components (복합 컴포넌트)">
|
|
131
|
+
<description>관련 컴포넌트를 그룹화하여 유연한 API 제공</description>
|
|
132
|
+
<example>
|
|
133
|
+
<![CDATA[
|
|
134
|
+
// 사용법 - 직관적이고 유연함
|
|
135
|
+
<Card>
|
|
136
|
+
<Card.Header>
|
|
137
|
+
<Card.Title>사용자 정보</Card.Title>
|
|
138
|
+
<Card.Description>프로필을 확인하세요</Card.Description>
|
|
139
|
+
</Card.Header>
|
|
140
|
+
<Card.Content>
|
|
141
|
+
<UserInfo user={user} />
|
|
142
|
+
</Card.Content>
|
|
143
|
+
<Card.Footer>
|
|
144
|
+
<Button variant="outline">취소</Button>
|
|
145
|
+
<Button>저장</Button>
|
|
146
|
+
</Card.Footer>
|
|
147
|
+
</Card>
|
|
148
|
+
|
|
149
|
+
// 구현 - Context로 상태 공유
|
|
150
|
+
interface CardContextValue {
|
|
151
|
+
variant?: 'default' | 'outline';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const CardContext = createContext<CardContextValue>({});
|
|
155
|
+
|
|
156
|
+
function Card({ children, variant = 'default' }: PropsWithChildren<{ variant?: 'default' | 'outline' }>) {
|
|
157
|
+
return (
|
|
158
|
+
<CardContext.Provider value={{ variant }}>
|
|
159
|
+
<div className={cn('card', `card--${variant}`)}>{children}</div>
|
|
160
|
+
</CardContext.Provider>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
Card.Header = function CardHeader({ children }: PropsWithChildren) {
|
|
165
|
+
return <div className="card-header">{children}</div>;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
Card.Title = function CardTitle({ children }: PropsWithChildren) {
|
|
169
|
+
return <h3 className="card-title">{children}</h3>;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
Card.Description = function CardDescription({ children }: PropsWithChildren) {
|
|
173
|
+
return <p className="card-description">{children}</p>;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
Card.Content = function CardContent({ children }: PropsWithChildren) {
|
|
177
|
+
return <div className="card-content">{children}</div>;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
Card.Footer = function CardFooter({ children }: PropsWithChildren) {
|
|
181
|
+
return <div className="card-footer">{children}</div>;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export { Card };
|
|
185
|
+
]]>
|
|
186
|
+
</example>
|
|
187
|
+
</pattern>
|
|
188
|
+
|
|
189
|
+
<pattern name="Render Props">
|
|
190
|
+
<description>렌더링 로직을 외부에서 주입</description>
|
|
191
|
+
<example>
|
|
192
|
+
<![CDATA[
|
|
193
|
+
// 마우스 위치 추적 컴포넌트
|
|
194
|
+
interface MouseTrackerProps {
|
|
195
|
+
children: (position: { x: number; y: number }) => ReactNode;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function MouseTracker({ children }: MouseTrackerProps) {
|
|
199
|
+
const [position, setPosition] = useState({ x: 0, y: 0 });
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
const handleMove = (e: MouseEvent) => {
|
|
203
|
+
setPosition({ x: e.clientX, y: e.clientY });
|
|
204
|
+
};
|
|
205
|
+
window.addEventListener('mousemove', handleMove);
|
|
206
|
+
return () => window.removeEventListener('mousemove', handleMove);
|
|
207
|
+
}, []);
|
|
208
|
+
|
|
209
|
+
return <>{children(position)}</>;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 사용
|
|
213
|
+
<MouseTracker>
|
|
214
|
+
{({ x, y }) => (
|
|
215
|
+
<div>마우스 위치: {x}, {y}</div>
|
|
216
|
+
)}
|
|
217
|
+
</MouseTracker>
|
|
218
|
+
|
|
219
|
+
// 실전: 데이터 페칭 렌더 프롭
|
|
220
|
+
interface DataFetcherProps<T> {
|
|
221
|
+
url: string;
|
|
222
|
+
children: (state: {
|
|
223
|
+
data: T | null;
|
|
224
|
+
isLoading: boolean;
|
|
225
|
+
error: Error | null;
|
|
226
|
+
}) => ReactNode;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
|
|
230
|
+
const { data, isLoading, error } = useQuery<T>({
|
|
231
|
+
queryKey: [url],
|
|
232
|
+
queryFn: () => fetch(url).then(res => res.json()),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return <>{children({ data: data ?? null, isLoading, error })}</>;
|
|
236
|
+
}
|
|
237
|
+
]]>
|
|
238
|
+
</example>
|
|
239
|
+
</pattern>
|
|
240
|
+
|
|
241
|
+
<pattern name="Forwarding Refs">
|
|
242
|
+
<description>ref를 내부 DOM 요소로 전달</description>
|
|
243
|
+
<example>
|
|
244
|
+
<![CDATA[
|
|
245
|
+
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
246
|
+
variant?: 'primary' | 'secondary' | 'outline';
|
|
247
|
+
size?: 'sm' | 'md' | 'lg';
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// forwardRef로 ref 전달 가능하게
|
|
251
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
252
|
+
function Button({ variant = 'primary', size = 'md', className, children, ...props }, ref) {
|
|
253
|
+
return (
|
|
254
|
+
<button
|
|
255
|
+
ref={ref}
|
|
256
|
+
className={cn(
|
|
257
|
+
'button',
|
|
258
|
+
`button--${variant}`,
|
|
259
|
+
`button--${size}`,
|
|
260
|
+
className
|
|
261
|
+
)}
|
|
262
|
+
{...props}
|
|
263
|
+
>
|
|
264
|
+
{children}
|
|
265
|
+
</button>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// 사용 - 부모에서 ref로 DOM 직접 접근 가능
|
|
271
|
+
function Form() {
|
|
272
|
+
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
273
|
+
|
|
274
|
+
const focusButton = () => {
|
|
275
|
+
buttonRef.current?.focus();
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
<>
|
|
280
|
+
<Button ref={buttonRef}>Submit</Button>
|
|
281
|
+
<button onClick={focusButton}>Focus Submit</button>
|
|
282
|
+
</>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
]]>
|
|
286
|
+
</example>
|
|
287
|
+
</pattern>
|
|
288
|
+
</component-patterns>
|
|
289
|
+
|
|
290
|
+
<hooks-patterns>
|
|
291
|
+
<pattern name="커스텀 훅으로 로직 분리">
|
|
292
|
+
<example type="bad">
|
|
293
|
+
<![CDATA[
|
|
294
|
+
// Bad: 컴포넌트에 로직이 섞임
|
|
295
|
+
function UserProfile({ userId }: Props) {
|
|
296
|
+
const [user, setUser] = useState<User | null>(null);
|
|
297
|
+
const [loading, setLoading] = useState(true);
|
|
298
|
+
const [error, setError] = useState<Error | null>(null);
|
|
299
|
+
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
setLoading(true);
|
|
302
|
+
fetchUser(userId)
|
|
303
|
+
.then(setUser)
|
|
304
|
+
.catch(setError)
|
|
305
|
+
.finally(() => setLoading(false));
|
|
306
|
+
}, [userId]);
|
|
307
|
+
|
|
308
|
+
// 200줄의 복잡한 렌더링 로직...
|
|
309
|
+
}
|
|
310
|
+
]]>
|
|
311
|
+
</example>
|
|
312
|
+
<example type="good">
|
|
313
|
+
<![CDATA[
|
|
314
|
+
// Good: 훅으로 데이터 페칭 분리
|
|
315
|
+
// hooks/use-user.ts
|
|
316
|
+
export function useUser(userId: string) {
|
|
317
|
+
return useQuery({
|
|
318
|
+
queryKey: ['user', userId],
|
|
319
|
+
queryFn: () => fetchUser(userId),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// components/user-profile.tsx - 깔끔한 UI 로직만
|
|
324
|
+
function UserProfile({ userId }: Props) {
|
|
325
|
+
const { data: user, isLoading, error } = useUser(userId);
|
|
326
|
+
|
|
327
|
+
if (isLoading) return <Skeleton />;
|
|
328
|
+
if (error) return <ErrorMessage error={error} />;
|
|
329
|
+
|
|
330
|
+
return <ProfileCard user={user} />;
|
|
331
|
+
}
|
|
332
|
+
]]>
|
|
333
|
+
</example>
|
|
334
|
+
</pattern>
|
|
335
|
+
|
|
336
|
+
<pattern name="useReducer (복잡한 상태)">
|
|
337
|
+
<description>상태 변경 로직이 복잡할 때 reducer로 관리</description>
|
|
338
|
+
<example>
|
|
339
|
+
<![CDATA[
|
|
340
|
+
// 장바구니 상태 - 여러 액션이 있는 복잡한 상태
|
|
341
|
+
type CartAction =
|
|
342
|
+
| { type: 'ADD_ITEM'; item: CartItem }
|
|
343
|
+
| { type: 'REMOVE_ITEM'; itemId: string }
|
|
344
|
+
| { type: 'UPDATE_QUANTITY'; itemId: string; quantity: number }
|
|
345
|
+
| { type: 'CLEAR' }
|
|
346
|
+
| { type: 'APPLY_COUPON'; coupon: Coupon };
|
|
347
|
+
|
|
348
|
+
interface CartState {
|
|
349
|
+
items: CartItem[];
|
|
350
|
+
coupon: Coupon | null;
|
|
351
|
+
total: number;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function cartReducer(state: CartState, action: CartAction): CartState {
|
|
355
|
+
switch (action.type) {
|
|
356
|
+
case 'ADD_ITEM': {
|
|
357
|
+
const existing = state.items.find(i => i.id === action.item.id);
|
|
358
|
+
if (existing) {
|
|
359
|
+
return {
|
|
360
|
+
...state,
|
|
361
|
+
items: state.items.map(i =>
|
|
362
|
+
i.id === action.item.id
|
|
363
|
+
? { ...i, quantity: i.quantity + 1 }
|
|
364
|
+
: i
|
|
365
|
+
),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
...state,
|
|
370
|
+
items: [...state.items, { ...action.item, quantity: 1 }],
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
case 'REMOVE_ITEM':
|
|
374
|
+
return {
|
|
375
|
+
...state,
|
|
376
|
+
items: state.items.filter(i => i.id !== action.itemId),
|
|
377
|
+
};
|
|
378
|
+
case 'UPDATE_QUANTITY':
|
|
379
|
+
return {
|
|
380
|
+
...state,
|
|
381
|
+
items: state.items.map(i =>
|
|
382
|
+
i.id === action.itemId
|
|
383
|
+
? { ...i, quantity: action.quantity }
|
|
384
|
+
: i
|
|
385
|
+
),
|
|
386
|
+
};
|
|
387
|
+
case 'CLEAR':
|
|
388
|
+
return { ...state, items: [], coupon: null };
|
|
389
|
+
case 'APPLY_COUPON':
|
|
390
|
+
return { ...state, coupon: action.coupon };
|
|
391
|
+
default:
|
|
392
|
+
return state;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 커스텀 훅으로 래핑
|
|
397
|
+
export function useCart() {
|
|
398
|
+
const [state, dispatch] = useReducer(cartReducer, {
|
|
399
|
+
items: [],
|
|
400
|
+
coupon: null,
|
|
401
|
+
total: 0,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const addItem = useCallback((item: CartItem) => {
|
|
405
|
+
dispatch({ type: 'ADD_ITEM', item });
|
|
406
|
+
}, []);
|
|
407
|
+
|
|
408
|
+
const removeItem = useCallback((itemId: string) => {
|
|
409
|
+
dispatch({ type: 'REMOVE_ITEM', itemId });
|
|
410
|
+
}, []);
|
|
411
|
+
|
|
412
|
+
const updateQuantity = useCallback((itemId: string, quantity: number) => {
|
|
413
|
+
dispatch({ type: 'UPDATE_QUANTITY', itemId, quantity });
|
|
414
|
+
}, []);
|
|
415
|
+
|
|
416
|
+
const clear = useCallback(() => {
|
|
417
|
+
dispatch({ type: 'CLEAR' });
|
|
418
|
+
}, []);
|
|
419
|
+
|
|
420
|
+
// 파생 값
|
|
421
|
+
const total = useMemo(() => {
|
|
422
|
+
const subtotal = state.items.reduce(
|
|
423
|
+
(sum, item) => sum + item.price * item.quantity,
|
|
424
|
+
0
|
|
425
|
+
);
|
|
426
|
+
if (state.coupon) {
|
|
427
|
+
return subtotal * (1 - state.coupon.discount);
|
|
428
|
+
}
|
|
429
|
+
return subtotal;
|
|
430
|
+
}, [state.items, state.coupon]);
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
items: state.items,
|
|
434
|
+
coupon: state.coupon,
|
|
435
|
+
total,
|
|
436
|
+
addItem,
|
|
437
|
+
removeItem,
|
|
438
|
+
updateQuantity,
|
|
439
|
+
clear,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
]]>
|
|
443
|
+
</example>
|
|
444
|
+
</pattern>
|
|
445
|
+
|
|
446
|
+
<pattern name="useImperativeHandle (명령형 API 노출)">
|
|
447
|
+
<description>부모가 자식의 메서드를 호출해야 할 때</description>
|
|
448
|
+
<example>
|
|
449
|
+
<![CDATA[
|
|
450
|
+
// 모달 컴포넌트 - 부모가 열고 닫기를 제어
|
|
451
|
+
interface ModalHandle {
|
|
452
|
+
open: () => void;
|
|
453
|
+
close: () => void;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
interface ModalProps {
|
|
457
|
+
title: string;
|
|
458
|
+
children: ReactNode;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export const Modal = forwardRef<ModalHandle, ModalProps>(
|
|
462
|
+
function Modal({ title, children }, ref) {
|
|
463
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
464
|
+
|
|
465
|
+
// 부모에게 노출할 메서드 정의
|
|
466
|
+
useImperativeHandle(ref, () => ({
|
|
467
|
+
open: () => setIsOpen(true),
|
|
468
|
+
close: () => setIsOpen(false),
|
|
469
|
+
}), []);
|
|
470
|
+
|
|
471
|
+
if (!isOpen) return null;
|
|
472
|
+
|
|
473
|
+
return (
|
|
474
|
+
<div className="modal-overlay">
|
|
475
|
+
<div className="modal">
|
|
476
|
+
<h2>{title}</h2>
|
|
477
|
+
{children}
|
|
478
|
+
<button onClick={() => setIsOpen(false)}>닫기</button>
|
|
479
|
+
</div>
|
|
480
|
+
</div>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
// 사용
|
|
486
|
+
function App() {
|
|
487
|
+
const modalRef = useRef<ModalHandle>(null);
|
|
488
|
+
|
|
489
|
+
return (
|
|
490
|
+
<>
|
|
491
|
+
<button onClick={() => modalRef.current?.open()}>
|
|
492
|
+
모달 열기
|
|
493
|
+
</button>
|
|
494
|
+
<Modal ref={modalRef} title="확인">
|
|
495
|
+
<p>정말 삭제하시겠습니까?</p>
|
|
496
|
+
</Modal>
|
|
497
|
+
</>
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
]]>
|
|
501
|
+
</example>
|
|
502
|
+
</pattern>
|
|
503
|
+
|
|
504
|
+
<pattern name="커스텀 훅 패턴 모음">
|
|
505
|
+
<example name="useToggle">
|
|
506
|
+
<![CDATA[
|
|
507
|
+
export function useToggle(initialValue = false) {
|
|
508
|
+
const [value, setValue] = useState(initialValue);
|
|
509
|
+
|
|
510
|
+
const toggle = useCallback(() => setValue(v => !v), []);
|
|
511
|
+
const setTrue = useCallback(() => setValue(true), []);
|
|
512
|
+
const setFalse = useCallback(() => setValue(false), []);
|
|
513
|
+
|
|
514
|
+
return { value, toggle, setTrue, setFalse };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// 사용
|
|
518
|
+
const { value: isOpen, toggle, setFalse: close } = useToggle();
|
|
519
|
+
]]>
|
|
520
|
+
</example>
|
|
521
|
+
<example name="useDebounce">
|
|
522
|
+
<![CDATA[
|
|
523
|
+
export function useDebounce<T>(value: T, delay: number): T {
|
|
524
|
+
const [debouncedValue, setDebouncedValue] = useState(value);
|
|
525
|
+
|
|
526
|
+
useEffect(() => {
|
|
527
|
+
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
|
528
|
+
return () => clearTimeout(timer);
|
|
529
|
+
}, [value, delay]);
|
|
530
|
+
|
|
531
|
+
return debouncedValue;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 사용 - 검색 입력
|
|
535
|
+
function SearchInput() {
|
|
536
|
+
const [query, setQuery] = useState('');
|
|
537
|
+
const debouncedQuery = useDebounce(query, 300);
|
|
538
|
+
|
|
539
|
+
// debouncedQuery가 변경될 때만 API 호출
|
|
540
|
+
const { data } = useQuery({
|
|
541
|
+
queryKey: ['search', debouncedQuery],
|
|
542
|
+
queryFn: () => searchApi(debouncedQuery),
|
|
543
|
+
enabled: debouncedQuery.length > 0,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
]]>
|
|
547
|
+
</example>
|
|
548
|
+
<example name="useLocalStorage">
|
|
549
|
+
<![CDATA[
|
|
550
|
+
export function useLocalStorage<T>(key: string, initialValue: T) {
|
|
551
|
+
const [storedValue, setStoredValue] = useState<T>(() => {
|
|
552
|
+
if (typeof window === 'undefined') return initialValue;
|
|
553
|
+
try {
|
|
554
|
+
const item = window.localStorage.getItem(key);
|
|
555
|
+
return item ? JSON.parse(item) : initialValue;
|
|
556
|
+
} catch {
|
|
557
|
+
return initialValue;
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const setValue = useCallback((value: T | ((val: T) => T)) => {
|
|
562
|
+
setStoredValue(prev => {
|
|
563
|
+
const valueToStore = value instanceof Function ? value(prev) : value;
|
|
564
|
+
if (typeof window !== 'undefined') {
|
|
565
|
+
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
|
566
|
+
}
|
|
567
|
+
return valueToStore;
|
|
568
|
+
});
|
|
569
|
+
}, [key]);
|
|
570
|
+
|
|
571
|
+
return [storedValue, setValue] as const;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// 사용
|
|
575
|
+
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
|
|
576
|
+
]]>
|
|
577
|
+
</example>
|
|
578
|
+
<example name="usePrevious">
|
|
579
|
+
<![CDATA[
|
|
580
|
+
export function usePrevious<T>(value: T): T | undefined {
|
|
581
|
+
const ref = useRef<T>();
|
|
582
|
+
|
|
583
|
+
useEffect(() => {
|
|
584
|
+
ref.current = value;
|
|
585
|
+
}, [value]);
|
|
586
|
+
|
|
587
|
+
return ref.current;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// 사용 - 이전 값과 비교
|
|
591
|
+
function Counter({ count }: { count: number }) {
|
|
592
|
+
const prevCount = usePrevious(count);
|
|
593
|
+
|
|
594
|
+
return (
|
|
595
|
+
<div>
|
|
596
|
+
현재: {count}, 이전: {prevCount ?? 'N/A'}
|
|
597
|
+
{prevCount !== undefined && count > prevCount && ' ↑'}
|
|
598
|
+
{prevCount !== undefined && count < prevCount && ' ↓'}
|
|
599
|
+
</div>
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
]]>
|
|
603
|
+
</example>
|
|
604
|
+
</pattern>
|
|
605
|
+
</hooks-patterns>
|
|
606
|
+
|
|
607
|
+
<state-management>
|
|
608
|
+
<guideline name="상태 위치 결정">
|
|
609
|
+
<case location="로컬 (useState)">단일 컴포넌트에서만 사용</case>
|
|
610
|
+
<case location="상위 전달 (Props)">부모-자식 간 공유</case>
|
|
611
|
+
<case location="Context">깊은 트리에서 공유 (테마, 인증, i18n)</case>
|
|
612
|
+
<case location="전역 (Zustand)">여러 페이지에서 공유, 복잡한 상태</case>
|
|
613
|
+
<case location="서버 상태 (React Query)">API 데이터 - 캐싱, 동기화</case>
|
|
614
|
+
</guideline>
|
|
615
|
+
|
|
616
|
+
<example name="Zustand 기본">
|
|
617
|
+
<![CDATA[
|
|
618
|
+
// store/use-auth-store.ts
|
|
619
|
+
import { create } from 'zustand';
|
|
620
|
+
import { persist } from 'zustand/middleware';
|
|
621
|
+
|
|
622
|
+
interface User {
|
|
623
|
+
id: string;
|
|
624
|
+
email: string;
|
|
625
|
+
name: string;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
interface AuthStore {
|
|
629
|
+
user: User | null;
|
|
630
|
+
token: string | null;
|
|
631
|
+
isAuthenticated: boolean;
|
|
632
|
+
login: (user: User, token: string) => void;
|
|
633
|
+
logout: () => void;
|
|
634
|
+
updateUser: (updates: Partial<User>) => void;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export const useAuthStore = create<AuthStore>()(
|
|
638
|
+
persist(
|
|
639
|
+
(set, get) => ({
|
|
640
|
+
user: null,
|
|
641
|
+
token: null,
|
|
642
|
+
isAuthenticated: false,
|
|
643
|
+
|
|
644
|
+
login: (user, token) => set({
|
|
645
|
+
user,
|
|
646
|
+
token,
|
|
647
|
+
isAuthenticated: true,
|
|
648
|
+
}),
|
|
649
|
+
|
|
650
|
+
logout: () => set({
|
|
651
|
+
user: null,
|
|
652
|
+
token: null,
|
|
653
|
+
isAuthenticated: false,
|
|
654
|
+
}),
|
|
655
|
+
|
|
656
|
+
updateUser: (updates) => set({
|
|
657
|
+
user: get().user ? { ...get().user!, ...updates } : null,
|
|
658
|
+
}),
|
|
659
|
+
}),
|
|
660
|
+
{
|
|
661
|
+
name: 'auth-storage', // localStorage key
|
|
662
|
+
partialize: (state) => ({ token: state.token }), // token만 저장
|
|
663
|
+
}
|
|
664
|
+
)
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
// 사용
|
|
668
|
+
function Profile() {
|
|
669
|
+
const { user, logout } = useAuthStore();
|
|
670
|
+
|
|
671
|
+
if (!user) return <LoginPrompt />;
|
|
672
|
+
|
|
673
|
+
return (
|
|
674
|
+
<div>
|
|
675
|
+
<h1>{user.name}</h1>
|
|
676
|
+
<button onClick={logout}>로그아웃</button>
|
|
677
|
+
</div>
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
]]>
|
|
681
|
+
</example>
|
|
682
|
+
|
|
683
|
+
<example name="Zustand + React Query 조합">
|
|
684
|
+
<![CDATA[
|
|
685
|
+
// 서버 상태는 React Query, UI 상태는 Zustand
|
|
686
|
+
|
|
687
|
+
// store/use-ui-store.ts
|
|
688
|
+
interface UIStore {
|
|
689
|
+
sidebarOpen: boolean;
|
|
690
|
+
toggleSidebar: () => void;
|
|
691
|
+
selectedItems: string[];
|
|
692
|
+
selectItem: (id: string) => void;
|
|
693
|
+
clearSelection: () => void;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export const useUIStore = create<UIStore>((set) => ({
|
|
697
|
+
sidebarOpen: true,
|
|
698
|
+
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
|
699
|
+
selectedItems: [],
|
|
700
|
+
selectItem: (id) => set((state) => ({
|
|
701
|
+
selectedItems: state.selectedItems.includes(id)
|
|
702
|
+
? state.selectedItems.filter(i => i !== id)
|
|
703
|
+
: [...state.selectedItems, id]
|
|
704
|
+
})),
|
|
705
|
+
clearSelection: () => set({ selectedItems: [] }),
|
|
706
|
+
}));
|
|
707
|
+
|
|
708
|
+
// 컴포넌트에서 조합
|
|
709
|
+
function ItemList() {
|
|
710
|
+
// 서버 상태 (React Query)
|
|
711
|
+
const { data: items, isLoading } = useQuery({
|
|
712
|
+
queryKey: ['items'],
|
|
713
|
+
queryFn: fetchItems,
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// UI 상태 (Zustand)
|
|
717
|
+
const { selectedItems, selectItem } = useUIStore();
|
|
718
|
+
|
|
719
|
+
if (isLoading) return <Skeleton />;
|
|
720
|
+
|
|
721
|
+
return (
|
|
722
|
+
<ul>
|
|
723
|
+
{items?.map(item => (
|
|
724
|
+
<li
|
|
725
|
+
key={item.id}
|
|
726
|
+
className={selectedItems.includes(item.id) ? 'selected' : ''}
|
|
727
|
+
onClick={() => selectItem(item.id)}
|
|
728
|
+
>
|
|
729
|
+
{item.name}
|
|
730
|
+
</li>
|
|
731
|
+
))}
|
|
732
|
+
</ul>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
]]>
|
|
736
|
+
</example>
|
|
737
|
+
</state-management>
|
|
738
|
+
|
|
739
|
+
<vercel-best-practices>
|
|
740
|
+
<section name="Eliminating Waterfalls" priority="CRITICAL">
|
|
741
|
+
<description>Waterfalls are the #1 performance killer. Each sequential await adds full network latency.</description>
|
|
742
|
+
<pattern name="Promise.all for Independent Operations">
|
|
743
|
+
<example type="bad">
|
|
744
|
+
<![CDATA[
|
|
745
|
+
// Bad: sequential execution, 3 round trips
|
|
746
|
+
const user = await fetchUser();
|
|
747
|
+
const posts = await fetchPosts();
|
|
748
|
+
const comments = await fetchComments();
|
|
749
|
+
]]>
|
|
750
|
+
</example>
|
|
751
|
+
<example type="good">
|
|
752
|
+
<![CDATA[
|
|
753
|
+
// Good: parallel execution, 1 round trip
|
|
754
|
+
const [user, posts, comments] = await Promise.all([
|
|
755
|
+
fetchUser(),
|
|
756
|
+
fetchPosts(),
|
|
757
|
+
fetchComments(),
|
|
758
|
+
]);
|
|
759
|
+
]]>
|
|
760
|
+
</example>
|
|
761
|
+
</pattern>
|
|
762
|
+
<pattern name="Strategic Suspense Boundaries">
|
|
763
|
+
<example type="bad">
|
|
764
|
+
<![CDATA[
|
|
765
|
+
// Bad: entire layout waits for data
|
|
766
|
+
async function Page() {
|
|
767
|
+
const data = await fetchData(); // Blocks entire page
|
|
768
|
+
|
|
769
|
+
return (
|
|
770
|
+
<div>
|
|
771
|
+
<Sidebar />
|
|
772
|
+
<Header />
|
|
773
|
+
<DataDisplay data={data} />
|
|
774
|
+
<Footer />
|
|
775
|
+
</div>
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
]]>
|
|
779
|
+
</example>
|
|
780
|
+
<example type="good">
|
|
781
|
+
<![CDATA[
|
|
782
|
+
// Good: wrapper shows immediately, data streams in
|
|
783
|
+
function Page() {
|
|
784
|
+
return (
|
|
785
|
+
<div>
|
|
786
|
+
<Sidebar />
|
|
787
|
+
<Header />
|
|
788
|
+
<Suspense fallback={<Skeleton />}>
|
|
789
|
+
<DataDisplay />
|
|
790
|
+
</Suspense>
|
|
791
|
+
<Footer />
|
|
792
|
+
</div>
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function DataDisplay() {
|
|
797
|
+
const data = await fetchData(); // Only blocks this component
|
|
798
|
+
return <div>{data.content}</div>;
|
|
799
|
+
}
|
|
800
|
+
]]>
|
|
801
|
+
</example>
|
|
802
|
+
</pattern>
|
|
803
|
+
</section>
|
|
804
|
+
|
|
805
|
+
<section name="Bundle Size Optimization" priority="CRITICAL">
|
|
806
|
+
<pattern name="Avoid Barrel File Imports">
|
|
807
|
+
<description>Import directly from source files to avoid loading thousands of unused modules</description>
|
|
808
|
+
<example type="bad">
|
|
809
|
+
<![CDATA[
|
|
810
|
+
// Bad: imports entire library (200-800ms extra on cold start)
|
|
811
|
+
import { Check, X, Menu } from 'lucide-react';
|
|
812
|
+
import { Button, TextField } from '@mui/material';
|
|
813
|
+
]]>
|
|
814
|
+
</example>
|
|
815
|
+
<example type="good">
|
|
816
|
+
<![CDATA[
|
|
817
|
+
// Good: imports only what you need
|
|
818
|
+
import Check from 'lucide-react/dist/esm/icons/check';
|
|
819
|
+
import X from 'lucide-react/dist/esm/icons/x';
|
|
820
|
+
import Button from '@mui/material/Button';
|
|
821
|
+
|
|
822
|
+
// Or use Next.js config:
|
|
823
|
+
// next.config.js
|
|
824
|
+
module.exports = {
|
|
825
|
+
experimental: {
|
|
826
|
+
optimizePackageImports: ['lucide-react', '@mui/material']
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
]]>
|
|
830
|
+
</example>
|
|
831
|
+
</pattern>
|
|
832
|
+
<pattern name="Dynamic Imports for Heavy Components">
|
|
833
|
+
<example type="bad">
|
|
834
|
+
<![CDATA[
|
|
835
|
+
// Bad: Monaco bundles with main chunk ~300KB
|
|
836
|
+
import { MonacoEditor } from './monaco-editor';
|
|
837
|
+
|
|
838
|
+
function CodePanel({ code }: { code: string }) {
|
|
839
|
+
return <MonacoEditor value={code} />;
|
|
840
|
+
}
|
|
841
|
+
]]>
|
|
842
|
+
</example>
|
|
843
|
+
<example type="good">
|
|
844
|
+
<![CDATA[
|
|
845
|
+
// Good: Monaco loads on demand
|
|
846
|
+
import dynamic from 'next/dynamic';
|
|
847
|
+
|
|
848
|
+
const MonacoEditor = dynamic(
|
|
849
|
+
() => import('./monaco-editor').then(m => m.MonacoEditor),
|
|
850
|
+
{ ssr: false }
|
|
851
|
+
);
|
|
852
|
+
|
|
853
|
+
function CodePanel({ code }: { code: string }) {
|
|
854
|
+
return <MonacoEditor value={code} />;
|
|
855
|
+
}
|
|
856
|
+
]]>
|
|
857
|
+
</example>
|
|
858
|
+
</pattern>
|
|
859
|
+
<pattern name="Preload Based on User Intent">
|
|
860
|
+
<example>
|
|
861
|
+
<![CDATA[
|
|
862
|
+
// Preload on hover/focus for perceived speed
|
|
863
|
+
function EditorButton({ onClick }: { onClick: () => void }) {
|
|
864
|
+
const preload = () => {
|
|
865
|
+
if (typeof window !== 'undefined') {
|
|
866
|
+
void import('./monaco-editor');
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
return (
|
|
871
|
+
<button
|
|
872
|
+
onMouseEnter={preload}
|
|
873
|
+
onFocus={preload}
|
|
874
|
+
onClick={onClick}
|
|
875
|
+
>
|
|
876
|
+
Open Editor
|
|
877
|
+
</button>
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
]]>
|
|
881
|
+
</example>
|
|
882
|
+
</pattern>
|
|
883
|
+
</section>
|
|
884
|
+
|
|
885
|
+
<section name="Server-Side Performance" priority="HIGH">
|
|
886
|
+
<pattern name="Minimize Serialization at RSC Boundaries">
|
|
887
|
+
<description>Only pass fields that the client actually uses</description>
|
|
888
|
+
<example type="bad">
|
|
889
|
+
<![CDATA[
|
|
890
|
+
// Bad: serializes all 50 fields
|
|
891
|
+
async function Page() {
|
|
892
|
+
const user = await fetchUser(); // 50 fields
|
|
893
|
+
return <Profile user={user} />;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
'use client'
|
|
897
|
+
function Profile({ user }: { user: User }) {
|
|
898
|
+
return <div>{user.name}</div>; // uses 1 field
|
|
899
|
+
}
|
|
900
|
+
]]>
|
|
901
|
+
</example>
|
|
902
|
+
<example type="good">
|
|
903
|
+
<![CDATA[
|
|
904
|
+
// Good: serializes only 1 field
|
|
905
|
+
async function Page() {
|
|
906
|
+
const user = await fetchUser();
|
|
907
|
+
return <Profile name={user.name} />;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
'use client'
|
|
911
|
+
function Profile({ name }: { name: string }) {
|
|
912
|
+
return <div>{name}</div>;
|
|
913
|
+
}
|
|
914
|
+
]]>
|
|
915
|
+
</example>
|
|
916
|
+
</pattern>
|
|
917
|
+
<pattern name="React.cache() for Per-Request Deduplication">
|
|
918
|
+
<example>
|
|
919
|
+
<![CDATA[
|
|
920
|
+
import { cache } from 'react';
|
|
921
|
+
|
|
922
|
+
export const getCurrentUser = cache(async () => {
|
|
923
|
+
const session = await auth();
|
|
924
|
+
if (!session?.user?.id) return null;
|
|
925
|
+
return await db.user.findUnique({
|
|
926
|
+
where: { id: session.user.id }
|
|
927
|
+
});
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
// Multiple calls execute query only once within a single request
|
|
931
|
+
]]>
|
|
932
|
+
</example>
|
|
933
|
+
</pattern>
|
|
934
|
+
</section>
|
|
935
|
+
|
|
936
|
+
<section name="Re-render Optimization" priority="MEDIUM">
|
|
937
|
+
<pattern name="Functional setState Updates">
|
|
938
|
+
<description>Prevents stale closures and creates stable callback references</description>
|
|
939
|
+
<example type="bad">
|
|
940
|
+
<![CDATA[
|
|
941
|
+
// Bad: requires state as dependency, risk of stale closure
|
|
942
|
+
const [items, setItems] = useState(initialItems);
|
|
943
|
+
|
|
944
|
+
const addItems = useCallback((newItems: Item[]) => {
|
|
945
|
+
setItems([...items, ...newItems]);
|
|
946
|
+
}, [items]); // ❌ recreated on every items change
|
|
947
|
+
]]>
|
|
948
|
+
</example>
|
|
949
|
+
<example type="good">
|
|
950
|
+
<![CDATA[
|
|
951
|
+
// Good: stable callback, no stale closures
|
|
952
|
+
const [items, setItems] = useState(initialItems);
|
|
953
|
+
|
|
954
|
+
const addItems = useCallback((newItems: Item[]) => {
|
|
955
|
+
setItems(curr => [...curr, ...newItems]);
|
|
956
|
+
}, []); // ✅ No dependencies needed, never recreated
|
|
957
|
+
]]>
|
|
958
|
+
</example>
|
|
959
|
+
</pattern>
|
|
960
|
+
<pattern name="Lazy State Initialization">
|
|
961
|
+
<example type="bad">
|
|
962
|
+
<![CDATA[
|
|
963
|
+
// Bad: buildSearchIndex() runs on EVERY render
|
|
964
|
+
const [searchIndex, setSearchIndex] = useState(buildSearchIndex(items));
|
|
965
|
+
]]>
|
|
966
|
+
</example>
|
|
967
|
+
<example type="good">
|
|
968
|
+
<![CDATA[
|
|
969
|
+
// Good: buildSearchIndex() runs ONLY on initial render
|
|
970
|
+
const [searchIndex, setSearchIndex] = useState(() => buildSearchIndex(items));
|
|
971
|
+
]]>
|
|
972
|
+
</example>
|
|
973
|
+
</pattern>
|
|
974
|
+
<pattern name="startTransition for Non-Urgent Updates">
|
|
975
|
+
<example>
|
|
976
|
+
<![CDATA[
|
|
977
|
+
import { startTransition } from 'react';
|
|
978
|
+
|
|
979
|
+
function ScrollTracker() {
|
|
980
|
+
const [scrollY, setScrollY] = useState(0);
|
|
981
|
+
|
|
982
|
+
useEffect(() => {
|
|
983
|
+
const handler = () => {
|
|
984
|
+
startTransition(() => setScrollY(window.scrollY));
|
|
985
|
+
};
|
|
986
|
+
window.addEventListener('scroll', handler, { passive: true });
|
|
987
|
+
return () => window.removeEventListener('scroll', handler);
|
|
988
|
+
}, []);
|
|
989
|
+
}
|
|
990
|
+
]]>
|
|
991
|
+
</example>
|
|
992
|
+
</pattern>
|
|
993
|
+
</section>
|
|
994
|
+
|
|
995
|
+
<section name="Rendering Performance" priority="MEDIUM">
|
|
996
|
+
<pattern name="CSS content-visibility for Long Lists">
|
|
997
|
+
<example>
|
|
998
|
+
<![CDATA[
|
|
999
|
+
// CSS - defers off-screen rendering
|
|
1000
|
+
.message-item {
|
|
1001
|
+
content-visibility: auto;
|
|
1002
|
+
contain-intrinsic-size: 0 80px;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// 1000 messages: browser skips layout/paint for ~990 off-screen items
|
|
1006
|
+
// Result: 10× faster initial render
|
|
1007
|
+
]]>
|
|
1008
|
+
</example>
|
|
1009
|
+
</pattern>
|
|
1010
|
+
<pattern name="Explicit Conditional Rendering">
|
|
1011
|
+
<example type="bad">
|
|
1012
|
+
<![CDATA[
|
|
1013
|
+
// Bad: renders "0" when count is 0
|
|
1014
|
+
{count && <span className="badge">{count}</span>}
|
|
1015
|
+
]]>
|
|
1016
|
+
</example>
|
|
1017
|
+
<example type="good">
|
|
1018
|
+
<![CDATA[
|
|
1019
|
+
// Good: renders nothing when count is 0
|
|
1020
|
+
{count > 0 ? <span className="badge">{count}</span> : null}
|
|
1021
|
+
]]>
|
|
1022
|
+
</example>
|
|
1023
|
+
</pattern>
|
|
1024
|
+
<pattern name="Use toSorted() for Immutability">
|
|
1025
|
+
<example type="bad">
|
|
1026
|
+
<![CDATA[
|
|
1027
|
+
// Bad: mutates the users prop array!
|
|
1028
|
+
const sorted = useMemo(
|
|
1029
|
+
() => users.sort((a, b) => a.name.localeCompare(b.name)),
|
|
1030
|
+
[users]
|
|
1031
|
+
);
|
|
1032
|
+
]]>
|
|
1033
|
+
</example>
|
|
1034
|
+
<example type="good">
|
|
1035
|
+
<![CDATA[
|
|
1036
|
+
// Good: creates new sorted array, original unchanged
|
|
1037
|
+
const sorted = useMemo(
|
|
1038
|
+
() => users.toSorted((a, b) => a.name.localeCompare(b.name)),
|
|
1039
|
+
[users]
|
|
1040
|
+
);
|
|
1041
|
+
]]>
|
|
1042
|
+
</example>
|
|
1043
|
+
</pattern>
|
|
1044
|
+
</section>
|
|
1045
|
+
</vercel-best-practices>
|
|
1046
|
+
|
|
1047
|
+
<performance-patterns>
|
|
1048
|
+
<pattern name="React.memo (리렌더링 방지)">
|
|
1049
|
+
<description>props가 변경되지 않으면 리렌더링 스킵</description>
|
|
1050
|
+
<example>
|
|
1051
|
+
<![CDATA[
|
|
1052
|
+
// 무거운 컴포넌트 - memo로 감싸기
|
|
1053
|
+
interface ExpensiveListProps {
|
|
1054
|
+
items: Item[];
|
|
1055
|
+
onItemClick: (item: Item) => void;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
export const ExpensiveList = memo(function ExpensiveList({
|
|
1059
|
+
items,
|
|
1060
|
+
onItemClick,
|
|
1061
|
+
}: ExpensiveListProps) {
|
|
1062
|
+
console.log('ExpensiveList rendered'); // props 변경 시만 출력
|
|
1063
|
+
|
|
1064
|
+
return (
|
|
1065
|
+
<ul>
|
|
1066
|
+
{items.map(item => (
|
|
1067
|
+
<li key={item.id} onClick={() => onItemClick(item)}>
|
|
1068
|
+
{item.name}
|
|
1069
|
+
</li>
|
|
1070
|
+
))}
|
|
1071
|
+
</ul>
|
|
1072
|
+
);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
// 부모에서 사용 시 주의: onItemClick도 안정적이어야 함
|
|
1076
|
+
function Parent() {
|
|
1077
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
1078
|
+
const [count, setCount] = useState(0);
|
|
1079
|
+
|
|
1080
|
+
// useCallback으로 함수 참조 안정화
|
|
1081
|
+
const handleItemClick = useCallback((item: Item) => {
|
|
1082
|
+
console.log('clicked', item);
|
|
1083
|
+
}, []); // 의존성 없으면 함수 참조 유지
|
|
1084
|
+
|
|
1085
|
+
return (
|
|
1086
|
+
<>
|
|
1087
|
+
<button onClick={() => setCount(c => c + 1)}>
|
|
1088
|
+
Count: {count}
|
|
1089
|
+
</button>
|
|
1090
|
+
{/* count가 변해도 ExpensiveList는 리렌더링 안 됨 */}
|
|
1091
|
+
<ExpensiveList items={items} onItemClick={handleItemClick} />
|
|
1092
|
+
</>
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
]]>
|
|
1096
|
+
</example>
|
|
1097
|
+
</pattern>
|
|
1098
|
+
|
|
1099
|
+
<pattern name="useMemo (계산 캐싱)">
|
|
1100
|
+
<example>
|
|
1101
|
+
<![CDATA[
|
|
1102
|
+
function Dashboard({ transactions }: { transactions: Transaction[] }) {
|
|
1103
|
+
// 무거운 계산 - 의존성 변경 시만 재계산
|
|
1104
|
+
const statistics = useMemo(() => {
|
|
1105
|
+
console.log('Calculating statistics...');
|
|
1106
|
+
return {
|
|
1107
|
+
total: transactions.reduce((sum, t) => sum + t.amount, 0),
|
|
1108
|
+
average: transactions.length
|
|
1109
|
+
? transactions.reduce((sum, t) => sum + t.amount, 0) / transactions.length
|
|
1110
|
+
: 0,
|
|
1111
|
+
max: Math.max(...transactions.map(t => t.amount)),
|
|
1112
|
+
byCategory: transactions.reduce((acc, t) => {
|
|
1113
|
+
acc[t.category] = (acc[t.category] || 0) + t.amount;
|
|
1114
|
+
return acc;
|
|
1115
|
+
}, {} as Record<string, number>),
|
|
1116
|
+
};
|
|
1117
|
+
}, [transactions]); // transactions 변경 시만 재계산
|
|
1118
|
+
|
|
1119
|
+
return (
|
|
1120
|
+
<div>
|
|
1121
|
+
<StatCard title="총액" value={statistics.total} />
|
|
1122
|
+
<StatCard title="평균" value={statistics.average} />
|
|
1123
|
+
<CategoryChart data={statistics.byCategory} />
|
|
1124
|
+
</div>
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
]]>
|
|
1128
|
+
</example>
|
|
1129
|
+
</pattern>
|
|
1130
|
+
|
|
1131
|
+
<pattern name="가상화 (Virtualization)">
|
|
1132
|
+
<description>대량 리스트 렌더링 최적화</description>
|
|
1133
|
+
<example>
|
|
1134
|
+
<![CDATA[
|
|
1135
|
+
// @tanstack/react-virtual 사용
|
|
1136
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
1137
|
+
|
|
1138
|
+
interface VirtualListProps {
|
|
1139
|
+
items: Item[];
|
|
1140
|
+
onItemClick: (item: Item) => void;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
export function VirtualList({ items, onItemClick }: VirtualListProps) {
|
|
1144
|
+
const parentRef = useRef<HTMLDivElement>(null);
|
|
1145
|
+
|
|
1146
|
+
const virtualizer = useVirtualizer({
|
|
1147
|
+
count: items.length,
|
|
1148
|
+
getScrollElement: () => parentRef.current,
|
|
1149
|
+
estimateSize: () => 50, // 각 아이템 높이 추정
|
|
1150
|
+
overscan: 5, // 뷰포트 밖에 미리 렌더링할 개수
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
return (
|
|
1154
|
+
<div
|
|
1155
|
+
ref={parentRef}
|
|
1156
|
+
className="virtual-list-container"
|
|
1157
|
+
style={{ height: '400px', overflow: 'auto' }}
|
|
1158
|
+
>
|
|
1159
|
+
<div
|
|
1160
|
+
style={{
|
|
1161
|
+
height: `${virtualizer.getTotalSize()}px`,
|
|
1162
|
+
width: '100%',
|
|
1163
|
+
position: 'relative',
|
|
1164
|
+
}}
|
|
1165
|
+
>
|
|
1166
|
+
{virtualizer.getVirtualItems().map((virtualItem) => {
|
|
1167
|
+
const item = items[virtualItem.index];
|
|
1168
|
+
return (
|
|
1169
|
+
<div
|
|
1170
|
+
key={item.id}
|
|
1171
|
+
className="virtual-list-item"
|
|
1172
|
+
style={{
|
|
1173
|
+
position: 'absolute',
|
|
1174
|
+
top: 0,
|
|
1175
|
+
left: 0,
|
|
1176
|
+
width: '100%',
|
|
1177
|
+
height: `${virtualItem.size}px`,
|
|
1178
|
+
transform: `translateY(${virtualItem.start}px)`,
|
|
1179
|
+
}}
|
|
1180
|
+
onClick={() => onItemClick(item)}
|
|
1181
|
+
>
|
|
1182
|
+
{item.name}
|
|
1183
|
+
</div>
|
|
1184
|
+
);
|
|
1185
|
+
})}
|
|
1186
|
+
</div>
|
|
1187
|
+
</div>
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// 10,000개 아이템도 부드럽게 렌더링
|
|
1192
|
+
<VirtualList items={tenThousandItems} onItemClick={handleClick} />
|
|
1193
|
+
]]>
|
|
1194
|
+
</example>
|
|
1195
|
+
</pattern>
|
|
1196
|
+
|
|
1197
|
+
<pattern name="Code Splitting (지연 로딩)">
|
|
1198
|
+
<example>
|
|
1199
|
+
<![CDATA[
|
|
1200
|
+
import { lazy, Suspense } from 'react';
|
|
1201
|
+
|
|
1202
|
+
// 무거운 컴포넌트 지연 로딩
|
|
1203
|
+
const HeavyChart = lazy(() => import('./HeavyChart'));
|
|
1204
|
+
const AdminPanel = lazy(() => import('./AdminPanel'));
|
|
1205
|
+
|
|
1206
|
+
function App() {
|
|
1207
|
+
const { isAdmin } = useAuth();
|
|
1208
|
+
|
|
1209
|
+
return (
|
|
1210
|
+
<div>
|
|
1211
|
+
{/* 차트가 필요할 때만 로딩 */}
|
|
1212
|
+
<Suspense fallback={<ChartSkeleton />}>
|
|
1213
|
+
<HeavyChart data={data} />
|
|
1214
|
+
</Suspense>
|
|
1215
|
+
|
|
1216
|
+
{/* 관리자만 접근하는 패널 */}
|
|
1217
|
+
{isAdmin && (
|
|
1218
|
+
<Suspense fallback={<Loading />}>
|
|
1219
|
+
<AdminPanel />
|
|
1220
|
+
</Suspense>
|
|
1221
|
+
)}
|
|
1222
|
+
</div>
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// 라우터에서 페이지 단위 분할
|
|
1227
|
+
const routes = [
|
|
1228
|
+
{
|
|
1229
|
+
path: '/dashboard',
|
|
1230
|
+
element: lazy(() => import('./pages/Dashboard')),
|
|
1231
|
+
},
|
|
1232
|
+
{
|
|
1233
|
+
path: '/settings',
|
|
1234
|
+
element: lazy(() => import('./pages/Settings')),
|
|
1235
|
+
},
|
|
1236
|
+
];
|
|
1237
|
+
]]>
|
|
1238
|
+
</example>
|
|
1239
|
+
</pattern>
|
|
1240
|
+
</performance-patterns>
|
|
1241
|
+
|
|
1242
|
+
<error-handling>
|
|
1243
|
+
<pattern name="Error Boundary">
|
|
1244
|
+
<example>
|
|
1245
|
+
<![CDATA[
|
|
1246
|
+
// components/error-boundary.tsx
|
|
1247
|
+
interface ErrorBoundaryProps {
|
|
1248
|
+
children: ReactNode;
|
|
1249
|
+
fallback?: ReactNode;
|
|
1250
|
+
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
interface ErrorBoundaryState {
|
|
1254
|
+
hasError: boolean;
|
|
1255
|
+
error: Error | null;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
1259
|
+
state: ErrorBoundaryState = { hasError: false, error: null };
|
|
1260
|
+
|
|
1261
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
1262
|
+
return { hasError: true, error };
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
1266
|
+
// 에러 로깅 서비스로 전송
|
|
1267
|
+
console.error('Error caught by boundary:', error, errorInfo);
|
|
1268
|
+
this.props.onError?.(error, errorInfo);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
render() {
|
|
1272
|
+
if (this.state.hasError) {
|
|
1273
|
+
return this.props.fallback ?? (
|
|
1274
|
+
<div className="error-fallback">
|
|
1275
|
+
<h2>문제가 발생했습니다</h2>
|
|
1276
|
+
<p>{this.state.error?.message}</p>
|
|
1277
|
+
<button onClick={() => this.setState({ hasError: false, error: null })}>
|
|
1278
|
+
다시 시도
|
|
1279
|
+
</button>
|
|
1280
|
+
</div>
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
return this.props.children;
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// 사용
|
|
1289
|
+
<ErrorBoundary
|
|
1290
|
+
fallback={<ErrorPage />}
|
|
1291
|
+
onError={(error) => sendToSentry(error)}
|
|
1292
|
+
>
|
|
1293
|
+
<App />
|
|
1294
|
+
</ErrorBoundary>
|
|
1295
|
+
|
|
1296
|
+
// 섹션별로 격리
|
|
1297
|
+
<Layout>
|
|
1298
|
+
<ErrorBoundary fallback={<SidebarError />}>
|
|
1299
|
+
<Sidebar />
|
|
1300
|
+
</ErrorBoundary>
|
|
1301
|
+
<ErrorBoundary fallback={<ContentError />}>
|
|
1302
|
+
<MainContent />
|
|
1303
|
+
</ErrorBoundary>
|
|
1304
|
+
</Layout>
|
|
1305
|
+
]]>
|
|
1306
|
+
</example>
|
|
1307
|
+
</pattern>
|
|
1308
|
+
</error-handling>
|
|
1309
|
+
|
|
1310
|
+
<form-patterns>
|
|
1311
|
+
<pattern name="React Hook Form + Zod">
|
|
1312
|
+
<description>타입 안전한 폼 처리</description>
|
|
1313
|
+
<example>
|
|
1314
|
+
<![CDATA[
|
|
1315
|
+
import { useForm } from 'react-hook-form';
|
|
1316
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
1317
|
+
import { z } from 'zod';
|
|
1318
|
+
|
|
1319
|
+
// 스키마 정의
|
|
1320
|
+
const signupSchema = z.object({
|
|
1321
|
+
email: z.string().email('올바른 이메일을 입력하세요'),
|
|
1322
|
+
password: z.string()
|
|
1323
|
+
.min(8, '비밀번호는 8자 이상이어야 합니다')
|
|
1324
|
+
.regex(/[A-Z]/, '대문자를 포함해야 합니다')
|
|
1325
|
+
.regex(/[0-9]/, '숫자를 포함해야 합니다'),
|
|
1326
|
+
confirmPassword: z.string(),
|
|
1327
|
+
name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
|
|
1328
|
+
agreeToTerms: z.literal(true, {
|
|
1329
|
+
errorMap: () => ({ message: '약관에 동의해야 합니다' }),
|
|
1330
|
+
}),
|
|
1331
|
+
}).refine((data) => data.password === data.confirmPassword, {
|
|
1332
|
+
message: '비밀번호가 일치하지 않습니다',
|
|
1333
|
+
path: ['confirmPassword'],
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
type SignupForm = z.infer<typeof signupSchema>;
|
|
1337
|
+
|
|
1338
|
+
export function SignupForm() {
|
|
1339
|
+
const {
|
|
1340
|
+
register,
|
|
1341
|
+
handleSubmit,
|
|
1342
|
+
formState: { errors, isSubmitting },
|
|
1343
|
+
reset,
|
|
1344
|
+
} = useForm<SignupForm>({
|
|
1345
|
+
resolver: zodResolver(signupSchema),
|
|
1346
|
+
defaultValues: {
|
|
1347
|
+
email: '',
|
|
1348
|
+
password: '',
|
|
1349
|
+
confirmPassword: '',
|
|
1350
|
+
name: '',
|
|
1351
|
+
agreeToTerms: false,
|
|
1352
|
+
},
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
const onSubmit = async (data: SignupForm) => {
|
|
1356
|
+
try {
|
|
1357
|
+
await signupApi(data);
|
|
1358
|
+
reset();
|
|
1359
|
+
toast.success('회원가입 완료!');
|
|
1360
|
+
} catch (error) {
|
|
1361
|
+
toast.error('회원가입 실패');
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
return (
|
|
1366
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
1367
|
+
<div>
|
|
1368
|
+
<label>이메일</label>
|
|
1369
|
+
<input {...register('email')} type="email" />
|
|
1370
|
+
{errors.email && <span className="error">{errors.email.message}</span>}
|
|
1371
|
+
</div>
|
|
1372
|
+
|
|
1373
|
+
<div>
|
|
1374
|
+
<label>비밀번호</label>
|
|
1375
|
+
<input {...register('password')} type="password" />
|
|
1376
|
+
{errors.password && <span className="error">{errors.password.message}</span>}
|
|
1377
|
+
</div>
|
|
1378
|
+
|
|
1379
|
+
<div>
|
|
1380
|
+
<label>비밀번호 확인</label>
|
|
1381
|
+
<input {...register('confirmPassword')} type="password" />
|
|
1382
|
+
{errors.confirmPassword && (
|
|
1383
|
+
<span className="error">{errors.confirmPassword.message}</span>
|
|
1384
|
+
)}
|
|
1385
|
+
</div>
|
|
1386
|
+
|
|
1387
|
+
<div>
|
|
1388
|
+
<label>이름</label>
|
|
1389
|
+
<input {...register('name')} />
|
|
1390
|
+
{errors.name && <span className="error">{errors.name.message}</span>}
|
|
1391
|
+
</div>
|
|
1392
|
+
|
|
1393
|
+
<div>
|
|
1394
|
+
<label>
|
|
1395
|
+
<input {...register('agreeToTerms')} type="checkbox" />
|
|
1396
|
+
약관에 동의합니다
|
|
1397
|
+
</label>
|
|
1398
|
+
{errors.agreeToTerms && (
|
|
1399
|
+
<span className="error">{errors.agreeToTerms.message}</span>
|
|
1400
|
+
)}
|
|
1401
|
+
</div>
|
|
1402
|
+
|
|
1403
|
+
<button type="submit" disabled={isSubmitting}>
|
|
1404
|
+
{isSubmitting ? '처리 중...' : '회원가입'}
|
|
1405
|
+
</button>
|
|
1406
|
+
</form>
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
]]>
|
|
1410
|
+
</example>
|
|
1411
|
+
</pattern>
|
|
1412
|
+
</form-patterns>
|
|
1413
|
+
|
|
1414
|
+
<testing-patterns>
|
|
1415
|
+
<pattern name="컴포넌트 테스트 (Testing Library)">
|
|
1416
|
+
<example>
|
|
1417
|
+
<![CDATA[
|
|
1418
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
1419
|
+
import userEvent from '@testing-library/user-event';
|
|
1420
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
1421
|
+
import { UserProfile } from './UserProfile';
|
|
1422
|
+
|
|
1423
|
+
// 테스트용 래퍼
|
|
1424
|
+
function createWrapper() {
|
|
1425
|
+
const queryClient = new QueryClient({
|
|
1426
|
+
defaultOptions: {
|
|
1427
|
+
queries: { retry: false },
|
|
1428
|
+
},
|
|
1429
|
+
});
|
|
1430
|
+
return ({ children }: { children: ReactNode }) => (
|
|
1431
|
+
<QueryClientProvider client={queryClient}>
|
|
1432
|
+
{children}
|
|
1433
|
+
</QueryClientProvider>
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
describe('UserProfile', () => {
|
|
1438
|
+
it('로딩 상태를 표시한다', () => {
|
|
1439
|
+
render(<UserProfile userId="1" />, { wrapper: createWrapper() });
|
|
1440
|
+
|
|
1441
|
+
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it('사용자 정보를 표시한다', async () => {
|
|
1445
|
+
// API Mock
|
|
1446
|
+
server.use(
|
|
1447
|
+
rest.get('/api/users/1', (req, res, ctx) => {
|
|
1448
|
+
return res(ctx.json({ id: '1', name: 'John', email: 'john@test.com' }));
|
|
1449
|
+
})
|
|
1450
|
+
);
|
|
1451
|
+
|
|
1452
|
+
render(<UserProfile userId="1" />, { wrapper: createWrapper() });
|
|
1453
|
+
|
|
1454
|
+
await waitFor(() => {
|
|
1455
|
+
expect(screen.getByText('John')).toBeInTheDocument();
|
|
1456
|
+
expect(screen.getByText('john@test.com')).toBeInTheDocument();
|
|
1457
|
+
});
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
it('에러 상태를 표시한다', async () => {
|
|
1461
|
+
server.use(
|
|
1462
|
+
rest.get('/api/users/1', (req, res, ctx) => {
|
|
1463
|
+
return res(ctx.status(500));
|
|
1464
|
+
})
|
|
1465
|
+
);
|
|
1466
|
+
|
|
1467
|
+
render(<UserProfile userId="1" />, { wrapper: createWrapper() });
|
|
1468
|
+
|
|
1469
|
+
await waitFor(() => {
|
|
1470
|
+
expect(screen.getByText(/오류가 발생했습니다/i)).toBeInTheDocument();
|
|
1471
|
+
});
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
it('수정 버튼 클릭 시 모달이 열린다', async () => {
|
|
1475
|
+
const user = userEvent.setup();
|
|
1476
|
+
|
|
1477
|
+
server.use(
|
|
1478
|
+
rest.get('/api/users/1', (req, res, ctx) => {
|
|
1479
|
+
return res(ctx.json({ id: '1', name: 'John', email: 'john@test.com' }));
|
|
1480
|
+
})
|
|
1481
|
+
);
|
|
1482
|
+
|
|
1483
|
+
render(<UserProfile userId="1" />, { wrapper: createWrapper() });
|
|
1484
|
+
|
|
1485
|
+
await waitFor(() => {
|
|
1486
|
+
expect(screen.getByText('John')).toBeInTheDocument();
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
await user.click(screen.getByRole('button', { name: '수정' }));
|
|
1490
|
+
|
|
1491
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
1492
|
+
});
|
|
1493
|
+
});
|
|
1494
|
+
]]>
|
|
1495
|
+
</example>
|
|
1496
|
+
</pattern>
|
|
1497
|
+
|
|
1498
|
+
<pattern name="커스텀 훅 테스트">
|
|
1499
|
+
<example>
|
|
1500
|
+
<![CDATA[
|
|
1501
|
+
import { renderHook, act, waitFor } from '@testing-library/react';
|
|
1502
|
+
import { useCounter } from './useCounter';
|
|
1503
|
+
import { useDebounce } from './useDebounce';
|
|
1504
|
+
|
|
1505
|
+
describe('useCounter', () => {
|
|
1506
|
+
it('초기값을 설정한다', () => {
|
|
1507
|
+
const { result } = renderHook(() => useCounter(10));
|
|
1508
|
+
|
|
1509
|
+
expect(result.current.count).toBe(10);
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
it('increment가 값을 증가시킨다', () => {
|
|
1513
|
+
const { result } = renderHook(() => useCounter(0));
|
|
1514
|
+
|
|
1515
|
+
act(() => {
|
|
1516
|
+
result.current.increment();
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
expect(result.current.count).toBe(1);
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
it('decrement가 값을 감소시킨다', () => {
|
|
1523
|
+
const { result } = renderHook(() => useCounter(10));
|
|
1524
|
+
|
|
1525
|
+
act(() => {
|
|
1526
|
+
result.current.decrement();
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
expect(result.current.count).toBe(9);
|
|
1530
|
+
});
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
describe('useDebounce', () => {
|
|
1534
|
+
beforeEach(() => {
|
|
1535
|
+
vi.useFakeTimers();
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
afterEach(() => {
|
|
1539
|
+
vi.useRealTimers();
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
it('지정된 시간 후에 값을 업데이트한다', async () => {
|
|
1543
|
+
const { result, rerender } = renderHook(
|
|
1544
|
+
({ value, delay }) => useDebounce(value, delay),
|
|
1545
|
+
{ initialProps: { value: 'initial', delay: 500 } }
|
|
1546
|
+
);
|
|
1547
|
+
|
|
1548
|
+
expect(result.current).toBe('initial');
|
|
1549
|
+
|
|
1550
|
+
// 값 변경
|
|
1551
|
+
rerender({ value: 'updated', delay: 500 });
|
|
1552
|
+
|
|
1553
|
+
// 아직 변경 안 됨
|
|
1554
|
+
expect(result.current).toBe('initial');
|
|
1555
|
+
|
|
1556
|
+
// 시간 경과
|
|
1557
|
+
act(() => {
|
|
1558
|
+
vi.advanceTimersByTime(500);
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
// 이제 변경됨
|
|
1562
|
+
expect(result.current).toBe('updated');
|
|
1563
|
+
});
|
|
1564
|
+
});
|
|
1565
|
+
]]>
|
|
1566
|
+
</example>
|
|
1567
|
+
</pattern>
|
|
1568
|
+
</testing-patterns>
|
|
1569
|
+
|
|
1570
|
+
<file-structure>
|
|
1571
|
+
<structure>
|
|
1572
|
+
<![CDATA[
|
|
1573
|
+
src/
|
|
1574
|
+
├── components/ # 재사용 UI 컴포넌트
|
|
1575
|
+
│ ├── ui/ # 기본 UI (Button, Input, Card)
|
|
1576
|
+
│ │ ├── button.tsx
|
|
1577
|
+
│ │ ├── input.tsx
|
|
1578
|
+
│ │ └── index.ts # barrel export
|
|
1579
|
+
│ └── common/ # 공통 컴포넌트 (Header, Footer)
|
|
1580
|
+
│
|
|
1581
|
+
├── features/ # 기능별 모듈 (Feature-Sliced Design)
|
|
1582
|
+
│ ├── auth/
|
|
1583
|
+
│ │ ├── components/ # 기능 전용 컴포넌트
|
|
1584
|
+
│ │ │ ├── LoginForm.tsx
|
|
1585
|
+
│ │ │ └── SignupForm.tsx
|
|
1586
|
+
│ │ ├── hooks/ # 기능 전용 훅
|
|
1587
|
+
│ │ │ └── useAuth.ts
|
|
1588
|
+
│ │ ├── api/ # API 호출
|
|
1589
|
+
│ │ │ └── auth.api.ts
|
|
1590
|
+
│ │ └── types.ts # 타입 정의
|
|
1591
|
+
│ └── users/
|
|
1592
|
+
│ ├── components/
|
|
1593
|
+
│ ├── hooks/
|
|
1594
|
+
│ └── api/
|
|
1595
|
+
│
|
|
1596
|
+
├── hooks/ # 공통 커스텀 훅
|
|
1597
|
+
│ ├── useDebounce.ts
|
|
1598
|
+
│ ├── useLocalStorage.ts
|
|
1599
|
+
│ └── index.ts
|
|
1600
|
+
│
|
|
1601
|
+
├── lib/ # 유틸리티, 설정
|
|
1602
|
+
│ ├── utils.ts
|
|
1603
|
+
│ └── api-client.ts
|
|
1604
|
+
│
|
|
1605
|
+
├── stores/ # 전역 상태 (Zustand)
|
|
1606
|
+
│ ├── useAuthStore.ts
|
|
1607
|
+
│ └── useUIStore.ts
|
|
1608
|
+
│
|
|
1609
|
+
└── types/ # 공통 타입 정의
|
|
1610
|
+
└── index.ts
|
|
1611
|
+
]]>
|
|
1612
|
+
</structure>
|
|
1613
|
+
</file-structure>
|
|
1614
|
+
|
|
1615
|
+
<rules>
|
|
1616
|
+
<category name="컴포넌트">
|
|
1617
|
+
<must>함수형 컴포넌트 사용</must>
|
|
1618
|
+
<must>Props 타입 명시 (interface)</must>
|
|
1619
|
+
<must>단일 책임 원칙 - 한 가지 일만</must>
|
|
1620
|
+
<must>named export 사용 (export function)</must>
|
|
1621
|
+
<must>forwardRef로 ref 전달 지원</must>
|
|
1622
|
+
<must-not>클래스 컴포넌트</must-not>
|
|
1623
|
+
<must-not>default export (컴포넌트)</must-not>
|
|
1624
|
+
<must-not>200줄 넘는 컴포넌트</must-not>
|
|
1625
|
+
</category>
|
|
1626
|
+
<category name="훅">
|
|
1627
|
+
<must>커스텀 훅으로 로직 분리</must>
|
|
1628
|
+
<must>훅 이름은 use로 시작</must>
|
|
1629
|
+
<must>의존성 배열 정확히 관리</must>
|
|
1630
|
+
<must>useCallback으로 자식에 전달하는 함수 안정화</must>
|
|
1631
|
+
<must>함수형 setState로 stale closure 방지</must>
|
|
1632
|
+
<must-not>조건문/반복문 안에서 훅 호출</must-not>
|
|
1633
|
+
<must-not>useEffect 남용 (대부분 필요 없음)</must-not>
|
|
1634
|
+
</category>
|
|
1635
|
+
<category name="상태 관리">
|
|
1636
|
+
<must>서버 상태는 React Query</must>
|
|
1637
|
+
<must>전역 UI 상태는 Zustand</must>
|
|
1638
|
+
<must>로컬 상태는 useState</must>
|
|
1639
|
+
<must>복잡한 로직은 useReducer</must>
|
|
1640
|
+
<must>비싼 초기값은 lazy initialization</must>
|
|
1641
|
+
<must-not>모든 상태를 전역으로</must-not>
|
|
1642
|
+
<must-not>props drilling (3단계 이상)</must-not>
|
|
1643
|
+
</category>
|
|
1644
|
+
<category name="성능 (Vercel Best Practices)">
|
|
1645
|
+
<must>리스트에 고유한 key (index 사용 금지)</must>
|
|
1646
|
+
<must>무거운 계산은 useMemo</must>
|
|
1647
|
+
<must>무거운 컴포넌트는 memo</must>
|
|
1648
|
+
<must>대량 리스트는 가상화 또는 content-visibility</must>
|
|
1649
|
+
<must>barrel import 피하기 (직접 import)</must>
|
|
1650
|
+
<must>무거운 컴포넌트는 dynamic import</must>
|
|
1651
|
+
<must>Promise.all()로 병렬 fetch</must>
|
|
1652
|
+
<must>toSorted()로 배열 불변성 유지</must>
|
|
1653
|
+
<must-not>렌더링마다 새 객체/배열 생성</must-not>
|
|
1654
|
+
<must-not>Sequential await (Waterfall)</must-not>
|
|
1655
|
+
<must-not>RSC 경계에서 불필요한 데이터 직렬화</must-not>
|
|
1656
|
+
</category>
|
|
1657
|
+
</rules>
|
|
1658
|
+
|
|
1659
|
+
<anti-patterns>
|
|
1660
|
+
<anti-pattern name="Props Drilling">
|
|
1661
|
+
<problem>여러 레벨 거쳐 props 전달 (3단계 이상)</problem>
|
|
1662
|
+
<solution>Context 또는 Zustand로 상태 관리</solution>
|
|
1663
|
+
</anti-pattern>
|
|
1664
|
+
<anti-pattern name="useEffect for derived state">
|
|
1665
|
+
<problem>useEffect로 파생 상태 계산</problem>
|
|
1666
|
+
<example type="bad">
|
|
1667
|
+
<![CDATA[
|
|
1668
|
+
// Bad
|
|
1669
|
+
const [items, setItems] = useState([]);
|
|
1670
|
+
const [total, setTotal] = useState(0);
|
|
1671
|
+
|
|
1672
|
+
useEffect(() => {
|
|
1673
|
+
setTotal(items.reduce((sum, i) => sum + i.price, 0));
|
|
1674
|
+
}, [items]);
|
|
1675
|
+
]]>
|
|
1676
|
+
</example>
|
|
1677
|
+
<solution>렌더링 중 계산 또는 useMemo</solution>
|
|
1678
|
+
<example type="good">
|
|
1679
|
+
<![CDATA[
|
|
1680
|
+
// Good
|
|
1681
|
+
const total = useMemo(
|
|
1682
|
+
() => items.reduce((sum, i) => sum + i.price, 0),
|
|
1683
|
+
[items]
|
|
1684
|
+
);
|
|
1685
|
+
]]>
|
|
1686
|
+
</example>
|
|
1687
|
+
</anti-pattern>
|
|
1688
|
+
<anti-pattern name="God Component">
|
|
1689
|
+
<problem>하나의 거대한 컴포넌트 (500줄+)</problem>
|
|
1690
|
+
<solution>작은 단위로 분리, 훅으로 로직 추출</solution>
|
|
1691
|
+
</anti-pattern>
|
|
1692
|
+
<anti-pattern name="Inline Object/Array in JSX">
|
|
1693
|
+
<problem>렌더링마다 새 참조 생성</problem>
|
|
1694
|
+
<example type="bad">
|
|
1695
|
+
<![CDATA[
|
|
1696
|
+
// Bad - 매 렌더링마다 새 객체
|
|
1697
|
+
<Child style={{ color: 'red' }} items={[1, 2, 3]} />
|
|
1698
|
+
]]>
|
|
1699
|
+
</example>
|
|
1700
|
+
<solution>상수로 추출 또는 useMemo</solution>
|
|
1701
|
+
<example type="good">
|
|
1702
|
+
<![CDATA[
|
|
1703
|
+
// Good
|
|
1704
|
+
const style = useMemo(() => ({ color: 'red' }), []);
|
|
1705
|
+
const items = useMemo(() => [1, 2, 3], []);
|
|
1706
|
+
<Child style={style} items={items} />
|
|
1707
|
+
]]>
|
|
1708
|
+
</example>
|
|
1709
|
+
</anti-pattern>
|
|
1710
|
+
</anti-patterns>
|
|
1711
|
+
|
|
1712
|
+
<checklist>
|
|
1713
|
+
<item priority="critical">함수형 컴포넌트 + Props 타입 정의</item>
|
|
1714
|
+
<item priority="critical">커스텀 훅으로 로직 분리</item>
|
|
1715
|
+
<item priority="critical">서버 상태는 React Query 사용</item>
|
|
1716
|
+
<item priority="critical">Error Boundary로 에러 격리</item>
|
|
1717
|
+
<item priority="critical">Waterfall 제거 (Promise.all)</item>
|
|
1718
|
+
<item priority="critical">Barrel import 피하기</item>
|
|
1719
|
+
<item priority="high">의존성 배열 정확히 관리</item>
|
|
1720
|
+
<item priority="high">리스트에 고유 key (index 금지)</item>
|
|
1721
|
+
<item priority="high">폼은 React Hook Form + Zod</item>
|
|
1722
|
+
<item priority="high">함수형 setState 사용</item>
|
|
1723
|
+
<item priority="high">Dynamic import로 무거운 컴포넌트 분리</item>
|
|
1724
|
+
<item priority="medium">대량 리스트 가상화 / content-visibility</item>
|
|
1725
|
+
<item priority="medium">Code Splitting 적용</item>
|
|
1726
|
+
<item priority="medium">불필요한 useEffect 제거</item>
|
|
1727
|
+
<item priority="medium">RSC 경계에서 필요한 데이터만 전달</item>
|
|
1728
|
+
</checklist>
|
|
1729
|
+
</skill>
|